ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

volatile与synchronized深度解析:从Java内存模型到并发选型实战

volatile与synchronized深度解析:从Java内存模型到并发选型实战

先抛一个我经常被问到的问题:volatile和synchronized到底有什么区别?这个问题几乎每个做过Java并发开发的人都会遇到。网上答案很多,但大多停留在“volatile保证可见性,synchronized保证原子性”这种层面。说实话,这句话背起来容易,真到排查线上并发问题、决定用哪个关键字的时候,很多人还是拿不准。

这篇文章我打算把这两个关键字从问题根源、JVM实现、典型场景到常见坑点完整串一遍。适合正在系统学习并发编程的开发者,也适合那种“代码里用了volatile问题还在”的同行作为排查参考。读完你可以回答的不再是“volatile能干嘛”,而是“当前这个并发场景到底该靠谁”。

1. 并发问题的三个维度:为什么需要这两个关键字

1.1 从硬件缓存到Java内存模型

想真正理解volatile和synchronized,得先看它们到底在解决什么问题。现代CPU早就是多核架构,每个核心都有自己的高速缓存。程序运行时,变量不会每次都去内存读,而是先复制到CPU缓存,改完再刷回内存。硬件这么做是为了速度,但也带来了一个副作用:某个线程修改了共享变量的值,另一个线程看到的不一定是最新的,因为它的执行核心可能还停留在自己缓存的旧副本上。

Java跨平台,不能直接依赖某一种CPU的具体缓存协议,所以Java语言定义了一层抽象规则,这就是Java内存模型(JMM)。JMM规定:所有共享变量存在主内存中,每个线程有自己的工作内存(可以理解为线程私有的缓存抽象)。线程对变量的所有操作,都是先在“工作内存”里进行,再整体同步回主内存。

可以这样类比:主内存是公司的公告栏,每个线程是各自工位上的员工。员工A改了公告栏上的排班表,但员工B工位上贴的还是昨天那份旧表格。如果B不主动去看公告栏,他永远拿着过期信息干活。volatile和synchronized,本质上就是强迫线程放弃“只看自己工位旧消息”的偷懒行为,去跟主内存对齐。

1.2 可见性、原子性、有序性对应哪些痛点

并发编程有三个经典维度:可见性、原子性、有序性。三者要分开说,因为很多bug就是因为把它们混成了一回事。

可见性说的是:一个线程修改共享变量后,另一个线程能不能马上看到最新值。缓存和指令优化都会导致看不见。硬件层面有缓存一致性协议,Java层面则靠volatile、synchronized、final等机制来实现。

原子性说的是:一组动作要么全部执行完,要么全部不执行,中间不能被其他线程插入。比如count++,实际上包含“读取count、加1、写回count”三个步骤。多人同时做这件事,最终结果可能比预期小得多。原子性需要靠锁或者原子类来保证。

有序性说的是:编译器和CPU为了优化,可能会调整指令执行顺序。单线程内怎么调整都不影响结果,但多线程共享数据的场景下,调整顺序可能让另一个线程看到一个“违反直觉”的中间状态。volatile通过内存屏障禁掉关键重排序,synchronized通过锁的互斥让临界区内代码不可能被其他线程并发看到中间状态。

这三个维度不是独立的。synchronized能同时保证原子性、可见性和临界区内的有序性,volatile只保证可见性和部分有序性,不保证原子性。理解这个能力边界,后面选型就不容易出圈。

2. volatile:轻量级的可见性通知机制

2.1 volatile保证什么:可见性与禁止重排序的实现

volatile最核心的两个能力,一个是可见性,一个是有序性。

可见性实现很直接:对volatile变量的写,必须立即刷新到主内存;对volatile变量的读,必须从主内存重新读取,不能直接用工作内存里的旧副本。所以当一个线程改了volatile标志位,其他线程读它时一定能看到最新值。

