Lazy loaded image
Java丨volatile 为什么不能保证原子性?JMM、内存屏障、happens-before 一文讲清
Words 7879Read Time 20 min
2025-10-12
type
Post
status
Published
date
Oct 12, 2025
slug
java4
summary
tags
技术探索
category
icon
password
Java 并发里有三个绕不开的问题:
原子性。
可见性。
有序性。
volatilesynchronizedLock、CAS、原子类、线程池中的很多设计,最终都是围绕这三个问题展开的。
其中 volatile 是最容易被误解的一个关键字。
很多人知道:
volatile 能保证可见性。
volatile 能禁止一部分指令重排序。
volatile 不能保证复合操作的原子性。
但为什么是这样?
为什么 volatile boolean flag 可以作为停止标志?
为什么 volatile int countcount++ 仍然不安全?
为什么双重检查锁单例必须加 volatile
happens-before 到底解决了什么问题?
这篇文章就把这些问题一次讲清楚。

一、并发问题的本质:原子性、可见性、有序性

多线程访问共享变量时,最核心的问题有三类。

1. 原子性

原子性指的是一个操作要么全部执行完成,要么完全不执行,中间不能被其他线程打断。
比如:
很多人以为这是一行代码,所以它就是一个原子操作。
其实不是。
它至少包含三个步骤:
第一步,读取 count 的值。
第二步,把读取到的值加 1。
第三步,把新值写回 count
如果多个线程同时执行,就可能出现更新丢失。

2. 可见性

可见性指的是一个线程修改了共享变量,其他线程能不能及时看到这个修改。
比如:
一个线程把 running 改成 false,另一个线程在循环里读取它。
如果没有可见性保证,另一个线程可能一直看不到最新值,导致循环无法退出。

3. 有序性

有序性指的是程序执行顺序是否和代码书写顺序一致。
为了提升性能,编译器和 CPU 可能会进行指令重排序。
单线程下,只要最终结果不变,这种优化通常没有问题。
但在多线程场景下,如果没有约束,重排序可能导致其他线程看到不符合预期的中间状态。

二、一个最典型的可见性问题

先看一段代码:
这段代码的本意是:
主线程 1 秒后把 running 改成 false
工作线程看到 running == false 后退出循环。
但问题是:running 是普通变量,没有任何可见性保证。
在某些运行环境、JIT 优化、CPU 缓存影响下,工作线程可能长期看不到主线程的修改。
改成 volatile
这里 volatile 解决的是可见性问题。
一个线程写入 volatile 变量。
其他线程后续读取这个 volatile 变量时,可以看到最新写入。
所以 volatile 非常适合做状态标志。

三、volatile 为什么不能保证 count++ 安全?

再看一个反例。
为什么 volatile int count 仍然不安全?
因为 count++ 不是一个不可分割的操作。
它可以拆成:
读取 count
加 1。
写回 count
两个线程可能这样交错执行:
线程 A 读取到 count = 0
线程 B 也读取到 count = 0
线程 A 写回 count = 1
线程 B 也写回 count = 1
两个线程都加了一次,结果却只增加了 1。
所以:
volatile 能保证可见性。
volatile 不能把多个步骤合成一个原子操作。
count++ 这种复合更新,不能只靠 volatile

四、用 synchronized 保证原子性

如果要保证 count++ 的原子性,可以使用 synchronized
synchronized 同时提供三类能力:
原子性:同一时刻只有一个线程进入临界区。
可见性:释放锁前的修改,对后续获取同一把锁的线程可见。
有序性:进入和退出同步块时,会形成内存语义约束。
如果只是单个计数变量,也可以使用原子类。

五、用 AtomicInteger 保证原子更新

AtomicInteger 可以保证单变量的原子更新。
看一个简化版的 AtomicInteger 核心源码。
再看 getAndAddInt 的简化逻辑。
这段逻辑说明:
volatile 负责让当前值可见。
CAS 负责保证更新过程的原子性。
多线程同时更新时,只有一个线程能 CAS 成功。
CAS 失败的线程会重新读取新值并重试。
所以 AtomicInteger 的本质不是“只靠 volatile”,而是:
volatile + CAS 自旋

