← 学习门户 每日面试题 学习看板

2026-10-20

Java 并发 —— 从线程安全/AQS 到上下文透传与线程池排障

10 题 · 平均 2.6/5 · low 2.67 · medium 2.5 · high 2.67 · 第 23 轮

#1 什么是线程安全 low java-concurrency

题目

什么是线程安全

我的回答

线程安全核心是保证在多线程并发访问共享资源的情况下,每个线程都获取到符合预期的结果; java中核心是使用JMM定义的内存管理规范方式实现线程安全; 线程安全的核心是三个方面,互斥,可见性,有序性; 分别对应的处理方案,互斥方面主要是锁,以及ThreadLocal走的每个线程独立的一个数据实现互斥; 可见性方面主要使用synchronized和volatile去实现,依据是操作系统的底层命令将数据强刷进入到cpu主存中给所有线程共享; 有序性方面是基于jmm的happend-before原则,以及读写屏障保证有序性

点评

优点:三个层次(互斥/可见性/有序性)这次讲清楚了,比 09-26 的结构感明显提升;能主动挂到 JMM 与 happens-before 上,层次正确
不足:① "ThreadLocal 实现互斥" 说法不准——ThreadLocal 是**消除共享**,不是互斥;② 可见性归因于"操作系统底层命令强刷主存"过于笼统,准确说是 **MESI 缓存一致性 + 内存屏障(lock 前缀 / LoadLoad 等)**;③ 未提"共享可变"这个前提,也没给反例
补充:三层次之外还有第四层——**原子性**(i++ 不是原子操作);实现谱系:强制同步 / 无锁 CAS / 弱化共享(ThreadLocal、不可变)/ 单线程化 > ℹ️ 完整讲解见上方首次记录(2026-09-26)

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否说清"多线程+共享资源"这个前提,以及互斥/可见性/有序性三个层次。

**知识点拆解**:
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 在线程池会污染

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,线程安全的本质是"多线程访问共享可变资源时,任意时刻的执行结果都符合预期"。我把它拆成三个层次来说。

**第一个层次是互斥**——同一时刻只能有一个线程修改共享数据,避免"读-改-写"竞态。经典例子是 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 是经典的非线程安全类。

这就是我对线程安全的理解。

---

#2 公平锁和非公平锁的区别? low java-concurrency

题目

公平锁和非公平锁的区别?

我的回答

公平锁和非公平锁是依据锁释放后唤醒阻塞中线程具体谁来执行进行划分的; 按照哪个线程等待时间长哪个线程执行就是公平锁,也就是给线程按时间排序,先来后到; 非公平锁是唤醒所有等待线程,大家争抢,谁抢到了谁就执行,看的是线程调度优先级; java中synchronized升级为重量级锁后是非公平锁,使用aqs的reechangelock可以自己选择公平锁或者非公平锁,在创建锁的时候可以设置; 非公平锁可以让更快的线程执行,但是可能会有一些线程一直执行不到的情况;

点评

优点:抓住"先来后到 vs 争抢"的本质差异;知道 ReentrantLock 构造器可切公平性;知道非公平会饥饿
不足:① **"非公平锁唤醒所有等待线程"是硬伤**——释放锁只 `unpark` 后继一个节点,非公平指的是**新来的线程可以插队(barging)**,不是惊群;② 公平锁判据不是"等待时间",而是 `hasQueuedPredecessors()`——队列里有没有前驱;③ 说"看线程调度优先级"错,OS 优先级与锁竞争无关;④ 漏了公平锁吞吐更低、非公平默认(ReentrantLock 默认 false)
补充:公平锁 = 严格 FIFO + 无插队;非公平锁 = 允许新线程直接 CAS 抢,抢不到再入队;差距在高竞争时才明显(吞吐差 10%~100%)

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否说清"公平"的判据是队列前驱判定,而非等待时长;非公平的收益与代价。

