news 2026/9/29 1:27:04

Java volatile与synchronized:原理、区别与选型实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java volatile与synchronized:原理、区别与选型实战

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 volatileC/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 / AtomicIntegervolatile 不保证原子性
复合条件判断后修改synchronized需要互斥保护"检查 - 修改"整体
一次修改多个相关变量需保持一致synchronizedvolatile 无法保证多变量原子性
读多写少、且写操作是简单赋值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 仅用于状态发布",能救后来人一命,也能救你自己。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 1:26:50

软件工程期末大作业高分指南:从选题到答辩全流程拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:26:47

三相异步电机机械特性MATLAB仿真:从参数计算到报告输出

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:26:37

高精度ADC选型:从参数表到物理约束的系统工程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:26:37

C# EasyHook 实战:本地与远程 API Hook 最小 Demo 及避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:26:34

BK7238单芯片Wi-Fi+BLE共存原理与量产落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:26:30

红火蚁YOLO数据集:三格式对齐+时间分层划分的农业检测方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华