前言

在 Java 并发编程中,共享变量的可见性问题、指令重排序问题、原子性问题,是导致多线程程序出现诡异 Bug(死循环、计算错误、空指针、状态不一致)的三大核心根源。这些问题并非语言设计缺陷,而是现代计算机硬件架构 + 编译器优化 + Java 内存模型(JMM) 共同作用的必然结果。本篇笔记从硬件底层 → 操作系统 → JVM 内存模型 → 编译优化 → 代码层面逐层拆解,完整解释:

  • 为什么一个线程改了变量,另一个线程永远读不到旧值(可见性)
  • 为什么代码明明写的顺序 A→B→C,实际执行却是 A→C→B(有序性 / 乱序)
  • 为什么 i++ 明明一行代码,多线程下结果却总是少算(原子性 / 不安全)
  • 最终形成一套可落地的问题识别 + 原理理解 + 解决方案体系。

全文结构:

  1. 多线程不安全的本质:共享 + 并发 + 非原子操作
  2. 硬件根源:CPU 缓存架构与缓存一致性
  3. JMM 抽象:主内存与线程工作内存模型
  4. 可见性问题:为什么线程间 “看不见” 对方修改
  5. 重排序问题:编译器 / CPU 为什么要 “乱序执行”
  6. 原子性问题:一行代码为什么不是 “一步完成”
  7. 三大问题汇总与 happens-before 规则
  8. 从根源解决:volatile/synchronized/final/ 原子类原理
  9. 典型场景与实战避坑

一、多线程不安全的本质

先给出最本质结论多线程下共享变量出现 “可见性、乱序、不安全”,本质只有一句话:

多个线程在没有正确同步的情况下,同时读写共享变量,且硬件 / 编译器为了极致性能,打破了「单线程直觉」。

拆成三个必要条件:

  1. 共享:变量是堆内存中的共享对象 / 成员变量(不是局部变量)
  2. 读写:至少一个线程写,至少一个线程读
  3. 不同步:没有使用 volatilesynchronizedLock原子类 等同步机制

只要同时满足这三点,理论上就一定可能出现可见性、重排、原子性问题,只是在低并发、小数据量下 “碰巧不触发”,高并发下必现。

局部变量为什么永远安全?

  • 局部变量在线程栈上,线程私有,不共享
  • 不存在 “多线程同时读写同一个变量”,自然无三大问题

二、硬件底层根源:CPU 缓存架构(一切问题的起点)

要理解可见性,必须先理解CPU 与内存的速度鸿沟

2.1 CPU 与内存的性能差(数量级差距)

  • CPU 指令执行:纳秒级(1ns)
  • 内存访问:百纳秒~几百纳秒
  • 硬盘访问:毫秒级

如果 CPU 每次都直接读写主内存,99% 的时间都在等待内存,CPU 利用率极低。

为此,现代 CPU 设计了多级缓存架构

plaintext

CPU → L1 缓存 → L2 缓存 → L3 缓存 → 主内存(RAM)
  • L1/L2:核心独有(每个 CPU 核心自己的)
  • L3:同插槽多核共享
  • 主内存:所有核心共享

访问速度:L1 ≈ 1nsL2 ≈ 3nsL3 ≈ 10ns内存 ≈ 60~100ns

2.2 缓存带来的问题:数据不一致

CPU 执行时:

  1. 从主内存把数据读到缓存
  2. 在缓存里计算、修改
  3. 择机写回主内存

问题:每个核心有自己的私有缓存,线程 A 在核心 1 修改了变量,只改了自己的缓存,没刷回主内存;线程 B 在核心 2 读到的,还是主内存的旧值,或自己缓存的旧值。

这就是可见性问题的硬件根源

2.3 缓存一致性协议(MESI)为什么不能完全解决?

CPU 厂商定义了缓存一致性协议(如 Intel 的 MESI),试图保证多核缓存一致。

MESI 四种状态:

  • M(Modified):已修改,仅本缓存有效
  • E(Exclusive):独占,与内存一致
  • S(Shared):共享,多核心持有
  • I(Invalid):无效,需重新读内存

