侧边栏壁纸
  • 累计撰写 136 篇文章
  • 累计创建 19 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

MESI 之后,线程安全了?从 CPU 乱序执行到 Java 内存模型

YaFuX
2026-04-10 / 0 评论 / 0 点赞 / 53 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

很多 Java 程序员都会写 volatile,也知道 DCL 单例一定要加 volatile

但是很少有人真正思考:

CPU 为什么会乱序执行?

为什么会出现 DCL 单例失效?

volatile 到底是 JVM 做的,还是 CPU 做的?

内存屏障到底是什么?

如果不了解 CPU 的执行机制,你看到的 Java 内存模型(JMM)其实只是"表象"。

这篇文章,我们从 CPU 的角度,一步步理解 Java 并发为什么要这样设计。


一、为什么 CPU 不按照代码顺序执行?

很多人的第一反应是:

我代码写的是

a = 1;
b = 2;

CPU 就一定是

执行a
↓
执行b

实际上不是。

现代 CPU 为了提高吞吐量,会尽可能让更多执行单元同时工作。

例如:

A 指令:读取内存

B 指令:计算加法

C 指令:写入寄存器

如果它们彼此没有依赖关系,

CPU 完全可能变成:

执行B
↓
执行C
↓
执行A

虽然顺序变了,

但是最终结果一样。

这种技术,就叫:

Out-of-Order Execution(乱序执行)

它几乎是现代所有高性能 CPU 的标配。


二、CPU 为什么要乱序?

因为 CPU 太快了。

真正慢的是:

  • 内存
  • IO
  • Cache Miss

例如:

读取内存
↓
等待100ns

CPU 不可能傻傻等待。

于是它会:

等待内存的时候
↓
先去执行后面的指令

这样整个流水线不会停。

所以:

CPU 的目标不是按顺序执行,而是最快完成任务


三、乱序执行依赖哪些技术?

有了 MESI,为什么还需要 volatile?一文搞懂 CPU 乱序执行1.png

现代 CPU 能做到乱序,并不是简单交换指令。

它背后依赖很多硬件技术。

例如:

流水线(Pipeline)

把一条指令拆成多个阶段:

取指
↓
译码
↓
执行
↓
写回

不同指令可以同时处于不同阶段。


分支预测(Branch Prediction)

CPU 会提前猜:

if (x > 0)

到底走哪条路径。

猜对:

几乎没有损耗。

猜错:

流水线全部清空。

重新执行。


寄存器重命名(Register Renaming)

避免两个变量实际上使用同一个物理寄存器。

减少数据冲突。


重排序缓冲区(ROB)

虽然执行顺序乱了,

但是提交结果时,

必须恢复程序员看到的执行顺序。

所以 CPU 内部还有一个:

Reorder Buffer

负责"善后"。


四、乱序执行为什么不会影响单线程?

这里有一个经典原则:

As-if-Serial

意思就是:

CPU 可以随便优化。

但是:

最终结果必须和顺序执行一致。

例如:

a = 1;
b = 2;

CPU 可以:

先准备b
再准备a
最后一起提交

程序员完全感觉不到。

所以:

单线程一般不会出问题。


五、多线程为什么会出问题?

有了 MESI,为什么还需要 volatile?一文搞懂 CPU 乱序执行2.png

真正的问题,从多线程开始出现。

来看一段经典代码。假设 abxy 的初始值都是 0

// Thread 1
a = 1;
x = b;

// Thread 2
b = 1;
y = a;

线程 1 先把 a 改成 1,然后读取 b

线程 2 先把 b 改成 1,然后读取 a

最终可能出现哪些结果?

x = 0, y = 1
x = 1, y = 0
x = 1, y = 1

这些结果都比较容易理解。

例如,线程 1 先执行完,那么线程 2 读取 a 时,就可能读到 1

但还有一个看起来不可能的结果:

x = 0, y = 0

为什么它看起来不可能?

如果所有指令都严格按照代码顺序执行,并且一个线程写入的数据能够立刻被另一个线程看到,那么:

  • 线程 1 要读到 b = 0,就必须在线程 2 执行 b = 1 之前读取 b
  • 线程 2 要读到 a = 0,就必须在线程 1 执行 a = 1 之前读取 a

但是,每个线程内部又都是“先写后读”:

线程 1:写 a → 读 b
线程 2:写 b → 读 a

按照严格的全局执行顺序,这两个条件无法同时成立。

因此,在理想的顺序一致性模型中,x = 0、y = 0 不应该出现。

但真实的 CPU 并不是这样工作的。

现代 CPU 为了提高执行效率,通常不会等待一次写操作完全同步到其他 CPU 核心之后,再继续执行后面的读操作。写入的数据可能先进入当前核心的 Store Buffer,而后面的读取操作已经开始执行。

于是,实际效果可能变成:

线程 1:a = 1 暂时保存在本地,然后读取 b,得到 0
线程 2:b = 1 暂时保存在本地,然后读取 a,得到 0

