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

目 录CONTENT

文章目录

CPU乱序执行_内存屏障与Java内存模型

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

前言

前面的文章已经把 CPU Cache、Cache Line、MESI 和 False Sharing 串了起来。

但理解到这里,很容易产生另一个错觉:

既然多个 CPU Core 之间已经有缓存一致性协议,那么只要内存里的数据最终能够保持一致,多线程程序是不是就不会再出问题了?

答案显然不是。

下面这段 Java 代码看起来非常简单:

int a = 1;
int b = 2;
int c = a + b;

我们平时会自然地认为:

第一行
  ↓
第二行
  ↓
第三行

CPU 就会严格按照源代码的先后顺序,一条一条执行。

但现代处理器为了追求性能,实际做的事情远比这个模型复杂:

  • 前一条指令正在等待内存,后面的指令一定要陪着等吗?
  • 两条没有依赖关系的指令,为什么不能同时执行?
  • CPU 内部可以乱序,为什么单线程程序通常又不会算错?
  • 两个线程都没有写错代码,为什么却可能观察到“违反直觉”的结果?
  • DCL 单例里的 volatile 到底在防什么?
  • volatile 是不是简单粗暴地“禁止 CPU 乱序”?
  • LoadLoad、StoreStore、LoadStore、StoreLoad 到底是谁定义的?
  • x86 的 lfencesfencemfence,和 Java 的 happens-before 又是什么关系?
  • NUMA 为什么又会影响 JVM 的内存分配?

如果把这些概念分开背,会得到一堆名词:

Out-of-Order
Memory Barrier
volatile
happens-before
Store Buffer
NUMA

但它们真正有价值的地方,是能够组成一条完整的因果链:

CPU 等待数据
乱序与并行执行
单线程仍需保持正确
多线程出现可见性与顺序问题
JMM 定义同步语义
volatile / 锁 / happens-before
JIT 映射为内存顺序约束
CPU Fence / 原子指令

这篇文章就沿着这条链往下走。

一、CPU 为什么会乱序执行?

1. CPU 为什么不能一直等待内存?

假设 CPU 正准备执行下面几步:

指令 1:读取内存中的 x
指令 2:计算 2 + 3
指令 3:计算 8 * 4
指令 4:使用 x 做计算

其中指令 4 依赖指令 1:

指令1 → 指令4

但指令 2、指令 3 和 x 没关系。

如果 CPU 采用最机械的执行方式:

读取 x
  ↓
等内存
  ↓
等内存
  ↓
x 回来了
  ↓
算 2 + 3
  ↓
算 8 * 4

那么 CPU 在等待内存期间,大量执行资源都处于空闲状态。

更合理的做法当然是:

读取 x
  ↓
等待 x 的同时
  ├── 算 2 + 3
  └── 算 8 * 4
  ↓
x 返回
  ↓
执行依赖 x 的指令

这就是理解乱序执行最重要的出发点:

乱序执行不是为了“打乱程序”,而是为了在某条指令暂时无法继续时,让其他已经具备条件的工作先跑起来。

2. 用后厨出餐理解乱序执行

换一个场景。

假设一家后厨正在准备一份套餐,需要完成:

  • 烤箱里烤鸡;
  • 切配沙拉;
  • 装饮料;
  • 给烤好的鸡肉淋酱;
  • 最后一起装盘。

其中“淋酱”必须等鸡肉烤好,但切沙拉和装饮料并不依赖烤箱。

如果厨师采用严格串行方式:

把鸡放进烤箱
      ↓
站在烤箱前等待
      ↓
鸡肉烤好
      ↓
切沙拉
      ↓
装饮料
      ↓
淋酱、装盘

等待烤箱的这段时间就完全浪费了。

更合理的做法是:

鸡肉进入烤箱
等待烘烤完成
同时切配沙拉
同时准备饮料
鸡肉出炉
给鸡肉淋酱
套餐装盘

