JMM · HAPPENS-BEFORE · VOLATILE · SYNCHRONIZED · FINAL · 重排序 · 内存屏障 · 双重检查锁

一个线程写的值,另一个线程什么时候看得到

编译器和 CPU 为了快,会把你的代码调换顺序、把变量先放在寄存器和写缓冲区里,单线程看不出来,多线程就会看到“不可能”的结果。Java 内存模型(JMM)不承诺所有代码都按顺序执行,只承诺一件事:只要两次访问之间有 happens-before 关系(用 volatile、锁、线程启动和 join 等建立),后一次一定能看到前一次的写;没有数据竞争的程序,结果就和按顺序一条条执行一样。

假设场景线上一个定时任务,用一个 boolean 标志让工作线程停下,结果怎么也停不下来;代码评审时有人说双重检查的单例必须加 volatile,你说不清为什么。面试里“volatile 和 synchronized 的区别”“happens-before 是什么”也必问。注意它和“JVM 内存结构”是两回事,后者讲堆、栈、元空间,见 JVM 内存结构。标实测的数字来自 ② 的程序,在 2 核 ARM 云主机上用 JDK 17 跑。

①要解决的问题:写了,不等于别人看到了

线程 1 先准备数据、再把 ready 设为 true;线程 2 等到 ready 为 true 就去读数据。看起来天经地义,中间却有三个地方可能“不按你写的来”。

线程 1 data = 42; ready = true; 主内存(共享) data = 0 → 42 ? ready = false → true ? 线程 2 while (!ready) { } print(data); 1 编译器可能把两行写调换 2 CPU 写缓冲区:写了先排队,晚点才进内存 3 JIT 把 ready 读进寄存器,循环里不再重读 问题:线程 2 一定能退出循环吗?退出以后一定打印 42 吗?
三处都是正经的性能优化,单线程里完全看不出来(结果和按顺序执行一样)。只有另一个线程同时在看,才会撞上。

可见性

一个线程写的值,别的线程什么时候能看到。

有序性

一个线程里的几次读写,别的线程看到的顺序,是不是和代码里写的一样。

原子性

一个操作会不会被切成几半、中间被别的线程插进来。count++ 就是读、加、写三步。

②朴素做法:以为“写了就看得到、按顺序执行”

最自然的假设叫“顺序一致性”:所有线程的读写排成一条线,每个线程内部按代码顺序。下面两个实验把这个假设打破了。

1JmmDemo.javaJDK 8 / 17 / 21 都能跑;不依赖任何库
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicIntegerArray;

/** 亲眼看到 Java 内存模型要解决的两个问题:看不见、乱了序。JDK 8 / 17 / 21 都能跑。
 *  java JmmDemo stop       不加 volatile 的停止标志
 *  java JmmDemo stopv      加了 volatile
 *  java JmmDemo sb [n]     两个线程各写一个、读另一个,统计四种结果
 *  java JmmDemo sbv [n]    同上,但 x、y 用 volatile 语义读写 */
public class JmmDemo {
    // ---------- 1. 可见性 ----------
    static boolean stop;              // 普通字段
    static volatile boolean vstop;    // volatile 字段

    static void stopDemo(final boolean useVolatile) throws Exception {
        Thread worker = new Thread(() -> {
            long n = 0;
            if (useVolatile) { while (!vstop) n++; }
            else             { while (!stop)  n++; }
            System.out.println("工作线程看到了停止标志,退出(循环了 " + n + " 次)");
        });
        worker.setDaemon(true);
        worker.start();
        Thread.sleep(1000);                          // 让循环先跑一会儿,被 JIT 编译
        if (useVolatile) vstop = true; else stop = true;
        System.out.println("主线程已把标志设为 true");
        worker.join(3000);
        System.out.println(worker.isAlive() ? "3 秒后工作线程还在跑:它一直没看到这次写" : "工作线程已结束");
    }