六、JMM 到底是什么?

JMM,全称 Java Memory Model,也就是 Java 内存模型。
它不是某一种具体硬件缓存结构,也不是 JVM 堆内存布局。
JMM 是 Java 语言层面对多线程读写共享变量的一套规范。
它主要回答这些问题:
一个线程写入共享变量,什么时候对另一个线程可见?
编译器和处理器可以怎样重排序?
哪些操作之间必须建立先后关系?
volatilesynchronizedfinal、线程启动、线程终止分别有什么内存语义?
JMM 里有两个重要抽象:
主内存:共享变量所在的位置。
工作内存:线程自己的本地副本抽象。
注意,这只是 Java 内存模型中的抽象说法,不要机械地理解成某个具体硬件结构。
真实硬件中可能有:
CPU 寄存器。
L1、L2、L3 缓存。
Store Buffer。
Invalidate Queue。
缓存一致性协议。
JMM 屏蔽了这些底层差异,给 Java 程序提供统一的并发语义。

七、为什么会有可见性问题?

现代 CPU 为了性能,会引入多级缓存。
线程在不同 CPU 核心上运行时,对共享变量的读写可能先发生在本地缓存或写缓冲中。
这就可能导致:
线程 A 修改了变量。
修改还没有及时对线程 B 可见。
线程 B 继续读取旧值。
程序行为和预期不一致。
除此之外,JIT 编译器也可能做优化。
比如在一个循环里,如果它认为某个普通变量没有被当前线程修改,可能会减少重复读取。
这就是为什么普通变量在多线程场景下不能保证可见性。
volatile 会在读写时加入特定的内存语义,限制这类问题。

八、volatile 的内存语义

volatile 有两个核心语义。

1. volatile 写语义

当一个线程写入 volatile 变量时:
该线程之前对共享变量的修改,不能被重排序到 volatile 写之后。
这些修改会对后续读取同一个 volatile 变量的线程可见。

2. volatile 读语义

当一个线程读取 volatile 变量时:
该线程之后的普通读写,不能被重排序到 volatile 读之前。
如果读到了某个线程写入的 volatile 值,也能看到那个线程在写 volatile 之前做的修改。
看一个例子:
这段代码里,ready 不只是一个标志位。
它还承担了“发布”作用:
写线程先准备数据。
再通过 volatile 写发布状态。
读线程通过 volatile 读看到状态。
然后安全读取之前准备好的数据。

九、volatile 禁止的是哪些重排序?

volatile 不是禁止所有重排序,而是禁止会破坏其内存语义的重排序。
可以简单理解为:
普通写不能重排序到 volatile 写之后。
volatile 读不能重排序到普通读之后。
volatile 写和后续 volatile 读之间建立可见性关系。
编译器和 CPU 仍然可以做不影响语义的优化。
示例:
这里 volatile 起到的是屏障作用。
它保证了“发布数据”和“读取数据”的顺序关系。

十、内存屏障是什么?

内存屏障可以理解为一种约束指令重排序和内存可见性的机制。
它不是 Java 独有的概念,底层和 CPU 架构有关。
在 Java 里,可以把它理解为:
在特定读写操作前后插入约束。
防止某些读写越过屏障发生重排序。
确保某些写入能被其他线程看到。
确保某些读取能读到符合规则的值。
对于 volatile,JVM 会在合适位置插入内存屏障。
简化理解:
volatile 写前后会加入写相关屏障。
volatile 读前后会加入读相关屏障。
屏障的具体实现依赖 JVM 和 CPU 架构。
不同平台上的实际机器指令可能不同。
所以不要把 volatile 简单理解成“直接从主内存读写”。
更准确的说法是:
volatile 通过 Java 内存模型定义的读写语义,结合编译器和底层内存屏障,保证可见性并限制重排序。

十一、happens-before 是什么?

happens-before 是 JMM 中非常重要的概念。
它不是简单的“时间上先发生”。
它表示的是一种内存可见性关系。
如果操作 A happens-before 操作 B,那么意味着:
A 的执行结果对 B 可见。
A 和 B 之间的执行顺序受到 JMM 约束。
编译器和处理器不能做破坏这种关系的重排序。
举个例子:
在单线程里,a = 1 写在 b = 2 前面。
根据程序顺序规则,前面的操作 happens-before 后面的操作。
但在多线程中,不是代码写在前面,另一个线程就一定能看到。
必须有明确的 happens-before 关系。