这里最关键的不是“把步骤打乱”,而是区分两类关系:

没有依赖:
烤鸡进行中 ←→ 切沙拉 / 准备饮料

存在依赖:
鸡肉烤好 → 淋酱

CPU 的思路非常类似。

某条指令正在等待内存数据时,如果后面的指令已经具备操作数,而且不存在必须等待的依赖关系,那么处理器就有机会先利用空闲的执行单元去处理这些工作。

但真正依赖前序结果的操作仍然不能凭空越过去。

所以乱序执行真正追求的是:

让“已经准备好的工作”先使用空闲硬件资源,而不是为了改变程序逻辑而故意打乱顺序。

3. 乱序执行不是随便打乱顺序

真实处理器并不是看到两条 Java 语句没有依赖,就直接交换它们。

现代 CPU 内部通常还有:

  • 指令 Fetch;
  • Decode;
  • 微操作;
  • 寄存器重命名;
  • 调度器;
  • Reservation Station / Scheduler;
  • Load / Store Queue;
  • Reorder Buffer;
  • 多个执行端口;
  • 分支预测;
  • 推测执行。

一个更接近现代处理器的抽象流程是:

Fetch
Decode
Rename
Dispatch
等待操作数就绪
Out-of-Order Execute
Write Result
Retire / Commit

内部执行顺序可以和程序顺序不同,但最终提交体系结构状态时,又要满足处理器定义的规则。

所以:

乱序执行描述的是 CPU 内部为了提高吞吐量而进行的动态调度,不等于“程序语义也可以随便乱”。

二、重排序到底发生在哪些层级?

讨论 Java 并发时,经常把所有问题都统一叫:

CPU 指令重排序。

这其实会把几个不同层级混在一起。

1. 从 Java 代码到 CPU 的三个层级

一段 Java 代码从源代码到真正执行,会经过:

Java 源代码
javac
JVM 字节码
HotSpot 解释器 / JIT
目标平台机器指令
CPU 微架构执行

这里至少可能出现三种不同意义上的“顺序变化”。

编译器 / JIT 优化

只要不破坏语言允许的可观察行为,编译器可以:

  • 消除无用代码;
  • 合并操作;
  • 移动计算;
  • 做公共子表达式消除;
  • 做循环优化;
  • 调整内存操作顺序。

ISA 的内存顺序模型

不同处理器架构,对 Load / Store 向其他处理器可观察的顺序有不同约束。

例如 x86 的内存顺序通常比 ARM 更强,但也不是“所有 Load / Store 都绝对按照程序顺序立即对其他 Core 可见”。

CPU 微架构的乱序执行

处理器内部可以让已经准备好的微操作提前进入执行单元,只要最终满足 ISA 和内存顺序模型。

因此后面看到某个多线程结果时,不能一上来就断言:

一定是 CPU 把这两条指令交换了。

因为你观察到的是整条链最终产生的行为。

三、(0, 0) 实验到底说明了什么?

下面这个小程序经常用来讲“指令重排序”。

初始状态:

int x = 0;
int y = 0;
int a = 0;
int b = 0;

两个线程分别执行:

Thread 1             Thread 2

a = 1;               b = 1;
x = b;               y = a;

如果严格按照顺序一致性来理解,并且每个线程内部程序顺序不能改变,那么:

x == 0 && y == 0

无法出现。

为什么?

要让:

x == 0

就意味着 Thread 1 读取 b 时:

b = 1

还没有发生。

而要让:

y == 0

又意味着 Thread 2 读取 a 时:

a = 1

还没有发生。

把两个条件连起来,会形成一个无法满足的循环顺序。

1. 示例代码

可以写一个简单实验:

public class Disorder {

    static int x, y, a, b;