MESI 只能保证 “最终一致”,不能保证 “实时一致”

  • 当核心 1 修改变量,会发出消息通知其他核心该缓存行失效
  • 但这个通知是异步的,有延迟
  • 且 CPU 为了避免频繁消息通信,引入了Store Buffer、Invalid Queue 等优化,进一步打破 “实时可见”

结论:硬件层面本身就不保证 “一个线程写完,另一个线程立刻读到新值”,这是性能优先的设计。


三、Java 内存模型(JMM):抽象层的可见性规则

Java 不想让开发者直接面对 CPU 架构差异(x86/ARM/ARM64 缓存规则不同),因此定义了JMM:一套统一的内存访问规范。

3.1 JMM 核心抽象:主内存 vs 线程工作内存

JMM 规定:

  1. 所有共享变量存在于主内存(堆内存)
  2. 每个线程有自己的工作内存(CPU 寄存器 + L1/L2 缓存 + 线程本地缓冲区)
  3. 线程不能直接读写主内存,只能操作自己工作内存中的副本
  4. 线程对变量的所有操作,都必须先拷贝到工作内存 → 修改 → 再同步回主内存

流程:

plaintext

主内存 → 读取 → 线程工作内存(副本)→ 修改 → 写入 → 主内存

3.2 无同步时的行为(问题根源)

在没有 volatile/ synchronized 时,JMM 对 “何时从主内存读、何时写回主内存”几乎没有约束

  • 线程可以一直使用工作内存的旧副本,永远不主动读主内存
  • 线程修改后,可以长时间不刷回主内存
  • 线程之间完全不知道对方的修改

这就是可见性问题的 JVM 层面解释

简单代码示例:

java

运行

boolean stop = false;

// 线程 1
while (!stop) {
    // 循环
}

// 线程 2
stop = true;

现象:线程 2 把 stop = true,但线程 1 可能永远死循环。原因:

  • 线程 1 缓存了 stop = false
  • 没有强制刷新,线程 1 一直读工作内存旧值
  • JMM 允许这种 “长期不可见” 的优化

四、可见性问题深度解析:为什么 “看不见”

4.1 可见性定义

可见性:当一个线程修改了共享变量,其他线程能立即、可靠地看到修改后的值。

无同步 → 无可见性保证。

4.2 导致不可见的三大机制

4.2.1 缓存未刷新(最常见)

线程修改变量 → 只写到Store Buffer / 缓存 → 没有立即刷回主内存。

4.2.2 编译器优化:寄存器分配

JIT 编译器会做激进优化:

java

运行

while (!stop) {}

被优化为:

java

运行

boolean local = stop;
while (!local) {}

把变量永久读到寄存器,不再从内存加载,导致永远看不见新值。

4.2.3 指令重排间接导致不可见

后面会讲:重排会让 “写变量” 的动作晚于后续代码,导致其他线程看到 “变量没改”。

4.3 不可见的典型后果

  1. 线程死循环(如上 stop 标记)
  2. 配置更新不生效(开关、阈值、地址)
  3. 状态机混乱(线程 A 认为状态 = 1,线程 B 认为 = 0)
  4. 并发容器内部状态不一致

4.4 可见性问题的 “随机性”

很多人说:“我本地跑没问题,一上生产就出问题”。原因:

  • 本地 CPU 负载低、核心少、切换少 → 碰巧缓存及时同步
  • 生产高并发、多核心、频繁线程切换 → 缓存不一致被放大

可见性问题不是 “偶尔出现”,是 “理论必然”


五、重排序问题:为什么代码会 “乱序执行”

5.1 什么是重排序

重排序:编译器、JIT、CPU 为了优化性能,在不改变单线程执行结果的前提下,改变指令实际执行顺序。

代码写的:

plaintext

A;
B;
C;

实际执行可能:

plaintext

A;
C;
B;

5.2 重排序的三个层面

  1. 编译器重排(javac / JIT)
  2. 指令级并行重排(CPU 乱序执行)
  3. 内存系统重排(缓存读写重排)

5.3 为什么要重排序?(性能刚需)

5.3.1 CPU 乱序执行

CPU 遇到长延迟指令(如读内存),不会干等,而是先执行后面不依赖它的指令

示例:

plaintext

A = 读内存(慢)
B = 1 + 2

CPU 会先算 B=1+2,再等 A,提升吞吐量。