    // ---------- 2. 重排序:store buffering ----------
    static void sbDemo(final int n, final boolean useVolatile) throws Exception {
        final int[] x = new int[n], y = new int[n], r1 = new int[n], r2 = new int[n];
        final AtomicIntegerArray vx = new AtomicIntegerArray(n), vy = new AtomicIntegerArray(n);
        final AtomicInteger barrier = new AtomicInteger();
        Thread a = new Thread(() -> {
            for (int i = 0; i < n; i++) {
                await(barrier, i);
                if (useVolatile) { vx.set(i, 1); r1[i] = vy.get(i); }   // volatile 写 x,再 volatile 读 y
                else             { x[i] = 1;     r1[i] = y[i]; }        // 普通写 x,再普通读 y
            }
        });
        Thread b = new Thread(() -> {
            for (int i = 0; i < n; i++) {
                await(barrier, i);
                if (useVolatile) { vy.set(i, 1); r2[i] = vx.get(i); }
                else             { y[i] = 1;     r2[i] = x[i]; }
            }
        });
        long t0 = System.nanoTime();
        a.start(); b.start(); a.join(); b.join();     // join 之后,主线程能看到两个线程的全部结果
        int[] c = new int[4];
        for (int i = 0; i < n; i++) c[r1[i] * 2 + r2[i]]++;
        System.out.printf("%d 次(%d ms):r1=1,r2=1 %d 次 | r1=0,r2=1 %d 次 | r1=1,r2=0 %d 次 | r1=0,r2=0 %d 次%n",
                n, (System.nanoTime() - t0) / 1_000_000, c[3], c[1], c[2], c[0]);
    }
    // 两个线程每一轮都在这里会合,然后几乎同时开始这一轮
    static void await(AtomicInteger barrier, int round) {
        barrier.incrementAndGet();
        while (barrier.get() < 2 * (round + 1)) { /* 自旋等另一个线程 */ }
    }

    public static void main(String[] args) throws Exception {
        String mode = args.length > 0 ? args[0] : "stop";
        int n = args.length > 1 ? Integer.parseInt(args[1]) : 1_000_000;
        System.out.println("Java " + System.getProperty("java.version") + "," + Runtime.getRuntime().availableProcessors() + " 核,模式 " + mode);
        switch (mode) {
            case "stop":  stopDemo(false); break;
            case "stopv": stopDemo(true);  break;
            case "sb":    sbDemo(n, false); break;
            case "sbv":   sbDemo(n, true);  break;
            default: System.out.println("未知模式");
        }
    }
}
2运行本机 JDK 17.0.20.1,2 核 ARM,javac --release 8 编译
$ java JmmDemo stop
主线程已把标志设为 true
3 秒后工作线程还在跑:它一直没看到这次写
$ java JmmDemo stopv
主线程已把标志设为 true
工作线程看到了停止标志,退出(循环了 1902526033 次)
$ java JmmDemo sb 1000000
1000000 次(153 ms):r1=1,r2=1 19 次 | r1=0,r2=1 355235 次 | r1=1,r2=0 353629 次 | r1=0,r2=0 291117 次
$ java JmmDemo sbv 1000000
1000000 次(202 ms):r1=1,r2=1 263077 次 | r1=0,r2=1 368155 次 | r1=1,r2=0 368768 次 | r1=0,r2=0 0 次
线程 A:x = 1; r1 = y;  线程 B:y = 1; r2 = x;  各 100 万次 040 万次 r1=1, r2=1 普通 19 volatile 263077 r1=0, r2=1 普通 355235 volatile 368155 r1=1, r2=0 普通 353629 volatile 368768 r1=0, r2=0 普通 291117 volatile 0 按“顺序执行”, 至少有一边的写 先发生,r1、r2 不可能都是 0
实测 2 核 ARM 云主机,JDK 17.0.20.1,每个模式跑一次;次数每次跑都会不同,但 volatile 下 r1=0,r2=0 一直是 0。
疼在哪

实验 1:主线程写了 stop = true,工作线程 3 秒都没看到,因为 JIT 把循环里对 stop 的读提到了循环外面(①的第 3 处)。实验 2:两个线程都“先写后读”,却有 29% 的情况两个都读到 0,因为写还在写缓冲区里排队,读已经先做了(①的第 2 处)。

缺的那块:一套规则,告诉程序员“怎么写才保证看得到、顺序对”,同时告诉编译器和 CPU“哪些地方不能乱动”。这就是 Java 内存模型。

