Java并发编程面试题
并发三大特性:原子性、可见性、有序性
这三个特性是Java 并发编程的底层基石,所有我们遇到的线程安全问题(脏读、死锁、诡异的偶发 bug),本质上都是这三个特性没有被正确保证导致的。
🔒 原子性(Atomicity)
核心定义:一个操作是不可分割的最小执行单元,要么全部执行成功,要么全部执行失败,执行过程中不能被任何线程打断。
问题根源:一行 Java 代码会被编译成多条 CPU 指令,而 CPU 是按时间片调度线程的,一个操作可能执行到一半就被切换走。
经典反例:
count++绝对不是原子操作!它会被拆成 3 步:- 从主存读取 count 的值到线程工作内存
- 在工作内存中执行 + 1 计算
- 将计算结果写回主存
Java保证手段:
synchronized关键字(悲观锁)java.util.concurrent.atomic原子类(CAS 乐观锁)Lock接口及其实现类
🧵 最经典的坑:i++ 不是原子的
int count = 0;
// 10个线程各执行1000次count++你看这代码就一行,但 JVM 底层其实是三步:
- 从主存读取 count 当前值到工作内存;
- 在工作内存中执行 +1;
- 把新值写回主存。
线程切换可能发生在任意两步之间,这就导致“读-改-写”被拆散,最终结果小于 10000。

👁️ 可见性(Visibility)
核心定义:当一个线程修改了共享变量的值,其他所有线程能够立即看到这个最新的修改。
问题根源:Java 内存模型(JMM)规定,所有变量存储在主内存,每个线程有独立的工作内存。线程对变量的操作必须在工作内存中进行,不能直接读写主内存。
经典反例:一个线程修改了
stopFlag=true,另一个线程可能永远看不到这个修改,导致程序陷入死循环。Java保证手段:
volatile关键字(强制修改立即刷新主存,读取时强制从主存拉取)synchronized关键字(解锁前会将工作内存所有修改刷新到主存)Lock接口(解锁前刷新主存)final关键字(初始化完成后对所有线程可见)
记住了:多线程共享的标志位,一定要用 volatile 修饰! 🚩
这就导致一个经典 BUG:线程 A 改了 flag,线程 B 死循环看不到。
// 线程A
boolean stop = false;
while(!stop) {
// 做点事
}
// 线程B 将 stop 设为 true
stop = true; // B改的是自己工作内存的副本,没刷到主存,A看不到!
📋 有序性(Ordering)
核心定义:程序执行的顺序严格按照代码的先后顺序执行。
问题根源:编译器和 CPU 为了提高执行效率,会在不影响单线程执行结果的前提下,对指令进行重排序。单线程下没问题,但多线程下会导致其他线程看到混乱的执行顺序。
经典反例:双重检查锁(DCL)单例模式中,如果没有volatile修饰,可能会出现 "半初始化" 的对象,导致空指针异常。
Java保证手段:
volatile关键字(禁止指令重排序)synchronized关键字(同一时刻只有一个线程执行同步块,天然有序)- JMM 定义的Happens-Before规则(判断有序性的核心依据)
🎭 重排序的经典惨案:双重检查锁定单例
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 引用指向内存空间
但 JIT 编译器可能把 2 和 3 重排序,变成 1→3→2。如果线程 A 执行到 3 还没初始化完,线程 B 恰好走进第一个 if(instance==null),发现 instance 非空,直接返回了一个半拉子对象,炸了💥。
📊 三大特性对比速查表
| 特性 | 核心问题 | 问题根源 | 主要保证手段 | 典型应用场景 |
|---|---|---|---|---|
| 🔒 原子性 | 操作被中途打断 | CPU 时间片调度 | synchronized、Atomic 原子类、Lock | 计数器、转账、库存扣减 |
| 👁️ 可见性 | 修改不能及时被感知 | JMM 工作内存机制 | volatile、synchronized、Lock | 状态标记、开关控制 |
| 📋 有序性 | 指令执行顺序被打乱 | 编译器 / CPU 指令重排序 | volatile、synchronized、Happens-Before | 单例模式、安全发布对象 |
🔄 它们之间的配合
一个变量如果只用 volatile,能保证 可见性 和 有序性,但 count++ 这种复合操作依然不保证原子性。反过来,synchronized 块里,三者全包了,但性能开销大。所以实际开发中,要根据场景挑武器:
- 标志位 →
volatile✓ - 计数累加 →
AtomicLong✓ - 复合条件判断 →
synchronized/Lock✓
💯 面试加分小技巧
- 必提陷阱:
volatile只能保证可见性和有序性,绝对不能保证原子性!这是 90% 面试官都会追问的高频考点。 - 对比加分:
synchronized可以同时保证三个特性,是 "万能锁",但性能开销较大;volatile更轻量,只能解决特定问题。 - 深度延伸:提到 Happens-Before 规则是 JMM 判断有序性的唯一标准,而不是单纯的代码书写顺序。
- 实战加分:如果能举一个你实际项目中遇到的因为这三个特性导致的 bug,以及你是怎么排查和解决的,会直接拉开和其他候选人的差距。
volatile 关键字作用:保证可见性 + 禁止指令重排序,能否保证原子性?

两个核心作用 ✅
🔍 作用一:保证多线程间的可见性
- 原理:volatile 变量的写操作会立即刷回主内存,读操作会直接从主内存读取,同时使其他线程的本地缓存副本失效
- 解决问题:避免一个线程修改了变量值,其他线程还在使用旧的缓存值
🚧 作用二:禁止指令重排序
- 原理:编译器和 CPU 会对指令进行重排序优化,volatile 会在变量读写前后插入内存屏障,禁止特定类型的重排序
- 经典应用:双重检查锁 (DCL) 单例模式,防止出现 "半初始化对象" 的问题
灵魂拷问:能否保证原子性? ❌
明确回答:不能! 这是 volatile 最容易被踩坑的点。
🧨 经典反例:i++ 的原子性问题
public class VolatileAtomicDemo {
// 即使加了volatile,count++依然线程不安全
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
// 10个线程,每个加1000次
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++;
}
}).start();
}
Thread.sleep(1000);
// 预期:10000,实际:永远小于10000
System.out.println("最终结果:" + count);
}
}🔬 字节码层面拆解原因
count++ 看似一行代码,实际被编译成4 条字节码指令,是复合操作:
0: getstatic #2 // 从主内存读取count值(volatile保证这一步拿到最新值)
3: iconst_1 // 加载常量1
4: iadd // 执行加法运算
5: putstatic #2 // 将结果写回主内存“读-改-写”这个三连招中间是可以被切换、被插队的
⚠️ 关键问题:在iadd和putstatic之间,线程可能被调度器挂起,其他线程趁机修改了 count 值,导致写回的是过期的计算结果。

