1. 并发究竟在解决什么问题:先搞清楚线程安全为什么难
很多同学第一次接触 JUC 并发编程,都是从背八股文开始的:进程和线程的区别、start 和 run 的区别、sleep 和 wait 的区别……这些基础概念当然重要,但如果你真去写并发代码,会发现最大的难点根本不在这些名词上,而在于“你根本想象不到代码会以什么样的顺序被执行”。
先讲一个我实际接手过的案例。某个后台服务里有段很“经典”的代码:一个计数器,多个线程并发累加。负责人理直气壮地说加上了 synchronized,肯定安全,结果压测的时候数据就是不对。后来查下来,他发现锁加在了方法内部的局部变量上——每次进入方法都是新对象,锁了个寂寞。这类问题并不罕见,根源就在于很多人背了概念,却不知道每个并发工具到底解决的是哪一类问题。
在我看来,并发编程要解决的核心问题其实只有三个大类:原子性、可见性、有序性。这三件事也是 JUC 所有工具、所有关键字存在的根本理由。
- 原子性:一个操作或者多个操作,要么全部执行且执行过程中不被任何因素打断,要么全部不执行。典型例子就是
count++,它不是原子操作,拆成“读、加、写”三步,线程交替执行就丢了更新。 - 可见性:当一个线程修改了共享变量时,其他线程能否立刻看到这个修改。现代 CPU 有多级缓存,线程读到的可能不是最新值。
- 有序性:程序执行的顺序按照代码的先后顺序执行。但编译器和 CPU 为了优化,可能会对指令进行重排。
这三个特性并不是独立存在的,它们经常交织在一起。比如经典的“双重检查锁单例”,synchronized 保证了原子性,但如果不加 volatile,有序性问题(对象实例化时的指令重排)就会导致另一个线程拿到“半个对象”。
搞清楚这三个特性的定义之后,你再看 JUC 包里的东西,会发现它们都是对症下药的:synchronized 和 Lock 解决原子性(顺带通过锁的机制也解决了可见性),volatile 解决可见性和有序性,CAS 走的是乐观锁路线,ThreadLocal 则是直接从“不共享”角度回避问题。
很多人在准备并发面试时,只背概念,不做归因,导致面试官换个说法就答不上来。我建议你把每一个并发问题都归结到“三特性”上,弄清楚每个工具分别解决什么,这样不仅面试能过,写代码时也能少出很多幺蛾子。
2. 从 CPU 缓存到 JMM:理解内存模型是理解并发的底座
聊并发三特性很容易停留在抽象层面,你可能会觉得“原子性好理解,可见性为什么这么难?”要真正理解可见性,你必须先知道现代计算机的存储结构和 Java 内存模型的关系。
多核 CPU 的缓存结构大致是:每个核心都有一级缓存和二级缓存(每个核独立),再共享一个三级缓存,然后才到内存。当 CPU 执行指令时,它不会每次都从内存读数据,而是先从缓存里找。如果多个核缓存了同一个变量的副本,某个核改了之后,其他核的缓存不失效,就会读到旧值。
Java 为了屏蔽不同硬件和操作系统的差异,定义了自己的内存模型规范,即 JMM(Java Memory Model)。它的规矩是:
- 所有共享变量存在主内存中(对应物理内存的概念)
- 每个线程有自己的工作内存(对应 CPU 缓存、寄存器等),线程对变量的读改写都必须在工作内存中完成
- 线程之间不能直接访问对方的工作内存,只能通过主内存交互
所以,如果线程 A 修改了变量 i,线程 B 的 i 依然保存在自己的工作内存中,它是无法感知 A 的修改的。这就是可见性问题最朴素的来源。
用生活类比的话:你和同事在同一个文档协作平台(主内存)上编辑同一份文档,但你俩各自的本地缓存里都有一份副本。你把某一处改成了新版本并保存到了平台,可同事的本地副本还是旧的,他就看不到你改的东西。除非平台通知他重新拉取,或者你们的编辑软件做了实时同步。
JMM 通过volatile关键字来规定:用 volatile 修饰的变量,线程每次使用前都必须回主内存读取最新值,每次修改后也必须立即刷回主内存。所以 volatile 解决的就是可见性问题。
这里我要给个重要提醒:volatile 只保证可见性,不保证原子性。多线程同时对同一个 volatile 变量做count++,照样会丢数据。这是因为“读最新值”和“写新值”已经是两次独立操作了,中间线程一样可能被切走。很多初学者觉得“volatile 万金油”,实际上它必须用在对的场景——典型的场景是状态标志位、单例对象引用、基于 CAS 的原子类的内部 value 字段。
另外,JMM 还有一个很重要的东西叫happens-before 规则。它是判断两个操作是否可见的规则集,面试八股里常考,但很多人只是背名字。简单说,如果 A happens-before B,那么 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。包括程序顺序规则、监视器锁规则(解锁 happens-before 后续加锁)、volatile 变量规则(写 happens-before 后续读)、线程启动规则、线程终止规则等。
搞懂这一点,很多看似玄学的并发问题就能解释通了:为什么 synchronized 块里读到的变量一定是新的?因为管程锁规则里,释放锁动作 happens-before 后续获取同一个锁的动作,所以临界区内部相当于被“隔离保护”了,天然具备可见性。
3. synchronized 与 volatile 的使用边界:不是所有场景都该上锁
面试基础题里最高频的就是“synchronized 和 volatile 的区别”,但很多回答都是干巴巴罗列条目。我从实际代码设计的角度聊聊,什么时候用什么。
synchronized 是阻塞式同步。它的特点就是重操作,一旦某个线程拿到锁,其他想拿锁的线程只能阻塞等待,直到锁被释放。Java 底层对 synchronized 做过很多优化,比如偏向锁、轻量级锁、重量级锁的升级路径,不同锁状态对应不同的获取方式。虽然现代 JDK 里 synchronized 性能已经不输给 ReentrantLock,但它本质上是“互斥串行化”的思路——同一时刻只允许一个线程执行临界区。
volatile 是非阻塞式同步。它不锁任何东西,不阻塞任何线程,只做两件事:保证可见性,禁止指令重排。它的好处是开销极小,但代价是它管不了多步骤复合操作的原子性。
基于这两个核心差异,我总结出几个实际选择原则:
| 对比维度 | volatile | synchronized |
|---|---|---|
| 线程阻塞 | 不阻塞 | 会阻塞等待锁 |
| 原子性保障 | 不保证 | 保证临界区原子性 |
| 可见性保障 | 保证 | 保证 |
| 指令重排禁止 | 保证 | 保证(加锁和解锁两侧也有边界) |
| 性能开销 | 极小 | 相对更大(现代 JDK 已优化) |
| 典型应用 | 状态标志、单例 DCL | 多步更新、复合操作、资源池管理 |
举个非常典型的场景对比:状态标志位和多步更新。
状态标志位,比如一个线程负责启动后台处理,另一个线程负责接受停止命令,那么用一个 volatile boolean 字段当开关旗标就够了:
public class TaskRunner { private volatile boolean running = true; public void stop() { running = false; } public void run() { while (running) { // 执行任务主循环 } } }这里用 volatile 而不是 synchronized 的原因很直接:主循环轮询不需要阻塞,用锁反而会拖慢整个执行流程;而且running = false是单步写入,不涉及复合操作,天然原子。反过来看多步更新,比如转账的余额扣减 + 流水记录 + 对方余额增加,这三步必须是一个不可分割的整体,任何一个中间状态都不允许被其他线程看到,这种场景就必须用 synchronized 或者 Lock:
public synchronized void transfer(Account from, Account to, int amount) { from.balance -= amount; to.balance += amount; // 写流水、做校验…… }还有一类场景要特别警惕:多个锁的顺序问题。如果你的转账方法里同时锁定了两个账户,线程 T1 锁 A 再锁 B,线程 T2 锁 B 再锁 A,就会互相等待,形成死锁。JUC 包里有专门定位死锁的工具,比如 jstack 可以打线程快照,jconsole也可以直接检测到死锁。实际编码时更推荐的做法是锁粒度尽量小、锁顺序全局一致,或者用ReentrantLock的tryLock()方法加超时时间避免无限期等待。
4. 线程的生命周期与核心方法:抛开八股,看它们真正控制什么
线程这一块是并发基础里最基本的,但面试和实际使用之间存在一道明显的坎。背八股的同学能脱口而出六种状态(NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED),但真要问“什么操作会让线程进入 WAITING 状态,又靠什么唤醒”,往往就卡住了。这个问题我觉得值得展开讲,因为你在理解生产代码里的协作机制时,时刻都要面对它。
Java 线程状态转换的核心逻辑其实很简单:
- NEW:创建后还没调用 start()
- RUNNABLE:调用 start() 后,等待 CPU 调度。注意这里它已经在运行状态上了,操作系统层面的 ready 和 running 在 Java 这边统一归为 RUNNABLE
- BLOCKED:想进入 synchronized 代码块但锁被其他线程持有,等待锁
- WAITING:主动调用了 wait()、join()、park() 等方法,进入无时间限制的等待
- TIMED_WAITING:调用了 sleep(ms)、wait(ms)、join(ms) 等方法,有超时时间的等待
- TERMINATED:run() 方法正常执行完,或中途抛出未捕获异常退出
这几个状态之间的转换,才是并发协作机制的精髓。
先说Thread.sleep() 和 Object.wait() 的区别。这是面试必问的。sleep 不会释放任何锁,纯粹是“睡一会儿”;wait 的语义则是“我暂时不满足条件了,放弃锁,等别人唤醒我”。所以 wait 必须在 synchronized 代码块中调用,否则就违背了“放弃锁”的前提。而且 wait 之后线程进入的是 WAITING 或 TIMED_WAITING,而不是 BLOCKED。
另一个容易搞混的是join() 和 CountDownLatch。join 的语义是“等待某个线程终止”,底层实现其实是 main 线程调用t1.join()时,进入 wait 循环直到 t1 终止。如果你有三个线程并发执行,想等它们全部结束后再汇总结果,直接调用三个 join 按顺序执行即可。CountDownLatch 更像是“倒计时门闩”,每个线程完成工作后调用countDown(),主线程 await 直到计数归零,实现比 join 更灵活,因为你可以控制“哪几步完成后再继续”。
还有interrupt() 和中断响应机制。Java 的中断不是强杀线程,而是协作式地“标记中断状态,并让阻塞中的线程被唤醒”。调用thread.interrupt()后,如果目标线程正在 sleep/wait/join,它会被唤醒并抛出 InterruptedException;如果线程正在执行普通代码,它只会被设置中断标志位,需要代码里自己判断Thread.interrupted()或者isInterrupted()来决定后续行为。生产代码里这是优雅停止工作线程的常用套路:
while (!Thread.currentThread().isInterrupted()) { // 处理任务 }之所以强调这些,是因为你在写线程池的时候,shutdownNow()内部就是靠 interrupt 来中断正在执行的任务的,如果任务代码没有合理响应中断,线程池没办法立即停掉它,只能等其自然结束。
5. start() 与 run() 的底层差异:为什么面试官死磕这个看似简单的问题
“调用 run() 和调用 start() 的区别”算是最基础的八股题,但很多人答得流于表面:start 是启动新线程,run 是执行当前线程里的方法。要答得让面试官眼前一亮,就得深入底层。
在 HotSpot 虚拟机中,start() 的核心链路是:Thread.start()→start0()(JNI 方法)→ JVM 内部创建操作系统线程,并调用thread_entry→ 最终回调 Java 的run()方法。也就是说,只有 start() 才会触发 JVM 创建新的操作系统线程,run() 只是一个普通方法,在调用它的线程里同步执行。
因此:直接thread.run()本质上是把多线程编程当作单线程串行执行,完全没有并发效果。这通常不是调用者有意为之,更多是从Runnable里拿了run方法直接调用的误操作,压测环境里这类“假并发”还很不好排查——你看到输出日志顺序是正常的,线程状态监控里却只有一个 RUNNABLE 线程在跑。
更有意思的是,Thread 类本身也是实现了 Runnable 接口的。如果用匿名内部类创建一个自定义 Thread,并重写了它的run()方法,那你传进来的 Runnable 参数其实不会被执行。因为 Thread 的默认run()方法是“如果 target(传入的 Runnable)不为空,就执行 target.run()”,而一旦重写了 run(),target 就被丢到一边了。这是初学时很容易踩的坑:
// 错误示范 Thread t = new Thread(() -> System.out.println("runnable 实现")) { @Override public void run() { System.out.println("子类覆盖实现"); } }; t.start(); // 只输出"子类覆盖实现"所以在实际代码规范里,我一般都推荐优先使用实现 Runnable 或 Callable 的方式,而不是继承 Thread。理由有三点:
- Java 单继承,继承了 Thread 就没法继承别的类
- Runnable 把任务和线程分开,线程池里复用的都是 Runnable/Callable 任务
- 继承 Thread 容易在不经意间重写掉默认行为,产生隐蔽 bug
Callable 和 Runnable 的区别也值得放在这里一起说。Runnable 的 run() 没有返回值,也不能抛受检异常;Callable 的 call() 可以有返回值,可以抛异常。如果你想在线程里跑一段逻辑,然后把计算结果交回主线程,用 Callable + FutureTask 是标准姿势:
Callable<Integer> task = () -> computeResult(); FutureTask<Integer> futureTask = new FutureTask<>(task); new Thread(futureTask).start(); Integer result = futureTask.get(); // 阻塞等待结果就绪这种方式在并发计算、批量任务拆解时非常实用。Runnable 更像“只做事不汇报”,Callable 则是“做完事要交差”。现代 Java 里主推的是通过线程池提交 Callable 任务,返回 Future 对象,后续我们从源码层面聊 AbstractExecutorService 的时候再展开。
6. volatile 的底层内存屏障:从字节码到 CPU 指令的完整链路
前面说 volatile 保证可见性和有序性,但这是 JMM 规范层面的表述。要深入理解 volatile,必须看它到底在底层做了什么:内存屏障。
用 javap 反编译一段 volatile 字段的赋值代码,你会看到它对应的字节码和普通字段赋值几乎一样(都是 putfield),但 JVM 在生成机器指令时,会在 volatile 写操作的后面插入一个StoreLoad 屏障,在 volatile 读操作的后面插入一个LoadLoad + LoadStore 屏障。
这里的关键是 CPU 的内存屏障指令,x86 平台下的经典组合是lock前缀指令。lock; addl $0, 0(%%rsp)这类空操作在 x86 上是一个全功能的内存屏障。它的效果有两层:
- 强制将对缓存的修改立即写回主内存,并让其他核心里缓存的对应数据失效
- 禁止屏障两侧的指令越过屏障重排
这也就解释了为什么 volatile 能保证有序性——不是因为 volatile 变量本身不被重排,而是 JVM 通过内存屏障限制住了它周围的指令排序。拿经典的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,instance = new Singleton()这行会被编译器拆成三步:分配内存、调用构造器初始化、把引用赋值给 instance。指令重排后,可能先把引用赋给了 instance(此时内存还没初始化完成),另一个线程第一次检查时发现 instance != null,直接 return 拿走了半成品对象。volatile 在这里限制了“引用赋值”不能越过“构造器初始化”被提前执行。
读 volatile 变量的内存屏障同样重要。你读一个 volatile 变量时,后续的普通读操作都会被限制在屏障之后执行,这样就能保证:先读到共享变量的最新值,再去读其他相关变量时,不会因为本地工作内存里的旧副本而错乱。
这里要顺带提一个很实用的排查技巧:你可以通过 JDK 自带的-XX:+PrintAssembly参数查看 volatile 相关指令的汇编码,但前提是本地安装有 hsdis 插件。如果没有,也可以用-Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly组合观察。不过实际工作中我更推荐用 jcstress 这个并发正确性测试工具,它可以在各种 CPU 架构和 JVM 配置下自动跑并发压力测试,帮助验证 volatile 或者 CAS 的用法是否正确。很多并发 bug 在一种架构上表现出来,在另一种架构上可能几周都不触发,手写多线程测试很难覆盖全面。
7. CAS 与自旋:乐观锁思想在并发底层的第一次落地
如果说 synchronized 是 Java 并发世界里的“悲观锁之王”,那 CAS(Compare And Swap)就是“乐观锁鼻祖”。理解 CAS,你才真正理解 JUC 里一大票原子类、无锁数据结构的设计起源。
CAS 的操作语义非常朴素:比较并交换。给定一个内存地址 V,一个期望值 A,一个新值 B,只有当 V 中当前值等于 A 时,才把 V 更新为 B;否则不做任何操作。整个比较和交换的过程是硬件级别的一条原子指令,所以它能做到不加锁却安全地更新共享变量。
Java 层面,Unsafe类提供了compareAndSwapInt、compareAndSwapLong、compareAndSwapObject等 native 方法,JUC 的原子类(AtomicInteger、AtomicLong、AtomicReference 等)全部封装在这之上。以 AtomicInteger 的incrementAndGet()为例,它的底层就是死循环 + CAS:
public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) { return next; } } }如果 compareAndSet 失败,说明在读取 current 和更新之间,有其他线程抢先改了这个值,那么循环重试,重新读取最新值,再尝试更新。这个“失败就重新试”的过程就是自旋。它的优点是线程永远不会被阻塞,适合锁竞争时间短的场景;缺点是如果竞争很激烈,CPU 空转会造成大量性能损耗,这就是传说中的“ABA 问题”之外最需要考虑的性能瓶颈。
ABA 问题是另一个面试必考点:变量从 A 变成 B 再变回 A,CAS 检查当前值是 A,就认为没有变化,于是更新成功。但实际这个值已经被改了两次,可能产生了副作用。解决办法是用版本号或时间戳来区分,JUC 包里的AtomicStampedReference就是为此设计的。
在实际项目中,我会优先考虑一个思路:能用原子类解决的不要用 synchronized,能用 synchronized 解决的不要自己画蛇添足去手写 CAS。原因不难理解:
- 原子变量的自旋在低竞争场景下性能远高于锁
- 但是高竞争场景,锁(尤其是 synchronized 的分代锁优化)会让阻塞线程进入挂起状态,不消耗 CPU,此时原子类的自旋反而会因为不断重试而让 CPU 飙满
什么时候用 CAS?典型如统计请求量、全局唯一 ID 生成器、某些无锁栈和队列的实现。什么时候别硬用?比如临界区很长、操作步骤很多、业务需要多变量一致性保护时,老老实实加锁。
我记得之前做一个网关系统,单机每秒上万的流量统计,当时用了 AtomicLong 做计数器,跑得很稳。后来业务升级,加了一个“统计后按阈值告警”的逻辑,这个判断动作加上一个辅助计数,用原子变量组合起来就非常别扭,最后改成了 LongAdder 才解决问题。大家如果做高并发统计类需求,可以多了解一下 LongAdder,它把热点分散到多个 Cell 上,在高竞争下比 AtomicLong 的 CAS 自旋表现好得多。
另外,CAS 还有一个重要应用面是并发容器和锁框架:ConcurrentLinkedQueue基于 CAS 实现入队出队,AbstractQueuedSynchronizer(AQS)里更新 state 状态也是 CAS。可以说从这层往下,JUC 的整个骨架都是 CAS + volatile 撑起来的。
8. 并发基础自测清单:用这几道题检查你到底是真懂还是假懂
写到这里,我整理了 10 道并发基础的题目,都是面试高频和实际编码中容易翻车的点。建议你遮住答案,先自己回答一遍,再看解析。
| 问题 | 关键考察点 | 易错点 |
|---|---|---|
1.i++原子吗? | 原子性的定义 | 很多人体感上觉得“一行代码是原子的”,实际不是 |
| 2. volatile 能替代 synchronized 吗? | 各自适用范围 | 经常有人把“可见性”误当“原子性” |
| 3. sleep 持有的锁会释放吗? | Thread.sleep 与 wait 的区别 | 容易混淆为“sleep 会释放所有锁” |
| 4. 什么时候必须用 synchronized? | 多步操作保护 | 想不起复合操作也需要锁保护 |
| 5. JMM 里 happens-before 是什么? | JMM 内存模型理解 | 背了名字但解释不清作用 |
| 6. DCL 单例为什么要 volatile? | 指令重排的危害 | 从没想过 new 对象是三步操作 |
| 7. CAS 一定会导致 ABA 吗? | CAS 的局限性 | 误以为每次都必然发生 ABA |
| 8. start() 直接执行了 run() 吗? | 线程启动链路 | 以为 start0 直接回调 run |
| 9. interrupt() 能立即终止线程吗? | 协作式中断机制 | 以为 interrupt 是强杀线程 |
| 10. 锁竞争激烈时,自旋一定更快吗? | 原子类与锁取舍 | 忘了自旋会消耗 CPU |
我大致说一下第 4 题和第 9 题,因为这两题的实际指导意义最强。
第 4 题:如果操作只有一个共享变量的单步写入或读取,volatile 通常够;如果操作是“读-改-写”模式(比如计数器累加)、或者多个变量需要保持一致性、或者一段代码不允许并发进入,那一定得用锁。这个判断标准简单干脆,能覆盖绝大多数业务场景。
第 9 题:interrupt()不是强杀线程。它只是发了一个“请中断”的信号,目标线程如果正阻塞在wait/sleep/join,就会抛出 InterruptedException;否则需要代码主动检查中断状态。所以生产环境里优雅停机一定要在业务代码中配合响应中断,否则线程池shutdownNow()很难立刻见效。
这套题你如果能不看资料回答清楚,并且能在真实代码中解释出为什么这样用,那么并发基础这一关就基本稳了。后续再往深走,就可以开始啃 AQS、ReentrantLock 的源码,体会同步队列、条件队列的具体实现了。
9. 我总结的几条并发编码习惯
最后聊点实践沉淀。并发不像其他编程方向,出问题不一定复现,跑几天才蹦出一个偶发 bug,排查成本非常高。所以我这些年养成了几条习惯,分享给大家。
第一,共享变量越少越好。能用局部变量不用成员变量,能用 ThreadLocal 不用共享可变对象,能在同一个事务里操作完就释放的不要长时间持有。并发问题的根源几乎都出在共享可变状态上,减少共享,等于直接消灭问题。
第二,写同步代码时先把“不变式”写清楚。比如你写一个转账方法,你要清楚什么是不允许出现的中间状态;你写一个发布订阅模型,要清楚哪些数据在什么阶段必须对读者可见。先把这些注释写明白,再写代码,后面对照检查会轻松非常多。
第三,锁的范围宁小勿大,但不要为了减小锁范围而引入更复杂的新问题。锁范围大,阻塞时间长,吞吐量下降;锁范围小到几个方法各自为战,又容易破坏逻辑一致性。我见过一套代码里把“检查库存、扣减库存、创建订单、清空购物车”四步各自用锁,结果并发下单时库存超卖,就是因为“检查+扣减”本来是原子语义,硬被拆开了。
第四,下单压测之前先做一次线程快照检查。养成用jstack看线程状态的习惯,尤其是看是否有大量 WAITING、BLOCKED。这比事后看监控指标更直接,能快速发现锁竞争位置和死锁线索。也可以启一个-XX:+PrintGC之类 JVM 参数观察是否出现严重的 STW 停顿,排除 GC 因素对延迟的影响。
并发编程入门难,但一旦把原子性、可见性、有序性这些底层逻辑和 JUC 工具之间的关系理顺,后面看 AQS、ReentrantLock、ConcurrentHashMap 的源码就会顺畅很多。这篇文章适合作为你并发学习路线上的第一块基石,建议配合一个简单的多线程压测 Demo 边跑边看,不用急着啃完所有源码,先把几个核心关键字和原子类用熟悉,下一步再往深处走。