典型误解“是因为每个 CPU 有自己的缓存,缓存不同步。”现代 CPU 的缓存之间有一致性协议,缓存本身是同步的。真正让你看不到的,是编译器的优化(把读提到循环外、调换顺序)和 CPU 的写缓冲区、乱序执行。把问题怪在“缓存”上,就推不出为什么 volatile 能解决。

③几种思路:内存模型可以定多严

硬件和语言都要回答同一个问题:允许多少重排。定得越严越好懂,但越慢;定得越松越快,但越难写对。

模型允许什么谁在用代价
顺序一致性不允许任何重排,所有线程看到同一个全局顺序理论上的理想模型写缓冲区、寄存器缓存、指令重排都不能用,慢很多
x86 的 TSO只允许“先写后读”被重排成“先读后写”(写缓冲区造成的)Intel、AMD比较好懂;实验 2 那种结果在 x86 上也会出现
ARM、RISC-V 的弱模型读读、读写、写写、写读四种都可能重排手机、苹果 M 系列、云上的 ARM 服务器(本页的实验机)最快,最难写对,靠屏障指令约束
语言级:无数据竞争就顺序一致没标记同步的地方随便优化;标记了同步(volatile、锁)的地方按规则排序Java(JMM)、C++11、Go程序员要把共享变量的同步标出来

Java 选最后一种:不管底下是 x86 还是 ARM,同一份程序只要没有数据竞争,结果都和顺序执行一样;具体怎么做到,由 JVM 在每种 CPU 上插不同的屏障指令。本页学 JMM 的规则(happens-before)和三个关键字各保证什么。

④关键洞察:重排序不可怕,没有 happens-before 才可怕

先看重排序从哪来、为什么单线程看不出来;再看 JMM 用来约束它的那张“先后关系图”;然后看为什么 Java 只保证“没有数据竞争的程序”;最后把 volatile、synchronized、final 各管什么对齐。

先知道这些

数据竞争

两个线程访问同一个变量,至少一个是写,而且两次访问之间没有 happens-before 关系。JMM 的保证都以“没有数据竞争”为前提。

as-if-serial

编译器和 CPU 怎么重排都行,只要本线程看到的结果和按顺序执行一样。别的线程不在这个承诺里。

缓存是一致的

多核 CPU 的缓存之间有一致性协议,同一地址不会长期存两个不同的值。问题出在写缓冲区、乱序执行和编译器优化。

JIT

热点代码会被 JIT 编译成机器码,编译时会做循环外提、删掉“没用”的读写等优化,所以有的问题只在跑了一会儿之后才出现。

洞察 1重排序从哪来:三层都在“帮你优化”

源代码你写的顺序 javac基本照搬成字节码 JIT 编译器循环里的读提到循环外调换没有依赖的读写 CPU写先进写缓冲区乱序执行、提前读 实验 1 撞上的是这里 实验 2 撞上的是这里 每一层只保证“本线程看起来按顺序”(as-if-serial);别的线程看到的顺序,要靠 JMM 的规则来约束。

洞察 2happens-before:一张“谁一定先于谁”的图

JMM 不描述机器怎么执行,只规定一组“先后关系”。如果操作 A happens-before 操作 B,那么 A 的写对 B 一定可见,而且 B 看到的 A 之前的一切也都生效了。关系能传递,所以把几条边连起来就能推出结论。

线程 1 线程 2 data = 42 ready = true(volatile 写) 读 ready → true(volatile 读) 读 data → 一定是 42 ① 程序顺序 ② volatile 写 → 读 ③ 程序顺序 ①②③ 连起来(传递): data = 42 happens-before 读 data 去掉 volatile:没有 ②,两个线程之间没有任何边,成了数据竞争:可能读到 0,甚至一直看不到 ready

能建立 happens-before 的几条规则(JLS 17.4.5)

  • 程序顺序:同一个线程里,前面的操作先于后面的操作。
  • 锁:对一把锁的解锁,先于之后任何线程对同一把锁的加锁。
  • volatile:对一个 volatile 变量的写,先于之后对同一个变量的读。
  • 线程启动:t.start() 先于线程 t 里的任何操作。
  • 线程结束:线程 t 里的所有操作,先于别的线程从 t.join() 返回。②的实验就是靠 join 拿到结果数组的。
  • 传递性:A 先于 B、B 先于 C,则 A 先于 C。

