type
Post
status
Published
date
Oct 12, 2025
slug
java4
summary
tags
技术探索
category
icon
password
Java 并发里有三个绕不开的问题:
原子性。可见性。有序性。
volatile、synchronized、Lock、CAS、原子类、线程池中的很多设计,最终都是围绕这三个问题展开的。其中
volatile 是最容易被误解的一个关键字。很多人知道:
volatile能保证可见性。volatile能禁止一部分指令重排序。volatile不能保证复合操作的原子性。
但为什么是这样?
为什么
volatile boolean flag 可以作为停止标志?为什么
volatile int count 做 count++ 仍然不安全?为什么双重检查锁单例必须加
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 语言层面对多线程读写共享变量的一套规范。
它主要回答这些问题:
一个线程写入共享变量,什么时候对另一个线程可见?编译器和处理器可以怎样重排序?哪些操作之间必须建立先后关系?volatile、synchronized、final、线程启动、线程终止分别有什么内存语义?
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。
如果是单变量原子更新:
使用AtomicInteger、AtomicLong。高并发统计场景可以考虑LongAdder。
如果是多个变量要保持一致:
使用synchronized或ReentrantLock。或者封装成不可变对象,用AtomicReference或volatile引用整体替换。
如果是临界区里有多步业务逻辑:
使用锁保护整个临界区。
如果只是安全发布配置:
可以使用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 或更高层的并发工具。
分享