有序性则依赖内存屏障。JVM在生成指令时,会在volatile写操作前后插入内存屏障,在volatile读操作后面也插入合适的屏障。这里不要求你背全所有屏障名字,但理解机制有助于排错。简单说,volatile写之前的普通写操作不允许重排序到volatile写之后;volatile读之后的普通读操作不允许重排序到volatile读之前。这样就能保证“先更新数据、再更新标志位”的顺序不被编译器打破。

Java内存模型还给出了happens-before规则:对一个volatile变量的写,happens-before于之后再对这个变量的读。换句话说,只要线程B在线程A写完volatile变量之后去读它,就能看到线程A在写volatile之前做过的所有其他修改。这正是很多“状态发布”场景能用volatile的原因。

2.2 volatile不保证原子性:count++的经典失败现场

volatile最容易被误解的地方,就是它看上去无所不能,但面对count++就会露馅。看一段很常见的代码:

public class Counter { private volatile int count = 0; public void increment() { count++; } public int get() { return count; } }

假设8个线程同时调用1000次increment,最后count一定等于8000吗?答案是几乎不可能。原因在于count++不是单条原子指令,它等价于:读count当前值、把值加1、把新值写回。即使count是volatile,也只能保证每一步读写都从主内存来、写回主内存去,但无法阻止多个线程同时读到同一个旧值,然后各自加1再写回,最终互相覆盖。

画个时间线你可能立刻就懂了。线程A读到count=5,线程B也读到count=5,A先计算并写回6,B后计算也写回6。本应加两次变成7,结果还是6,有一次更新白白丢失。这就是典型的“丢失更新”。要解决它,要么用AtomicInteger这种CAS原子类,要么用synchronized把increment整段包起来,保证每次“读-加-写”之间没有别人插队。

这也是volatile和synchronized最本质的分界线:volatile管的是“一个线程写完,另一个立刻知道”,synchronized管的是“这段代码同时只能一个人进来执行完”。

2.3 volatile真正够用的三类场景

虽然volatile不保证原子性,但在一些特定场景里,它足够而且更轻量。

第一类是状态标志位。比如后台线程的停止开关:

private volatile boolean stopped = false; // 某个工作线程循环里判断 while (!stopped) { doWork(); } // 其他线程调用 public void stop() { stopped = true; }

这里写操作不依赖当前值,不需要复合运算,volatile完美胜任。

第二类是对象引用的安全发布。如果某个对象在构建完成后,把引用赋值给一个volatile字段,那么其他线程通过这个字段拿到对象后,能看到对象构造过程中的所有字段赋值。因为volatile写和读之间存在happens-before关系。

第三类是配合锁使用的“改进可见性”。比如在synchronized块内更新普通变量,相比于每次锁进出,额外用一个volatile变量来发布结果,可以降低锁的范围。但这种情况要小心设计,不会用之前别乱用。

3. synchronized:完整的互斥与内存语义

3.1 synchronized用什么锁住临界区:对象头与管程模型

synchronized能同时解决原子性、可见性和有序性,靠的是“锁”的概念。Java里每一个对象都可以成为一把锁,这个锁信息就存在对象头的Mark Word里。synchronized修饰实例方法时,锁的是当前实例对象this;修饰静态方法时,锁的是该类的Class对象;修饰代码块时,锁的是括号里指定的对象。

一旦线程获得锁进入临界区,其他想进入同一个锁的线程就必须阻塞等待。锁的互斥保证了临界区代码不会并发执行,所以count++哪怕拆成三步,只要整个方法都用同一个锁串起来,就不会出现互相覆盖。

synchronized还提供wait/notify这套配合机制,底层是建立在管程模型上的。可以把管程理解成一个带门禁的房间,线程必须先拿到钥匙进房间,房间里面一次只能有一个人,其他人排队在门外等。wait会让线程主动让出房间并等待通知,notify会叫醒一个等待者重新抢钥匙。这套模型非常成熟,很多并发工具本质上都围绕它做优化。

3.2 锁升级路径:从偏向锁到重量级锁