三大同步机制特性对比 📊
| 特性 | volatile | synchronized | AtomicInteger |
|---|---|---|---|
| 可见性 | ✅ 完全保证 | ✅ 完全保证 | ✅ 完全保证 |
| 原子性 | ❌ 不保证 | ✅ 完全保证 | ✅ 完全保证 |
| 有序性 | ✅ 部分保证 | ✅ 完全保证 | ✅ 完全保证 |
| 性能开销 | 极低(无锁) | 较高(重量级锁) | 低(CAS 无锁) |
| 适用场景 | 状态标记、DCL | 同步代码块 / 方法 | 原子计数、更新 |
💡 总结与正确使用场景
volatile 是轻量级同步机制,只能解决可见性和有序性问题,绝对不能解决原子性问题
正确使用场景:
- 布尔类型的状态标记(如
boolean running = true) - 双重检查锁实现单例模式
- 一次性安全发布对象
- 布尔类型的状态标记(如
错误使用场景:
- 计数类操作(i++、i--)
- 任何复合操作(如
a = a + b)
synchronized 底层原理:偏向锁 → 轻量级锁 → 重量级锁,锁升级过程
🎯关于 synchronized 的锁升级过程,会从对象头这个核心载体入手,按顺序讲清楚从偏向锁到轻量级锁再到重量级锁的完整触发条件和底层实现。
🔑 前置必知:对象头 Mark Word 结构
synchronized 的所有锁逻辑,本质上都是在修改对象头里的Mark Word(64 位 JVM 下占 8 字节)。不同锁状态下,Mark Word 存储的内容完全不同:
| 锁状态 | 25 位 unused | 31 位 hashCode | 1 位 偏向锁位 | 2 位 锁标志位 | 存储内容(简化) |
|---|---|---|---|---|---|
| 无锁 | ✅ | ✅ | 0 | 01 | 对象的 hashCode、分代年龄等 |
| 偏向锁 | 54 位 线程 ID + 2 位 epoch | ❌ | 1 | 01 | 线程ID + Epoch + 分代年龄 |
| 轻量级锁 | 62 位 指向栈中锁记录的指针 | ❌ | - | 00 | 指向栈中 Lock Record 的指针 |
| 重量级锁 | 62 位 指向操作系统互斥量的指针 | ❌ | - | 10 | 指向 ObjectMonitor 对象指针 |
| GC 标记 | - | - | - | 11 | 空,等待 GC |
💡 核心结论:锁标志位是判断当前锁状态的唯一依据,01 状态下通过 "偏向锁位" 区分无锁和偏向锁。
不同状态的含义,就是锁升级的依据。记住一点:Mark Word 会随着锁升级被“改写”成不同的内容,这就是底层承载体。
🚀 锁升级完整流程图

⚡ 各阶段锁核心原理详解
偏向锁 🎯(JDK1.6 引入,JDK15 默认关闭)
理念:如果一个锁总是被同一个线程获取,那干脆在对象头里记下这个线程的 ID,以后它再进来,连 CAS 操作都省了。
触发条件:只有一个线程反复访问同一个同步块,没有任何竞争
底层操作:第一个线程执行同步块时,通过CAS将自己的线程 ID 写入对象头的 Mark Word,同时将偏向锁位设为 1
核心优势:零开销!后续同一线程进入同步块时,只需检查 Mark Word 中的线程 ID 是否是自己,不需要任何 CAS 操作
撤销条件(面试官必问):
- 有其他线程尝试竞争该锁
- 调用了对象的
hashCode()方法(偏向锁状态下 Mark Word 没有空间存 hashCode)
⚠️ 注意:偏向锁撤销必须等待全局安全点(STW),这也是高并发下偏向锁反而会降低性能的原因
轻量级锁 🌀
理念:假定竞争会很快结束,用“自旋”等待代替阻塞,避免 OS 切换开销。
触发条件:偏向锁被撤销,或者有两个线程交替访问同步块(竞争不激烈)
底层操作:
- 线程在自己的栈帧中创建锁记录(Lock Record)
- 通过CAS将对象头的 Mark Word 替换为指向自己锁记录的指针
- 竞争失败的线程不会立即阻塞,而是自旋等待(消耗 CPU 但不切换内核态)
核心优势:避免了操作系统级别的线程阻塞和上下文切换,在竞争不激烈时性能远高于重量级锁
自旋优化:JDK1.6 引入自适应自旋,自旋次数不是固定值,而是根据前一次在同一个锁上的自旋时间动态调整
重量级锁 🔒
理念:竞争已经激烈到自旋就是浪费 CPU,必须让操作系统把线程挂起、排队。
触发条件:
- 竞争线程自旋次数超过阈值(默认 10 次,自适应自旋会动态调整)
- 有超过两个线程同时竞争同一个锁
底层操作:
- 将对象头的 Mark Word 替换为指向操作系统 互斥量(Mutex) 的指针
- 所有竞争失败的线程都会被阻塞(从用户态切换到内核态)
- 锁释放时,操作系统会唤醒所有等待的线程,重新竞争锁
核心缺点:线程阻塞和唤醒需要操作系统介入,上下文切换开销极大,所以叫 "重量级" 锁

注意:升级方向是单向不可逆的,像个只能拧紧的发条(实际运行中可能会有批量重偏向等优化,但主干逻辑是单向升级)。
📊 三种锁核心对比表
| 特性 | 偏向锁 | 轻量级锁 | 重量级锁 |
|---|---|---|---|
| 适用场景 | 单线程反复访问 | 多线程交替访问 | 多线程同时激烈竞争 |
| 同步开销 | 几乎为 0 | 少量 CAS 操作 | 操作系统级线程阻塞 |
| 线程状态 | 运行态 | 运行态(自旋) | 阻塞态 |
| 上下文切换 | 无 | 无 | 频繁 |
| 性能 | 最高 | 中等 | 最低 |
用生活场景消化一下 🛵
- 偏向锁:你每天固定车位,物业直接把你的车牌号写在车位牌上,你回来不用打招呼,直接停。
- 撤销偏向:某天邻居说你占了他的位置,物业核实后发现你确实没挪窝,把你的名字擦掉(全局安全点),改成公共规则。
- 轻量级锁:车位变成先到先得,你到了发现车位空着,赶紧放个“有人”牌子(CAS)。别人来了看见牌子,就在旁边转两圈(自旋),盼你马上走。
- 重量级锁:转半天你还不走,后面又来了第三辆车,物业大叔出来大吼:“都别转了!拿号排队去,车位空了我会叫你们!”——于是大家都去大厅睡觉(阻塞),等他通知。
💡 面试官常追问的关键补充
- 锁升级是单向的:只能从偏向锁→轻量级锁→重量级锁,不能降级
- 锁粗化:JIT 编译器会将连续的多个加锁解锁操作合并为一个大的同步块,减少频繁加锁解锁的开销
- 锁消除:JIT 编译器会分析代码,如果发现某个对象不会被其他线程访问,就会自动消除该对象的同步锁
- JDK15 的变化:默认关闭了偏向锁,因为现代应用大多是多线程环境,偏向锁的撤销开销已经超过了它带来的收益
- 偏向锁与 hashCode 的互斥:对象的
hashCode()一旦被调用,Mark Word 需要存储 hash 值,而偏向锁状态下没地方存,所以会直接撤销偏向,甚至禁用该对象的偏向。如果你能点出“没有重写hashCode()的对象才最受益于偏向锁”,面试官眼睛会亮 ✨。 - 批量重偏向与批量撤销:JVM 会用 epoch 机制,如果一个类的很多对象被同一个线程反复获取,可以批量重偏向,减少撤销开销;如果被不同线程撤销太频繁,JVM 会直接禁用该类的偏向锁。这是工程化的极致体现。
- 轻量级锁重入的实现:通过栈上叠加 Lock Record,不修改 Mark Word,这点体现了无额外开销的巧妙设计。
面试官总结:
“synchronized 的锁升级,本质上是用日益复杂的同步策略去适应不同程度的竞争,核心思想就是用空间换时间、用自旋换阻塞、用预测换开销。理解了这个,你就不只是会用 synchronized,而是吃透了它的灵魂 🧘。”
synchronized 与 ReentrantLock 区别及选型
这是 Java 并发编程高频必考题,中高级面试 10 题 9 问,答好直接拉开差距 ✨
synchronized 是 Java 亲儿子,关键字级别,JVM 帮你兜底;
ReentrantLock 是 JUC 包下的“瑞士军刀”,灵活度高,但得自己擦屁股。
📊 核心区别对比表(一眼看懂)
| 对比维度 | synchronized🔒 | ReentrantLock |
|---|---|---|
| 底层实现 | JVM 层面,基于 对象监视器 (Monitor) 实现 | JDK 层面,基于 AQS (抽象队列同步器) 实现 |
| 锁的类型 | 可重入、非公平锁 | 可重入、支持公平 / 非公平锁(默认非公平) |
| 响应中断 | ❌ 不支持,线程获取锁时会一直阻塞 | ✅ 支持,lockInterruptibly()可响应中断 |
| 超时获取 | ❌ 不支持,拿不到锁就无限等待 | ✅ 支持,tryLock(time, unit)超时返回 false |
| 多条件变量 | ❌ 只能有 1 个 wait/notify 队列 | ✅ 支持多个Condition,精确唤醒指定线程 |
| 释放方式 | 自动释放:代码块执行完 / 抛出异常 | 手动释放:必须在 finally 块中调用 unlock () |
| 性能表现 | JDK1.6 后大幅优化(偏向锁 / 轻量级锁),低竞争下更优 | 高竞争下性能更稳定,吞吐量更高 |
| 使用复杂度 | 极低,语法简洁,不易出错 | 较高,需手动管理锁的获取与释放 |
| 调试难度 | 低,JVM 内置支持,jstack 可直接查看锁状态 | 高,需结合 AQS 源码分析 |
🧠 本质差异:两个重量级选手的底层肌肉
🔒 synchronized 的升级打怪路径(无锁 → 偏向 → 轻量 → 重量)

⚙️ ReentrantLock 基于 AQS(抽象队列同步器)

所有排队、唤醒、重入计数都由 AQS 的 state 变量 + CLH 队列完成,纯 Java 代码,不直接陷进内核。
🔍 关键特性深度解析(面试官追问重点)
🔒 可重入性的本质
两者都是可重入锁,但实现原理不同:
- synchronized:在 Monitor 对象中记录持有线程 ID和重入次数,同一线程再次获取锁时直接计数 + 1,释放时计数 - 1,减到 0 才真正释放锁
- ReentrantLock:通过 AQS 的
state变量记录重入次数,每次lock()时 CAS 将 state+1,unlock()时 state-1,state=0 时释放锁
⚖️ 公平锁与非公平锁
- synchronized:只有非公平锁,JVM 会优先让当前线程尝试抢锁,抢不到再进入等待队列,性能更好但可能导致线程饥饿
- ReentrantLock:构造方法传true即可创建公平锁,严格按照等待队列的 FIFO 顺序获取锁,公平但性能损耗约 10%-20%
⏱️ 超时获取与响应中断(ReentrantLock 核心优势)
这是 synchronized 永远做不到的功能,也是生产环境避免死锁的关键:
// 尝试获取锁,最多等待3秒,超时返回false
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
// 执行业务逻辑
} finally {
lock.unlock();
}
} else {
// 超时处理逻辑
}🎯 多条件变量(解决 "惊群效应")
- synchronized:
notify()只能随机唤醒一个等待线程,notifyAll()会唤醒所有等待线程,造成不必要的上下文切换(惊群效应) - ReentrantLock:可以创建多个
Condition对象,每个Condition对应一个等待队列,实现精确唤醒
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// 生产者等待队列未满
notFull.await();
// 只唤醒消费者,不唤醒其他生产者
notEmpty.signal();✅ 生产环境选型建议(记住这个原则)
优先使用 synchronized 的场景
- 大部分普通并发场景(如单例模式、简单的线程安全方法)
- 代码简洁性要求高,不想手动管理锁释放
- 低到中等竞争程度的场景(JVM 优化后性能不输
ReentrantLock) - 调试便利性要求高(
jstack可直接查看锁持有情况)
必须使用 ReentrantLock 的场景
- 需要公平锁的特殊业务场景(极少使用)
- 需要超时获取锁,避免死锁(如分布式任务调度、资源竞争)
- 需要响应中断,支持任务取消(如可终止的后台任务)
- 需要多个条件变量,精确唤醒线程(如生产者消费者模型、线程池)
- 需要尝试获取锁或超时放弃
❌ 绝对不要做的事
不要为了 "显得高级" 而滥用 ReentrantLock!90% 的并发场景 synchronized 都能完美解决,手动释放锁很容易因遗漏finally导致死锁,排查难度极大。
💯 面试加分点(让你脱颖而出)
- 提到JDK1.6 对 synchronized 的优化:偏向锁、轻量级锁、自旋锁、适应性自旋、锁消除、锁粗化,说明你知道两者性能差距已经很小
- 简单提一下AQS 核心原理:state 变量、CLH 双向队列、CAS 操作,体现你对底层的理解
- 结合实际业务场景举例:"我们项目中实现超时重试的分布式任务时,用了 ReentrantLock 的 tryLock 来避免多个节点同时执行同一个任务,有效防止了死锁"
- 客观说出两者的缺点:synchronized 功能单一,ReentrantLock 代码复杂度高易出错
📝 一句话总结
能用 synchronized 就用 synchronized,只有当 synchronized 满足不了你的需求时,再考虑 ReentrantLock。这是无数生产环境踩坑总结出来的最佳实践 👍
AQS(AbstractQueuedSynchronizer)核心原理与设计
AQS 是Java 并发包的绝对基石,ReentrantLock、CountDownLatch、Semaphore、CyclicBarrier 这些我们天天用的同步工具,底层全是基于 AQS 实现的。它本质上是一个提供了通用同步能力的抽象框架,帮我们解决了 "线程排队、等待唤醒、锁状态管理" 这些并发编程中最复杂的问题。
AQS 说白了就是个用来构建锁和同步器的骨架。它把“排队阻塞”这种脏活累活都封装好了,我们只需实现tryAcquire/tryRelease这类模板方法,就能搭出ReentrantLock、Semaphore、CountDownLatch这些工具。
🔑 核心设计思想
一句话总结:用一个 volatile 修饰的 int 变量表示同步状态,再配合一个 FIFO 的双向等待队列,来管理所有等待锁的线程。
🏗️ 核心三要素:状态、队列、方法

