1. 从一段诡异的死循环说起
我第一次被 volatile 和 synchronized 的区别"拷打",是在一个很普通的下午。代码逻辑简单得不能再简单:一个布尔标志位running,主线程把它改成 false,工作线程在循环里检查它,为 false 就退出。结果工作线程死活不退出,日志一行都不打,CPU 却跑满了。当时我以为是线程池的问题,换成了Thread、换了 JDK 版本、甚至怀疑过机器,最后才发现问题出在running这个变量没有加volatile。
这个坑几乎每个写并发的人都会踩一次。它背后牵扯出的正是这篇文章要讲清楚的两个关键字:volatile和synchronized。很多人对它们的印象是模糊的——"都是保证线程安全的""volatile 轻量,synchronized 重"——但真到了要选型的场景,往往就卡壳了。这篇文章我会把这两个关键字的底层原理、适用边界、常见误用、性能实测、选型思路全部拆开讲,不管你是刚接触并发的新手,还是写过多线程代码但对内存模型没完全吃透的开发者,看完应该都能对"什么时候用哪个"有个清晰的判断。
需要先说明一点:下面讲的 volatile 默认指 Java 的 volatile,同时我也会专门拿 C 语言里的 volatile 做对比,因为这两个同名关键字经常被人混淆,而热词里也大量出现了volatile c语言、#define clkcon_uni ((volatile clkcon *) (sfr_base + 0x00 * 4))这类嵌入式写法,这块的差异讲清楚会省掉你很多困惑。
2. 为什么一个关键字能"看不见"另一个线程的修改
2.1 可见性问题的根源:三层缓存与写缓冲
要理解 volatile,必须先理解一个问题:为什么一个线程改了变量,另一个线程会"看不到"。这不是 Java 独有的,而是现代 CPU 架构带来的必然结果。
现代计算机的内存层次大概是这样的:CPU 寄存器最快但容量极小,L1/L2/L3 缓存次之,主内存最慢但容量最大。为了弥补 CPU 和主内存之间巨大的速度差,CPU 会把频繁访问的数据缓存到自己的缓存里,读的时候先查缓存,写的时候也是先写缓存,再在某个时机刷回主内存。单核时代这么做没问题,因为只有一个核在看这些数据。但多核时代,每个核心都有自己的缓存,A 核改了数据只写进了 A 的缓存,B 核读的还是自己缓存里的旧值,看起来就是"改动丢失了"。
除此之外还有写缓冲(Store Buffer)和无效队列(Invalidate Queue)的加入。即使缓存之间有一致性协议(比如 MESI)来同步状态,为了性能,写操作会先进写缓冲,读操作会先进无效队列,滞后处理。这就导致即便缓存协议在硬件层面"最终一致",程序看到的顺序和时机也未必符合直觉。
所以说到底,可见性问题的本质是:一个线程的写,和另一个线程的读,发生在不同的缓存副本上,中间还有缓冲机制延迟同步。volatile 要解决的,就是这个"看不到"的问题。
2.2 volatile 干了两件什么活
Java 的 volatile 关键字,本质上是让 JVM 帮你在变量读写前后插入内存屏障(Memory Barrier,也叫 Fence)。它的语义可以拆成两条:
- 可见性:对 volatile 变量的写,会立刻刷到主内存(准确说是让其他线程的缓存副本失效);对 volatile 变量的读,会强制从主内存(或最新的缓存状态)读取。这样 A 线程的写,B 线程马上就能看到。
- 禁止指令重排序:编译器和 CPU 为了优化性能会重排指令顺序,volatile 会限制屏障前后的指令不能随意跨过屏障重排。
这里有个点特别容易被忽略:volatile 不保证原子性。i++这种看似简单的操作,实际上是"读 - 改 - 写"三步,volatile 只能保证"读"到的是最新值、"写"出去能被看到,但两个线程同时读到同一个旧值再各自加一,结果还是会丢更新。这是新手最容易踩的坑,以为加了 volatile 就万事大吉。
而 synchronized 的语义完全不同。它解决的是互斥问题:同一时刻只允许一个线程进入临界区,其他线程排队等待。它顺带也保证了可见性——因为线程在退出 synchronized 块时会刷新缓存,进入时会重新读取,所以在 synchronized 块内修改的变量,下一个拿到锁的线程一定看得到。
但我常跟人说一句话:synchronized 的可见性是"附带"的,互斥才是它的主业。你为了可见性去用 synchronized,等于开着一辆卡车去买一瓶矿泉水。能用 volatile 的场景,用 volatile 就够了。
2.3 一个 bash 演示让问题具象化
很多人看理论看半天没感觉,跑一段代码立刻就有体感了。下面这段代码,去掉 volatile 就会卡死,加上就正常退出:
public class VisibilityDemo { // 去掉 volatile 试试,大概率陷入死循环 private static volatile boolean running = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { System.out.println("worker start"); while (running) { // 故意空转,让 JIT 有机会优化 } System.out.println("worker stop"); }); worker.start(); Thread.sleep(1000); running = false; System.out.println("main set running=false"); worker.join(); } }去掉 volatile 之后,worker 线程很可能一直不退出。原因在于 JIT 编译器发现while (running)这个循环体内没有对 running 的写操作,就把 running 的值提升到了寄存器里做循环条件,不再每次从主内存读。主线程改了主内存的值,worker 却一直读自己寄存器里的旧值,于是永远为真。这就是"可见性丢失"最经典的现场。
注意:这个现象依赖 JIT 优化,有的机器可能碰巧不复现,所以不要用它去做严谨验证。想稳定复现可以加
-Xint(关闭 JIT)或调整循环复杂度来触发优化,但真正的并发测试还是建议用 JCStress 这类专门工具。
3. volatile 在 Java 和 C 里根本不是一回事
3.1 Java volatile 保证的是并发语义
Java 里的 volatile 是语言级别的并发原语,JVM 和 JMM(Java 内存模型)明确规定了它的语义:可见性 + 有序性(禁止特定重排)。JVM 会在底层翻译成对应平台的内存屏障指令,比如 x86 上 volatile 写会编译成带lock前缀的指令,这个前缀会强制刷写缓冲并同步缓存。
我见过不少人的误区是:"volatile 就是给变量加个内存屏障,把值刷来刷去。" 这个理解不算错,但太机械了。更准确的说法是:volatile 建立了一种happens-before 关系——对 volatile 变量的写 happens-before 后续对它的读。这个关系是 JMM 给程序员的承诺,也是并发正确性推理的基础。
3.2 C 语言 volatile 管的是"不要优化掉访问"
C 语言(以及 C++)里的 volatile 完全是另一套东西。它不保证线程安全,不保证原子性,不提供内存屏障,也不保证多线程可见性。它只告诉编译器一件事:这个变量可能被程序之外的因素改变,不要把它优化到寄存器里,每次访问都老老实实去内存读写。
那它用在哪?典型场景是内存映射的硬件寄存器。你热词里那个#define clkcon_uni ((volatile clkcon *) (sfr_base + 0x00 * 4))就是干这个的——嵌入式芯片里某个时钟控制寄存器映射到某个地址,硬件会自己改它的值,编译器如果把它优化进寄存器,读到的就是过时的值。加 volatile 就是警告编译器"别自作聪明"。
还有个经典场景是信号处理函数里修改的变量,或者裸机环境下中断服务程序与主循环共享的变量。这些场景的共同特点是"改动来源在编译器视野之外",编译器必须每次都真去读内存。
3.3 别把两者混为一谈
这里给一个特别容易搞混的对照表,我建议直接记下来:
| 对比维度 | Java volatile | C/C++ volatile |
|---|---|---|
| 解决的核心问题 | 多线程可见性、禁止重排序 | 防止编译器优化掉访问 |
| 是否保证原子性 | 否(只保证单个读/写原子,不保证复合操作) | 否 |
| 是否提供内存屏障 | 是(JVM 插入屏障) | 否(标准不保证,编译器一般不插) |
| 是否适用多线程同步 | 是(配合其他手段完成同步) | 否(不能用来做线程同步) |
| 典型场景 | 状态标志位、单次发布引用 | 硬件寄存器、信号处理、裸机中断 |
我在面试里问过不下几十个人"Java 的 volatile 和 C 的 volatile 有什么区别",能答到"一个管并发、一个管编译器优化"这个层面的不到三成。如果你正准备面试并发相关岗位,这条务必搞清楚,因为它能直接区分"背过八股"和"真理解"。
提示:C++11 之后引入了
std::atomic,如果你在 C++ 里要做多线程共享变量,请用std::atomic而不是volatile。volatile 在 C++ 里同样只管优化,不管并发。这个坑我实打实见过来自生产环境的事故排查。
4. synchronized 的进化史与锁升级路径
4.1 从"重量级"到"自适应"
早年间 synchronized 的名声不太好,被叫做"重量级锁"。因为它依赖操作系统的互斥量(mutex),加锁解锁都要陷入内核态,线程阻塞、唤醒涉及用户态和内核态切换,开销很大。所以那时候大家都推荐用ReentrantLock或者原子类来替代。
但 JDK 6 之后,HotSpot 对 synchronized 做了一系列优化,引入了偏向锁(Biased Locking)、轻量级锁(Lightweight Locking)、自旋锁(Adaptive Spinning),形成了"锁升级"路径。简单说:
- 无锁 → 偏向锁:如果只有一个线程反复获取同一把锁,JVM 直接把锁标记偏向这个线程,后续进入几乎零开销,连 CAS 都不用做。
- 偏向锁 → 轻量级锁:出现第二个线程竞争时,偏向锁撤销,升级为轻量级锁,用 CAS 自旋来竞争。
- 轻量级锁 → 重量级锁:自旋到一定次数还没拿到锁,就升级为重量级锁,真正阻塞线程。
所以现在说"synchronized 慢",多数情况下是不成立的。没有竞争或者竞争不激烈时,它的性能和 ReentrantLock 差不了多少,甚至更优。而且它不需要手动释放,异常自动释放锁,用起来更安全。
4.2 锁升级的状态记在哪
锁状态信息记在对象头的 Mark Word 里。64 位 JVM 下一个对象头一般占 12 字节(8 字节 Mark Word + 4 字节类型指针,开启压缩指针时),Mark Word 会随着锁状态变化记录不同内容:偏向锁记线程 ID,轻量级锁记指向栈中锁记录的指针,重量级锁记指向 Monitor 的指针。理解这个有助于你知道为什么 Java 里任何对象都能当锁——因为每个对象头里都预留了锁状态的位置。
顺带说个实践里常被问的问题:锁对象的选择。用this当锁,锁的粒度是这个实例;用ClassName.class当锁,锁的是整个类的所有实例。很多单例写错、并发控制失效的事故,都是因为锁对象选错了。我一般的建议是:专门定义一个private final Object lock = new Object();作为锁对象,语义清晰且不会被外部意外拿到,避免锁竞争范围失控。
4.3 自旋锁不是越多越好
自旋锁的原理是:拿不到锁时不立即阻塞,而是空转几圈,看看锁会不会很快被释放。这适用于锁持有时间短的场景,因为线程阻塞/唤醒的上下文切换开销,往往比自旋几十上百个时钟周期还大。
但自旋也有代价:如果锁一直被持有,自旋就是纯浪费 CPU。所以 HotSpot 用的是"自适应自旋"——根据上一次在同一把锁上的自旋成功率动态调整自旋次数。简单说就是"上次自旋成功了,这次多转几圈;上次失败了,这次少转或直接阻塞"。
我实测过一个高竞争场景,把同步块内容从"几行内存操作"改成"包含一次 IO 调用"后,吞吐量断崖式下跌。原因就是锁持有时间变长,自旋全部失败,大批线程进入重量级锁阻塞,上下文切换爆炸。所以在 synchronized 里做耗时操作是性能大忌,能拆就拆,能减小锁粒度就减小。
5. 选型实战:什么场景用哪个
5.1 决策清单
我把日常用到的判断整理成一张表,照着场景对号入座就行:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单个状态标志位(如 running、初始化标记) | volatile | 只需可见性,无需互斥 |
| 单次安全发布对象引用 | volatile | 配合 final 字段,避免半初始化对象逸出 |
| 计数器累加(i++) | synchronized / AtomicInteger | volatile 不保证原子性 |
| 复合条件判断后修改 | synchronized | 需要互斥保护"检查 - 修改"整体 |
| 一次修改多个相关变量需保持一致 | synchronized | volatile 无法保证多变量原子性 |
| 读多写少、且写操作是简单赋值 | volatile | 性能开销最小 |
| 临界区含耗时操作 | 尽量缩小同步块,或改用并发容器 | 减少锁持有时间 |
5.2 一个真实场景的演进过程
我之前维护过一个设备状态缓存,需求是"后台线程定期刷新设备状态,前端查询时拿到最新的状态"。最初实现是一个HashMap加 synchronized 方法,读写都加锁。压测时 QPS 上不去,锁竞争严重。
第一次优化:把"读"改成 volatile 引用发布——用不可变对象封装整个状态快照,写的时候构建新快照再一次性赋给 volatile 引用,读的时候直接读引用。这样读操作完全无锁,只有写操作需要重建对象,性能提升非常明显。核心思路是利用 volatile 保证引用发布的可见性,同时用不可变对象避免读到中间状态。
第二次优化:写操作频率其实很低,干脆用AtomicReference配合 CAS 替换 volatile 引用,保证并发写时只有一个成功。这里之所以能用 CAS 而不是锁,是因为"状态替换"本身是单变量原子操作,不需要跨变量一致性。但如果需求变成"同时更新设备数量和时间戳且两者必须一致",那就只能回到 synchronized。
这个演进过程把两个关键字的边界体现得淋漓尽致:volatile 能解决可见性和发布问题,但一旦涉及"多个操作要作为一个整体"或"复合条件判断",就必须请出 synchronized。
5.3 我踩过的坑与避坑清单
讲几个实操里真栽过的跟头,比理论好使:
- 坑一:给数组加 volatile。
volatile int[] arr只保证数组引用本身的可见性,不保证数组元素的可见性。要保证元素可见,得用AtomicIntegerArray或者对元素操作加锁。 - 坑二:以为 volatile 加了就线程安全。前面说的 i++、先检查后操作、多变量关联修改,volatile 全都护不住。
- 坑三:synchronized 锁了不同对象。两个方法用 this 锁,但如果通过不同实例调用,锁的其实是不同对象,照样并发。日志里看不出问题,因为每个实例内部确实是串行的。
- 坑四:在同步块里调用外部方法。外部方法可能耗时、可能再来锁,容易死锁或长时间持锁。原则是同步块里只放该放的东西。
提示:如果一段代码既需要可见性又需要原子性,不要纠结用 volatile 还是 synchronized,直接用 synchronized 或对应原子类,简单可靠。过早优化引入的复杂度,往往比那点性能收益贵得多。
6. 常见问题与排查实录
6.1 死循环不退出,怎么定位
我一般按这个顺序查:先确认循环条件变量有没有 volatile;再看循环体里有没有可能触发 JIT 把变量提升到寄存器的操作(比如循环体为空、或只读不写);然后用-Xint或-XX:-UseCompile跑一遍看现象是否消失,消失基本就锁定是可见性问题。更稳妥的方式是用线程 dump(jstack)看 worker 线程卡在哪个方法上,如果卡在纯 CPU 循环里,基本就是这个原因。
6.2 synchronized 导致性能毛刺怎么排查
典型的症状是:平时吞吐正常,偶发几百毫秒到几秒的停顿。排查手段是抓 thread dump,看是不是大量线程 BLOCKED 在同一把锁上;再用 JFR 或 async-profiler 看锁竞争火焰图,找到热点锁;最后看临界区代码,通常能在里面揪出一两次不该有的 IO、日志、或者大对象创建。把临界区缩到最小后,毛刺基本就消失了。
6.3 volatile 到底管不管有序性
管,但要限定范围。volatile 保证的是对 volatile 变量本身的读写不会与其他内存操作任意重排:volatile 写之前的操作不能排到写之后,volatile 读之后的操作不能排到读之前。这叫"半屏障"效果。但两个普通变量之间,即使都在 volatile 读写附近,它们的相互顺序也不是 volatile 保证的。所以别把 volatile 当万能有序性武器,涉及多变量顺序依赖时,还是得靠锁或者明确的同步原语。
6.4 常见误区速查
| 误区表述 | 实际情况 |
|---|---|
| volatile 保证原子性 | 不保证,只保证单次读写可见与有序 |
| volatile 能替代锁 | 只在无复合操作时能替代,其他不行 |
| synchronized 一定比 volatile 慢很多 | 无竞争或轻竞争时差距很小 |
| C 的 volatile 能用于多线程同步 | 不能,只是防编译器优化 |
| volatile 数组元素也可见 | 不,只保证数组引用的可见性 |
| 加了 synchronized 就万事大吉 | 还得看锁对象、锁范围、是否死锁 |
7. 一点个人的选型经验
用了这么多年,我总结出一条特别朴素的原则:能用 volatile 解决的,绝不上锁;不能确定能不能用的,先用锁。因为锁的语义更好推理,出错概率低,性能也未必差。追求无锁性能的优化,应该发生在被压测证明是瓶颈之后,而不是一开始就上。
另外,我强烈建议你在项目里把并发相关的代码单独立一个模块或者包,写清楚每个共享变量的并发语义——是 volatile 可见性、还是锁保护、还是不可变。很多线上并发 bug 之所以难查,不是因为技术难,而是因为三个月后没人记得这个变量当初为什么这么写。注释里写一句"本字段由 XXX 锁保护""本字段 volatile 仅用于状态发布",能救后来人一命,也能救你自己。