尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java内存模型详解:理解并发编程基础

Java内存模型详解:理解并发编程基础 你写下的每一行并发代码最终都会变成一堆无序的指令、缓存的脏读和线程间的互相踩踏。这不是危言耸听而是Java内存模型JMM试图驯服的野兽。JMM不是一份“建议”而是一套硬性的内存访问协议它规定了线程如何以及何时可以看到其他线程写入的值以及如何保证操作的原子性。如果你不理解它那么你写的volatile、synchronized、Lock都只是碰运气的咒语而不是有依据的工程决策。从一道“必死”的面试题说起很多人在面试时都被问过“两个线程同时对一个int变量做i最终结果是多少”直觉告诉你可能小于20000但真正的原因是在JMM层面i不是一件原子操作它被拆解成“读取-修改-写入”三步。更隐蔽的是每个线程都有自己的工作内存寄存器或缓存线程A修改了i线程B可能根本看不见因为它读取的是自己工作内存中的副本。Java内存模型的核心挑战就是处理线程间共享变量的可见性、有序性和原子性带来的“内存一致性错误”。它不关心你用的是哪款CPU、哪片RAM它只规定了一套抽象规则让Java程序在所有平台上有一致的行为基线。JMM将内存分为主内存所有线程共享和工作内存线程私有线程对变量的所有操作都必须在工作内存中进行不能直接读写主内存且线程间无法直接访问对方的工作内存。这就是“共享变量”在物理层面的真相共享只是假象隔离才是常态。工作内存每个线程心中的“哈姆雷特”工作内存是JMM的一个纯抽象概念它对应CPU的寄存器、多级缓存以及编译器可能做的指令重排。每个线程在启动时会从主内存拷贝一份共享变量的快照到自己的工作内存。此后线程修改变量修改的是快照线程读取变量读的也是快照。只有在特定的“同步”动作发生时快照才会与主内存进行刷新或回写。这意味着你程序里的一个boolean标志在另一个线程眼里可能“永远都是false”直到某个内存屏障被跨越。举一个经典的死循环例子主线程设置running false但子线程的while(running)就是停不下来。不一定是你的逻辑错了而是子线程的工作内存里running的副本根本没有被更新。这就是可见性问题的根源。JMM要求如果一个变量被多个线程共享且至少一个线程写入就必须通过同步机制来建立“先行发生”关系否则程序的结果是未定义的——不是“可能出错”而是“可以出任何错”。volatile最廉价的“内存屏障”volatile是解决可见性和有序性最简单的手段但它的能力边界常常被误解。volatile保证的是“读到的永远是最新值”而不是“操作是原子的”。它对long和double类型的读写也保证原子性在64位JVM上普通long读写并非原子但volatile变量的i依然是线程不安全的因为“读取最新值”和“写回新值”之间依然存在时间窗口其他线程可能插入修改。volatile的真正底层机制是内存屏障。当一个volatile变量被写入时JMM会插入一个StoreStore屏障屏障之前的普通写操作全部刷新到主内存和一个StoreLoad屏障屏障之后的读操作不能乱序到屏障之前。当一个volatile变量被读取时会插入LoadLoad和LoadStore屏障迫使当前线程的工作内存失效直接从主内存重新加载副本。因此volatile实际上构建了一个“半同步”通道写线程发布的值能立即被读线程看到但通道两侧的普通变量操作却被强行限制了重排。这引出一个重要的设计模式“发布不可变状态”。你可以用volatile来安全地发布一个线程安全的容器比如volatile ListT list只要list的更新操作是原子的例如替换整个引用那么读线程不需要额外加锁就能看到最新的list。但如果你尝试用volatile来保护“累加计算”那就是拿勺子去挖煤炭——注定崩溃。synchronized上升到监视器锁的“全排序”volatile管不了原子性那么原子性靠什么JMM给了两条硬路synchronized和Lock。synchronized基于监视器锁Monitor实现它在字节码层面对应monitorenter和monitorexit指令。当线程进入同步块时它会强制从主内存中读取所有共享变量的最新值退出同步块时强制将工作内存中修改的变量全部刷新到主内存。这等于把一个线程的“私人世界”公开给下一个获得锁的线程。因此synchronized不仅提供了互斥还提供了完整的“先行发生”保证同一个锁的解锁操作先行发生于后续的加锁操作。这是JMM中最重要的一条规则它意味着只要你在锁内修改了共享变量下一个拿到锁的线程必定能看到。反过来如果你在锁外修改了变量其他线程即使在锁内也未必能看到——因为锁外没有内存屏障。很多性能优化者喜欢把volatile和synchronized混用比如用volatile控制状态用synchronized保护临界区只要遵循“锁内写、锁内读”或“volatile写、volatile读”的对应关系就能构成正确的同步。有序性重排的“魔鬼”与happens-before“上帝”处理器为了优化流水线会对指令进行乱序执行编译器也会为了寄存器分配和循环优化而重排代码。单线程内重排不会改变程序结果这叫as-if-serial语义但多线程下重排会彻底颠覆你的执行意图。JMM的有序性规则正是通过happens-before关系来约束重排的边界。happens-before不是时间先后而是因果可见性。如果操作Ahappens-before操作B那么A的结果对B可见且A的执行顺序排在B之前。JMM定义了八条基础规则程序次序规则单线程内按代码顺序、监视器锁规则、volatile变量规则、线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性。这八条规则就是并发程序的“十诫”违反任何隐含的因果关系就会堕入数据竞争的深渊。一个典型的反模式是“双重检查锁定”DCL未加volatile。你可能会写class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }由于new Singleton()在底层不是原子操作它包含“分配内存、初始化对象、将引用赋值给instance”三步。编译器或CPU可能将“分配内存”和“引用赋值”重排导致另一个线程在第一个线程尚未执行“初始化对象”时就看到instance不为空从而拿到一个“半初始化”的对象。修复方式就是给instance加上volatile因为volatile的写操作会建立StoreStore屏障确保“初始化对象”在“引用赋值”之前完成。重排可以乱飞但happens-before规则是它飞不过的墙。原子性不只是iJMM保证的原子性基础层面是“对int、long、double等基本类型的变量的单个读写是原子的”——但请注意long和double在32位JVM上可能被拆成两次32位写因此volatile long是一种保险。真正的复合操作比如“读-改-写”、“检查-然后-执行”JMM不提供任何免费的原子性。原子性不是内存模型的恩赐而是同步机制的结果。你可以用AtomicInteger、LongAdder等基于CAS比较并交换的原子类它们底层依赖Unsafe的硬件原子指令。CAS本身是原子的但CAS失败后如何重试、如何优雅地处理ABA问题恰恰考验的是你对“可见性”和“顺序性”的综合把控。很多并发bug并非原子性失效而是“原子操作非原子保护”的混合使用比如先用AtomicBoolean判断再放心地操作普通变量结果AtomicBoolean的值变了但相关数据还没同步过来。对于需要多变量原子更新的场景JMM没有魔法只能靠Lock或synchronized来构造临界区。记住原子性的最终依据是你要么把整个操作装进锁里要么依赖一个原子类保证单个变量的同步但不能中途换道。final的“冻结”语义final在JMM中有特殊地位它涉及到对象的“安全发布”。JMM规定在构造函数内对final字段的写入与随后该对象的构造完成即引用被其他线程看到之间存在happens-before关系。更准确地说final字段的“冻结”发生在构造函数退出时之后任何线程都无法看到“半初始化”的final字段即使没有使用volatile或同步。原因在于JMM为final字段提供了“写入屏障”的语义——编译器会保证构造函数内对final字段的写入不会被重排到构造函数之外且其他线程读取final字段时会读到其“冻结”后的值。这就是“不可变对象不需要同步”的理论基础。但要注意final只保证字段引用本身的安全发布不保证字段指向的对象内部可变状态的发布。比如class Holder { final int[] array {1,2,3}; }如果你在构造函数外部修改array[0]的值那就不在final保护范围内。安全发布不可变对象的标准姿势是所有字段都是final对象本身不可变并且构造完成后不要修改任何状态。内存屏障的具体形态与“底层实锤”JMM不是空中楼阁它在x86、ARM、RISC-V等不同架构上有不同的物理实现。x86拥有强大的硬件一致性TSO所以JMM在x86上需要的屏障数量很少而ARM等弱内存模型架构则要插入更多显式的DMB/DSB屏障。这些屏障包括LoadLoad、LoadStore、StoreStore、StoreLoad四种。StoreLoad是最强的它同时清空写缓冲和失效队列成本也最高。底层实现揭示了一个真相volatile的成本在不同服务器上差异巨大但volatile与synchronized的取舍永远不能只看理论复杂度还得考虑锁的竞争程度。在低竞争下重量级锁可能被偏向锁和轻量级锁优化而volatile的StoreLoad屏障在x86上可能导致整个流水线停顿。因此写出正确的并发代码只是第一步理解内存屏障才能写出真正高性能的并发代码。数据竞争与“安全发布”如果一个变量被多个线程访问且至少有一个线程写而它们之间没有happens-before关系那么这就是数据竞争。数据竞争下的行为在JMM中是“未定义”的——严格来说JMM允许JVM做出任何后果包括读到一个完全不可能的值比如int的-1甚至某些字节的混合脏数据。数据竞争是并发编程中的“未定义行为”它比普通的竞态条件更可怕因为连崩溃都不是必然的。安全发布是避免数据竞争的关键。常见的安全发布方式有四种通过volatile字段、通过synchronized块、通过静态初始化块类加载是线程安全的、通过ConcurrentHashMap等并发容器。安全发布的本质是让“发布引用”这个动作与“写内部状态”之间建立happens-before关系。从JMM到“可见性设计哲学”如果你已经理解了JMM的所有规则还是会发现一个更哲学的问题并发编程的目标不是让所有线程看到同一个“主内存”而是保证“当线程需要时它能看到它应该看到的值”。这就像分布式系统里的最终一致性——JMM允许中间状态不一致只要最终能通过同步动作收敛。这种“异步容忍”的设计实际上是性能与正确性之间的精妙妥协。比如ConcurrentHashMap的实现中节点数组用volatile修饰节点的val和next字段用volatile而它的size()是非精确的。它并不试图提供强一致的快照而是利用JMM的规则在“最终一致”和“高吞吐”之间取得平衡。如果你追求强一致就会失去并发度如果你追求并发度就必须接受延迟可见。选择用哪种同步机制本质上是在“正确性语义”和“性能成本”之间画一条水线。实战如何用JMM思维调试并发bug当你遇到一个奇怪的并发问题不要先怀疑你用了哪个API而是先问自己四个问题这个变量是否被多个线程读写读写之间是否建立了happens-before关系涉及的操作是否是复合操作有没有可能发生指令重排这四个问题几乎能过滤掉90%的并发bug。一个常见的错误是“用Thread.sleep来等待其他线程执行”。sleep不提供任何内存语义它不会刷新工作内存也不会建立任何同步关系。依靠Thread.sleep来保证可见性是并发编程中最脆弱的迷信。真正可靠的方式是使用CountDownLatch、CyclicBarrier或Future.get()这些工具在底层都提供了内存屏障和happens-before保证。此外Thread.join()也有很强的规则join方法的执行与线程的终止动作之间存在happens-before关系即线程内部所有写操作对join返回后的主线程可见。重排的极端案例写缓冲与内存屏障的战争我们来做一个更底层的实验。假设有两个线程// 线程A: X 1; // 普通写 V true; // volatile写 // 线程B: if (V) { // volatile读 int a X; // 普通读 }由于V是volatileJMM保证线程B看到V为true时也一定能看到X1因为volatile写之前的所有普通写都先于volatile写刷新到主内存volatile读之后的所有普通读都从主内存读取。这被称为“volatile的写-读语义”它天然提供了对普通变量的“边界同步”。但反过来说如果线程B先写X再读V而线程A在读V那么即使V的值是true线程A也不一定能看到线程B写的X因为线程B的X...发生在volatile写之前它不会自动被volatile读“连带”。所以volatile是一条单向的“时间流”写屏障让之前的写变可见读屏障让之后的读变最新。你必须在正确的方向上安排你的变量访问。JMM与JVM实现的“优化战争”JMM不仅定义了规则还定义了编译器可以进行的优化空间。JSR-133Java内存模型规范允许编译器在不违反规则的前提下尽可能进行激进的重排和缓存。这意味着你的代码最终执行的指令顺序可能跟源代码完全不同。这既是性能的福音也是调试的噩梦。作为开发者你需要始终记住你面对的是一个“有纪律的乱序处理器”纪律就是happens-before。JVM实际上还会做一些“锁粗化”和“锁消除”的优化。锁粗化把多个同步块合并成一个锁消除则在逃逸分析发现锁对象不会逃逸出线程时直接去掉锁。这些优化不改变语义但改变了性能特征。理解JMM能帮助你预测哪些代码会被优化成什么样子以及为什么有些代码在高并发下表现异常。终极结论JMM是契约不是实现很多人问“JMM到底有什么用反正我写业务代码也用不到内存屏障。”这就像说“我不会算数也能买菜”——是的但你会被找错零钱。JMM是Java并发库的基石ConcurrentHashMap、AQS、CompletableFuture的底层都依赖JMM的保证。当你调用lock.lock()时你实际上是购买了一份“内存可见性契约”而这份契约的条款就是JMM。正确的并发编程方法不是记住某个API的用法而是先画出你的共享变量依赖图然后找到每一条跨线程的访问路径确认每一条路径上都有一座“happens-before”的桥。如果没有就主动加上volatile、synchronized或Lock。只有做到这一步你的并发代码才不是在一堆不确定性的边缘跳舞而是在JMM的规则框架内精准地编排线程间的有序协作。JMM不会保证你的程序不出bug它只保证如果出了bug你可以从规则中找到原因而不是从运气中寻找答案。
返回列表