我用一个大家都懂的 "抢车位" 类比来解释:
state变量 = 停车场剩余车位(0 = 空,1 = 被占,>1 = 可重入次数)- 等待队列 = 停车场外排队的车
- 加锁 = 抢车位(CAS 改 state)
- 释放锁 = 开车走(改 state,通知下一辆车)
- 阻塞 / 唤醒 = 司机熄火等待 / 保安喊你进场
📦 核心数据结构(面试必问)
AQS 的所有能力都建立在这两个核心数据结构之上:
| 组件 | 类型 | 核心作用 | 关键特性 |
|---|---|---|---|
| state | volatile int | 表示同步状态 | 1. volatile 保证多线程可见性 2. 用 CAS 原子修改 3. 子类可自定义含义(独占 / 共享) |
| CLH 双向等待队列 | Node节点组成的双向链表 | 存放所有获取锁失败的线程 | 1. 头节点是当前持有锁的线程 2. 尾节点是最后一个排队的线程 3. 每个 Node 保存:线程引用、等待状态、前驱 / 后继指针 |
Node 节点核心状态:
Node {
waitStatus // SIGNAL(-1)、CANCELLED(1)、CONDITION(-2)、PROPAGATE(-3)
prev / next
thread
}SIGNAL(-1):后继节点需要被当前节点唤醒CANCELLED(1):节点已取消(超时 / 中断)CONDITION(-2):节点在条件队列中等待PROPAGATE(-3):共享模式下,唤醒需要向后传播
🎯 核心设计模式:模板方法模式
这是 AQS 最精妙的设计!AQS 已经把所有通用的排队、等待、唤醒逻辑都写死了,子类只需要实现 5 个简单的 protected 方法,就能自定义出完全不同的同步工具:
| 方法 | 作用 | 适用模式 |
|---|---|---|
| tryAcquire(int arg) | 尝试获取独占锁 | 独占模式 |
| tryRelease(int arg) | 尝试释放独占锁 | 独占模式 |
| tryAcquireShared(int arg) | 尝试获取共享锁 | 共享模式 |
| tryReleaseShared(int arg) | 尝试释放共享锁 | 共享模式 |
| isHeldExclusively() | 判断当前线程是否持有独占锁 | 独占模式 |
例子:
- ReentrantLock:实现了 tryAcquire/tryRelease,state 表示重入次数
- CountDownLatch:实现了 tryAcquireShared/tryReleaseShared,state 表示计数
- Semaphore:实现了 tryAcquireShared/tryReleaseShared,state 表示许可数
排队的本质:自旋两次 + park 休眠