**知识点拆解**:
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)`;普通缓存、计数器场景没必要为公平付吞吐代价,默认非公平就好。

#3 什么是ThreadLocal,如何实现的? low java-concurrency

题目

什么是ThreadLocal,如何实现的?

我的回答

ThreadLocal是java中的可以用来保证线程安全的一个工具; 核心是在每个线程都存储对应的对象,用当前线程做key的一个map中,实现多线程不共享对象,实现线程安全; ThreadLocal是获取线程中的对应的ThreadLocalMap的value,将设置的共享对象设置为value,将当前执行线程设置为key,每个线程只会获取到自己的线程的对象; ThreadLocal在使用时可能会存在内存泄露问题,需要在使用后进行对象的删除调用ThreadLocal提供的删除方法,释放掉当前调用线程的value值;

点评

优点:抓住"每个线程各存一份、消除共享"的本质;知道用 ThreadLocalMap;知道要手动 remove 防泄漏
不足:① **key 说反了**——key 是 **ThreadLocal 实例本身**(弱引用),value 才是业务对象;线程是"持有者"(`Thread.threadLocals`),不是 key;② 未提 `Entry extends WeakReference<ThreadLocal>`;③ 未说 hash 定位(`threadLocalHashCode` 斐波那契散列 + 线性探测);④ 未说泄漏根因:key 被回收后 value 仍被 Entry 强引用,线程池线程不死 → value 常驻;⑤ 未提 `setInitialValue` / `withInitial`
补充:一句话——`Thread` 持有 `ThreadLocalMap`,map 的 key 是 ThreadLocal 弱引用、value 是值;`remove()` 是唯一正确清理方式

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否说清"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 显式透传,而且传递完必须清理,否则比不用还危险。

#4 如何理解AQS? medium java-concurrency

题目

如何理解AQS?

我的回答

AQS是java中并发编程jut包的核心,其设计思想是通过父类明确好并发编程中关于调度,开始阻塞等逻辑,子类自行实现自己的规则,实现不同的并非线程控制功能; 核心是 三个内容,分别是 state,在不同的子类实现下标识不同的作用,锁中标识持有锁以及可重入锁下持有锁的次数,只有当state归零后才算释放锁;在栅栏中表示要等待线程的数量; clh队列,用于存放阻塞中的任务,其实现是双端队列,因为不同的子类有些是获取最新的有些是获取最早的任务; 另外是独占和共享模式,独占模式就是一次只有一个线程去执行,其他线程都等待,共享模式类似于读写锁中的读锁,以及栅栏,信号量等模式下同时走多个线程执行也就是同时有多个线程任务在临界区中执行

点评

优点:state / CLH 队列 / 独占与共享三条主线都点到了,比上次(2/5)质变;模板方法的设计意图说得对
不足:① 漏全称 **AbstractQueuedSynchronizer**;② 双向链表的理由说错——不是为了"取最新/最早",而是**释放时要 O(1) 找到前驱、支持取消节点与条件队列转移**;③ 没说 `tryAcquire/tryRelease`、`tryAcquireShared/tryReleaseShared`、`Node.waitStatus`;④ "栅栏"(CyclicBarrier)内部其实用 ReentrantLock + Condition,不是直接继承 AQS
补充:AQS 的三件套 = `volatile int state` + CLH 变体队列 + 模板方法;state 的语义由子类定义(锁=重入次数,Semaphore=许可数,CountDownLatch=计数) > ℹ️ 完整讲解见上方首次记录(2026-09-26)

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否说清 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 抢,抢到就是它。非公平锁吞吐更高,公平锁避免饥饿

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,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 的理解。

---

#5 并发编程中的原子性和数据库ACID的原子性一样吗? medium java-concurrency

题目

并发编程中的原子性和数据库ACID的原子性一样吗?

我的回答

并发编程的原子性和数据库中的原子性不是一个概念; 并发编程的原子性是一个操作在是不可再分的,一个指令就完成,不会有多个指令,一次就完成,比如赋值操作,或者通过锁使得一组操作只有一个线程完成,其他线程阻塞不能进入执行临界区; 数据库ACID中的原子性是事务下,一组操作执行要么都成功,要么都失败是事务下整个事务操作的原子性

点评

优点:核心区别一次说中——并发原子性是"不可分割的单个操作",ACID 原子性是"事务内多个操作的全或无";知道用锁把一组操作变原子
不足:① 未提**原子性的实现手段**(CAS/`AtomicInteger`、`synchronized` 的内存屏障)与破坏原子性的经典例子(`i++` 三条指令);② 未提 ACID 原子性靠 **undo log 回滚**实现;③ 未点出两者真正的联系——单机原子性无法跨进程,所以才有分布式事务(2PC/TCC 本质是"把原子性从单库扩展到多库");④ 缺"可见性/有序性不因原子性而保证"
补充:并发原子性 = 单条指令或临界区不可分割;ACID 原子性 = 事务要么全提交要么全回滚,靠 undo log;两者在"全或无"语义上同构,但作用域与实现完全不同

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否把"单机指令级原子"与"事务级全或无"分开,并说清各自的实现机制与作用域。

**知识点拆解**:
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 循环。

#6 CompletableFuture的底层是如何实现的? medium java-concurrency

题目

CompletableFuture的底层是如何实现的?

我的回答

CompletableFuture是用于实现异步编程的工具类,核心是异步执行任务,非阻塞的回调,执行任务编排;

点评

优点:定位说对了——异步 + 回调 + 编排
不足:几乎没触及底层:① 未提 `result` 字段 + `volatile` 可见性;② 未提**依赖栈 `stack`(Treiber 栈)** 存储回调,完成时 `postComplete` 弹栈触发;③ 未提 `Completion` 链表与 `UniApply/UniAccept/UniCompose` 等节点类型;④ 未提 `ASYNC` 后缀与 `ForkJoinPool.commonPool()` / 自定义 Executor;⑤ 未提 `thenApply` vs `thenApplyAsync` 的线程语义差异;⑥ 未提异常传播(`whenComplete`/`handle`/`exceptionally`)
补充:一句话——`CompletableFuture` = `volatile Object result` + Treiber 回调栈 + CAS 状态机;同步回调在完成线程执行,`Async` 变体提交到线程池

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否说清 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 来做结构化并发。

#7 为什么虚拟线程不要和线程池一起用? medium java-concurrency

题目

为什么虚拟线程不要和线程池一起用?

我的回答

线程池的核心是复用昂贵的系统级线程资源,而虚拟线程本身是廉价的快速生灭的,二者的设计就是背道而驰的;把廉价的虚拟线程使用线程池管理去复用是一种浪费或者说错误执行方式,只是让操作变得更复杂

点评

优点:核心逻辑对——虚拟线程廉价、无需池化,池化反而增加复杂度
不足:① 未提**池化会限制并发度**:线程池的 maxPoolSize 会把本可以百万级的虚拟线程压回几百个,直接抵消收益;② 未提**平台线程池里跑虚拟线程**才是真正该避免的反模式(载体线程被占用);③ 未提 **pinning**:synchronized 块 / native 调用会钉住载体线程,池化后更容易耗尽载体;④ 未提替代方案:用 `Semaphore` 或 `StructuredTaskScope` 做并发上限,而不是池;⑤ 未提 ThreadLocal 在虚拟线程上的内存放大(每线程一份)
补充:一句话——虚拟线程的正确并发控制是"许可/信号量 + 结构化并发",不是线程池;池化只适用于平台线程这种昂贵资源

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否从"资源成本 → 并发模型 → 调度机制"说明虚拟线程不适合池化的原因。

**知识点拆解**:
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 连接池照样要用,因为稀缺的是连接不是线程;只是别把虚拟线程当平台线程那样池化。

#8 AQS 自研同步器接入业务限流:如何设计 state 语义并避免唤醒丢失与惊群? high java-concurrency

题目

AQS 自研同步器接入业务限流:如何设计 state 语义并避免唤醒丢失与惊群?

我的回答

AQS自研同步器接入业务限流,要进行限流,state要控制在初始化的时候设置的就是单位时间内的限流量,当限流后其实有两个处理方案,一个是返回异常进行阻止后续请求实现限流;另一个是放入队列中等待前置任务完成,然后进行唤醒队列中的任务,继续拿出一批进行执行; 前者会出现丢失,后者可能会让任务产生堆积,长时间执行不完; 前者是常规的限流业务处理方案,只要给出好的反馈就好,后者有一点类似排队任务的概念,这里需要把控下,具体要能有多少任务被保留无限大队列会让jvm发生oom无法使用; 为了防止前置任务执行完成,后续任务全部唤醒,可以使用公平锁的模式,从队列中拿出最多state限流配置的任务数去执行;

点评

优点:意识到 state 承载"限流额度"语义;区分了"快速失败"与"排队等待"两种策略;知道要有界队列防 OOM;知道用公平性控制唤醒顺序
不足:① **state 语义没闭环**——限流器 state 应该是"当前可用许可数",需要 `tryAcquireShared` 里 CAS 递减、释放时 CAS 递增,且**必须处理"许可不够时返回负值让 AQS 入队"**;② "唤醒丢失"的原因说错了——不是"前置完成就丢失",而是 **release 时未在临界区检查/未调用 unpark,或 signal 先于 await 导致条件丢失**,AQS 内部靠 `waitStatus` 的 SIGNAL 状态避免;③ 惊群:正确做法是**共享模式下只唤醒必要数量**(`setHeadAndPropagate` 按剩余许可传播),而不是"用公平锁";④ 未提超时(`tryAcquireSharedNanos`)、许可的时间窗口重置(需要额外 `nextFreeTicketMicros`,AQS 的 state 单变量不够用)
补充:单变量 state 只够做"并发数信号量";要做 QPS 限流得叠加时间窗口(如令牌桶的 nextFreeTicket),或者直接上 Guava RateLimiter/Redisson RRateLimiter

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否把限流语义映射到 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 令牌桶,别自己造。

#9 异步线程池下 ThreadLocal 上下文丢失与内存泄漏,如何设计跨线程透传与清理机制? high java-concurrency

题目

异步线程池下 ThreadLocal 上下文丢失与内存泄漏,如何设计跨线程透传与清理机制?

我的回答

异步线程池中ThreadLocal传递的内容会因为线程的使用发生丢失,可以直接用jdk自带的可以传递给子线程共享的ThreadLocal,jdk中有直接的实现; 自己实现的话可以自建线程池,在任务结束时做回调,调用ThreadLocal清理,处理内存泄露,上下文传递可以进行主线程到子线程内部的传递,不过直接使用jdk自带的更合理些

点评

优点:方向对了——JDK 有子线程继承方案、需要任务结束清理、自建线程池做增强
不足:① **"JDK 自带可传给子线程的 ThreadLocal"在池化场景是失效的**:`InheritableThreadLocal` 只在 `new Thread` 时拷贝一次,线程池复用线程后不再拷贝 → 这才是"丢失"的根因;② 未点名 `TransmittableThreadLocal`(TTL)及其 `TtlRunnable/TtlExecutors`;③ 未提 Spring 的 `TaskDecorator`(最常用的低侵入方案);④ 清理只说了"回调",未说必须 **finally 里 remove**,且线程池线程不死导致 value 常驻;⑤ 未提异步编排(CompletableFuture)与 MDC 日志链路
补充:透传三选一——TTL(侵入最小、支持线程池)、TaskDecorator(Spring 生态)、手动 copy/restore;清理统一 try-finally remove

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否说清"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。

#10 核心线程打满且队列无界,接口 P99 从 50ms 涨到 3s,如何定位并做不停机调优? high java-concurrency

题目

核心线程打满且队列无界,接口 P99 从 50ms 涨到 3s,如何定位并做不停机调优?

我的回答

当前观测到的核心问题是p99的上升,先通过监控确认具体是某个端口p99暴增还是所有接口都暴增; 查看gc情况是否有大量gc; 如果有skywalking之类链路追踪工具,查看下接口的时间具体消耗在哪里了; 如果是某个或者某几个接口出现p99那么是这个接口中线程调度阻塞了太多的任务导致无法快速完成; 如果是系统中某台服务所有接口都慢,那么应该堆积的队列太大导致大量gc; 不停机调优方面看具体问题,看具体有哪些工具,如果有nacos并且对线程池做了动态配置,可以通过nacos调整线程池配置,增加核心线程数量,选择有界队列进行阻塞队列配置,队列长度按照1000/50*核心线程数*2,来获取,然后最大线程数配置为核心线程数*2用来进行冗余;核心线程数量需要通过qps来进行评估; 如果线程池配置不是动态参数的,那么就需要先增加机器承担请求量,保证能平稳承载业务后再进行调整

点评

优点:排查顺序专业——先界定范围(单接口 vs 全站)→ 看 GC → 链路追踪定位 → 区分线程池排队与下游依赖;调优有"动态优先、扩缩容兜底"的工程意识;队列公式有量化意图
不足:① **最关键指标漏了**:线程池 `activeCount`、`queue.size`、任务**排队等待时间**(`getQueue().size()` + Micrometer 的 `executor.queued`),P99 涨到 3s 往往就是排队时间,不是执行时间;② 未提 **jstack/arthas `thread`** 看线程状态分布(RUNNABLE 卡在 IO?BLOCKED 锁竞争?);③ 队列公式量纲不对(`1000/50` 是 QPS?应该是 Little's Law:队列容量 ≈ QPS × 可接受排队时长);④ 未提**有界队列 + 拒绝策略**(CallerRuns 反压 or 快速失败)与**线程池隔离**(核心/非核心业务分开池);⑤ 未提下游依赖(DB 连接池、HTTP 超时)才是常见真因;⑥ 无界队列下 `maximumPoolSize` 永远不生效,这点应作为常识点出
补充:无界队列三大害——max 参数失效、OOM 风险、延迟不可观测;正确姿势:有界队列 + 明确拒绝策略 + 排队时长监控 + 舱壁隔离

完整讲解

第一部分:知识重点剖析

**核心考点定位**:能否用"排队时间 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 形成反压或者自定义快速失败。同时把核心业务和旁路业务分池隔离,避免一个慢接口拖垮全站。参数改不了就先扩容机器兜底,稳定后再调。

**【一句边界】**
当然,如果下游本来就扛不住,扩线程只会把压力更快地打过去——先限流降级保住下游,再谈线程池参数;调参是最后一步,不是第一步。

📚 查看全部面试记录