JMM这个话题,很多人的第一反应是背happens-before八条规则,背完再写并发程序,该踩的坑一个没少。上一篇我聊了为什么Java需要一个内存模型,讲了CPU缓存、指令重排和语言层抽象这三者之间的关系,不少朋友读完留言说想接着听设计目标这块。那这期就顺着往下聊:JSR-133背后的设计者(Jeremy Manson、Bill Pugh那一批人)在定义JMM时,脑子里到底在想什么,想让Java并发世界变成一个什么样的世界。
如果说上篇是“发现问题”,下篇就是“给出规则”。我不会把规范条文一条条抄给你,而是把JMM设计目标拆成几个维度:可见性怎么被精确锁定、有序性怎么被选择性约束、原子性的边界划在哪、为什么必须给编译器和CPU留足优化空间、以及它怎么跟硬件缓存协议配合。适合谁看?正在啃并发底层的开发者,准备面试问烂了JMM却总觉得自己没吃透的人,还有那些被线上奇怪并发Bug折磨过、想系统建立排查思路的工程师。看完你应该能回答一个问题:JMM为什么长成今天这样,而不是更强或更弱。
1. 先对上篇做个收尾
1.1 上篇结尾遗留的问题
上一篇其实就留下一个核心结论:Java程序跑在不同架构上,x86、ARM、RISC-V,它们的内存模型差别很大。x86的TSO模型相对“好说话”,ARM这类弱内存模型则宽松得多,再加上编译器和JIT随时可能做指令重排,如果语言层面没有一个统一的约定,程序员写的并发代码就是一堆运气游戏。
于是JMM被设计出来,它的定位不是“一台虚拟CPU”,而是一份站在语言层的契约。这份契约回答一个最朴素的问题:两个线程读写同一个变量,JVM到底能给你什么保证、不能给你什么保证。
1.2 下面分五个维度拆JMM想要什么
接下来的内容我打算按五个维度展开:可见性、有序性、原子性边界、编译器优化空间、硬件协同。这五个词在无数面经里出现过,但绝大多数人被问到的时候只能把概念背出来,不知道每一个维度背后都是一次设计取舍。
比如可见性,JMM希望达到的目标不是“让所有读都读到最新值”,因为它知道那样做的代价是不可接受的。有序性也不是“禁止一切重排”,因为CPU和编译器不可能配合。原子性就更微妙了,JMM明确告诉你:基本读写原子性是它管的,i++这种复合操作它不是不管,是故意不管。这些“故意”和“不故意”,就是设计目标的核心。
2. 可见性:把“玄学”变成可推理的契约
2.1 你读到的不是最新值,不是Bug而是设计允许
先看一个最经典的现象:
static boolean flag = false; // 线程A flag = true; // 线程B while (!flag) { // 死循环,flag永远可能看不到更新 }这段代码在无同步的情况下,B线程确实可能永远循环。原因不复杂:线程A写flag,值可能停留在CPU寄存器或者L1缓存里,还没来得及同步到主存;线程B读flag,读到的可能是自己CPU缓存里的旧副本。整个链路里有store buffer、CPU缓存、寄存器分配,任何一个环节都可能让更新“看起来”没发生。
这里我想强调一个认知:这不是JMM的缺陷,恰恰是JMM设计目标里允许的。JMM的哲学是,它不会魔法般让所有并发读写都自动正确,它只给你一条明确的路——按规则建立同步,否则一切皆有可能。
2.2 为什么不做到“每次读写都主存”
有人会问:那JMM干脆规定死了,所有共享变量的读写都直接命中主存,不就不存在缓存不一致了吗?这确实是最简单粗暴的方案,但代价大得离谱。
主存访问延迟大概是L1缓存的五十到一百倍。如果每次读共享变量都从内存拿,每次写共享变量都往内存刷,Java程序性能会退化到没法看,更别提JIT编译器的大量优化直接就废了。这就像快递公司,你当然可以要求每一单都专机闪送,但运费高到没人用得起;现实做法是承诺一个明确的送达窗口,让你知道普通件和加急件各是什么时效。
JMM做的就是这件事:把“可见性保证”绑定到明确的动作上。锁的释放与获取、volatile的写与读、线程的启动与终止,这些动作就是加急通道。你不走通道,那对不起,JMM不承诺任何时效。
2.3 volatile的最小承诺够干什么
volatile在JMM里的语义,是它最出名的部分,也是最容易被人用错的部分。JMM给volatile的承诺是:一个volatile变量的写,happens-before于后续对这个变量的读;也就是说,写完之后,任何线程再读,一定读得到这个新值。
注意这里说的是“单个volatile变量的读写”。它够干什么?够用来做状态标志、发布不可变引用、配合CAS实现无锁队列。不够干什么?不够保证count++这种读改写操作是原子的,也不够让多个volatile变量之间的复合状态保持一致。很多线上事故就出在把volatile当成万金油,这点后面第五部分会展开。
3. happens-before:给并发程序一张因果推理图
3.1 一条偏序关系能解决什么
如果可见性只是零散地绑定到动作上,程序员还是很难推理——A动作后B动作是什么顺序?B动作后C动作呢?于是JMM做了一个极其优雅的设计:定义一条偏序关系,叫happens-before。
它解決了一个核心痛点:把“同步动作”和“程序顺序”统一成一张有向图。只要你能在两个操作之间找到一条happens-before路径,那么前一个操作的结果对后一个操作就是可见的;找不到,两个操作之间就是竞争关系,行为未定义。
偏序的意思很好理解,就像家谱里的祖先关系:你爷爷是你爸的祖先,你爸是你的祖先,所以你爷爷也是你的祖先;但你和一个陌生人之间,没有任何先后关系,那就不承诺。JMM就是这个规则。
3.2 六条基础规则加传递性怎么记
规范的规则清单,我建议这样记,别死背,而是理解每条规则背后对应了什么样的同步原语:
- 程序顺序规则:一个线程内的每个操作happens-before该线程后续任意操作。这是as-if-serial的基础。
- 监视器锁规则:对一个锁的unlock,happens-before后续对这个锁的lock。也就是说,你把锁一放,后面拿到同一把锁的人,能看到你临界区里所有的写。
- volatile变量规则:对一个volatile字段的写,happens-before后续对这个字段的读。这是volatile可见性的本质。
- 线程启动规则:线程对象的start(),happens-before该线程中的任意动作。新线程能看到start之前主线程做的所有事。
- 线程终止规则:线程内的所有动作,happens-before另一个线程在这个线程上调用join()成功返回。主线程join完之后,能看到被join线程写过的所有东西。
- 中断规则:对线程调用interrupt(),happens-before被中断线程检测到中断(抛出InterruptedException或isInterrupted返回true)。
- 传递性:如果A happens-before B,B happens-before C,那么A happens-before C。
这套规则有一个共同特征:它们都对应真实世界中“同步原语完成的那一刻”。锁释放、volatile写、线程启动、join返回,每一个都是边界点。JMM设计目标就是让这些边界点成为推理的锚,而不是让你凭感觉判断“应该看到了吧”。
3.3 DCL和锁:推理代替背诵
拿DCL单例这个最著名的例子看一下怎么用推理替代背诵。早期版本有人写:
class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 可能出问题 } } } return instance; } }在Java 5之前,这个写法其实是错的。问题出在instance = new Singleton()不是一步,而是三步:分配内存、调用构造函数、把引用赋值给instance。编译器和CPU可以重排成:分配内存、引用赋值、再调用构造函数。这时候另一个线程第一次检查发现instance不为null,直接返回,拿到的却是一个构造函数还没执行完的对象。
但是注意,这一切发生在没有建立happens-before关系的情况下。如果给instance加上volatile修饰,JSR-133之后的JMM就保证:volatile写happens-before后续volatile读。A线程对instance的写(包括对象构造的所有内部写)在B线程读到instance时全部可见。这就是一个用happens-before推导出来的安全结论。
锁也是同一套逻辑:你进入synchronized块,获取锁,自动与之前任何线程对同一把锁的释放建立happens-before关系,因此能看到临界区全部写入。理解了这条,你就不用再背“synchronized保证可见性”这种结论了。
4. 有序性:重排序被允许,但有边界
4.1 重排序不只有CPU在干
很多Java开发者一听重排序,第一反应是CPU乱序执行。其实重排序有四个来源:编译器静态重排、JIT动态重排、CPU指令乱序执行、内存系统重排(比如store buffer让写看起来晚了)。四个来源叠加,你写的代码在执行时早就不是原来那个样子了。
JMM设计目标里最容易被误解的一点是:它并不试图消灭重排序,那也不可能。它做的事情是给重排序画一条线:不允许重排序破坏happens-before关系,不允许破坏单线程的as-if-serial语义。
4.2 as-if-serial是JMM的底线条款
as-if-serial是JMM的底线:不管怎么重排,单线程内程序执行结果必须与代码顺序执行的结果一致。这条保证了你在写普通业务逻辑的时候不需要关心底层,编译器再怎么折腾,看起来都像一条一条顺序执行。
到了多线程,JMM把底线再收紧一层:重排不能改变有happens-before关系的两个操作之间的可见性。具体落到编译器和JIT头上,就是一组明确的约束。比如volatile写之前的普通写,不允许重排到volatile写之后;volatile读之后的普通读,不允许重排到volatile读之前。这些约束保证了“同步点前后”的代码不会越过同步点乱飞。
4.3 宽松模型换来的性能红利
为什么JMM不直接采用顺序一致性模型?因为顺序一致性要求所有读写操作都必须和某种全局顺序一致,等于把编译器的手脚绑死。一个没有同步的共享变量循环读取,JIT就没办法把它缓存到寄存器,每次都要发一次内存访问;一个不逃逸的锁对象,JIT也没办法做锁消除。这些优化全是Java性能的重要来源,一旦用强模型,性能账根本算不过来。
JMM选择宽松模型,本质上是把“正确性责任”交给程序员:你按happens-before规则写,编译器在安全区里疯狂优化,两者互不干涉。这也解释了为什么Java并发性能能一路追赶,而很多强一致的语言模型在并发吞吐上反而吃力。
5. 原子性:JMM的边界感
5.1 基本读写原子性:long和double的历史坑
原子性方面,JMM明确负责的是基本读写。int、boolean、引用这些类型的读写,JMM保证是原子的——单次读不会读到一半写的结果。
但long和double有个历史坑。早期JSR-133规范之前,JMM没有强制要求long/double的读写保持原子,因为在32位JVM上,一个64位值可能被拆成两次32位操作。规范修复之后,规则是:volatile的long/double读写一定原子;普通long/double的读写,在主流64位JVM上也是原子的,但规范层面并没有把话说死。所以实践上,共享long/double最好加volatile,或者直接用AtomicLong和VarHandle,别留这个不确定性。
5.2 复合操作为什么被JMM拒之门外
count++这种操作,JMM明确不管。一个i++由读取、加一、写回三部分组成,JMM可以为每一步的读写提供原子性,但绝不给“三步作为一个整体”任何承诺。这正是设计目标的精妙所在:JMM提供的原子性是“单词级”的,组合逻辑的原子性必须靠锁或原子变量。
为什么JMM故意不管复合操作?你可以设想一下,如果JMM规定所有复合操作都隐式原子,那每一次i++背后都要加锁,性能不可控不说,语义也会变得极其复杂。JMM选择做一个“提供原语、不包办组合”的设计,把复合原子性的实现交给工具类库和程序员自己。
5.3 从synchronized到VarHandle的五种模式
Java在原子性工具上的演进,其实在延续JMM设计目标的方向:补足不同级别的内存顺序控制。Java 9引入的VarHandle(JEP 193)提供了五种访问模式:plain、opaque、acquire、release、volatile。前两种偏“裸”,volatile最强。acquire和release则介于中间,对应C++内存模型里的概念,用来精细构建无锁数据结构,让一部分不需要全屏障的场景省掉昂贵的指令。
日常开发中,synchronized仍然是复合操作的主力,它的语义清晰:锁的获取释放自动建立happens-before。AtomicInteger、LongAdder这些类是在synchronized之外的性能选择,底层靠CAS加volatile。理解了JMM的原子性边界,你才能判断什么时候用volatile、什么时候必须上锁、什么时候原子类能救你一把。
6. 与硬件协议的分工:JMM不是虚拟CPU
6.1 缓存一致性协议管到哪一层
写并发程序的人多少都听过MESI这类缓存一致性协议——它保证多个CPU核心的缓存行对同一内存地址的最终一致。听起来好像是硬件已经解决问题了,为什么Java还需要JMM?
因为MESI解决的是“缓存一致性”,不是“程序一致性”。它保证的是单个缓存行的数据最终收敛,但管不了编译器重排、管不了JIT把变量直接放进寄存器、管不了store buffer造成的乱序观感、管不了不同地址操作之间你的观感顺序。拿MESI当免罪牌,是并发排查里最常见的错误认知之一。
6.2 语言模型高于硬件模型的职责
JMM的设计目标在这个维度上很清楚:定义一套不依赖具体CPU的语义标准,由JVM负责把它映射到目标平台的屏障指令。你在代码里写volatile,JMM给你“写后对其他线程可见”的语义;JIT在x86上可能翻译成lock前缀或mfence,在ARM上翻译成dmb,具体指令不同,面向程序员的语义却一致。
这套分工让Java实现了跨平台并发语义统一。x86的TSO强,ARM弱,但Java并发代码在这些平台上的行为由JMM定义,而不是随硬件漂移。这也是“一次编写,到处一致”在并发领域的真正含义。
7. final与安全发布:不可变对象的立法保障
7.1 冻结语义让final真正“冻”住
final字段在JMM里的设计目标,我觉得是整部规范最容易被低估的部分。JSR-133之后,final字段有了一个叫“冻结”的语义:构造函数内对final字段的写入,在构造函数正常结束后会发生一次冻结,此后这些写入不允许被重排序到构造函数之外。
这意味着什么?一个正确构造的不可变对象,它的final字段不会出现“半初始化”“看到默认值”这类情况。JMM想让不可变对象成为并发世界最安全的构件。但注意一个前提:如果你在构造函数里把this引用发布出去了——也就是this逸出——冻结语义就失效了。这是final字段唯一能被破坏的路径。
7.2 安全发布的四种路线的happens-before底牌
不可变对象要给别人用,前提是安全发布。JMM给了四条主流路线,每条都能对应到happens-before链:
- 静态初始化:类加载机制保证类初始化happens-before任何线程使用该类,天然安全。
- volatile发布:volatile写happens-before后续volatile读,发布者和消费者之间有了明确边界。
- 锁保护:锁释放happens-before后续锁获取,比如放入synchronized块或线程安全的容器。
- 并发容器:ConcurrentHashMap这类容器内部已经用volatile、CAS建好了hb链,往里放往外取都安全。
反过来,如果把不可变对象丢进一个普通HashMap,再让另一个线程去读,对象本身final语义再强,也可能看不到或者看到旧数据。对象不可变不代表发布过程安全,这两件事经常被混为一谈。
8. 误判现场与排查实录
8.1 三个我踩过也带别人踩过的坑
第一个误判:给共享变量加上volatile就以为“原子了”。最典型的就是多线程累加器,每个线程对volatile int做++,跑出来结果永远不对。去看字节码就知道,++是getstatic、iconst、add、putstatic四步,volatile管得住每一步的可见性,管不住这四步作为一个整体的连贯性。
第二个误判:单测跑一遍没出问题,就觉得并发是安全的。我见过不只一次,本地单机测一万次没问题,部署到多核服务器立刻复现竞态。原因可能是测试机内存模型强、JIT还没优化到位、线程调度恰好没踩到窗口。并发正确性不能用“跑跑看”来验证。
第三个误判:用了AtomicReference就以为复合状态安全了。AtomicReference只解决“单个引用变量的原子更新”,但如果两个相关变量要保持一致性,你就需要锁或更高阶的抽象,比如用AtomicReference里存一个不可变状态对象。状态不是一个变量,就别指望一个原子类包打天下。
8.2 排查清单:怀疑JMM时按这个查
遇到看起来像并发问题的故障,我会按下面这个清单过一遍:
- 两个线程的读写路径之间,有没有建立happens-before链?找锁、volatile、start、join、并发容器内部同步,任何一条路都行,但必须有一条。
- 有没有可能读到半初始化对象?检查构造器里的this逸出,检查DCL有没有加volatile。
- 操作是不是复合的?++、check-then-act、read-modify-write,是复合操作就得升级到锁或原子工具。
- long/double有没有加volatile?在32位环境下尤其要查。
- 用jcstress这种工具去构造密集竞争验证内存语义,用hsdis看JIT生成的汇编里有没有锁前缀或内存屏障,用JFR观察锁竞争,用JMH量性能损耗。别靠感觉,靠工具。
我个人的体会是,JMM设计目标与其说是教科书里的一章,不如说是Java并发世界的一部立法。它没有试图消灭所有不确定的执行,而是精准划出了一条线:线内,程序员的同步写法得到明确承诺;线外,编译器和CPU的优化尽情发挥。很多年做下来,我越来越觉得,理解这套规则之后,写并发代码的心态会变得很不一样——你把每个同步点都当成一扇明确打开的门,而不是赌运气。下一个值得花时间的方向,是去读一读JSR-133的原始文档,再看一遍VarHandle的API设计,你会发现它们背后的思路是贯通的。