🧩 关键点:前驱节点是head时会再给一次 tryAcquire 的机会,避免无谓的 park/unpark 切换,这是性能优化。
🔓 释放流程 & 唤醒后继

🔀 独占 vs 共享
| 模式 | 代表同步器 | 获取方法 | 特点 |
|---|---|---|---|
| 独占 | ReentrantLock | acquire/release | 一个线程持有,tryRelease后才能唤醒下一个 |
| 共享 | Semaphore、CountDownLatch | acquireShared/releaseShared | 多个线程可同时持有,释放时会传播唤醒一连串共享节点 |
共享模式有意思的地方是 PROPAGATE 状态:当头节点释放后,会链式唤醒后面所有共享节点,避免“明明还有资源,线程却醒不过来”的bug。
🚀 核心流程 1:独占锁加锁(以 ReentrantLock 为例)
这是面试官最常让你手写 / 口述的流程,一步都不能错:
1. 线程调用lock() → 调用AQS的acquire(1)
2. 先尝试获取锁:调用子类实现的tryAcquire(1)
✅ 成功:直接返回,线程持有锁
❌ 失败:进入下一步
3. 把当前线程封装成Node节点,CAS加入队列尾部
4. 进入自旋循环:
a. 检查前驱节点是不是头节点
b. 如果是:再次调用tryAcquire(1)尝试抢锁
✅ 成功:把自己设为新头节点,删除旧头节点
❌ 失败:进入下一步
c. 如果不是/抢锁失败:调用LockSupport.park()阻塞自己
5. 被唤醒后,回到步骤4继续自旋💡 面试高频追问:为什么这里要自旋检查前驱是不是头节点?
答:为了减少线程阻塞唤醒的开销。因为阻塞唤醒需要操作系统切换上下文,成本很高。如果前驱刚好释放了锁,自旋就能直接抢到,不用走内核态。
🔓 核心流程 2:独占锁释放
1. 线程调用unlock() → 调用AQS的release(1)
2. 调用子类实现的tryRelease(1)修改state
✅ 成功(state变为0):进入下一步
❌ 失败(可重入锁还没释放完):直接返回
3. 找到头节点的后继节点
4. 调用LockSupport.unpark()唤醒后继节点的线程
5. 被唤醒的线程回到加锁流程的步骤4继续自旋抢锁💡 面试高频追问:为什么用 LockSupport.park/unpark 而不是 wait/notify?
答:
- 不需要在同步块中调用,更灵活
- 可以提前 unpark(先唤醒再 park,线程不会阻塞)
- 支持中断响应,且不会抛出 InterruptedException 异常
🛋️ Condition 条件队列
AQS 内部还有个单向的条件队列,和 CLH 队列是合作关系:
await():把当前线程包装成节点加入条件队列,释放锁,然后 park。signal():把条件队列的头节点转移到 CLH 队列尾部,等前面的人释放锁后它自然被唤醒。