十二、happens-before 的常见规则

常见规则包括以下几类。

1. 程序顺序规则

在同一个线程中,前面的操作 happens-before 后面的操作。
这个规则只保证单线程内的语义。
它不代表其他线程一定能看到这些写入。

2. volatile 规则

对一个 volatile 变量的写,happens-before 后续对这个变量的读。
这就是 volatile 可以做状态发布的原因。

3. 锁规则

对同一把锁的解锁,happens-before 后续对这把锁的加锁。
这就是 synchronized 能保证可见性的原因。

4. 线程启动规则

调用 Thread.start() 之前的操作,happens-before 新线程中的操作。
这里不需要把 data 声明成 volatile,新线程也能看到 start() 之前的写入。
原因就是线程启动规则建立了 happens-before 关系。

5. 线程终止规则

线程中的所有操作,happens-before 其他线程从 join() 成功返回。
join() 不只是等待线程结束,它还建立了可见性关系。

6. 传递性规则

如果 A happens-before B,B happens-before C,那么 A happens-before C。
传递性是很多并发安全发布模式成立的基础。

十三、双重检查锁为什么必须加 volatile?

双重检查锁,也就是 DCL,常用于懒加载单例。
错误写法:
问题出在这一行:
它大致可以拆成三个步骤:
第一步,分配对象内存。
第二步,调用构造方法初始化对象。
第三步,把对象引用赋值给 instance
在没有 volatile 的情况下,第二步和第三步可能发生重排序。
也就是可能变成:
第一步,分配对象内存。
第二步,把对象引用赋值给 instance
第三步,调用构造方法初始化对象。
如果线程 A 执行到第二步,instance 已经不为 null,但对象还没有初始化完成。
此时线程 B 进入 getInstance(),发现 instance != null,直接返回,就可能拿到一个半初始化对象。
正确写法:
这里 volatile 的作用是:
保证 instance 对其他线程可见。
禁止对象初始化和引用赋值发生危险重排序。
避免其他线程拿到半初始化对象。

十四、volatile 适合哪些场景?

volatile 适合读多写少、状态发布、单变量可见性这类场景。

1. 停止标志

这种场景下,volatile 很合适。
因为这里只需要保证一个线程修改标志后,另一个线程能看到。

2. 配置开关

这类开关通常是一写多读,用 volatile 比加锁更轻量。

3. 状态发布

注意,这里只有在读线程先读取 ready 的情况下,才能建立对应的可见性关系。

十五、volatile 不适合哪些场景?

1. 不适合复合更新

这种场景应该使用:
synchronized
ReentrantLock
AtomicInteger
高并发计数场景下可以考虑 LongAdder

2. 不适合多个变量之间的一致性维护

多个变量要维护一致性,通常应该加锁,或者封装成不可变对象后用 AtomicReference 整体替换。

十六、LongAdder 为什么适合高并发计数?

AtomicInteger 在高竞争下可能大量 CAS 失败。
比如很多线程同时更新同一个变量:
所有线程都竞争同一个热点 value。
同一时刻只有一个线程 CAS 成功。
其他线程 CAS 失败后不断重试。
并发越高,失败重试越多。
LongAdder 的思路是分散竞争。
它不是让所有线程都更新同一个值,而是把计数分散到多个单元上,最后求和。
示例:
简化理解:
低竞争时,直接更新基础值。
高竞争时,拆分成多个 Cell。
不同线程尽量更新不同 Cell。
最终通过 sum() 汇总。
所以:
AtomicInteger 适合一般原子计数。
LongAdder 更适合高并发统计类计数。
如果需要强一致的即时值,仍要谨慎选择 LongAdder,因为 sum() 汇总时不一定是严格瞬时一致快照。

十七、final 和安全发布