另外还有中断(interrupt 先于被中断线程发现中断)、对象构造结束先于它的 finalizer 等。并发包里的工具都在这些规则上搭起来:比如 ConcurrentHashMap.put 先于随后读到这个值的 get,线程池里 submit 先于任务开始执行。

洞察 3为什么只保证“没有数据竞争的程序”设计对比

被放弃的:所有读写都顺序一致

  • 每次读写共享变量都要插屏障,写缓冲区形同虚设。
  • JIT 不能再把变量放在寄存器里、不能把读提到循环外,几乎所有优化都要关掉。
  • 绝大多数变量只有一个线程在用,却都要为极少数共享变量付钱。

选中的:无数据竞争就顺序一致(DRF-SC)

  • 程序员用 volatile、锁、并发工具把共享变量的同步标出来。
  • JVM 只在这些标记处插屏障、禁止相关的重排,其余代码照常优化。
  • 换来的承诺:只要没有数据竞争,程序的结果就和按顺序一条条执行一样,不用去想底下是 x86 还是 ARM。

决定这个设计的约束有两条:一是性能,共享变量在程序里只占极少数;二是“一次编写到处运行”,x86 和 ARM 允许的重排完全不同。所以 Java 不规定机器怎么执行,只规定 happens-before;JVM 在每种 CPU 上用不同的屏障指令去兑现它(第三圈)。代价是:有数据竞争的程序,JMM 只给很弱的保证,结果可能随 CPU、JDK 版本、JIT 是否编译而变。

洞察 4volatile、synchronized、final 各保证什么

可见性有序性原子性代价
volatile写之后的读一定看到volatile 写之前的读写不会被排到它后面;volatile 读之后的不会被排到它前面只有单次读或写是原子的,count++ 不是读几乎免费;写要一道屏障
synchronized解锁前的写,下一个拿同一把锁的线程看得到临界区里的操作不会被排到锁外面整个代码块有竞争时要排队;锁不同就没有保证
final 字段构造器结束后,任何线程拿到这个对象,都能看到 final 字段的初始值只管这一件事—几乎免费;前提是构造器里 this 没有提前逃逸
Atomic*、VarHandle同 volatile同 volatile,9 起还能选更弱的模式单个变量的 CAS、自增竞争激烈时 CAS 反复重试
最常用的几种写法每种对应一类场景,细节放到第⑥步
volatile boolean running = true;                     // 状态标志:一个线程写、别的线程读

private final Map<String, String> config;            // 构造器里赋值,之后只读:不可变对象可以放心共享

synchronized void add(Item i) { items.add(i); }      // 读和写都要加同一把锁
synchronized List<Item> snapshot() { return new ArrayList<>(items); }

AtomicLong counter = new AtomicLong();               // 计数:用原子类,不用 volatile long++
counter.incrementAndGet();

class Config {                                       // 延迟加载的单例:静态内部类,类初始化天然线程安全
    private static class Holder { static final Config INSTANCE = new Config(); }
    static Config get() { return Holder.INSTANCE; }
}

⑤手推一遍:双重检查锁为什么要加 volatile

经典的延迟加载单例。new Singleton() 在机器层面是三步:分配内存、执行构造器写字段、把地址写进 instance。用下面的分步演示,分别推一遍不加和加了 volatile 的情况。

Singleton.java双重检查锁;这段是推演用的,不在本机复现(这种半初始化很难稳定撞上)
class Singleton {
    private static volatile Singleton instance;      // 演示里切换“有 / 没有 volatile”
    private int port;
    private Singleton() { port = 8080; }

