前言
并发编程是Java进阶的必经之路,也是面试中的重中之重。许多开发者对`volatile`、`synchronized`等关键字仅停留在"能保证可见性"的浅显理解,对于其底层的内存模型和指令重排序规则却知之甚少。
本文将基于Java内存模型(JMM),从底层硬件架构讲起,深入探讨指令重排序、内存屏障、`happens-before`原则,并结合单例模式(DCL)的终极写法,带你领略并发编程的"至高境界"。
1. Java内存模型(JMM)与三大特性
1.1 为什么需要内存模型?
由于CPU的计算速度远快于内存的读写速度,现代计算机都引入了高速缓存(Cache) 和流水线技术。这导致了两个核心问题:
-
可见性问题:一个线程对共享变量的修改,另一个线程无法立即看到。
-
有序性问题:为了提升性能,编译器和处理器会对指令进行重排序。
Java内存模型(JMM)是一套规范,它屏蔽了不同硬件和操作系统的差异,确保了Java程序在各种平台下都能有一致的并发表现。JMM的核心目标是保证程序的原子性、可见性和有序性。
1.2 指令重排序与可见性危机
指令重排序在单线程下能保证执行结果不变,但在多线程下可能导致灾难性后果。
经典示例:
两个线程并发执行以下代码,可能出现 `x = y = 0` 的结果。
java
```java
// 共享变量
int a = 0, b = 0;
int x = 0, y = 0;
// 线程A执行
a = 1;
x = b;
// 线程B执行
b = 2;
y = a;
```
| 可能的执行顺序 | 结果 (x, y) |
| :--- | :--- |
| A1->A2->B1->B2 | (0, 1) |
| B1->B2->A1->A2 | (2, 0) |
| A1->B1->A2->B2 | (2, 1) |
| **A1->B1->B2->A2(重排序)** | **(0, 0)** |
分析:
线程A的代码`a=1`和`x=b`在处理器执行时可能被重排序,先执行`x=b`(此时b=0)。线程B同理,先执行`y=a`(此时a=0)。最终导致`x`和`y`都为0,完全违背了程序员的直觉。
结论:重排序提升了性能,但破坏了多线程程序的正确性。我们需要一种机制来禁止特定场景下的指令重排序。
2. 破解之道:内存屏障与Happens-Before
2.1 内存屏障(Memory Barrier)
内存屏障是一种CPU指令,它就像一个栅栏,告诉编译器和CPU:屏障前后的指令禁止重排序。
JMM将内存屏障分为四类:
读读屏障 (LoadLoad):禁止Load1和Load2重排序。
读写屏障 (LoadStore):禁止Load1和Store2重排序。
写写屏障 (StoreStore):禁止Store1和Store2重排序。
写读屏障 (StoreLoad):禁止Store1和Load2重排序。这是一个"全能型"屏障,开销最大,但能实现其他所有屏障的效果。
2.2 Happens-Before原则
`happens-before`是JMM的核心概念。如果两个操作满足`happens-before`关系,那么第一个操作的执行结果对第二个操作一定是可见的,且第一个操作的执行顺序在第二个操作之前。
关键规则:
-
程序次序规则:一个线程内,书写在前的代码`happens-before`于书写在后的代码。
-
Volatile变量规则:对一个`volatile`变量的写操作,`happens-before`于后续对这个`volatile`变量的读操作。
-
锁规则:对一个锁的解锁(`unlock`)`happens-before`于后续对这个锁的加锁(`lock`)。
-
传递性:如果A `happens-before` B,且B `happens-before` C,那么A `happens-before` C。
> 面试点:`happens-before`并不是指令执行的先后顺序,而是结果的可见性保证。
3. volatile关键字深度解析
volatile是轻量级的同步机制,它保证了两件事:
-
可见性 :对一个
volatile变量的写,会立即刷新到主内存;读操作会从主内存读取,保证读到最新值。 -
有序性:通过禁止指令重排序来实现。
3.1 禁止重排序规则
volatile通过插入内存屏障来限制重排序,其核心规则是:
- 在每个
volatile写操作前插入StoreStore屏障。 - 在每个
volatile写操作后插入StoreLoad屏障。 - 在每个
volatile读操作后插入LoadLoad和LoadStore屏障。
示例:修复可见性问题
java
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // 普通写
flag = true; // volatile写
// 由于volatile写,a=1 不会被重排序到 flag=true 之后
}
public void reader() {
if (flag) { // volatile读
int i = a; // 此时 a 一定为 1
System.out.println(i);
}
}
}
3.2 volatile与synchronized的区别
| 特性 | volatile |
synchronized |
|---|---|---|
| 原子性 | 不保证(如count++非原子) |
保证,被修饰的代码块是原子的 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证(禁止重排序) | 保证(同一时刻单线程执行) |
| 阻塞 | 不阻塞,轻量级 | 阻塞,重量级 |
| 适用场景 | 单个变量的读写,状态标记 | 复合操作(读-改-写),代码块互斥 |
一句话总结 :
volatile解决的是可见性和有序性问题,synchronized解决的是原子性问题。
4. 单例模式(DCL)的终极写法:并发编程的"至高境界"
单例模式与双重检查锁(DCL)实现
4.1 为什么需要volatile?
以下是一个错误的DCL实现:
java
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(); 这一行代码在JVM中并非原子操作,它大致分为三步:
- 分配内存 :为
Singleton对象分配一块内存空间。 - 初始化对象:调用构造函数,对对象进行初始化。
- 建立关联 :将
instance引用指向这块内存地址。
步骤2和3在指令层面可能发生重排序!执行顺序可能变为 1 -> 3 -> 2。
引发错误:
- 线程A执行到
instance = new Singleton();,发生了重排序,先执行了步骤3(此时内存空间还未初始化,instance不为null,但对象内部都是默认值)。 - 此时线程B调用
getInstance(),在第一次检查时发现instance != null,直接返回。 - 线程B拿着一个尚未初始化完成的对象去操作,程序必然崩溃。
4.2 正确的DCL终极写法
java
public class Singleton {
// 关键点1:使用 volatile 禁止指令重排序
private static volatile Singleton instance;
// 关键点2:构造方法私有,防止外部 new
private Singleton() {}
public static Singleton getInstance() {
// 第一次检查:避免不必要的锁竞争
if (instance == null) {
// 关键点3:同步代码块,而不是同步方法,粒度更细,性能更好
synchronized (Singleton.class) {
// 第二次检查:防止在等待锁的过程中,其他线程已经创建了实例
if (instance == null) {
// volatile 保证了这一步的初始化操作不会被重排序
instance = new Singleton();
}
}
}
return instance;
}
}
逐行解释(面试必考)
-
private static volatile Singleton instance;:private:外部无法直接访问。static:类级别共享。volatile:核心 。禁止new Singleton()时的指令重排序,保证对象初始化完成后再将引用赋值给instance。
-
private Singleton() {}:- 构造方法私有,确保无法通过
new生成新实例,只能通过getInstance()获取。
- 构造方法私有,确保无法通过
-
第一次
if (instance == null):- 性能优化。如果实例已存在,直接返回,无需进入同步块。
-
synchronized (Singleton.class):- 加锁粒度是代码块,而非整个方法。对象创建完成后,后续的并发读操作将不再需要排队,提升性能。
-
第二次
if (instance == null):- 关键防护。考虑线程A和B同时通过了第一次检查,A进入同步块并创建了对象,释放锁后,B获得锁,必须再次检查,否则B会再创建一个对象。
5. 锁的底层:AQS与CAS
5.1 AQS(AbstractQueuedSynchronizer)
`ReentrantLock`、`CountDownLatch`等同步工具的底层都是AQS。它提供了一个FIFO队列(CLH队列)来管理阻塞的线程,并内置了一个`volatile int state`变量来表示同步状态(例如锁的重入次数)。
5.2 CAS(Compare And Swap)
CAS是一条CPU原子指令,它包含三个操作数:内存地址V,期望值A,新值B。如果V上的值等于A,则将其更新为B,否则什么都不做。整个过程是原子的。
CAS + 自旋 = 乐观锁的基础:
java
```java
// 模拟一个非阻塞的原子自增操作
public void increment() {
int oldValue;
int newValue;
do {
oldValue = get(); // 获取当前值
newValue = oldValue + 1;
} while (!compareAndSwap(oldValue, newValue)); // 自旋,直到设置成功
}
```
5.3 公平锁 vs 非公平锁
公平锁:线程严格按照申请锁的顺序获取锁。实现复杂,会频繁进行线程上下文切换,性能较低。
非公平锁:新来的线程有机会直接抢占锁。减少了上下文切换,整体吞吐量更高,是主流实现(如`ReentrantLock`默认就是非公平锁)。缺点是可能造成"线程饥饿"。
6. 总结
-
JMM是基石:理解了JMM、重排序、内存屏障和`happens-before`,才能真正看透并发问题。
-
volatile轻量级:适合修饰"状态标记"和一个不依赖当前值的"读写操作"。
-
synchronized`重量级:保证了原子性,适合复合操作,但性能相对较低。现代JVM对其优化后(锁升级),性能已大幅提升。
-
DCL单例是试金石:考察了`volatile`、锁、指令重排序、原子性等多个并发核心概念,手写并解释其原理是高级工程师的必备技能。
-
AQS与CAS是骨架:J.U.C包下几乎所有高级并发工具都基于AQS和CAS构建,理解它们就掌握了并发编程的半壁江山。
希望这篇博客能帮助你彻底拿下Java并发编程的核心知识点!