这样设计实现了精准唤醒,而不是像synchronized的notifyAll那样全惊动。😎
📌 常见 AQS 实现类对比
| 工具类 | 模式 | state 含义 | 核心用途 |
|---|---|---|---|
| ReentrantLock | 独占 | 锁的重入次数 | 可重入的互斥锁 |
| ReentrantReadWriteLock | 独占 + 共享 | 高 16 位 = 读锁次数,低 16 位 = 写锁次数 | 读写分离锁 |
| CountDownLatch | 共享 | 剩余计数 | 一个线程等待多个线程完成 |
| Semaphore | 共享 | 剩余许可数 | 控制同时访问资源的线程数 |
| CyclicBarrier | 共享 | 剩余等待线程数 | 多个线程互相等待到同一点 |
💡 一句话串联设计精髓
AQS 就像一家银行的排队叫号系统:
state= 当前窗口是否空闲/几个窗口可用- CLH 队列 = 等候区,进来先取号,瞅一眼前面是不是就剩一人(head),是的话再去窗口试试,不然就安心坐着(park)
- Condition = 某些业务需要等某个条件,单独去另一个等候室,条件满足再被带回主队列排队
💯 面试加分点(能说出来直接拉开差距)
- CLH 队列的变种:AQS 的队列不是原版 CLH,而是做了优化:用双向链表代替单向,增加了取消节点的处理
- 虚头节点设计:队列的头节点是一个空的虚节点,避免频繁的头节点创建和删除,减少 CAS 操作
- 取消节点的处理:当线程超时或中断时,会把节点状态设为 CANCELLED,然后从队列中移除
- 共享模式的传播机制:共享锁释放时,唤醒会向后传播,确保所有等待的共享线程都能被唤醒
- 性能优势:相比早期的 synchronized,AQS 用用户态的 CAS 代替了内核态的互斥量,在低竞争场景下性能高很多
CAS 原理、ABA 问题及解决方案(AtomicStampedReference)
CAS 核心原理(Compare-And-Swap)
CAS 是硬件级别的原子操作,是 Java 无锁并发编程的基石,所有java.util.concurrent.atomic包下的原子类底层都基于 CAS 实现。
🔍 CAS = Compare And Swap(比较并交换)
不整虚的,你就把它理解成一个原子化的“检查-替换”操作:
┌───────────────────────────────────────┐
│ 我想把变量从 A 改成 B │
│ │
│ ① 读取内存值 V │
│ ② 比较 V 是不是还是原来的 A │
│ ③ 是 → 换成 B ✅ │
│ 否 → 啥也不干,重试 ❌ │
└───────────────────────────────────────┘它是由 CPU 指令(如 cmpxchg)直接支撑的,Java 里通过 Unsafe 类来调用,上层就是我们经常用的 Atomic 系列,比如 AtomicInteger。
// 底层就是 CAS 自旋
atomicInt.compareAndSet(expect, update);👍 优点: 不用加锁,线程不阻塞,在高并发轻量争用时性能极好。
👎 缺点: 会空转消耗 CPU,只能保证一个变量的原子操作,以及——会出现 ABA 问题。
核心三要素
- 内存地址 V:要修改的变量在堆内存中的实际地址
- 旧预期值 A:执行更新前期望该变量当前的值
- 新值 B:要将变量更新成的目标值
原子执行逻辑
只有当内存地址 V 中的值严格等于旧预期值 A 时,才将 V 的值原子性地更新为 B;否则更新失败,立即返回当前 V 的真实值。
💡 本质:乐观锁思想 —— 假设没有竞争,先尝试更新,失败则自旋重试,避免了线程阻塞的开销。
Java 底层实现链路
AtomicInteger.getAndIncrement()
→ Unsafe.getAndAddInt()
→ Unsafe.compareAndSwapInt()(native方法)
→ CPU cmpxchg指令(x86架构)最终由 CPU 硬件指令保证操作的原子性,不需要操作系统内核态介入。
CAS vs Synchronized 核心对比表
| 特性 | CAS(乐观锁) | Synchronized(悲观锁) |
|---|---|---|
| 锁机制 | 无锁,自旋重试 | 重量级锁,阻塞线程 |
| 上下文切换 | 无 | 有(用户态→内核态) |
| 无竞争性能 | 极高 | 有锁获取开销 |
| 高并发性能 | 自旋浪费 CPU,性能下降 | 稳定 |
| 原子性范围 | 单个变量 | 整个代码块 |
| 典型缺陷 | ABA 问题、自旋开销 | 死锁、上下文切换 |
⚠️ CAS 的致命缺陷:ABA 问题
别被名字唬住,讲个🌰就懂了。
你有一杯奶茶(变量值是 A),你起身去拿吸管,回来发现奶茶还是那杯奶茶,但可能已经被人喝过又续上了——这就叫 ABA。
问题产生时序图
| 时间点 | 线程 1 操作 | 线程 2 操作 | 内存值 V |
|---|---|---|---|
| T1 | 读取 V=A,准备执行 CAS | - | A |
| T2 | 被 CPU 调度走,执行耗时业务 | 抢占 CPU,将 V 从 A 改为 B | B |
| T3 | - | 又将 V 从 B 改回 A | A |
| T4 | 恢复执行,CAS (V,A,C) 成功 | - | C |
❗ 问题本质:CAS 只比较最终值是否相等,完全不关心值的中间变化过程。线程 1 误以为变量从未被修改过,但实际上它已经被线程 2 篡改过两次。
┌───────────────────────────────┐
│ ❌ ABA 的核心 │
│ 比较的是“值”,但感知不到 │
│ “状态是否发生过变化” │
└───────────────────────────────┘典型危害场景
- 链表 / 栈的节点操作:可能导致节点丢失、内存泄漏或重复插入
- 资金转账系统:可能引发重复扣款或重复转账
- 有状态依赖的业务:状态被篡改但业务逻辑无法感知
✅ 终极解决方案:AtomicStampedReference
核心思想
给每个变量附加一个整数版本号(戳记 Stamp),每次修改变量时,必须同时更新版本号。CAS 操作时,需要同时比较值和版本号,只有两者都完全相等,才允许更新。
📦 结构图示
AtomicStampedReference
┌─────────────────────────────────┐
│ Pair (reference, stamp) │
│ ┌─────────────────┐ │
│ │ 引用 → 对象 A │ stamp=0 │
│ └─────────────────┘ │
└─────────────────────────────────┘
│ CAS(期望引用A, 新引用B,
│ 期望stamp 0, 新stamp 1)
▼
┌─────────────────────────────────┐
│ Pair (reference, stamp) │
│ ┌─────────────────┐ │
│ │ 引用 → 对象 B │ stamp=1 │
│ └─────────────────┘ │
└─────────────────────────────────┘即使值被改回 A,stamp 已经从 0 变到了 2,不可能再匹配期望的 0,CAS 直接失败,杜绝了 ABA 🎯。
新旧 CAS 逻辑对比
| 操作类型 | 比较条件 | 能否检测 ABA | 适用场景 |
|---|---|---|---|
| 普通 CAS | 仅比较值 | ❌ 不能 | 无状态依赖的简单计数 |
| AtomicStampedReference | 比较值 + 版本号 | ✅ 能 | 有状态依赖的复杂业务 |
核心 API 使用示例
// 初始化:初始值为"A",初始版本号为1
AtomicStampedReference<String> asr = new AtomicStampedReference<>("A", 1);
// 正常更新:预期值A→B,预期版本1→2
boolean success1 = asr.compareAndSet("A", "B", 1, 2);
System.out.println("第一次更新:" + success1); // true
// 模拟ABA操作:B→A,版本2→3
asr.compareAndSet("B", "A", 2, 3);
// 此时用旧版本号1更新,必然失败
boolean success2 = asr.compareAndSet("A", "C", 1, 4);
System.out.println("第二次更新:" + success2); // false补充:AtomicMarkableReference
- 是
AtomicStampedReference的简化版,用boolean mark代替int版本号 - 只能区分 "是否被修改过",无法记录具体修改次数
- 适合只需要知道变量是否被篡改,不需要追踪修改历史的场景
一张表说清楚三兄弟
| 工具类 | 核心能力 | 能否防 ABA |
|---|---|---|
AtomicInteger/AtomicReference | 单一变量的 CAS 原子操作 | ❌ 不能 |
AtomicStampedReference | (引用 + int 版本号) 原子操作 | ✅ 能 |
AtomicMarkableReference | (引用 + boolean 标记) 原子操作 | ❌ 只能防“一次”改变 |
场景决定选型:
- 简单计数、状态位 →
AtomicInteger - 需要精确知道“改过几次”,避免 ABA →
AtomicStampedReference👍 - 只需要知道“有没有被碰过”(如垃圾回收标记) →
AtomicMarkableReference
💡 面试官高频追问(加分项)
问:AtomicStampedReference 能完全解决所有 ABA 问题吗?
答:不能。它只能解决值循环修改的 ABA 问题。如果是引用对象的内部状态被修改,AtomicStampedReference是检测不到的,因为它比较的是引用地址,而不是对象的内部属性。
ThreadLocal 原理、内存泄漏问题及解决
ThreadLocal 核心原理 🎯
ThreadLocal 不是用来解决多线程共享变量问题的,而是提供线程私有变量,让每个线程都拥有自己独立的变量副本,实现 "线程隔离"。
🧱 原理:Thread → ThreadLocalMap → Entry
Thread-1 Thread-2 Thread-3
| | |
v v v
ThreadLocalMap ThreadLocalMap ThreadLocalMap
| | |
v v v
Entry[] table Entry[] table Entry[] table- 每个
Thread实例里藏着一个threadLocals字段,类型是ThreadLocal.ThreadLocalMap。 ThreadLocalMap是ThreadLocal的静态内部类,内部是一个 Entry 数组。- Entry 继承了 WeakReference<ThreadLocal<?>>,key 是对 ThreadLocal 对象的弱引用,value 是我们要存的变量副本。
🔑 关键点一句话:
数据其实存在 Thread 自己的 Map 里,ThreadLocal 只是用来当 key。
用 get() 时,它先拿到当前线程的 ThreadLocalMap,再用自己(this)作为 key 去哈希找 value。
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // t.threadLocals
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) return (T)e.value;
}
return setInitialValue();
}这个设计非常巧妙:线程隔离,无锁,高性能 ⚡。
内部数据结构(关键!)
Thread.currentThread() → Thread对象
↳ threadLocals: ThreadLocalMap(Thread的成员变量)
↳ Entry[] table(哈希表)
↳ Entry(key: ThreadLocal<?>(弱引用), value: Object(强引用))可视化结构图:
┌─────────────────────────────────────────────────────────┐
│ Thread 线程对象 │
├─────────────────────────────────────────────────────────┤
│ threadLocals: ThreadLocalMap │
│ ┌─────────────────────────────────────────────────────┐│
│ │ ThreadLocalMap ││
│ ├─────────────────────────────────────────────────────┤│
│ │ table: Entry[] ││
│ │ ┌─────────┬─────────┬─────────┬─────────┬─────────┐││
│ │ │ Entry 0 │ Entry 1 │ Entry 2 │ ... │ Entry n │││
│ │ ├─────────┼─────────┼─────────┼─────────┼─────────┤││
│ │ │ key: TL1│ key: TL2│ key: null│ │ │││
│ │ │ val: V1 │ val: V2 │ val: V3 │ │ │││
│ │ └─────────┴─────────┴─────────┴─────────┴─────────┘││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘核心方法执行流程
| 方法 | 执行逻辑 |
|---|---|
| set(T value) | 1. 获取当前线程 2. 获取线程的 ThreadLocalMap 3. 以当前 ThreadLocal 对象为 key,存入 value |
| get() | 1. 获取当前线程 2. 获取线程的 ThreadLocalMap 3. 以当前 ThreadLocal 对象为 key,查找 value 4. 若不存在,调用initialValue()初始化 |
| remove() | 1. 获取当前线程的 ThreadLocalMap 2. 移除以当前 ThreadLocal 为 key 的 Entry |
关键点:数据存在 Thread 对象里,ThreadLocal 只是一个工具类,本身不存储任何数据。
内存泄漏问题详解 ⚠️
这是面试必问的核心考点!
什么是内存泄漏?
不再被使用的对象,无法被 GC 回收,导致内存占用持续增加,最终引发 OOM。
ThreadLocal 内存泄漏的根本原因
ThreadLocalMap.Entry 的 key 是弱引用,value 是强引用!完整引用链:
Thread → ThreadLocalMap → Entry(key: WeakReference<ThreadLocal>, value: Object)泄漏发生的完整过程 🕳️
- 正常情况:ThreadLocal 对象有外部强引用 → key 存活 → Entry 存活
- 泄漏触发:ThreadLocal 的外部强引用被置为 null → key 变为弱引用
- GC 发生:弱引用的 key 被 GC 回收 → Entry 的 key 变为 null
- 泄漏形成:value 仍然被 Entry 强引用 → 只要线程不结束,value 永远无法被回收
- 灾难放大:如果使用线程池,线程会被复用,这些 "僵尸 Entry" 会一直存在,越积越多
为什么 key 要设计成弱引用?🤔
这是一个权衡设计:
- 如果 key 是强引用:即使 ThreadLocal 的外部引用被置为 null,key 也不会被回收,导致 Entry 永远无法被清理,泄漏更严重
- 如果 key 是弱引用:至少在 GC 时能回收 key,ThreadLocalMap 在后续的 get/set/remove 操作中会主动清理 key 为 null 的 Entry
结论:弱引用是为了减轻内存泄漏,而不是解决内存泄漏。真正的泄漏源是 value 的强引用。
内存泄漏的解决方案 ✅
黄金法则(必须遵守!)
每次使用完 ThreadLocal,都要显式调用 remove () 方法!
try {
threadLocal.set(value);
// 业务逻辑
} finally {
threadLocal.remove(); // 无论是否发生异常,都要清理
}其他辅助方案
- 尽量将 ThreadLocal 声明为 private static:延长 ThreadLocal 的生命周期,避免频繁创建和销毁,减少泄漏机会
- 避免存储大对象:如果必须存储,确保及时清理
- 升级 JDK 版本:JDK 8 及以上对 ThreadLocalMap 的清理逻辑做了优化🚑
- get() 和 set() 方法,在遇到 key == null 的脏 Entry 时,会触发启发式清理
expungeStaleEntry()。它会一路把脏 Entry 的 value 置 null,并把这些槽位干掉。 - 但这个清理不是全量的,依赖后续的 get/set 调用。如果线程再也不操作 ThreadLocal,泄露依然存在。
- get() 和 set() 方法,在遇到 key == null 的脏 Entry 时,会触发启发式清理
🧲 内存泄漏示意图

