ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JMM Happens-Before规则详解:从原理到实战,彻底解决并发可见性

JMM Happens-Before规则详解:从原理到实战,彻底解决并发可见性 1. 先搞清楚 JMM 到底在解决什么问题在聊 Happens-Before 之前得先掰扯清楚 JMMJava 内存模型这玩意到底是干嘛的不然你只记规则转身就忘面试被深挖照样懵。JMM 是一套抽象的内存访问规范它规定了一个 Java 程序在多线程环境下共享变量的读写行为在什么情况下是“可见的”“有序的”。它不是 JVM 实际的内存布局堆、栈、方法区那些而是定义了一套底层的内存交互逻辑用来回答一个核心问题线程 A 改了数据线程 B 什么时候一定能看到为什么需要这套规范因为底层硬件不配合。现代 CPU 都有自己的多级缓存L1、L2、L3线程执行时不是直接操作主内存而是先把数据从主内存拷贝到自己的本地缓存算完再刷回去。于是问题来了线程 A 在 CPU0 的缓存里改了count线程 B 在 CPU1 的缓存里读count如果 A 没把数据刷回主内存、或者刷回去了但 B 的缓存没失效B 读到的还是旧值。再加上编译器和 CPU 还会做指令重排序——只要单线程语义不被破坏它们可以任意调整指令执行顺序来提升性能。单线程没问题多线程就麻烦了两个线程之间没有任何约束执行顺序乱了套你以为先执行的操作另一个线程可能感受到了“后执行”的效果。所以 JMM 的价值就是在这层混沌之上画出清晰的安全边界。它规定只要两个操作满足 Happens-Before 关系JMM 就保证前者对后者可见且前者的执行顺序在后者之前对后者而言。不满足这条关系的操作JVM 不做任何承诺你可以把它们的执行结果视作随机的。这套东西搞明白了你再看synchronized、volatile、final的全部设计意图就能串起来了。2. 八条 Happens-Before 规则逐条拆解Happens-Before 不是某个工具或语法它是一套偏序关系集合。JMM 规定了八条规则前四条是程序员的“保命条款”后四条是结构性的推论规则。逐条过。2.1 程序次序规则Program Order Rule同一个线程内书写在前的操作 Happens-Before 书写在后的操作。这条是底线。它保证了单线程语义不会被重排序破坏。但注意它说的是“同一个线程内”。两个线程之间没有任何一条程序次序规则把它们的操作关联起来。举个例子线程 A 里int b 1; // 操作1 int a b 1; // 操作2操作1 在操作2 之前执行这个由程序次序规则保证。就算编译器实际上把它们的执行顺序调换了并发层面看到的效果也必须是操作1 先于操作2。这也是 JMM 设计的关键点对于单线程不管底层怎么重排执行结果必须和顺序执行一致as-if-serial 语义。这条规则是 Happens-Before 体系的基础很多传递性判断都要依赖它。2.2 监视器锁规则Monitor Lock Rule对一个锁的解锁 Happens-Before 于随后对这个锁的加锁。这是synchronized全部机理的根源。重点在这个“随后”——指时间上的先后顺序。如果线程 A 先执行完synchronized代码块并释放锁线程 B 随后通过同一把锁进入同步块那么 A 在释放锁之前的所有操作对 B 都是可见的。实战里最常见的用法public class Counter { private int count 0; public synchronized void increment() { count; } public synchronized int get() { return count; } }线程 A 调用increment()把count改成 100释放锁线程 B 再调用get()拿到锁它不光能看到count 100还能看到 A 在increment()里修改的所有共享变量。这条规则的本质是锁不仅是互斥的工具还是内存屏障。释放锁时JVM 会把当前线程本地缓存里的所有修改强制刷新到主内存获取锁时JVM 会让当前线程本地缓存失效必须从主内存重新拉取。所以锁覆盖的临界区天然有“写后读可见”的效果。注意区分锁规则要求的是“解锁”在“加锁”之前。两个线程如果用的是不同锁这条规则完全不成立。还有如果线程 B 加锁的时间在 A 解锁之前比如 A 还没退出同步块B 根本拿不到锁谈不上面向可见性。2.3 volatile 变量规则Volatile Variable Rule对一个 volatile 变量的写操作 Happens-Before 于后续对这个 volatile 变量的读操作。这也是“后续”两个字很关键。线程 A 写一个 volatile 变量线程 B 之后读到这个变量那么 A 在写之前的所有操作对 B 可见。典型场景——用 volatile 做线程间的执行信号public class VolatileExample { private volatile boolean flag false; private int value 0; public void writer() { value 42; // 普通写 flag true; // volatile 写 } public void reader() { if (flag) { // volatile 读 // 这里能读到 value 42 } } }这里flag是 volatile。A 线程序先写value再写flagB 线程读flag发现为 true再读value必然得到 42。因为 volatile 写 Happens-Before volatile 读且同线程内value 42在flag true之前通过传递性第 2.8 条可以推出value 42对 B 可见。JMM 对 volatile 的底层实现是写操作会插入内存屏障StoreStore StoreLoad强制把当前线程的修改刷新到主内存读操作会插入 LoadLoad LoadStore 屏障强制从主内存重新加载并使其后的读操作不重排到 volatile 读之前。所以 volatile 不只解决可见性还能禁止特定形式的指令重排序。但它不保证原子性。volatile int count; count;这条语句包含了读-改-写三步多个线程同时执行照样会丢更新必须靠 synchronized 或 AtomicInteger 这类 CAS 工具兜底。2.4 线程启动规则Thread Start Rule线程对象的 start() 方法 Happens-Before 于被启动线程中的任意操作。也就是说主线程在调用start()之前做的一切写操作对刚启动的子线程都是可见的。看代码public class StartExample { private int data 0; public void startThread() { data 100; Thread t new Thread(() - { // 这里读到 data 必然是 100 System.out.println(data); }); t.start(); } }这个设计意图很清晰既然要开启一个线程去干活那这个线程至少应该能看到启动前的“初始条件”。不然子线程一跑起来就看到半初始化的状态整个程序逻辑就没法写了。面试里容易被挖的一个点new Thread()这个动作本身不做同步保证只有start()才是同步点。如果你把data 100写在new Thread()之前、start()之后那子线程能不能看到就取决于重排序的运气了。2.5 线程终止规则Thread Termination Rule线程中的所有操作 Happens-Before 于其他线程检测到这个线程已经终止Thread.join() 返回、Thread.isAlive() 返回 false。这条规则保证了你join()一个线程成功返回后这个线程里发生的所有写操作对当前线程可见。Thread t new Thread(() - { result compute(); // 共享变量 }); t.start(); t.join(); // 这里读 result 一定能看到 compute() 的最新值这个特性在并发编程里非常实用——用join()做“分而治之”的结果汇总是初阶并发最不容易踩坑的模式之一。比用 volatile 标记线程执行完毕再轮询要稳得多因为 join 返回是有内存屏障保证的。2.6 线程中断规则Thread Interruption Rule对线程 interrupt() 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生通过 interrupted() 或 isInterrupted() 方法。也就是线程 A 调用了threadB.interrupt()那这之前 A 的写操作当 B 在中断检测点之后的代码里是可见的。实战中这个用得不算多但有一个场景很典型用中断来通知线程停止工作同时在停止前需要传递一些“最后的状态”。中断检测点就是同步点检测到中断后之前那个线程的写操作对当前线程可见。2.7 对象终结规则Finalizer Rule一个对象的初始化完成构造函数执行结束Happens-Before 于它的 finalize() 方法的开始。这条规则知道即可。它保证了 finalize 能看到构造函数里完成的所有初始化操作不至于看到一个半初始化状态的对象。现在 finalize 已经被标记为废弃了Java 9 起进入弃用流程基本不用在实战里考虑它。2.8 传递性Transitivity如果 A Happens-Before B且 B Happens-Before C那么 A Happens-Before C。这条是一个推理规则本身不说具体某个操作之间的约束而是把前面几条规则串联起来形成链式推导。前面 volatile 例子里value 42 - flag true - flag 读取 - value 读取的判断就是靠传递性把程序次序规则和 volatile 变量规则串在一起的。3. 实战用 Happens-Before 分析代码到底哪里出了问题理论背得再熟不会用就是假的。我见过太多人张口就背“一个 volatile 的写先行发生于后续的 volatile 读”但拿到一段出问题的并发代码完全看不出问题在哪。下面用一个典型例子演示完整分析过程。3.1 一个真实的可见性事故现场先看这段代码public class VisibilityProblem { private boolean running true; private int count 0; public void worker() { while (running) { count; // 做一些计算 } System.out.println(worker stopped, count count); } public void stop() { running false; } public static void main(String[] args) throws InterruptedException { VisibilityProblem vp new VisibilityProblem(); Thread worker new Thread(vp::worker); worker.start(); Thread.sleep(1000); vp.stop(); worker.join(); System.out.println(main: vp.count); } }这段代码在理论上可能出现一个诡异现象stop()方法执行了running被改成 false但 worker 线程死循环不退或者过了一会儿才退。用 Happens-Before 怎么分析running是普通变量stop()对running的写操作和 worker 线程对running的读操作之间没有建立任何 Happens-Before 关系。没有锁规则——stop()不在 synchronized 块里worker 的循环体也不在同步块里。没有 volatile 规则——running不是 volatile。没有线程启动/终止规则——操作发生在线程启动之后、尚未 join 返回的阶段。结论这是个数据竞争。JMM 对数据竞争下的读写顺序不做任何保证worker 线程可能永远看不到running的变化。修复很简单把running声明为 volatileprivate volatile boolean running true;这样一来stop()中的写操作 Happens-Before worker 线程之后的读操作可见性问题从根上解决。3.2 加了 volatile 就万事大吉了吗再给一个升级版的例子这个在很多公司面试里出现过变体public class VolatileAndCount { private volatile boolean flag false; private int count 0; public void producer() { count 100; flag true; } public void consumer() { if (flag) { System.out.println(count); } } }两个线程分别执行producer()和consumer()consumer 里最终一定能打印 100 吗答案是如果 consumer 在 producer 执行完 flag 写入之后才读到 flag 为 true那 count 一定打印 100。因为 volatile 写-读规则 程序次序规则的传递性链路是count 100程序次序→flag truevolatile 写然后flag的读volatile 读Happens-Beforecount的读程序次序最后通过传递性count 100Happens-Beforecount的读。但有个陷阱如果 consumer 在 producer 的flag true执行之前就执行了if (flag)读到 false什么都没打印这是正常时序交替的问题不是并发正确性问题。Happens-Before 不保证操作在时间上一定按某个顺序发生它只保证一旦发生了某个可识别的顺序那么后续操作对共享变量的可见性有确定结论。3.3 synchronized 和 volatile 的选型判断Happens-Before 规则在你做同步工具选型时可以当一张对照表需求场景推荐工具Happens-Before 依据理由复合操作读-改-写需要原子性synchronized / ReentrantLock / Atomic*监视器锁规则volatile 不保证原子性单一布尔位/引用的发布可见性volatilevolatile 变量规则轻量、无锁竞争多变量状态的一致快照发布synchronized / 不可变对象 volatile 引用监视器锁规则 / volatile 变量规则状态之间存在关联约束等待子线程算完再汇总Thread.join()线程终止规则对结果可见性天然有保障很多初学者有个误区觉得并发问题都要用锁反而是过度同步。volatile能解决的那部分可见性问题用锁反而是浪费。反过来只在字段上加 volatile 却期望它能保证原子性是另一种想当然。Happens-Before 规则框架的价值就在于给你一把尺子去度量某个字段、某个操作到底需要哪种同步手段才够。4. 面试和工程中的高频应用场景拆解Happens-Before 不只是笔试里的概念题面试官会把它嵌入到各种并发场景里去考察。这里挑几个高频场景做个深度拆解。4.1 双重检查锁定DCL单例模式的正确写法DCL 是最经典的 volatile 实战场景public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // 问题点 } } } return instance; } }为什么instance必须是 volatile因为new Singleton()不是一个原子操作它在字节码层面大致分三步分配内存空间调用构造函数初始化对象把引用指向分配的内存问题在第 2 步和第 3 步之间编译器和 CPU 可能把第 3 步重排到第 2 步之前。此时instance指向的内存还没完成初始化另一个线程第一次检查时看到instance ! null直接返回然后使用这个“半初始化”的对象直接炸。加了 volatile 之后写 volatile 变量会插入 StoreStore 屏障禁止把instance new Singleton()这一步之前的普通写构造函数里的初始化动作重排到 volatile 写之后。于是当 instance 的引用对外可见时构造函数里的初始化必然已经完成。用 Happens-Before 的语境来解释构造函数中的初始写操作Happens-Before volatile 写instance ...之后任意线程对instance的 volatile 读Happens-Before 它后续对单例对象的访问。整条传递链路是完整的。4.2 安全发布不可变对象的模式面试题里还有一类很刁钻的先发布一个对象引用然后初始化它。用 Happens-Before 判断能不能被其他线程安全读public class LazyInit { private HeavyObject heavy; public void init() { heavy new HeavyObject(); // 普通写 } public HeavyObject getHeavy() { return heavy; // 普通读 } }如果init()在某个线程执行另一个线程调用getHeavy()这里没有任何 Happens-Before 关系。这个对象是不安全发布——其他线程可能看到一个引用已赋值但对象内部状态未完全初始化的半成品。三个可选的修复路径分别对应不同的 Happens-Before 规则给heavy加 volatile → volatile 变量规则提供可见性保障把init()和getHeavy()都设为 synchronized → 监视器锁规则提供保障将HeavyObject设计为不可变所有字段 final 构造后不改→ final 字段有特殊的初始化安全性语义JMM 保证 final 字段在构造函数中正确赋值后任意线程通过正确发布的引用读取时一定能看到正确的 final 值实际工程里我更多用 volatile 引用 不可变对象的组合因为锁会引入竞争而不可变对象加 volatile 引用的方式读路径完全无锁。4.3 线程池中任务提交与执行的可见性线程池场景里任务提交到execute()任务内部能看到提交前主线程写入的数据吗要看具体用的哪个线程池。用ThreadPoolExecutor.execute()提交任务时任务最终会被放入一个BlockingQueue。入队操作内部用了锁比如ReentrantLock worker 线程从队列里 take 任务也用了同一个锁的锁竞争。根据监视器锁规则主线程的入队操作写共享数据 入队涉及锁的加锁与解锁worker 线程的 take 操作涉及同一把锁的加锁与解锁主线程的写操作 Happens-Before 解锁解锁 Happens-Before worker 线程的加锁加锁 Happens-Before 任务读取这条链路是成立的所以通过execute()提交的任务能看到提交前主线程的写操作。用ScheduledThreadPoolExecutor.schedule()类似内部也是走工作队列加锁的机制。但如果你自己写一个裸的Thread直接start()或者往一个没有任何同步机制的自定义“队列”里放任务那就另说了。这里要强调一个容易忽略的点提交任务本身的动作不算同步点是队列内部的锁操作构成了 Happens-Before 链。如果你用了无锁队列比如ConcurrentLinkedQueue它的可见性保障不是靠锁规则而是靠其内部对 volatile 变量head/tail 节点引用的 volatile 读写规则来支撑。这种情况下理解底层实现是用哪条规则比记住“线程池能保证可见性”这种结论更重要。4.4 状态标志 数据快照的发布工程里面特别常见的需求一个线程持续更新一组数据另一个线程需要拿到某一时刻的“快照”。最简单可靠的做法是 volatile 引用指向不可变对象public class SnapshotHolder { private volatile Config config; public void update(Config newConfig) { config newConfig; // volatile 写 } public Config get() { return config; // volatile 读 } }Config设计为不可变所有字段 final通过构造函数一次性赋值。这样写线程只要保证构造Config时所有的 final 字段初始化完成再把引用赋给 volatile 变量读线程拿到的引用一定是安全发布的对象。这里用到的规则链条是构造函数中的 final 字段写操作 → 有 JMM 的 final 字段语义保证对象安全发布后读操作可见volatile 引用写 → 其他线程 volatile 读时建立 Happens-Before 关系最终结果读线程拿到的是一个完全初始化且最新发布的对象这是一种把规则用得行云流水的组合招式比加一堆锁优雅得多性能也好看。实战中配置热更新、路由表刷新、黑白名单替换我都用这个模式。5. 哪些坑我替你踩过了Happens-Before 听起来很完美但它有边界和陷阱。以下几点是我在实际调试和写代码过程中踩出来的经验。5.1 Happens-Before 不保证时间上的先后关系这是初学者理解上最大的误区。Happens-Before 是一种偏序关系的抽象并不直接等价于“时间线上 A 一定先于 B 开始执行”。它保证的是内存可见性效果当 B 观察到某个同步事件后A 的操作效果对 B 可见。举个例子线程 A 在时间点 T1 执行volatileFlag true线程 B 在 T2T2 T1执行boolean flag volatileFlag。直觉上 B 应该读到 true。但如果 B 线程在 T1 之前就已经把 volatileFlag 的值缓存进本地寄存器并且由于编译器优化它在循环里根本没做出内存访问指令呢实际上 JMM 的语义会逼着 JVM 在 volatile 读指令处不缓存旧值所以这个例子在真实 JVM 里不会出现可见性问题。但我要强调的是一个代码场景有没有问题不能靠时间上的直觉推断必须回落到是否建立了 Happens-Before 关系。时间顺序是判断“后续”关系的前提但 Happens-Before 关系本身才是 JMM 承诺的边界。5.2 数据竞争与非数据竞争的差别JMM 规则承诺是针对“正确同步”的程序。如果你有一个数据竞争data race——即两个线程同时访问同一个共享变量至少一个线程是写操作且没有同步机制管理——那 JMM 对你不作任何保证。它不是保证你能看到一个旧值而是可能出现各种你完全想不到的诡异行为比如回环现象一个线程能连续看到同一个变量从某个值变到另一个值再“变回”旧值这种违反单调性的情况。Happens-Before 规则的适用前提就是代码里没有数据竞争或者你能证明每一个共享变量的访问都存在 Happens-Before 关系。所以遇到并发 bug第一步永远是找出哪些共享变量存在数据竞争第二步才是套规则判断这个竞争是否会导致问题。5.3 volatile 读的代价被低估了很多人以为 volatile 只是不加锁所以完全没有代价。其实 volatile 写会触发 StoreLoad 屏障这在 x86 平台上是一个 full fence代价相当高volatile 读在某些弱内存模型架构上也不便宜。我见过生产者把高频更新的热变量全标了 volatile结果吞吐量掉了几个百分点。正确做法是能用不可变对象 volatile 引用解决的问题不要用多个 volatile 字段。能在一个 volatile 字段里打包多个状态的就用一个 int 存多个标志位。锁竞争严重的场景下优先考虑LongAdder或条纹化的分段设计而不是盲目用 volatile 原子变量。5.4 别用 final 字段代替一切final 字段的安全初始化语义有一个前提对象必须通过正确的方式发布。JMM 规定如果对象的引用在构造函数中安全发布即没有让 this 引用逃逸到其他线程那么任何线程通过正确的方式读到这个引用后访问其 final 字段时应该能看到正确初始化的值。但如果你在构造函数中就启动了一个线程这个线程又去访问对象的字段那就是不安全的 this 逃逸。这种情况锁和 volatile 都救不了你因为问题出现的时机太早了。所以我的建议是不要在构造函数里启动线程不要在构造函数里向外部注册监听器。等构造完全结束再执行发布动作。6. 用 Happens-Before 指导并发代码审查在团队里做代码审查时我养成了一个习惯一旦涉及多线程共享变量心里自动跑一遍 Happens-Before 检查清单。这套清单可以分享出来这个共享变量是哪种类型的普通变量、volatile、final、还是由锁保护的变量写线程的操作与读线程的操作之间有没有任何同步动作可以建立 Happens-Before 链如果有锁两边的锁是不是同一把解锁和加锁的时序是否满足“随后”条件如果有 volatile读写之间的时序是否满足“后续”条件是否存在复合操作读-改-写仅靠可见性规则无法保证原子性对象发布路径是否涉及 this 逃逸如果通过线程池交接数据依赖的是队列内部的哪种同步机制它是怎么保证可见性的这套检查的核心价值在于你不用死记八股文条款只需要用“这个读写之间有没有建立关系”的思维模式去推演代码。八条规则就是推演时能用的全部“道具”。实际上我接手过不少并发线上事故最终定位下来绝大多数都可以归因到某个共享变量在两个线程之间的访问链路里存在一段没有被任何 Happens-Before 关系覆盖的空档。找到那个空档问题就解决了百分之八十。7. 最后的实操建议再补充一个非常具体的建议特别适合正在准备秋招或者跳槽面试的朋友。光背规则没用把规则变成自己的推理能力最好的办法是找一个现成的并发 bug 列表逐个用 Happens-Before 关系画一遍链路图。我自己常用的一种练习方式拿到一段并发代码先不运行纯看代码手动找出所有共享变量然后用笔画出每个变量从写线程到读线程的完整路径再在路径上标注每一步有没有建立起 Happens-Before 关系最后把路径里没有任何同步机制覆盖的环节标红——那就是 bug 的潜在爆发点。熟练之后看到任何并发场景你都会有肌肉记忆这不是背规则而是形成了一种“并发安全审查”的思维方式。这个思维方式才是 Happens-Before 整个知识体系最值钱的部分。很多东西你记不住是正常的但有了这个思维你随时能自己推导出来——这比会背八条规则重要得多。
返回列表