学完这篇你能弄清 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 会触发内存屏障让值刷新,看起来正常了,换台机器或去掉打印就又挂,这是最难排查的一类问题。
第三步:重排序——三个层次
重排序分三种:
- 编译器重排序(javac / JIT 优化)
- 指令级并行重排序(CPU 乱序执行)
- 内存系统重排序(写缓冲、缓存同步延迟)
三者都遵守一条底线:不改变单线程的语义(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 到底做了什么
两件事:
- 可见性:写 volatile 变量后,值立刻刷到主内存,并让其他线程的副本失效;读的时候强制从主内存读。
- 禁止重排序:写之前插入 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 域另有专门的内存语义保证。