面试加分项 🌟
- ThreadLocalMap 的哈希冲突解决:采用线性探测法,而不是 HashMap 的链表 + 红黑树
- 扩容机制:当负载因子达到 2/3 时,扩容为原来的 2 倍
- 脏 Entry 清理时机:get/set/remove 时都会触发部分清理,扩容时会进行全量清理
- InheritableThreadLocal:实现了子线程可以继承父线程的 ThreadLocal 变量,但同样存在内存泄漏问题
常见误区 ❌
❌ 误区 1:ThreadLocal 会导致内存泄漏,所以不要用
✅ 正确:只要规范使用,显式调用 remove (),就不会有问题
❌ 误区 2:ThreadLocal 是为了解决多线程安全问题
✅ 正确:ThreadLocal 是为了实现线程隔离,避免共享变量带来的线程安全问题
❌ 误区 3:ThreadLocal 的 value 是弱引用
✅ 正确:只有 key 是弱引用,value 是强引用,这才是泄漏的根源
虚拟线程 Virtual Threads:平台线程与虚拟线程调度差异、适用场景
关于虚拟线程,会从核心本质、调度差异、适用场景三个维度来回答,同时也会分享一些实际使用中的坑点:
🌱 先看一段“灵魂代码”
假设一个 HTTP 服务,每个请求要调三个下游(用户、订单、库存),每个下游需要 100ms,用同步阻塞写法:
// 传统平台线程池模式
ExecutorService pool = Executors.newFixedThreadPool(200);
// 遇到高并发,200 个线程全卡在 IO 等待上,再来请求直接拒死// 虚拟线程模式
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 几万并发可以轻松扛,每个任务一个虚拟线程,代码依然是同步风格
}这段代码背后,就是平台线程和虚拟线程在 调度模型 上的根本差异。
🔑 核心本质:平台线程 vs 虚拟线程
先给您看一张最直观的核心对比表,这是面试必背的基础点:
| 对比维度 | 平台线程(Platform Thread) | 虚拟线程(Virtual Thread) |
|---|---|---|
| 本质 | 操作系统线程 (OS Thread) 的一层轻封装 | JVM 内部管理的轻量级用户态线程 |
| 映射模型 | 1:1 映射内核线程(KLT) | M:N 映射平台线程(用户态调度) |
| 调度者 | 操作系统内核 | JVM 调度器(默认 ForkJoinPool) |
| 上下文切换 | 内核态切换,成本≈1-5μs | 用户态切换,成本≈几十 ns |
| 单机最大数量 | 千级(受内核线程数限制) | 百万级(仅受内存限制) |
| 默认内存开销 | 每个线程栈≈1MB | 每个线程栈≈几百字节 |
| 阻塞代价 | 阻塞会挂起整条 OS 线程,上下文切换成本高 | 阻塞时“卸载”载体线程,把载体线程让给其他虚拟线程,几乎无切换成本 |
| 创建销毁成本 | 高(涉及内核系统调用) | 极低(纯用户态操作) |
🔥 一句话总结:平台线程是 "重量级" 的,是操作系统的资源;虚拟线程是 "轻量级" 的,是 JVM 层面的资源,本质上是一段可暂停和恢复的代码。
⚙️ 调度机制核心差异(面试重点)
这是面试官最想听到的深度内容,我分两点讲清楚:
🎯 调度机制全景图
【平台线程调度 - 1:1 模型】
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 平台线程1 │ │ 平台线程2 │ │ 平台线程3 │
│ (OS线程) │ │ (OS线程) │ │ (OS线程) │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌──▼──┐ ┌──▼──┐ ┌──▼──┐
│阻塞了│ │阻塞了│ │运行中│
│ 😴 │ │ 😴 │ │ 🏃 │
└─────┘ └─────┘ └─────┘
每个线程在 OS 内核里都是一个“重量级”调度实体,
阻塞=内核上下文切换,成本高,能开的数量被 OS 内存和调度开销死死卡住。
【虚拟线程调度 - M:N 模型】
JVM 调度器 (ForkJoinPool)
┌─────────────┐
│ 载体线程1 │ 载体线程2
│ (OS线程) │ (OS线程)
└──┬───────┬──┘
mount │ │ unmount 后挂载别的虚拟线程
│ │
┌──────┐ ┌──▼─┐ ┌──▼─┐ ┌──────┐ ┌──────┐
│VT-1 │ │VT-2│ │VT-3│ │VT-4 │ │VT-5 │ ... 百万个虚拟线程
│🏃♂️(on)│ │😴阻塞│ │🏃♂️(on)│ │⏳等待│ │⏳等待│
└──────┘ └─────┘ └─────┘ └──────┘ └──────┘
当 VT-2 做 IO 阻塞时,JVM 自动把 VT-2 从载体线程“卸载”,
迅速挂上 VT-5 继续跑。阻塞瞬间完成“线”的切换,
OS 线程几乎不闲置。核心调度要点:
- 虚拟线程 不能独立运行,必须被 mount 到一个载体线程(即平台线程)上执行。
- 每当遇到 阻塞 IO (
socket read/write,Thread.sleep,LockSupport.park等),JVM 会触发unmount,把当前虚拟线程的栈帧保存到堆内存,载体线程立即去执行下一个就绪的虚拟线程。 - 当阻塞操作可以继续了,虚拟线程再次被调度器挂载到某个空闲载体线程上继续执行。程序员无感知,代码依旧是同步串行写法。
平台线程调度流程
应用代码 → 平台线程 → 操作系统内核 → CPU核心- 操作系统内核全权负责调度,采用时间片轮转算法
- 每次切换都需要陷入内核态,保存和恢复完整的 CPU 上下文(寄存器、程序计数器等)
- 一旦平台线程阻塞(如 IO、sleep),整个线程就会被挂起,CPU 资源被浪费
虚拟线程调度流程(核心优势所在)
应用代码 → 虚拟线程 → JVM调度器 → 平台线程 → 操作系统内核 → CPU核心- M:N 映射:多个虚拟线程共享少量平台线程(默认数量 = CPU 核心数)
- 挂载 / 卸载机制:当虚拟线程遇到阻塞操作时,JVM 会自动保存它的执行上下文(Continuation 延续),将其从平台线程上卸载下来,然后让平台线程去运行其他就绪的虚拟线程
- 当阻塞操作完成后,虚拟线程会被重新调度到任意一个空闲的平台线程上挂载,继续执行
- 整个调度过程完全在用户态完成,不需要操作系统参与,成本极低
关键区别:平台线程阻塞会浪费 CPU,虚拟线程阻塞只会暂停自己,平台线程可以继续干活。这就是为什么虚拟线程能支持百万级并发的根本原因。
🎯 黄金适用场景 & 绝对不适用场景
虚拟线程不是银弹,用对了性能提升 10 倍,用错了反而更慢:
✅ 黄金适用场景(闭眼用)
- 所有 IO 密集型任务:数据库 CRUD、Redis 调用、HTTP / 微服务远程调用、文件读写
- 高并发短任务场景:API 网关、消息消费者、定时任务、爬虫
- 长链路调用场景:微服务中一次请求调用多个下游服务的场景
❌ 绝对不适用场景(千万别用)
- CPU 密集型任务:大数据计算、加密解密、图像处理(虚拟线程只能并发不能并行,反而会增加调度开销)
- 长时间持有独占锁的任务:会导致平台线程被占用,其他虚拟线程无法调度
- 依赖 ThreadLocal 做大量数据存储的场景:百万级虚拟线程会导致内存暴涨
⚠️ 面试必问避坑点(90% 的人会踩)
- 绝对不要池化虚拟线程!虚拟线程的设计就是 "用完即弃",池化会限制并发能力,完全失去虚拟线程的意义
- 避免在虚拟线程中执行超过 10ms 的 CPU 密集型操作:会占用平台线程,导致其他虚拟线程饥饿
- 谨慎使用 ThreadLocal:优先使用 Java 21 引入的ScopedValue替代,它是专为虚拟线程设计的作用域变量
- 不要在虚拟线程中使用synchronized同步块:会导致平台线程被pin(钉住),无法卸载,建议使用
ReentrantLock
🚀 一句话总结
虚拟线程 = 高并发 IO 密集场景下的“同步编程春天”,用极低的资源消耗把平台线程“请出IO等待室”,让一个 OS 线程当十个百个用。而平台线程依旧是 CPU 密集型计算的得力干将。
结构化并发 Structured Concurrency:JEP 525,替代 CompletableFuture 组合的新范式
结构化并发是 Java 21 预览、Java 23 转正的革命性并发编程范式,它彻底解决了 CompletableFuture 组合式编程长期存在的 "孤儿线程"、"错误处理地狱" 和 "取消传播失效" 三大顽疾,是 Java 并发领域近 10 年最重要的进步之一。
🎭 一个场景代入:餐厅点餐小程序
假设要写一个"用户下单"方法,内部需并发执行 3 个步骤:
| 任务 | 动作 |
|---|---|
| Task 1 | 查用户余额 |
| Task 2 | 查商品库存 |
| Task 3 | 计算运费 |
看看在老范式和新范式下,写法有什么天壤之别。
🛠️ Part 1:旧范式 CompletableFuture 的"散装管理"
下面是 CompletableFuture 的传统写法:
CompletableFuture<BigDecimal> cf1 = CompletableFuture.supplyAsync(() -> checkBalance(id));
CompletableFuture<Integer> cf2 = CompletableFuture.supplyAsync(() -> checkStock(id));
CompletableFuture<BigDecimal> cf3 = CompletableFuture.supplyAsync(() -> calcFreight(id));
CompletableFuture<Void> combined = CompletableFuture.allOf(cf1, cf2, cf3);
// ❌ 痛点来了!如果 cf2 因为库存不足返回异常……
combined.join();
// 此时 cf1 和 cf3 仍然在后台"裸奔",继续消耗数据库连接池这种"散装"写法有三个致命伤:
- 生命周期失控 🏃➡️:
cf1一旦被supplyAsync抛到公共ForkJoinPool里,它就"自由"了。哪怕主逻辑失败,它依然在后台运行,消耗宝贵资源 - 异常传播断裂 💔:
cf2的异常不会自动取消cf1和cf3,需要手动写大量handle/whenComplete样板代码去传播取消 - 烧脑回调链 🤯:当业务不只是
allOf那么简单(比如任何子任务有结果就行、或者2/3成功即可),thenApply、thenCompose、thenCombine会层层嵌套,导致"回调地狱"
🧠 Part 2:新范式——结构化并发的核心思想
结构化并发的核心原则只有一句话:"像写单线程同步代码那样,管理多线程异步任务"。它强调在逻辑作用域内,父任务通过 fork 分裂出子任务,所有 fork 出来的子任务必须在父任务 join 点汇合,作用域结束即所有子线程终结。

