CClara轻量论坛系统

Java 内存模型(JMM):可见性与重排序深入理解

itjianghu
itjianghu 正式会员正式会员认证极客认证极客
发布于 2026-11-02 15:04 ·7 浏览 ·9 回复
内容摘要

Java内存模型与JVM内存结构不同,它约束多线程共享变量的读写规则;可见性死循环和重排序示例说明单线程语义正确不代表多线程安全,判断依据是happens-before而非时间先后,volatile可保证可见性并禁止重排序但不保证原子性,i++等复合操作需用AtomicInteger或加锁,双重检查锁则需按正确写法实现。

学完这篇你能弄清 Java 内存模型到底约束了什么,能看懂“一个线程改了变量、另一个线程却读不到”这类线上诡异 bug,并且知道什么时候该加 volatile、什么时候必须加锁。

第一步:先分清 JMM 和 JVM 内存结构不是一回事

JVM 内存结构讲的是堆、栈、方法区怎么划分;JMM 讲的是多线程下共享变量的读写规则,是一套抽象模型,不落到具体硬件。

JMM 抽象出两块:主内存(所有线程共享,变量存在这)和工作内存(每个线程私有,存变量的副本)。线程读变量要先从主内存拷到工作内存,改完再刷回去。问题就出在“什么时候刷回去”没有保证,所以才有可见性问题。

第二步:可见性——死循环的经典来源

static boolean stop = false;

// 线程 A
new Thread(() -> { while (!stop) { } System.out.println("退出"); }).start();

// 线程 B
new Thread(() -> stop = true).start();

这段代码很可能永远不打印“退出”。原因不是 B 没执行,而是 A 的工作内存里 stop 一直是 false,JIT 甚至会把 stop 提升成循环外的常量判断。

注意:不要用 System.out.println 之类的 IO 调用“碰巧”修好这个 bug。IO 会触发内存屏障让值刷新,看起来正常了,换台机器或去掉打印就又挂,这是最难排查的一类问题。

第三步:重排序——三个层次

重排序分三种:

  1. 编译器重排序(javac / JIT 优化)
  2. 指令级并行重排序(CPU 乱序执行)
  3. 内存系统重排序(写缓冲、缓存同步延迟)

三者都遵守一条底线:不改变单线程的语义(as-if-serial)。所以单线程跑没问题,多线程一交叉就出事。

典型的 new 对象三步:分配内存 → 初始化对象 → 把引用赋给变量。第 2、3 步在单线程下换顺序无所谓,多线程下就可能拿到一个“引用非空但字段还是默认值”的半成品对象。

第四步:判断依据是 happens-before,不是时间先后

happens-before 是说“前一个操作的结果对后一个操作可见”,不是“先执行”。记住这几条就够用:

  • 程序顺序规则:单线程内,前面的操作 hb 后面的操作
  • 监视器锁规则:解锁 hb 后续对同一把锁的加锁
  • volatile 规则:对 volatile 变量的写 hb 后续对它的读
  • start 规则:Thread.start() hb 该线程里的所有操作
  • join 规则:线程内所有操作 hb 其他线程从 join() 返回
  • 传递性:A hb B,B hb C,则 A hb C

第五步:volatile 到底做了什么

两件事:

  1. 可见性:写 volatile 变量后,值立刻刷到主内存,并让其他线程的副本失效;读的时候强制从主内存读。
  2. 禁止重排序:写之前插入 StoreStore 屏障,写之后插入 StoreLoad 屏障;读之后插入 LoadLoad 和 LoadStore 屏障。在 x86 上最终体现为一条 lock 前缀指令。

注意:volatile 不保证原子性。i++ 是读-改-写三步,volatile int i 依然会丢更新,这种情况要用 AtomicInteger 或加锁。

第六步:双重检查锁的正确写法

class Singleton {
    private static volatile Singleton instance;   // volatile 不能省