早期大家觉得synchronized性能差,是因为它底层直接调用操作系统的互斥量,线程阻塞和唤醒需要用户态和内核态切换,代价很高。后来JVM做了大量优化,引入了“无锁→偏向锁→轻量级锁→重量级锁”的升级路径。

一开始一个锁对象没有被竞争,只有一个线程反复进入临界区,JVM会启用偏向锁,在对象头里记录线程ID,后续这个线程再来就不用真正抢锁。一旦出现第二个线程竞争,偏向锁撤销并升级为轻量级锁。轻量级锁靠CAS自旋尝试获取锁,适合短小临界区,线程不用立刻阻塞。如果自旋超过一定次数或竞争加剧,就升级为重量级锁,线程真正进入阻塞排队。

需要提醒的是,偏向锁在某些JDK版本已经默认关闭,比如较新的LTS版本中默认不再启用偏向锁。但这条升级路径依旧值得了解,因为很多线上日志里的“BLOCKED”状态、锁竞争优化,都和“到底哪个锁阶段”有直接关系。它也能解释为什么现在synchronized在很多场景下并不比显式锁差太多。

3.3 synchronized自带可见性:内存屏障视角

很多人不知道synchronized也有“刷新主内存”的能力。线程进入synchronized块时,会从主内存重新读取锁保护的共享变量;线程退出synchronized块时,会把工作内存中修改过的变量强制刷新到主内存。这背后的机制同样是内存屏障,效果上等价于在synchronized块的入口做了一个全量读屏障,在出口做了一个全量写屏障。

因为同一把锁的加锁和释放存在happens-before关系,所以如果线程A先释放锁,线程B后获取同一把锁,线程B必然能看到线程A在临界区内做的所有修改。注意这是“同一把锁”,换了一把锁,等于两个互不相干的房间,彼此之间没有可见性保证。

理解了这一点,就能明白为什么很多老项目即便完全没有volatile,共享状态只要都在synchronized里读写,也不会出可见性问题。反而是那种“只给写方法加锁、读方法不加锁”的代码,才会因为读不到锁内更新的状态,出现旧数据。

4. 选型实战:什么场景该用volatile,什么场景必须synchronized

4.1 双重检查锁单例:volatile是刚需

最经典的选型问题就是双重检查锁(DCL)单例。先看代码:

public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

这里为什么必须加volatile?关键在于new Singleton()不是一个原子操作。它大致分三步:第一,分配一块内存;第二,在这块内存上执行构造函数;第三,把内存地址赋值给instance引用。编译器为了性能,可能把第二步和第三步重排序,变成先赋值引用、再执行构造函数。

这样就会出现一个极端情况:线程A正在创建对象,刚把引用写到instance字段(构造函数还没跑完),线程B判断instance不为null,直接返回并将这个半成品对象拿去用,结果读取到的是默认值或者直接抛异常。volatile的作用就是通过内存屏障禁止这个重排序,保证只有对象完整构造完成后,引用才会被外部看到。

这个例子特别适合理解“有序性”在实际工程里的价值。它不是一个理论概念,是真的会造成可用性事故。类似的问题还会出现在发布不可变对象、初始化缓存等场景中。

4.2 状态开关、累加器和复合操作的选型对照

我把常见场景按特征列了个表,方便直接对照:

场景特征典型例子推荐方案原因
写操作不依赖旧值,读多写少线程停止标志、配置切换开关volatile轻量,可见性足够
单一数值累加/更新页面访问计数、请求计数器AtomicInteger / LongAdder / synchronized需要原子复合操作
读-判断-写这种check-then-act先检查余额再扣款、库存扣减synchronized / ReentrantLock需要整体原子且防重入
多个变量需要保持一致转账时同时改账户A和账户Bsynchronized 或事务化操作volatile无法保证组合原子性
不可变对象发布配置对象构建后整个发布final + volatile 引用既加速又保证安全发布
缓存共享数据HashMap并发读写ConcurrentHashMap等并发容器要靠锁或CAS保护内部结构