    public static void main(String[] args) throws Exception {

        for (long i = 1; ; i++) {
            x = y = a = b = 0;

            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });

            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });

            t1.start();
            t2.start();

            t1.join();
            t2.join();

            if (x == 0 && y == 0) {
                System.out.println(
                        "第 " + i + " 次出现 (0, 0)"
                );
                break;
            }
        }
    }
}

编译:

javac Disorder.java

运行:

java Disorder

这个实验有两个重要注意事项。

第一,它不是一个保证几秒钟就能复现的程序

不同的:

  • CPU;
  • JDK;
  • JIT 状态;
  • 操作系统调度;
  • 运行环境;

都可能导致复现概率不同,甚至很长时间看不到 (0,0)

第二,也是更重要的一点:

即使真的观察到了 (0,0),也不能据此唯一证明“物理 CPU 把某两条机器指令交换了”。

2. 为什么不能只归因于 CPU 乱序?

这段代码存在 Data Race,没有使用任何同步手段。

Java Memory Model 本身就允许一些不符合顺序一致性直觉的执行结果。

产生最终行为的原因可能涉及:

Java 源代码
   ↓
JIT 优化
   ↓
机器指令
   ↓
Store Buffer / Memory Ordering
   ↓
CPU 执行

因此更严谨的结论是:

这个实验说明:没有同步关系的数据竞争程序,不能用“每个线程都严格按源码顺序、所有线程立刻看到彼此写入”的模型去推理。

这和“直接抓到 CPU 内部某两条微操作交换位置”是两件不同的事情。

四、DCL 为什么需要 volatile?

乱序和内存可见性真正影响业务代码的经典案例,就是 Double-Checked Locking。

1. DCL 的问题出在哪里?

先看错误版本:

public class Singleton {

    private static Singleton instance;

    private int value;

    private Singleton() {
        value = 8;
    }

    public static Singleton getInstance() {

        if (instance == null) {

            synchronized (Singleton.class) {

                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }

        return instance;
    }
}

它有两层检查:

第一次检查
    ↓
instance 已经存在?
    ├── 是 → 直接返回
    └── 否 → 进入 synchronized
                 ↓
             第二次检查
                 ↓
              创建对象

看起来锁已经足够了。

问题出在:

instance = new Singleton();

并不是我们想象中的一个不可分割的“神奇动作”。

2. 查看真实字节码

把实例变量改成正确的 volatile 版本:

private static volatile Singleton instance;

在本文使用的 OpenJDK 21.0.11 环境中编译:

javac Singleton.java

然后:

javap -c -p Singleton.class

关键位置可以看到类似:

17: new           #8
20: dup
21: invokespecial #17   // Method "<init>":()V
24: putstatic     #13   // Field instance:LSingleton;

也就是 Class 文件中的字节码顺序清楚地写着:

new
 ↓
dup
 ↓
invokespecial <init>
 ↓
putstatic

这就带来一个很重要的纠正。

3. 字节码真的被直接重排了吗?

很多 DCL 教程会说:

1. 分配内存
2. 执行构造方法
3. 把引用赋给 instance

然后:

2 和 3 可能重排序

于是变成:

1. 分配内存
3. 发布引用
2. 执行构造

这个模型很适合建立直觉,但如果把它理解成:

Class 文件里的 invokespecialputstatic 两条字节码会被 JVM 直接交换位置。

就不准确了。

javap 已经能看到,字节码本身仍然保持:

new → invokespecial → putstatic

真正的问题在于:

如果 instance 不是 volatile,另一个线程对这个对象引用的读取与构造线程中的普通写之间,没有建立可靠的 happens-before 关系。

因此从另一个线程可观察的角度,不能保证构造过程中对对象状态的写入,在它读到这个引用时已经按照需要全部可见。

所谓“看到半初始化对象”,更适合理解成一种不安全发布的可见性模型,而不是简单理解成“Class 文件把两条字节码调换了”。

4. 正确写法

public class Singleton {

