各位靓仔靓女,晚上好!我是你们今晚的并发编程向导,今天咱们要聊聊Java内存模型(JMM)中的“Happens-Before”规则。这玩意儿听起来挺高大上,但其实就是告诉你,在并发编程中,哪些操作“一定”发生在哪些操作之前。搞清楚它,你才能写出靠谱的多线程代码,避免那些神出鬼没的Bug。
一、开场白:并发编程的灵魂拷问
想象一下,你和你的小伙伴同时往一个账户里存钱。你存了100,他存了200。理想情况下,账户最终应该有你们的总和,也就是300。但如果你们的代码没写好,并发执行的时候,可能出现各种奇葩情况:
- 账户最后只有100?
- 账户最后竟然是0?
- 偶尔是300,偶尔不是?
这些都是并发编程中常见的“数据竞争”问题。问题的根源在于:
- 可见性: 你的小伙伴存了钱,你真的能立即看到账户的变化吗?
- 有序性: 你的代码是按照你写的顺序执行的吗?编译器和CPU可能会优化你的代码,导致执行顺序和你想象的不一样。
JMM就是用来解决这些问题的。它定义了一套规则,告诉编译器和CPU,哪些事情必须保证可见性,哪些事情必须保证有序性。而“Happens-Before”规则,就是这套规则的核心。
二、什么是Happens-Before?(别被名字吓到)
“Happens-Before”并不是说A操作真的在时间上发生在B操作之前,而是一种偏序关系,它表达的是一种可见性和有序性的保证。如果A happens-before B,那么:
- A操作的结果对B操作可见(可见性)。
- A操作的执行顺序在B操作之前(有序性)。
你可以把Happens-Before理解为一种承诺:JMM承诺,如果A happens-before B,那么A的执行结果一定会被B看到,而且A一定在B之前执行(从B的角度来看)。
三、Happens-Before 规则,一个都不能少
JMM定义了一系列Happens-Before规则,这些规则就像并发编程的法律,你必须遵守。下面我们来一一解读:
-
程序顺序规则(Program Order Rule): 在一个线程内,按照程序代码的顺序,书写在前面的操作 happens-before 书写在后面的操作。
- 人话: 你代码怎么写的,就怎么执行。
int a = 1; // 操作A int b = 2; // 操作B System.out.println(a + b); // 操作C
在这个例子中,A happens-before B,B happens-before C。所以,a一定先赋值,b一定后赋值,最后才能计算a+b。
-
管程锁定规则(Monitor Lock Rule): 对一个锁的解锁 happens-before 后面对同一个锁的加锁。
- 人话: 你解了锁,别人才能拿到锁,才能看到你解锁之前的操作。
private static int x = 0; private static final Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { synchronized (lock) { // 加锁 x = 10; // 操作A } // 解锁 }); Thread thread2 = new Thread(() -> { synchronized (lock) { // 加锁 System.out.println(x); // 操作B } }); thread1.start(); thread1.join(); // 等待thread1执行完毕 thread2.start(); }
在这个例子中,thread1解锁 happens-before thread2加锁。所以,thread2一定能看到thread1对x的修改。如果没有这个规则,thread2可能读到x的旧值。
-
volatile 变量规则(Volatile Variable Rule): 对一个 volatile 变量的写操作 happens-before 后面对这个 volatile 变量的读操作。
- 人话: 你写了volatile变量,别人就能立即看到。
private volatile static int x = 0; public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { x = 10; // 操作A (volatile写) }); Thread thread2 = new Thread(() -> { System.out.println(x); // 操作B (volatile读) }); thread1.start(); Thread.sleep(100); // 确保thread1先执行 thread2.start(); }
在这个例子中,thread1对x的volatile写 happens-before thread2对x的volatile读。所以,thread2一定能看到thread1对x的修改。volatile保证了可见性。
-
线程启动规则(Thread Start Rule): Thread 对象的 start() 方法 happens-before 此线程的每一个动作。
- 人话: 线程启动之后,才能执行里面的代码。
private static int x = 0; public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { x = 10; // 操作A }); thread.start(); // 操作B thread.join(); System.out.println(x); }
在这个例子中,thread.start() happens-before 线程中x=10的操作。所以,x一定会被赋值。
-
线程终止规则(Thread Termination Rule): 线程中的所有操作 happens-before 此线程的终止(Thread.join()或者Thread.isAlive()的返回值)。
- 人话: 线程结束之后,才能知道它干了什么。
private static int x = 0; public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { x = 10; // 操作A }); thread.start(); thread.join(); // 操作B System.out.println(x); }
在这个例子中,线程中x=10的操作 happens-before thread.join()。所以,主线程一定能看到线程对x的修改。
-
线程中断规则(Thread Interruption Rule): 对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生(通过 isInterrupted() 方法)。
- 人话: 你中断了线程,它才知道自己被中断了。
public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // 执行一些任务 System.out.println("Running..."); } System.out.println("Interrupted!"); }); thread.start(); Thread.sleep(100); thread.interrupt(); // 操作A thread.join(); }
在这个例子中,thread.interrupt() happens-before 线程中isInterrupted()的判断。所以,线程最终会退出循环。
-
对象终结规则(Finalizer Rule): 一个对象的初始化完成(构造函数执行结束) happens-before finalize() 方法的开始。
- 人话: 对象构造完成,才能被垃圾回收。这个规则比较少用,了解即可。
-
传递性(Transitivity): 如果 A happens-before B,B happens-before C,那么 A happens-before C。
- 人话: Happens-Before关系可以传递。
这个规则非常重要,它可以把多个Happens-Before关系串联起来,形成更复杂的可见性和有序性保证。
四、Happens-Before规则的应用:解决并发问题
掌握了Happens-Before规则,我们就可以利用它来解决并发编程中的各种问题。
案例1:Double-Checked Locking(DCL)单例模式
DCL单例模式是一种常用的单例模式实现方式,但如果实现不当,可能会出现线程安全问题。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 问题所在
}
}
}
return instance;
}
}
问题在于: instance = new Singleton();
这行代码并不是一个原子操作,它实际上包含了三个步骤:
- 分配内存空间。
- 初始化 Singleton 对象。
- 将 instance 指向分配的内存地址。
由于指令重排序的存在,步骤2和步骤3的顺序可能被打乱。如果线程A执行了步骤1和步骤3,但还没有执行步骤2,此时线程B判断instance不为null,直接返回instance,但此时instance指向的对象还没有被初始化,线程B就会拿到一个未初始化的对象。
如何解决?
使用 volatile 关键字:
public class Singleton {
private volatile static Singleton instance; // 使用volatile
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile 关键字可以禁止指令重排序,保证步骤2一定在步骤3之前执行。根据volatile变量规则,对volatile变量的写操作 happens-before 后面对这个 volatile 变量的读操作。这样就保证了线程B一定能拿到一个初始化完成的对象。
案例2:线程安全的计数器
实现一个线程安全的计数器,可以使用锁或者AtomicInteger。
使用锁:
public class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
在这个例子中,我们使用了锁来保证count的原子性操作。根据管程锁定规则,对一个锁的解锁 happens-before 后面对同一个锁的加锁。所以,每次increment之后,其他线程都能看到最新的count值。
使用AtomicInteger:
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
AtomicInteger内部使用了CAS(Compare and Swap)操作,CAS操作是一种原子操作,可以保证count的原子性操作。AtomicInteger的get操作可以保证读取到最新的count值,因为它内部使用了volatile关键字。
五、总结:Happens-Before,并发编程的指南针
Happens-Before规则是JMM的核心,它为我们编写线程安全的代码提供了重要的保证。理解并掌握这些规则,可以帮助我们避免很多并发编程中的陷阱,写出更健壮、更可靠的多线程程序。
Happens-Before 规则 | 说明 |
---|---|
程序顺序规则 | 在一个线程内,按照程序代码的顺序,书写在前面的操作 happens-before 书写在后面的操作。 |
管程锁定规则 | 对一个锁的解锁 happens-before 后面对同一个锁的加锁。 |
volatile 变量规则 | 对一个 volatile 变量的写操作 happens-before 后面对这个 volatile 变量的读操作。 |
线程启动规则 | Thread 对象的 start() 方法 happens-before 此线程的每一个动作。 |
线程终止规则 | 线程中的所有操作 happens-before 此线程的终止。 |
线程中断规则 | 对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。 |
对象终结规则 | 一个对象的初始化完成 happens-before finalize() 方法的开始。 |
传递性 | 如果 A happens-before B,B happens-before C,那么 A happens-before C。 |
最后,记住: 并发编程是一门复杂的艺术,需要不断学习和实践。希望今天的讲座能帮助你更好地理解Java内存模型和Happens-Before规则,让你在并发编程的道路上越走越远!感谢大家的聆听!下次再见!