5.3.2 编译器优化

合并读写、消除冗余、寄存器优化,都会改变指令顺序。

5.4 重排序在单线程下无害,多线程下灾难

as-if-serial 规则

不管怎么重排,单线程执行结果不变

但多线程下,没有同步时,重排会破坏线程间协作逻辑

5.5 经典重排导致的 Bug:DCL 单例空指针

java

运行

public class Singleton {
    private static Singleton instance;

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

new Singleton() 本质分三步:

  1. 分配内存:memory = allocate()
  2. 初始化对象:ctorInstance(memory)
  3. 指向引用:instance = memory

正常顺序:1→2→3重排后可能:1→3→2

后果:

  • 线程 A 执行到 3(instance 非空,但对象还没初始化)
  • 线程 B 进入 if (instance == null) 判断为 false
  • 线程 B 直接返回未初始化的对象,调用方法直接 NPE

这就是重排序导致的线程间安全问题

5.6 重排序与可见性的关系

重排会导致:

  • 写操作被延后 → 其他线程看不见新值
  • 读操作被提前 → 读到旧值
  • 变量之间的依赖关系被打破

重排序 + 缓存不可见 = 多线程 Bug 的主要来源。


六、原子性问题:为什么 i++ 不安全

6.1 原子性定义

原子性:一个操作是不可分割、不可中断的,要么全部执行成功,要么完全不执行。

在多线程中:

  • 操作开始 → 必须完整结束
  • 中间不能被其他线程插入

6.2 为什么一行 Java 代码不是原子操作

Java 代码是高级语言,一行代码对应多条 CPU 指令

i++ 为例:

java

运行

i++;

对应字节码 / CPU 指令:

  1. 读取:从内存 / 缓存读 i 到寄存器
  2. 加 1:寄存器中计算
  3. 写入:写回内存 / 缓存

这三步是可分割的,线程在任意一步都可能被切走。

6.3 多线程下 i++ 的经典丢失写

场景:i = 0,两个线程同时执行 i++

  • 线程 1:读 i=0
  • 线程 2:读 i=0
  • 线程 1:计算 i=1
  • 线程 2:计算 i=1
  • 线程 1:写回 i=1
  • 线程 2:写回 i=1

预期结果:2实际结果:1写丢失

6.4 哪些操作是原子的?

  • 除了 long/double 外,基本类型变量的单次读 / 写(32 位)
  • volatile 修饰的 long/double 的单次读 / 写

但:

  • i++i--a = a + 1if (state == A) state = B全都是复合操作,非原子

6.5 原子性问题 = “不安全” 的直接体现

所谓 “线程不安全”,90% 场景就是:共享变量 + 复合操作 + 无同步


七、三大问题汇总:可见性、有序性、原子性

多线程共享变量不安全,就是三大特性同时缺失

表格

问题名称 本质 表现
可见性 缓存 / 编译器优化导致线程间读不到最新值 死循环、状态不一致、配置不生效
有序性 重排序打破代码执行顺序 DCL 单例 NPE、先使用后初始化、状态错乱
原子性 一行代码对应多条指令,可被中断 写丢失、计数错误、并发修改覆盖

JMM 对未同步的多线程不保证三大特性中的任何一个


八、JMM 如何规范同步:Happens-Before 规则

JMM 不直接描述 “何时刷新缓存、何时禁止重排”,而是用Happens-Before(先行发生) 规则,定义线程间的可见性保证

8.1 Happens-Before 核心含义

如果 A Happens-Before B,那么:

  • A 的操作结果对 B 可见
  • A 的执行顺序排在 B 之前

8.2 常用 Happens-Before 规则(必须掌握)

  1. 程序顺序规则单线程内,前面代码 Happens-Before 后面代码。

  2. volatile 变量规则对 volatile 变量的,Happens-Before 后续。→ 保证可见性 + 禁止重排。

  3. 锁规则(synchronized/Lock)对锁的解锁,Happens-Before 后续加锁。→ 可见性 + 原子性 + 有序性全包。

  4. 线程启动规则thread.start() Happens-Before 线程内任何动作。

  5. 线程终止规则线程内所有操作 Happens-Before thread.join() 返回。

