Java 多线程开发里,最折磨人的从来不是锁写错了,而是一段“看起来完全没问题”的代码在并发下突然翻车。三年前我负责一个网关服务,后台线程定期刷新上游节点健康状态,业务线程读这个状态决定路由。上线后某个区域偶尔把请求打到已经宕掉的节点,过一阵又自己恢复。后来在状态变量前加了一个 volatile 再也没复现过。当时我只能照着“可见性问题”的结论修掉,但没办法把机制解释清楚。直到系统啃了一遍 JVM 内存模型和 Happens-Before 规则,我才明白那根本不是玄学,而是一套边界非常清晰的秩序规则。这篇文章就把这条知识链完整串起来:并发为什么会乱,JMM 如何定义乱与不乱,Happens-Before 如何在无序中搭出确定性,以及我们写生产代码时到底该守住哪些边界。
1. 一个让我熬夜的并发故障,先别急着总结“玄学”
先看一个最经典的“看起来没毛病”的代码。它是我经常拿来给团队复盘的演示程序,让新同学先猜输出:
public class VisibilityDemo { static boolean stop = false; static int loopCount = 0; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (!stop) { loopCount++; } System.out.println("worker exit, loopCount=" + loopCount); }); worker.start(); Thread.sleep(1000); stop = true; System.out.println("main: stop has been set to true"); worker.join(); System.out.println("main: worker joined"); } }单看逻辑,main 线程在 1 秒后把stop改成 true,worker 线程迟早会跳出循环。但实际运行起来,不同机器、不同 JVM 版本表现完全不一样:有些环境瞬间退出,有些环境跑几分钟都没动静,还有的环境要等到你手动采样线程栈之后才退。这个“不确定”本身就是并发问题最恶心的地方 —— 它不是必现,而是概率出现。
1.1 现场拆解:是 CPU 缓存,还是编译器动了手
很多人第一反应是 CPU 缓存。worker 线程读了stop之后把值缓存在自己的核心缓存里,main 线程修改的是主内存里的值,两边各看各的。这个解释方向对,但不完整。现代 x86 处理器有 MESI 这类缓存一致性协议,如果问题只出在硬件缓存,x86 上想复现还没那么容易。
真正的重头戏在 JIT 编译器。worker 线程里的循环没有修改stop,JVM 在做编译优化时认为这个读取结果在循环过程中不会变,于是把while (!stop)的判断提升到了循环外面,相当于改成了:
if (!stop) { while (true) { loopCount++; } }一旦机器码变成这样,无论 main 线程怎么写主内存,worker 线程都已经“看不见”了。这就是编译器重排序带来的结果:它默认单个线程内语义不变就够了,至于多线程能不能感知到变化,那是程序员要负责的事。
1.2 把混乱归类:可见性、有序性与原子性
这个例子同时牵出并发编程的两个老问题:可见性——一个线程的写什么时候对另一个线程可见;有序性——编译器和处理器为了性能会乱序执行,乱到什么程度才算越界。两者再加上原子性,就是 JVM 并发规范要回答的全部问题。
原子性代表不可分割,比如i++不是原子操作;可见性代表写入和读取之间的“时差”;有序性代表乱序优化不能破坏程序员感知到的逻辑。很多人说 JMM 是“并发三性的说明书”,这话没毛病,但说明书也有严格的边界。我们在下文就把它拆开看。
2. JMM 的底层逻辑:主内存与工作内存之间到底发生了什么
JMM 全称是 Java Memory Model,看名字容易误会成堆、栈、方法区那一套内存布局。其实它不是内存分配模型,而是规定“线程之间如何通信”的语义模型。Java 规范用了一个非常朴素的抽象:所有共享变量存在主内存里,每个线程有自己独立的工作内存,线程对变量的读写不能直接操作主内存,必须先把变量复制到工作内存,再回写主内存。
这套抽象和真实硬件不一定一一对应。你完全可以把它想象成:主内存是公司的公共文件柜,工作内存是每个人手里的笔记本。普通变量访问,没人强制你每次改完立刻归档,也没人强制你每次读之前先去文件柜重新抄一遍。于是线程 A 在笔记本上改了数字,线程 B 从自己的笔记本上读到的还是旧值。这就是“可见性”问题的根源。
2.1 一次变量读写背后的八个动作
JMM 定义了一组底层操作来描述数据怎么在“主内存”和“工作内存”之间流动:read/load/use/assign/store/write。这一串名词看起来冷冰冰,拆成人话就是:
| 操作 | 语义 |
|---|---|
read与load | 从主内存读值,并加载到线程的工作内存 |
use | 工作内存中的值交给执行引擎使用 |
assign | 执行引擎产生的新值写回工作内存 |
store与write | 工作内存中的值传回主内存并写入 |
再加上lock/unlock,就是完整的一套动作。JMM 还规定了一系列动作之间的“允许规则”和“禁止规则”,比如你不能从主内存读了一次却不 load,也不能只 assign 不 store。这些细节决定了哪些执行轨迹是合法的。
我见过不少人把这些动作当作 CPU 实际执行的步骤来背,其实没必要。把它理解为“边界协议”更合适:JMM 用这些动作给了编译器亿级自由度的同时,也划清了底线——单线程执行时,上面这套动作必须表现得和源码一致;多线程时,只有满足 Happens-Before 规则的轨迹才被允许发生。
2.2 JMM 不是运行时数据区,也别急着往 MESI 上靠
这是两个高频混淆点。第一,JMM 不是 JVM 内存区域的堆或栈;堆栈解决的是“对象和引用放哪”,JMM 解决的是“两个线程怎么感知同一个共享变量”。第二,JMM 不一定直接等于 CPU 的缓存一致性协议。MESI 是硬件层的协议,负责缓存行在物理核之间的同步;JMM 是语言层的契约,负责约束编译器和 JIT 的输出。
为什么不能混为一谈?因为就算硬件缓存一致了,JIT 照样可以把读取提升到循环外面,造成“可见性失效”。反过来,即便硬件乱序了,只要 JIT 生成了足够的内存屏障,语言层面也能达成有序性的承诺。所以我们在 Java 里谈并发,真正要盯的是 JMM 的规则,而不是某个具体 CPU 的微架构。
JMM 还有一个里程碑需要记住:Java 5 以前的旧内存模型存在不少设计缺陷,volatile 的语义太弱,final 字段的发布也不够安全。JSR-133 从 Java 5 开始提升了 volatile 和 final 的语义,我们现在能安全地用双重检查锁单例,靠的正是这套改动。很多老博客说“DCL 必崩”,那是在旧模型下的结论,阅读时要注意版本语境。
3. Happens-Before 八条界线,在无序里搭出确定性
JMM 面对的问题是:编译器、处理器、JIT 都希望乱序优化,程序员则希望代码还能按直觉运行。两边不能鸡同鸭讲,于是 JMM 给出了一套“买定离手”的规则——只要你的程序满足 Happens-Before 关系,JVM 就保证执行的可见性和有序性;不满足,那对不起,发生什么都合法。
Happens-Before 的定义很直白:如果操作 A happens-before 操作 B,那么 A 的执行效果对 B 可见,且 A 的执行顺序在操作结果上必定排在 B 之前。它不是时钟意义上的绝对时间,而是“可见性意义上的前驱关系”。下面这八条规则,是 Java 并发秩序的全部骨架。
| 规则名称 | 一句话含义 |
|---|---|
| 程序次序规则 | 单线程内,写在代码前面的动作 happens-before 后面的动作 |
| 监视器锁规则 | 同一个锁的 unlock happens-before 后续对这个锁的 lock |
| volatile 变量规则 | 对一个 volatile 变量的写 happens-before 后续对这个变量的读 |
| 线程启动规则 | 线程对象的start()happens-before 该线程中的任何动作 |
| 线程终止规则 | 线程中的所有动作 happens-before 其他线程从join()返回 |
| 中断规则 | 调用interrupt()happens-before 被中断线程检测到中断 |
| 终结器规则 | 对象构造函数的结束 happens-beforefinalize()的开始 |
| 传递性 | 如果 A happens-before B,B happens-before C,则 A happens-before C |
3.1 前三条规则是如何配合成一张网的
程序次序规则说的是“单线程视角”,它允许编译器重排,前提是重排后单线程结果不变。别小看这一条,它是整个 JMM 的妥协条款:给优化留了空间,又保住最基础的语义。
监视器锁规则和 volatile 变量规则是跨线程的两根主梁。同一把锁的解锁动作,对后面获取同一把锁的线程可见;volatile 的写,对后续读到该值的线程可见。注意两者的“可见”范围不一样:synchronized 是通过锁的临界区制造一个大边界,volatile 是围绕单个变量制造一个轻量边界。但它们都不能孤零零地看,必须和程序次序规则、传递性组合起来。你在锁块里写了 ten 个普通变量的修改,解锁之后这些修改会被一起“带出去”;你在 volatile 写之前写了普通变量,读线程只要看到了 volatile 新值,前面那些普通变量的写入也一并确认可见。这是我工作中最常用的一条推理链:把 volatile 当作“信号枪”,而不是单纯防住一个字段。
3.2 线程启动与 join:被低估的可见性保障
很多并发初学者只知道锁和 volatile,却忽略start()和join()本身就携带 Happens-Before 边。线程 A 调用threadB.start()之前写的所有变量,在线程 B 启动后的任何代码里都可见。这意味着你完全不需要为了“把配置传进新线程”额外加锁,只要配置是在start()之前写完的,worker 线程拿到的一定是最新值。
join()也是同理。worker 线程里写的所有结果,在 main 线程执行到worker.join()返回之后全部可见。很多高手写异步任务汇总时压根不用锁,把结果放在共享列表里,只要保证所有写都发生在 worker 线程结束前、读都发生在join()之后,数据就一定是完整的。
3.3 传递性是所有边界链的核心心脏
八条规则里,传递性最像数学里的等号:它能让你把两条不相邻的边拼接成一条新边。举一个最常见的例子:线程 A 在锁内写了a = 1,然后解锁;线程 B 获得这把锁后写了b = 2,然后另一个线程 C 通过 volatile 标志看到 B 的结果。此时 A 的写入也能一路传递到 C。没有传递性的话,Happens-Before 就只能管相邻的边,没法形成真正的跨线程顺序网。
因此画出正确的心智模型特别重要:不要一个规则一个规则地孤立背,而是把它当成可以在代码里“搭桥”的材料。我判断一段并发代码能不能成立,总是先找写线程和读线程之间,有没有一串由 lock/unlock、volatile write/read、start/join 组成的连通链路。有链,就秩序井然;没链,就是各自为政。
4. volatile 与 synchronized 是怎么把规则变成物理指令的
Happens-Before 是语法层面能看见的承诺,但 JVM 要在不同的 CPU 上兑现这个承诺,得靠内存屏障。内存屏障是一类 CPU 指令,作用是禁止屏障前后的特定重排,顺便刷一刷缓存或者让写缓冲落到可见状态。HotSpot 在 JIT 编译时,会根据目标架构生成不同的屏障组合。
经典的内存屏障分类一般有四类:LoadLoad、LoadStore、StoreStore、StoreLoad。JMM 语义落到 volatile 上大致是这样:
| volatile 操作 | 屏障位置 | 作用 |
|---|---|---|
| volatile 读之后 | LoadLoad | 禁止后续普通读越过 volatile 读 |
| volatile 读之后 | LoadStore | 禁止后续普通写越过 volatile 读 |
| volatile 写之前 | StoreStore | 禁止之前的普通写越过 volatile 写 |
| volatile 写之后 | StoreLoad | 禁止后续读写越过 volatile 写,这是最重的一道屏障 |
纯从规范角度说,JMM 并不强制 JVM 一定要插入这四类屏障,它只要求最终结果满足 Happens-Before。屏障只是 HotSpot 在大多数平台上用来“达成目标”的实现手段。理解这一点,你就不容易被网上各种“volatile 一定加 fence”的说法带偏。
4.1 不同 CPU 架构下的实现差异
x86 是强内存模型(TSO),硬件本身就天然保证 LoadLoad、LoadStore、StoreStore 的顺序,JIT 真正需要主动干预的主要是 StoreLoad。所以你在 x86 上写 volatile,经常看到的底层指令是lock前缀指令或者mfence,把 store buffer 中的写强制全局可见。
ARM 和 RISC-V 这类弱内存模型就麻烦得多。它们不保证普通读写的顺序,volatile 读可能得插入ldar这种带 acquire 语义的指令,volatile 写要用stlr这种带 release 语义的指令。这也解释了为什么同一段并发代码在 x86 上“碰巧正常”,迁到 ARM 服务器上就会出现诡异问题——不是代码的锅,而是不同 CPU 兑现 JMM 的方式不一样。
4.2 synchronized 的进入和退出
synchronized 的 Happens-Before 语义落在监视器(Monitor)的进入和退出上。进入锁时,JVM 会让工作内存中相关的缓存失效,确保后续从主内存重新读取;退出锁时,会把工作内存中修改过的内容回写到主内存。这本质上是给临界区装了一道“全封闭”墙。
实现层面,HotSpot 的瘦锁、重量锁会随竞争程度升级,但不管怎么升级,JMM 要的语义不变:同一把锁的解锁对后续加锁可见。有一点容易被忽略:锁必须是真的“同一把”。你写线程用synchronized(objA),读线程用synchronized(objB),就算 objA 和 objB 指向同一个对象的不同引用也可能没事,但若指向不同对象,那就完全没有可见性保障,代码再对称也白搭。我见过不少新人在两个类里各维护了一把“明明功能相同但并不是同一个实例”的锁,然后百思不得其解为什么还是出并发问题。
4.3 原子类与 VarHandle:更细粒度的同步原语
除了 volatile 和 synchronized,java.util.concurrent.atomic包里的原子类也很关键。AtomicInteger.incrementAndGet()这类操作会形成一个“线性化点”,它既保证原子性,也提供类似 volatile 的可见性,甚至能通过 CAS 的语义制造新的 Happens-Before 边。
JDK 9 之后出现的 VarHandle 把 acquire/release 语义做得更细:setRelease、getAcquire允许你按需声明读写边界。它不是 JMM 新规则,而是把已有的内存排序能力暴露给用户,让代码避免“为了一个字段加整把锁”的笨办法。在业务里如果只是要发布一个不可变快照,AtomicReference往往比 synchronized 更好用,读线程基本无锁也能拿到最新值。
5. DCL 是标准反面教材:为什么“看似聪明”的双重检查更容易翻车
双重检查锁(Double-Checked Locking)是我见过最典型的“每一步都对,合起来就错”的代码。曾经被无数博客拿来证明 Java 5 之前内存模型的缺陷,即使到了 JSR-133 之后,如果漏掉 volatile,它依然是个坑。先看错误版本:
public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 创建并赋值 } } } return instance; } }第一次检查用来挡掉大部分无谓加锁,第二次检查保证在锁内只创建一次。逻辑上无懈可击,但在 JMM 看来,问题出在最后一行。new Singleton()在字节码层面不是一步操作,而是三步的排列组合:分配内存、调用构造方法初始化对象、把引用赋值给静态字段。这三个步骤在单线程内怎么重排都无所谓,但多线程下,第三步“赋值”可能被编译器提到第二步“初始化”之前。
一旦发生这种重排,另一个线程在第一次检查时就会看到instance已经不是 null,于是直接 return 并使用。可它实际拿到的可能是一个“半初始化”对象:引用存在,内部字段还是默认值。轻则返回错误数据,重则抛空指针,而且极难复现。你给它加多少个if判断都没用,因为问题不在判断逻辑,而在对象发布的时序。
修复方式就是在instance字段前加 volatile:
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }加了 volatile 之后,赋值操作和它之前的内存操作之间就有了 StoreStore 屏障和 Happens-Before 关系的约束:构造方法里的初始化一定先于 volatile 写,读线程只要看到非 null 引用,就一定能看到构造完成的对象。这也是我在上一节强调“volatile 不是只管一个字段”的原因,它往往把一套初始化状态安全地发布了出去。
如果你的项目不需要懒加载,更清静的办法是用静态内部类:
public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }JVM 在类初始化阶段会拿到一个隐式的初始化锁,保证Holder的静态初始化只执行一次,而且初始化完成后对访问它的线程可见。这种方式不依赖 volatile 也安全,因为类初始化的同步语义天然由 JVM 背书,代码里少一个关键修饰符,就少一份被误改的风险。
6. 把 JMM 用到生产:我写并发代码反复执行的三个自检步骤
理论说再多,最终要落到“我怎么写才不出事”上。这些年我把自己的并发代码审查习惯压缩成了三个自检步骤,每一步都是在问同一个核心问题:写线程和读线程之间,有没有一条真正的 Happens-Before 链。
6.1 自检一:给每个共享变量画一条可见性链
遇到需要跨线程共享的变量,我会在代码注释里先写下这条链。比如“线程 A 在锁内更新 counts,线程 B 在读取前先获取同一把锁”,这就是一条完整的链。如果写线程没有任何同步操作,读线程也没有任何同步操作,代码就是“裸奔”。裸奔程序在任何机器上运行都属于运气问题,不是正确性。
画链的技巧是:不要把 volatile 当成万能药,它解决的是单个变量的可见性和排序;不要把 synchronized 当万能药,它只保护同一把锁范围内的访问。真正要做的,是把每一处共享变量的读写都归到某条链下,并让所有参与者都走同一条链。
6.2 自检二:确认对象发布是“安全发布”
对象的构造和发布是两个动作。构造完不代表其他线程能安全看到。安全发布有几种常见姿势:通过静态初始化、通过 volatile 字段、通过AtomicReference、通过正确加锁。还有一个容易被忽略的点:构造器里不能泄漏this引用。你在构造方法里把this交给了别的线程,别的线程可能拿到一个还没构造完的对象,这不是加不加 volatile 能补救的。
对不可变对象,Java 5 之后有“初始化安全”的保证:只要对象正确构造,final字段就能在其他线程看到最终值。但这个保证同样要求“正确发布”,如果你用一个普通静态字段写引用,没有 volatile 也没有同步,那发布本身就是不确定的,最终字段也谈不上安全。
6.3 自检三:能用工具类就不用徒手同步
我见过太多人想证明自己并发功底强,手写自旋锁、手写阻塞队列、手写读写缓存。绝大多数情况下,ConcurrentHashMap、AtomicBoolean、BlockingQueue这些现成工具已经包含了经过大量测试的同步语义。工具类的内部实现可能没用锁,也可能用了锁,但对外都提供了清晰的顺序保证。
比如一个简单的“运行开关”,volatile boolean可能够用;但如果要执行“只有从 false 变成 true 才动作”的语义,就应该用AtomicBoolean.compareAndSet(false, true)。后者自带线性化点,能避免“两个线程同时读到 false,然后各自执行一次”的尴尬。当工具类能表达语义时,不要自己造轮子,轮子越多,你需要在脑子里维护的 Happens-Before 边就越多,出错概率也越高。
6.4 验证工具:用 jcstress 把并发模型跑成证据
光靠推理容易出错,我偶尔会用 OpenJDK 的 jcstress 工具验证某个并发行为。它专门用来压测 JMM 下的边界情况,能把“理论上允许但肉眼难复现”的结果捞出来。下面这个测试,可以验证 volatile 写之前普通写的可见性传递:
@JCStressTest @Outcome(id = "0, 0", expect = ACCEPTABLE, desc = "read before write, values not visible yet") @Outcome(id = "1, 1", expect = ACCEPTABLE, desc = "both writes visible") @Outcome(id = "0, 1", expect = FORBIDDEN, desc = "volatile visible but plain write invisible") @State public class VolatileOrderingTest { int plain = 0; volatile int flag = 0; @Actor public void writer() { plain = 1; flag = 1; } @Actor public void reader(IntResult2 r) { if (flag == 1) { r.r1 = plain; } r.r2 = flag; } }如果运行结果真的出现0, 1,说明 JVM 没有兑现 volatile 的多写可见性,那基本可以断定是 JIT 或者环境出了问题。平时我们遇到诡异的可见性 bug,把疑似场景改写成这样的 jcstress 用例跑上几百万次,往往比在生产环境里烧高并发更有说服力。
7. 把规则落在日常编码习惯里
最后想分享一个工作习惯。我给自己的并发代码定了一个强制要求:跨线程共享的每个变量,在写入的代码行旁边,必须能说出它是通过哪条 Happens-Before 链被其他线程看到的。说不出,就绝不提交。
这个习惯听起来很苛刻,但它帮我挡掉了很多“慢排查”。比如有人在共享对象里加了一个volatile标志位,希望通过它带出另一个普通字段的新值——这个思路是对的,前提是读线程必须先读 volatile 标志位,再读普通字段;如果读线程把顺序写反了,那 volatile 的边界就失效了。还有人在锁外面读状态,锁里面写状态,以为只要“偶尔读一下”没关系,这种代码一旦流量上来,就是间歇性故障的温床。
如果你现在还在为并发问题头疼,建议先从最简单的模型入手:把 volatile 当“信号枪”,当“发布开关”,不要当“普通字段的加速器”;把 synchronized 当“小房间”,拿出来的时候记得把房间里的所有改动都带出来了;写每一段跨线程代码时,先画一遍 Happens-Before 链,画不出来就回头改设计。
JMM 的规则不会帮你自动消灭并发问题,但它把“什么是合法执行”这条边界画得清清楚楚。遵守边界,哪怕线程调度再乱,数据流也是有秩序的;越过边界,换再多机器、调再多参数,也只是增加复现运气,没有增加正确性。希望你的线程,都能在正确的秩序里碰面。