这里的核心是:不要因为volatile更轻量,就强行把它往所有并发场景上套。它只解决“看见”的问题,不解决“别挤我”的问题。一旦出现了“先读一个值,再基于它做判断,最后写入新值”这种路径,就应该默认上锁。

4.3 选型心法:先画线程读写关系再决定

我在实际项目里被并发问题折磨过几次后,养成了一个习惯:不急着写代码,先在纸上画清楚这个共享变量被哪些线程读、哪些线程写,改写的时候依赖不依赖当前值。

如果是“单线程写、多线程读”,volatile基本是最优解。如果是“多线程写,但写的是彼此独立的不同字段或槽位”,volatile也常常够用。如果是“多线程同时改写同一个值,而且新值依赖旧值”,这时候一定要锁或原子类。如果是一组变量需要一起更新才合理,那必须把它们放进同一个临界区。

画完这份关系图,选哪个关键字往往就水到渠成了。很多人纠结volatile和synchronized,其实不是因为不懂写法,而是没理清自己的并发模型。模型一乱,工具再好也会用错。

5. 实际项目里的常见误区与排查思路

5.1 误区一:把volatile当成万能可见性开关

我见过一个线上故障,程序里有个布尔标记,主线程控制工作线程退出,代码写的是volatile boolean running。结果有时候工作线程要好几秒才响应退出。排查后发现,工作线程的判断逻辑远不止这个布尔值,而是“running为true且队列为空且状态为READY”三者的组合。三个普通字段没有一致的可见性保证,即使running是volatile,另外两个字段刷新时机不可控,导致线程没有及时退出。

这就是典型的“只给一个字段上volatile,但共享状态不是一个字段”的误区。volatile能保证的是某个volatile写之前的其他写都可见,但那要求读也发生在这个volatile写之后的临界点上。如果你的逻辑依赖几个变量组合的“同一时刻快照”,应该把它们放进同一个锁或使用原子化的状态对象,而不是指望一个flag把整片状态都带上。

5.2 误区二:同步对象选错导致锁形同虚设

synchronized锁的对象一旦选错,所有临界区都会失效。最常见的反例是锁一个会被重新赋值的引用:

private Object lock = new Object(); public void methodA() { synchronized (lock) { doSomething(); } } public void resetLock() { lock = new Object(); // 这行很可能让后面的锁全部失效 }

如果你在另一个线程里调用了resetLock,那么后续methodA锁的是新对象,而原来等待旧锁的线程还在旧对象上排队。两批人各拿各的钥匙,临界区就形同虚设。修复方式很简单:锁对象要么用final,要么直接用Class对象,不要让锁引用指向变化。

还有一种坑是用字符串字面量作为锁。JVM会复用相同的字符串实例,如果别的模块也用了同一个字符串做锁,就会出现无意义的相互阻塞。排查这类问题最直接的手段是jstack导出线程栈,看线程到底阻塞在哪个对象的monitor上。看到“waiting for monitor lock”后面的对象描述,基本就能定位出锁对象是不是选错了。

5.3 一点关于并发改造的真实体会

我个人现在做并发改造时,遵循一个很保守的顺序:先把业务逻辑里所有涉及共享状态的路径找出来,标注“读”“写”“复合操作”。能真正用volatile的场景,我才会用;但凡有一点“读完要判断再写”的味道,我第一选择就是synchronized。这样可能不如某些精巧的无锁方案性能惊艳,但胜在正确性容易验证,出问题时也更好排查。

另外,如果你维护的是老代码,想从“只给方法加synchronized”改成“用volatile减少锁粒度”,一定记得先做并发压测,并且要加上线程状态监控。因为锁范围缩小后,共享状态的一致性边界变了,这个改动风险比表面看起来大得多。我在某次重构里因为少考虑一个嵌套状态变量,导致上线后偶发脏读,回滚后才缓过来。所以,先保证语义正确,再谈性能优化。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进