核心机制:所有 fork 出来的子任务都被绑定在一个 StructuredTaskScope 作用域中。scope 就像一个"容器",当主线程离开这个 scope(即 try-with-resources 结束)时,容器内所有正在运行的任务都会被强制取消和清理。
🚀 Part 3:新范式代码——StructuredTaskScope 实战
我们把上面的点餐场景用新范式重写:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 1. fork:开启一条虚拟线程跑子任务
var f1 = scope.fork(() -> checkBalance(id));
var f2 = scope.fork(() -> checkStock(id));
var f3 = scope.fork(() -> calcFreight(id));
// 2. join:等待所有任务完成(或任一失败)
scope.join();
// 3. throwIfFailed:合并异常
scope.throwIfFailed();
// 4. 取结果——生命周期绝对安全
return new OrderInfo(f1.get(), f2.get(), f3.get());
}🔥 发生了什么魔法?
ShutdownOnFailure:如果f2(查库存)先抛出OutOfStockException,scope 会立即向所有剩余子线程发送 interrupt 信号,而不是放任它们运行try-with-resources边界即清理边界:方法结束(无论正常还是异常),scope 确保所有fork出的虚拟线程全部终止,零线程泄漏- 线程树可视化 📊:在
jstack或JFR火焰图中,f1、f2、f3会作为当前主线程的"子线程"完整展示,主线程阻塞在join上,一目了然
⚔️ Part 4:核心梳理 —— 两者的本质差异
为了让你记得住,我把它们抽象为 "链式反应堆" 和 "有盖的容器" 的对决:
| 特性维度 | CompletableFuture (旧范式) | StructuredTaskScope (新范式) |
|---|---|---|
| 🧩 编程模型 | 响应式 / 链式回调 (thenApply) | 命令式 / 同步阻塞 (fork + join) |
| 🗑️ 子任务清理 | 手动(依赖 cancel 传播) | 自动(离开 scope 自动触发 interrupt) |
| ⚡ 失败处理 | 需要手动 handle/whenComplete 吞回调 | 通过 throwIfFailed 自动取消所有同级任务,并上抛首个失败异常 |
| 🎯 可观测性 | 弱(任务散落在 ForkJoinPool 中) | 强(父子线程关系绑定,JFR/Jstack 完美呈现树状结构) |
| 🧵 与虚拟线程关系 | 配合使用但无内在耦合 | 深度绑定:每个 fork 在当前运行时就是在一条新的虚拟线程上执行 |
🔭 Part 5:JEP 525 的演进与未来
- JEP 453(JDK 21):结构化并发的首个预览版,核心 API
StructuredTaskScope首次交付 - JEP 462 / 480 / 499 等:经过 JDK 22 ~ 25 的多次迭代预览,API 逐步成熟
- JEP 525(JDK 26,当前最新目标):第六轮预览,引入了更灵活的
onTimeout回调机制,让超时处理从"一刀切"走向可编程
当 Java 最终将结构化并发转为正式特性后,它与虚拟线程的组合将是 Java 服务端开发的默认范式:处理 IO 密集型场景(并行调多个 RPC / 数据库),性能远超线程池方案,代码简洁度和可维护性同时拉满。
一句话讲透核心思想 🎯
将一组并发任务的生命周期,与创建它们的代码块的生命周期严格绑定。就像结构化编程中 "代码块结束,所有变量销毁" 一样,结构化并发中 "代码块结束,所有子任务必然终止"。
为什么要替代 CompletableFuture?痛点对比 🆚
| 问题维度 | CompletableFuture(非结构化) | 结构化并发(JEP 525) |
|---|---|---|
| 线程泄漏 | ❌ 极易产生孤儿线程(父任务失败,子任务继续运行) | ✅ 绝对不会,代码块退出自动终止所有子任务 |
| 错误处理 | ❌ 异常链断裂,需要手动捕获所有阶段异常 | ✅ 任一子任务失败,立即取消所有其他子任务 |
| 取消传播 | ❌ 取消仅影响当前阶段,无法递归取消子任务 | ✅ 取消信号自动向下传播到所有子任务 |
| 代码可读性 | ❌ 嵌套 thenCompose 形成 "回调地狱" | ✅ 同步风格写异步代码,线性阅读 |
| 调试难度 | ❌ 线程栈断裂,无法追踪完整调用链 | ✅ 保留完整的父子任务调用栈 |
核心执行模型对比图 📊
CompletableFuture 非结构化执行:
┌─────────────┐
│ 主线程 │
└─────┬───────┘
│ 启动
▼
┌─────────────┐ ┌─────────────┐
│ 任务A │ │ 任务B │
└─────┬───────┘ └─────┬───────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ 任务A1 │ │ 任务B1 │
└─────────────┘ └─────────────┘
主线程已退出,子任务仍在后台运行 → 线程泄漏!
结构化并发 执行:
┌───────────────────────────────────┐
│ try (var scope = new StructuredTaskScope<>()) { │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 任务A │ │ 任务B │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ scope.join(); // 等待所有任务完成 │
│ │
│ } // 代码块结束,所有子任务强制终止 │
└───────────────────────────────────┘
无论正常退出还是异常退出,所有子任务必然终止核心 API 与代码示例 💻
基础用法:等待所有任务成功
// 传统CompletableFuture写法(有线程泄漏风险)
public User getUserInfo(Long userId) {
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> userService.findById(userId));
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> orderService.findByUserId(userId));
// 如果userFuture抛出异常,ordersFuture仍会继续运行!
return new UserInfo(userFuture.join(), ordersFuture.join());
}
// 结构化并发写法(绝对安全)
public User getUserInfo(Long userId) throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> userTask = scope.fork(() -> userService.findById(userId));
Subtask<List<Order>> ordersTask = scope.fork(() -> orderService.findByUserId(userId));
// 任一任务失败,立即取消另一个任务
scope.join().throwIfFailed();
return new UserInfo(userTask.get(), ordersTask.get());
}
}高级策略:只要第一个成功就返回
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Product>()) {
scope.fork(() -> productService.getFromCache(id));
scope.fork(() -> productService.getFromDB(id));
scope.fork(() -> productService.getFromRemote(id));
// 第一个成功的任务返回,其他任务自动取消
return scope.join().result();
}结构化并发的三大核心保证 ✅
- 生命周期绑定:子任务永远不会比父任务活得更久
- 错误传播:子任务异常自动传播到父任务,并取消所有其他子任务
- 取消透明:父任务取消,所有子任务自动收到取消信号
适用场景与局限性 ⚠️
✅ 最佳适用场景
- 微服务中的扇出调用(同时调用多个下游服务)
- 批量数据处理(拆分任务并行执行)
- 超时控制(整体任务超时,所有子任务自动终止)
❌ 不适用场景
- 长生命周期的后台任务(如消息消费者)
- 任务之间有复杂依赖关系的场景
- 需要手动精细控制线程生命周期的场景
总结 📝
结构化并发不是要完全取代 CompletableFuture,而是提供了一种更安全、更易维护的并发任务组合方式。它把并发编程从 "手动管理线程生命周期" 的低级模式,提升到了 "声明式管理任务组" 的高级模式,让 Java 开发者能够用同步代码的思维写出安全高效的异步程序。
