ARTICLE DETAIL

资讯详情

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

JMM设计目标解析:Java内存模型的契约与并发安全边界

JMM设计目标解析:Java内存模型的契约与并发安全边界 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的基础。监视器锁规则对一个锁的unlockhappens-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 BB 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引入的VarHandleJEP 193提供了五种访问模式plain、opaque、acquire、release、volatile。前两种偏“裸”volatile最强。acquire和release则介于中间对应C内存模型里的概念用来精细构建无锁数据结构让一部分不需要全屏障的场景省掉昂贵的指令。日常开发中synchronized仍然是复合操作的主力它的语义清晰锁的获取释放自动建立happens-before。AtomicInteger、LongAdder这些类是在synchronized之外的性能选择底层靠CAS加volatile。理解了JMM的原子性边界你才能判断什么时候用volatile、什么时候必须上锁、什么时候原子类能救你一把。6. 与硬件协议的分工JMM不是虚拟CPU6.1 缓存一致性协议管到哪一层写并发程序的人多少都听过MESI这类缓存一致性协议——它保证多个CPU核心的缓存行对同一内存地址的最终一致。听起来好像是硬件已经解决问题了为什么Java还需要JMM因为MESI解决的是“缓存一致性”不是“程序一致性”。它保证的是单个缓存行的数据最终收敛但管不了编译器重排、管不了JIT把变量直接放进寄存器、管不了store buffer造成的乱序观感、管不了不同地址操作之间你的观感顺序。拿MESI当免罪牌是并发排查里最常见的错误认知之一。6.2 语言模型高于硬件模型的职责JMM的设计目标在这个维度上很清楚定义一套不依赖具体CPU的语义标准由JVM负责把它映射到目标平台的屏障指令。你在代码里写volatileJMM给你“写后对其他线程可见”的语义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设计你会发现它们背后的思路是贯通的。
返回列表