    static Singleton get() {
        if (instance == null) {                      // 第一次检查:不加锁,图的是快
            synchronized (Singleton.class) {
                if (instance == null)                // 第二次检查:防止两个线程都创建
                    instance = new Singleton();      // 分配内存 → 构造器写 port → 写入 instance
            }
        }
        return instance;
    }
}
线程 1:第一次调用 get() if (instance == null) 加锁,再检查一次:还是 null 分配内存(port 默认 0) 构造器:port = 8080 instance = 对象地址 共享内存 instancenull 对象.port— 线程 2:同时调用 get() if (instance == null):不是 null 直接 return instance 读 port → ? “写 instance”被排到了构造器之前 volatile 写 → volatile 读:happens-before
instancenull共享内存里的值
对象.port—共享内存里的值
线程 2 读到的 port—应该是 8080
线程 1 写和线程 2 读之间—有没有 happens-before
到这里已经能交差了。能说清三个问题(可见性、有序性、原子性)从哪来;会用 happens-before 推一段代码对不对;知道状态标志用 volatile、复合操作用锁或原子类、单例用静态内部类;能讲清双重检查锁为什么要 volatile。
已知还没处理的细节
  • volatile 的 count++ 为什么还是会丢 → ⑥ 坑 1
  • 只给写加锁、读不加锁行不行 → ⑥ 坑 4
  • 加一行 println 问题就“消失”了 → ⑥ 坑 6
  • volatile 在 CPU 上到底插了什么 → ⑥ 第三圈

⑥边界和变体:哪里最容易以为自己写对了

第一圈的坑每条标了根源在 ④ 的哪一条;第二圈是团队里该怎么写;第三圈讲 volatile 在 CPU 上插了什么、VarHandle 的几种模式和各版本的情况。

第一圈

注意事项

坑 1加了 volatile,计数还是会丢← ④ 洞察 4
现象
volatile int count,两个线程各加 100 万次,结果小于 200 万。
根源
volatile 只保证单次读、单次写的可见性和顺序。count++ 是“读、加一、写回”三步,两个线程可能读到同一个旧值,各自加一后写回,丢掉一次。
做法
计数用 AtomicLong,高并发计数用 LongAdder;“先判断再修改”这类复合操作用锁。
坑 2双重检查锁不加 volatile← ⑤ 手推
现象
极少数情况下拿到一个字段还是默认值的单例,偶发 NPE 或配置为空,几乎无法复现。
根源
“把地址写进 instance”可以被排到构造器之前;第一次检查不加锁,和写之间没有 happens-before,另一个线程可能看到非 null 却没初始化完的对象。
做法
instance 加 volatile;更简单的是用静态内部类(Holder)或枚举单例,交给类初始化的线程安全保证。
坑 3停止标志不加 volatile,线程停不下来← ④ 洞察 1 JIT
现象
改了 running = false,后台线程照样跑;本地调试时又是好的。② 实测
根源
循环被 JIT 编译后,对普通字段的读被提到了循环外面,相当于 if (!stop) while (true)。调试时代码没被 JIT 编译,所以看不出来。
做法
标志用 volatile,或者用 AtomicBoolean;线程池里的任务优先用中断(Future.cancel(true))配合检查 Thread.interrupted()。
坑 4只给写加锁,读不加锁← ④ 洞察 2 锁规则
现象
“写入要防止并发,所以 set 方法加了 synchronized;读只是看一眼,不用加。”结果读到旧值,或者读到写了一半的状态。
根源
锁的 happens-before 只发生在“解锁”和之后对同一把锁的加锁之间。读线程不加锁,就和写线程之间没有任何边,还是数据竞争。
做法
读写都用同一把锁;读多写少用 ReadWriteLock,或者把状态做成不可变对象,用一个 volatile 引用整体替换。
坑 5构造器里把 this 交出去,final 也救不了← ④ 洞察 4 final
现象
在构造器里注册监听器、启动线程、把 this 放进静态集合,别的线程偶尔看到 final 字段是 null 或 0。
根源
final 的保证是“构造器结束后才拿到引用的线程,一定看到 final 字段的初始值”。构造器还没结束就把 this 交出去,这个保证就不成立。
做法
构造器里只赋值;注册、启动线程放到构造完成之后(工厂方法、@PostConstruct、start() 方法)。
坑 6加一行日志,问题就“好了”← ④ 洞察 3
现象
循环里加一句 System.out.println 或 Thread.sleep,停止标志就能被看到了,于是以为问题解决。
根源
println 内部有 synchronized,会妨碍 JIT 把读提到循环外,顺带让问题不再出现。但规范没有给你任何保证,换个日志框架、换个 JDK 版本、换到 ARM 上,可能又回来了。
做法
先用 happens-before 推一遍有没有边;没有边就是数据竞争,用 volatile、锁或并发工具把边建起来,别依赖巧合。
第二圈

使用规范

能不共享就不共享,能不可变就不可变

