23 场 · 225 题
**核心考点定位**:能否说清"多线程+共享资源"这个前提,以及互斥/可见性/有序性三个层次。
**知识点拆解**:
1. **底层前提**:线程安全只在"多线程 + 共享可变资源"同时成立时才有意义;无共享、无状态、无状态可变都不构成安全问题
2. **三个层次**:
- **互斥(Mutual Exclusion)**:同一时刻只有一个线程能修改,避免"读改写"竞态
- **可见性(Visibility)**:一个线程的修改对其他线程立即可见(涉及内存屏障、CPU 缓存一致性)
- **有序性(Ordering)**:指令不能随意重排破坏业务语义(涉及 JIT 优化、CPU 乱序执行)
3. **实现手段谱系**:
- 强制同步:synchronized、ReentrantLock、ReadWriteLock
- 无锁原子:AtomicXxx、CAS、VarHandle
- 弱化共享:ThreadLocal、不可变对象、无状态化
- 单线程:Redis 主线程、Netty 单 Reactor 线程
4. **反例与陷阱**:ConcurrentHashMap 不是线程安全的(只保证基本操作原子,复合操作仍需同步);synchronized 块内调用外部方法可能丢锁
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否区分"互斥/可见性/有序性"三个层次
- **广度**:能否列举多种实现手段,说出各自的适用场景
- **应用**:能否举出工程中的具体线程安全/不安全案例
**延伸递进面试题**:
1. **ConcurrentHashMap 是线程安全的吗?**
- 解答要点:基本操作(get/put)线程安全,但复合操作(先判断再 put、遍历中修改)不安全;需用 computeIfAbsent 等原子方法
2. **synchronized 和 volatile 分别解决什么问题?**
- 解答要点:synchronized 同时解决互斥+可见性+有序性;volatile 只解决可见性+有序性,不保证互斥(i++ 不行)
3. **有哪些"看起来安全实际不安全"的坑?**
- 解答要点:双重检查单例必须 volatile;HashMap 并发 put 会死循环;SimpleDateFormat 非线程安全;ThreadLocal 在线程池会污染
面试官您好,线程安全的本质是"多线程访问共享可变资源时,任意时刻的执行结果都符合预期"。我把它拆成三个层次来说。
**第一个层次是互斥**——同一时刻只能有一个线程修改共享数据,避免"读-改-写"竞态。经典例子是 i++,其实是三条指令:读 i、i+1、写回,两个线程同时执行就会丢更新。synchronized 和 Lock 就是干这个的。
**第二个层次是可见性**——一个线程改了共享变量,其他线程必须能立刻看到新值。这里有个反直觉的点:现代 CPU 每个核有自己的 L1 缓存,一个核改了主存,另一个核的缓存不会立刻失效,JIT 还会重排指令。volatile 就是给编译器加屏障、给 CPU 加 MESI 协议失效,强制写操作刷到主存、读操作从主存取。
**第三个层次是有序性**——编译器和 CPU 会重排指令来提升性能,但有时候会破坏业务语义,比如双重检查锁单例,如果不加 volatile,实例可能以"半初始化"的状态被其他线程看到。
工程上的应对手段我大概分四类:一是**强制同步**,synchronized 和 ReentrantLock,重量级但灵活;二是**无锁原子**,AtomicXxx 和 CAS,性能好但有 ABA 和自旋开销;三是**弱化共享**,ThreadLocal、不可变对象、纯函数;四是**单线程化**,比如 Redis 主线程、Netty 单 Reactor。
举几个反例说明"看起来安全实际不安全":ConcurrentHashMap 的 get/put 安全,但复合操作还是要同步;HashMap 并发 put 甚至会死循环;SimpleDateFormat 是经典的非线程安全类。
这就是我对线程安全的理解。
---
**核心考点定位**:能否说清"公平"的判据是队列前驱判定,而非等待时长;非公平的收益与代价。
**知识点拆解**:
1. **公平锁判据**:`FairSync.tryAcquire` 先调 `hasQueuedPredecessors()`——队列中是否存在排在当前线程之前的节点;有则入队,无则 CAS 抢
2. **非公平锁判据**:`NonfairSync.tryAcquire` 直接 `compareAndSetState(0, 1)`,成功即插队;失败才走 `acquireQueued` 入队阻塞
3. **唤醒机制**:释放时 `unparkSuccessor(h)` **只唤醒一个**后继,不是全部(惊群在 AQS 里被刻意避免)
4. **代价权衡**:非公平减少线程唤醒的上下文切换,吞吐更高;公平保证 FIFO,避免长尾饥饿,但每次都要 CAS 检查队列
5. **synchronized 的公平性**:偏向锁/轻量级锁阶段本质是 CAS 抢占,重量级锁的 Monitor 不保证 FIFO,整体**非公平**
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:`hasQueuedPredecessors` 的判定逻辑与 barging 时机
- **广度**:ReentrantLock / ReadWriteLock / synchronized 的公平性差异
- **应用**:什么业务该用公平锁(订单、库存等强顺序要求)
**延伸递进面试题**:
1. **非公平锁为什么吞吐更高?**
- 解答要点:刚释放锁的线程仍在 CPU 上(热缓存),让它继续跑省掉唤醒开销;避免"唤醒 -> 抢占失败 -> 再阻塞"的抖动
2. **公平锁能完全避免饥饿吗?**
- 解答要点:能避免锁饥饿,但 `tryLock()` 不带超时的插队语义、以及条件队列转移仍可能造成延迟
3. **读写锁的公平模式有什么特殊问题?**
- 解答要点:公平模式下读锁会阻塞后续写锁请求(防止写饥饿),非公平模式写锁可能长期抢不到
**【一句话结论】**
面试官您好,公平与非公平的差别就一句话:抢锁前要不要先看队列里有没有人在排队。
**【三点展开】**
第一,公平锁的 `tryAcquire` 会先调 `hasQueuedPredecessors()`,只要前面还有人排队就老老实实入队,严格 FIFO,不会插队。
第二,非公平锁直接 CAS 把 state 从 0 改成 1,抢到就跑,抢不到才入队——所以"非公平"说的是允许新线程插队,不是唤醒所有线程一起抢,释放时其实只唤醒一个后继节点。
第三,这个插队是有意为之:刚释放锁的线程还在 CPU 上、缓存是热的,让它继续跑能省掉一次上下文切换,所以非公平吞吐更高;代价是极端情况下远端线程可能一直抢不到,产生饥饿。
**【一句边界】**
当然,如果业务对顺序敏感,比如库存扣减、订单状态机,就该用 `new ReentrantLock(true)`;普通缓存、计数器场景没必要为公平付吞吐代价,默认非公平就好。
**核心考点定位**:能否说清"Thread → ThreadLocalMap → Entry(弱引用 key, 强引用 value)"的持有链,以及泄漏根因。
**知识点拆解**:
1. **数据结构**:每个 `Thread` 有一个 `threadLocals` 字段,类型 `ThreadLocal.ThreadLocalMap`;map 是定制 HashMap,`Entry` 的 key 是 ThreadLocal 的**弱引用**,value 是强引用
2. **存取路径**:`get()` → 当前线程的 map → 用 `threadLocalHashCode`(每次新建 +0x61c88647,斐波那契散列)定位桶 → 冲突用**线性探测**(开放寻址,非链表)
3. **为什么弱引用**:key 用弱引用,是为了 ThreadLocal 实例本身可被回收;但 value 是强引用,若线程长期存活(线程池),key 被回收后 value 就成为"key=null 的僵尸节点"
4. **清理机制**:`get/set/remove` 会顺带 `expungeStaleEntry` 清理 key=null 的槽位,属于"被动防御",不能替代显式 `remove()`
5. **ThreadLocal 与线程安全的关系**:它不是同步手段,而是**用空间换隔离**——把共享变量变成线程私有,从而不需要同步
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:弱引用 key + 强引用 value 造成的泄漏链
- **广度**:InheritableThreadLocal、TransmittableThreadLocal、FastThreadLocal(Netty)
- **应用**:线程池场景为什么必须 try-finally remove
**延伸递进面试题**:
1. **ThreadLocalMap 为什么用开放寻址而不是链表?**
- 解答要点:Entry 数量少(一个线程通常个位数),开放寻址无指针、缓存友好;且需要支持 stale entry 清理
2. **InheritableThreadLocal 的局限?**
- 解答要点:只在**创建子线程时**拷贝一次,线程池复用线程后不再继承 → 必须用 TTL 或 TaskDecorator
3. **ThreadLocal 会导致内存泄漏,为什么 JDK 不把 value 也设成弱引用?**
- 解答要点:value 若弱引用,业务对象随时可能被 GC,`get()` 拿到 null,语义崩坏;只能靠 remove 或 stale 清理
**【一句话结论】**
面试官您好,ThreadLocal 不是锁,它是给每个线程存一份私有副本,用隔离代替同步。
**【三点展开】**
第一,结构上真正持有数据的是 Thread:每个 Thread 有个 `threadLocals` 字段,指向 ThreadLocalMap;map 的 key 是 ThreadLocal 实例本身(弱引用),value 才是我们存的值。所以准确说是"线程持有 map,map 以 ThreadLocal 为 key",不是"以线程为 key"。
第二,查找靠 `threadLocalHashCode` 做斐波那契散列,冲突走线性探测;这也是为什么它敢用定制 map——通常一个线程就几个 Entry,开放寻址比链表更快、更省内存。
第三,泄漏的根因在弱引用 key 配强引用 value:线程池里的线程不死,ThreadLocal 实例被回收后 Entry 的 key 变成 null,value 却还被强引用着,就成了僵尸节点。JDK 会在 get/set 时顺手清理一部分,但不能依赖,规范做法是 try-finally 里 remove。
**【一句边界】**
当然,异步链路里 ThreadLocal 会失效——跨线程池要用 TTL 或 TaskDecorator 显式透传,而且传递完必须清理,否则比不用还危险。
**核心考点定位**:能否说清 AQS 的全称、state 变量、CLH 队列、独占/共享两种模式,以及模板方法的设计。
**知识点拆解**:
1. **全称与定位**:AQS = AbstractQueuedSynchronizer(抽象队列同步器),位于 `java.util.concurrent.locks` 包,是 JUC 并发工具的基座。几乎所有同步器(ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier、ReadWriteLock)都直接或间接继承它
2. **两大核心数据结构**:
- **`volatile int state`**:同步状态,记录锁的"当前情况"。ReentrantLock 用 state 表示重入次数、Semaphore 用 state 表示剩余许可数、CountDownLatch 用 state 表示倒计时
- **CLH 双向队列**(Treiber 队列变种):获取锁失败的线程会被封装成 Node 节点,加入尾链表等待。head 是头节点(已获取锁的线程),tail 是尾指针
3. **两种工作模式**:
- **独占模式(Exclusive)**:一次只有一个线程能持有锁,如 ReentrantLock、ReentrantReadWriteLock 的写锁。子类实现 `tryAcquire` / `tryRelease`
- **共享模式(Shared)**:多个线程可同时持有,如 ReadLock、Semaphore、CountDownLatch。子类实现 `tryAcquireShared` / `tryReleaseShared`
4. **模板方法模式**:父类 AQS 负责排队、唤醒、调度;子类只实现"拿到锁/释放锁"的具体判断逻辑。这是 AQS 设计得优雅的关键——把"如何判断"和"如何调度"解耦
5. **与 synchronized 的区别**:
- synchronized 由 JVM 内置实现,不可中断、不可超时、公平/非公平由 JVM 决定
- AQS 是用户态实现,支持 `lockInterruptibly`、`tryLock(timeout)`、`isFair` 显式控制
- 高竞争下 AQS 通常更快(可优化调度),低竞争下两者差异不大
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:能否说清 state 变量 + CLH 队列 + 独占/共享两条线
- **广度**:能否列举主要子类并说出它们的 state 含义
- **应用**:能否举出工程中用 ReentrantLock 而非 synchronized 的场景
**延伸递进面试题**:
1. **AQS 为什么用双向链表而不是单向?**
- 解答要点:释放锁时要唤醒前驱的后继节点(独占)或所有后继(共享),单向链表无法高效遍历;且线程中断场景需要反向唤醒
2. **ReentrantLock 怎么实现可重入?**
- 解答要点:state 记录重入次数,同一线程再次获取时 state++,释放时 state--,直到 0 才真正释放
3. **公平锁 vs 非公平锁的区别?**
- 解答要点:公平锁 tryAcquire 时先看 hasQueuedPredecessors(队列里有没有更老的线程);非公平锁直接 CAS 抢,抢到就是它。非公平锁吞吐更高,公平锁避免饥饿
面试官您好,AQS 全称是 **AbstractQueuedSynchronizer,抽象队列同步器**,位于 `java.util.concurrent.locks` 包下,是 JUC 整个并发包的基座。ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier、ReadWriteLock 都是直接或间接继承它。
**AQS 的核心有两个**:
第一是 **`volatile int state` 状态变量**。它记录了同步器的"当前情况",但具体含义由子类决定:ReentrantLock 用 state 表示重入次数,Semaphore 用 state 表示剩余许可数,CountDownLatch 用 state 表示倒计时。state 用 volatile 修饰,保证可见性。
第二是 **CLH 双向队列**。当线程获取锁失败时,会被封装成一个 Node 节点挂到队列尾部,进入等待。队列头节点代表当前持有锁的线程,队列尾指针是 tail。释放锁时要遍历队列唤醒后继线程。
**AQS 有两种工作模式**:
**独占模式**:一次只能一个线程拿到锁,典型例子是 ReentrantLock 和 ReadWriteLock 的写锁。子类实现 `tryAcquire` 和 `tryRelease` 两个方法,告诉父类"我拿到了吗"和"我释放了"。
**共享模式**:多个线程可以同时持有,典型例子是 ReadLock、Semaphore、CountDownLatch。子类实现 `tryAcquireShared` 和 `tryReleaseShared`。
AQS 的设计思想是 **模板方法模式**:父类负责排队、唤醒、调度这些通用逻辑;子类只需要关心"怎么判断能不能拿到锁"和"怎么释放"。这样把"调度"和"策略"解耦,扩展性极强。
**和 synchronized 的对比**:synchronized 是 JVM 内置的,不可中断、不可超时、公平性由 JVM 决定;AQS 是用户态实现,支持 `lockInterruptibly`、`tryLock(timeout)`、`isFair` 显式控制,所以业务上需要这些能力时就用 AQS 的 ReentrantLock。
典型用法上,需要可中断、可超时、读写分离、信号量限流、倒计时这类场景,基本都是 AQS 的子类。
这就是我对 AQS 的理解。
---
**核心考点定位**:能否把"单机指令级原子"与"事务级全或无"分开,并说清各自的实现机制与作用域。
**知识点拆解**:
1. **并发原子性**:一个或多个操作在执行过程中不被其他线程打断;最小粒度是 CPU 指令(如 `MOV`、`CAS`),`i++` 是"读-改-写"三条指令,天然不原子
2. **并发原子性的实现**:`AtomicInteger` 用 CAS 自旋(`Unsafe.compareAndSwapInt`);`synchronized`/`Lock` 用互斥把复合操作包成临界区;`LongAdder` 用分段累加降低争用
3. **ACID 原子性**:事务内所有 DML 要么全部生效、要么全部回滚;实现依赖 **undo log 反向补偿** + redo log 保证崩溃恢复
4. **两者的联系**:语义上都是"全或无",但作用域不同——并发原子性作用于**单进程内存**,事务原子性作用于**单个存储引擎**;跨库就失效,于是需要 2PC/TCC/Saga
5. **常见混淆点**:原子性 ≠ 可见性 ≠ 有序性;`volatile` 保证可见性与有序性但**不保证原子性**;`AtomicInteger` 保证单个变量原子但**不保证复合逻辑原子**(先查再改仍需锁)
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:CAS 的 ABA 问题与 `AtomicStampedReference`
- **广度**:从单机原子性推到分布式事务(2PC/TCC/本地消息表)
- **应用**:库存扣减为什么"先 select 再 update"会超卖
**延伸递进面试题**:
1. **`AtomicInteger` 能保证 `if (count < 10) count++` 的原子性吗?**
- 解答要点:不能,两个操作合起来不是原子的;要用 `compareAndSet` 循环或 `updateAndGet` 函数式接口
2. **synchronized 和 CAS 各自适合什么场景?**
- 解答要点:低竞争 CAS 无锁更快;高竞争 CAS 自旋耗 CPU,锁把竞争交给 OS 排队更稳
3. **数据库事务原子性和分布式事务原子性的差别?**
- 解答要点:单库靠 undo log 真回滚;分布式只能"补偿"(TCC Cancel)或"重试到成功"(最终一致),没有真正的全局回滚
**【一句话结论】**
面试官您好,两者都讲"全或无",但一个是单条指令不可分割,一个是整个事务可回滚,作用域和实现完全不同。
**【三点展开】**
第一,并发原子性控制在指令和临界区级别。`i++` 编译出来是读、加、写三条指令,两个线程交错就会丢更新;解决要么用 CAS 自旋,要么用锁把这三条包成临界区。
第二,ACID 的原子性是事务级别的全或无,靠 undo log 反向补偿实现——中途失败就拿 undo 把已执行的语句逐条撤销,再配合 redo log 保证崩溃后能恢复到一致状态。
第三,两者的联系在于"作用域"。单机原子性出了进程就失效,所以跨库转账才需要 2PC 或 TCC;而 TCC 的 Cancel 本质是"用业务补偿模拟回滚",拿不到数据库那种真正的原子回滚。
**【一句边界】**
当然,原子性不等于线程安全——`volatile` 有可见性、有序性但不保证原子性;`AtomicInteger` 保证单变量原子,但"先判断再修改"这种复合逻辑还是得靠锁或 CAS 循环。
**核心考点定位**:能否说清 result 字段、回调栈、完成触发链路,以及同步/异步回调的线程语义。
**知识点拆解**:
1. **核心字段**:`volatile Object result`(null=未完成,`NIL`=无结果完成,`AltResult`=异常完成);`volatile Completion stack`(Treiber 栈,LIFO)
2. **回调注册**:`thenApply` 等把动作封装成 `UniApply/UniAccept/UniCompose/UniRelay` 节点,`push` 到 stack;若已存在结果则直接 `postComplete`
3. **完成触发**:`complete()` CAS 设置 result,然后 `postComplete()` 依次 `pop` 节点并执行,执行时还会处理"依赖的依赖"(嵌套触发)
4. **同步 vs 异步**:`thenApply` 在**完成它的那个线程**上执行(可能是主线程,也可能是 IO 线程);`thenApplyAsync` 提交到 `ForkJoinPool.commonPool()` 或指定 Executor
5. **异常语义**:异常被包装成 `AltResult`,沿链路传播;`exceptionally` 兜底,`handle` 同时处理正常与异常,`whenComplete` 不吞异常
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:Treiber 栈 + CAS 如何做到无锁注册回调
- **广度**:与 `Future`、`CompletableFuture`、虚拟线程 + `StructuredTaskScope` 的对比
- **应用**:并行调用多个下游时怎么控制超时与线程池隔离
**延伸递进面试题**:
1. **`thenApply` 和 `thenApplyAsync` 的区别?**
- 解答要点:前者在完成线程执行(可能阻塞 IO 线程),后者提交线程池;IO 回调里做重活必须用 Async + 自定义池
2. **`allOf` 怎么实现等待全部完成?**
- 解答要点:为每个 future 注册一个 `BiRelay` 节点递减计数,计数归零时完成聚合 future
3. **CompletableFuture 有什么坑?**
- 解答要点:默认 commonPool 是 CPU 核数-1,IO 密集会打满;异常不处理会静默丢失;`get()` 阻塞失去异步意义
**【一句话结论】**
面试官您好,CompletableFuture 本质是一个 volatile 结果字段加一个无锁回调栈,完成时把栈里的回调依次弹出来执行。
**【三点展开】**
第一,它只有两个核心字段:`volatile Object result` 记录结果,未完成是 null,异常完成是 AltResult;`volatile Completion stack` 是个 Treiber 栈,用 CAS 做无锁 push。
第二,`thenApply` 这类方法会把动作包成 UniApply 节点压栈;如果此时已经完成,就直接进 `postComplete` 弹栈执行,所以注册和执行没有固定顺序,靠 result 的 CAS 状态机来同步。
第三,线程语义要分清:`thenApply` 跑在"完成它的那个线程"上,可能是业务线程也可能是 IO 线程;`thenApplyAsync` 才提交到 commonPool 或你指定的池。线上 IO 密集场景一定要自定义池,否则 commonPool 只有 CPU 核数减一个线程,很容易被打满。
**【一句边界】**
当然,它不适合做长链路阻塞编排——默认池不可控、异常容易静默丢失;JDK 21 之后更推荐虚拟线程 + StructuredTaskScope 来做结构化并发。
**核心考点定位**:能否从"资源成本 → 并发模型 → 调度机制"说明虚拟线程不适合池化的原因。
**知识点拆解**:
1. **成本模型**:平台线程 = 1MB 栈 + 内核线程,创建/切换昂贵 → 必须池化;虚拟线程 = 堆上 Continuation + 几百字节,创建成本接近普通对象 → 池化无收益
2. **并发语义**:虚拟线程的卖点是"一请求一线程",百万级并发;线程池的 `maximumPoolSize` 是硬上限,池化等于人为把并发压回平台线程时代
3. **调度机制**:虚拟线程由 JVM 调度到 **ForkJoinPool 的载体线程(carrier)** 上,遇到阻塞 IO 会 `yield` 卸载;如果虚拟线程自己再进一个平台线程池,等于多一层排队
4. **Pinning 风险**:`synchronized` 块内阻塞、JNI 调用会让虚拟线程钉住载体线程无法卸载;池化 + pinning 会迅速耗尽载体线程,表现为"假死"
5. **正确姿势**:`Executors.newVirtualThreadPerTaskExecutor()`(每任务一个虚拟线程,不是池);限流用 `Semaphore`,结构化并发用 `StructuredTaskScope`;`ThreadLocal` 尽量换成 `ScopedValue`
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:Continuation / 载体线程 / yield 的调度模型
- **广度**:与 Reactor(Netty)、协程(Kotlin)、Go goroutine 的对比
- **应用**:老系统迁移到虚拟线程时的坑(pinning、连接池、ThreadLocal)
**延伸递进面试题**:
1. **虚拟线程遇到 synchronized 会怎样?**
- 解答要点:JDK 21/22 会 pin 住载体线程;JDK 24(JEP 491)已解决 synchronized 导致的 pinning,但 JNI 仍会
2. **虚拟线程还需要连接池吗?**
- 解答要点:需要,但语义变了——数据库连接是稀缺资源,池化的是连接不是线程;虚拟线程数量可以远大于连接数
3. **怎么限制虚拟线程的并发度?**
- 解答要点:`Semaphore` 许可、`StructuredTaskScope.ShutdownOnFailure` 的并发上限、或限流器;不要用固定大小线程池
**【一句话结论】**
面试官您好,线程池是为"昂贵且稀缺"的平台线程设计的,虚拟线程恰恰廉价且海量,池化等于把它的优势又锁回去了。
**【三点展开】**
第一,成本上两者不在一个量级:平台线程要 1MB 栈和内核线程,创建切换都贵,所以复用有意义;虚拟线程是堆上的 Continuation,创建成本接近 new 一个对象,池化的复用收益几乎为零,反而多一层队列和状态管理。
第二,更关键的是并发度。虚拟线程的卖点是"一请求一线程"跑到几十万并发,你一旦套上线程池,maximumPoolSize 就成了硬天花板,直接退回平台线程时代的并发量。
第三,还有 pinning 的坑:虚拟线程在 synchronized 块里阻塞或者走 JNI,会钉住载体线程无法卸载;再叠加池化,载体线程很快被占满,表现就是服务假死。所以正确做法是每任务一个虚拟线程,限流用 Semaphore 或 StructuredTaskScope,而不是池。
**【一句边界】**
当然,池化本身没错——数据库连接池、HTTP 连接池照样要用,因为稀缺的是连接不是线程;只是别把虚拟线程当平台线程那样池化。
**核心考点定位**:能否把限流语义映射到 AQS 的 state + 共享模式,并说清唤醒丢失/惊群的真正成因。
**知识点拆解**:
1. **state 语义设计**:`state = 当前可用许可数`;`tryAcquireShared(arg)` 循环 CAS `state - arg`,成功返回剩余值(≥0),失败返回负值让 AQS 入队;`tryReleaseShared` CAS `state + arg` 并返回 true 触发传播
2. **唤醒丢失的真正成因**(AQS 之外的 Condition 场景):`signal()` 必须在持有锁时调用;先 signal 后 await 会导致信号丢失;AQS 队列侧则靠 `waitStatus=SIGNAL` 标记 + `unparkSuccessor` 保证不漏唤醒
3. **惊群规避**:共享模式释放时 `setHeadAndPropagate(node, r)` 只在 `r > 0` 或 PROPAGATE 状态下继续唤醒后继,**按剩余许可传播**,不是全量 unpark
4. **公平性的作用**:`hasQueuedPredecessors()` 只保证入队顺序,不解决惊群;高争用下限流器通常选非公平(允许抢占许可)
5. **AQS 的边界**:单 state 只能表达"瞬时并发数";要做"每秒钟 N 个"必须引入时间维度(令牌桶 `nextFreeTicketMicros`、滑动窗口),这已经超出 AQS 单变量能力
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:`tryAcquireShared` 返回值语义与传播机制
- **广度**:Semaphore / RateLimiter / Sentinel 流控的差异
- **应用**:限流失败后是快速失败、排队还是降级
**延伸递进面试题**:
1. **Semaphore 的公平模式为什么慢?**
- 解答要点:每次 acquire 都要 `hasQueuedPredecessors` 读队列 + 严格 FIFO 唤醒,高争用下 CAS 成功率低、上下文切换多
2. **AQS 实现限流,许可怎么"按时间恢复"?**
- 解答要点:单 state 做不到;需要额外的 `nextFreeTicketMicros` 字段 + 每次 acquire 时按 elapsed 补充许可,本质是令牌桶(Guava RateLimiter 的 SmoothBursty)
3. **限流器被唤醒的线程太多导致 CPU 抖动,怎么查?**
- 解答要点:看 `jstack` 中大量 WAITING→RUNNABLE 抖动、`context switch` 指标;改用按需唤醒 + 有界队列 + 快速失败
**【一句话结论】**
面试官您好,如果只是"同时最多 N 个",AQS 的共享模式能直接做;但要做"每秒 N 个",单靠 state 不够,得引入时间维度。
**【三点展开】**
第一,state 设计成可用许可数。acquire 走 `tryAcquireShared`,循环 CAS 把 state 减一,成功返回剩余值,失败返回负数让 AQS 把线程挂到队列;release 走 `tryReleaseShared` 把 state 加回去并触发传播。这样"排队等待"和"快速失败"就变成了两个入口:`acquire` 阻塞,`tryAcquire` 直接返回 false。
第二,唤醒丢失不是"前置完成后丢了",而是释放路径没做对:共享模式释放后必须让 `setHeadAndPropagate` 按剩余许可继续唤醒后继;如果是 Condition 场景,`signal` 必须在持锁时调用,且要防止先 signal 后 await。AQS 队列侧靠 waitStatus 的 SIGNAL 标记兜底,漏唤醒基本都出在自定义 `tryReleaseShared` 返回值写错。
第三,惊群要靠"按需传播"而不是公平锁。公平锁只保证入队顺序,不减少唤醒数量;真正的做法是只在有许可时往后传播,配合有界队列和拒绝策略,避免一次放进来几千个线程又全部抢不到。
**【一句边界】**
当然,要做 QPS 限流 AQS 就不够用了——state 是单变量,表达不了时间窗口;生产上直接用 Guava RateLimiter、Sentinel 或 Redis 令牌桶,别自己造。
**核心考点定位**:能否说清"InheritableThreadLocal 为什么在池化下失效",并给出可落地的透传 + 清理方案。
**知识点拆解**:
1. **失效根因**:`InheritableThreadLocal` 在 `Thread` 构造时从父线程 `inheritableThreadLocals` 拷贝;线程池的线程是**复用的**,创建时父线程可能是任意一个请求线程,之后再也不拷贝 → 上下文串号或丢失
2. **TTL 方案**:`TransmittableThreadLocal` 在任务提交时把上下文快照塞进 `TtlRunnable`,执行前 `replay`、执行后 `restore`;需配合 `TtlExecutors.getTtlExecutorService()` 包装线程池
3. **Spring 方案**:`TaskDecorator` 在 `execute` 外层做 context copy + finally 清理;适合已有 `ThreadPoolTaskExecutor` 的项目
4. **泄漏链条**:线程池线程长期存活 → `ThreadLocalMap` 的 Entry 若不 remove,value 被强引用无法回收;key 是弱引用被回收后 value 仍在 → 每次任务都新增一个 stale entry
5. **日志链路**:MDC 底层也是 ThreadLocal/InheritableThreadLocal,跨线程必须一起透传,否则 traceId 断链
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:TTL 的 snapshot/replay/restore 三步与嵌套任务
- **广度**:MDC、OpenTelemetry Context、gRPC Context 的透传差异
- **应用**:异步链路上 traceId 丢失怎么排查
**延伸递进面试题**:
1. **TTL 和 InheritableThreadLocal 的本质区别?**
- 解答要点:前者在**任务提交时**快照、执行时回放,与线程创建无关;后者只在**线程创建时**拷贝,池化场景失效
2. **用了 TTL 还需要 remove 吗?**
- 解答要点:需要。TTL 的 restore 只恢复提交前的状态,若任务本身 set 了新值仍要清理;父线程执行完也应 remove
3. **CompletableFuture 链路怎么透传?**
- 解答要点:用 `TtlExecutors` 包装自定义线程池后传给 `*Async`;或统一封装 `wrap(Runnable/Supplier)`;不要用默认 commonPool
**【一句话结论】**
面试官您好,跨线程池透传不能用 InheritableThreadLocal,它在池化下必然失效,要用 TTL 或 TaskDecorator 在任务提交时做快照。
**【三点展开】**
第一,先解释为什么会丢:InheritableThreadLocal 只在 new Thread 那一次从父线程拷贝,线程池的线程是复用的,创建时的父线程跟后来的请求没关系,所以要么拿不到、要么拿到别人的上下文。
第二,正确做法是"提交时快照、执行时回放、结束后恢复"。TTL 就是干这个的,用 TtlExecutors 包装线程池,TtlRunnable 在 run 前后做 replay 和 restore;Spring 项目更简单,实现 TaskDecorator,在 execute 外层 copy 再 finally 清理,MDC 的 traceId 也一并带上。
第三,清理必须做,而且必须在 finally 里。线程池线程不死,Entry 的 value 是强引用,不 remove 就会一直挂着,key 被回收后变成 stale entry,越积越多就是内存泄漏。规范是父线程 set 后 try-finally remove,子任务里同样。
**【一句边界】**
当然,如果链路是同步的,就别引入 TTL,InheritableThreadLocal 够用且更轻;只有跨线程池、异步编排才需要这套,而且线程池最好自己管控,别用默认 commonPool。
**核心考点定位**:能否用"排队时间 vs 执行时间"切分延迟,并给出可观测、可回滚的调优路径。
**知识点拆解**:
1. **无界队列的机制陷阱**:`LinkedBlockingQueue` 默认容量 `Integer.MAX_VALUE`,线程池永远只用到 corePoolSize,`maximumPoolSize` 形同虚设;任务无限堆积 → 延迟线性上涨 → 最终 OOM
2. **延迟拆解**:`P99 总延迟 = 排队时间 + 执行时间 + 下游等待`;先用 `executor.queued`、`executor.active`、任务提交到开始的耗时定位是哪一段
3. **排查工具链**:Micrometer/Actuator 指标 → arthas `thread -n 10` / `jstack` 看栈 → SkyWalking/Arthas trace 看下游 RT → GC 日志排除 STW
4. **容量估算**:Little's Law `L = λ × W`——队列容量 ≈ 目标 QPS × 可接受排队秒数;例如 200 QPS、最多等 0.5s → 100
5. **动态调优**:`ThreadPoolExecutor.setCorePoolSize/setMaximumPoolSize` 可在运行期调整(注意 setMaximumPoolSize 小于 core 会抛异常);Nacos/Apollo 监听 + 参数校验 + 灰度;不能动态时先扩容机器兜底
6. **舱壁隔离**:核心业务与旁路业务分池;拒绝策略选 `CallerRunsPolicy`(反压)或自定义快速失败,禁止 `DiscardPolicy` 静默丢任务
> 按「底层原理 → 设计权衡 → 工程实践」递进排列。
**考察方向**:
- **深度**:无界队列让 max 失效的机制、Little's Law 的用法
- **广度**:线程池隔离、拒绝策略、动态线程池框架(Hippo4j/Dynamic-TP)
- **应用**:如何在故障中不停机地把 core/max/queue 调到合理值
**延伸递进面试题**:
1. **为什么线程池要先 core 再 queue 再 max?**
- 解答要点:设计假设是"队列能兜住突发",避免频繁创建线程;但配无界队列就退化成"永远只用 core";想优先扩容线程应使用 `SynchronousQueue` 或小容量队列
2. **`CallerRunsPolicy` 有什么副作用?**
- 解答要点:由提交线程执行任务,形成天然反压;但若提交线程是 Tomcat 工作线程,会拖慢上游接口,需确认可接受
3. **QPS 涨了 10 倍,线程数是不是也该涨 10 倍?**
- 解答要点:不是。线程数取决于"CPU 核数 × 目标利用率 × (1 + 等待时间/计算时间)",IO 密集可多、CPU 密集接近核数;盲目加线程只会加剧切换和下游压力
**【一句话结论】**
面试官您好,P99 从 50ms 涨到 3s,第一反应不是加线程,而是先确认这 3 秒里有多少是排队等出来的。
**【三点展开】**
第一,先做延迟拆解。看线程池的 activeCount、queue.size 和任务从提交到开始执行的耗时:如果排队时间占了 2.9 秒,那就是容量问题;如果排队很短、执行很长,就往下游查——DB 连接池、HTTP 超时、锁竞争。同时用 arthas 的 thread 看线程栈,确认是卡在 IO 还是 BLOCKED。
第二,根因大概率是无界队列。LinkedBlockingQueue 不传容量就是 Integer.MAX_VALUE,线程池永远只用 core,maximumPoolSize 根本不生效,任务无限堆积,延迟线性上涨,最后 OOM。所以第一刀是把队列改成有界,容量按 Little's Law 算:QPS 乘上你能接受的最大排队秒数,比如 200 QPS 最多等 0.5 秒就是 100。
第三,调优要能回滚。core/max 支持运行期 set,接 Nacos 做动态配置并加参数校验和灰度;拒绝策略别用 Discard,选 CallerRuns 形成反压或者自定义快速失败。同时把核心业务和旁路业务分池隔离,避免一个慢接口拖垮全站。参数改不了就先扩容机器兜底,稳定后再调。
**【一句边界】**
当然,如果下游本来就扛不住,扩线程只会把压力更快地打过去——先限流降级保住下游,再谈线程池参数;调参是最后一步,不是第一步。