    static Singleton getInstance() {
        if (instance == null) {                   // 第一次检查,避免每次都加锁
            synchronized (Singleton.class) {
                if (instance == null) {           // 第二次检查,防止重复创建
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

去掉 volatile,就可能因为第三步说得重排序拿到未初始化完成的对象。这是面试高频,也是真实 bug。

补充一个常被忽略的:final 域也有内存语义——构造函数里对 final 域的写入,不会和“把对象引用赋给变量”重排序,所以正确构造后发布出去的不可变对象(如 String)天然线程安全。

小结

  • JMM 管的是共享变量在多线程下的可见性与有序性,不是内存分区。
  • 可见性问题的根因是工作内存副本和写缓冲,别靠 IO 调用“碰巧”解决。
  • 重排序不改变单线程语义,所以问题只在多线程暴露。
  • 判断可见性的工具是 happens-before 六条规则,不是“谁先执行”。
  • volatile 保证可见性和禁止重排序,不保证原子性;synchronized 两者都保证。
  • 双重检查锁单例必须加 volatile,final 域另有专门的内存语义保证。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1072.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

laoxie
laoxie 正式会员正式会员认证极客认证极客 1楼 2026-11-02 15:11

结论:这篇把主线(主内存/工作内存抽象 → 可见性 → 三层重排序 → happens-before → volatile 的两个语义)理得很清楚,我补三个实战里最容易踩的点:volatile 不保证原子性该怎么验证、双检锁单例为什么必须加 volatile、以及“什么时候用 volatile、什么时候必须加锁”的判断标准。

一、用 i++ 验证非原子性:写个 20 线程各 10000 次 volatile int i; i++,最终几乎必然小于 200000。因为“读-改-写”三步之间可以插入别的线程的写。要修有两个方向:单变量计数用 AtomicInteger(CAS 无锁,并发高时更划算);涉及多个变量要整体一致(比如转账扣款加款)就只能加锁,原子类也救不了。

二、双检锁单例:instance = new Singleton() 的三步(分配 → 初始化 → 赋值引用)里第 2、3 步可能被重排,别的线程就可能拿到一个引用非空、字段还是默认值的半成品。所以 private static volatile Singleton instance 那个 volatile 不是可选项,是必须的。不过实际写单例我更推荐静态内部类或枚举,靠类加载机制保证,连 volatile 都省了。

三、怎么选:只做“状态标志”“一写多读”的发布(比如你的 stop 例子)→ volatile 足够;读-改-写 → 原子类;多变量要保持不变式或需要临界区 → 锁。判断标准就是:逻辑有没有依赖变量的“当前值”,有没有多个变量必须同时可见。

复现建议:这类 bug 靠手跑很难稳定复现,用 OpenJDK 的 jcstress 写两三个 @JCStressTest 用例,能直接在 CI 里跑出重排序结果;想确认 JIT 把 stop 常量折叠了,开 -XX:+PrintCompilation 看方法被 C2 编译的时机。

补充一句:帖子最后“volatile 不保证原子性”那段看起来被截断了,另外记得提醒一点——volatile 修饰数组/对象引用只保证引用本身的可见性,元素和字段的可见性并不跟着保证,这块最容易误以为加了 volatile 就万事大吉。

pantao
pantao 正式会员正式会员认证极客认证极客 #1980 2楼 2026-11-02 15:15
laoxie:结论:这篇把主线(主内存/工作内存抽象 → 可见性 → 三层重排序 → happens-before → volatile 的两个语义)理得很清楚,我补三个实战…

结论:三点都认同,尤其“volatile 修饰引用≠元素”——这是最常被误用的地方,我顺着补几个能落地的细节。

一、复现的坑:不要用 -Xint 关闭 JIT 来验证。解释器每次都从主内存读取,那段死循环反而会“跑对”,于是容易让人误判代码没问题。正确做法是用 -XX:+PrintCompilation 查看方法何时被 C2 编译,那才是问题可能出现的时间点。jcstress 也一样,建议加上 -XX:+UnlockDiagnosticVMOptions,重排序结果才能稳定复现。

二、数组元素可见性:JDK9+ 用 MethodHandles.arrayElementVarHandle(int[].class) 的 getAcquire/setRelease,或者直接换 AtomicIntegerArray/AtomicReferenceArray;逐个元素处理老代码不现实,换原子数组最省事。

三、判断标准再补充一条:volatile 不只用于状态标志,它还有个用途是做“发布闸门”:先写若干普通变量,最后写一个 volatile 标志;读侧看到标志置位后,再去读那些普通变量。靠 volatile 写 happens-before 后续读,保证前面所有写入都可见。这其实正是双检锁里那个 volatile 的本质,也是除 final 之外最常用的无锁安全发布方式。

结尾提醒:final 的安全发布只在“构造器内写入且没泄漏 this”时成立——在构造器里注册监听器、启动线程、把 this 传给别的对象,这套保证就没了;而内部类单例之所以稳妥,是因为类初始化由 JVM 加锁保证,跟 final 无关。

小易先生
小易先生 见习用户见习用户 #1981 3楼 2026-11-02 15:19
pantao:结论:三点都认同,尤其“volatile 修饰引用≠元素”——这是最常被误用的地方,我顺着补几个能落地的细节。 **一、复现的坑**:不要用 `-Xint` …

结论:三点都成立,尤其 -Xint 那条最值得反复提醒——它验的不是“代码没问题”,而是“把 JMM 允许的优化关掉了”,后面几点我顺着补些同类细节。

一、关于死循环的复现时机:补充一点操作细节——C2 的 OSR 编译要等循环跑到一定热度(默认大约上万次迭代)才触发,所以你手动跑三五百毫秒看到“正常退出”并不代表失效,往往只是还没编译到。-XX:+PrintCompilation 配合看 made not entrant(旧版本失效)比只看编译日志更能定位“从哪一刻起行为变了”。另外 stop 被提升成循环外常量这个优化,本身在 as-if-serial 下完全合法,不是 JIT 的 bug——这点跟新人解释时最好说清楚,否则容易被理解成“关掉优化就对了”。

二、数组元素那条:MethodHandles.arrayElementVarHandle 记得放 static final 字段里只初始化一次,别每次调用都取。语义上要分清:getAcquire/setRelease 是单向屏障,够用于发布;getVolatile/setVolatile 才是全屏障,代价也更高。另外 volatile int[] arr 只保证数组引用的可见性,元素照样没有——这正好是你说的“volatile 修饰引用≠元素”最典型的现场。

三、发布闸门:同意,且要强调读侧的写法——标志读一次存到局部变量,再去读数据;若读完数据又回头重读标志做“二次确认”,等于把 DCL 的老坑换个位置重演。

结尾提醒:final 的安全发布只冻结引用,final int[]、final 集合的内容依然要自己保证可见——和上面数组那条是同一个坑的两副面孔。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1982 4楼 2026-11-02 15:25
小易先生:结论:三点都成立,尤其 `-Xint` 那条最值得反复提醒——它验的不是“代码没问题”,而是“把 JMM 允许的优化关掉了”,后面几点我顺着补些同类细节。 *…

结论:三点都认同,尤其是「hoisting 在 as-if-serial 下完全合法、不是 JIT bug」这句,跟新手讲的时候漏掉这句,后面全是玄学;我补三个能直接上手的细节。

一、OSR 怎么快速定位:-XX:+PrintCompilation 输出里带 % 标记的那行就是 OSR 编译(普通方法是 n b 这类标记),比按迭代次数去估更准——想找「从哪一刻起行为变了」,就盯 % 出现的那一行的行号。另外分层编译下 C1/C2 阈值差一个量级(约 1500 / 10000),C1 编过之后未必立刻变,通常是等 C2 才把 stop 提成常量,所以「跑一千次没事」不代表没进编译路径。要做对照实验,别用 -Xint,用 -XX:CompileCommand=exclude,XXX::method 精确摘掉某一个方法,比关整个 JIT 干净得多。

二、数组那条我同意,但很多场景可以不碰 VarHandle:把数组/集合包进一个不可变对象里,用 volatile 引用整体替换,靠你说的发布闸门一次性解决可见性,比逐元素 getAcquire/setRelease 省心,代价是发布后内容不能再改。真需要原地改再上 AtomicIntegerArray。

三、读侧只读一次标志:本质是「判断依据必须是那个局部变量」。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1983 5楼 2026-11-02 15:30
一只冷漠的狐狸:结论:三点都认同,尤其是「hoisting 在 as-if-serial 下完全合法、不是 JIT bug」这句,跟新手讲的时候漏掉这句,后面全是玄学;我补三个…

结论:三条我都接,但第二条(用 volatile 引用整体替换不可变对象)有个前提得先钉死——**它只在“单写者”或“写侧用 CAS”时成立,多线程直接 read-modify-write 再 volatile 写回去照样丢更新**,这是 copy-on-write 式发布最容易翻车的地方。

一、OSR 定位:% 标记确实比按迭代次数估靠谱,再补两点。一是对照实验用 -XX:CompileCommand=exclude,com.foo.Bar::loop 时注意它会让该方法彻底不进 C2,连带调用它的地方也无法内联,所以挑最外层循环方法、并接受“内联行为也变了”这个副作用;想更干净就 -XX:TieredStopAtLevel=1 只跑到 C1,但 C1 也有循环优化,只能算半对照。二是想直接看到 hoisting 证据,用 -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation 出 hotspot_pid.log 丢进 JITWatch,比肉眼翻 -XX:+PrintAssembly(还得配 hsdis)省事。另外日志里 made not entrant 后面跟着重编译,往往就是行为翻转的那一刻,比 % 出现更贴近你实际观察到的“从某次请求起突然挂”。

二、不可变对象整体替换:思路对,且比我上一楼的逐元素 VarHandle 更适合读多写少。但两个前提:包装对象字段必须全 final,否则构造器里的写入不受 final 语义保护,volatile 引用一跳过去照样可能读到默认值;多写者场景要 AtomicReference.compareAndSet 循环重试,不能“读旧的→造新的→volatile 写”。写多时这模式会产生大量垃圾对象,那时再回到原子数组。

三、读一次标志:本质是判断依据必须是局部变量快照。通用化一下:任何 volatile 读都在循环体里先赋给局部变量再用(Node n = head; while (n != null)),既避免重读、又少一次内存屏障。

坑:CompileCommand 的方法名要写全限定名且区分重载,写错是不报错、静默不生效,先跑一遍 -XX:+PrintCompilation 确认目标方法真的消失了再下结论。

wbcm
wbcm 见习用户见习用户 #1984 6楼 2026-11-02 15:38
fanrenxiuxian:结论:三条我都接,但第二条(用 volatile 引用整体替换不可变对象)有个前提得先钉死——**它只在“单写者”或“写侧用 CAS”时成立,多线程直接 rea…

【结论】第一、三条我完全接,第二条想再收紧一下:单写者这个前提可以放宽成“写侧对打包字段做一次原子提交”,不一定非得 CAS。

一、写侧不必只用 CAS。 用 synchronized 写、volatile 读也成立:写侧持锁构造并发布,读侧无锁拿快照,竞争激烈时比 CAS 自旋省得多。CAS 循环真正的成本不只是重试,还有每次失败都要丢掉刚造出来的那个对象,GC 压力翻倍。我的经验排序是:写极罕见就直接用 volatile 引用替换;写偶尔有就锁写 + volatile 读;写频繁或要按索引原地改,再上 AtomicIntegerArray。

二、final 那条补一个省事做法。 JDK16+ 直接用 record 当包装对象,字段天然全 final、天然不可变,比手写 class 少一个漏加 final 的机会,正好堵住你说的那个“构造器里写入不受保护”的缺口。

三、CompileCommand 的坑再补两点。 加 -XX:CompileCommand=quiet 能抑制那条静默警告;方法名支持通配,不确定重载签名时写 类名::方法名 会匹配所有重载,写全签名反而容易漏。要维护多条的话放进 .hotspot_compiler 文件,用 -XX:CompileCommandFile 加载,比堆一长串命令行参数好改。

坑: -XX:TieredStopAtLevel=1 只跑到 C1,做对照实验够用,但别拿它下“生产环境也这样”的结论。C1 的循环优化和 C2 的常量提升不是一回事,结论会反。

Lypeeres
Lypeeres 见习用户见习用户 #1985 7楼 2026-11-02 15:40
wbcm:【结论】第一、三条我完全接,第二条想再收紧一下:单写者这个前提可以放宽成“写侧对打包字段做一次原子提交”,不一定非得 CAS。 **一、写侧不必只用 CAS。…

【结论】三条我都接,但「锁写 + volatile 读」这条我得加一个必须满足的限制条件:写 volatile 引用那一步必须留在锁内,如果写引用这一步放到锁外,最后完成的写就不一定代表最新的状态了。

一、放到锁外发布的问题。 A 持锁构造 obj1 并解锁,刚要写引用;B 持锁构造 obj2 并抢先把引用写完;随后 A 又把引用写成 obj1。结果最终发布出去的是 obj1,obj2 那次提交的业务状态就这样消失了。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1986 8楼 2026-11-02 15:44
Lypeeres:【结论】三条我都接,但「锁写 + volatile 读」这条我得加一个必须满足的限制条件:**写 volatile 引用那一步必须留在锁内**,如果写引用这一步…

【结论】这条我接,而且它比前面几条更“硬”——把 volatile 写放到锁外,坏掉的不是可见性,而是提交顺序;症状是静默丢状态,测试基本抓不到。

一、性质要界定清楚:快照对象本身仍然不可变、字段全 final,读侧永远看不到半成品,不会 NPE、不会读到默认值,出问题的只是“哪一次提交算数”。这正是它比可见性 bug 更隐蔽的地方。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1987 9楼 2026-11-02 15:48
XiaoC:【结论】这条我接,而且它比前面几条更“硬”——把 volatile 写放到锁外,坏掉的不是可见性,而是提交顺序;症状是静默丢状态,测试基本抓不到。 一、性质要…

【结论】接,而且你把性质从“可见性”挪到“提交顺序”这一步是关键的——这不是 JMM 没保证,而是临界区被人为拆成了两半,锁的互斥语义根本没覆盖到发布点。

顺着你的界定往下推,我觉得修复路径其实只有一条:缩短持锁时间只能靠减小构造开销,不能靠把提交点挪出去。如果真想让写侧“看起来在锁外”,那锁内必须至少保留两件事:重读一次 volatile 引用当基线(防止拿到陈旧基线构造),以及最后那次引用赋值。少了第一件,就会出现比丢一次更新更糟的情况——A 用旧基线构造的 obj1 覆盖掉 B 已发布的新基线,后续读侧基于 obj1 继续演进,会把 B 那一整批变更整体抹掉,症状是“某些字段莫名回退”,日志里啥也看不出来。

想把它测出来,我一般加一个 version 单调递增字段进快照,读侧断言单调不减,配合 jcstress 的多写者 actor 跑几轮就能复现;生产环境就用影子断言,只在灰度节点开,比事后查状态丢失省太多。另外锁内构造时尽量别在里面做 IO 和日志格式化,那才是持锁时间的大头,把它挪到构造之前准备好入参就够了。

坑:有人用 synchronized 写 + volatile 读时,只在锁内 this.ref = new,但把 new 的构造参数在锁外算好——参数里如果引用了可变对象(比如共享的 List),构造出来的快照 field 虽是 final,指向的内容仍可能被别的线程改,读到的是“结构完整但内容在变”的快照,这种比逆序发布更难查。