局部变量、ThreadLocal / ScopedValue、final 字段组成的不可变对象,天然没有数据竞争。

所有可见性问题

优先用现成的并发工具

ConcurrentHashMap、AtomicLong、LongAdder、BlockingQueue、线程池,它们的文档里写明了 happens-before。

自己写同步写错

状态标志用 volatile,复合操作用锁或原子类

“一个写、多个读”的布尔、引用用 volatile;读改写用 CAS 或锁。

坑 1、坑 3

安全发布对象的四种方式

静态初始化器里赋值;存进 volatile 字段或 AtomicReference;存进 final 字段;存进由锁保护的字段或并发容器。

别的线程看到半初始化的对象(坑 2、坑 5)

单例不要手写双重检查

用静态内部类或枚举;Spring 里交给容器管理。

坑 2

并发代码用 jcstress 测

OpenJDK 的并发压力测试框架,专门跑 ② 那种实验,会把各种结果出现的次数统计出来。普通单元测试几乎测不出数据竞争。

“我跑了一万遍都没问题”

第三圈

更深一层

volatile 在 CPU 上插了什么

JSR-133 的实现指南把屏障分成四种:LoadLoad、LoadStore、StoreStore、StoreLoad。volatile 写之前要 StoreStore、之后要 StoreLoad;volatile 读之后要 LoadLoad 和 LoadStore。

  • x86 本来只会把“先写后读”重排,所以只有 StoreLoad 需要真指令:HotSpot 在 volatile 写后加一条带 lock 前缀的指令,volatile 读不需要额外指令。这就是“volatile 读几乎免费”。
  • ARM 四种重排都可能,HotSpot 用 ldar / stlr 这类自带获取、释放语义的读写指令。所以同一段有数据竞争的代码,在 ARM 上更容易出事(② 的实验机就是 ARM)。

VarHandle:比 volatile 更细的几档(JDK 9)

对同一个变量可以按需选内存顺序,从弱到强:

  • plain:普通读写,没有任何保证。
  • opaque:保证最终能看到、不会被优化掉,但不保证和其他变量的顺序。
  • release / acquire:写之前的操作不会排到写后面,读之后的不会排到读前面,“消息传递”够用了,比 volatile 便宜(不需要 StoreLoad)。
  • volatile:最强,全局一个顺序。

业务代码一般用不到,JDK 的并发类、Netty 等底层库在用,也是替代 sun.misc.Unsafe 的官方方式。

为什么 String 能随便在线程之间传

String 存内容的字段是 final,构造完成后不再修改(缓存哈希值的字段是例外,但它每次重算出来都一样),加上 final 字段的初始化保证,任何线程拿到它都能看到完整的内容,不需要任何同步。这就是“不可变对象天然线程安全”在 JMM 里的根据。

版本差异:JMM 自 JDK 5 起基本没变

  • JDK 5(JSR 133)重新定义了 JMM:修好了旧模型下 final 字段可能被看到变化、volatile 不能阻止与普通读写重排等问题,双重检查锁从此“加 volatile 就对”。
  • JDK 9 加了 VarHandle 的几种访问模式,规范的核心规则没变。
  • JDK 8、17、21、25 写并发代码的规则一样;虚拟线程(21)也遵守同一套 happens-before,start、join 规则同样适用。

和 JMM 同年加入的 java.util.concurrent 见 JDK 页的“地基”。

面试官会怎么问

volatile 和 synchronized 有什么区别?

volatile 只管可见性和有序性,不保证复合操作的原子性,不会阻塞;synchronized 三样都管,还提供互斥,有竞争时会阻塞。前者适合状态标志、一写多读的引用,后者适合需要“读改写”整体不被打断的地方。

← ④ 洞察 4

什么是 happens-before?举几条规则。

JMM 定义的“先后关系”:A happens-before B,则 A 的结果对 B 可见,且 A 不会被排到 B 之后。规则有程序顺序、同一把锁的解锁先于加锁、同一个 volatile 变量的写先于读、start 先于线程内操作、线程内操作先于 join 返回、传递性等。

← ④ 洞察 2

指令重排序是什么?为什么单线程感觉不到?

