首页 / 资讯中心 / 文章详情

Java内存模型与happens-before:一文读懂并发可见性核心

Java内存模型与happens-before:一文读懂并发可见性核心 ★ FEATURED ARTICLE
1. 一个排查了一整天的Bug标志位明明变了线程却停不下来先说我几年前在项目里实际踩过的一个坑。当时有个后台轮询线程用一个boolean标志位控制退出代码长这样static boolean running true; // 线程A工作循环 new Thread(() - { while (running) { // 做一些轮询处理 } System.out.println(正常退出); }).start(); // 线程B两秒后叫停 Thread.sleep(2000); running false;代码简单到不能再简单逻辑上也没毛病但线上表现就是线程A永远不打印“正常退出”。我一开始以为死循环了加了一堆日志后发现线程A读到的running始终是true——可主线程明明已经把它改成false了。这就是典型的内存可见性问题。JVM不是骗了你而是它压根没向你承诺过一个线程修改了变量另一个线程下一秒就能看到。在Java内存模型JMMJava Memory Model的框架里“另一个线程什么时候能看到我的写入”这件事不靠时间顺序决定而是靠一套叫happens-before规则的机制来保证。这也是几乎所有Java并发面试题绕不开的核心。这篇文章我就把这套规则一次性讲透。不光是列出那8条更重要的是讲清楚它到底在解决什么问题、每条规则背后的执行原理是什么、实际代码里哪些地方正在悄悄依赖它。无论你是准备面试还是被线上的并发问题折磨过这篇文章都值得静下心来读完。2. 先把底层铺平JMM的工作内存模型与编译器悄悄做的重排序要理解 happens-before先得理解JMM到底在管什么。它本质上回答三个问题多线程环境下一个线程对共享变量的修改什么时候对其他线程可见指令重排序能做到什么程度什么样的访问顺序是合法的2.1 主内存与工作内存JMM对内存的抽象JMM规定所有共享变量都存在主内存里每个线程还有自己的一份工作内存可以理解为CPU寄存器、各级缓存和本地内存的抽象。线程对变量的所有读写操作都必须先在工作内存中进行然后再同步回主内存不能直接操作主内存中的变量。也就是说线程A改了running false它先改的是自己工作内存里的副本至于这个副本什么时候刷回主内存JMM没有硬性时间表。线程B去读running它读的也是自己工作内存里的副本这个副本多久从主内存更新一次也没有硬性约定。两个线程如果没有同步机制各自的副本可能就是两份“各自为政”的数据。这就像两个人在两个房间里各自抄一份黑板上的数字A把自己的那份改成0B手里的那份还是1只要没人去同步黑板和数据拷贝两人就永远各说各话。volatile、synchronized这些关键字本质就是用来规定“什么时候必须同步”的规则。2.2 三种重排序编译器、CPU流水线、内存系统除了可见性还有一道坎是重排序。为了优化性能编译器和CPU会悄悄改变指令的执行顺序编译器优化重排编译Java源码为字节码再编译为机器码时编译器可以在不影响单线程语义的前提下调整指令顺序。CPU流水线级重排现代CPU采用多级流水线、分支预测、乱序执行在硬件层面就可能让某些指令提前或延后执行。内存系统重排即使CPU按顺序执行指令缓存一致性协议和写缓冲区的存在也会让其他核心观察到的访存顺序与真实执行顺序不一致。这三种重排序叠加起来你写的代码顺序和另一个线程实际观察到的操作顺序完全可能是两码事。2.3 as-if-serial重排序的边界到底在哪这里必须澄清一个概念重排序不是无限制的。编译器保证单个线程内的执行结果不会被重排序改变这就是as-if-serial语义——不管怎么排单线程跑出来的结果必须和按源码顺序跑出来的结果一致。但“单线程语义一致”只约束了当前线程自己它约束不了另一个线程观察到的中间状态。多线程环境下A线程重排序后的操作顺序对B线程是完全“可见”的如果两份顺序对不上Bug就来了。happens-before规则的核心目标就是在“允许重排序”和“限制重排序对多线程可见性造成破坏”之间划定边界。3. happens-before不是一个命令而是一份“可见性契约”8条规则逐条拆解3.1 先给规则一个严格的定义Java语言规范JLS §17.4.5对happens-before的定义是如果操作A happens-before 操作B那么A的执行结果对B是可见的并且A的执行顺序在B之前。重点在两个字可见。happens-before描述的不是时间上的先后顺序而是内存可见性的保证契约。A先执行、A的结果B能看到这两件事它都管到。如果两个操作之间没有happens-before关系JVM就可以随意重排它们B看到的数据状态也就完全不可控。3.2 程序次序规则与管程锁规则最基础的两条程序次序规则在一个线程内按照控制流顺序写在前面的操作先行发生于写在后面的操作。这里有个细节容易忽略规范说的是“控制流顺序”不是“代码书写顺序”。if、while、for这些分支和循环会影响实际执行路径所以按“代码物理顺序”理解这条规则是错的按“实际执行路径顺序”理解才对。管程锁规则一个unlock操作先行发生于后面对同一个锁的lock操作。“同一个锁”三个字是精髓锁不是同一把这条规则就不成立。最典型的就是synchronizedint shared 0; synchronized (lock) { shared 42; // 线程A在锁内写入 } // 线程B执行 synchronized (lock) { int x shared; // 同一把锁保护下的读取 }线程B如果拿到了同一把锁就一定能看到线程A写入的shared 42。原因在于Java的对象头里记录了锁状态monitorenter和monitorexit指令会配合内存屏障保证临界区内的写入在释放锁前刷回主内存。ReentrantLock也遵循这条规则只不过它的底层是volatile的AQS状态变量我在第5章会展开。3.3 volatile变量规则与传递性最容易被误解的一条volatile变量规则对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作。注意它的措辞不要求必须是同一个线程。线程A写了一个volatile变量线程B之后读到了这个变量的值那么线程A在写入之前做的所有操作对线程B都是可见的。volatile boolean ready false; int answer 0; // 线程A answer 42; ready true; // 线程B if (ready) { // 这里一定能看到 answer 42 }传递性如果A happens-before B且B happens-before C那么A happens-before C。把前面三个规则串起来线程A写入answer普通变量发生在写入readyvolatile变量之前线程B读到ready之后再去读answer通过传递性线程A的answer 42对线程B就是可见的。为什么能实现这个效果因为volatile写入前后会插入内存屏障直接把重排序的边界卡死我第4章会讲。3.4 线程的启动、终止与中断三兄弟这三条规则专门处理线程本身的生命周期事件线程启动规则线程A调用ThreadB.start()那么A在启动B之前的所有操作对B都可见。线程终止规则被终止线程的所有操作先行发生于检测到该线程终止的线程。检测方式包括Thread.join()返回、Thread.isAlive()返回false。线程中断规则调用线程A的interrupt()方法先行发生于线程A的代码检测到中断事件通过抛出InterruptedException或调用Thread.interrupted()/isInterrupted()检测到。这三条在工程里非常常用。比如Thread.join()之所以能安全地读取被等待线程的计算结果依赖的就是线程终止规则任务的取消和超时机制依赖的是中断规则。3.5 对象终结规则很多人没注意到的一条对象终结规则一个对象的初始化完成构造方法正常返回先行发生于它的finalize()方法的开始。这条规则保证finalize()方法里能安全地读取对象构造过程中设置的所有字段值。不过现在finalize()基本已经被Cleaner和try-with-resources取代了你就知道有这条规则存在就行面试时提一嘴能加分但实际开发中几乎用不到。4. 规则不是口号volatile与synchronized在字节码和CPU层面到底做了什么光背8条规则是不够的。面试官真正想听的是你有没有理解“规则是怎么被物理实现的”。没有内存屏障兜底上面的所有规则都只是空头支票。4.1 volatile读写背后的内存屏障先说volatile。它是通过内存屏障来实现可见性和有序性保证的。编译器在生成指令序列时会在volatile操作前后插入特定类型的屏障屏障类型作用典型插入位置LoadLoad屏障阻止后面的读操作越过前面的读操作volatile读之后StoreStore屏障阻止前面的写操作越过后面的写操作volatile写之前LoadStore屏障阻止后面的写操作越过前面的读操作volatile读之后StoreLoad屏障最强的屏障阻止前面的写与后面的读重排volatile写之后开销最大把这套机制翻译成人话就是volatile写的时候之前的普通写入不能重排到后面去写完还要立刻刷回主内存volatile读的时候读到的是主内存里的最新值且之后的普通读写不能重排到它前面来。一个经典的面试追问是“volatile能保证原子性吗”答案是不能。volatile修饰的count依然不是原子的它只保证了一件事——每次读到的不是旧值但“读-改-写”的完整过程并没有加锁。4.2 synchronized临界区如何“锁住可见性”synchronized的可见性保证分两部分进入临界区时线程需要重新从主内存加载变量相当于执行了LoadLoad / LoadStore屏障退出临界区时把工作内存里的修改全部刷回主内存相当于执行了StoreStore屏障。这和管程锁规则正好对上解锁线程把工作内存里的脏数据刷回主内存加锁线程进入时重新从主内存拉取两条线一对接前一个临界区里的写入对后一个临界区就完全可见了。还有一个常见的误区synchronized的可见性不只在“同一个锁保护的代码块内部”成立而是对后续所有拿到同一把锁的代码都成立。只要锁没换前一个线程在锁内写的任何共享变量后一个线程在锁内都能看到。4.3 final字段的初始化安全性final字段也有自己的一套可见性故事。final修饰的实例字段有一点特殊待遇只要在构造函数里正确初始化并且没有发生逸出引用在构造过程中被其他线程拿到那么其他线程读取这个final字段时不需要同步也能看到正确的值JMM会通过写屏障保证构造后的发布是安全的。但这里有个大坑final保证的“安全发布”只针对字段本身的值。如果final字段指向一个可变对象比如final ListString list new ArrayList()那么list引用是安全发布的但如果另一个线程在构造之后修改了list的内容其他线程看到这个修改同样需要同步。final背不动“内容线程安全”的锅。5. 从规则到工程哪些并发代码一起手就在依赖happens-before大部分人学这套规则时都觉得抽象是因为没把它跟实际代码对上号。这一章我列出三个典型场景看完你会发现你每天写的并发代码里处处都是happens-before在暗中兜底。5.1 双重检查锁为什么多一个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; } }这里instance必须用volatile。原因在于new Singleton()在底层大致拆成三步分配内存、调用构造方法、把引用赋值给instance。如果不加volatile编译器或CPU完全可能把“赋值引用”重排到“调用构造方法”前面。当线程A执行到一半时线程B看到instance不是null直接返回了一个构造还没执行完的“半个对象”。加了volatile之后volatile变量写之前的普通操作不能重排到写之后就堵死了这条发布路径。这段代码里的happens-before关系链是线程A的构造操作 - volatile写instance赋值- 线程B的volatile读判空- 线程B后续对实例的使用由volatile规则加传递性串起来。没有volatile整条链默认不存在可见性保证。5.2 线程池、AQS与并发容器如何消费这份契约很多你每天都在用的Java并发组件底层就是靠这些规则堆出来的AQSAbstractQueuedSynchronizer它的state字段是volatile int。tryLock是否成功、signal唤醒后能不能看到前驱线程的修改都依赖对state的volatile读写建立happens-before关系。ConcurrentHashMapJDK 8里大量使用volatile修饰Node数组和链表节点的next指针配合CAS操作实现无锁读size()、get()能看到最新状态靠的是volatile规则。ThreadPoolExecutor工作线程的ctl字段用volatile int把线程池状态和线程数打包在一个变量里。你调用shutdown()后工作线程能感知到靠的就是volatile写读。可以说整个java.util.concurrent包的骨架就是建在happens-before规则这张契约上的。理解了规则你等于拿到了读这些框架源码的钥匙。5.3 用happens-before反推线上问题实战排查并发问题时的思路应该反过来用看到一个共享变量在多个线程间传递先问自己这两次读写之间有没有建立任何一条happens-before关系链路我之前排查过一个数据不一致问题A线程写了一个HashMap然后通过ExecutorService提交任务给B线程处理B线程读取时偶尔读到旧数据。原因就是任务提交环节虽然内部有自己的同步但任务对象里的HashMap引用是普通字段提交动作和读取动作之间的happens-before关系依赖的是线程启动规则而ExecutorService的任务提交并不保证把之前线程的所有写入都对消费者线程可见。后来把那个字段改成volatile问题立刻消失。当你有了“构建happens-before关系链”的习惯很多玄学并发问题都会变成可推理的工程问题。6. 面试官问“什么是happens-before”怎么答才能从八股文里跳出来6.1 先背顺规则再说透本质面试中这个问题的高频度几乎和“HashMap原理”并列。回答的结构我建议分三层第一层一句话概括本质happens-before是Java内存模型定义的、用于判断两个操作之间内存可见性的偏序关系它不保证时间先后但保证结果可见。第二层背出8条规则按类别记忆更快程序次序、管程锁、volatile变量、线程启动、线程终止、线程中断、对象终结、传递性。表达时不需要死背原文用自己的话说出核心即可。第三层讲落地机制解释这8条规则为什么能成立——内存屏障、临界区的flush/reload、以及CPU缓存一致性协议在底层的配合。这一层是最能拉开差距的部分90%的候选人只答到第二层就停了。6.2 最容易暴露底子的三个理论误区面试里见过太多人在这三个地方翻车第一个误区把happens-before当成时间顺序。A happens-before B不等于A一定在时钟上先于B发生它只保证A的结果对B可见。打个比方你写完一封信放进邮箱不代表邮差一定按你写的顺序派送但收信人最终看到的一定是完整的内容。第二个误区认为volatile能保证原子性。这在第4章已经强调过了volatile int i的i在并发下照样丢失更新。第三个误区以为单条规则就能覆盖所有场景。实际定义里强调“如果A是普通写、B是普通读两者无happens-before关系那么B可能永远看不到A的写入”。并发编程中真正要做的是刻意通过锁、volatile、并发容器等工具把一段代码里的所有关键读写串成一条完整的happens-before关系链。6.3 一套能落地验证的练习思路纸上得来终觉浅。我建议你动手跑几个小实验来加深理解不加任何同步写一个第1章那样的标志位循环观察它是否真的“停不下来”在部分高并发场景下会很明显也有的机器缓存策略下能侥幸通过反复变异地跑更能体会问题本质。把标志位改成volatile对比前后差异。写一个DCL单例把volatile去掉后用一个大量并发访问的测试类模拟配合-server模式和禁掉偏斜锁参数复现拿到半初始化对象的过程。用Thread.join()验证线程终止规则让子线程计算结果主线程join()之后再读看看是否稳定可见。这些实验做完你对这套规则的体感会完全不一样。毕竟面试题背得住但只有真正动手踩过才知道为什么Java需要这套“契约”来约束CPU的积极性。
阅读完成 · 觉得有帮助?
咨询建站