JVM、Java Memory Model 与 CPU:为何在 x86 上正常工作,却在 ARM 上出错
Source: Dev.to
引言
“但在我的机器上从未出现过问题。”
很可能是因为你的机器是 x86。换成 ARM,一些“沉睡”的并发 bug 就会出现。
核心思想:x86 在实践中往往更“保守”,而 ARM 允许更多的重排。如果你的代码依赖硬件的“好心”行为,它可能在 x86 上能跑通,却在 ARM 上失败。
Java 内存模型 (JMM)
JMM 并不仅仅是“线程共享内存,就这样”。它定义了:
- 可见性 – 一个线程写入的内容何时能被另一个线程看到。
- 顺序 – 允许哪些重排。
- happens‑before – 在线程之间建立真实保证的关系。
如果两个操作之间没有 happens‑before,则没有以下保证:
- 看到最新的值;
- 按写入的顺序看到数据;
- 看到一个“已经准备好”的对象。
这就是许多“幽灵” bug 的根源。
经典示例
x = 42;
ready = true;
很多人以为,只要看到 ready == true,另一线程就一定会看到 x == 42。JMM 允许在没有同步的情况下,另一线程观察到:
ready == true
x == 0
为什么?
x可能仍在缓存/寄存器中;- 写操作可能被重排;
- 另一线程可能读取到旧值。
这是一种极其隐蔽的 bug。
对象的安全发布
instance = new MyObject();
虽然看起来是一次操作,但底层实际上会经历:
- 分配内存;
- 初始化字段(构造函数);
- 发布引用(
instance指向对象)。
如果没有同步,JVM/CPU 实际上可能允许发布步骤(3)在初始化步骤(2)对其他线程可见之前就发生。于是第二个线程可能执行:
if (instance != null) {
instance.doSomething();
}
instance 已经不是 null,但对象的字段仍可能处于“默认”状态(0/null)。在 double‑checked locking 等模式中,这已经导致了真实的生产环境问题。
使用 volatile
private static volatile MyObject instance;
将引用声明为 volatile 带来两个重要保证:
- 可见性 – 读取
volatile时会看到最新的值(不会被本地缓存卡住)。 - 顺序 –
volatile在变量周围创建屏障,阻止某些重排。
对 volatile 的写 happens‑before 随后对同一 volatile 的任何读取。实际上,如果线程 B 读取到非空的 instance(volatile),它就保证能看到线程 A 在发布引用之前所做的所有写入(包括构造函数中对对象的写入)。
JIT 如何处理 volatile
对 volatile 的写
在编译对 volatile 的写入时,JVM 必须确保:
- 之前的所有写入不会被“挂起”而不可见;
- 该值的发布不会“抢先”于之前的操作。
实现方式是围绕访问插入 memory fences/barriers(具体指令取决于架构和 JIT)。
对 volatile 的读
在编译对 volatile 的读取时,JVM 必须确保:
- 不会从缓存/寄存器中读取到旧值;
- 随后的读取不会被移动到这次读取之前。
同样会使用屏障和具有相应语义的指令。
x86 与 ARM
在 x86 上,许多重排不那么激进,平台往往会“帮忙”,即使你没有显式请求。这 并不 表明代码是正确的——只是 bug 可能不易显现。
在 ARM 上,允许的重排更多,架构要求显式同步来保证顺序和可见性。如果没有使用 volatile、synchronized、锁或原子类,ARM 更容易暴露出 bug。
结果:同一个在 x86 上“正常”的程序,在 ARM 上高负载时可能会失败。
实用规则(不谈哲学)
如果存在线程间共享,选择合适的策略:
| 策略 | 何时使用 |
|---|---|
volatile | 简单标志/状态以及安全发布引用 |
synchronized / Lock | 不变式和复合操作 |
Atomic* | 无锁的原子操作(CAS),但有各自的成本和限制 |
如果没有显式的 happens‑before,就是在押注架构和运气。