编译器和 CPU 为了性能调换没有依赖关系的读写顺序。它们遵守 as-if-serial:只保证本线程看到的结果和按顺序执行一样,所以单线程感觉不到;另一个线程同时观察时才会看到“不可能”的结果。

← ④ 洞察 1

下一圈

  • 已做:JMM 下一圈:jcstress、CPU 缓存与伪共享,本页 ↺ 的预期在 ARM 上用 jcstress 真跑了一遍
  • jcstress:怎么写一个并发压力测试,读懂它的结果表
  • CPU 缓存一致性(MESI)、写缓冲区和失效队列
  • C++ 的 memory_order 和 Java 的对照
  • AQS、ConcurrentHashMap 源码里 volatile 和 CAS 是怎么配合的
  • 伪共享和 @Contended:两个变量挤在同一个缓存行
  • final 字段的冻结语义和反序列化(与 JDK 26 的 JEP 500 有关)

↺回到第⑤步:改 JmmDemo

预期是按 ④ 推出来的,没有在本机跑过。并发实验每次结果都会有波动,看趋势。

练习 1 · 先猜,再在循环里打日志

在 stop 模式的循环里加一句 System.out.print(""),不加 volatile,再跑。先猜能不能停下来。

预期:多半能停下来:print 里的 synchronized 让 JIT 不再把读提到循环外。但这是巧合,不是保证(坑 6)。

用到:坑 6、④ 洞察 1

练习 2 · volatile 计数

加一个模式:static volatile int count,两个线程各 count++ 100 万次,join 后打印。再换成 AtomicInteger 比一比。

预期:volatile 版每次都小于 200 万,而且每次不一样;AtomicInteger 版每次都是 200 万。

用到:坑 1

练习 3 · 只让一个变量是 volatile

sb 模式里,只把 x 改成 volatile 语义(vx),y 仍是普通数组,再跑 100 万次。先猜 r1=0、r2=0 还会不会出现。

预期:还会出现。线程 B 那边是普通写 y、普通读 x,它自己的“先写后读”照样可以被重排;要禁止这种结果,两边都得是 volatile。

用到:④ 洞察 2、第三圈屏障

1. 用一句话说出 Java 内存模型的精华。

编译器和 CPU 会重排、会延迟写,JMM 不禁止这些,只规定 happens-before:用 volatile、锁、start / join 等建立先后关系的地方一定看得到、顺序对;没有数据竞争的程序,结果和按顺序执行一样。

← 开头 + ④ 洞察 3

2. “写了就看得到、按顺序执行”这个朴素假设,被哪两个实验打破了?

不加 volatile 的停止标志,工作线程 3 秒都没看到(JIT 把读提到了循环外);两个线程都先写后读,100 万次里有 29% 两个都读到 0(写还在写缓冲区里)。

← ②

3. 双重检查锁不加 volatile 会拿到半初始化对象,根源是 ④ 的哪一条?

洞察 2:第一次检查不加锁,和线程 1 写 instance 之间没有 happens-before;而“写 instance”可以被排到构造器之前(洞察 1)。加了 volatile,写 instance 之前的构造器写入,对读到它的线程一定可见。

← ④ 洞察 1、2 + ⑤

4. 预测:线程 1 在 synchronized(lockA) 里写 x = 1;线程 2 在 synchronized(lockB) 里读 x。线程 2 一定能看到 1 吗?

不一定。锁规则只连接同一把锁的解锁和加锁,lockA 和 lockB 之间没有边,这仍然是数据竞争。

← ④ 洞察 2 锁规则

5. 预测:主线程写好一个普通的 HashMap,再 new Thread(...).start(),新线程里读这个 Map,能看到全部数据吗?如果新线程是在 Map 写完之前就启动了呢?

先写完再 start:能,start 先于新线程里的所有操作。先 start 再写:不能保证,之后的写和新线程之间没有边,而且 HashMap 本身也不是线程安全的。

← ④ 洞察 2 线程启动规则

6. 预测:② 的 sb 实验换到一台 x86 机器上,普通变量时 r1=0、r2=0 还会出现吗?

会。x86 的 TSO 唯一允许的重排就是“先写后读”,而写缓冲区正是造成这个结果的原因。比例可能和 ARM 上不同,但不会是 0;只有两边都用 volatile 才会是 0。

← ③ 硬件模型 + 第三圈