此时,两个线程都执行了自己的写操作,但这些写操作还没有及时对另一个线程可见。

所以最终就可能得到:

x = 0
y = 0

需要注意的是,这种现象不能只用“CPU 把两条指令交换了顺序”来理解。

更准确地说,它可能由多种因素共同造成:

  • 编译器或 JIT 对指令进行重排序;
  • CPU 采用乱序执行;
  • 写操作暂存在 Store Buffer 中;
  • 不同 CPU 核心观察到内存操作的顺序不同;
  • 程序中没有使用同步机制建立可见性和有序性保证。

因此,多线程程序中的“执行顺序”,不能只看源代码的书写顺序。

对当前线程来说,代码可能仍然保持正确的执行结果;但对另一个线程来说,它观察到的内存操作顺序却可能完全不同。

这也是为什么 Java 需要通过 volatilesynchronized 和内存屏障,来约束重排序,并保证线程之间的内存可见性。


六、DCL 单例为什么必须加 volatile?

有了 MESI,为什么还需要 volatile?一文搞懂 CPU 乱序执行3.png

来看经典的双重检查锁定,也就是 DCL:

public class Singleton {

    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这里为什么一定要给 instance 加上 volatile

关键就在这一句:

instance = new Singleton();

从 Java 源代码来看,它只有一行;但从底层执行过程来看,可以大致拆成三个步骤:

① 为 Singleton 对象分配内存

② 执行构造过程,初始化对象

③ 将对象引用赋值给 instance

按照我们直觉中的顺序,应该是:

分配内存
   ↓
初始化对象
   ↓
发布对象引用

也就是只有当对象完全初始化之后,其他线程才能通过 instance 访问它。

但是,在没有任何内存语义约束的情况下,编译器、JIT 和 CPU 可能对内存操作进行优化。

从其他线程的观察结果来看,步骤②和步骤③可能表现为:

① 分配内存

③ instance 指向这块内存

② 对象初始化完成

注意,这里不一定意味着 CPU 真的把两条机器指令简单交换了位置。

更准确地说,是对象引用的写入可能先被另一个线程观察到,而构造过程中对对象字段的写入还没有全部对该线程可见。

此时,执行过程可能变成:

线程 A:
为对象分配内存
   ↓
instance 已经指向对象
   ↓
继续初始化对象

线程 B:
发现 instance != null
   ↓
直接返回 instance
   ↓
读取到尚未完全初始化的状态

线程 B 虽然拿到了一个非空引用,但这个对象的构造结果还没有安全地发布给它。

这就是 DCL 中真正危险的地方。

因此,volatile 在这里有两个重要作用。

第一,保证可见性。

线程 A 完成对 instance 的写入后,线程 B 能够看到最新的引用。

第二,保证有序性和安全发布。

instance 的 volatile 写入,不能被重排序到对象初始化操作之前;而线程 B 读取到这个 volatile 引用后,也能够看到线程 A 在发布引用之前完成的初始化操作。

在 Java 内存模型中:

对 volatile 变量的写
        happens-before
后续对同一个 volatile 变量的读

因此,只要线程 B 读取到了线程 A 写入的 instance,它就能同时看到线程 A 在写入 instance 之前完成的对象初始化。

所以,DCL 中的 volatile 并不只是为了让其他线程“及时看到 instance”。

它更重要的作用是:

防止对象引用在初始化完成之前被发布,并保证对象能够被安全地交给其他线程使用。


七、volatile 到底解决了什么?

很多人只记住一句话:

volatile 保证可见性。

这句话没有错,但并不完整。

volatile 主要提供两方面的内存语义:

可见性
+
有序性

1. 可见性

当一个线程修改 volatile 变量时,其他线程再次读取这个变量,能够看到最新的值。

例如:

volatile boolean running = true;

一个线程执行:

running = false;

另一个线程不断读取 running 时,能够观察到这次修改。

但需要注意:

volatile 只保证单次读取和写入的可见性,并不能让复合操作自动具备原子性。

例如:

volatile int count = 0;

count++;

count++ 实际上包含:

读取 count

加 1

写回 count

多个线程同时执行时仍然可能发生数据竞争。

因此:

volatile 不能代替锁,也不能保证所有操作都是线程安全的。

2. 有序性

volatile 还会限制特定的指令重排序。

对于 volatile 写:

data = 100;
ready = true; // volatile 写

Java 内存模型要求:

data = 100
不能被重排序到
ready = true
之后

对于 volatile 读:

if (ready) {  // volatile 读
    System.out.println(data);
}

读取 ready 之后的普通读操作,也不能被随意移动到 volatile 读之前。

于是,两个线程之间形成了这样的关系:

线程 A:

写入普通变量
     ↓
写入 volatile 变量


线程 B:

读取 volatile 变量
     ↓
读取普通变量

如果线程 B 读取到了线程 A 写入的 volatile 值,那么线程 A 在 volatile 写之前完成的普通写入,对线程 B 也必须可见。

这就是 volatile 的核心语义:

volatile 不只是让某个变量本身可见,它还可以建立线程之间的 happens-before 关系。

有了 MESI,为什么还需要 volatile?一文搞懂 CPU 乱序执行 4.png

3. volatile 和内存屏障

为了实现这些语义,JVM 会在生成机器代码时加入必要的内存顺序约束。

在经典的内存屏障模型中,常见屏障包括:

LoadLoad
LoadStore
StoreStore
StoreLoad

它们分别用于限制不同类型内存操作之间的重排序。

可以简单理解为:

LoadLoad:限制“读—读”重排序

LoadStore:限制“读—写”重排序

StoreStore:限制“写—写”重排序

StoreLoad:限制“写—读”重排序

但需要注意:

并不是每次 volatile 读写前后,都会机械地插入全部四种屏障。

JMM 规定的是 volatile 必须表现出的内存语义;JVM 会根据具体操作和 CPU 架构,生成满足这些语义的最小指令组合。

通常可以把 volatile 的语义理解为:

volatile 写具有 release 语义

volatile 读具有 acquire 语义

release 保证发布之前的写操作不会跑到发布之后;

acquire 保证读取之后的操作不会跑到读取之前。

两者配合,完成线程之间的数据发布与读取。

有了 MESI,为什么还需要 volatile?一文搞懂 CPU 乱序执行 5.png


八、CPU 如何真正限制乱序执行?

Java 内存模型规定了程序必须表现出的结果,但 JMM 本身并不直接执行任何机器指令。

真正将这些规则落实到底层的是:

Java 编译器
   ↓
JIT 编译器
   ↓
JVM
   ↓
具体 CPU 架构

首先,编译器和 JIT 必须限制自身的重排序。

它们不能把本应受到 volatile 或锁保护的操作,优化到不正确的位置。

这种约束通常可以称为:

Compiler Barrier

也就是编译器屏障。

它首先解决的是:

不允许编译器在这里进行破坏内存语义的代码移动。

但只限制编译器还不够。

现代 CPU 内部还存在:

乱序执行

Store Buffer

失效队列

多级缓存

推测执行

即使生成的机器指令顺序没有变化,不同核心观察到内存操作的顺序也可能不同。

因此,在必要时,JVM还需要生成特定的硬件屏障指令。

例如,在 x86 架构中可能涉及:

mfence

带 lock 前缀的指令

在 ARM 架构中,则可能使用:

dmb

不同处理器的内存模型不同,因此 JVM 生成的指令也不一样。

x86 的内存模型相对较强,很多“读—读”“读—写”和“写—写”顺序天然就能得到保证。

它最需要额外处理的通常是:

StoreLoad

也就是前面的写操作与后面的读操作之间的顺序。

因此,在 x86 上,JVM 不一定需要为每一种内存顺序都生成一条独立的硬件屏障指令。

而在 ARM 等内存模型较弱的处理器上,JVM通常需要使用更多明确的屏障指令,才能实现相同的 Java 内存语义。

内存屏障的作用也不能简单理解为:

屏障前面的所有指令必须全部执行结束,后面的指令才能开始。

更准确地说,它限制的是:

特定类型的内存操作

不能以不被允许的顺序

被其他处理器观察到

它关注的重点不是单个 CPU 核心内部的时间顺序,而是多个线程之间能够观察到的内存顺序。

因此,内存屏障并不是告诉 CPU:

这里完全不能优化。

而是告诉编译器和 CPU:

这里的内存操作必须满足规定的顺序和可见性,不能进行破坏语义的重排序。

最终,Java 中的一行:

volatile boolean ready;

背后连接的是一整套机制:

Java 内存模型

happens-before 规则

JIT 编译器屏障

CPU 内存屏障

缓存一致性机制

它们共同保证了:

一个线程正确发布的数据,能够被另一个线程按照规定的顺序观察到。

有了 MESI,为什么还需要 volatile?一文搞懂 CPU 乱序执行 6.png


九、CPU 还有哪些底层优化?

除了乱序执行,

现代 CPU 还有很多性能优化技术。

例如:

技术作用
Cache Line提高缓存利用率
Cache Line Alignment避免伪共享
Write Combining合并多个写操作
Cache Coherence保证多核缓存一致
NUMA就近访问内存,提高多 CPU 性能

这些技术共同决定了一台服务器真正的性能上限。

有了 MESI,为什么还需要 volatile?一文搞懂 CPU 乱序执行 7.png


十、总结

很多人学习 volatile 时,只停留在 Java 语法层。

真正理解之后,你会发现:

Java
↓
JMM
↓
HotSpot
↓
CPU
↓
晶体管

这是一条完整的技术链路。

volatile 不是魔法。

它只是 JVM 对 CPU 能力的一层抽象。

理解 CPU 为什么会乱序执行,理解内存屏障为什么存在,理解缓存一致性协议如何工作,你才真正理解了 Java 并发的底层逻辑。

0

评论区