在并发编程这块待久了,你会发现真正让人头疼的不是死锁,也不是线程池参数,而是一些看起来“明明没问题”的代码,跑起来却像中了邪一样随机出错。我印象最深的一次是在排查一个库存扣减的偶发超卖问题:业务逻辑加了对账、落了日志、上了事务,但在压力测试下就是会有几条数据不合预期。最后定位下来,问题恰恰出在一个普通布尔字段的读写上——多个线程对同一个标志位的修改,压根没有在第一时间同步到其他线程的“眼睛”里。这背后牵扯的,就是Java并发中必须吃透的两件事:线程安全与内存可见性。
这篇文章不会堆概念,而是从一次真实的排障现场出发,把Java内存模型(JMM)、volatile、synchronized、原子类这些关键字逐个摆到台面上,用可运行的代码案例告诉你为什么会有可见性问题,怎么复现它,又该怎么修。同时会把我在实际项目中踩过的一些坑、用过的排查工具和一些“看起来写了等于没写”的坑爹写法都一并分享出来。无论你是刚接触多线程的初学者,还是已经写过一段时间并发代码的开发者,相信看完都会有收获。
1. 先从三个并发问题的根源说起
1.1 原子性、可见性、有序性到底是什么
很多人把线程安全等同于“加了锁就安全”,这话只说对了一半。并发场景下的Bug,通常逃不出三个维度:原子性、可见性、有序性。
原子性,解决的是“操作不被中断”的问题。比如count++这行代码,看起来是一条语句,底层其实是“读取-修改-写入”三步,多线程同时执行时就会互相穿插,把中间结果搞丢。可见性,解决的是“一个线程改了,另一个线程能不能立刻看到”的问题。有序性,解决的是“代码执行顺序和指令顺序是否一致”的问题,编译器、CPU都可能为了优化而重排序。
这三个维度经常被混为一谈,导致很多人用错了手段。比如你给一个布尔标志位加了synchronized,本意是想保证可见性,这当然有效,但代价偏大;又比如你用AtomicInteger保证了原子性,却没注意被它引用的其他普通字段的可见性,结果还是会翻车。所以排查并发问题时,先要问自己一句:这个Bug到底是哪个维度出的问题?方向错了,后面的修法肯定也错。
1.2 CPU缓存与Java线程的“隔空喊话”
要理解可见性,先得知道数据在硬件上是怎么流转的。现代CPU无论多少核,运算速度都快得离谱,但内存却相对慢得多。为了平衡速度差,CPU里加了多层缓存:L1、L2、L3。每个核心各自持有部分缓存,同时又共享最后一级缓存和主内存。
线程运行在CPU核心上,读变量时不一定直接去主内存拿,而是优先读本核心的缓存;写变量时也不一定立刻刷回主内存,而是先落在缓存里,等到某个时机再同步。这就带来了一个天然的问题:核心A改了变量的值,核心B如果还扎在自己的缓存里读旧值,两边看到的数据就不一致。
Java线程模型在设计时参考了这套硬件结构,给出了自己的抽象:每条线程有自己的“工作内存”,共享变量存放在“主内存”。工作内存对应CPU缓存或寄存器,主内存对应物理内存。具体什么时候刷新、什么时候失效,JVM规范没有强制要求在每条指令上保证,而是定义了一套规则——这就是Java内存模型的核心作用。所以“线程A写了一个变量,线程B马上就能看到”在Java里从来都不是默认行为,需要开发者在代码层面主动去建立联系。
2. 用代码复现一个经典的可见性陷阱
2.1 一个可能“永远停不下来”的循环
纸上谈兵没意思,直接来个现场实验。下面这段代码很有代表性:
public class VisibilityDemo { private static boolean flag = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { long count = 0; while (flag) { count++; } System.out.println("线程退出,共执行了 " + count + " 次循环"); }); worker.start(); Thread.sleep(1000); flag = false; System.out.println("主线程已将flag置为false"); } }逻辑上,主线程睡1秒后把flag改成false,worker线程应该很快跳出循环并打印退出信息。但你在你的电脑上跑一下,大概率看到的情况是:主线程打印完了,worker线程却一直不打印退出消息,程序卡在那里不退出。
为什么会这样?因为worker线程的循环体里,每次循环都会去读flag。第一次读的时候发现是true,于是进入循环;进入循环后,JIT编译器会做一个优化,把flag的读取“提升”到循环外,相当于循环变成了if (flag) { while(true) count++; }——flag只读了一次,之后不管主线程改不改,worker线程都看不见。
这种优化在单线程环境下完全没有问题,因为单线程里flag在循环中确实没被修改。但到了多线程环境中,它直接打破了可见性的预期。你可能会说:“我的循环体里有别的操作,比如打印日志,会不会就不会被提升了?”不一定。JIT优化极其细致,它会分析循环体里哪些操作有副作用、哪些可以挪动。更靠谱的做法,是显式地把线程间的共享关系声明清楚。
2.2 为什么加了打印偶尔就“正常”了
这个问题我回答过很多次,也经常被追问:“我给循环体里加一行System.out.println(count),程序就能正常退出,是不是说明问题消失了?”
不,问题还在。加打印后正常退出的原因,通常是System.out内部是synchronized的,println执行时经过了锁和内存屏障,顺带触发了工作内存和主内存的同步,这个效应“捎带”着把flag的新值刷新到了worker线程的可见范围。但这是副作用,不是保证。你换个平台、换个JVM参数、换个时机,行为可能又不一样。用副作用去“碰巧”解决并发问题,是开发的大忌,今天能跑不代表明天能跑。
想彻底解决,最直接的方案是把flag声明为volatile。volatile保证了对该变量的写操作立即刷新到主内存,读操作则直接从主内存读,同时禁止编译器和CPU对它做重排序。改完之后,worker线程就能及时感知到变化并退出循环。这个关键字就是为这类“状态标志位”的场景设计的,轻量、无锁,性能代价远小于synchronized。
private static volatile boolean flag = true;后面我们在第5节会具体展开volatile的语义细节,包括它什么时候能用、什么时候不能用。这里先记住一个结论:多个线程共享一个状态标志来控制流程时,优先考虑volatile。
3. Java内存模型与happens-before规则
3.1 JMM不只是规范,更是排查问题的“标尺”
很多人觉得JMM就是教科书上的一段概念,工作中用不上。实际上,我后来在团队里做代码审查时,都是直接拿JMM的规则去对照,一眼就能看出某个字段该不该加volatile、某个线程间协作能不能成立。
Java内存模型规定,所有共享变量存储在主内存,每条线程有自己的工作内存。线程对共享变量的所有操作都必须在工作内存中进行,不能直接读写主内存。不同线程之间无法直接访问对方的工作内存,只能通过主内存来传递数据。这套模型意味着,只要两个线程之间没有建立某种“协调规则”,那么一个线程对变量的修改对另一个线程来说就是不可预知的。
那“协调规则”在Java里具体长什么样?就是happens-before关系。如果操作A happens-before操作B,那么A的执行结果对B是可见的。JMM里总结了若干条规则:程序次序规则、监视器锁规则、volatile变量规则、传递性、Thread.start()规则、Thread.join()规则等。
拿监视器锁规则来说,同一个锁上,解锁操作happens-before后续的加锁操作。意思是线程A释放锁之前对共享变量做的所有修改,线程B获取到同一把锁之后一定能看见。这就是为什么synchronized不仅能保证原子性,也能保证可见性——它不是玄学,而是有明确规则背书。用的时候心里要有数,别只把它当“互斥工具”使。
3.2 把happens-before关系画在脑子里
我在实际分析并发问题时,习惯把参与协作的线程和操作画成一条时间线,然后逐条套happens-before规则。最常用的三条是:
- 对volatile变量的写操作,happens-before后续对同一个volatile变量的读操作。
- 对synchronized锁的解锁操作,happens-before后续对同一个锁的加锁操作。
Thread.start()的调用,happens-before被启动线程中的任何操作;被启动线程中的所有操作,happens-beforeThread.join()返回。
这三条记住了,大部分可见性问题都能定位。比如前面那个循环案例,主线程写flag,worker线程读flag,两者之间没有任何happens-before关系,因此主线程的写入不保证对worker线程可见。声明成volatile后,写flag和读flag之间就建立了happens-before关系,问题迎刃而解。
有一个常见的误区是:以为加锁就能完全替代volatile。锁当然能建立可见性,但前提是锁对象的加解锁要发生在同一把锁上,而且加锁解锁的顺序要严格配对。如果你在线程A里synchronized(obj)改了变量,而线程B读变量时用的是另一个锁对象,或者根本没用锁,那可见性依然没有保证。这个坑我在项目里见得太多了,加锁加了一半,效果等于零。
4. 原子性和可见性,是两个维度的区别
4.1 i++到底错在哪
很多初学者会把“线程安全”理解为“结果正确”,看到使用了volatile就认为大功告成。这里必须泼一盆冷水:volatile只保证可见性和有序性,不保证原子性。
经典的例子是count++。假设count是volatile的,初始值为0,两个线程各执行1000次自增,按说结果应该是2000。但实际跑下来经常会小于2000。原因很简单:count++不是一步完成的,它先读count,加1,再把结果写回去。两个线程可能同时读到1,各自加1写回,结果还是1,一次自增被丢掉了。
volatile管不住这种“读-改-写”的中间过程。它保证读的时候看到的是最新值,写的时候也是最新值,但没办法保证“在我读完之后、写进去之前,别人没动过”。想要原子性,得靠synchronized、ReentrantLock,或者用原子类AtomicInteger、AtomicLong。
这里我把三者的适用场景整理了一下,方便对比:
| 手段 | 原子性 | 可见性 | 适用场景 | 性能开销 |
|---|---|---|---|---|
| volatile | 否 | 是 | 状态标志位、发布不可变对象 | 低 |
| synchronized | 是 | 是 | 对共享资源进行复合操作 | 中高 |
| Atomic类 | 是 | 是 | 计数器、累加器、单个变量的CAS操作 | 中 |
4.2 用AtomicInteger做正确计数
既然提到了原子类,就顺手写个正确的累加案例:
public class AtomicCounterDemo { private static AtomicInteger counter = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Runnable task = () -> { for (int i = 0; i < 10000; i++) { counter.incrementAndGet(); } }; Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("最终计数: " + counter.get()); } }AtomicInteger.incrementAndGet()底层依赖CAS(Compare And Swap)指令,先比较当前值是否还是自己读到的值,如果是就交换成新值,否则重试。这个操作是CPU硬件层面保证的,粒度高、性能好,不需要锁。但也有限制:它只能针对单个变量做操作,如果想要实现“多个变量一起改”或“某个复合状态一起更新”,还得靠锁。
至于synchronized,它的本质是互斥 + 内存屏障。进锁时刷新工作内存,解锁时把修改刷回主内存。原子性由锁的排他性保证,可见性由内存屏障保证,一举两得。缺点就是太重:如果锁竞争激烈,线程切换和阻塞等待的成本会吃掉不少性能。
5. synchronized、volatile、final的可见性保障细节
5.1 synchronized并不只是“加把锁”
很多人对synchronized的理解停留在“线程互斥进入代码块”,这没有错,但不够。从内存可见性的角度看,synchronized还承担着刷新和失效高速缓存的工作。
JVM会在synchronized块的入口和出口插入内存屏障。进入时,线程的工作内存被强制失效,后续读取变量必须从主内存或共享缓存重新加载;退出时,会把当前工作内存中修改过的共享变量强制刷新回主内存。用JMM的happens-before规则来描述就是:解锁操作happens-before后续的加锁操作。
这意味着,即使你只是在一个synchronized方法里读了一个普通变量,这个读操作也有机会以“最新值”为准,因为在锁的入口,过期缓存被清掉了。但请注意,前提是读线程和写线程用的是同一把锁。如果是不同的锁,规则不成立,一切免谈。
5.2 volatile到底做了什么
volatile的原理可以拆成三条:
- 强制把写入操作刷新到主内存,防止只留在缓存里。
- 强制把读取操作从主内存加载,防止读工作内存里的旧缓存。
- 禁止指令重排序,尤其是在这个变量附近的读写操作。
前两条解决了可见性,第三条解决了有序性。JVM在volatile写之前会插入StoreStore屏障,写之后插入StoreLoad屏障;在读操作之后插入LoadLoad和LoadStore屏障。这些屏障翻译成大白话就是在关键节点上竖一道墙,不允许编译器和CPU把墙两边的指令乱序调度。
用volatile有个额外的好处:它与synchronized相比没有任何锁竞争开销,也不涉及线程阻塞,所以非常适合“一个线程写、多个线程读”的场景。比如配置开关、运行状态标志、缓存中某个不可变引用的发布,都是典型应用。
但还是要强调:不要在volatile变量上做依赖其旧值的复合操作。典型反例是volatile int count; count++;——这会丢更新;以及“先检查后执行”的逻辑,比如if (volatileField >= 10) { doSomething(); },在多线程下判断完之后,字段可能已经被别的线程改掉了,后续动作的决策基础已经过期。
5.3 final也参与可见性
你可能会觉得奇怪,final字段不是不可变的吗?还需要关注可见性?确实需要。
JMM有一条final域规则:只要对象被安全地发布,也就是构造方法没有把this泄漏出去,那么在另一个线程中读取这个对象的final字段,一定能看到初始化后的值。这是JVM在final字段写完后插入StoreStore屏障实现的,目的就是保证对象构造完成之前,final字段的值不会“漂移”到别的线程可见。
说人话就是:如果你把一个对象通过volatile引用、锁或者其他同步机制发布到其他线程,其他线程不需要再次同步就能安全地读取这个对象的final字段。这在设计不可变对象时非常有用,比如配置类、请求包装类,把成员都设为final,天然享受可见性保证,省掉大量同步代码。
6. 从使用方视角看Java并发类的可见性保证
6.1 线程池、并发容器和锁的可见性
写业务代码时,多数人不会直接操作线程和volatile,而是用更高层的工具:线程池、并发容器、锁等。这些工具之所以“并发安全”,底层都建立在happens-before规则之上。
拿ThreadPoolExecutor来说,你把一个任务通过execute()提交进线程池,那么这个任务被执行前的所有操作,对执行该任务的线程来说都是可见的。这是由execute内部的入队、唤醒操作和线程池工作线程的等待唤醒机制共同保证的。反过来说,如果任务内部修改了某个共享变量,而任务结束后,另一个提交到线程池的任务需要读取这个变量,这两者之间不一定有确定的happens-before关系。除非它们经过同一个队列、同一把锁或同一个volatile变量建立起关系。很多线上诡异数据错乱,就是在不同的线程池调用之间传递可变共享状态导致的。
ConcurrentHashMap同样如此。它不仅提供了线程安全的KV读写,还保证了各个操作之间存在合理的happens-before边界,因此你可以放心地在多线程环境下用它共享数据,而不必额外加锁。常见的误区是在使用ConcurrentHashMap的同时,在外面又包一层synchronized,结果不仅性能下降,还容易引入死锁。要学会信任并发容器已经做好的可见性保障。
有件事值得留心:Lock接口的可见性语义比synchronized更复杂,主要因为它允许多个等待条件(Condition)。在使用ReentrantLock时,lock.lock()与lock.unlock()之间的规则仍然成立,但条件等待await()返回之后,必须重新检查你关心的共享状态,不能假设Condition唤醒时状态一定是最新的。
6.2 单例模式里的可见性问题
单例模式是面试老题,也是实战中可见性Bug的高发地段。双重检查锁定(Double-Checked Locking)曾经是个经典的反面教材,原因就出在对象构造过程中的指令重排序和可见性上。
先看一个看似没问题的实现:
public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }问题在于,instance = new Singleton()这行代码,在底层会拆成:分配内存、初始化字段、把引用赋值给instance。编译器和CPU可能把“初始化字段”和“赋值引用”两步调换顺序。于是线程A执行到“赋值引用”但还没完成字段初始化时,线程B判断instance != null,直接返回了一个初始化到一半的对象,后续访问它的字段就是错的。
修复方式就是给instance加上volatile:
private static volatile Singleton instance;volatile禁止了“赋值引用”越过“初始化字段”的重排序,同时保证线程B在线程A完成整个构造过程后,才能看到非null的引用。这个案例可以说是理解volatile有序性的最好入门题。
7. 实战排查工具与步骤
7.1 遇到诡异并发Bug怎么定位
在真实项目里,可见性问题不会像演示代码那样直白,它通常藏在纷繁复杂的调用链中,且出现频率不稳定。我总结了一套排查路径,每次遇到疑似并发Bug都会按这个顺序走一遍。
第一步,确认是否真是并发问题。把代码在单线程下跑一遍,如果稳定复现,那和可见性无关,先把业务逻辑查清楚。多线程下才出现、且出现次数不稳定,才进入下一步。
第二步,锁定共享变量。审查线程之间通过哪些变量交互。重点关注没有volatile、没有被锁保护、也不是并发容器支持的普通字段。把这些变量列成清单,逐一对照happens-before规则,看看读写之间有没有建立可见性关系。
第三步,用工具观测。比较高效的工具有两个:jstack查看线程栈,确认线程是不是卡在某个循环或等待点;以及JIT编译日志,通过-XX:+PrintCompilation观察热点方法是否被优化,必要时配合-Xint强行跑解释模式做对照实验。如果解释模式下Bug消失、JIT模式下复现,那八成跟JIT优化导致的可见性/重排序有关。
第四步,做最小复现。把怀疑的变量抽出来,像本文第2节那样写一个最小Demo,用循环压力测试跑上百遍,大概率能稳定复现。复现出来后,再用volatile或锁修复并验证。
7.2 代码审查时重点盯的五个雷区
作为约定,这里分享我在评审时一定会重点留意的几个模式:
- 写完一个共享状态后,只是简单赋值,没有用volatile或锁,后续其他线程立刻读取该状态。
- 在循环条件里直接读取普通共享变量,这类代码最容易被JIT提升读取操作,导致循环无法退出。
- 用基本类型long、double做共享读写。在32位JVM上,long和double的写入可能被拆成两个32位操作,出现“写了一半”的情况。虽然现代64位JVM大多数情况下可以原子写入,但规范层面仍建议把这类字段声明为volatile,在32位平台或跨平台场景下更不能心存侥幸。
- 借助“无意识的同步”来传递共享变量,比如因为打印方法恰好是synchronized就依赖它刷缓存。今天能跑,换一个打印实现就崩。
- 对象发布时泄漏了this,导致别的线程在构造完成前就看到了半成品对象。
踩过这五个坑,基本上你的并发代码就会比大部分项目稳一个档次。
8. 最后分享一套实战经验总结
我经常跟身边的人说,并发编程的核心不在于背了多少API,而在于能否准确回答出“两个线程之间到底是怎么建立看见关系的”。平时写代码,先问自己:这个共享变量,写线程和读线程之间有没有happens-before?如果没有,加volatile;如果是复合操作,加锁或改用原子类;如果是高阶并发容器内部的状态,信任它已经处理好了。
前面那个库存超卖排障,最终修复只是把状态标志位改成volatile,又给扣减操作换了AtomicBoolean做原子比较更新,问题就消失了。回头复盘那几行代码,其实任何一个环节多想一步,都不至于让排查耗掉两天。还有一点建议:凡是涉及线程间状态同步的代码,尽量用现成的并发工具,比如用AtomicBoolean.compareAndSet代替“if判断+volatile赋值”,用ConcurrentHashMap.compute代替“get判断后再put”。并发容器自带的复合操作,比你手写的两步走要可靠得多,不要总觉得直接操作底层的volatile更酷,能用工具就用工具。
另外想多说一句:可见性问题往往只在特定硬件和JVM版本下暴露,测试环境里跑一天可能都安然无恙,一到生产环境就原形毕露。遇到这类问题,别急着加锁,先把时间线理清楚,找到那个“写”和“读”没有关联的点。能明确定位到根因,代码修改通常就是一行volatile的事;定位不到根因,就算把每次访问都包一层synchronized,也未必能彻底解决问题。
实践出真知,找一台多核机器,把文中的示例代码跑起来,再用-XX:+PrintCompilation看看JIT的变化,你对可见性的理解会拔高一个层级。