Java内存模型(JMM):happens-before规则与多线程下的可见性问题
大家好,今天我们来深入探讨Java内存模型(JMM)以及它如何影响多线程编程中的可见性问题。理解JMM对于编写正确、高效的并发程序至关重要。
1. 什么是Java内存模型(JMM)?
JMM并非指实际存在的内存结构,而是一套规范,描述了Java程序中各种变量(实例字段、静态字段和构成数组对象的元素)的访问规则,以及在多线程环境下线程如何与主内存交互。简单来说,JMM定义了共享变量的可见性、原子性和有序性。
1.1 主内存与工作内存
JMM规定了所有的变量都存储在主内存中(可以类比为计算机的物理内存)。每当一个线程访问变量时,会将该变量从主内存拷贝一份到自己的工作内存中(可以类比为CPU的缓存)。线程对变量的所有操作(读取、赋值等)都必须在自己的工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间的变量值传递需要通过主内存来完成。
1.2 JMM与硬件内存架构的关系
JMM的抽象模型是为了屏蔽底层不同硬件平台的内存访问差异,让Java程序可以在各种平台上运行。实际上,工作内存可能对应CPU的寄存器、高速缓存等,主内存则对应物理内存。JMM规范允许编译器和处理器对代码进行优化,只要保证程序的最终执行结果与按照JMM模型执行的结果一致即可。
2. 多线程下的可见性问题
由于线程只能操作自己的工作内存,因此当多个线程同时访问同一个共享变量时,就可能出现可见性问题。一个线程修改了共享变量的值,另一个线程可能无法立即看到修改后的值。
2.1 可见性问题的产生
假设有两个线程A和B,共享变量count
的初始值为0:
- 线程A从主内存读取
count
的值到自己的工作内存,然后将count
的值加1,并将更新后的值写回主内存。 - 线程B也从主内存读取
count
的值到自己的工作内存,此时count
的值仍然是0,线程B也会将count
的值加1,并将更新后的值写回主内存。
最终,count
的值应该是2,但实际上可能是1。这就是因为线程A和B在自己的工作内存中操作变量,导致了可见性问题。
2.2 代码示例
public class VisibilityExample {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + count); // 预期20000,实际可能小于20000
}
}
在这个例子中,两个线程同时对count
进行递增操作。由于可见性问题,最终的count
值很可能小于20000。
3. happens-before规则
JMM通过happens-before规则来保证多线程环境下的可见性。happens-before 并非指某个操作一定在另一个操作之前执行,而是指一个操作的结果对于另一个操作是可见的。如果一个操作happens-before另一个操作,那么第一个操作的执行结果对第二个操作可见。
3.1 happens-before规则的定义
JMM定义了以下happens-before规则:
- 程序顺序规则:在一个线程中,按照程序代码的顺序,前面的操作happens-before后面的操作。
- 管程锁定规则:对一个锁的解锁happens-before后续对这个锁的加锁。
- volatile变量规则:对一个volatile变量的写操作happens-before后续对这个volatile变量的读操作。
- 线程启动规则:Thread对象的start()方法happens-before此线程的每一个动作。
- 线程终止规则:线程的所有操作都happens-before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
- 线程中断规则:对线程interrupt()方法的调用happens-before被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)happens-before finalize()方法的开始。
- 传递性:如果操作A happens-before操作B,操作B happens-before操作C,那么操作A happens-before操作C。
3.2 happens-before规则的示例
- 程序顺序规则
int i = 1;
int j = 2;
// i=1 happens-before j=2
- 管程锁定规则
synchronized (lock) {
// A
}
// unlock happens-before lock again
synchronized (lock) {
// B
}
// A happens-before B
- volatile变量规则
volatile boolean flag = false;
// 线程A
flag = true; // 写操作
// 线程B
if (flag) { // 读操作
// ...
}
// 写操作 happens-before 读操作
- 线程启动规则
Thread t = new Thread(() -> {
// A
});
t.start();
// t.start() happens-before A
- 线程终止规则
Thread t = new Thread(() -> {
// A
});
t.start();
t.join();
// A happens-before t.join()
4. 使用volatile关键字解决可见性问题
volatile关键字是解决可见性问题的一种常用方法。当一个变量被声明为volatile时,JMM会保证:
- 可见性:当一个线程修改了volatile变量的值,这个新值对其他线程来说是立即可见的。
- 禁止指令重排序:保证代码的执行顺序与程序代码的顺序一致。
4.1 volatile的原理
当一个线程修改了volatile变量的值时,JMM会立即将该变量的值写回主内存。当另一个线程要读取volatile变量的值时,JMM会强制该线程从主内存中重新读取该变量的值。这样就保证了volatile变量的可见性。
4.2 volatile的使用示例
public class VolatileExample {
private volatile static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + count); // 几乎总是20000
}
}
在这个例子中,count
变量被声明为volatile。因此,两个线程对count
的修改操作对彼此都是可见的,最终的count
值几乎总是20000。
4.3 volatile的局限性
volatile只能保证可见性,不能保证原子性。例如,count++
操作实际上包含三个步骤:
- 读取
count
的值。 - 将
count
的值加1。 - 将更新后的
count
的值写回主内存。
即使count
被声明为volatile,这三个步骤也不是原子的。因此,在多线程环境下,仍然可能出现并发问题。如果需要保证原子性,可以使用synchronized
关键字或java.util.concurrent.atomic
包中的原子类。
4.4 代码示例:原子性问题
public class VolatileAtomicityExample {
private volatile static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + count); // 仍然可能小于20000
}
}
即使count
被声明为volatile,由于count++
操作不是原子的,最终的count
值仍然可能小于20000。
5. 使用synchronized关键字解决可见性和原子性问题
synchronized关键字是Java中用于实现线程同步的一种机制。它可以保证:
- 可见性:当一个线程进入synchronized代码块时,会从主内存中重新读取共享变量的值。当一个线程退出synchronized代码块时,会将修改后的共享变量的值写回主内存。
- 原子性:synchronized代码块中的操作是原子的,不会被其他线程中断。
- 有序性:synchronized代码块内的代码,其执行顺序与程序代码的顺序一致。
5.1 synchronized的原理
synchronized关键字通过锁来实现线程同步。每个Java对象都可以作为一个锁。当一个线程要执行synchronized代码块时,需要先获取锁。如果锁已经被其他线程占用,则该线程会被阻塞,直到获取到锁为止。当线程执行完synchronized代码块后,会释放锁。
5.2 synchronized的使用示例
public class SynchronizedExample {
private static int count = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + count); // 总是20000
}
}
在这个例子中,count++
操作被放在synchronized代码块中。因此,两个线程对count
的修改操作是互斥的,并且对彼此都是可见的,最终的count
值总是20000。
5.3 synchronized的性能
synchronized关键字的性能开销相对较大,因为它会涉及到线程的阻塞和唤醒。在不需要保证原子性的情况下,可以使用volatile关键字来提高性能。
6. 使用原子类解决可见性和原子性问题
java.util.concurrent.atomic
包中提供了一些原子类,例如AtomicInteger
、AtomicLong
、AtomicBoolean
等。这些原子类使用CAS(Compare and Swap)算法来实现原子操作。CAS算法是一种无锁算法,它可以避免线程的阻塞和唤醒,从而提高性能。
6.1 原子类的原理
CAS算法包含三个操作数:
- V:要更新的变量的值。
- E:期望的值。
- N:新值。
CAS算法的执行过程如下:
- 比较V的值是否等于E。
- 如果V的值等于E,则将V的值更新为N。
- 如果V的值不等于E,则说明V的值已经被其他线程修改,CAS操作失败。
原子类通过循环执行CAS操作,直到CAS操作成功为止。
6.2 原子类的使用示例
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerExample {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count.incrementAndGet();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count.incrementAndGet();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + count); // 总是20000
}
}
在这个例子中,count
变量被声明为AtomicInteger
。incrementAndGet()
方法是一个原子操作,它可以保证count
的递增操作是原子的。因此,最终的count
值总是20000。
6.3 原子类的优点
原子类具有以下优点:
- 高性能:原子类使用CAS算法,避免了线程的阻塞和唤醒,从而提高了性能。
- 易于使用:原子类的API简单易懂,易于使用。
7. 总结:可见性、原子性与JMM在多线程编程中的重要性
理解Java内存模型对于编写正确的并发程序至关重要。happens-before规则定义了操作之间的可见性关系。volatile关键字可以保证变量的可见性,但不能保证原子性。synchronized关键字可以保证变量的可见性和原子性,但性能开销较大。原子类使用CAS算法,可以保证变量的原子性,并且具有较高的性能。选择合适的同步机制是编写高效并发程序的关键。
希望今天的讲解对大家有所帮助!