尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

并发 02 · JMM 内存模型

并发 02 · JMM 内存模型 上一篇我们让并发世界的三个幽灵——原子性、可见性、有序性——现了形也埋下一个没回答的问题既然 CPU 有缓存、编译器和处理器还会偷偷重排指令那到底什么时候一个线程的修改能被另一个线程看到“哪些重排序被允许、哪些被禁止”谁说了算如果这套规则因 CPU 而异、因 JVM 实现而异那一次编写、到处运行的 Java 就成了一句空话——同一段并发代码在 x86 上跑对了换到 ARM 上就可能出诡异 bug。答案就是这一篇的主角Java 内存模型Java Memory ModelJMM。它是整个 Java 并发大厦的地基——后面所有的volatile、synchronized、Lock、原子类本质上都是在 JMM 定下的规则里做文章。这也是为什么面试里问 JMM问的从来不是背几个名词而是看你到底理不理解并发的底层契约。很多人对 JMM 的印象停留在主内存、工作内存两块框那只是最表层这一篇要往下钻两层把重排序、happens-before、内存屏障这条真正的主线讲透。和 JVM 系列的分工JVM 系列里讲过 JMM 的概览主内存/工作内存、volatile、synchronized锁升级。这一篇不重复那些科普而是做纵深为什么需要 JMM 这层抽象、重排序到底发生在哪几层、happens-before 八条规则逐条抠、以及它最终怎么靠内存屏障落地。读完这篇你会知道volatile那点魔力具体是几条 CPU 屏障指令换来的。这篇按这条线索展开先讲清 JMM 为什么存在、它是一层什么样的抽象再拆开重排序这件事的三个来源然后引出 JMM 的核心武器happens-before逐条过完它的规则接着钻到最底层看 happens-before 是怎么靠内存屏障变成真实 CPU 指令的最后把volatile/synchronized/final各自给出的承诺用 happens-before 的视角串起来。目录JMM 是什么屏蔽硬件差异的一层抽象重排序三个层次的偷偷调整happens-beforeJMM 给你的可见性承诺八条规则逐条看内存屏障happens-before 怎么落地volatile / synchronized / final 的承诺一、JMM 是什么屏蔽硬件差异的一层抽象先破一个最常见的误解JMM 不是内存的物理布局而是一套规则。它跟 JVM 运行时数据区堆、栈、方法区那些完全是两码事。运行时数据区讲的是对象、变量物理上放在哪块内存而 JMM 压根不关心物理位置它定义的是一件抽象的事——一个线程对共享变量的写在什么条件下、什么时机能对另一个线程可见。一个是内存的地图一个是内存访问的交通规则别混。为什么需要这层抽象因为底层硬件是一团乱麻每个 CPU 核有自己的 L1/L2 缓存写了不一定立刻同步给别人可见性问题的来源不同架构的内存模型强弱不一——x86 是较强的 TSO 模型ARM/PowerPC 是弱内存模型允许的重排序种类多得多编译器含 JIT为了性能会重排指令。如果让 Java 程序员直接面对这些差异那跨平台就废了你得为每种 CPU 写一套并发代码。JMM 的使命就是在千差万别的硬件之上架一层统一的抽象给程序员一个与具体 CPU 无关的承诺只要你按 JMM 的规则写该加volatile加volatile、该同步就同步你的并发语义在任何平台上都一致。至于底层是 x86 还是 ARM、要插几条屏障指令才能兑现这个承诺那是 JVM 和编译器的活与你无关。JMM 是 Java 并发的可移植性契约。为了描述这套规则JMM 定义了一个抽象的内存模型主内存Main Memory所有线程共享存放共享变量对应堆里的对象实例字段、静态变量。工作内存Working Memory每个线程私有存放它用到的共享变量的副本。线程对变量的所有读写都在自己的工作内存里进行不能直接碰主内存。线程 A 想把修改传给线程 B必须走两步A 把新值从工作内存刷回主内存B 再从主内存重新读取到自己的工作内存。这里必须强调一句否则容易学偏工作内存是 JMM 为了讲清规则而虚构的一个抽象概念它并不精确对应某个物理部件。现实中它可能落在 CPU 寄存器、store buffer、各级缓存的组合上。你不用纠结它物理上是什么——记住它想表达的那件事就够了线程之间不能直接互相读写变量只能通过主内存中转而这个中转不是实时的中间的延迟和乱序正是可见性与有序性问题的根。理解了这个抽象才好接着谈JMM 用什么规则约束这个中转。二、重排序三个层次的偷偷调整有序性问题的根是重排序。但重排序不是一个笼统的东西它其实发生在三个不同的层次理清这三层才知道 JMM 到底在管谁。第一层编译器重排序。编译器包括 Java 的 JIT在不改变单线程语义的前提下会调整语句的执行顺序比如把一个循环不变的计算提到循环外、把两条互不依赖的语句交换顺序以减少指令数、提高寄存器利用率。这一层重排序发生在代码变成指令之前。第二层指令级并行重排序CPU 层。现代 CPU 是超标量、乱序执行的它一次取多条指令只要指令之间没有依赖就可能不按程序顺序执行谁的操作数先就绪就先算谁以填满流水线。这一层是处理器执行指令时的乱序。第三层内存系统重排序。这一层最隐蔽。CPU 写变量时往往不是直接写进缓存/主内存而是先扔进一个叫store buffer写缓冲区的队列然后继续执行后面的指令稍后再异步刷出去。这就导致从别的核的视角看你的写生效的时机被推迟了看起来就像写操作和后面的读操作发生了重排序。x86 上唯一被允许的重排序StoreLoad写后读正是它造成的。这三层叠加结果就是你写的代码顺序、和它在多核上真实被别人观察到的顺序可能完全不是一回事。那为什么单线程从来不会因为重排序出错因为有两条底线兜着数据依赖性如果两个操作有依赖后一个用到前一个的结果或都写同一个变量任何层次都不会重排它们。a 1; b a 1;这俩永远不会换序。as-if-serial 语义不管怎么重排单线程程序的执行结果必须和顺序执行一模一样。编译器和 CPU 保证这一点所以单线程程序员完全感知不到重排序的存在。问题就出在as-if-serial 只保护单线程的结果它对多线程之间的可见性不做任何承诺。重排序在单线程里人畜无害一到多线程就可能暴露出半成品状态第 1 篇那个 DCL 单例的坑就是例子。于是需要有人站出来明确规定跨线程时哪些顺序必须被保证——这个人就是 happens-before。三、happens-beforeJMM 给你的可见性承诺happens-before 是 JMM 的核心也是最容易被字面意思带偏的概念。先把最大的坑挑明happens-before 不是时间上先发生而是前一个操作的结果对后一个操作可见且顺序不会被重排。这两者天差地别。“A happens-before B” 表达的是一种可见性 有序性的保证跟这两个操作在物理时间上谁先谁后没有必然关系。举个反直觉的例子两个操作可能在时间上 A 先 B 后但它们之间没有happens-before 关系——那么即便 A 先执行B 也不保证能看到 A 的结果。反过来只要 JMM 规定了 A happens-before B哪怕底层做了各种重排JMM 也必须保证 B 能看到 A 的全部修改。为什么 JMM 要用这么一个绕的概念而不直接说禁止一切重排序因为禁止所有重排序 放弃所有性能优化那 Java 就慢得没法用了。JMM 的高明之处在于它只在你真正需要的地方有 happens-before 关系的地方保证顺序和可见性其余地方一律放开让编译器和 CPU 尽情优化。这是一个够用就好的精妙平衡——既给了程序员可以依赖的确定性又没有把性能一刀切掉。所以写并发代码的思维方式应该是别去想这行代码物理上什么时候执行而要想我依赖的这个可见性有没有一条 happens-before 关系撑着。如果有放心如果没有就得自己用volatile、锁、或其他手段建立一条 happens-before 关系出来。整个并发编程某种意义上就是在编织 happens-before 关系网。那这些 happens-before 关系从哪来JMM 直接内置了一批规则天然成立、不需要你做任何事。下一节逐条看。四、八条规则逐条看JMM 定义了下面这些 happens-before 规则。它们是天然成立的——只要满足条件happens-before 关系就存在你可以直接依赖。① 程序顺序规则Program Order Rule一个线程内部按照程序代码的书写顺序前面的操作 happens-before 后面的操作。——注意这只保证结果看起来像按顺序as-if-serial线程内部实际仍可能重排。它约束的是单线程内的语义。② 监视器锁规则Monitor Lock Rule对一把锁的解锁happens-before 于后续对这把锁的加锁。synchronized(lock){x10;// 写在锁里}// 解锁 —— happens-before ↓// ... 另一个线程 ...synchronized(lock){// 加锁System.out.println(x);// 保证看到 x 10}这条是synchronized能保证可见性的根本原因不光是互斥解锁到加锁之间还架起了一条 happens-before前一个线程在锁里的所有修改对后一个拿到锁的线程全部可见。③ volatile 变量规则Volatile Variable Rule对一个volatile变量的写happens-before 于后续对这个变量的读。——这是volatile保证可见性的根本。而且配合下面的传递性它的威力远不止这一个变量可见见后面的例子。④ 线程启动规则Thread Start RuleThread.start()happens-before 于这个新线程里的任何操作。——所以主线程在start()之前准备好的数据子线程一定看得到不用额外同步。⑤ 线程终止规则Thread Termination Rule一个线程里的所有操作happens-before 于其他线程检测到这个线程已终止比如Thread.join()返回、或isAlive()返回 false。——所以你join()一个线程后它跑出来的结果你一定读得到。⑥ 线程中断规则Thread Interruption Rule对线程interrupt()的调用happens-before 于被中断线程检测到中断isInterrupted()返回 true 或抛InterruptedException。——呼应第 1 篇的协作式中断中断信号的传递是有 happens-before 保证的。⑦ 对象终结规则Finalizer Rule一个对象构造函数的结束happens-before 于它的finalize()方法开始。⑧ 传递性Transitivity如果 A happens-before B且 B happens-before C那么 A happens-before C。——这是最有用的一条前面所有规则都靠它串成链。传递性有多重要看这个经典例子它揭示了volatile一个常被忽略的能力inta0;// 普通变量volatilebooleanflagfalse;// volatile// 线程 Aa1;// ① 普通写flagtrue;// ② volatile 写// 线程 Bif(flag){// ③ volatile 读System.out.println(a);// ④ 这里能看到 a 1 吗}答案是能而且这非常关键。推导链条① happens-before ②程序顺序规则→ ② happens-before ③volatile 变量规则→ ③ happens-before ④程序顺序规则由传递性① happens-before ④。于是线程 B 读到flag true时一定也能看到a 1——哪怕a只是个普通变量这就是重点volatile不只保证它自己那个变量可见它还像一道栅栏把它之前的所有普通写都带过去了。这个特性正是很多无锁编程和框架比如用一个volatile标志位发布一整个对象的基石。理解了传递性你才算真正理解volatile。五、内存屏障happens-before 怎么落地happens-before 是 JMM 给的承诺但承诺得靠东西兑现。到了最底层兑现它的就是——内存屏障Memory Barrier / Fence。这是 CPU 提供的一类特殊指令作用是禁止特定类型的重排序并强制刷新/失效缓存。JMM 规定的每一条 happens-before最终都会被编译器翻译成在恰当位置插入恰当的屏障。JSR-133 把内存屏障抽象成四类按前操作类型 后操作类型命名理解了这四个名字就理解了全部屏障类型语义前; 屏障; 后作用LoadLoadLoad1; LoadLoad; Load2保证 Load1 的读先于 Load2 完成StoreStoreStore1; StoreStore; Store2保证 Store1 的写先刷出、对别人可见才轮到 Store2LoadStoreLoad1; LoadStore; Store2保证 Load1 的读先于 Store2 的写完成StoreLoadStore1; StoreLoad; Load2保证 Store1 的写对所有处理器可见才执行 Load2其中StoreLoad 是全能屏障也是最贵的一个——它同时具备其余三种的效果会强制把 store buffer 排空、让写真正对所有核可见。它昂贵是因为它要求 CPU 真的等写落地代价最高。记住StoreLoad 最贵这一点就能理解为什么volatile写比volatile读更耗性能。那volatile具体是怎么用这四类屏障实现的JMM 采用一套保守策略在volatile读写前后插入屏障volatile写前面插StoreStore保证前面的普通写都先刷出去——这正是上一节传递性能成立的底层原因后面插StoreLoad保证这次写对后续所有读可见。volatile读后面插LoadLoad和LoadStore保证后面的读写不会被重排到这次读之前。画成图volatile变量就像被四道栅栏围起来重排序的指令谁也别想越界一个很实际的点屏障不是到处都真的插。上面是 JMM 的保守策略实际由 JIT 结合具体 CPU 架构优化——比如x86 本身是强内存模型只允许 StoreLoad 重排序所以在 x86 上volatile读几乎是免费的不需要额外屏障指令只有volatile写需要一条带lock前缀的指令来充当 StoreLoad 屏障。这也解释了一个现象同样的volatile代码在 x86 上开销小换到 ARM 这种弱内存模型的机器上屏障指令就实打实多起来了。JMM 的抽象值就值在这——同一套语义各平台各自用最省的方式兑现。六、volatile / synchronized / final 的承诺把前面串起来就能用统一的 happens-before 视角看清 Java 三个最基础的关键字各自给了什么承诺。它们的细节留给后面几篇这里先建立整体地图。volatile——可见性 禁重排但不保证原子性。保证可见性volatile 写 hb volatile 读保证有序性前后插屏障禁止重排不保证原子性volatile int i; i;依然是坏的——因为i是读-改-写三步volatile只保证每一步读到的是最新值管不住三步之间被插队。这是volatile最大的误区务必记牢。治原子性得靠下一层的 CAS 和锁。synchronized——三性全包靠互斥。原子性同一时刻只有一个线程进临界区临界区里的操作对别的线程而言就是原子的可见性解锁 hb 加锁规则②进临界区看得到上一个持有者的全部修改有序性临界区串行执行跨临界区不会乱。代价是互斥带来的阻塞与上下文切换所以有了后面的锁升级第 3 篇来优化轻度竞争。final——一次安全发布的承诺。这个最容易被忽略但很重要JMM 对final字段有专门规定——只要对象的构造函数正确结束没有 this 引用逸出那么其他线程看到这个对象引用时一定能看到它final字段被正确初始化后的值无需额外同步。这正是不可变对象如String天生线程安全、可以随便共享的底层依据第 7 篇细讲。一张表收口三者的承诺关键字原子性可见性有序性靠什么volatile✗✓✓内存屏障synchronized✓✓✓互斥 锁的 hb 规则final—发布安全✓构造完成后✓构造期屏障这一篇我们钻到了并发的最底层。回头看这条链子会很清晰硬件千差万别、还会三层重排序 → 所以需要 JMM 架一层可移植的抽象 → JMM 用 happens-before 精准地只在需要处保证可见性与有序性 → happens-before 最终靠四类内存屏障在 CPU 上兑现。上层所有并发工具都是在这条链子上工作。带走三句话就够① happens-before 讲的是可见性保证不是时间先后② 写并发的本质是想清楚我依赖的可见性有没有 happens-before 撑着没有就自己建一条③volatile靠屏障、synchronized靠互斥、final靠构造期保证各有各的承诺别张冠李戴尤其别指望volatile保原子性。地基打好了。下一篇我们从这层抽象落到最常用的两个关键字——volatile与synchronized以 JDK 8 的经典实现为主线看看偏向锁、轻量级锁和重量级锁如何配合并补充现代 JDK 已移除偏向锁的版本变化。
返回列表