为什么并发编程如此困难?
初学者往往认为多线程编程只是「多开几个线程、加个锁」这么简单。但实际工程中,并发 Bug 往往是最难复现、最难定位的一类问题——它们可能在测试环境中从不出现,却在生产环境高并发下频繁崩溃。
根本原因在于:现代计算机硬件的实际执行模型与程序员的心智模型之间存在巨大的鸿沟。 CPU 有多级缓存,编译器会重排序指令,内存系统并非严格按照程序书写顺序操作——这些优化在单线程下安全, 但在多线程下可能产生难以预料的结果。
Java 通过 Java 内存模型(JMM, Java Memory Model)规范化了多线程程序的行为, 为开发者提供了一套明确的规则:什么情况下可以安全读写共享变量,什么情况下必须同步。
Java 内存模型(JMM)
JMM 是什么?
JMM 并不是 JVM 的物理内存布局(那是 JVM 内存结构,包含堆、栈、方法区等),而是一套 抽象的内存可见性规范,定义了:
- 线程如何与主内存(Main Memory)交互
- 线程的工作内存(Working Memory / CPU Cache)与主内存之间如何同步
- 哪些操作是原子的
- 什么时候对一个变量的写操作对另一个线程可见
很多初学者把两者混淆。JVM 内存结构是 JVM 的物理划分(堆、栈、方法区、PC 寄存器等), 是运行时数据区的分配方式。JMM 是并发编程中的抽象规范,描述线程间共享变量的可见性规则。 两者描述的是不同层面的问题。
主内存与工作内存模型
JMM 将内存抽象为两个层次:
JMM 的八种操作
JMM 定义了 8 种原子操作,描述线程如何与内存交互:
并发三大核心问题
1. 原子性(Atomicity)
原子性指一个或多个操作作为一个整体执行,执行过程不能被中断——要么全部完成,要么完全不执行。
最典型的例子是 i++ 操作。看起来是一条语句,实际上分为三步:
- 从内存读取
i的当前值(read+load) - 将值加 1(
use+assign) - 将新值写回内存(
store+write)
如果两个线程同时执行 i++,线程 A 读取到 i=5,尚未写回时线程 B 也读取到
i=5,两者各自加 1 后都写入 6——结果是 6 而非预期的 7。
这就是丢失更新(Lost Update)问题。
// 非原子操作示例:i++ 在多线程下不安全
public class Counter {
private int count = 0;
public void increment() {
count++; // 非原子!read → add → write 三步可被中断
}
public int get() { return count; }
}
// 10个线程各执行1000次 increment,最终结果通常 < 10000
Java 中,对基本类型(byte、short、int、char、
float、boolean)的读写是原子的,但 long 和 double
在 32 位 JVM 上不保证原子性(分两次 32 位操作)。在 64 位 JVM 上通常也是原子的,但 JMM 规范未强制要求。
2. 可见性(Visibility)
可见性指一个线程对共享变量的修改,另一个线程能够立即看到最新值。
由于 CPU 缓存的存在,线程 A 修改的变量可能只更新了 A 的 CPU 缓存,尚未刷新到主内存; 线程 B 从自己的缓存读取,可能拿到旧值——这就是缓存不一致(Cache Incoherence)问题。
// 可见性问题示例
public class VisibilityDemo {
private static boolean running = true; // 没有 volatile!
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (running) { // 可能永远循环:线程从缓存读到 true
// do work
}
System.out.println("Thread stopped");
}).start();
Thread.sleep(100);
running = false; // 主线程修改,但工作线程可能看不到!
}
}
3. 有序性(Ordering)
有序性指程序按照代码书写的顺序执行。但实际上,编译器和 CPU 会进行指令重排序 (Instruction Reordering)来优化执行效率,只要不影响单线程执行结果就允许重排。
问题在于,在多线程下,这种重排可能会改变程序的语义:
// 指令重排序导致的问题(经典双检锁问题)
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 非原子!可被重排序
// 正常顺序:1.分配内存 2.初始化对象 3.赋值给 instance
// 重排顺序:1.分配内存 2.赋值给 instance 3.初始化对象
// 若另一线程在步骤 2 后读取 instance,拿到未初始化的对象!
}
}
}
return instance;
}
}
将 instance 声明为 volatile,禁止重排序:
private static volatile Singleton instance;
happens-before 规则
JMM 通过 happens-before(先行发生)关系来描述操作之间的内存可见性保证。 如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见,且 A 的执行顺序在 B 之前。
happens-before 不代表物理时间上 A 先执行,而是一种内存可见性的保证协议。 JMM 规定了 8 条天然的 happens-before 规则:
happens-before 实战理解
// happens-before 关系示例
class HappensBeforeDemo {
int x = 0;
volatile boolean flag = false;
// 线程 A
void writer() {
x = 42; // 操作 1
flag = true; // 操作 2(volatile 写)
}
// 线程 B
void reader() {
if (flag) { // 操作 3(volatile 读)
// 此处能看到 x == 42 吗?
System.out.println(x); // 操作 4
}
}
}
// 分析:
// 操作1 happens-before 操作2(程序次序规则)
// 操作2 happens-before 操作3(volatile 变量规则)
// 操作3 happens-before 操作4(程序次序规则)
// 因此:操作1 happens-before 操作4(传递性)
// 结论:线程 B 在读到 flag==true 后,能看到 x==42 ✓
volatile 关键字
volatile 的两大保证
volatile 关键字是 Java 并发中一个轻量级的同步机制,提供两个核心保证:
volatile int i; i++; 仍然不是原子操作!volatile 只保证单次读或单次写的可见性,
但 i++ 是「读-改-写」的复合操作,需要使用 synchronized 或 AtomicInteger。
volatile 的内存屏障实现
volatile 的实现依赖 CPU 的内存屏障指令(x86 上为 LOCK 前缀或 MFENCE 指令)。
JVM 在 volatile 写前后和读前后插入四种屏障:
volatile 的适用场景
适合 volatile 的场景
- 状态标志(如
running、initialized) - 双检锁单例中的
instance变量 - 一写多读的共享变量
- 独立观察(一个线程写,其他线程只需读最新值)
不适合 volatile 的场景
- 需要原子性的「读-改-写」操作(用
AtomicInteger) - 多线程同时写的场景(用
synchronized或锁) - 需要保证一组操作整体原子性(用
synchronized) - 复杂的依赖状态更新
内存屏障与 CPU 缓存一致性协议
MESI 协议简介
现代 CPU 采用 MESI 协议(或其变体)来保证多核缓存的一致性。MESI 是四种缓存行状态的缩写:
当 CPU A 修改一个缓存行时(M 状态),会通过总线发送「失效消息」给其他 CPU,将它们的对应缓存行设为 I 状态。 其他 CPU 下次访问该变量时,发现缓存已失效,会重新从主内存(或 CPU A 的缓存)读取最新值。 这就是硬件层面的可见性保证机制。
然而,为了提升 CPU 流水线效率,引入了写缓冲区(Store Buffer)和失效队列 (Invalidation Queue),使得 MESI 协议并非严格即时生效——这正是为什么 JMM 需要内存屏障来显式 刷新/排空这些缓冲区。
同步的成本与权衡
理解了并发的三大问题后,我们需要在「正确性」和「性能」之间权衡。常见的同步手段及其开销:
| 同步手段 | 保证原子性 | 保证可见性 | 保证有序性 | 性能开销 |
|---|---|---|---|---|
synchronized |
✓ | ✓ | ✓ | 中等(偏向锁→轻量级锁→重量级锁) |
volatile |
✗(单次读/写除外) | ✓ | ✓(禁止重排) | 低(仅内存屏障) |
AtomicXxx |
✓(CAS) | ✓ | 部分 | 低~中(CAS + volatile) |
ReentrantLock |
✓ | ✓ | ✓ | 中等(比 synchronized 略高) |
| 无同步(ThreadLocal) | ✓(无共享) | 不需要 | 不需要 | 最低 |
实践:正确的双检锁单例
/**
* 正确的双检锁(Double-Checked Locking)单例实现
* 使用 volatile 禁止重排序,确保对象完全初始化后才对外可见
*/
public class SafeSingleton {
// volatile 禁止 "分配内存→赋值给 instance→初始化对象" 的重排
private static volatile SafeSingleton instance;
private SafeSingleton() {}
public static SafeSingleton getInstance() {
if (instance == null) { // 第1次检查:无锁快速路径
synchronized (SafeSingleton.class) { // 加锁
if (instance == null) { // 第2次检查:防止重复创建
instance = new SafeSingleton(); // 现在安全了
}
}
}
return instance;
}
}
// 更简洁的替代方案:静态内部类(利用类加载机制的线程安全性)
public class BetterSingleton {
private BetterSingleton() {}
private static class Holder {
static final BetterSingleton INSTANCE = new BetterSingleton();
}
public static BetterSingleton getInstance() {
return Holder.INSTANCE; // JVM 保证类加载线程安全
}
}
在 Java 中,推荐使用枚举单例(enum Singleton { INSTANCE; })或静态内部类,
它们既线程安全,又防止反序列化重复创建实例,代码也更简洁。
双检锁主要用于需要延迟初始化且初始化成本很高的场景。
本章小结
本章我们建立了 Java 并发编程的理论基础:
- JMM 是一套内存可见性规范,将内存抽象为主内存与线程工作内存。
- 并发三大核心问题:原子性(操作不可分割)、可见性(修改立即可见)、有序性(禁止重排序)。
- happens-before 是 JMM 提供的可见性保证规则,有 8 条天然关系,通过传递性可以推导更多关系。
- volatile 保证可见性和有序性(禁止重排),但不保证原子性。
- 底层实现依赖 CPU 的 MESI 缓存一致性协议和内存屏障指令。
下一章我们将进入线程的生命周期、Thread API,以及 Java 21 引入的虚拟线程(Virtual Threads)。