Java内存模型中的同步与互斥
Java内存模型基础
在深入探讨Java内存模型(JMM)中的同步与互斥之前,我们先来了解一下JMM的基础概念。Java内存模型是一种抽象的概念,它定义了Java程序中多线程之间如何访问共享变量以及如何同步这些访问的规则。
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存保存了该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
例如,假设有两个线程 ThreadA
和 ThreadB
,它们都要访问共享变量 x
:
public class SharedVariableExample {
private static int x = 0;
public static void main(String[] args) {
Thread threadA = new Thread(() -> {
// 线程A从主内存读取x到工作内存
int localX = x;
// 对工作内存中的localX进行操作
localX = localX + 1;
// 将工作内存中的localX写回主内存
x = localX;
});
Thread threadB = new Thread(() -> {
// 线程B从主内存读取x到工作内存
int localX = x;
// 对工作内存中的localX进行操作
localX = localX * 2;
// 将工作内存中的localX写回主内存
x = localX;
});
threadA.start();
threadB.start();
try {
threadA.join();
threadB.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终的x值: " + x);
}
}
在这个例子中,由于 ThreadA
和 ThreadB
对 x
的操作是在各自的工作内存中进行,然后再写回主内存,这就可能导致竞态条件(Race Condition)。如果没有合适的同步机制,最终 x
的值可能不是我们预期的结果。
同步机制之synchronized关键字
原理
synchronized
关键字是Java提供的一种最基本的同步手段。它可以修饰方法或者代码块,用来保证在同一时刻,只有一个线程能够执行被 synchronized
修饰的代码。
当一个线程访问一个被 synchronized
修饰的方法或者代码块时,它会首先尝试获取对象的锁(Monitor)。如果锁可用,线程获取锁并执行代码。当代码执行完毕或者抛出异常时,线程释放锁,其他等待该锁的线程可以竞争获取锁。
修饰实例方法
当 synchronized
修饰实例方法时,锁是当前对象实例。
public class SynchronizedInstanceMethodExample {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) {
SynchronizedInstanceMethodExample example = new SynchronizedInstanceMethodExample();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终的count值: " + example.getCount());
}
}
在这个例子中,increment
方法被 synchronized
修饰,因此每次只有一个线程能够执行 increment
方法,从而避免了竞态条件,最终 count
的值会是2000。
修饰静态方法
当 synchronized
修饰静态方法时,锁是当前类的Class对象。因为静态方法属于类,而不是类的实例,所以锁针对的是整个类。
public class SynchronizedStaticMethodExample {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static int getCount() {
return count;
}
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
SynchronizedStaticMethodExample.increment();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
SynchronizedStaticMethodExample.increment();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终的count值: " + SynchronizedStaticMethodExample.getCount());
}
}
在这个例子中,无论有多少个类的实例,increment
方法的同步都是基于类的Class对象锁,保证了多线程环境下 count
操作的原子性。
修饰代码块
synchronized
还可以修饰代码块,这种方式更加灵活,可以指定不同的锁对象。
public class SynchronizedBlockExample {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
public static void main(String[] args) {
SynchronizedBlockExample example = new SynchronizedBlockExample();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终的count值: " + example.getCount());
}
}
在这个例子中,我们创建了一个 lock
对象,increment
方法中的 synchronized
代码块使用 lock
作为锁。这样,只有获取到 lock
锁的线程才能执行代码块中的 count++
操作,从而保证了线程安全。
互斥与同步的关系
互斥是同步的一种特殊情况。互斥强调的是对共享资源的排他性访问,即同一时刻只有一个线程能够访问共享资源。而同步则更广泛,它不仅包括互斥,还包括线程之间的协作,例如线程之间的信号传递等。
在Java中,synchronized
关键字实现了互斥,同时也通过锁机制提供了一定程度的同步。例如,一个线程在获取锁并执行完 synchronized
代码块后,会将工作内存中的变量刷新回主内存,其他线程获取锁后会从主内存重新读取变量,这就保证了不同线程之间对共享变量的同步。
同步机制之volatile关键字
原理
volatile
关键字与 synchronized
不同,它主要用于保证变量的可见性。当一个变量被声明为 volatile
时,线程对该变量的修改会立即刷新到主内存,并且其他线程在读取该变量时会直接从主内存读取,而不是从自己的工作内存读取旧值。
示例
public class VolatileExample {
private static volatile boolean flag = false;
public static void main(String[] args) {
Thread writerThread = new Thread(() -> {
flag = true;
System.out.println("Writer thread set flag to true");
});
Thread readerThread = new Thread(() -> {
while (!flag) {
// 等待flag变为true
}
System.out.println("Reader thread saw flag is true");
});
readerThread.start();
writerThread.start();
try {
writerThread.join();
readerThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在这个例子中,如果 flag
没有被声明为 volatile
,readerThread
可能会一直处于循环中,因为它可能一直在读取自己工作内存中的旧的 flag
值。而将 flag
声明为 volatile
后,writerThread
修改 flag
后会立即刷新到主内存,readerThread
会从主内存读取到最新的 flag
值,从而退出循环。
需要注意的是,volatile
并不能保证原子性。例如,对于 volatile int num = 0; num++;
这样的操作,由于 num++
不是原子操作(它包含读取、加一、写回三个步骤),在多线程环境下仍然可能出现竞态条件。
同步机制之ReentrantLock
原理
ReentrantLock
是Java 5.0引入的一种可重入的互斥锁,它提供了比 synchronized
更灵活和强大的功能。ReentrantLock
实现了 Lock
接口,它的核心原理是基于AQS(AbstractQueuedSynchronizer)框架。
AQS是一个用于构建锁和同步器的框架,它通过一个FIFO队列来管理等待获取锁的线程。当一个线程尝试获取 ReentrantLock
时,如果锁可用,线程获取锁并将锁的持有计数加一;如果锁不可用,线程会被放入AQS队列中等待。当持有锁的线程释放锁时,它会将锁的持有计数减一,当计数为0时,锁被完全释放,AQS队列中的一个等待线程会被唤醒并尝试获取锁。
使用示例
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private static int count = 0;
private static ReentrantLock lock = new ReentrantLock();
public static void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public static int getCount() {
return count;
}
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
increment();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
increment();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终的count值: " + getCount());
}
}
在这个例子中,我们使用 ReentrantLock
来保护 count
的操作。lock.lock()
方法用于获取锁,lock.unlock()
方法用于释放锁。需要注意的是,unlock()
方法通常放在 finally
块中,以确保无论代码块中是否抛出异常,锁都会被正确释放。
与synchronized的比较
- 灵活性:
ReentrantLock
比synchronized
更灵活。例如,ReentrantLock
支持公平锁和非公平锁(默认是非公平锁),而synchronized
是非公平的。公平锁会按照线程等待的顺序来分配锁,而非公平锁则允许线程在锁可用时直接竞争锁,这样可能会导致某些线程长时间等待。 - 功能:
ReentrantLock
提供了更多的功能,如tryLock()
方法可以尝试获取锁,如果获取不到锁不会一直等待,而是立即返回false
;lockInterruptibly()
方法允许在等待锁的过程中响应中断。 - 性能:在竞争不激烈的情况下,
synchronized
的性能与ReentrantLock
相近。但在竞争激烈的情况下,ReentrantLock
的性能可能会更好,因为它可以使用非公平锁来减少线程切换的开销。
同步机制之Condition
原理
Condition
是与 Lock
配合使用的一个接口,它提供了更灵活的线程间协作方式。Condition
可以看作是传统 Object
类的 wait()
、notify()
和 notifyAll()
方法的更强大替代品。
一个 Lock
对象可以创建多个 Condition
对象,每个 Condition
对象都有自己的等待队列。线程可以调用 Condition
的 await()
方法进入等待状态,同时释放持有的锁。当其他线程调用该 Condition
的 signal()
或 signalAll()
方法时,等待队列中的一个或所有线程会被唤醒,并尝试重新获取锁。
使用示例
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionExample {
private static Lock lock = new ReentrantLock();
private static Condition condition = lock.newCondition();
private static boolean ready = false;
public static void main(String[] args) {
Thread producerThread = new Thread(() -> {
lock.lock();
try {
// 模拟生产过程
Thread.sleep(1000);
ready = true;
System.out.println("Producer: Production completed");
condition.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
Thread consumerThread = new Thread(() -> {
lock.lock();
try {
while (!ready) {
condition.await();
}
System.out.println("Consumer: Consuming data");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
consumerThread.start();
producerThread.start();
try {
producerThread.join();
consumerThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在这个例子中,consumerThread
在 ready
为 false
时调用 condition.await()
进入等待状态并释放锁。producerThread
在生产完成后调用 condition.signalAll()
唤醒 consumerThread
,consumerThread
被唤醒后重新获取锁并继续执行。
死锁问题
死锁的定义与成因
死锁是指两个或多个线程相互等待对方释放资源,导致所有线程都无法继续执行的一种状态。死锁的形成通常需要满足以下四个条件:
- 互斥条件:资源不能被共享,只能被一个线程持有。
- 占有并等待条件:线程已经持有了一些资源,但又请求其他资源,并且在等待其他资源的过程中不会释放已持有的资源。
- 不可剥夺条件:线程持有的资源不能被其他线程强行剥夺,只能由持有资源的线程自己释放。
- 循环等待条件:存在一个线程环,其中每个线程都在等待下一个线程释放资源。
死锁示例
public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1: Waiting for lock 2");
synchronized (lock2) {
System.out.println("Thread 1: Holding lock 1 and lock 2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2: Waiting for lock 1");
synchronized (lock1) {
System.out.println("Thread 2: Holding lock 1 and lock 2");
}
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在这个例子中,thread1
先获取 lock1
,然后尝试获取 lock2
;thread2
先获取 lock2
,然后尝试获取 lock1
。由于两个线程都不会主动释放已持有的锁,就会导致死锁。
死锁的预防与检测
- 预防死锁:
- 破坏互斥条件:尽量使用可共享的资源,避免独占资源。但有些资源本身就是独占的,如打印机,这种方法不太容易实现。
- 破坏占有并等待条件:可以要求线程在启动时一次性获取所有需要的资源,或者在请求新资源时先释放已持有的资源。
- 破坏不可剥夺条件:允许线程在一定条件下剥夺其他线程持有的资源。例如,当一个线程等待资源超时,可以强制它释放已持有的资源。
- 破坏循环等待条件:可以对资源进行排序,线程按照一定的顺序获取资源,避免形成循环等待。
- 检测死锁:可以使用工具如
jstack
来检测Java程序中的死锁。jstack
命令可以打印出Java进程中所有线程的栈信息,通过分析栈信息可以发现死锁的线程。另外,一些高级的监控工具如VisualVM也可以帮助检测死锁。
并发容器与同步
ConcurrentHashMap
ConcurrentHashMap
是Java提供的线程安全的哈希表。它与 Hashtable
和 synchronizedMap
不同,ConcurrentHashMap
采用了分段锁(Segment)的机制(在Java 8之后采用了CAS和synchronized相结合的方式),允许多个线程同时对不同的段进行操作,从而提高了并发性能。
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapExample {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put("key" + i, i);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 1000; i < 2000; i++) {
map.put("key" + i, i);
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Map size: " + map.size());
}
}
在这个例子中,ConcurrentHashMap
可以支持多个线程同时进行插入操作,而不需要对整个哈希表进行加锁,提高了并发性能。
CopyOnWriteArrayList
CopyOnWriteArrayList
是一个线程安全的 List
实现。它的核心思想是当对列表进行修改(如添加、删除元素)时,会创建一个原列表的副本,在副本上进行修改,然后将原列表的引用指向新的副本。而读操作则直接在原列表上进行,不需要加锁。
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnWriteArrayListExample {
public static void main(String[] args) {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
Thread writerThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
list.add("element" + i);
}
});
Thread readerThread = new Thread(() -> {
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
writerThread.start();
readerThread.start();
try {
writerThread.join();
readerThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在这个例子中,readerThread
可以在 writerThread
修改列表的同时进行读取操作,因为读操作是基于原列表的,而写操作创建新副本不会影响读操作。但需要注意的是,CopyOnWriteArrayList
适合读多写少的场景,因为写操作的开销较大。
通过对Java内存模型中的同步与互斥机制的深入探讨,我们了解了 synchronized
、volatile
、ReentrantLock
、Condition
等同步工具的原理和使用方法,以及死锁的成因、预防和检测,还有并发容器在同步方面的特点。在实际的多线程编程中,我们需要根据具体的需求和场景选择合适的同步机制,以确保程序的正确性和高性能。