1. 并发三要素到底是什么:先建立全局认识
1.1 为什么单线程时代没有这些问题
做 Java 后端久了,你会发现并发编程里翻来覆去就三个词:原子性、可见性、有序性。不管面试被问“volatile 和 synchronized 的区别”,还是线上遇到“一个变量改了,另一个线程怎么就是读不到”,到最后都能归到这三个词上。Java 线程之间通过共享内存协作,但共享内存并不是“我改了你立刻能看到”这么简单,中间隔着一层 CPU 缓存,又隔着一层 JIT 编译优化,再叠加线程调度,各种诡异问题就冒出来了。
单线程时代完全不存在这些问题,因为程序从头到尾只有一条执行路径。a = 1 执行完,下一步读 a,得到的一定是 1。多线程就不同:两个线程同时读同一个变量,各自在本地缓存里操作,然后写回主内存,时间节点不一致,数据就“丢”了;两个线程一前一后修改同一个计数器,最后一次覆盖会让中间结果彻底消失。更麻烦的是,编译器和 CPU 为了性能会重排指令,单线程下重排不影响结果,多线程下就可能把“先判断后初始化”变成“先初始化后判断”,对象还没造好就被人拿走了。
这三个词,与其说是三个独立的知识点,不如说是并发安全的三大支柱。你学的 synchronized、volatile、Lock、AtomicInteger、ConcurrentHashMap,底层都在围绕这三件事做文章。我见过不少同学把并发问题当成玄学,其实每个线上怪现象都能归因到其中一个或多个要素上。搞懂了它们,各类并发工具在你眼里就不再是孤立 API,而是一套有逻辑的解决方案。
1.2 三要素各自解决哪一类问题
我用一句话概括三要素:原子性是“不可分割”,可见性是“改完大家能看到”,有序性是“指令执行顺序不乱来”。
- 原子性:把多个操作绑成一个整体,要么全部执行完,要么一个都不执行。比如银行转账的“扣钱 + 加钱”必须绑定,不能只扣不加。
- 可见性:一个线程修改了共享变量,另一个线程在随后的读取中必须能看到这个修改,而不是读到旧值或者“过期缓存”。
- 有序性:程序代码在编译和运行过程中可能被重排序,多线程环境下必须保证最终的“逻辑顺序”不会导致错误结果。
如果你想象一个多人协作的团队,会更直观:原子性就是任务不能拆散,要么你干完,要么别人别插手;可见性就是你更新了共享文档,团队其他人打开文档必须是最新版;有序性就是工作流要分步骤,先准备素材再发布,不能颠倒。
1.3 三要素不是孤立存在的
需要特别注意,三要素经常交叉出现。一个 i++ 既涉及原子性(读、加、写三步),也依赖可见性(加完写回主内存),还可能因为重排序导致后续逻辑出问题。
所以看一个方案怎么起作用,要看它覆盖了几件事:
- volatile:保证可见性,禁止某些重排序,但不保证复合操作的原子性;
- synchronized / Lock:通过锁的互斥,让临界区代码“原子”执行,同时锁的释放和获取自带“把工作内存刷回主内存”的语义,所以也保证可见性,锁还天然建立顺序性;
- AtomicInteger 等原子类:用 CAS 无锁方案保证单个读改写操作的原子性,底层字段本身是 volatile,所以也保证可见性,但它不管多个原子操作之间的整体顺序。
这块别死记,先用上面的分类理解,后面第 5 章会有一个速查表,面试前扫一眼就能回忆起来。我在实际带人时发现,只要把三要素之间的覆盖关系理清楚,很多并发题根本不用背,靠推理就能答得七七八八。
2. 原子性:线程安全的“第一根底线”
2.1 什么是原子操作,什么不是
原子性,学术一点叫 Atomicity:一个操作或者一系列操作,从外部看是不可分割的整体。这个操作执行过程中,不允许其他线程插进来操作同一个共享变量。
Java 里只有很有限的场景天生就是原子的,比如 int、long、float、double 这些基本类型的普通赋值,只要不涉及多个字段联动,一般可以看作原子操作。但“原子操作”不等于“原子业务流程”,一旦你把几个操作组合在一起,比如“判断余额是否足够 -> 扣款 -> 转账”,这个组合就不是原子的,哪怕每一步本身都很简单。
最容易翻车的操作是 i++,它不是一条 Java 指令,而是 READ -> MODIFY -> WRITE 三个动作。用 javap -c 看编译后的字节码,至少是 getstatic、iconst_1、iadd、putstatic 这么几条。三个步骤之间完全可能被其他线程插入,于是两个线程同时读到同一个旧值,各自加一后写回,结果只加了一次。
复杂一点的还有 check-then-act,比如“先检查 map 里有没有 key,没有就 put”。这两个操作中间空了很长一段窗口,两个线程可能同时通过检查,然后重复写入。这类问题比 i++ 更隐蔽,因为线上数据量大时,偶尔重复插入一次两次,不仔细看根本发现不了,积累到一定量才会触发报警。
2.2 经典案例:用 100 个线程做 count++
我直接给你一个可以复制到本地跑的案例。定义一个共享的静态变量 count,开 100 个线程,每个线程循环 10000 次 count++,最后打印结果:
public class AtomicityDemo { private static int count = 0; public static void main(String[] args) throws InterruptedException { int threads = 100; int loops = 10000; Thread[] workers = new Thread[threads]; for (int i = 0; i < threads; i++) { workers[i] = new Thread(() -> { for (int j = 0; j < loops; j++) { count++; } }); workers[i].start(); } for (Thread t : workers) { t.join(); } System.out.println("result = " + count); } }理论上应该是 1,000,000,实际情况却是不确定的,可能是 982,xxx,可能是 999,xxx,多跑几次每次都不一样。这不是编译器问题,也不是代码写错了,而是 count++ 本身就不是原子操作。线程 A 读到 count=10,还没写回去,线程 B 也读到 count=10,两个人都把 11 写回去,最终 count 只加了 1。
这个例子特别适合用来验证原子性的概念,我在面试时也经常让候选人事先跑一遍。绝大多数人第一反应是“啊?Java 里 count++ 不是原子的吗?”对,不是。这也解释了为什么网上所有 Java 并发八股文都会拿 i++ 开刀。
2.3 原子性靠什么保证:synchronized、Lock、Atomic 类
要解决 count++ 的原子性,有两条路线。一条是互斥锁,另一条是 CAS 无锁。
互斥锁最简单,直接在 increment 方法上加上 synchronized:
public synchronized void increment() { count++; }同步块内部的代码同一时刻只能有一个线程进入,其他线程全部阻塞在门口。这样本来应该拆成三步的 count++ 就被“锁”成了一个整体,从外部看它不会被打断。Lock 和 ReentrantLock 原理类似,只是提供了更灵活的控制,比如 tryLock 带超时、可中断地等待锁。
另一条路线是用原子类。AtomicInteger 的 incrementAndGet() 内部走的是 CAS(Compare And Swap)循环:读取当前值 expected,执行加一,然后调用底层 compareAndSet 检查当前值是不是还是 expected,如果是就替换为新值,否则重试。整个循环里没有加锁,没有阻塞,并发能力强很多。
AtomicInteger count = new AtomicInteger(0); count.incrementAndGet();注意一个限制:原子类只保护“单个方法”的原子性。如果你写成:
if (atomicBalance.get() >= money) { atomicBalance.addAndGet(-money); }这个“检查再扣款”的组合操作就不是原子的,两个线程可能同时通过余额检查。要保证复合操作原子,要么用 synchronized 把整个 if 包起来,要么在 CAS 循环里组合判断,要么引入版本号,看业务复杂程度。
2.4 原子性的实操心得:不要靠运气写并发
我的第一个建议是:写并发代码之前,先问自己这个共享数据是不是真的需要共享。如果每次请求都可以自己 copy 一份,完全不需要共享,并发问题就少一半。
第二个建议是:优先考虑不可变对象。字段用 final 修饰,对象发布之后不再改变,那么原子性问题天然消失,因为没人改它。
第三个建议是:如果必须要用原子类,高并发场景从 AtomicLong 换成 LongAdder。LongAdder 内部把计数分散到多个 cell,在线程多、写操作非常频繁时,吞吐量比 AtomicLong 高不少。读的时候需要 sum() 汇总一下,但读多写少场景下效果很好。
最后还有一个坑,虽然现代 64 位 JVM 上 volatile long/double 的赋值几乎都是原子操作,但 JMM 规范严谨的说法是:非 volatile 的 long/double 写操作允许被拆成两个 32 位写。规范说“允许”,不代表你用的 JVM 一定会拆。可一旦遇到 32 位虚拟机或者某些特殊硬件,就会读到撕裂的数据。稳妥的做法是:跨线程共享的 long/double,要么加 volatile,要么用 AtomicLong,别赌实现细节。
3. 可见性:你改的变量,别的线程真的能看到吗
3.1 JMM 与主内存/工作内存
可见性比原子性更隐蔽。原子性问题至少能靠计数器多跑几遍复现,可见性问题可能几天才出现一次,而且切换到 debug 模式或者修改一下日志,问题就消失了。
Java 内存模型(JMM)用一套抽象规则解释可见性:所有共享变量存在主内存中,每个线程又有一份自己的工作内存。线程读变量时,先到工作内存里找,找不到或认为过期才从主内存加载;线程写变量时,先写到工作内存,再刷回主内存。这个“什么时候刷”没有强制保证,于是可能发生:线程 A 写了一下午,线程 B 还在用自己工作内存里的旧副本。
实际硬件上,对应的是 CPU 多级缓存。核心 0 改了变量,变量可能还留在自己的 L1/L2 Cache 里,核心 1 读到的还是主内存旧值。现代 CPU 有缓存一致性协议(比如 MESI),听起来很美好,但写缓冲(store buffer)等机制仍然可能导致短暂的不一致。在 Java 层面,你不需要精确掌握 MESI,只要记住:没有同步手段,跨线程读到旧值是“合法”的,问题不是玄学,是 JMM 允许的行为。
3.2 经典案例:一个布尔变量引发的死循环
我用一个最简单也最吓人的案例说明可见性:
public class VisibilityDemo { private static boolean running = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (running) { // 空循环 } System.out.println("worker stopped"); }); worker.start(); Thread.sleep(1000); running = false; System.out.println("main set running=false"); } }看起来主线程 1 秒后把 running 改成 false,worker 线程的 while 循环条件不满足,应该退出。但实测时,有的机器立刻退出,有的机器直接卡死,还有的机器用不同 JVM 参数跑结果都不一样。核心原因是:worker 线程可能把 running 读取进了 CPU 寄存器或工作内存,当 JIT 发现这个循环里没有修改 running,还可能把读取优化成“一次性加载”,于是循环永远读旧值。
解决办法很简单,给 running 加 volatile:
private static volatile boolean running = true;加上之后,worker 线程每次循环都必须重新从主内存读取 running,这个问题就消失了。我给非技术背景的同事解释时常用一个比喻:volatile 相当于把每个线程的小纸条扔了,强制去公告板上看最新消息。代价是多一点内存屏障开销,但换来的是正确的可见性。
3.3 volatile 到底做了什么
volatile 的语义可以拆成三句话:
- 写一个 volatile 变量时,JVM 会把这个写操作“推”到主内存,而不是留在工作内存;
- 读一个 volatile 变量时,JVM 会直接从主内存读,而不是用本地缓存;
- 读和写之间会插入内存屏障(Memory Barrier),禁止某些指令重排序,避免把不该越过的读写顺序调乱。
内存屏障听起来很深,但你可以把它当成一道“栅栏”:栅栏前后的指令,不允许随便跨到对面去。volatile 在写操作后会插入写屏障,防止把之前的普通写操作重排到 volatile 写之后;在读操作前会插入读屏障,防止把之后的普通读操作重排到 volatile 读之前。
但一定要记住,volatile 不解决原子性。就算 running 是 volatile,如果改成 running++ 这种读-改-写,两个线程依旧可能同时读到同一个旧值,然后各自改了写回,结果丢更新。所以 volatile 适合“一个线程写,其他线程读”的状态标志位,不适合“多个线程同时写”的计数器。
3.4 volatile 之外的可见性手段与实操心得
除了 volatile,synchronized 和 Lock 也保证可见性。锁的释放会把线程工作内存强制刷新到主内存,锁的获取会让工作内存失效,从而不得不重新从主内存加载。所以你在 synchronized 块里读共享变量,看到的一定是最新值;退出 synchronized 前改的共享变量,对后续获取同一把锁的线程可见。
final 字段也值得说。某个对象安全发布后,它的 final 字段对所有读到该对象的线程可见,不需要额外加 volatile。前提是对象构造时不能让 this 逃逸,也就是不能在构造函数里把 this 发布给其他线程,否则发布顺序被打乱,final 的保证也会失效。
日常开发中,我最常见的 volatile 使用场景就是“开关类状态”:服务关闭标记、缓存加载完成标志、任务暂停开关。判断要点是:变量只有一个线程负责改,其他线程只读。如果你发现自己试图用 volatile 去“锁”住一段业务逻辑,基本说明思路不对,应该考虑换 synchronized 或者并发容器。
还有一个细节:volatile 修饰数组时,volatile 只对数组引用本身生效,数组里的元素仍然没有 volatile 语义。如果多线程要操作共享数组的元素,可以用 AtomicIntegerArray、AtomicLongArray 或者干脆换成并发容器,直接避免在裸数组上做并发操作。
4. 有序性:重排序是如何把代码“悄悄换顺序”的
4.1 编译器、CPU、指令重排序
有序性问题最难理解,因为它违反了我们的直觉:代码写成一二三四,凭什么执行顺序变成一三四二?
为了提升性能,编译器和 CPU 会做指令重排序。编译器在生成字节码或机器码时,认为只要不影响单线程的执行结果,就可以调整某些无关指令的顺序;CPU 执行时,又可能通过乱序执行管道把指令顺序再调一次;甚至内存系统也可能把写入刷回的顺序凑一凑。这一切在单线程下都是“优化”,多线程下就可能是灾难。
例如:
int a = 0; int b = 0; void write() { a = 1; // 操作1 b = 2; // 操作2 } int read() { if (b == 2) { return a; } return -1; }如果线程 1 执行 write,线程 2 执行 read,操作 1 和操作 2 没有数据依赖,编译器可能把 b=2 提到 a=1 前面。结果线程 2 看到 b==2 时,a 可能还是 0,于是 read 返回 0。如果没有重排序,read 应该返回 1。这个例子看着很做作,实际工程里,类似问题在“发布对象”时非常常见。
4.2 经典案例:双重检查锁单例为什么需要 volatile
最经典的实战案例是双重检查锁(DCL)单例。我第一次看这段代码时也觉得奇怪:外层判空、内层加锁、再次判空,已经很完美了,为什么非要加 volatile?
public class Singleton { private static volatile Singleton instance; private Singleton() { // 初始化逻辑 } public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); } } } return instance; } }new Singleton() 在字节码层面大概是三步:分配内存空间、执行构造函数完成初始化、把对象的引用赋值给 instance。第三步和第二步之间,JVM 在不改变单线程语义的前提下,可能把顺序调成:分配内存、把引用先赋给 instance、再执行构造函数。
如果这个重排发生了,线程 A 执行到“把引用赋给 instance”,构造函数还没跑完;线程 B 正好进来,看到 instance 非空,直接返回并开始使用。B 拿到的是一个内存空间分配了但构造函数尚未执行完的“半初始化对象”,在构造函数里初始化的字段全部是默认值,接下来就是各种不可预期的空指针和业务错误。
加 volatile 后,写 volatile 变量会插入 StoreStore 屏障,禁止把构造函数中的普通写操作重排到 volatile 写之后,也就是禁止“提前发布引用”。这样 instance 被赋值时,构造函数一定已经完成,其他线程读到的一定是完整对象。如果不想用 volatile,可以用静态内部类懒加载方式,或者直接在枚举单例里写,Java 会保证枚举常量的初始化安全,连双重检查锁都省了。
4.3 happens-before 规则:判断可见性与有序性的依据
面试时如果只背“volatile 禁止重排序”其实不够,真正能帮你推理一切并发代码的,是 happens-before(先行发生)原则。只要两个操作满足 happens-before,前一个操作的结果对后一个操作可见,且前一个操作不会“跑到”后一个操作后面去。
我整理下最常用的几条:
- 程序次序规则:同一个线程内,写在前面的代码先行发生于后面的代码;
- 监视器锁规则:解锁操作先行发生于同一个锁后续的加锁操作;
- volatile 变量规则:对一个 volatile 变量的写操作,先行发生于之后对同一个变量的读操作;
- 线程启动规则:线程对象的 start() 方法先行发生于该线程内部的所有动作;
- 线程终止规则:线程中的所有操作,先行发生于其他线程对该线程的 join() 成功返回;
- 传递性:如果 A 先行发生于 B,B 先行发生于 C,那么 A 先行发生于 C。
为什么 synchronized 能同时保证原子性、可见性、有序性?因为它满足监视器锁规则,锁内代码执行完退出锁,对后续拿锁的线程可见,同时锁又保证同一时刻只有一个线程进入临界区,临界区内的操作天然不会被并发拆散。volatile 则靠 volatile 变量规则保证“先写后读”的有序和可见。
我建议你在设计并发代码时,不要凭感觉说“这里应该没问题”,而是找一条 happens-before 路径:是否满足某一规则?如果找不出来,说明这段数据流还没有被正确同步,出了问题不要惊讶。
4.4 关于有序性的实操心得:别把希望放在“概率”
重排序问题最难办的是“概率不可控”。你写了一个看起来有点问题的并发代码,跑了几百次都正常,投放线上后过了两周突然炸了,这种“幽灵 bug”最消耗精力。
所以我实际操作时有一个习惯:任何涉及共享变量初始化和发布的代码,一律按严格的安全发布方案写。要么用 final 字段配合安全发布,要么用并发容器,要么在最坏情况下都加上 volatile 或者锁。这看起来很保守,但能避免半夜被手机震醒。
此外,想研究重排序底层,可以用 OpenJDK 的 jcstress 并发压力测试框架。它能生成专门的并发测试用例,用很多线程反复压同一个操作,把那些低概率的乱序行为暴露出来。普通项目里不会让你写 jcstress 测试,但看几个官方示例,会对“重排序居然真的存在”这件事有非常直观的认知。
如果你排查线上问题,抓到可疑堆栈后怀疑是重排序,不看汇编往往很难定论。可以加上 -XX:+PrintAssembly 看 JIT 生成的汇编指令,或者用 -XX:-TieredCompilation 之类的参数改变编译策略,然后对比问题是否复现。对多数开发者而言,重点是先把正确的同步手段写好,而不是去证明“到底哪一步重排了”。
5. 面试与实战速查:避坑清单和记忆方法
5.1 面试官最爱问的问题
现在网上都是“Java 并发面试题”这类内容,其实翻来覆去核心就那么几个。真正能拉开差距的不是背答案,而是理解为什么。
Q1:volatile 能保证原子性吗? 不能。volatile 只保证可见性和有序性,不保证复合操作的原子性。计数器场景要用 AtomicInteger 或者加锁。
Q2:synchronized 和 volatile 有什么区别? volatile 是轻量级的“变量级同步”,无锁、不阻塞,修饰变量;synchronized 是重量级的“代码块级同步”,有锁竞争和线程阻塞,但性能并不一定差。synchronized 可以保证原子性、可见性、有序性,volatile 只能保证后两个。
Q3:为什么双重检查锁单例要加 volatile? 因为 new 一个对象不是原子的,可能发生重排序,导致引用被提前发布,其他线程拿到“半初始化”对象。volatile 禁止构造步骤重排,保证了对象的完整发布。
Q4:有哪些 happens-before 规则? 程序次序、监视器锁、volatile 变量、线程启动、线程终止、传递性。面试时说出前五条,再补一句传递性,基本就够了。
Q5:多线程都要读一个状态标志位,改了之后要立刻看到,用什么? 用 volatile。只要写入操作不依赖当前值,并且只有一个线程去写,volatile 是最省事的方案。
5.2 三要素对应技术选型速查表
| 场景 | 推荐方案 | 背后原因 |
|---|---|---|
| 计数器 / 累加操作 | AtomicInteger、LongAdder | CAS 无锁,单个操作原子 |
| 状态开关,一写多读 | volatile | 轻量保证可见性,不阻塞 |
| check-then-act 复合操作 | synchronized / Lock / 分布式锁 | 需要把检查和执行绑成原子整体 |
| 懒加载单例 | 静态内部类 / 枚举 / volatile+DCL | 保证安全发布且延迟加载 |
| 共享集合 | ConcurrentHashMap、CopyOnWriteArrayList | JDK 已封装并发细节 |
| 跨线程共享 long/double | volatile long/double 或 AtomicLong | 防止撕裂写 |
| 不可变配置 | final + 安全发布 | 发布后不变,天然线程安全 |
这张表我面试前必扫一眼,不是因为能直接背答案,而是它帮我快速定位“当前场景主要缺哪个要素”,选型就不容易乱。
5.3 记忆口诀:原子不拆、可见即达、有序不乱
给想快速记忆的朋友一个口诀:
- 原子性:“一个操作要么全做,要么全不做”,像数据库事务一样;
- 可见性:“改完了,其他线程马上能看到”,像群里发通知一样;
- 有序性:“逻辑先后别乱套”,像先洗菜再下锅一样。
严格来说,口诀无法覆盖所有边界情况,但面试时用来开场特别有用。说完口诀,再补一句“实际上这三者经常交叉,比如 volatile 保证可见性和有序性,但不保证原子性”,对方就知道你真的理解而不是背了段顺口溜。
5.4 最后的实操提醒:从设计上减少并发问题
聊了这么多,最后想说的其实是:并发三要素很重要,但最好的并发代码是“没有共享”的代码。能不用共享变量就不用共享变量,能用不可变对象就用不可变对象,能交给现成并发容器就交给并发容器。自己做同步,意味着要同时管理原子性、可见性、有序性三个维度,任何一个疏漏都是线上事故。
我自己做并发改造时的顺序是:先梳理数据是怎么在线程之间流动的,找到所有读和写的点;然后看能不能把写去掉或收敛到一个线程,减少共享;如果必须共享,再选用对应的同步原语;最后用代码审查和并发测试验证。这个过程没有捷径,但比靠“跑一次没出错”来判断靠谱得多。
最后再分享一个小技巧:我给团队定过一条很土但很有效的规则,新增任何跨线程共享变量,Code Review 里必须写清楚它靠什么保证原子性、可见性、有序性。写不出来,就不要合并。这个方法很死板,但真的比事后对着玄学 bug 熬夜排查划算太多。