    private static volatile Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {

        if (instance == null) {

            synchronized (Singleton.class) {

                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }

        return instance;
    }
}

关键就是:

volatile

它在这里不仅是“让别的线程马上看到 instance 不为 null”这么简单。

更重要的是建立:

构造线程对对象状态的写
        ↓
volatile 写 instance
        ↓
happens-before
        ↓
另一个线程 volatile 读 instance
        ↓
安全观察前面的初始化结果

这才是理解 DCL 的核心。

五、volatile 到底保证什么?

volatile 是 Java 并发里最容易被一句话说坏的关键字之一。

常见说法是:

volatile 保证可见性,并禁止指令重排序。

方向没错,但还需要继续拆开。

1. volatile 为什么不保证复合原子性?

例如:

volatile int count = 0;

执行:

count++;

仍然不是一个不可分割操作。

它至少可以抽象成:

读取 count
   ↓
+1
   ↓
写回 count

两个线程同时执行时仍然可能丢失更新。

所以:

volatile ≠ 锁
volatile ≠ 所有操作原子化

2. volatile 如何建立同步关系?

Java Language Specification 对 volatile 给出的关键关系之一是:

对某个 volatile 字段的写,happens-before 后续对同一个字段的读。

因此:

data = 100;
ready = true; // volatile

另一个线程:

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

一旦这个线程通过 volatile 读观察到对应的:

ready = true

Java Memory Model 就为前面的普通写建立了需要的可见性关系。

可以画成:

普通写 data = 100
volatile 写 ready = true
happens-before
volatile 读 ready
读取 data

所以 volatile 最值得记住的不是某一条固定汇编指令,而是:

它在 Java 内存模型中建立了一条跨线程的同步边。

六、Java 的内存屏障怎么落到 CPU?

这里需要特别小心,因为“内存屏障”这个词经常同时出现在:

JMM
HotSpot
CPU

三个层级。

如果不分层,很容易越学越乱。

1. JLS 到底规定了什么?

Java Language Specification 负责规定的是:

  • volatile 的同步语义;
  • monitor 的同步语义;
  • Thread.start / join 等跨线程关系;
  • happens-before;
  • 哪些执行属于 Java 语言允许的行为。

它不会规定:

x86 必须生成 mfence
ARM 必须生成 dmb

因为 JVM 的目标之一就是跨平台。

真正到了 HotSpot,需要由 JVM 实现把这些语言层语义映射到底层硬件。

2. LoadLoad 等四种屏障是什么?

经常会看到四种屏障:

LoadLoad
LoadStore
StoreStore
StoreLoad

它们不是四条 JVM 字节码,也不是 JLS 要求 CPU 必须存在的四条真实机器指令。

OpenJDK HotSpot 的 OrderAccess 接口明确说明:

这套接口基于 JSR-133 Cookbook for Compiler Writers。

它定义这些抽象屏障,是为了让 JVM 编译器能够用一个跨平台模型描述:

哪些 Load / Store 不能跨过彼此移动

例如:

Load1
LoadLoad
Load2

表达:

Load1 不能越过屏障跑到 Load2 后面

同理:

Store1
StoreStore
Store2

限制两个 Store 的相对移动。

3. volatile 的四屏障模型

一种常见的编译器教学模型是:

volatile 写

普通 Store
普通 Store
    ↓
StoreStore
    ↓
volatile Store
    ↓
StoreLoad
    ↓
后续 Load

volatile 读

volatile Load
    ↓
LoadLoad
LoadStore
    ↓
后续 Load / Store

这个模型非常适合理解:

volatile 为什么能够限制它周围的内存操作重排序。

但一定要加一句:

这是内存屏障抽象模型,不等于每次 volatile 访问都会在所有 CPU 上原样生成四条硬件 Fence。

4. 为什么 x86 和 ARM 实现不同?

因为不同 ISA 的内存顺序模型不同。

以 x86 为例,很多 LoadLoad、LoadStore、StoreStore 顺序本身已经由其内存模型提供较强保证。

HotSpot 当前 OrderAccess 源码中的架构映射表也明确体现了这一点:

x86 上很多 acquire / release 场景
不需要额外硬件 fence

StoreLoad / full fence
才需要更强的处理

而在更弱内存顺序的架构上,JVM 可能需要插入不同的 Barrier 指令。

所以我们写:

volatile boolean ready;

Java 代码完全一样。

真正落到底层时却可能是:

x86-64
   ↓
一种指令序列

AArch64
   ↓
另一种指令序列

这正是 JVM 实现需要完成的工作。

七、x86 Fence 与 LOCK 如何保证顺序?

从 Java 再向下一层,就进入真实 CPU 指令。

1. LFENCE / SFENCE / MFENCE

在 x86 中可以看到:

LFENCE
SFENCE
MFENCE

可以先建立如下直觉:

指令主要关注
LFENCELoad 相关顺序与执行约束
SFENCEStore 相关顺序
MFENCE前后 Load / Store 的完整内存顺序约束

但不要把它们机械翻译成:

LoadLoad = lfence
StoreStore = sfence
StoreLoad = mfence

因为:

JVM 抽象屏障
        ≠
某一条固定 CPU 指令

JVM 会结合目标架构自身的内存模型选择实现方式。

2. LOCK 真的会锁住整个总线吗?

旧资料经常会说:

x86 的 LOCK 指令执行时会锁住内存总线。

这是历史上非常常见的解释,但对于现代 x86 来说过于绝对。

对于正常的、可缓存内存,现代处理器通常可以依靠:

Cache Coherence
+
Cache Line 所有权

完成带 LOCK 的原子操作,而不需要让整个外部内存总线停止工作。

只有某些特殊场景,例如:

  • Split Lock;
  • 不可缓存内存;
  • 特殊内存类型;

才可能走更昂贵的总线级路径。

这一点和前面讲 MESI 时是一致的:

现代多核处理器会尽量把同步控制限制在必要的 Cache Line 和一致性域中,而不是动不动就封锁整个内存系统。

3. volatile 固定使用 lock addl 吗?

也不能这么绝对地记。

OpenJDK HotSpot 的 OrderAccess 当前源码中,在 x86 的某些 Fence 路径上确实使用了 locked add 一类操作来实现强屏障。

但:

volatile
   ↓
一定固定生成
lock addl $0, (%rsp)

不是 Java 语言规范。

具体代码生成取决于:

  • JDK 版本;
  • HotSpot 实现;
  • JIT 编译器;
  • CPU 架构;
  • 当前操作类型;
  • 上下文中已有的顺序保证。

所以面试里真正应该回答的是:

volatile 的语义由 JMM 规定,HotSpot 再依据目标 CPU 的内存模型,用编译器屏障、Fence 或原子指令等手段实现。

而不是背死某一条汇编模板。

八、happens-before 到底解决什么?

如果说 Memory Barrier 更接近“实现手段”,那么 happens-before 更接近:

Java 程序员应该用什么规则判断线程之间的可见性和顺序。

1. happens-before 不是物理时间顺序

假设:

A happens-before B

它并不是在说:

CPU 内部必须真的先把 A 的所有微操作执行完,再开始碰 B。

JLS 甚至明确允许实现重排序,只要最终产生的结果仍然符合合法执行。

所以 happens-before 更重要的含义是:

A 对 B 可见
+
A 在 Java 内存模型规定的顺序上先于 B

2. happens-before 从哪里建立?

很多教程会整理成:

happens-before 八大规则。

这种记忆方式没有错,但容易让人误以为 JLS 就有一个标题叫“八大规则”。

更接近规范的理解是:

Program Order
Happens-Before
Synchronizes-With
Transitivity

Program Order

同一个线程内,动作按照该线程的程序顺序建立 happens-before。

Synchronizes-With

一些同步动作会建立跨线程边,例如:

unlock
   ↓
后续对同一 monitor 的 lock
volatile write
   ↓
后续对同一字段的 volatile read
Thread.start()
   ↓
新线程中的动作

以及线程终止与其他线程检测到其终止之间的关系。

Transitivity

如果:

A hb B
B hb C

那么:

A hb C

这就是为什么 volatile 可以把前面的普通写“传递”给另一个线程。

3. 为什么不能只说“刷新主内存”?

很多 Java 并发教程喜欢说:

volatile 写会立即刷新主内存,volatile 读会立即从主内存读取。

这种说法适合作为极粗糙比喻,但现代 CPU 并不是:

Core 0
  ↓
每次 volatile 都写 DRAM
  ↓
Core 1 再去 DRAM 取

中间还有:

  • Store Buffer;
  • Cache;
  • Coherence;
  • 共享 LLC;
  • 内存控制器。

因此真正跨平台、不会误导的描述应该是:

volatile 建立 Java Memory Model 要求的可见性和顺序关系;JVM 再利用目标硬件的内存顺序机制实现这些语义。

九、as-if-serial:乱序后为什么单线程不乱?

下面两句:

int a = 1;
int b = 2;

如果后面的程序没有依赖它们的先后顺序,编译器和 CPU 有很大优化空间。

但:

int a = 1;
int b = a + 1;

第二句依赖第一句的结果。

无论内部怎样优化,当前线程自己观察到的行为都必须符合 Java 的单线程语义。

这就是理解 as-if-serial 的核心:

实现可以改变内部执行方式,但不能让单线程程序观察到一个违反自身语言语义的结果。

1. 它不是把指令重新排回源码顺序

不要理解成:

先乱序执行
  ↓
最后又把所有指令重新排回原来的物理顺序

CPU 不需要这么做。

只需要保证:

对当前线程而言,结果和一个合法的顺序执行一致。

多线程为什么更难?

因为另一个线程可能成为新的“观察者”。

原来单线程不可观察的内部顺序变化,在缺少同步时,可能通过共享内存表现出来。

于是我们才需要:

volatile
synchronized
Lock
Atomic
VarHandle

这些同步工具。

十、CPU 如何优化写入?

前面多次提到 CPU 不愿意等待。

读数据如此,写数据也是如此。

1. Store Buffer:先把写操作缓冲起来

如果 CPU 每执行一个 Store,都必须等待整个 Cache Coherence 流程完成以后才能继续,执行流水线会经常停住。

于是现代处理器通常存在 Store Buffer 一类结构,让 Store 可以先进入缓冲,再在满足内存顺序规则的前提下完成后续传播。

可以先建立:

执行单元
   ↓
Store Buffer
   ↓
Cache / Coherence
   ↓
更下层内存体系

这也是为什么:

“当前 Core 已经执行了 Store”与“其他 Core 已经观察到了这个 Store”不是完全相同的概念。

2. Write Combining:合并多次写入

Write Combining 是另一种写入优化思想:

多次相邻写
   ↓
在合适的缓冲结构中合并
   ↓
更高效地向下层传输

它在图形缓冲、设备内存、非时间性写入等场景尤其有价值。

但这里不要记一个错误模型:

CPU 固定有一个 4 字节 WC Buffer,ALU 写满 4 字节后就刷入 L2。

真实的 Write Combining Buffer:

  • 数量是微架构相关的;
  • 大小和组织方式不是 Java 可以依赖的固定常量;
  • 和普通 Write-Back Cache 下的 Store Buffer 也不是同一个概念;
  • 数据流也不能简单画成“ALU → 4B WC → L1 → L2”。

所以这一部分了解目的即可:

现代 CPU 不仅会优化“什么时候读”,还会通过缓冲、合并等方式优化“什么时候把写入继续向下传播”。

至于具体某款处理器有几个 Buffer、怎么合并,不值得普通 Java 代码依赖。

十一、NUMA:为什么内存也有远近?

在普通桌面电脑上,我们很容易把内存想成:

所有 CPU Core
      ↓
同一块内存

访问任何内存地址的成本都差不多。

在大型多 Socket 服务器上,这个模型就不够用了。

1. UMA:统一内存访问

最简单的抽象:

CPU / Core
Shared Memory
CPU / Core
CPU / Core
CPU / Core

所有处理器看到一个相对统一的主内存访问模型。

2. NUMA:本地内存与远程内存

NUMA 是:

Non-Uniform Memory Access
非统一内存访问

在典型多 Socket 机器上,可以想象成:

NUMA Node 1
NUMA Node 0
Remote Access
Remote Access
Socket / CPU 1
Local Memory 1
Socket / CPU 0
Local Memory 0

CPU 0 访问:

Local Memory 0

通常比访问:

Remote Memory 1

延迟更低、带宽条件也更好。

于是出现一个很现实的问题:

如果线程一直在 CPU 0 上执行,但它最频繁访问的数据全被放到了 Node 1,会发生什么?

程序当然还能运行,但会不断付出跨 NUMA Node 的访问成本。

3. Linux 如何查看 NUMA?

在 Linux 上可以执行:

lscpu | grep -E 'NUMA node'

本文测试环境得到:

NUMA node(s): 1
NUMA node0 CPU(s): 0-4

说明这个环境只暴露一个 NUMA Node,因此没法直接观察 Local / Remote 内存差异。

在真正的多 Socket NUMA 服务器上,则可能看到:

NUMA node(s):        2
NUMA node0 CPU(s):   ...
NUMA node1 CPU(s):   ...

如果安装了 numactl,还可以:

numactl --hardware

查看各 NUMA Node 的 CPU、内存容量以及 Node Distance。

4. NUMA 为什么会影响 Java?

如果 JVM 的 Heap 很大,又横跨多个 NUMA Node,那么对象分配位置就可能影响后续访问成本。

这也是垃圾收集器会考虑 NUMA 的原因。

例如 G1 从 JDK 14 的 JEP 345 开始提供 NUMA-Aware Allocation:

-XX:+UseNUMA

在支持的 Linux NUMA 环境里,G1 会尝试让年轻代对象分配更贴近当前线程所在的 NUMA Node。

ZGC 也具备 NUMA Awareness,OpenJDK 在 ZGC 成为生产特性的 JEP 377 中还专门列出了对 NUMA Awareness 的改进。

但不要进一步推导成:

Java 对象永远会被放到当前 CPU 最近的内存。

实际行为还受到:

  • GC 类型;
  • JDK 版本;
  • UseNUMA 配置;
  • 操作系统;
  • CPU Affinity;
  • 线程迁移;
  • Heap 布局;

等因素影响。

所以 NUMA 更适合记成:

在多 Socket 服务器上,“内存在哪里”也可能成为 JVM 性能问题。

十二、常见误区

1. 关于乱序与实验

误区 1:出现 (0,0) 就证明 CPU 把两条机器指令交换了。

不准确。

它说明这段存在 Data Race 的程序不能按照顺序一致性模型推理。最终行为可能同时涉及 JIT 优化、硬件 Memory Ordering、Store Buffer 和 CPU 微架构。

误区 2:CPU 可以把任何两条指令随便交换。

错误。

数据依赖、控制依赖、ISA 内存顺序、同步指令以及最终可观察语义都会限制优化空间。

2. 关于 DCL 与 volatile

误区 3:DCL 的问题就是 JVM 把 invokespecialputstatic 两条字节码直接互换。

不准确。

Class 文件中的字节码顺序仍然可以是:

new → dup → invokespecial → putstatic

真正要解决的是没有同步时的安全发布、可见性和内存顺序问题。

误区 4:volatile 能让 count++ 变成原子操作。

错误。

volatile 不会把“读 → 改 → 写”这种复合操作自动合成原子事务。

3. 关于内存屏障

误区 5:JLS 定义了 LoadLoad、StoreStore、LoadStore、StoreLoad 四条 JVM 指令。

错误。

JLS 定义的是 Java 内存模型语义。这四种 Barrier 是 JSR-133 Cookbook / JVM 实现中常用的跨平台内存顺序抽象。

误区 6:每个 volatile 写都固定生成 lock addl $0, (%rsp)

错误。

这是特定 HotSpot、特定架构、特定实现路径中可能出现的实现方式,不是 Java 语言规范。

误区 7:x86 LOCK 一定会锁住整个内存总线。

错误。

现代处理器对正常可缓存内存通常优先依靠 Cache Coherence 实现原子访问;总线级锁定属于更特殊的情况。

4. 关于 happens-before 和 NUMA

误区 8:happens-before 就是物理时间上 A 必须彻底执行完才能执行 B。

错误。

它定义的是 Java 内存模型中的顺序与可见性关系。实现仍然可以优化,只要最终行为满足规范。

误区 9:JLS 明文规定了“happens-before 八大规则”。

不准确。

“八大规则”是常见教学整理。JLS 更正式地通过 Program Order、Synchronizes-With 和传递闭包定义 happens-before。

误区 10:NUMA 是缓存一致性的一部分。

错误。

NUMA 解决的是多处理器系统中的内存拓扑与访问局部性问题;Cache Coherence 解决的是多个缓存副本的一致性。两者会共同影响性能,但不是同一个概念。

十三、把整条执行链串起来

现在重新回答最开始的问题:

CPU 为什么可以乱序,而 Java 多线程程序又不能随便乱?

完整链路是:

CPU 与内存存在巨大延迟差
流水线 / 多执行单元
Out-of-Order Execution
提高硬件利用率
单线程必须保持合法语义
as-if-serial / intra-thread semantics
多线程共享内存
不同线程可能观察到非直觉顺序
JMM 定义同步关系
volatile / monitor / start / join
happens-before
JIT 编译器内存顺序约束
LoadLoad / StoreStore 等抽象 Barrier
x86 / ARM 的具体 Fence 或原子指令
Cache / Store Buffer / Coherence
大型多 Socket 系统
NUMA Local / Remote Memory

也就是说:

乱序执行

解决的是:

CPU 怎么更快。

而:

JMM / volatile / happens-before

解决的是:

在允许底层继续优化的同时,Java 程序员怎样得到可靠的跨线程语义。

这两个目标不是敌人。

Java Memory Model 并不是要求 CPU:

从此全部老老实实顺序执行

而是在性能优化和并发正确性之间画出边界:

边界以内
可以优化

跨过同步语义的边界
必须保证程序看到正确结果

十四、总结

理解 CPU 乱序执行之后,最容易犯的错误,是把所有“顺序变化”都归咎于 CPU。

实际上,从 Java 源代码到硬件之间至少存在:

Java 编译器
   ↓
JVM / JIT
   ↓
ISA Memory Model
   ↓
CPU 微架构
   ↓
Cache / Buffer / Coherence

多个层级。

因此讨论并发时,最可靠的方法不是猜:

CPU 到底把哪两条指令交换了?

而是先问:

这两个线程之间有没有建立 Java Memory Model 认可的同步关系?

有了:

volatile
monitor
Lock
Atomic
Thread.start()
Thread.join()

等同步机制,JMM 才能为跨线程行为建立 happens-before。

至于这些语义最后怎样落到 x86、ARM 或其他 CPU 上,是 HotSpot 等 JVM 实现需要完成的工作。

现代 CPU 可以为了性能乱序执行,Java 编译器也可以进行合法优化;真正约束并发程序正确性的,不是“所有指令永远按源码顺序跑”,而是 Java Memory Model 定义的可见性、同步关系与 happens-before。

0

评论区