除了 volatile 和锁,final 也有特殊的内存语义。
如果一个对象被正确构造,并且构造过程中没有让 this 逸出,那么其他线程看到这个对象时,通常可以看到它的 final 字段被正确初始化后的值。
示例:
但是要避免构造过程中 this 逸出。
错误示例:
安全发布对象的常见方式包括:
使用 volatile 引用发布。
使用锁保护发布和读取。
使用类初始化机制发布静态对象。
使用线程安全容器发布对象。
避免构造期间 this 逸出。

十八、类初始化为什么天然线程安全?

Java 的类初始化过程由 JVM 保证线程安全。
所以静态内部类单例是一种更简洁的懒加载方式。
这种写法的优点是:
懒加载。
不需要显式加锁。
不需要手写双重检查。
依赖 JVM 类初始化保证线程安全。

十九、一个完整的发布订阅可见性示例

下面这个例子展示 volatile 如何用于“数据准备完成”的发布。
这个模式成立的关键是:
写线程先写普通数据,再写 volatile 状态。
读线程先读 volatile 状态,再读普通数据。
读线程只有在看到状态已发布后,才读取数据。
如果读线程绕过 loaded 直接读 config,就没有利用到这条 volatile 可见性链路。

二十、常见误区

1. volatile 能让所有操作都线程安全吗?

不能。
volatile 只保证单次读写的可见性和一定的有序性,不保证复合操作原子性。
比如 count++check-then-act、多个变量一致性更新,都不能只靠 volatile

2. volatile 是不是每次都直接读写主内存?

这种说法不够准确。
更准确的说法是:
volatile 通过 JMM 定义的内存语义和底层屏障机制,保证可见性并限制重排序。
不要把 JMM 的“主内存、工作内存”抽象,简单等同于具体硬件上的内存和缓存。

3. volatile 能不能替代 synchronized?

不能。
二者解决的问题不同。
volatile 更轻量,适合状态标志、发布配置、单变量可见性。
synchronized 能保护临界区,保证复合操作原子性,并提供可见性和有序性。
如果存在多个操作必须作为一个整体执行,就应该考虑锁或原子类,而不是只用 volatile

4. volatile 修饰引用,对象内部字段也都安全了吗?

不一定。
volatile 修饰引用,保证的是引用本身的可见性,以及发布引用前的写入可见性。
但如果对象已经发布后,多个线程继续并发修改对象内部普通字段,仍然可能有线程安全问题。
更推荐的方式是使用不可变对象整体替换。

二十一、如何选择 volatile、锁、原子类?

可以按场景选择。
如果只是状态标志:
使用 volatile
如果是单变量原子更新:
使用 AtomicIntegerAtomicLong
高并发统计场景可以考虑 LongAdder
如果是多个变量要保持一致:
使用 synchronizedReentrantLock
或者封装成不可变对象,用 AtomicReferencevolatile 引用整体替换。
如果是临界区里有多步业务逻辑:
使用锁保护整个临界区。
如果只是安全发布配置:
可以使用 volatile 引用加不可变对象。

二十二、总结

Java 并发中最核心的问题是:
原子性。
可见性。
有序性。
volatile 主要解决的是:
可见性。
有序性。
状态发布。
防止特定场景下的危险重排序。
但它不能解决:
复合操作的原子性。
多变量一致性。
临界区互斥。
对象内部字段的任意并发修改安全问题。
volatile 的底层可以这样理解:
Java 层面,它受 JMM 约束。
语义层面,它建立 volatile 写和 volatile 读之间的 happens-before 关系。
实现层面,JVM 会结合内存屏障限制重排序并保证可见性。
使用层面,它适合状态标志、配置发布、DCL 单例、读多写少场景。
happens-before 是理解 JMM 的关键。
它不是简单的时间先后,而是内存可见性关系:
如果 A happens-before B,那么 A 的结果对 B 可见。
编译器和处理器不能做破坏这种关系的重排序。
常见 happens-before 规则包括:
程序顺序规则。
volatile 写读规则。
锁释放获取规则。
线程启动规则。
线程终止规则。
传递性规则。
最后记住一句话:
volatile 适合解决“看得见”和“别乱排”的问题。
但如果要解决“不能被打断”的问题,就需要锁、CAS 或更高层的并发工具。
回到首页