  6. 传递性A HB B,B HB C → A HB C。

只要满足 HB,就不会出现可见性 / 乱序问题


九、从根源解决三大问题

9.1 解决可见性 + 有序性:volatile

volatile 直接针对可见性 + 重排序

9.1.1 volatile 两大语义

  1. 可见性写 volatile 变量 → 立即刷回主内存读 volatile 变量 → 强制从主内存重读

  2. 禁止重排序通过内存屏障(Memory Barrier) 实现:

    • 写屏障:写 volatile 前,所有前面写操作都刷回主内存
    • 读屏障:读 volatile 后,所有后面读操作都从主内存读

9.1.2 volatile 底层:内存屏障

JVM 在 volatile 指令前后插入屏障:

  • StoreStore
  • StoreLoad
  • LoadLoad
  • LoadStore

最终在 x86 上体现为:volatile 写 → 加 lock 前缀指令lock addl $0, 0(%rsp)

作用:

  • 强制缓存刷回主内存
  • 使其他 CPU 缓存行失效
  • 禁止重排

9.1.3 volatile 不保证原子性

volatile int i; i++ 依然不安全。volatile 只保证单次读写原子,不保证复合操作。

9.2 解决全部三大问题:synchronized / Lock

9.2.1 synchronized 三大保证

  1. 原子性:同一时间只有一个线程执行,复合操作不可中断
  2. 可见性:解锁时强制刷回主内存,加锁时强制重读
  3. 有序性:锁内代码重排受限,且满足 HB 规则

9.2.2 锁的内存语义

  • 加锁:相当于读 volatile
  • 解锁:相当于写 volatile

所以锁天然包含 volatile 的所有能力,且多了原子性。

9.3 解决原子性:CAS + 原子类(AtomicInteger)

原子类(AtomicIntegerAtomicBoolean):

  • 底层用 CAS(Compare And Swap) 原子指令
  • 结合 volatile 保证可见性
  • 无锁,高并发下性能优于锁

CAS 流程:

plaintext

预期值 = 当前值
if (当前值 == 预期值) {
    赋值新值
}

整个过程是一条 CPU 原子指令,不可分割。

9.4 天然安全:final 关键字

final 字段:

  • 构造函数正常初始化完成后
  • 对其他线程一定可见,不会看到半初始化状态

可避免 “对象引用已发布,但字段未初始化” 的重排问题。


十、典型场景实战:从问题到方案

10.1 场景 1:线程停止标记(可见性问题)

坏代码:

java

运行

boolean stop = false;

// 线程1
while (!stop) { }

// 线程2
stop = true;

问题:线程 1 可能死循环。

修复:

java

运行

volatile boolean stop = false;

10.2 场景 2:DCL 单例(重排序问题)

坏代码:

java

运行

private static Singleton instance;

修复:

java

运行

private static volatile Singleton instance;

10.3 场景 3:计数器(原子性问题)

坏代码:

java

运行

int count = 0;
count++;

修复:

java

运行

AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();

10.4 场景 4:状态转移(可见性 + 原子性)

java

运行

int state = 0;

if (state == 0) {
    state = 1;
}

问题:判断 + 赋值非原子,多线程同时进入。

修复:

  • 加锁
  • 或使用 AtomicInteger + compareAndSet

十一、总结:多线程变量不安全的完整逻辑链

从底层到上层,完整因果链:

  1. CPU 与内存速度差距巨大 → 必须用多级缓存
  2. 多核私有缓存 → 缓存不一致 → 可见性问题
  3. CPU / 编译器追求性能 → 乱序执行 → 重排序问题
  4. 高级语言一行代码对应多条指令 → 复合操作非原子 → 原子性问题
  5. JMM 对无同步代码不做任何保证 → 三大问题同时爆发
  6. 表现为:看不见、乱序、计算错误、死循环、NPE

最终解决方案:

  • 只需要可见性 + 禁止重排volatile
  • 需要原子性synchronized/Lock/AtomicXXX
  • 只读不变量 → final
  • 不共享变量 → 局部变量 / ThreadLocal

一句话记忆:

多线程共享变量不同步,硬件会缓存不可见,编译器 / CPU 会重排,代码会被打断,必然出现可见性、乱序、不安全。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