Appearance
Day12 Java 多线程:线程创建、线程状态、锁体系、线程池、AQS
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. 进程、线程与 Java 的线程模型
并发这套知识的所有结论,都建立在一个前提上:Java 的 Thread 不是「轻量级」的东西,它是操作系统内核线程的一层薄包装。理解这一点,才能理解后面「为什么要用线程池」「为什么上下文切换贵」「为什么 JDK21 要搞虚拟线程」。
text
进程 Process:资源分配的最小单位(独立地址空间、文件句柄、内存)
└── 线程 Thread:CPU 调度的最小单位(共享进程内存,独享栈 + PC + 本地方法栈)
Java 线程模型(HotSpot)= 1 : 1 内核线程模型
Java Thread 对象 ──映射──► JVM 内部 JavaThread ──1:1──► OS 内核线程
│
由 OS 调度器统一调度
结论:
① new Thread().start() 会真的调用 pthread_create,是一次系统调用
② 线程切换需要陷入内核态,保存/恢复上下文,成本约 1~10 微秒
③ 每个线程默认栈 1MB(-Xss 可调),1 万个线程 ≈ 10GB 虚拟内存 → 开不起
④ 所以线程必须「池化复用」,这就是线程池存在的根本理由
对比:虚拟线程(JDK21 正式)= M : N 模型
N 个虚拟线程 ──挂载/卸载──► 少量载体线程(ForkJoinPool)──► 内核线程
阻塞时只卸载虚拟线程,不阻塞内核线程 → 可以开百万级| 维度 | 平台线程(Thread) | 虚拟线程(JDK21) |
|---|---|---|
| 与内核线程 | 1 : 1 | M : N(挂载在载体线程上) |
| 创建成本 | 高(系统调用 + 1MB 栈) | 极低(堆上对象,栈按需增长) |
| 可创建数量 | 几千 | 百万级 |
| 阻塞代价 | 内核线程被占用 | 仅卸载虚拟线程,载体线程可复用 |
| 适用场景 | CPU 密集、需要线程池调控 | IO 密集、高并发请求处理 |
一句话结论: 线程贵,所以要池化;线程切换贵,所以线程数不是越多越好;虚拟线程解决的是「IO 密集场景下线程不够用」的问题,不能提升 CPU 密集任务的吞吐。
2. 线程创建的 5 种方式
text
① 继承 Thread —— 重写 run(),Java 单继承,侵入性强,不推荐
② 实现 Runnable —— 无返回值、不能抛检查异常,最常用的任务抽象
③ Callable + FutureTask —— 有返回值、可抛异常,get() 阻塞获取结果
④ 线程池 ThreadPoolExecutor —— 生产唯一推荐,复用线程 + 可控可监控
⑤ CompletableFuture —— 任务编排(串行/并行/聚合),本质还是跑在线程池上java
// ① 继承 Thread
class MyThread extends Thread {
@Override public void run() { System.out.println("run by " + getName()); }
}
new MyThread().start();
// ② 实现 Runnable(推荐:任务与执行解耦)
new Thread(() -> System.out.println("runnable"), "biz-thread-1").start();
// ③ Callable + FutureTask:有返回值
FutureTask<Integer> task = new FutureTask<>(() -> {
Thread.sleep(100);
return 666; // Callable 可以抛异常、可以有返回值
});
new Thread(task).start();
Integer r = task.get(); // 阻塞直到任务完成;get(timeout) 可超时
// ④ 线程池(生产写法:手动 new,不用 Executors)
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), // 必须有界
new ThreadFactoryBuilder().setNameFormat("order-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
Future<Integer> f = pool.submit(() -> 1 + 1);
// ⑤ CompletableFuture 编排
CompletableFuture<String> cf = CompletableFuture
.supplyAsync(() -> queryUser(), pool) // 一定要传自定义线程池
.thenApply(u -> u.getName())
.exceptionally(e -> "default");| 方式 | 返回值 | 抛检查异常 | 线程复用 | 生产可用性 |
|---|---|---|---|---|
继承 Thread | ❌ | ❌ | ❌ | ❌ 不用 |
Runnable | ❌ | ❌ | ❌ | 作为任务抽象 ✅ |
Callable+Future | ✅ | ✅ | ❌ | 需要结果时 ✅ |
ThreadPoolExecutor | ✅ | ✅ | ✅ | ✅ 唯一推荐 |
CompletableFuture | ✅ | ✅ | ✅ | ✅ 多任务编排首选 |
面试高频细节:
start()和run()的区别 ——start()由 JVM 调用本地方法start0()创建内核线程,由 OS 调度后回调run();直接调run()就是一次普通方法调用,仍在当前线程执行,没有任何并发。而且start()只能调用一次,第二次会抛IllegalThreadStateException(threadStatus != 0)—— 线程是一次性用品,这也是线程池必须复用线程而不能复用Thread对象的原因。
3. 线程的 6 大状态与流转
Java 的线程状态定义在 Thread.State 枚举里,只有 6 个,和操作系统的 5 态模型不是一回事。最容易答错的点:Java 把 OS 的「就绪」和「运行」合并成了一个 RUNNABLE。
text
NEW --start()--> RUNNABLE --run() 结束 / 抛未捕获异常--> TERMINATED
RUNNABLE 出去的三条路,最终都会回到 RUNNABLE:
RUNNABLE --抢 synchronized 锁失败--> BLOCKED
BLOCKED --拿到监视器锁--> RUNNABLE
RUNNABLE --wait() / join() / LockSupport.park()--> WAITING
WAITING --notify() / notifyAll() / 目标线程结束 / unpark()--> RUNNABLE
(wait 被唤醒后需先变 BLOCKED 重新抢锁,再回 RUNNABLE)
RUNNABLE --sleep(n) / wait(n) / join(n) / parkNanos(n)--> TIMED_WAITING
TIMED_WAITING --超时自动返回 / 被提前唤醒--> RUNNABLE
关键记忆点:
① RUNNABLE = OS 的「就绪」+「运行中」,Java 不区分,jstack 里看到
RUNNABLE 不代表它正在占用 CPU
② BLOCKED 只有一种来源:抢 synchronized 监视器锁失败;
ReentrantLock 抢锁失败是 park,显示 WAITING / TIMED_WAITING
③ WAITING 和 TIMED_WAITING 的唯一区别是「有没有超时参数」
④ IO 阻塞(socket read)在 Java 里仍然显示 RUNNABLE(JVM 不感知)
⑤ TERMINATED 不可逆:线程一次性,重复 start() 抛 IllegalThreadStateException| 状态 | 触发方式 | 是否持有锁 | jstack 显示 |
|---|---|---|---|
NEW | new Thread() | — | 不出现 |
RUNNABLE | start() 之后、IO 阻塞中 | 视代码而定 | runnable |
BLOCKED | 抢 synchronized 失败 | 没拿到 | blocked on monitor entry |
WAITING | wait()、join()、park()、await() | wait 会释放锁 | in Object.wait() / parking |
TIMED_WAITING | sleep(n)、wait(n)、join(n)、parkNanos、offer(timeout) | sleep 不释放锁 | waiting on condition |
TERMINATED | run() 正常返回或抛未捕获异常 | 已释放 | 不出现 |
易错提醒: 面试常问「
BLOCKED和WAITING有什么区别」。答案不是「阻塞和等待」这种同义反复,而是:BLOCKED是被动的 —— 线程想往前走但锁被别人占着,一旦锁释放立刻自动参与竞争;WAITING是主动的 —— 线程自己交出了执行权,必须等别人显式notify/unpark/ 目标线程结束才可能回到RUNNABLE。另外BLOCKED专属于synchronized,ReentrantLock的等待者是WAITING,这是jstack排查时区分两类锁的关键特征。
4. 线程常用 API 与易错点
java
Thread t = new Thread(task, "pay-worker");
t.setDaemon(true); // 守护线程:必须在 start() 前设置,否则抛 IllegalThreadStateException
t.setPriority(8); // 优先级 1~10,默认 5,但【OS 不保证】,生产不要依赖
t.start(); // 唯一合法启动方式,只能调一次
t.join(); // 当前线程等 t 结束(当前线程进 WAITING)
t.join(1000); // 最多等 1 秒(TIMED_WAITING)
Thread.sleep(100); // 当前线程睡 100ms,不释放 synchronized 锁
Thread.yield(); // 提示调度器「我可以让一让」,仅建议,可能被忽略
Thread.currentThread().getName();
t.interrupt(); // 只是打「中断标记」,不会强行停止线程
t.isInterrupted(); // 查标记,不清除
Thread.interrupted(); // 查当前线程标记,【并清除】标记① 守护线程 vs 用户线程
text
用户线程:只要还有一个存活,JVM 就不退出
守护线程:JVM 退出时直接被抛弃,finally 块【不保证】执行
典型守护线程:GC 线程、JIT 编译线程、Spring 的定时任务调度线程(部分)
使用原则:守护线程里【绝不能】写「必须完成」的逻辑(写文件、提交事务、刷日志)② 中断机制:Java 没有「强杀线程」
stop()、suspend()、resume() 已全部 @Deprecated,因为 stop() 会在任意位置抛 ThreadDeath 并立刻释放所有锁,导致共享数据处于被改坏一半的状态。Java 的正确做法是协作式中断:
java
// 正确的可中断任务模板
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) { // ① 轮询检查标记
doWork();
Thread.sleep(100); // ② 阻塞方法会响应中断
}
} catch (InterruptedException e) {
// ③ sleep/wait/join 抛出 InterruptedException 时,【标记会被自动清除】
// 所以这里必须补回来,否则上层无法感知
Thread.currentThread().interrupt();
} finally {
cleanup(); // ④ 资源清理
}
}| 场景 | interrupt() 的效果 |
|---|---|
| 线程正在正常执行计算 | 只设置标记位,代码不检查就等于没发生 |
阻塞在 sleep / wait / join | 立刻抛 InterruptedException,并清除标记 |
阻塞在 lock.lockInterruptibly() | 抛 InterruptedException |
阻塞在 lock.lock()(不可中断) | 只设标记,继续等锁 |
阻塞在 synchronized 抢锁 | 只设标记,继续等(BLOCKED 不响应中断) |
阻塞在 InputStream.read()(socket) | 不响应,需要关闭流/连接来打断 |
阻塞在 NIO 的 Selector.select() | 响应,select 立刻返回 |
线程已 TERMINATED | 无任何效果 |
三个必须记住的结论: ①
InterruptedException被捕获后标记会被清除,要么补interrupt()、要么向上抛,吞掉它是并发代码里最常见的错误;②Thread.interrupted()是静态方法且会清除标记,isInterrupted()是实例方法不清除,名字很像但行为相反;③ 线程池的shutdownNow()本质就是对所有 worker 调interrupt(),任务不响应中断,shutdownNow就停不下来。
5. JMM 内存模型与并发三大特性
并发 Bug 的根源只有一句话:每个 CPU 核心有自己的高速缓存,编译器和 CPU 还会重排指令。JMM(Java Memory Model)就是 Java 对这件事定义的一套抽象规则。
text
┌─────────── 主内存 Main Memory(所有线程共享)───────────┐
│ 堆上的实例字段、静态字段、数组元素 │
└───────▲───────────────────────────────▲────────────────┘
│ read / write │
┌───────────┴──────────┐ ┌───────────┴──────────┐
│ 线程 A 工作内存 │ │ 线程 B 工作内存 │
│ (CPU 寄存器 + L1/L2)│ │ (CPU 寄存器 + L1/L2)│
│ count 的副本 = 0 │ │ count 的副本 = 0 │
└──────────────────────┘ └──────────────────────┘
问题:A 把 count 改成 1,只改了自己的副本 → B 永远看到 0 →可见性问题
count++ 实际是 read-modify-write 三步 → 交错执行丢更新 → 原子性问题
编译器/CPU 为了性能调整语句顺序 → DCL 半初始化 → 有序性问题| 特性 | 含义 | 被破坏的原因 | 解决手段 |
|---|---|---|---|
| 原子性 | 操作不可分割,要么全做要么不做 | 时间片切换 | synchronized、Lock、CAS 原子类 |
| 可见性 | 一个线程的修改其他线程立刻能看到 | CPU 缓存未同步 | volatile、synchronized、final |
| 有序性 | 程序执行顺序符合代码顺序 | 编译器/CPU 指令重排 | volatile(内存屏障)、synchronized |
happens-before 八条规则(JMM 的核心,面试必背前五条):
text
① 程序次序规则:单线程内,前面的操作 happens-before 后面的操作
② 监视器锁规则:unlock 一个锁 happens-before 后续对【同一把锁】的 lock
③ volatile 规则:对 volatile 变量的写 happens-before 后续对它的读
④ 线程启动规则:Thread.start() happens-before 该线程的任何操作
⑤ 线程终止规则:线程的所有操作 happens-before 其他线程检测到它已终止
(join() 返回、isAlive() 返回 false)
⑥ 线程中断规则:interrupt() happens-before 被中断线程检测到中断
⑦ 对象终结规则:构造函数结束 happens-before finalize() 开始
⑧ 传递性:A hb B,B hb C ⇒ A hb Chappens-before 不是「时间上先发生」,而是「前一个操作的结果对后一个操作可见,且执行顺序不会被重排到它后面」。比如
synchronized块内部的两条无依赖语句照样可能被重排,但只要不影响「锁释放前的全部修改对下一个持锁者可见」这个承诺就合法。理解这一点,才能理解「为什么加了锁就同时解决了可见性和有序性」—— 不是锁禁止了重排,而是锁的语义通过规则 ② + ⑧ 建立了 happens-before 链。
6. synchronized:JVM 内置锁
java
public class SyncDemo {
private int count;
private static int total;
// ① 修饰实例方法:锁的是 this(同一个对象的不同同步方法互斥)
public synchronized void inc() { count++; }
// ② 修饰静态方法:锁的是 SyncDemo.class(全类唯一,和实例锁互不干扰)
public static synchronized void incTotal() { total++; }
// ③ 修饰代码块:锁括号里的对象,范围最小,推荐
private final Object lock = new Object();
public void incBlock() {
// 只锁必要的几行,其他耗时操作放锁外
synchronized (lock) { count++; }
}
}| 用法 | 锁对象 | 作用范围 | 注意 |
|---|---|---|---|
| 实例方法 | this | 同一实例的所有同步实例方法 | 不同实例之间不互斥 |
| 静态方法 | 类.class | 全类所有实例 | 与实例锁是两把不同的锁 |
| 代码块 | 括号内指定对象 | 块内代码 | 推荐,锁粒度可控 |
字节码层面:
text
synchronized 代码块 → monitorenter / monitorexit 两条字节码指令
编译器会生成【两个】monitorexit:一个正常出口,
一个异常出口(所以抛异常也会自动释放锁)
synchronized 方法 → 方法的 access_flags 带 ACC_SYNCHRONIZED 标志
JVM 执行时隐式做 monitorenter/exit
底层:每个 Java 对象都关联一个 ObjectMonitor(C++ 实现,重量级锁时才真正创建)
ObjectMonitor {
_owner // 持锁线程
_recursions // 重入次数(这就是可重入的实现)
_EntryList // 抢锁失败阻塞的线程 → 对应 BLOCKED 状态
_WaitSet // 调用 wait() 后等待的线程 → 对应 WAITING 状态
_count
}
synchronized的四个关键性质: ① 可重入 —— 同一线程可重复进入,靠_recursions计数,所以子类同步方法可以调父类同步方法而不会自锁死;② 非公平 —— 新来的线程可以直接抢,不排队,吞吐高但可能饿死;③ 不可中断 ——lock.lock()的平替没有,等锁期间interrupt()无效;④ 自动释放 —— 方法返回或抛异常都会monitorexit,这是它相比ReentrantLock最大的优势(后者忘了unlock就是永久死锁)。
7. 对象头与锁升级(锁膨胀)
JDK6 以后 synchronized 不再是「一上来就找 OS 要互斥量」的重量级锁,而是会随竞争激烈程度单向升级。升级状态记录在对象头的 Mark Word 里。
text
64 位 JVM 下,普通对象的对象头 = Mark Word(8B) + Klass Pointer(4B 压缩后)
Mark Word 的复用(同一块 8 字节,按锁标志位解释成不同含义):
┌────────────────────────────────────┬──────────┬──────┐
│ 无锁 hashCode(31) 分代年龄(4) │ 偏向 0 │ 01 │
│ 偏向锁 线程ID(54) epoch(2) 年龄(4) │ 偏向 1 │ 01 │
│ 轻量级锁 指向线程栈中 Lock Record 的指针(62) │ 00 │
│ 重量级锁 指向 ObjectMonitor 的指针(62) │ 10 │
│ GC 标记 │ 11 │
└────────────────────────────────────┴──────────┴──────┘
升级路径(只能升不能降,直到 GC 后对象重新分配):
无锁 ──第一个线程进入──► 偏向锁 ──出现第二个线程──► 轻量级锁
(CAS 自旋抢)
──自旋失败/竞争加剧──► 重量级锁(向 OS 申请互斥量,线程挂起)| 锁状态 | 加锁方式 | 竞争场景 | 开销 |
|---|---|---|---|
| 偏向锁 | Mark Word 里 CAS 写入线程 ID,之后无同步开销 | 全程只有一个线程 | 几乎为 0 |
| 轻量级锁 | 线程栈创建 Lock Record,CAS 替换 Mark Word,失败则自旋 | 交替执行,无实际并发 | 自旋消耗 CPU,但不切换上下文 |
| 重量级锁 | 进入 ObjectMonitor 的 _EntryList,线程 park 挂起 | 真实并发,竞争激烈 | 用户态↔内核态切换,微秒级 |
一个必须知道的版本变化: 偏向锁在 JDK15 被默认关闭(JEP 374),JDK18 彻底移除。原因是:① 偏向锁撤销(revoke)需要进入安全点 STW,在竞争多的现代应用里,撤销的总成本超过了它节省的 CAS 成本;② 现在的 CAS 在主流 CPU 上已经非常便宜;③ 这段代码维护复杂度高,阻碍了 Lilliput 等对象头压缩项目。所以面试答锁升级时,加一句「JDK15 之后偏向锁已默认禁用,升级路径简化成 无锁 → 轻量级 → 重量级」会是明显的加分项。
8. volatile:轻量级的可见性保证
java
public class VolatileDemo {
// ① 典型场景一:状态标记位(最经典、最安全的用法)
private volatile boolean running = true;
public void shutdown() { running = false; } // 其他线程立刻可见
public void run() { while (running) { work(); } }
// ② 典型场景二:DCL 单例(靠的是「禁止重排」而不是可见性)
private static volatile VolatileDemo instance;
// ③ 反例:volatile 不保证原子性,这里依然会丢更新
private volatile int count;
public void inc() { count++; } // read + 1 + write 三步,非原子
}text
volatile 做了两件事:
① 可见性:写操作立刻刷回主内存,并让其他 CPU 的缓存行失效(MESI 缓存一致性协议)
底层是在汇编层加 lock 前缀指令(lock addl $0x0, (%rsp))
② 有序性:插入内存屏障,禁止特定方向的重排
volatile 写之前:StoreStore 屏障(前面的普通写不能排到它后面)
volatile 写之后:StoreLoad 屏障(最重的屏障,保证写对后续读可见)
volatile 读之后:LoadLoad + LoadStore 屏障(后面的读写不能排到它前面)
volatile 没做的事:
✗ 不保证原子性(count++ 三步仍会交错)
✗ 不会加锁,不会阻塞线程
✗ 不能替代 synchronized 做复合操作| 对比项 | volatile | synchronized |
|---|---|---|
| 原子性 | ❌ 不保证 | ✅ 保证 |
| 可见性 | ✅ | ✅ |
| 有序性 | ✅(禁止重排) | ✅(临界区整体串行) |
| 阻塞线程 | ❌ 不阻塞 | ✅ 会阻塞 |
| 作用对象 | 变量 | 代码块 / 方法 |
| 性能 | 很轻(无上下文切换) | 升级到重量级锁后较重 |
| 适用 | 状态标记、DCL、一写多读 | 复合操作、需要互斥的临界区 |
判断是否能用
volatile的一条准则: 写入的新值不依赖当前值(如flag = false可以,count++不行),或者只有单一线程写、多线程读。满足其一才能用,否则必须上锁或用原子类。
9. CAS 与原子类
CAS(Compare And Swap)是「无锁并发」的基石:比较内存中的值是否等于预期值,相等才写入新值,整个过程由 CPU 的 cmpxchg 指令保证原子。它是乐观锁,失败就自旋重试,不会阻塞线程。
java
// AtomicInteger.incrementAndGet() 的本质(JDK8 实现)
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// Unsafe 内部就是一个自旋 CAS
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset); // ① 读当前值(volatile 读)
} while (!compareAndSwapInt(o, offset, v, v + delta)); // ② CAS 失败就重试
return v;
}text
CAS(内存地址 V, 预期值 A, 新值 B):
if (V == A) { V = B; return true; }
else { return false; } ← 失败则自旋重试
三个经典问题:
① ABA 问题:线程1 读到 A → 线程2 把 A 改成 B 又改回 A → 线程1 CAS 成功
但中间状态已经丢失(栈顶替换、链表节点复用场景会出 Bug)
解法:加版本号 → AtomicStampedReference(int 版本戳)
AtomicMarkableReference(boolean 标记)
② 自旋开销:高竞争下大量线程空转烧 CPU
解法:分段累加 → LongAdder;或退化为阻塞锁
③ 只能保证一个变量的原子性
解法:把多个字段封装成对象用 AtomicReference;或直接上锁| 分类 | 代表类 | 用途 |
|---|---|---|
| 基本类型 | AtomicInteger、AtomicLong、AtomicBoolean | 计数器、开关 |
| 引用类型 | AtomicReference、AtomicStampedReference | 对象整体替换、解决 ABA |
| 数组 | AtomicIntegerArray、AtomicReferenceArray | 数组元素级原子更新 |
| 字段更新器 | AtomicIntegerFieldUpdater | 给已有类的 volatile 字段加原子性 |
| 高性能累加 | LongAdder、LongAccumulator、DoubleAdder | 高并发计数首选 |
工程选择: 低并发计数用
AtomicLong(结果精确、get()立即一致);高并发只做累加、偶尔读总数的场景一律用LongAdder—— 它把一个热点变量拆成base + Cell[]数组,不同线程 CAS 到不同 Cell 上,彻底避开了单点竞争,实测高并发下吞吐是AtomicLong的数倍。代价是sum()是瞬时估算值(遍历期间其他线程还在改),且占内存更多。
10. AQS:JUC 的半壁江山
AbstractQueuedSynchronizer 是 ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock、ThreadPoolExecutor.Worker 的共同父类。它只做两件事:用一个 volatile int state 表示同步状态,用一个 FIFO 双向队列(CLH 变体)存放抢不到资源的线程。
text
AQS 核心结构
private volatile int state; // 同步状态,含义由子类定义
private transient Node head; // 等待队列头(虚拟头节点,不存线程)
private transient Node tail; // 等待队列尾
state 的含义由子类自己解释:
ReentrantLock → 0 未锁,n 表示重入 n 次
Semaphore → 剩余许可数
CountDownLatch → 剩余计数
ReentrantReadWriteLock → 高 16 位读锁次数,低 16 位写锁次数
等待队列(CLH 的双向链表变体)
head ⇄ Node(T1) ⇄ Node(T2) ⇄ Node(T3) ⇄ tail
▲
每个 Node 里有:thread、prev、next、waitStatus、nextWaiter
waitStatus:
0 初始
-1 SIGNAL 后继节点需要被唤醒(当前节点释放时要 unpark 后继)
-2 CONDITION 在 Condition 的条件队列中
-3 PROPAGATE 共享模式下需要向后传播唤醒
1 CANCELLED 已取消(超时或被中断)
两种资源共享模式
独占 Exclusive:同一时刻只有一个线程能拿到 state
→ ReentrantLock、ReentrantReadWriteLock 的写锁
共享 Shared :多个线程可同时获取
→ CountDownLatch、Semaphore、ReadWriteLock 的读锁模板方法设计 —— 自定义同步器只需重写 4 个方法:
java
// AQS 已实现的模板方法(final,不可重写):
// acquire / release 独占式获取/释放
// acquireShared / releaseShared 共享式获取/释放
// acquireInterruptibly / tryAcquireNanos 可中断/超时版本
// 子类只需按需重写这几个(默认实现都是抛 UnsupportedOperationException):
protected boolean tryAcquire(int arg) { ... } // 独占获取
protected boolean tryRelease(int arg) { ... } // 独占释放
protected int tryAcquireShared(int arg) { ... } // 共享获取,返回剩余许可
protected boolean tryReleaseShared(int arg) { ... } // 共享释放
protected boolean isHeldExclusively() { ... } // 是否被当前线程独占(Condition 用)AQS 的精髓一句话: 「把『获取资源的判定逻辑』交给子类,把『线程排队、阻塞、唤醒、取消』这些又难又容易出错的部分统一实现」。所以面试被问「AQS 是什么」,答「抽象队列同步器」只是名词翻译,要答出:
volatile state+ CLH 双向队列 +LockSupport.park/unpark阻塞线程 + 模板方法模式这四个要点。
11. JUC 锁与同步工具全景
text
java.util.concurrent.locks
├── Lock 接口
│ ├── ReentrantLock 可重入独占锁,可公平/非公平
│ └── ReadWriteLock.readLock / writeLock
├── ReentrantReadWriteLock 读写锁:读共享、写独占、读写互斥
├── StampedLock(JDK8) 读写锁升级版,支持【乐观读】,不可重入
├── Condition 条件队列,替代 wait/notify,一把锁可多个条件
└── LockSupport park/unpark,所有阻塞的最底层工具
java.util.concurrent(同步工具类)
├── CountDownLatch 倒计时门闩:一次性,await 等 countDown 归零
├── CyclicBarrier 循环屏障:可复用,所有线程到齐再一起放行
├── Semaphore 信号量:控制同时访问的线程数(限流、连接池)
├── Phaser 多阶段栅栏,CyclicBarrier 的增强版(可动态增减参与者)
└── Exchanger 两个线程之间交换数据java
// ① ReentrantLock:标准写法
private final ReentrantLock lock = new ReentrantLock(); // true 为公平锁
public void biz() {
lock.lock(); // 不响应中断
try { doWork(); }
finally { lock.unlock(); } // 【必须放 finally】
}
// 带超时的写法:防死锁的利器
public boolean bizWithTimeout() throws InterruptedException {
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) return false; // 拿不到就放弃
try { doWork(); return true; }
finally { lock.unlock(); }
}
// ② 读写锁:读多写少
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
public V get(K k) {
rw.readLock().lock();
try { return map.get(k); } finally { rw.readLock().unlock(); }
}
public void put(K k, V v) {
rw.writeLock().lock();
try { map.put(k, v); } finally { rw.writeLock().unlock(); }
}
// ③ CountDownLatch:主线程等所有子任务
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
pool.execute(() -> {
try { query(); } finally { latch.countDown(); } // 【countDown 必须在 finally】
});
}
latch.await(3, TimeUnit.SECONDS); // 【一定要带超时】
// ④ CyclicBarrier:多线程互相等,可复用
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("本轮全部到齐"));
// 每个线程 barrier.await(),第 3 个到达时触发回调并一起放行,之后自动重置
// ⑤ Semaphore:限流
Semaphore sem = new Semaphore(10); // 最多 10 个并发
sem.acquire();
try { callThirdParty(); } finally { sem.release(); } // 【release 必须在 finally】| 工具 | state 含义 | 可否复用 | 典型场景 |
|---|---|---|---|
ReentrantLock | 重入次数 | — | 替代 synchronized,需要超时/中断/公平 |
ReadWriteLock | 读写计数 | — | 本地缓存、配置中心(读多写少) |
StampedLock | 版本戳 | — | 极致读性能,不可重入、不支持 Condition |
CountDownLatch | 剩余计数 | ❌ 一次性 | 并行查询后聚合、服务启动等待依赖就绪 |
CyclicBarrier | 到达计数 | ✅ 可复用 | 分批压测、多阶段并行计算 |
Semaphore | 剩余许可 | ✅ | 接口限流、资源池、控制第三方调用并发 |
CountDownLatch与CyclicBarrier的本质区别: 前者是一个线程等 N个线程(计数只减不增,归零后永久失效);后者是N 个线程互相等(到齐后自动重置,可循环使用)。还有一个工程细节:CyclicBarrier任一线程超时或被中断,会让所有在屏障上等待的线程抛BrokenBarrierException,屏障进入「损坏」状态,需要reset()才能恢复 —— 这是它比CountDownLatch危险的地方。
12. ThreadPoolExecutor:7 大参数与执行流程
java
public ThreadPoolExecutor(
int corePoolSize, // ① 核心线程数:常驻,默认不回收
int maximumPoolSize, // ② 最大线程数 = 核心 + 救急线程
long keepAliveTime, // ③ 空闲存活时间:只作用于【非核心】线程
TimeUnit unit, // ④ 时间单位
BlockingQueue<Runnable> workQueue, // ⑤ 任务阻塞队列:【必须有界】
ThreadFactory threadFactory, // ⑥ 线程工厂:自定义线程名,排查问题的关键
RejectedExecutionHandler handler) // ⑦ 拒绝策略text
execute(task) 的四级流水线(面试必背,顺序千万不能错):
任务进来
│
▼
[1] 工作线程数 < corePoolSize ? ── 是 ──► 新建核心线程立刻执行
│ 否
▼
[2] 阻塞队列没满 ? ── 是 ──► 入队排队(等核心线程空闲自取)
│ 否
▼
[3] 线程数 < maximumPoolSize ? ── 是 ──► 新建非核心(救急)线程执行
│ 否
▼
[4] 执行拒绝策略 handler
最容易答错的点:是【先入队,队列满了才扩线程】,
不是「先把线程开到 max 再入队」。所以用无界队列时,
maximumPoolSize 这个参数【永远不会生效】。阻塞队列的选择(决定线程池的行为特征):
| 队列 | 有界 | 特点 | 配套场景 |
|---|---|---|---|
ArrayBlockingQueue | ✅ | 数组、定长、一把锁 | 生产首选,可控且会触发扩线程 |
LinkedBlockingQueue | 默认无界 | 链表、读写两把锁、吞吐更高 | 必须显式传容量,否则 OOM |
SynchronousQueue | 容量 0 | 不存任务,直接手递手交给线程 | newCachedThreadPool 用它 |
PriorityBlockingQueue | 无界 | 按优先级出队 | 任务有优先级时 |
DelayedWorkQueue | 无界 | 按到期时间出队 | ScheduledThreadPoolExecutor |
LinkedTransferQueue | 无界 | transfer() 等消费者取走才返回 | 高吞吐消息传递 |
四种拒绝策略:
| 策略 | 行为 | 适用 |
|---|---|---|
AbortPolicy(默认) | 抛 RejectedExecutionException | 核心业务,必须让上层感知失败 |
CallerRunsPolicy | 由提交任务的线程自己执行 | 需要反压、不允许丢任务(强烈推荐) |
DiscardPolicy | 静默丢弃 | 可丢的埋点、日志上报 |
DiscardOldestPolicy | 丢掉队列里最老的任务,再尝试提交 | 只关心最新数据(实时行情、最新快照) |
| 自定义 | 实现 RejectedExecutionHandler,落库/告警/降级 | 生产最常用:记日志 + 打点 + 转存 MQ |
线程池的 5 种生命周期状态(存在 ctl 这一个 AtomicInteger 的高 3 位):
text
ctl = 高 3 位 runState + 低 29 位 workerCount(一个变量管两件事,CAS 一次搞定)
RUNNING (-1 << 29) 接收新任务 + 处理队列任务
│ shutdown() │ shutdownNow()
▼ ▼
SHUTDOWN (0) STOP (1 << 29)
不收新任务,继续处理完队列 不收新任务,【清空队列 + interrupt 所有线程】
│ │
└──────► 队列空且线程数为 0 ◄──────┘
▼
TIDYING (2 << 29) ──► 执行 terminated() 钩子 ──► TERMINATED (3 << 29)| 方法 | 行为 |
|---|---|
shutdown() | 优雅关闭:不收新任务,已入队任务照常执行完 |
shutdownNow() | 强制关闭:返回未执行的任务列表,并 interrupt() 所有工作线程 |
awaitTermination(t, unit) | 阻塞等待关闭完成,返回是否在超时内完成 |
isShutdown() | 是否已调用过 shutdown(不代表任务跑完) |
isTerminated() | 是否所有任务都已结束 |
java
// 生产级优雅关闭模板(Spring 里一般交给容器,但原理要会)
pool.shutdown();
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
List<Runnable> undone = pool.shutdownNow(); // 兜底强制停
log.warn("强制关闭,剩余未执行任务数={}", undone.size());
pool.awaitTermination(10, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt(); // 补回中断标记
}为什么禁用 Executors(阿里规约强制项):
Executors 工厂方法 | 等价参数 | 风险 |
|---|---|---|
newFixedThreadPool(n) | core = max = n,LinkedBlockingQueue 无界 | 任务堆积 → OOM |
newSingleThreadExecutor() | core = max = 1,无界队列 | 同上,且单线程阻塞会让队列无限涨 |
newCachedThreadPool() | core = 0,max = Integer.MAX_VALUE,SynchronousQueue | 线程数无限 → 创建几万线程 OOM |
newScheduledThreadPool(n) | max = Integer.MAX_VALUE,DelayedWorkQueue 无界 | 队列无界 → OOM |
newWorkStealingPool() | ForkJoinPool,守护线程 | JVM 退出不等任务跑完 |
结论: 生产环境一律手动
new ThreadPoolExecutor,四个硬性要求:队列必须有界、必须自定义线程名(ThreadFactoryBuilder或guava/hutool的工厂,否则pool-1-thread-5这种名字排查问题等于盲查)、必须选择合适的拒绝策略、必须接监控(getActiveCount、getQueue().size()、getCompletedTaskCount、拒绝次数打点)。
13. ThreadLocal:线程本地变量
ThreadLocal 提供线程私有的变量副本,让每个线程访问同一个 ThreadLocal 对象时拿到互不干扰的值,是典型的空间换线程安全(与 synchronized 的"时间换安全"相反)。
核心结构(关键:Map 挂在 Thread 上,不在 ThreadLocal 上):
text
Thread(线程对象)
└── threadLocals ──> ThreadLocalMap
├── Entry[0]: key=ThreadLocal-A(弱引用) → value=Object
├── Entry[1]: key=ThreadLocal-B(弱引用) → value=Object
└── Entry[n]: ...
多个线程 → 各自持有一个 ThreadLocalMap → 天然隔离java
public class Thread {
// 包级可见,由 ThreadLocal 维护
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}get/ set 源码骨架:
java
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // 取 t.threadLocals
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this); // this 即 ThreadLocal 作为 key
if (e != null) return (T) e.value;
}
return setInitialValue(); // 调 initialValue(),默认 null
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) map.set(this, value);
else createMap(t, value);
}ThreadLocalMap 的四个设计细节:
| 细节 | 实现 | 原因 |
|---|---|---|
| 哈希算法 | key.threadLocalHashCode & (len - 1),每次 new ThreadLocal 自增魔数 0x61c88647 | 斐波那契散列,使下标在 2 的幂长度数组上均匀分布 |
| 冲突解决 | 开放地址法(线性探测下一个槽),不是链表 | Entry 数量通常极少(一个线程往往只有几个 ThreadLocal),链表的指针开销不值得 |
| 扩容阈值 | size >= len * 2/3 时先 rehash 清理,真实超过 3/4 再两倍扩容 | 开放地址法必须保持低装载因子,否则探测退化为 O(n) |
| key 为弱引用 | Entry extends WeakReference<ThreadLocal<?>> | 让"没人再引用的 ThreadLocal"能被 GC 回收,避免 key 泄漏 |
内存泄漏链路(高频面试点):
text
栈: ThreadLocal 强引用 tl ──┐
├──> ThreadLocal 对象
Thread.threadLocals ──> Entry.key(弱引用) ──┘
Entry.value ← 强引用 ← Entry ← ThreadLocalMap ← Thread
当 tl = null 之后:
key 被 GC 回收 → Entry.key == null(变成"脏 Entry")
value 仍被 Entry 强引用 → 只要线程不死,value 永远回收不掉- 根因:key 是弱引用、value 是强引用,引用链为
Thread → ThreadLocalMap → Entry → value。 - 线程池场景被放大:核心线程永不销毁,
Thread对象生命周期等于应用生命周期,脏 Entry 的 value 就一直活着 → 累积成 OOM。 - JDK 的补救:
get/set/remove内部会调用expungeStaleEntry()顺带清理部分key == null的 Entry,但这是启发式、不保证的(只扫描探测路径上的槽位)。 - 唯一可靠解法:
try { ... } finally { threadLocal.remove(); }。
java
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
public void handle(Request req) {
CTX.set(new UserContext(req.getUserId(), req.getTenantId()));
try {
bizService.process(); // 调用链深处用 CTX.get() 取用户上下文
} finally {
CTX.remove(); // 必须!线程池复用前把本次请求的痕迹擦干净
}
}InheritableThreadLocal:父子线程传递
java
static final ThreadLocal<String> TL = new ThreadLocal<>();
static final ThreadLocal<String> ITL = new InheritableThreadLocal<>();
public static void main(String[] args) throws Exception {
TL.set("parent");
ITL.set("parent");
Thread child = new Thread(() -> {
System.out.println(TL.get()); // null —— 普通 ThreadLocal 不传递
System.out.println(ITL.get()); // parent —— 可继承
});
child.start();
child.join();
}原理:Thread 构造方法里有一句 if (parent.inheritableThreadLocals != null) this.inheritableThreadLocals = createInheritedMap(parent.inheritableThreadLocals);,即只在 new Thread() 那一刻复制一份父线程的 Map。
所以 InheritableThreadLocal 在线程池下失效:线程是提前创建/复用的,复制动作早在提交任务之前就发生了,拿到的是"创建线程时那个提交者"的上下文——很可能是别的请求的数据,这比丢失更危险。
| 方案 | 新建线程 | 线程池复用 | 实现要点 |
|---|---|---|---|
ThreadLocal | 不传递 | 不传递 | 完全隔离 |
InheritableThreadLocal | ✅ 传递 | ❌ 错乱 | 仅在 new Thread 时快照父线程 Map |
TransmittableThreadLocal(阿里 TTL) | ✅ 传递 | ✅ 正确 | 包装 Runnable/Executor,在任务提交时抓快照、执行前 replay、执行后 restore |
java
// TTL 用法:装饰线程池即可,业务代码零侵入
ExecutorService pool = TtlExecutors.getTtlExecutorService(bizPool);
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal<>();典型应用场景: 用户登录上下文(UserContextHolder)、链路追踪 traceId(MDC 底层就是 ThreadLocal<Map>)、数据库读写分离的数据源路由标记、Spring 事务的 TransactionSynchronizationManager、SimpleDateFormat 等非线程安全对象的线程私有化(JDK8 后应直接用 DateTimeFormatter)。
14. 阻塞队列家族与 CompletableFuture 编排
BlockingQueue 四组方法的行为差异(必背):
| 操作 | 抛异常 | 返回特殊值 | 阻塞 | 超时阻塞 |
|---|---|---|---|---|
| 入队 | add | offer | put | offer(e, time, unit) |
| 出队 | remove | poll | take | poll(time, unit) |
| 查看 | element | peek | — | — |
生产代码优先用带超时的
offer/poll:put/take无限阻塞,一旦消费端挂掉会让生产线程全部卡死且无感知;add抛IllegalStateException不便处理。
ArrayBlockingQueue 核心实现(一把锁 + 两个条件变量):
java
final ReentrantLock lock = new ReentrantLock(fair);
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
public void put(E e) throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == items.length) notFull.await(); // while 防虚假唤醒
enqueue(e); // 入队后唤醒消费者
} finally { lock.unlock(); }
}
public E take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == 0) notEmpty.await();
return dequeue();
} finally { lock.unlock(); }
}LinkedBlockingQueue 则是两把锁(putLock + takeLock)+ AtomicInteger count,头尾分离因此生产消费可以真正并行,吞吐更高;代价是 size() 等需要同时持有两把锁的操作更贵。
CompletableFuture 常用编排 API:
| 方法 | 入参 / 返回 | 用途 |
|---|---|---|
supplyAsync(Supplier) | 无参 → 有返回值 | 异步执行并产出结果 |
runAsync(Runnable) | 无参 → 无返回值 | 纯异步执行 |
thenApply / thenApplyAsync | T → U | 结果转换(串行依赖) |
thenAccept / thenRun | T → void / 无 → void | 消费结果 / 收尾动作 |
thenCompose | T → CompletableFuture<U> | 扁平化嵌套 Future(类似 flatMap) |
thenCombine | 两个 Future → 合并 | 两个互不依赖的任务结果合并 |
allOf / anyOf | 多个 Future | 全部完成 / 任一完成 |
exceptionally | Throwable → T | 异常兜底返回默认值 |
handle / whenComplete | (result, ex) | 无论成败都执行;handle 可改返回值 |
orTimeout / completeOnTimeout | JDK 9+ | 超时抛异常 / 超时给默认值 |
java
// 聚合查询:三个 RPC 并行,合并成一个 VO
CompletableFuture<User> uf = CompletableFuture.supplyAsync(() -> userApi.get(id), bizPool);
CompletableFuture<Order> of = CompletableFuture.supplyAsync(() -> orderApi.list(id), bizPool);
CompletableFuture<Coupon> cf = CompletableFuture.supplyAsync(() -> couponApi.list(id), bizPool)
.completeOnTimeout(Coupon.empty(), 200, TimeUnit.MILLISECONDS) // 弱依赖降级
.exceptionally(ex -> Coupon.empty());
DetailVO vo = CompletableFuture.allOf(uf, of, cf)
.thenApply(v -> new DetailVO(uf.join(), of.join(), cf.join()))
.get(500, TimeUnit.MILLISECONDS); // 整体兜底超时三个必须记住的用法要点:
- 一定传自定义线程池。不传则用
ForkJoinPool.commonPool(),其并行度为CPU 核数 - 1,是全 JVM 共享的,一个慢 IO 任务就能把整个应用的所有异步编排拖死。 allOf返回CompletableFuture<Void>,拿不到结果,必须在thenApply里用join()逐个取;且allOf不会因为某个子任务异常而立即失败,异常会在join()时抛出。get()抛受检异常、join()抛运行时异常(CompletionException包裹原始异常);在 lambda 内部只能用join()。
✨ 高级深挖知识点
1. wait / notify / sleep / join / park 终极对比
| 维度 | Object.wait() | Thread.sleep() | Thread.join() | LockSupport.park() |
|---|---|---|---|---|
| 所属 | Object 实例方法 | Thread 静态方法 | Thread 实例方法 | LockSupport 静态方法 |
| 是否释放锁 | 释放监视器锁 | 不释放任何锁 | 释放的是 Thread 对象锁(内部就是 wait) | 不释放任何锁 |
| 前置条件 | 必须在 synchronized 块内,否则 IllegalMonitorStateException | 任意位置 | 任意位置 | 任意位置 |
| 唤醒方式 | notify / notifyAll / 超时 | 超时自动 | 目标线程结束 / 超时 | unpark(thread) / 中断 |
| 线程状态 | WAITING / TIMED_WAITING | TIMED_WAITING | WAITING / TIMED_WAITING | WAITING / TIMED_WAITING |
| 响应中断 | 是,抛 InterruptedException | 是,抛异常并清标志 | 是 | 是,但不抛异常,park 直接返回且不清除中断标志 |
| 许可语义 | 无 | 无 | 无 | 有一个二元许可,unpark 可先于 park 调用 |
park / unpark 相对 wait / notify 的两个优势:
- 不要求持有锁,编程模型更自由(AQS 正是基于它实现的)。
- 许可可以提前发放:先
unpark再park不会阻塞;而先notify再wait会永久错失信号导致死等。
java
// unpark 先调用也安全
Thread t = new Thread(() -> {
System.out.println("before park");
LockSupport.park(); // 已有许可,直接消耗掉并返回
System.out.println("after park");
});
LockSupport.unpark(t); // 甚至可以在 start 前发许可
t.start();注意:
park的许可不可累积,连续两次unpark只等于一个许可,所以后续第二次park依然会阻塞。
2. 虚假唤醒与条件谓词:为什么 wait 必须写在 while 里
虚假唤醒(spurious wakeup) 指线程在没有任何线程调用 notify 的情况下也可能从 wait 返回(POSIX规范允许,JVM 在部分平台上存在)。更现实的场景是:notifyAll 唤醒了全部等待者,但条件只够满足其中一个。
java
// ❌ 错误:if 判断
synchronized (lock) {
if (queue.isEmpty()) {
lock.wait();
}
Object item = queue.remove(0); // 可能 NPE / IndexOutOfBounds
}
// ✅ 正确:while 循环重新检查条件谓词
synchronized (lock) {
while (queue.isEmpty()) {
lock.wait(); // 被唤醒后回到 while 再判一次
}
Object item = queue.remove(0);
}根因链路: wait 返回只意味着"你被唤醒了并且重新抢到了锁",不意味着条件成立。两个消费者同时被 notifyAll 唤醒,A 先抢到锁消费了唯一的元素,B 再抢到锁时队列已空——用 if 就会越界。
JDK 规范级结论:
wait必须永远出现在循环中,循环条件是条件谓词;Condition.await()同理。JDK 文档原话:"waitshould always be used in a loop"。
notify vs notifyAll: notify 只随机唤醒一个等待者,若唤醒的恰好是"不满足条件"的那个(比如队列满时唤醒了另一个生产者),就会造成信号丢失 + 全员死等。所以默认用 notifyAll;只有在"所有等待者等的是同一个条件且唤醒一个就够"时才敢用 notify。ReentrantLock 的 Condition 则允许按条件分组(notFull / notEmpty 两个队列),可以精准 signal,这是它相对 synchronized 的核心优势。
3. JVM 的锁优化:偏向锁、轻量级锁、自旋、锁消除、锁粗化
JDK 6 之后 synchronized 性能大幅提升,靠的是这一整套优化:
| 优化 | 触发时机 | 原理 |
|---|---|---|
| 偏向锁 | 只有一个线程访问同步块 | Mark Word 记下线程 ID,后续进入只做一次 CAS 比对,无原子操作 |
| 轻量级锁 | 有竞争但不激烈,交替执行 | 线程栈里建 Lock Record,CAS 把 Mark Word 指向它,失败则自旋 |
| 自适应自旋 | 轻量级锁 CAS 失败 | 先忙等若干次而不挂起;自旋次数由上次在同一锁上自旋成功率动态调整 |
| 重量级锁 | 自旋失败 / 竞争激烈 | 膨胀为 ObjectMonitor,线程进内核态挂起(BLOCKED) |
| 锁消除 | JIT 逃逸分析证明对象不会逃逸 | 直接把 monitorenter / monitorexit 删掉 |
| 锁粗化 | 连续多次对同一锁加解锁 | 把多个小同步块合并成一个大的,减少加解锁次数 |
锁消除实例(高频考点):
java
public String concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // 局部变量,不逃逸
sb.append(a); // append 是 synchronized 方法
sb.append(b);
return sb.toString(); // JIT 逃逸分析 → 锁被完全消除
}因此"StringBuffer 一定比 StringBuilder 慢"在 JIT 优化后并不绝对成立;但代码可读性上仍应优先 StringBuilder。
锁粗化实例:
java
// 源码:循环内 100 次加解锁
for (int i = 0; i < 100; i++) {
synchronized (lock) { count++; }
}
// JIT 可能粗化为:
synchronized (lock) {
for (int i = 0; i < 100; i++) { count++; }
}可用
-XX:+PrintCompilation、-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining观察 JIT 行为;-XX:-EliminateLocks可关闭锁消除做对比实验。
偏向锁为何在 JDK 15 被默认关闭(JEP 374):
- 撤销成本高:偏向锁撤销需要到达安全点并 STW,一旦出现竞争,批量撤销会造成明显停顿。
- 收益场景萎缩:现代应用普遍用线程池 + JUC,"单线程独占同一个锁"的场景大幅减少。
- 维护代价大:偏向锁逻辑渗透到 JVM 大量代码路径里,阻碍其他优化(如 Lilliput 压缩对象头)。
所以在 JDK 15+ 上回答锁升级,务必加一句"JDK 15 已默认禁用偏向锁,JDK 18 彻底移除,现在是无锁 → 轻量级 → 重量级三级",这是区分「背八股」和「真的看过版本变更」的分水岭。
4. 用 JOL 实测对象头:把锁升级"看见"
xml
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>java
public class JolDemo {
static final Object LOCK = new Object();
public static void main(String[] args) throws Exception {
// JDK8 需加 -XX:BiasedLockingStartupDelay=0,否则启动 4s 内不开启偏向锁
System.out.println("1. 无锁:");
System.out.println(ClassLayout.parseInstance(LOCK).toPrintable());
synchronized (LOCK) { // 单线程 → 偏向锁
System.out.println("2. 偏向锁(JDK8)/ 轻量级锁(JDK15+):");
System.out.println(ClassLayout.parseInstance(LOCK).toPrintable());
}
Thread t = new Thread(() -> {
synchronized (LOCK) { // 另一个线程来抢 → 升级
System.out.println("3. 竞争后:");
System.out.println(ClassLayout.parseInstance(LOCK).toPrintable());
}
});
t.start();
t.join();
}
}读 Mark Word 末两位(64 位 JVM,小端输出要从右往左读):
| 末 3 位(biased_lock + lock) | 状态 | 其余位含义 |
|---|---|---|
0 01 | 无锁 | 31 位 hashCode + 4 位分代年龄 |
1 01 | 偏向锁 | 54 位线程 ID + epoch |
0 00 | 轻量级锁 | 62 位指向线程栈 Lock Record 的指针 |
0 10 | 重量级锁 | 62 位指向 ObjectMonitor 的指针 |
0 11 | GC 标记 | — |
两个实验结论(面试可直接当亮点讲):
- 调用
hashCode()会直接让偏向锁失效。因为 Mark Word 里放线程 ID 的位置正是 hashCode 的位置,二者空间互斥,一旦算出 identity hashCode 就只能降级走轻量级/重量级锁。 - 锁升级不可逆(单向),但重量级锁在没有竞争且 ObjectMonitor 空闲时可以通过 deflation(JDK 15+ 的
-XX:+UseObjectMonitorTable相关优化)归还,日常面试只需答"不可降级"。
5. volatile 的底层实现:内存屏障与 lock 前缀指令
JMM 层面:编译器在字节码/机器码中插入四类屏障
| 屏障类型 | 作用 | volatile 插入位置 |
|---|---|---|
LoadLoad | 禁止后面的普通读重排到前面的 volatile 读之前 | volatile 读之后 |
LoadStore | 禁止后面的普通写重排到前面的 volatile 读之前 | volatile 读之后 |
StoreStore | 保证前面所有普通写先刷新,再执行 volatile 写 | volatile 写之前 |
StoreLoad | 全能屏障(开销最大),保证 volatile 写对后续读可见 | volatile 写之后 |
text
普通写 普通写
--- StoreStore 屏障 ---
volatile 写
--- StoreLoad 屏障 --- ← 开销最大的一道,x86 用 lock 前缀实现
volatile 读
--- LoadLoad 屏障 ---
--- LoadStore 屏障 ---
普通读 普通写CPU 层面(x86): 用 hsdis 反汇编可以看到 volatile 写被编译成带 lock 前缀的指令(如 lock addl $0x0,(%rsp))。lock 前缀做了三件事:
- 锁定缓存行(现代 CPU 用缓存锁而非总线锁);
- 把当前 CPU 的 store buffer 全部写回主内存(或 L3),使修改对其他核可见;
- 触发 MESI 协议的失效消息,让其他核的该缓存行标记为 Invalid,下次读必须重新加载。
lock addl $0x0,(%rsp)是对栈顶做一次"加 0"的空操作,目的纯粹是借lock前缀的内存屏障语义,这是 x86 上实现StoreLoad屏障的惯用技巧(比mfence在部分 CPU 上更快)。
x86 的"强内存模型"带来的误区: x86 本身只允许 StoreLoad 重排(TSO 模型),所以在 x86 上漏写 volatile 的 bug 常常测不出来,但在 ARM(如 Apple Silicon、鲲鹏服务器)这种弱内存模型机器上就会暴露。不要用"我本地跑了一万次没问题"来证明并发正确性。
6. DCL 单例为什么必须加 volatile
java
public class Singleton {
private static volatile Singleton instance; // volatile 不可省
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:无锁快速路径
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
}new Singleton() 实际是三条字节码指令,不是原子操作:
text
① 分配内存空间 (new)
② 调用构造方法初始化对象 (invokespecial <init>)
③ 把引用赋给 instance (putstatic)
JIT / CPU 允许 ② 和 ③ 重排序 → 变成 ① ③ ②
线程 A: ① 分配内存 → ③ instance 已非 null(但对象还没初始化!)
线程 B: 第一次检查 instance != null → 直接 return
→ 拿到一个"半初始化对象",访问字段全是默认值 → NPE / 脏数据volatile通过StoreStore屏障禁止 ② ③ 重排,保证引用可见时对象已完全构造;同时保证线程 B 读到的是最新值(可见性)。- 只加
synchronized不够:synchronized只保证临界区内的原子性与进出时的可见性,并不禁止临界区内部的指令重排;线程 B 的第一次检查是在锁外读的,没有任何同步语义。
五种单例写法的取舍:
| 写法 | 线程安全 | 懒加载 | 防反射/序列化破坏 | 评价 |
|---|---|---|---|---|
| 饿汉式(静态字段) | ✅ 类加载保证 | ❌ | ❌ | 简单,但类加载即创建 |
| 懒汉 + 方法加锁 | ✅ | ✅ | ❌ | 每次 getInstance 都加锁,性能差 |
| DCL + volatile | ✅ | ✅ | ❌ | 通用最优解 |
| 静态内部类 | ✅ 类加载保证 | ✅ | ❌ | 最优雅,推荐(无锁 + 懒加载) |
| 枚举 | ✅ | ❌ | ✅ 天然防御 | 《Effective Java》首推 |
java
// 静态内部类:JVM 类加载机制保证 Holder 只初始化一次,天然线程安全且懒加载
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() { return Holder.INSTANCE; }
}7. CAS 三大问题的工程解法
问题一:ABA
java
// 线程 A 读到 100,准备 CAS(100 → 200)
// 线程 B 把 100 → 50 → 100
// 线程 A 的 CAS 成功了,但中间状态被完全忽略ABA 在数值累加场景无害,但在链表/栈的无锁结构里是致命的(节点被摘除又放回,指针已失效)。解法是加版本号:
java
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
int[] stampHolder = new int[1];
Integer cur = ref.get(stampHolder);
int stamp = stampHolder[0];
// 期望值 + 期望版本号都匹配才成功
boolean ok = ref.compareAndSet(cur, 200, stamp, stamp + 1);| 类 | 携带信息 | 适用场景 |
|---|---|---|
AtomicStampedReference | int 版本戳 | 需要知道改了几次 |
AtomicMarkableReference | boolean 标记位 | 只关心有没有被改过,更省 |
问题二:自旋开销
高竞争下 CAS 大量失败重试,空转烧 CPU。解法:
- 换成阻塞锁(
ReentrantLock,竞争激烈时挂起比自旋更省); - 用
LongAdder分段降低竞争(见下节); - JDK 9+ 在自旋循环中调用
Thread.onSpinWait()(编译为 x86 的pause指令),降低功耗并缓解内存序惩罚。
问题三:只能保证单个变量原子性
解法:
AtomicReference+ 不可变对象,把多个字段打包成一个对象整体 CAS;- 实在不行就用锁。
java
// 把两个字段打包成不可变对象,一次 CAS 更新
record Balance(long available, long frozen) {}
AtomicReference<Balance> ref = new AtomicReference<>(new Balance(100, 0));
Balance prev, next;
do {
prev = ref.get();
next = new Balance(prev.available() - 10, prev.frozen() + 10);
} while (!ref.compareAndSet(prev, next));8. LongAdder 原理:分段 CAS + 伪共享消除
AtomicLong 在高并发下所有线程 CAS 同一个 value,失败率随线程数急剧上升。LongAdder 的思路是空间换时间:把一个热点变量拆成多个。
text
LongAdder
├── base(无竞争时直接 CAS 这里)
└── Cell[] cells(有竞争时才初始化,长度为 2 的幂,最大 ≈ CPU 核数)
├── Cell[0].value ← 线程 T1 按 probe 哈希命中
├── Cell[1].value ← 线程 T2
└── ...
sum() = base + Σ cells[i].valuejava
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
if ((cs = cells) != null || !casBase(b = base, b + x)) { // ① 先试 base
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0
|| (c = cs[getProbe() & m]) == null // ② 按线程 probe 定位 Cell
|| !(uncontended = c.cas(v = c.value, v + x))) { // ③ CAS 自己的 Cell
longAccumulate(x, null, uncontended); // ④ 扩容 / 重散列 / 初始化
}
}
}四个关键设计:
getProbe()读的是Thread.threadLocalRandomProbe,相当于线程的哈希值;CAS 失败时调用advanceProbe()换一个槽再试(而不是死磕同一个 Cell)。cells懒初始化:没有竞争时只用base,退化为AtomicLong,零额外内存。- 扩容上限≈ CPU 核数:再多也没意义,因为同时真正并行的线程数就那么多。
@sun.misc.Contended消除伪共享:
java
@sun.misc.Contended static final class Cell {
volatile long value;
// ...
}伪共享(False Sharing)是什么: CPU 缓存以缓存行(通常 64 字节)为单位加载。两个毫不相关的变量若落在同一缓存行,一个核改 A 会让另一个核缓存的 B 整行失效(MESI 失效风暴),性能可能差 5~10 倍。Cell 只有 8 字节,8 个 Cell 就会挤在一行里——@Contended 通过填充到独占一个缓存行来解决(需 -XX:-RestrictContended 才能在用户代码使用,JDK 内部类默认生效)。
选型结论:
| 场景 | 选择 | 理由 |
|---|---|---|
| 低并发 / 需要精确的 CAS 语义 | AtomicLong | incrementAndGet 返回值精确,compareAndSet 可用 |
| 高并发纯计数(QPS、埋点) | LongAdder | 吞吐高一个量级 |
| 需要实时精确总数 | AtomicLong | LongAdder.sum() 不是原子快照,统计期间有写入会不准 |
9. AQS 源码精读:acquire 的完整链路
java
public final void acquire(int arg) {
if (!tryAcquire(arg) && // ① 子类实现,抢一次
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // ② 入队 → ③ 排队自旋
selfInterrupt(); // ④ 补上被吞掉的中断
}② addWaiter:CAS 入队(尾分叉问题)
java
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
for (;;) {
Node t = tail;
if (t == null) { // 队列为空,先 CAS 建哨兵头节点(懒初始化)
if (compareAndSetHead(new Node())) tail = head;
} else {
node.prev = t; // ★ 先设 prev(此时 t.next 还没指向 node)
if (compareAndSetTail(t, node)) {
t.next = node; // ★ 再设 next,这两步不是原子的
return node;
}
}
}
}这就是"AQS 为什么从
tail往前遍历"的答案:compareAndSetTail成功到t.next = node之间存在窗口,此刻从head向后遍历会漏掉新节点,但node.prev一定已经设好,所以unparkSuccessor里从tail反向找最靠前的有效节点才是安全的。
③ acquireQueued:自旋 + 挂起
java
final boolean acquireQueued(Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {
Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 只有"老二"才有资格抢锁
setHead(node); // 抢到 → 自己变哨兵头,断开旧 head
p.next = null; // 帮助 GC
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt(); // 真正挂起
}
} catch (Throwable t) {
cancelAcquire(node); // 异常时把节点标记 CANCELLED 并摘除
throw t;
}
}shouldParkAfterFailedAcquire:挂起前的三种处理
java
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) return true; // 前驱承诺会唤醒我 → 可以安心睡
if (ws > 0) { // 前驱是 CANCELLED(1) → 向前跳过
do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL); // 把前驱改成 SIGNAL,下轮再判
}
return false; // 返回 false → 再自旋一次确认
}三个必须记住的点: ① 挂起前至少多试一轮(避免"刚好锁已释放却白白挂起"的开销);②
waitStatus设在前驱节点上,表示"我有责任唤醒后继";③CANCELLED节点在这里被顺手清理,AQS 没有独立的清理线程。
release:唤醒后继
java
public final boolean release(int arg) {
if (tryRelease(arg)) { // 子类实现,state 归零才算真正释放
Node h = head;
if (h != null && h.waitStatus != 0) unparkSuccessor(h);
return true;
}
return false;
}10. ReentrantLock:公平 / 非公平与 Condition 的双队列迁移
非公平 vs 公平的唯一代码差异:
java
// NonfairSync:直接插队试一次 CAS
final boolean nonfairTryAcquire(int acquires) {
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) { // ★ 不管队列里有没有人排队,直接抢
setExclusiveOwnerThread(current); return true;
}
} else if (current == getExclusiveOwnerThread()) {
setState(c + acquires); return true; // ★ 可重入:state 累加
}
return false;
}
// FairSync:多一个 hasQueuedPredecessors() 判断
protected final boolean tryAcquire(int acquires) {
if (c == 0) {
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { ... }
}
// ...
}| 维度 | 非公平(默认) | 公平 |
|---|---|---|
| 吞吐量 | 高 | 低(约差一个数量级) |
| 饥饿 | 可能(但实测概率低) | 不会 |
| 原因 | 省掉了"唤醒队首线程 → 上下文切换 → 它才开始跑"的真空期,让正在 CPU 上的线程直接拿锁 | 每次都必须唤醒队首,切换开销大 |
为什么非公平更快: 唤醒一个 park 的线程需要约几微秒的上下文切换,这段时间锁是空闲的;非公平让"恰好路过"的活跃线程立刻用上这段空窗期,整体吞吐更高。这叫锁的"插队红利"。
Condition 的条件队列与同步队列迁移(AQS 最精妙的部分):
text
await() 流程:
① 把当前线程包成 Node(CONDITION) 放入「条件队列」(单向链表 firstWaiter → lastWaiter)
② fullyRelease(savedState) —— ★ 完全释放锁(重入 N 次就一次性全放掉)
③ park 挂起,直到自己被转移到「同步队列」
④ 被唤醒后 acquireQueued(savedState) —— 重新抢锁并恢复原来的重入次数
signal() 流程:
① 取条件队列首节点
② transferForSignal:CAS 把 waitStatus 从 CONDITION 改为 0,
再 enq() 挂到「同步队列」尾部
③ 只有它在同步队列里排到队首并拿到锁,await() 才会真正返回三个必须记住的点:
signal()不等于立即运行,只是"从条件队列搬到同步队列",仍要排队抢锁——所以signal后应尽快unlock。fullyRelease保存并恢复state,这是Condition支持可重入的关键;若简单release(1)则重入计数会丢失。- 一个
Lock可以有多个Condition(notFull/notEmpty),精准唤醒;而synchronized只有一个隐式条件队列,只能notifyAll广播。
java
// 必须的写法:await 在 while 中,unlock 在 finally 中
lock.lock();
try {
while (!conditionHolds()) {
condition.await(); // 会抛 InterruptedException
}
doWork();
} finally {
lock.unlock(); // ★ 一定在 finally,否则异常后锁永久泄漏
}11. 线程池源码:ctl、Worker 与 getTask 的回收逻辑
ctl:一个 AtomicInteger 装两个状态
java
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // 29
private static final int CAPACITY = (1 << COUNT_BITS) - 1; // 低 29 位:最大线程数 ≈ 5 亿
private static int runStateOf(int c) { return c & ~CAPACITY; } // 高 3 位:运行状态
private static int workerCountOf(int c) { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }为什么合成一个变量? 为了用一次 CAS 同时修改"状态 + 线程数",避免两个变量之间出现不一致的中间态(比如状态已 SHUTDOWN 但线程数还在涨)。这是并发设计里"把多个需要同时变更的字段压进一个原子变量"的经典技巧。
Worker:自己就是一个 AQS
java
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
final Thread thread;
Runnable firstTask;
volatile long completedTasks;
Worker(Runnable firstTask) {
setState(-1); // ★ 禁止中断,直到 runWorker 开始
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}
protected boolean isHeldExclusively() { return getState() != 0; }
protected boolean tryAcquire(int unused) { return compareAndSetState(0, 1); }
}Worker继承 AQS 实现了一把不可重入的独占锁,用途是标记"我正在执行任务":shutdown()只中断空闲(能被tryLock成功)的 Worker,正在跑任务的不打扰;shutdownNow()则无视锁全部中断。- 构造时
setState(-1)是为了防止线程还没进入runWorker就被interruptIdleWorkers中断。
runWorker:Worker 的一生
java
final void runWorker(Worker w) {
Runnable task = w.firstTask; w.firstTask = null;
w.unlock(); // state 0 → 允许中断
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) { // ★ 核心循环
w.lock(); // 标记忙碌
try {
beforeExecute(w.thread, task); // 钩子:可做 MDC / 链路透传
task.run(); // ★ 直接 run(),不是 start()
afterExecute(task, null);
} finally {
task = null; w.completedTasks++; w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly); // 清理 + 必要时补充新 Worker
}
}getTask:线程回收的真正发生地
java
private Runnable getTask() {
boolean timedOut = false;
for (;;) {
int c = ctl.get();
// ① 状态校验:SHUTDOWN 且队列空,或 STOP → 线程数减一并退出
if (runStateAtLeast(c, SHUTDOWN) && (runStateAtLeast(c, STOP) || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
int wc = workerCountOf(c);
// ② 是否需要超时回收:超出核心数,或开启了 allowCoreThreadTimeOut
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c)) return null; // ★ 回收点
continue;
}
try {
// ③ 核心线程 take() 无限阻塞;非核心 poll(keepAlive) 超时返回 null
Runnable r = timed ? workQueue.poll(keepAliveTime, NANOSECONDS) : workQueue.take();
if (r != null) return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}三个必须记住的点:
- "核心线程不回收"的本质不是什么特殊标记,而是走了
take()这个无限阻塞分支;线程池里没有任何字段区分哪个线程是核心线程,只看"当前线程数是否超过corePoolSize"。 - 开启
allowCoreThreadTimeOut(true)后核心线程也会被回收,适合流量稀疏的定时任务池。 - 线程复用的关键是
task.run():Worker 线程只start()过一次,之后在while循环里不断取任务直接调用run(),所以同一个线程会串行执行成千上万个任务——这也是ThreadLocal必须remove的根本原因。
12. 线程池参数怎么定、怎么动态调、怎么监控
第一步:先分清任务类型
| 任务类型 | 特征 | 经验公式 |
|---|---|---|
| CPU 密集 | 加解密、压缩、复杂计算 | core = N + 1(+1 为防止偶发页缺失浪费 CPU) |
| IO 密集 | RPC、DB、文件、HTTP | core = N * 2 起步,或按下方公式精算 |
| 混合型 | 既算又调接口 | 拆成两个池,分别按上面两条配置 |
Little's Law 精算公式:
text
最佳线程数 = N核数 × 目标CPU利用率 × (1 + W/C)
W = 线程等待时间(IO 耗时),C = 线程计算时间(CPU 耗时)
例:8 核,目标利用率 80%,一次 RPC 等待 180ms、计算 20ms
→ 8 × 0.8 × (1 + 180/20) = 8 × 0.8 × 10 = 64但真正的工程答案是:公式只给初值,最终靠压测定。 面试时这样回答才完整:
"我会先按任务类型用公式估一个初值,然后用压测(JMeter / wrk)阶梯加压,观察 QPS、P99 耗时、CPU 使用率、队列积压、GC 频率这几个指标。QPS 不再上升而 P99 开始劣化的那个点,就是容量上限;线程数取拐点前的值。另外队列长度要按业务可接受的排队延迟反推:如果单任务 50ms、要求排队不超过 1s,队列就不该超过 20×线程数。"
第二步:动态调参(美团方案的核心思路)
ThreadPoolExecutor 的三个参数支持运行时修改,这是动态线程池的基础:
java
pool.setCorePoolSize(32); // ✅ 可动态改,变小时多余线程会被 interruptIdleWorkers 回收
pool.setMaximumPoolSize(64); // ✅ 可动态改
pool.setKeepAliveTime(30, TimeUnit.SECONDS); // ✅ 可动态改
pool.setRejectedExecutionHandler(newHandler); // ✅ 可动态改
// ❌ workQueue 的容量是 final,官方不支持改队列容量怎么动态改? 自定义一个 ResizableCapacityLinkedBlockingQueue:复制 LinkedBlockingQueue 源码,把 private final int capacity 改成 private volatile int capacity 并提供 setCapacity。美团的 DynamicTp、Hippo4j 都是这个思路。
java
// 接配置中心(Nacos / Apollo)监听,实现无需重启的调参
@RefreshScope
public void onConfigChange(PoolConfig cfg) {
pool.setCorePoolSize(cfg.getCore());
pool.setMaximumPoolSize(cfg.getMax());
queue.setCapacity(cfg.getQueueSize());
log.info("线程池参数已更新: {}", cfg);
}第三步:监控与告警(面试加分项)
java
// 定时上报 Micrometer / Prometheus
Gauge.builder("thread.pool.active", pool, ThreadPoolExecutor::getActiveCount).register(registry);
Gauge.builder("thread.pool.queue.size", pool, p -> p.getQueue().size()).register(registry);
Gauge.builder("thread.pool.completed", pool, ThreadPoolExecutor::getCompletedTaskCount).register(registry);
Gauge.builder("thread.pool.largest", pool, ThreadPoolExecutor::getLargestPoolSize).register(registry);| 指标 | 含义 | 告警阈值建议 |
|---|---|---|
getActiveCount() | 正在执行任务的线程数 | 持续 ≥ maximumPoolSize × 0.8 |
getQueue().size() | 队列积压 | ≥ 容量 × 0.8(最重要的先行指标) |
getLargestPoolSize() | 历史峰值线程数 | 等于 max 说明曾经打满 |
| 拒绝次数 | 自定义 Handler 里打点 | > 0 就应该告警 |
getCompletedTaskCount() | 已完成任务数 | 用于算吞吐(差值 / 时间) |
扩展
beforeExecute/afterExecute可以拿到更关键的两个指标:任务排队耗时(提交时间戳 → 开始执行时间戳)和执行耗时。排队耗时才是用户真正感知到的延迟来源。
13. 线程池的异常处理:submit 为什么"吞"异常
java
ExecutorService pool = Executors.newFixedThreadPool(1);
// ① execute:异常会打印堆栈,且该线程"死掉"并被替换为新线程
pool.execute(() -> { throw new RuntimeException("execute 异常"); });
// 控制台能看到 Exception in thread "pool-1-thread-1" ...
// ② submit:异常被包进 Future,不 get 就永远看不到 —— 最危险
Future<?> f = pool.submit(() -> { throw new RuntimeException("submit 异常"); });
// 控制台一片安静,任务静默失败
f.get(); // 这时才抛 ExecutionException,cause 是原始异常源码层面的原因:
java
// submit 把任务包成 FutureTask,run 里 catch 了所有 Throwable
public void run() {
try {
result = callable.call();
} catch (Throwable ex) {
setException(ex); // ★ 异常存进 outcome 字段,不往上抛
}
// ...
}
// 而 execute 的 runWorker 里异常会一路抛到 Thread.run 之外
// → 触发 UncaughtExceptionHandler → processWorkerExit 补一个新 Worker| 维度 | execute | submit |
|---|---|---|
| 返回值 | void | Future<T> |
| 异常是否可见 | ✅ 打印堆栈 / 触发 UEH | ❌ 静默,只有 get() 才抛 |
| 线程是否被销毁 | 是(随后补充新线程) | 否(FutureTask 吞掉了,线程继续复用) |
| 入参 | Runnable | Runnable / Callable |
四种正确的兜底方案:
java
// 方案1:任务内部 try-catch 全包(最推荐,异常处理贴近业务)
pool.execute(() -> {
try { bizService.doWork(); }
catch (Throwable t) { log.error("任务执行失败, bizId={}", id, t); }
finally { CTX.remove(); } // 顺手清理 ThreadLocal
});
// 方案2:ThreadFactory 里设 UncaughtExceptionHandler(只对 execute 生效)
ThreadFactory factory = r -> {
Thread t = new Thread(r, "biz-pool-" + idx.getAndIncrement());
t.setUncaughtExceptionHandler((th, e) -> log.error("线程异常退出: {}", th.getName(), e));
return t;
};
// 方案3:重写 afterExecute,统一拦截(对 execute 和 submit 都生效)
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
if (t == null && r instanceof Future<?> f && f.isDone()) {
try { f.get(); } // ★ 把 submit 吞掉的异常挖出来
catch (CancellationException ce) { /* 忽略 */ }
catch (ExecutionException ee) { t = ee.getCause(); }
catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}
if (t != null) log.error("任务异常", t);
}
// 方案4:CompletableFuture 用 exceptionally / whenComplete 兜底
CompletableFuture.supplyAsync(this::query, pool)
.exceptionally(ex -> { log.error("查询失败", ex); return fallback(); });线程池的 ThreadLocal 污染(和异常处理同源的坑):
text
请求 A → 线程 T1 → CTX.set(userA) → 业务异常抛出,没走到 remove
请求 B → 线程 T1(被复用!)→ CTX.get() → 拿到 userA
→ 越权读到别人的数据 / 日志 traceId 全乱 → 线上资损级事故解法只有两条:① 业务入口用 try-finally 保证 remove;② 在 afterExecute 里做统一兜底清理(框架层拦截,防止某个业务同学忘记)。
14. 死锁:四个必要条件、jstack 定位与破除策略
java
// 经典转账死锁:两个线程以相反顺序加锁
void transfer(Account from, Account to, long amount) {
synchronized (from) {
synchronized (to) { // 线程 A: lock(a)→lock(b);线程 B: lock(b)→lock(a)
from.debit(amount);
to.credit(amount);
}
}
}四个必要条件(缺一不可,破除任意一个即可解开):
| 条件 | 含义 | 破除手段 |
|---|---|---|
| 互斥 | 资源同一时刻只能被一个线程占用 | 几乎无法破除(锁的本质) |
| 占有且等待 | 持有 A 的同时去申请 B | 一次性申请全部资源(tryLock 全拿到才干活) |
| 不可抢占 | 已获得的资源不能被强行剥夺 | 用 tryLock(timeout),超时就主动释放已有锁 |
| 循环等待 | 形成环形等待链 | 按固定顺序加锁(如按对象 hashCode / 账号 ID 排序) |
java
// ✅ 解法一:固定加锁顺序(按唯一且稳定的 ID 排序)
void transferSafe(Account from, Account to, long amount) {
Account first = from.getId() < to.getId() ? from : to;
Account second = from.getId() < to.getId() ? to : from;
synchronized (first) {
synchronized (second) { from.debit(amount); to.credit(amount); }
}
}
// ✅ 解法二:tryLock + 超时 + 退避重试(破"不可抢占")
boolean transferTry(Account from, Account to, long amt) throws InterruptedException {
while (true) {
if (from.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (to.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try { from.debit(amt); to.credit(amt); return true; }
finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); }
}
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 50)); // ★ 随机退避防活锁
}
}注意:死锁解开后要防"活锁"——两个线程同时超时、同时重试、再次冲突,无限循环。所以重试前必须加随机退避(抖动),这和网络重试的指数退避是同一个思路。
jstack 定位死锁(线上实操流程):
bash
jps -l # ① 找到 Java 进程 pid
jstack -l 12345 > dump.txt # ② 导出线程快照(-l 显示锁的额外信息)
# ③ 搜索关键字 "Found one Java-level deadlock"text
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f... (object 0x000000076ab9, a Account),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x00007f... (object 0x000000076abd, a Account),
which is held by "Thread-1"
→ 下方 "Java stack information" 直接给出两个线程卡在哪一行代码四类线程问题的定位命令速查:
| 现象 | 定位手段 |
|---|---|
| 死锁 | jstack 搜 Found one Java-level deadlock;或 jconsole 的"检测死锁"按钮 |
| CPU 100% | top -Hp <pid> 找最高的线程 → printf "%x\n" <tid> 转 16 进制 → 在 jstack 里搜 nid=0x... |
大量 BLOCKED | jstack 统计 blocked on monitor entry 的数量,看是否都卡在同一把锁上(锁粒度过粗) |
| 线程数暴涨 | jstack 按线程名分组计数,定位是哪个池;常见于 newCachedThreadPool 或每次请求 new Thread |
bash
# Arthas 更高效(生产推荐)
thread -b # 一键找出阻塞其他线程的那个"罪魁祸首"线程
thread -n 3 # 列出最忙的 3 个线程及其栈
thread --state BLOCKED
ReentrantLock造成的死锁jstack检测不出来(它基于park,不是监视器锁,JVM 的死锁检测算法只认 monitor 和java.util.concurrent.locks.AbstractOwnableSynchronizer)。好消息是加-l参数后jstack会打印Locked ownable synchronizers,JDK 8+ 也能识别 AQS 死锁;但读写锁、Semaphore的信号量耗尽这类"逻辑死锁"仍需靠业务指标和线程栈人工判断。
15. CompletableFuture 的四个生产级坑
坑 1:默认线程池 commonPool 是全 JVM 共享的
java
// ❌ 不传池 → ForkJoinPool.commonPool(),并行度 = CPU核数 - 1
CompletableFuture.supplyAsync(() -> rpcCall()); // 一个慢 IO 拖垮全应用的异步编排
// ✅ 业务自己的池,并按业务域隔离(订单池、营销池互不影响)
CompletableFuture.supplyAsync(() -> rpcCall(), ORDER_POOL);更隐蔽的是:
commonPool的线程是守护线程,若main结束它们会被直接杀掉,任务可能没跑完就没了。
坑 2:thenApply vs thenApplyAsync 的执行线程不确定
| 方法 | 执行线程 |
|---|---|
thenApply | 不确定:前一步已完成则由调用 thenApply 的线程执行;未完成则由完成前一步的那个线程执行 |
thenApplyAsync | 明确提交到指定池(不传则 commonPool) |
这会导致偶发的线程切换,如果回调里依赖了 ThreadLocal(比如 MDC 的 traceId),就会出现"本地能复现、线上偶发丢 traceId"的诡异问题。涉及上下文时请显式用 ...Async(fn, pool)。
坑 3:异常不会自动传播到"兄弟"分支,且 allOf 不短路
java
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.join(); // ★ 若 f2 异常,这里抛 CompletionException;f1/f3 仍会跑完
// ✅ 正确:每个分支自己兜底,保证 allOf 永远不炸
CompletableFuture<Coupon> safe = couponFuture
.exceptionally(ex -> { log.warn("优惠券查询失败,降级", ex); return Coupon.empty(); });坑 4:没有超时控制会导致请求无限挂起
java
// JDK 8 只能靠 get(timeout),且不会取消上游任务
vo = future.get(500, TimeUnit.MILLISECONDS);
// JDK 9+ 提供了更好的 API
future.orTimeout(500, TimeUnit.MILLISECONDS) // 超时抛 TimeoutException
.completeOnTimeout(DEFAULT_VO, 500, TimeUnit.MILLISECONDS); // 超时给默认值三个必须记住的点: ① 永远传自定义池;② 涉及 ThreadLocal 一律用
Async显式指定池;③ 每个弱依赖分支都要有exceptionally+ 超时降级,否则异步聚合的可用性等于所有下游可用性的乘积。
16. 虚拟线程(JDK 21 正式版)与 pinning 问题
核心变化:线程不再是稀缺资源。
java
// 每个任务一个虚拟线程,百万级也扛得住(每个虚拟线程栈初始仅几百字节,存在堆上)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> { httpClient.send(req); return null; });
}
} // close() 会等所有任务结束(AutoCloseable)
// 单个创建
Thread vt = Thread.ofVirtual().name("vt-1").start(() -> doWork());
Thread pt = Thread.ofPlatform().name("pt-1").start(() -> doWork()); // 传统平台线程调度模型:
text
虚拟线程(Virtual Thread,百万个,JVM 管理,存在堆上)
↓ mount / unmount(挂载与卸载)
载体线程(Carrier Thread = ForkJoinPool 的平台线程,数量 ≈ CPU 核数)
↓ 1:1
OS 内核线程
遇到阻塞 IO 时:虚拟线程把栈帧拷到堆上并 unmount,
载体线程立刻去跑别的虚拟线程 → 零线程浪费pinning(钉住):虚拟线程最大的坑
某些情况下虚拟线程无法 unmount,会把载体线程一起阻塞住,等于退化成平台线程:
| 触发场景 | JDK 21 行为 | 应对 |
|---|---|---|
在 synchronized 块内阻塞 | pinned(载体被占住) | 换成 ReentrantLock |
| 执行 native 方法 / JNI 中阻塞 | pinned | 无法避免,减少此类调用 |
Object.wait() | pinned | 换 Condition.await() |
ReentrantLock / NIO / Thread.sleep | 正常 unmount | 推荐用法 |
JDK 24(JEP 491)已经解决了
synchronized的 pinning 问题,但在 21 这个 LTS 上回答时必须提到这个限制——这是真正用过虚拟线程的标志。诊断方式:-Djdk.tracePinnedThreads=full会打印被钉住的栈。
使用准则:
- 不要池化虚拟线程。它本身就是"廉价、一次性"的,用
newVirtualThreadPerTaskExecutor()而不是固定大小池;池化反而把并发度又限制回去了。 - 限流不再靠线程数,改用
Semaphore。以前"池子 50 个线程"天然就是对下游的限流;虚拟线程没有这个限制,必须显式加信号量,否则会把下游打崩。 - 只对 IO 密集有效。CPU 密集任务的瓶颈是核数,虚拟线程不会让计算变快。
ThreadLocal仍可用但要慎用:百万虚拟线程 × 每个一份副本 = 内存爆炸,JDK 21+ 推荐ScopedValue(JEP 429/446)。
17. ForkJoinPool 与工作窃取:并行流的隐患
text
传统线程池:所有线程抢一个全局队列 → 队列是竞争热点
ForkJoinPool:每个 Worker 一个双端队列(Deque)
Worker-1: [t1][t2][t3] ← 自己从队头 LIFO 取(缓存友好)
Worker-2: [] ← 空了,从 Worker-1 的队尾 FIFO "窃取"
(两端操作,几乎不冲突)ForkJoinTask 的分治模板:
java
class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000; // 阈值:太小则拆分开销大于收益
private final long[] arr; private final int lo, hi;
protected Long compute() {
if (hi - lo <= THRESHOLD) { // ① 小到一定程度直接算
long sum = 0;
for (int i = lo; i < hi; i++) sum += arr[i];
return sum;
}
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(arr, lo, mid);
SumTask right = new SumTask(arr, mid, hi);
left.fork(); // ② 异步执行左半
Long r = right.compute(); // ③ ★ 当前线程直接算右半,不要两次 fork
return left.join() + r; // ④ 合并
}
}
fork()+compute()比两次fork()+ 两次join()更优:减少一次任务入队和线程切换,这是 JDK 文档推荐的写法。
parallelStream 的三个坑(面试高频):
java
// ❌ 坑 1:默认用 commonPool,一个慢任务拖垮全应用所有并行流
list.parallelStream().map(this::rpcCall).collect(toList());
// ✅ 提交到自己的 ForkJoinPool 里执行(唯一的"换池"手段)
ForkJoinPool myPool = new ForkJoinPool(16);
List<R> r = myPool.submit(() -> list.parallelStream().map(this::rpcCall).collect(toList())).get();
// ❌ 坑 2:collect 到非线程安全容器
List<R> out = new ArrayList<>();
list.parallelStream().forEach(out::add); // ArrayList 非线程安全 → 丢数据 / 数组越界
// ✅ 用 collect(toList()) 或 Collections.synchronizedList / forEachOrdered
// ❌ 坑 3:对 IO 任务用并行流
// commonPool 并行度只有 N-1,IO 任务会把它占满,正确做法是用 CompletableFuture + 自定义池选型结论: 并行流只适合纯 CPU 计算 + 数据量大(万级以上)+ 无共享状态 + 无 IO 的场景;其余一律用 CompletableFuture + 自定义线程池。
18. 并发编程的工程准则(Code Review Checklist)
| # | 准则 | 反例 → 正解 |
|---|---|---|
| 1 | 线程 / 线程池必须有名字 | pool-1-thread-3 → order-query-pool-3 |
| 2 | 所有阻塞调用必须有超时 | queue.take() → queue.poll(1, SECONDS) |
| 3 | 锁的获取与释放必须 try-finally | lock(); doWork(); unlock(); → finally { unlock(); } |
| 4 | 锁的粒度尽量小、持有时间尽量短 | 同步块里做 RPC → 把 RPC 挪到锁外 |
| 5 | 绝不在持锁期间调用外部方法(含回调) | 可能导致未知的嵌套加锁 → 死锁 |
| 6 | 共享可变状态优先用不可变对象 | 可变 DTO 跨线程传递 → record / 加 final |
| 7 | 能用并发容器就不要自己加锁 | synchronized Map → ConcurrentHashMap |
| 8 | 复合操作要用原子方法 | if (!map.containsKey(k)) map.put(k,v) → putIfAbsent |
| 9 | ThreadLocal 必须 remove | 线程池下不清理 → 内存泄漏 + 用户数据串台 |
| 10 | 中断必须响应或重新设置标志 | 空 catch (InterruptedException e) {} → Thread.currentThread().interrupt() |
| 11 | 拒绝策略要有业务语义 | 默认 AbortPolicy 直接抛 → 落 MQ / 降级 / 打点告警 |
| 12 | 任务必须有兜底 try-catch | 异常静默丢失 → 统一日志 + 监控 |
| 13 | 不同业务用不同线程池隔离 | 一个池混用导致慢业务拖死快业务(舱壁模式) |
| 14 | 并发正确性靠推理,不靠测试 | "跑一万次没问题" 不能证明无 bug,x86 上尤其容易掩盖重排问题 |
19. 安全发布、this 逸出与 final 域的内存语义
前面讲 DCL 时反复强调"半初始化对象",但那只是一个特例。它背后的通用问题叫 安全发布(Safe Publication):把一个对象的引用交给其他线程时,怎么保证对方看到的是一个"完整"的对象。
text
对象创建在字节码层面是三步(可被重排):
① 分配内存 memory = allocate()
② 初始化对象 ctorInstance(memory) ← 填充字段
③ 把引用赋给变量 instance = memory ← 引用可见
重排成 ①③② 之后,别的线程通过 instance 拿到的是「字段还是默认值」的对象:
线程 A: ① → ③ ────────────────► ②
线程 B: 读 instance ≠ null → 读 name == null ❌ NPE / 脏数据为什么重排是合法的? 因为 JMM 只承诺 as-if-serial:编译器和 CPU 可以任意重排,只要单线程内的执行结果不变。② 和 ③ 之间没有数据依赖,单线程看不出差别,所以允许换序 —— 跨线程的"看起来不对"不在它的保证范围内,除非你用 volatile / 锁 / final 建立了 happens-before。
this 逸出(this escape):对象还没构造完,引用就跑出去了
java
// ❌ 坑 1:构造器里启动线程,并把 this 传进去
public class BadListener {
private final int id;
public BadListener(int id) {
new Thread(() -> System.out.println(this.id)).start(); // ★ 可能读到 0
this.id = id; // 赋值在启动之后
}
}
// ❌ 坑 2:构造器里注册自己到外部容器(最隐蔽的一种)
public class BadSubscriber {
private final Config config;
public BadSubscriber(EventBus bus) {
bus.register(this); // ★ 其他线程立刻可能回调到 this
this.config = loadConfig();// 回调时 config 还是 null
}
}
// ❌ 坑 3:构造器调用可被子类重写的方法
public class Base {
public Base() { init(); } // ★ 调用的是子类的 init
protected void init() {}
}
class Child extends Base {
private final List<String> items = new ArrayList<>();
@Override protected void init() { items.add("x"); } // ★ items 还是 null → NPE
}
// ✅ 正解:构造器只做字段赋值;需要启动线程/注册监听,拆出 start() / init() 由调用方显式触发
public final class GoodSubscriber {
private final Config config;
private GoodSubscriber(Config config) { this.config = config; }
public static GoodSubscriber create(EventBus bus, Config config) {
GoodSubscriber s = new GoodSubscriber(config); // 构造完全结束
bus.register(s); // 再发布
return s;
}
}注意坑 3 不是并发问题,单线程就会 NPE —— 但它和并发下的
this逸出是同一个根因:对象在"未完成状态"被使用。这也是为什么 Java 把"构造器里调用可重写方法"列为代码坏味道。
final 域的两条重排序规则(JSR-133 新增的内存语义)
text
规则 ①(写 final 域):
构造器内对 final 域的写 ───不允许重排到───► 构造器外(引用赋值)
⇒ 只要别的线程拿到了引用,final 域一定已经初始化完成
规则 ②(读 final 域):
读对象引用 ───不允许重排到───► 读该对象的 final 域之后
⇒ 不会出现「先读到字段、再读到引用」的诡异顺序
⚠️ 前提:构造期间 this 没有逸出。this 一逸出,两条规则同时失效!java
// ✅ final 让不可变对象天生可以安全发布,连 volatile 都不需要
public final class Snapshot {
private final long ts;
private final List<String> items; // 字段 final
public Snapshot(long ts, List<String> items) {
this.ts = ts;
this.items = List.copyOf(items); // ★ 防御性拷贝,否则外部还能改
}
public List<String> getItems() { return items; } // 返回的是不可变视图
}
static Snapshot current; // 不加 volatile 也不会读到半成品
// 但!引用本身的可见性仍不保证,别的线程可能长时间看不到新值 → 换引用仍需 volatile四种安全发布方式(《Java 并发编程实战》经典结论)
| 方式 | 原理 | 典型写法 |
|---|---|---|
| 静态初始化器中初始化 | JVM 类初始化锁 + happens-before | static final Holder H = new Holder(); |
引用存入 volatile / AtomicReference | volatile 写-读建立 happens-before | DCL 的 volatile instance |
引用存入正确构造对象的 final 域 | final 域重排序规则 | 不可变对象 record / 全 final 字段 |
| 引用由锁保护(读写都在同一把锁内) | 监视器锁规则 | synchronized 的 getter / setter |
线程安全的五个级别(从强到弱,判断一个类该怎么用)
| 级别 | 含义 | 例子 |
|---|---|---|
| 不可变 | 天生安全,无需任何同步 | String、Integer、record、List.of |
| 绝对线程安全 | 任意调用序列都安全(几乎不存在) | —— 理想概念 |
| 相对线程安全 | 单个方法安全,复合操作仍需自己同步 | ConcurrentHashMap、Vector |
| 线程兼容 | 本身不安全,但可以靠外部同步正确使用 | ArrayList、HashMap、SimpleDateFormat |
| 线程对立 | 无论怎么同步都不安全 | Thread.suspend/resume、System.setOut 混用 |
加粗要点: 安全发布的核心是"别人看到引用时,对象必须已经完整";
this逸出会让final的全部保证失效,所以构造器里不要启动线程、不要注册自己、不要调用可被重写的方法;不可变对象(全final+ 防御性拷贝)是成本最低的并发手段 —— 但它只保证"对象内容完整",换引用的可见性仍然要靠volatile,这两件事千万不要混为一谈。
20. Spring 场景的线程池:@Async / @Scheduled 的六个坑
前面讲的都是原生 ThreadPoolExecutor。但实际项目里 99% 的异步是写一个 @Async 注解完事 —— 而 Spring 的默认行为和你以为的完全不一样,这是线上事故的高发区。
坑 1:@Async 不配线程池 = 每次调用新建一个线程
java
// ❌ 什么都不配,直接用
@Async
public void sendMsg(Long uid) { ... }text
Spring 找异步执行器的顺序(AsyncExecutionAspectSupport#determineAsyncExecutor):
① 注解上指定的 beanName:@Async("bizPool")
② 容器里唯一的 TaskExecutor 类型 Bean
③ 名为 "taskExecutor" 的 Bean
④ 都没有 → SimpleAsyncTaskExecutor ★★★ 致命
└── 它根本不是线程池!每个任务 new 一个线程,不复用、无上限
并发一上来 → 线程数爆炸 → OOM: unable to create native threadSpring Boot 2.1+ 如果引入了
spring-boot-autoconfigure,会自动配置一个名为applicationTaskExecutor的ThreadPoolTaskExecutor(走分支 ②/③),所以很多人没踩到坑。但它的默认参数是core=8、max=Integer.MAX_VALUE、queueCapacity=Integer.MAX_VALUE—— 队列无界,max永远用不上,本质还是会堆到 OOM,只是从"线程爆炸"换成了"队列爆炸"。
坑 2:ThreadPoolTaskExecutor 的参数名和语义都和原生不一样
ThreadPoolTaskExecutor | 对应原生参数 | Spring 默认值 | 必须改成 |
|---|---|---|---|
corePoolSize | corePoolSize | 1(手动 new 时)/ 8(自动配置) | 按业务类型算 |
maxPoolSize | maximumPoolSize | Integer.MAX_VALUE | 必须设有限值 |
queueCapacity | workQueue 的容量 | Integer.MAX_VALUE | 必须设有限值(否则 max 失效) |
keepAliveSeconds | keepAliveTime | 60 | 一般不用动 |
rejectedExecutionHandler | handler | AbortPolicy | 按业务选,并打点告警 |
allowCoreThreadTimeOut | 同名 | false | 稀疏任务可开 |
setWaitForTasksToCompleteOnShutdown | ——(Spring 特有) | false | true + awaitTerminationSeconds |
java
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("bizPool") // ★ 显式命名,@Async 里指定,别依赖自动查找
public ThreadPoolTaskExecutor bizPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(16);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500); // ★ 有界,否则 maxPoolSize 形同虚设
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("biz-async-"); // ★ 便于 jstack 定位
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// ★ 优雅停机:等在跑的任务做完,最多等 30s
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}坑 3:@Async 自调用完全不生效
java
@Service
public class OrderService {
public void create(Order o) {
save(o);
this.notifyUser(o); // ❌ this 调用绕过代理 → 同步执行,注解等于注释
}
@Async public void notifyUser(Order o) { ... }
}@Async 靠 AOP 代理实现(默认 JDK 动态代理),this.xxx() 走的是原始对象,根本不经过代理。另外三个同源失效场景:方法是 private / static / final(代理不了)、类没被 Spring 管理(自己 new 的)、漏了 @EnableAsync。
java
// ✅ 三种解法,优先第一种
// ① 拆到另一个 Bean(职责也更清晰)
@Resource private NotifyService notifyService; // notifyService.notifyUser(o)
// ② 注入自己的代理(Spring 4.3+ 支持自注入,但要防循环依赖告警)
@Lazy @Resource private OrderService self; // self.notifyUser(o)
// ③ 从上下文拿代理(需开启 @EnableAspectJAutoProxy(exposeProxy = true))
((OrderService) AopContext.currentProxy()).notifyUser(o);坑 4:@Async 的返回值与异常
| 返回类型 | 行为 |
|---|---|
void | 异常不会抛给调用方 → 必须配 AsyncUncaughtExceptionHandler |
Future<T> / CompletableFuture<T> | 异常封装在 Future 里,get() 才抛(和 submit 一样) |
其他类型(如 String) | 直接返回 null!注解被"静默忽略"的典型 |
java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override public Executor getAsyncExecutor() { return bizPool(); }
@Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
// ★ 只对返回 void 的 @Async 方法生效
return (ex, method, params) ->
log.error("异步任务异常 method={} args={}", method.getName(), params, ex);
}
}坑 5:@Async + @Transactional 的事务不传播
事务绑定在 ThreadLocal(TransactionSynchronizationManager)上,换线程 = 换事务。常见后果有两个:① 异步方法里的事务是独立的,主事务回滚它不回滚;② 主事务还没提交,异步线程已经去查库,查不到刚写入的数据(经典的"异步任务读不到数据")。
java
// ✅ 正解:事务提交后再发起异步
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent e) { asyncService.handle(e.getId()); }坑 6:@Scheduled 默认是单线程,且异常会让任务永久停摆
text
@Scheduled 默认执行器 = 单线程的 ScheduledThreadPoolExecutor(core = 1)
后果 ①:10 个定时任务串行跑,一个慢任务阻塞其余全部
后果 ②:任务抛出未捕获异常 → 该任务的后续调度【永久取消】,静默无日志
后果 ③:fixedRate 下任务执行时间 > 间隔,会立刻连续补跑(堆积),不是并行java
// ✅ 配置 1:给定时任务独立多线程池
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler s = new ThreadPoolTaskScheduler();
s.setPoolSize(8);
s.setThreadNamePrefix("scheduled-");
s.setWaitForTasksToCompleteOnShutdown(true);
s.setAwaitTerminationSeconds(20);
return s;
}
// ✅ 配置 2:每个定时任务体内必须自己兜住异常
@Scheduled(cron = "0 */5 * * * ?")
public void syncJob() {
try { doSync(); }
catch (Exception e) { log.error("syncJob 失败", e); } // ★ 绝不让异常冒出方法
}加粗要点:
@Async的三条铁律 —— 必须显式指定自定义线程池(默认可能是SimpleAsyncTaskExecutor或无界队列池)、不能自调用(AOP 代理失效)、void返回值的异常必须配 handler;ThreadPoolTaskExecutor的queueCapacity默认无界,不改它maxPoolSize就是个摆设;@Scheduled默认单线程且异常会终止后续调度,所以必须换多线程TaskScheduler+ 任务内全包 try-catch。分布式环境下定时任务还要加分布式锁(或直接用 XXL-JOB / ElasticJob),否则多实例会重复执行。
21. 任务取消体系:Future.cancel、两阶段终止与不可中断阻塞的唤醒
Java 没有安全的"强杀线程"手段(Thread.stop() 已废弃并在 JDK20 彻底抛 UnsupportedOperationException,因为它会在任意字节码处抛异常,导致锁释放但状态只改了一半)。所以取消在 Java 里是协作式的:发起方只能"请求",任务方必须"配合"。
取消的三层语义
┌───────────────────────────────────────────────┐
│ 1. 中断标志位 interrupt() → 只是打个标记 │
│ 2. 阻塞方法感知 → sleep/wait/join/park/BlockingQueue
│ 抛 InterruptedException 并【清除】标志 │
│ 3. 业务代码感知 → 自己轮询 isInterrupted() 退出循环 │
└───────────────────────────────────────────────┘
任何一层不配合,取消就失效Future.cancel(boolean mayInterruptIfRunning) 的完整语义(以 FutureTask 为准):
| 任务当前状态 | cancel(true) | cancel(false) | 返回值 |
|---|---|---|---|
| 还在队列里没跑(NEW) | 标记 CANCELLED,永不执行 | 标记 CANCELLED,永不执行 | true |
| 正在执行(NEW + runner≠null) | 标记 INTERRUPTING,对执行线程 interrupt() | 标记 CANCELLED,不打断,任务跑完但结果丢弃 | true |
| 已正常完成 / 已异常 / 已取消 | 什么都不做 | 什么都不做 | false |
java
Future<?> f = pool.submit(task);
boolean ok = f.cancel(true);
// ★ ok==true 只代表"取消请求发出去了",不代表任务真的停了!
// ★ 之后 f.get() 一律抛 CancellationException(不是返回 null)
// ★ f.isCancelled()==true 但线程可能还在死循环里跑三个最容易答错的点:
cancel(false)对正在执行的任务毫无约束力——任务会一直跑到结束,只是结果被丢弃。它唯一的真实用途是"取消还在队列里排队的任务"。cancel(true)只是interrupt()。如果任务体是while(true){ compute(); }这种纯计算且不检查标志的代码,取消完全无效。cancel返回true≠ 任务已停止。要确认真的停了,必须让任务自己上报(如结束时latch.countDown())。
CompletableFuture.cancel() 的差异(高频陷阱):
FutureTask.cancel(true) | CompletableFuture.cancel(true) | |
|---|---|---|
| 是否中断执行线程 | 会,对 runner 发 interrupt | 不会,参数被完全忽略 |
| 做了什么 | 置状态 + interrupt | 仅把 CF 置为 CancellationException 完成 |
| 上游任务 | 队列中的不再执行 | 继续跑完,只是没人要结果了 |
| 下游依赖 | — | 下游 thenApply 等全部以 CompletionException 短路 |
java
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
heavyIoCall(); // ★ cancel 之后它照样跑完、照样占着线程
return "done";
});
cf.cancel(true); // 只是让 cf 本身变成"已取消"
// ★ 想真正停:自己传 volatile 标志 / 用可中断的底层调用 / 给 IO 设超时所以
CompletableFuture上的超时(orTimeout)本质是让下游别等了,不是让上游停下来。要释放资源必须在上游任务内部设置 socket/HTTP 客户端的读超时。
两阶段终止模式(Two-Phase Termination)——线上长驻线程优雅停机的标准写法:
阶段一:发出停止意图 volatile running = false + interrupt()
阶段二:任务自己收尾 退出循环 → flush 缓冲 → 关资源 → countDown 上报
┌──────────────┐
stop() ──┤ running=false │──► 循环条件下次判定为 false
│ t.interrupt() │──► 唤醒正阻塞在 sleep/take 的线程
└──────────────┘
★ 两个动作必须【同时】做:
只置标志 → 线程卡在 sleep 里半小时不退出
只 interrupt → 任务 catch 后继续循环(标志已被清除)java
public class MonitorThread {
private volatile boolean running = true; // ★ 必须 volatile
private Thread worker;
private final CountDownLatch done = new CountDownLatch(1);
public void start() {
worker = new Thread(() -> {
try {
while (running && !Thread.currentThread().isInterrupted()) {
try {
collectMetrics();
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
// ★ 阻塞方法抛异常时标志已被清除,必须补回来
Thread.currentThread().interrupt();
break; // 或 continue 由 running 决定
} catch (Exception biz) {
log.error("本轮采集失败,继续下一轮", biz); // ★ 业务异常不能让线程死掉
}
}
} finally {
flushBuffer(); // ★ 阶段二收尾
closeResources();
done.countDown();
}
}, "metrics-monitor");
worker.setDaemon(true);
worker.start();
}
public void stop() throws InterruptedException {
running = false; // ① 置意图
worker.interrupt(); // ② 唤醒阻塞
if (!done.await(10, TimeUnit.SECONDS)) { // ③ 限时等收尾
log.warn("worker 未在 10s 内退出,放弃等待");
}
}
}三条必须记住的细节:
| 细节 | 原因 |
|---|---|
标志位必须 volatile | 否则工作线程可能一直读自己缓存里的 true,循环永不退出 |
catch (InterruptedException) 里要么 break 要么补回标志 | 异常抛出时 JVM 已清除中断标志,吞掉就等于把取消信号丢了 |
| 业务异常要在循环内部捕获 | 否则一次偶发异常就让整个长驻线程永久消失,表现为"监控/消费莫名停了" |
毒丸(Poison Pill)——队列型任务的取消手段:
当生产者消费者通过 BlockingQueue 解耦时,直接 interrupt 消费者会让"队列里剩余的任务"被丢弃。想要处理完存量再退出,就往队列里塞一个哨兵对象:
java
private static final Runnable POISON = () -> {}; // ★ 唯一标识,用 == 比较
// 消费者
while (true) {
Runnable task = queue.take();
if (task == POISON) { // ★ 必须用 ==,不能用 equals
queue.put(POISON); // ★ 多消费者时放回去,传递给下一个
break;
}
task.run();
}
// 生产者关闭时
queue.put(POISON);| 对比 | interrupt | 毒丸 |
|---|---|---|
| 存量任务 | 丢弃 | 全部处理完 |
| 响应速度 | 立即 | 要等前面任务排完 |
| 适用 | 需要尽快停(如压测终止) | 不能丢数据(如日志/订单落库) |
| 坑 | 任务不配合则无效 | 生产者挂了没塞毒丸 → 消费者永久阻塞(要配超时 poll) |
不可中断阻塞怎么办(面试加分点):
interrupt() 只能唤醒"声明了 InterruptedException"的方法。下面这些阻塞对中断完全免疫,线程会一直挂着直到底层返回:
| 阻塞场景 | 为什么中断无效 | 正确的唤醒手段 |
|---|---|---|
InputStream.read() / Socket.getInputStream().read() | 阻塞在 OS 的 read 系统调用上 | 另一个线程调 socket.close(),read 会抛 SocketException |
synchronized 等锁 | 进入 BLOCKED 态,设计上不可中断 | 换成 lock.lockInterruptibly() 或 tryLock(timeout) |
lock.lock() | ReentrantLock 的不可中断版本 | 同上,用 lockInterruptibly() |
传统 FileInputStream 读本地文件 | 同 OS 调用 | close();或用 FileChannel |
| JDBC 查询执行中 | 阻塞在数据库网络 IO | statement.cancel() + 连接池的 queryTimeout |
SelectableChannel / NIO | — | 这类是可中断的,中断后 channel 自动关闭并抛 ClosedByInterruptException |
java
// ★ 关闭资源来"伪装"中断:把 close 动作挂到中断处理里
public class CancellableReader {
private final Socket socket;
public void readLoop() {
try (InputStream in = socket.getInputStream()) {
byte[] buf = new byte[1024];
int n;
while ((n = in.read(buf)) > 0) { handle(buf, n); }
} catch (IOException e) {
// ★ 被 close 打断时走这里,不要无脑 log.error 成"读取失败"
if (socket.isClosed()) { log.info("读取被主动取消"); }
else { log.error("读取异常", e); }
}
}
public void cancel() {
try { socket.close(); } catch (IOException ignore) {} // ★ 唯一有效的唤醒方式
}
}工程做法:凡是封装不可中断阻塞的组件,都应该额外暴露一个
cancel()/close(),并在文档里写明"本任务不响应 interrupt"。否则pool.shutdownNow()会静默无效,你只会看到awaitTermination一直超时。
JDK21 结构化并发 StructuredTaskScope(预览特性)——取消的终极形态:
传统 ExecutorService 的问题是:父子任务生命周期无关联,父任务被取消/异常了,子任务还在后台跑;想手动取消就得自己存一堆 Future 再逐个 cancel。结构化并发把"一组任务"变成一个有作用域的整体:
java
// ★ 任一子任务失败 → 自动取消其余所有子任务 → scope 关闭时保证全部结束
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> userService.find(id));
Subtask<Order> order = scope.fork(() -> orderService.find(id));
scope.join(); // 等全部结束(或被第一个失败触发取消)
scope.throwIfFailed(); // 有失败就抛出
return new Profile(user.get(), order.get());
} // ★ try-with-resources 退出时,保证没有子线程泄漏| 维度 | ExecutorService + Future | StructuredTaskScope |
|---|---|---|
| 任务生命周期 | 与提交者无关,容易泄漏 | 严格限定在 try 块内,不可能泄漏 |
| 失败传播 | 手动遍历 cancel | ShutdownOnFailure 自动取消兄弟任务 |
| 取第一个成功结果 | 自己写 anyOf + 取消 | ShutdownOnSuccess 开箱即用 |
| 线程成本 | 平台线程,需池化 | 配虚拟线程,fork 一个就是一个虚拟线程 |
| 调用栈/可观测性 | 子任务栈与父任务割裂 | 保留父子关系,dump 可见层级 |
加粗要点: Java 的取消是协作式的,
interrupt()只是打标记,三层(标志位 / 阻塞方法 / 业务轮询)任何一层不配合就失效;Future.cancel(true)等于 interrupt,cancel(false)对正在执行的任务毫无约束力,返回true也只代表"请求已发出";CompletableFuture.cancel完全忽略布尔参数、不中断上游,超时只是让下游别等;长驻线程必须用两阶段终止(volatile标志 +interrupt()同时做,catch里补回标志,finally里收尾,外部限时await),不能丢数据的队列场景用毒丸;read()、synchronized、lock()等不可中断阻塞只能靠close()资源或换lockInterruptibly()唤醒,否则shutdownNow()静默失效;JDK21 的StructuredTaskScope用作用域把父子任务绑在一起,从根上消灭线程泄漏和手动级联取消。
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
题 1:synchronized 和 ReentrantLock 有什么区别?
精简口述答案:
两者都是可重入的独占锁,但层次不同:
synchronized是 JVM 层面的关键字,靠对象头 Mark Word 和 ObjectMonitor 实现,加解锁由 JVM 自动完成,异常时自动释放;ReentrantLock是 JDK 层面的类,基于 AQS 的volatile state加 CLH 队列,必须手动lock/unlock,并且unlock一定要写在finally里。功能上
ReentrantLock是超集,多了四样synchronized没有的能力:可中断(lockInterruptibly)、可超时(tryLock(timeout))、可公平(构造参数true)、多条件队列(多个Condition实现精准唤醒,比如阻塞队列的notFull和notEmpty)。性能上,JDK 6 之后
synchronized引入了偏向锁、轻量级锁、自适应自旋、锁消除、锁粗化,低竞争下两者差距很小。所以我的选型原则是:能用synchronized就用它(代码简洁、不会漏释放、JVM 还能帮你优化掉),只有需要上面那四种高级能力时才换ReentrantLock。
连环追问链:
- 追问:锁升级的完整过程是什么?
- 答:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,单向不可逆。偏向锁在 Mark Word 里记线程 ID,同一线程再进只需比对 ID;出现第二个线程竞争就升级为轻量级锁,线程在自己栈里建 Lock Record 并 CAS 指向它,失败则自适应自旋;自旋还失败就膨胀为重量级锁,走 ObjectMonitor,线程进内核态挂起变成
BLOCKED。
- 答:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,单向不可逆。偏向锁在 Mark Word 里记线程 ID,同一线程再进只需比对 ID;出现第二个线程竞争就升级为轻量级锁,线程在自己栈里建 Lock Record 并 CAS 指向它,失败则自适应自旋;自旋还失败就膨胀为重量级锁,走 ObjectMonitor,线程进内核态挂起变成
- 追问:JDK 15 对偏向锁做了什么改动?为什么?
- 答:JEP 374 默认禁用偏向锁,JDK 18 彻底移除。原因有三:偏向锁撤销需要到安全点 STW,代价高;现代应用普遍用线程池 + JUC,"单线程独占一把锁"的场景大幅减少,收益萎缩;偏向锁代码渗透整个 JVM,阻碍 Lilliput 这类对象头压缩优化。所以在新版本上答锁升级要说"现在是无锁 → 轻量级 → 重量级三级"。
- 追问:为什么
synchronized是可重入的?重入计数存在哪里?- 答:ObjectMonitor 里有
_owner和_recursions两个字段,_owner记录持有者线程,同一线程再次进入只是_recursions++,退出时递减,减到 0 才真正释放。ReentrantLock则是把重入次数累加在 AQS 的state上。可重入的意义是避免"自己被自己锁死"——比如一个synchronized方法调用同类的另一个synchronized方法。
- 答:ObjectMonitor 里有
- 追问:
ReentrantLock的公平锁和非公平锁差在哪?为什么默认非公平?- 答:代码上只差一个
hasQueuedPredecessors()判断——公平锁抢锁前先看队列里有没有人等,有就乖乖排队;非公平锁直接 CAS 插队。默认非公平是因为吞吐量高一个数量级:唤醒一个 park 的线程需要几微秒上下文切换,这期间锁是空闲的,让正在 CPU 上跑的线程直接拿走能填满这个空窗期。代价是理论上可能饥饿,但实测概率很低。
- 答:代码上只差一个
- 追问:
synchronized锁字符串常量会有什么问题?- 答:字符串常量在常量池里是同一个对象,不同模块各自
synchronized("lock")会意外共享同一把锁,造成完全无关的代码互相阻塞,而且极难排查。同理也不能锁Integer(有缓存池)、Boolean,以及不能锁会被重新赋值的变量(锁对象变了就等于没锁)。正确做法是private static final Object LOCK = new Object();。
- 答:字符串常量在常量池里是同一个对象,不同模块各自
- 追问:
Condition的await/signal和wait/notify底层有什么不同?- 答:
wait/notify操作的是 ObjectMonitor 里的_WaitSet,一个锁只有一个等待集合;Condition则是 AQS 的条件队列,await会先fullyRelease完全释放锁(保存重入次数)再 park,signal把节点从条件队列转移到同步队列,它还得重新排队抢锁才能真正返回。关键区别是Condition可以有多个,支持按条件精准唤醒,避免notifyAll的无效唤醒风暴。
- 答:
✅ 项目关联话术(按真实经历替换):
我在做库存扣减的本地缓存刷新时用过
ReentrantLock的tryLock:缓存过期瞬间会有大量请求同时回源 DB,我用tryLock(0)让只有一个线程去回源,其余线程直接拿旧值返回,避免了缓存击穿。这里必须用ReentrantLock而不是synchronized,因为synchronized没有"抢不到就立刻放弃"的语义,所有线程都会排队阻塞,反而把 Tomcat 线程全耗光了。
题 2:线程池的 7 个核心参数是什么?执行流程是怎样的?参数怎么定?
精简口述答案:
7 个参数是:
corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime+unit(非核心线程空闲存活时间)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。执行流程是四级流水线:① 当前线程数 <
core就直接建核心线程执行(不管现有线程是否空闲);② 否则入队;③ 队列满了才扩容到max,新建的是非核心线程;④ 线程也满了就走拒绝策略。这里最容易答错的是顺序——是"先入队再扩容",不是"先扩线程再入队",所以用无界队列时maximumPoolSize这个参数永远不会生效。参数怎么定:先按任务类型估初值,CPU 密集
N+1、IO 密集N*2起步或用N × 利用率 × (1 + 等待/计算)精算;但公式只给初值,最终必须靠压测定,看 QPS 拐点、P99、CPU 和队列积压。队列长度按"可接受的排队延迟 ÷ 单任务耗时"反推。另外生产上一律禁用Executors工厂方法,因为newFixedThreadPool队列无界会 OOM、newCachedThreadPool线程数无上限也会 OOM。
连环追问链:
- 追问:为什么核心线程优先于复用空闲线程?
- 答:这是
execute第一步的设计——只要workerCountOf(c) < corePoolSize就addWorker,不检查现有线程是否空闲。目的是让线程池尽快"预热"到核心规模,因为核心线程是常驻的,早建好后续就不用再付创建成本;而且判断"有没有空闲线程"本身需要额外的同步开销。
- 答:这是
- 追问:线程池为什么不回收核心线程?源码里是怎么区分核心线程的?
- 答:源码里根本没有区分哪个线程是核心线程。
getTask()里判断timed = allowCoreThreadTimeOut || wc > corePoolSize,为true就走poll(keepAliveTime)超时返回 null 然后退出,为false就走take()无限阻塞。所以"核心线程不死"的本质是它走了take()这条永不超时的分支。调allowCoreThreadTimeOut(true)核心线程也会被回收。
- 答:源码里根本没有区分哪个线程是核心线程。
- 追问:有哪几种拒绝策略?线上你会怎么选?
- 答:JDK 自带四种——
AbortPolicy抛RejectedExecutionException(默认)、CallerRunsPolicy调用者线程自己执行(天然反压)、DiscardPolicy静默丢弃、DiscardOldestPolicy丢最老的再重试。线上我倾向自定义:先打点告警,然后按业务重要性分流——核心链路(下单)落 MQ 或降级到同步执行,非核心(埋点、推送)直接丢弃并记日志。绝对不用DiscardPolicy,静默丢数据是最可怕的。
- 答:JDK 自带四种——
- 追问:
CallerRunsPolicy有什么隐患?- 答:它让提交任务的线程自己跑任务。如果提交者是 Tomcat 的 Web 线程,就会把 Web 线程占住,请求响应变慢,连锁反应是 Tomcat 线程池也打满,整个服务不可用。它的优点是天然反压(提交变慢了,上游自然就慢下来),所以适合离线批处理这种"宁慢不丢"的场景,不适合在线接口。
- 追问:线程池的 5 种状态和
shutdown/shutdownNow的区别?- 答:
RUNNING(接新任务、处理队列)→SHUTDOWN(不接新任务但处理完队列)→STOP(不接新任务、丢弃队列、中断正在执行的任务)→TIDYING(任务全空、线程全销毁,执行terminated()钩子)→TERMINATED。状态和线程数一起存在ctl这个AtomicInteger里,高 3 位状态、低 29 位线程数,这样一次 CAS 就能原子地改两个值。shutdown温和,只中断空闲线程;shutdownNow会中断所有线程并返回未执行的任务列表。
- 答:
- 追问:怎么做动态线程池?哪些参数能改?
- 答:
corePoolSize、maximumPoolSize、keepAliveTime、handler都有 setter 可以运行时改;队列容量是final改不了,所以要么自己复制LinkedBlockingQueue源码把capacity改成volatile并加 setter,要么用现成的 DynamicTp、Hippo4j。再把参数接到 Nacos / Apollo 上监听变更,同时把getActiveCount、getQueue().size()、拒绝次数接到 Prometheus 做告警。
- 答:
- 追问:
execute和submit的异常处理有什么区别?- 答:
execute的异常会一路抛出线程,触发UncaughtExceptionHandler并打印堆栈,该线程销毁后线程池补一个新的;submit把任务包成FutureTask,run里catch (Throwable)后setException存进outcome字段,不get()就永远看不到异常,这是线上"任务静默失败"的常见原因。兜底办法是任务内部全包 try-catch,或者重写afterExecute把Future的异常挖出来统一打日志。
- 答:
✅ 项目关联话术(按真实经历替换):
我接手的一个报表导出服务曾经因为用了
Executors.newFixedThreadPool(10)出过一次 OOM:队列是无界的LinkedBlockingQueue,大促期间导出请求激增,几万个任务对象(每个还持有查询参数和部分结果集)全堆在队列里把堆打满了。我的改造是三件事:换成手动new ThreadPoolExecutor并把队列改成容量 2000 的ArrayBlockingQueue;拒绝策略自定义为"落 MQ 延迟重试 + 告警";最后把队列积压和拒绝次数接到监控,积压超 80% 就告警。改完之后同样的流量下内存平稳,再也没有 OOM。
题 3:AQS 的原理是什么?它是怎么支撑整个 JUC 的?
精简口述答案:
AQS 是
AbstractQueuedSynchronizer,JUC 几乎所有同步工具的骨架。它的核心就两样东西:一个volatile int state表示同步状态,一个 CLH 变体的双向等待队列存放抢不到锁的线程。工作流程是:线程先调子类实现的
tryAcquire尝试改state,成功就直接执行;失败就被包成 Node 入队,然后在acquireQueued里自旋——只有前驱是 head 的"老二"才有资格再抢一次,抢不到就把前驱的waitStatus改成SIGNAL然后LockSupport.park()挂起。释放时tryRelease把state改回去,再unparkSuccessor唤醒后继节点。AQS 用的是模板方法模式:它把入队、挂起、唤醒、取消这些最难写的部分都实现好了,子类只需要重写
tryAcquire/tryRelease(独占)或tryAcquireShared/tryReleaseShared(共享),并定义state的语义。所以ReentrantLock的state是重入次数、Semaphore是剩余许可数、CountDownLatch是剩余计数、ReentrantReadWriteLock把state拆成高 16 位读锁、低 16 位写锁——换个state语义就是一个新的同步器,这就是它支撑整个 JUC 的方式。
连环追问链:
- 追问:AQS 的队列为什么要从
tail往前遍历?- 答:因为
addWaiter入队是两步非原子操作:先node.prev = t,再compareAndSetTail成功后才t.next = node。中间存在一个窗口,此时新节点的prev已经接好但前驱的next还是 null——这叫尾分叉。所以从 head 向后遍历会漏掉节点,而反向遍历靠prev一定能走通,unparkSuccessor和cancelAcquire里的遍历都是反向的。
- 答:因为
- 追问:head 节点为什么是个"空"节点?
- 答:head 是哨兵节点(dummy),不关联任何线程,它的作用是承载
waitStatus—— 队列里每个节点的唤醒职责都写在前驱节点的waitStatus上,队首线程需要一个前驱来存SIGNAL标记。而且线程抢到锁之后会setHead(node)把自己变成新的哨兵并清空 thread 字段,这样既复用了节点又帮助了 GC。
- 答:head 是哨兵节点(dummy),不关联任何线程,它的作用是承载
- 追问:独占模式和共享模式的区别是什么?
- 答:独占模式(
Node.EXCLUSIVE)同一时刻只有一个线程能拿到state,释放时只唤醒一个后继,典型是ReentrantLock、写锁。共享模式(Node.SHARED)允许多个线程同时持有,关键差别是setHeadAndPropagate的唤醒传播:一个线程拿到共享锁后会继续唤醒后面的共享节点,形成"连锁唤醒",典型是Semaphore、CountDownLatch、读锁。
- 答:独占模式(
- 追问:
CountDownLatch和CyclicBarrier的实现差别?- 答:
CountDownLatch基于 AQS 共享模式,state初始化为计数,countDown就是releaseShared把state减一,减到 0 时唤醒所有在await上阻塞的线程;一次性、不可重置。CyclicBarrier根本不是直接用 AQS,而是用ReentrantLock+Condition实现的:每个线程到达就count--并await,最后一个到达的执行可选的 barrierAction 并signalAll,然后重置 count 进入下一代(generation),所以可以循环使用。
- 答:
- 追问:
ReentrantReadWriteLock怎么用一个state表示两种锁?会有什么问题?- 答:把 32 位的
state拆开,高 16 位记读锁的持有总数、低 16 位记写锁的重入次数,所以读锁最多 65535 个。各线程自己的读锁重入次数则存在ThreadLocal(HoldCounter)里。它的问题是写饥饿:读多写少时读锁源源不断进来,写线程可能长期拿不到锁,所以它提供了apparentlyFirstQueuedIsExclusive判断——非公平模式下如果队首是写线程,新来的读线程也要排队。另外它不支持锁升级(持读锁时直接writeLock().lock()会死锁),只支持降级(持写锁时获取读锁再释放写锁)。
- 答:把 32 位的
- 追问:
AQS的cancelAcquire在什么时候触发?- 答:三种情况——
lockInterruptibly时线程被中断、tryLock(timeout)超时、tryAcquire抛异常。处理是把该节点waitStatus置为CANCELLED(1),然后从队列里摘除;它不会立刻清理整条链,剩下的脏节点由后续线程在shouldParkAfterFailedAcquire里"顺路"跳过并清理。AQS 没有专门的清理线程,这是一种惰性清理设计。
- 答:三种情况——
✅ 项目关联话术(按真实经历替换):
我在做批量导入的并发控制时自己基于 AQS 写过一个"限流闭锁":业务要求"最多 5 个分片并行处理,且主线程要等全部完成"。一开始我用
Semaphore+CountDownLatch两个对象组合,后来发现用 AQS 共享模式把两件事合成一个同步器更清晰——state既当许可数又当完成计数。虽然最终上线用的还是标准库的组合(毕竟自己写的同步器要承担正确性风险),但这次实践让我真正搞明白了tryAcquireShared返回值的含义:返回负数表示失败要入队,0 表示成功但后面没资源了,正数表示成功且还有剩余可以继续传播唤醒。
题 4:volatile 能保证什么?JMM 和 happens-before 是什么?
精简口述答案:
volatile保证两件事:可见性和有序性(禁止指令重排)。它不保证原子性,所以i++这种复合操作用volatile依然是线程不安全的,必须用AtomicInteger或加锁。底层实现是内存屏障:volatile 写之前插
StoreStore、之后插StoreLoad;volatile 读之后插LoadLoad和LoadStore。在 x86 上,volatile 写会被编译成带lock前缀的指令(比如lock addl $0x0,(%rsp)),它会锁定缓存行、把 store buffer 刷回内存,并通过 MESI 协议让其他核的对应缓存行失效。JMM 是 Java 内存模型,它规定了多线程下共享变量的访问规则。而 happens-before 是 JMM 的核心——它不是说"时间上先发生",而是说"前一个操作的结果对后一个操作可见"。有八条规则,最常用的是:程序顺序规则(单线程内按代码顺序)、监视器锁规则(解锁 happens-before 后续加锁)、volatile 规则(写 happens-before 后续读)、线程启动规则(
start()之前的操作对新线程可见)、线程终止规则(线程内所有操作 happens-beforejoin()返回),以及传递性。有了 happens-before,我们就不需要去关心底层屏障的细节,只要能推出"A happens-before B"就说明并发是安全的。
连环追问链:
- 追问:
volatile为什么不能保证原子性?举个例子。- 答:
i++编译成三步:读取 i、加 1、写回 i。volatile只保证每次读拿到的是最新值、每次写立刻可见,但读到写之间的这段窗口里,别的线程完全可以插入自己的写——两个线程都读到 5,各自加 1 写回 6,结果丢了一次累加。20 个线程各加 10000 次,结果必然小于 200000。要原子就得用AtomicInteger(CAS 自旋)或synchronized。
- 答:
- 追问:什么场景适合用
volatile?- 答:三个典型场景。① 状态标志位:
volatile boolean running,一个线程写、其他线程读,没有复合操作;② DCL 单例的 instance 字段,靠的是它禁止重排的能力;③ 一写多读且写入值不依赖当前值,比如配置热更新的volatile Config config = newConfig。判断准则就一句话:写入不依赖当前值,且没有其他变量参与不变式约束。
- 答:三个典型场景。① 状态标志位:
- 追问:DCL 单例为什么必须加
volatile?不加会怎样?- 答:
new Singleton()实际是三步:分配内存、初始化对象、把引用赋给变量。JIT 允许后两步重排,变成"先赋引用、后初始化"。这时线程 A 刚赋完引用还没初始化,线程 B 的第一次检查(在锁外,没有任何同步语义)发现instance != null就直接返回了一个半初始化对象,访问字段全是默认值,表现为诡异的 NPE 或脏数据。加volatile后StoreStore屏障禁止了这个重排。注意光加synchronized不够,因为它不禁止临界区内部的重排。
- 答:
- 追问:
volatile和synchronized的区别?- 答:
volatile只能修饰变量,保证可见性和有序性,不保证原子性,不阻塞、无上下文切换、性能开销小;synchronized可修饰方法和代码块,三个特性全保证,有竞争时会阻塞并可能膨胀为重量级锁。可以理解为volatile是轻量级的同步,适合状态通知;synchronized适合保护临界区。
- 答:
- 追问:happens-before 里哪条最容易被忽略?
- 答:线程启动规则和终止规则。很多人以为"新线程里读到的变量要加 volatile",其实
t.start()之前主线程的所有写操作对新线程都是可见的(start 规则);同样t.join()返回后,该线程内的所有写对主线程可见。这两条让"主线程准备数据 → 起线程计算 → join 后取结果"这种模式天然安全,不需要额外同步。
- 答:线程启动规则和终止规则。很多人以为"新线程里读到的变量要加 volatile",其实
- 追问:为什么说"x86 上测不出重排 bug"?
- 答:x86 是 TSO(Total Store Order)强内存模型,只允许
StoreLoad一种重排,大部分漏写volatile的代码在 x86 上跑起来居然是对的。但 ARM / Power 是弱内存模型,允许的重排多得多——同样的代码在 Apple Silicon 的 Mac、鲲鹏或飞腾服务器上就会暴露问题。所以并发正确性必须靠 happens-before 推理,不能靠"我本地跑了一万次没问题"。
- 答:x86 是 TSO(Total Store Order)强内存模型,只允许
✅ 项目关联话术(按真实经历替换):
我们有个消息消费的开关曾经踩过可见性的坑:管理后台调接口把
boolean consuming改成 false,但消费线程的while (consuming)循环根本停不下来,只能靠重启。原因是这个字段没加volatile,消费线程把它缓存在工作内存里一直读旧值(JIT 甚至可能把循环条件直接优化成常量 true)。加上volatile后立刻正常。这件事之后我们在 Code Review 里加了一条硬规则:所有跨线程的状态标志位必须volatile,或者直接用AtomicBoolean。
题 5:说一下 Java 线程的 6 种状态,以及 wait / sleep / join / park 的区别
精简口述答案:
Java 线程状态定义在
Thread.State枚举里,一共 6 个:NEW(已 new 未 start)、RUNNABLE(可运行,把操作系统层面的"就绪"和"运行中"合并了,也包含正在做 IO 阻塞的线程)、BLOCKED(等synchronized监视器锁)、WAITING(无限期等待,靠别人唤醒)、TIMED_WAITING(带超时的等待)、TERMINATED(结束)。
关键流转是:
start()从 NEW 到 RUNNABLE;抢synchronized失败进 BLOCKED;wait()/join()/park()进 WAITING;带超时参数的sleep(n)/wait(n)/join(n)/parkNanos进 TIMED_WAITING;run()结束或抛异常进 TERMINATED。注意 WAITING 被notify()唤醒后不是直接回 RUNNABLE,而是先进 BLOCKED 重新抢锁,抢到才回 RUNNABLE。
四个等待方法的核心区别是:
wait是 Object 方法、必须持锁调用、会释放锁;sleep是 Thread 静态方法、不需要锁、不释放锁;join本质是在目标线程对象上wait,所以释放的是目标线程对象的锁(这也是不要对 Thread 对象加锁的原因);park是LockSupport提供的许可(permit)机制,不需要锁、不释放锁,且不要求 unpark 一定后发生——先 unpark 再 park 不会阻塞,这是它能安全替代 wait/notify 做 AQS 底座的关键。
连环追问链:
追问:为什么 Java 把操作系统的"就绪"和"运行中"合并成一个 RUNNABLE?
- 答:因为 JVM 作为跨平台虚拟机,拿不到也不该依赖操作系统线程调度器的细粒度状态。一个线程是在 CPU 上跑还是在就绪队列里排队,由 OS 调度器决定、随时切换,JVM 查询到的瞬时值毫无意义。所以 Java 只区分"我有没有把自己主动挂起"这件自己能控制的事。
- 答:一个重要的副作用是:做 socket 读写、磁盘 IO 而阻塞的线程,在 Java 里状态仍然是 RUNNABLE。所以线上
jstack看到一堆 RUNNABLE 不代表 CPU 很忙,要结合栈顶是不是socketRead0这类 native 方法来判断。
追问:BLOCKED 和 WAITING 到底差在哪?为什么要分两个状态?
- 答:BLOCKED 只有一个来源——竞争
synchronized的监视器锁失败,被塞进 ObjectMonitor 的_EntryList。它是被动的,线程自己没打算等,是因为锁被别人拿着才等。 - 答:WAITING 是主动的,线程自己调
wait()/join()/park()声明"我要等某个事件",进入_WaitSet或 AQS 的条件队列。 - 答:分开的实际价值在排查问题:
jstack里一片 BLOCKED 指向锁竞争激烈,要看锁粒度能不能拆;一片 WAITING 指向协作逻辑卡住,比如 notify 没发出来、CountDownLatch 没减到 0、从连接池拿不到连接。两者的优化方向完全不同。 - 答:补充一个坑:
ReentrantLock抢锁失败走的是LockSupport.park,所以状态是 WAITING / TIMED_WAITING 而不是 BLOCKED。只看状态猜不出是不是锁竞争,必须看栈。
- 答:BLOCKED 只有一个来源——竞争
追问:
notify()之后线程立刻变 RUNNABLE 吗?- 答:不是。
notify()只是把线程从_WaitSet挪到_EntryList,状态从 WAITING 变成 BLOCKED。它必须重新竞争到那把synchronized锁,才能从wait()里真正返回变成 RUNNABLE。 - 答:这也解释了为什么
notify()之后不用急着退出同步块——通知方还持有锁,被唤醒方根本抢不到,得等通知方走出synchronized块。所以"notify()放在同步块最后一行"是个优化习惯,不是正确性要求。
- 答:不是。
追问:
sleep(0)和Thread.yield()有什么用?- 答:
yield()是给调度器的一个建议:"我这个时间片可以让出去"。它不释放锁,状态保持 RUNNABLE,而且调度器完全可以忽略它,下一个被选中的可能还是自己。所以它不能用来做任何同步保证,只适合自旋等待时降低 CPU 空转(Thread.onSpinWait()是更现代的选择,会生成pause指令)。 - 答:
sleep(0)在 HotSpot 上的效果和yield()类似(触发一次调度点),但它会抛InterruptedException,语义更重。实践中都不建议依赖,它们属于"调优小手段",不是同步原语。
- 答:
追问:
join()的实现原理是什么?为什么不要对 Thread 对象加锁?- 答:
join()的实现非常朴素:synchronized在目标 Thread 对象上,然后while (isAlive()) wait(0)。线程结束时,JVM 在 native 层会对这个 Thread 对象执行notifyAll(),所有 join 的线程被唤醒,检查isAlive()为 false 后返回。 - 答:危险点有两个:第一,JVM 会在线程结束时自动 notifyAll 这个对象,如果你的业务代码也用
t.wait()/t.notify()把 Thread 对象当锁用,就会和join机制互相干扰,出现莫名其妙的提前唤醒。第二,join里用的是wait,会释放 Thread 对象的锁,你以为的互斥区间其实是漏的。 - 答:所以规则是:永远不要把 Thread 对象当作同步锁。要等多个线程结束,用
CountDownLatch或ExecutorService.awaitTermination(),语义清晰且不依赖 JVM 内部约定。
- 答:
追问:
LockSupport.park/unpark凭什么能替代wait/notify?它好在哪?- 答:第一,不需要持锁。
wait必须在synchronized块里,否则IllegalMonitorStateException;park 可以在任何地方调用,这让 AQS 可以自己管理队列而不依赖 JVM 的_WaitSet。 - 答:第二,没有顺序要求(许可可以预先发放)。每个线程有一个最多为 1 的 permit:
unpark把 permit 置 1,park发现有 permit 就直接消耗掉并返回。所以"先 unpark 后 park"不会阻塞,而"先 notify 后 wait"会永久丢失通知导致死等。这是解决并发唤醒竞态的关键武器。 - 答:第三,精准唤醒单个线程。
notify是从_WaitSet里随机挑一个,notifyAll会惊群;unpark(thread)直接指定目标,AQS 据此实现"只唤醒队首后继节点"的高效传递。 - 答:第四,park 可以被中断唤醒但不抛异常,只是设置中断标志并返回。所以 AQS 能做到"先返回、再统一处理中断",实现
lock()(吞掉中断,退出前补selfInterrupt())和lockInterruptibly()(抛异常)两套语义。 - 答:代价是 park 返回的原因有四种(unpark / 中断 / 超时 / 虚假唤醒),所以必须在循环里 park 并自己检查条件,这和 wait 的循环要求是一样的。
- 答:第一,不需要持锁。
追问:线上
jstack抓出来一个线程一直是 TIMED_WAITING,你怎么判断正常还是异常?- 答:先看栈顶。如果是
ThreadPoolExecutor.getTask→DelayedWorkQueue.take/poll(keepAliveTime),那是线程池核心线程在正常待命,完全健康。 - 答:如果是业务代码里的
future.get(timeout)、lock.tryLock(timeout)、连接池getConnection,就要连续抓 3~5 次 jstack 对比:如果同一个线程、同一行、卡了很久不变,说明超时时间设得过长或下游真的挂了。 - 答:再结合线程名和数量:如果某个线程池的线程全部 TIMED_WAITING 在同一个下游调用上,基本可以判定下游故障引发了线程池耗尽,要立刻看熔断和超时配置。
- 答:先看栈顶。如果是
✅ 项目关联话术(按真实经历替换):
我们排查过一次"接口偶发超时但 CPU 和 GC 都很正常"的问题,就是靠线程状态定位的。连续抓了几次
jstack,发现业务线程池里大量线程处于 BLOCKED,栈顶都停在一个工具类的synchronized静态方法上——那是个做日期格式化的方法,里面用了共享的SimpleDateFormat,前人为了修线程不安全问题直接在方法上加了synchronized,结果把所有请求串行化了。改成DateTimeFormatter(无状态、线程安全)之后 BLOCKED 全部消失,P99 从 800ms 降到 60ms。这件事让我养成了习惯:看到 BLOCKED 先找锁对象,看到 WAITING 先找谁该唤醒它。
题 6:ThreadLocal 的原理是什么?为什么会内存泄漏?
精简口述答案:
ThreadLocal的核心是把数据存在线程自己身上,而不是存在 ThreadLocal 对象里。每个Thread有一个threadLocals字段,类型是ThreadLocal.ThreadLocalMap。set(v)实际是Thread.currentThread().threadLocals.put(this, v)——key 是 ThreadLocal 实例本身,value 是你的数据。所以线程之间天然隔离,不需要任何同步。
ThreadLocalMap不是 HashMap:它是开放寻址 + 线性探测的数组结构,容量 2 的幂,初始 16,负载因子 2/3,扩容翻倍;它的Entry extends WeakReference<ThreadLocal<?>>,也就是对 key 是弱引用,对 value 是强引用。
泄漏就出在这个强弱不对称上。引用链是:
Thread→threadLocals→Entry→value,全是强引用。当 ThreadLocal 实例自身没有强引用了,GC 会回收 key,Entry 变成key == null的"脏 Entry",但 value 还被 Entry 强引用着回收不掉,而且你再也拿不到它。如果这个 Thread 是线程池里长期复用的核心线程,它的生命周期等于应用生命周期,这块 value 就永久泄漏了。解决办法只有一个:try/finally里必须remove()。
连环追问链:
追问:为什么 key 要设计成弱引用?不用弱引用会怎样?
- 答:弱引用是一道兜底的补救措施,不是泄漏的原因。如果 key 用强引用,那么只要线程活着,
Entry就强引用着 ThreadLocal 实例,即使业务代码里这个 ThreadLocal 变量已经不可达(比如它是个局部变量、或者它所属的类被卸载了),key 和 value 都回收不了,泄漏会更严重。 - 答:用弱引用后,至少 key 能被回收,然后 ThreadLocalMap 在
set/get/remove时遇到key == null的 Entry 会触发清理,有机会顺带把 value 置 null。注意是"有机会"——清理是启发式的、不保证执行,所以不能依赖它。 - 答:一句话总结:弱引用让泄漏变得"可能被修复",但
remove()才是"确定被修复"。
- 答:弱引用是一道兜底的补救措施,不是泄漏的原因。如果 key 用强引用,那么只要线程活着,
追问:ThreadLocalMap 的清理机制具体是怎么工作的?
- 答:有两个方法。
expungeStaleEntry(i):从位置 i 开始,把该脏 Entry 的 value 和 Entry 本身置 null,然后继续向后遍历直到遇到 null 槽,对途中的脏 Entry 清理、对正常 Entry 做 rehash 重新探测(因为开放寻址删除一个元素会破坏探测链,必须整段重排)。 - 答:
cleanSomeSlots(i, n):一个对数复杂度的启发式扫描,循环log2(n)次,每次往后跳一格看是不是脏 Entry,是就触发expungeStaleEntry。set插入新元素后会调用它。 - 答:所以清理的触发点是:
get探测过程中遇到脏 Entry、set时遇到脏 Entry 或插入后抽查、rehash前的全量expungeStaleEntries。如果一个线程 set 完之后再也不碰这个 ThreadLocal,那就永远不会触发清理。这正是线程池场景泄漏的根因。
- 答:有两个方法。
追问:
ThreadLocalMap为什么用开放寻址而不是 HashMap 的链地址法?- 答:第一,元素极少。一个线程通常只有个位数的 ThreadLocal(traceId、用户上下文、事务连接),数组线性探测的冲突概率低且缓存局部性好,不需要链表/红黑树的额外对象开销。
- 答:第二,hash 分布很好。ThreadLocal 的
threadLocalHashCode是用全局AtomicInteger以固定步长0x61c88647(黄金分割数相关)递增生成的,这个魔数能让连续生成的 hash 在 2 的幂长度的数组里近似均匀分布,冲突本来就少。 - 答:第三,Entry 需要继承 WeakReference。链地址法的 Node 要带 next 指针,而开放寻址下 Entry 可以就是一个纯粹的 WeakReference 子类,结构最简单,也方便 GC 语义的实现。
追问:线程池里用 ThreadLocal 除了泄漏,还有什么更危险的问题?
- 答:脏数据污染,而且这个比泄漏危险得多。线程池复用线程,上一个任务 set 的值如果没 remove,下一个任务
get就会拿到前一个请求的数据。如果里面存的是用户身份,就是实打实的越权/串号事故——A 用户的请求读到了 B 用户的上下文。 - 答:泄漏只是内存慢慢涨,监控能发现;污染是数据正确性问题,而且只在高并发下偶发,极难复现。所以
remove()的首要理由是防污染,其次才是防泄漏。 - 答:工程上的做法:用拦截器/Filter 在请求出口统一
remove,或者封装一个ContextHolder工具类,只暴露set/get/clear,并在 AOP 切面里强制 clear,不让业务代码直接接触 ThreadLocal。
- 答:脏数据污染,而且这个比泄漏危险得多。线程池复用线程,上一个任务 set 的值如果没 remove,下一个任务
追问:父线程的 ThreadLocal 怎么传给子线程?线程池场景怎么办?
- 答:JDK 提供
InheritableThreadLocal,它利用Thread的另一个字段inheritableThreadLocals。在new Thread()的构造过程中(Thread.init)会把父线程的inheritableThreadLocals浅拷贝一份给子线程,所以子线程能读到父线程的值。 - 答:但它在线程池里完全失效:因为拷贝只发生在"创建线程"的那一刻,线程池的线程是第一次提交任务时创建的,之后一直复用,后续任务提交时不会再发生任何拷贝,子线程里的值永远是第一个任务那次留下的旧值。
- 答:正确方案是阿里的 TransmittableThreadLocal (TTL)。思路是把传递时机从"创建线程"挪到"提交任务":用
TtlRunnable.get(task)包装任务,在提交时捕获当前线程的上下文快照,在任务run开始时回放到执行线程、结束时恢复原样。配合TtlExecutors.getTtlExecutorService()或 javaagent 可以做到无侵入。 - 答:补充:浅拷贝还有个坑——父子线程拿到的是同一个对象引用。如果 value 是可变对象(比如
Map),子线程改了父线程也会看到,必须自己做深拷贝或用不可变对象。
- 答:JDK 提供
追问:
ThreadLocal存储的 value 是弱引用吗?如果我把 ThreadLocal 声明成 static final 还会泄漏吗?- 答:value 是强引用,这是泄漏的必要条件之一。
- 答:声明成
static final是推荐做法,因为它保证 key 一直有强引用、不会变成脏 Entry,Entry 结构稳定,也避免了"每个请求 new 一个 ThreadLocal"这种灾难性写法(那会让 ThreadLocalMap 不断膨胀又不断产生脏 Entry)。 - 答:但它不能免除 remove。
static final只解决了"key 被回收导致 value 不可达"这一种泄漏,而"线程长期存活 + value 一直被引用 + 业务不再需要它"这种泄漏和数据污染问题依然存在。所以结论不变:无论怎么声明,try/finally+remove()是铁律。
追问:有哪些框架在用 ThreadLocal?它们怎么管理生命周期的?
- 答:Spring 的事务管理用
TransactionSynchronizationManager(内部一堆NamedThreadLocal)存当前线程绑定的Connection,保证同一事务里的多次 DAO 操作用同一个连接;事务提交/回滚后的cleanup阶段会统一remove。 - 答:Spring MVC 的
RequestContextHolder用 ThreadLocal 存Request,FrameworkServlet在 finally 里 reset。MyBatis 的SqlSessionManager、日志框架的 MDC(MDCAdapter底层就是 ThreadLocal 存 traceId)也都是同一套模式。 - 答:共同点非常明确:所有框架都把 ThreadLocal 的 set 和 remove 配对放在一个"请求/事务"边界的 try/finally 里。我们自己写的时候照抄这个模式就不会错。
- 答:Spring 的事务管理用
✅ 项目关联话术(按真实经历替换):
我们线上出过一次 ThreadLocal 的事故,印象很深。当时做了一个"当前登录用户上下文",用
static ThreadLocal<UserInfo>在 Filter 里 set,业务层随处 get,但 Filter 里只在正常返回时 remove,异常分支漏了。平时没问题,有一天下游超时导致接口大量抛异常,于是线程池里的线程留下了一堆没清理的用户上下文;紧接着这些线程被复用去执行另一个请求,而那个接口对未登录用户有降级逻辑——结果降级没走,直接用了上一个请求残留的用户身份,产生了跨用户的数据读取。
复盘后我们做了三件事:一是把 remove 挪到
finally里;二是写了一个UserContextHolder把 ThreadLocal 彻底封装起来,业务代码拿不到原始对象;三是在异步线程池上套了 TTL 的包装器,顺手解决了之前"异步任务里 traceId 丢失"的老问题。从那以后我 review 代码只要看到ThreadLocal.set,第一反应就是去找对应的remove在不在finally里。
题 7:线上发生死锁了,你怎么定位和解决?怎么从设计上避免?
精简口述答案:
死锁必须同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。打破任意一个就能消除死锁,工程上最常用的是打破"循环等待"(固定加锁顺序)和"持有并等待"(用
tryLock带超时)。
定位分三步:第一步看现象——接口无响应但 CPU 不高、线程池活跃线程数打满不下降;第二步
jstack <pid>导出线程栈,JVM 会在输出末尾自动打印Found one Java-level deadlock并列出环路(这是synchronized死锁的最快路径);第三步如果是ReentrantLock死锁,jstack 不会自动检测,要靠parking to wait for <ReentrantLock$NonfairSync>的地址加上- locked信息手工串出环,或者用 Arthas 的thread -b直接找阻塞源头。
解决上,线上救急只能重启(死锁不可剥夺,无法在运行时打破)。代码上的根治手段按优先级是:统一加锁顺序 → 用
tryLock(timeout)让出 → 缩小锁粒度/合并成一把锁 → 改成无锁结构(CAS / 不可变对象 / 单线程串行化)。
连环追问链:
追问:死锁、活锁、饥饿有什么区别?
- 答:死锁是互相持有对方需要的锁,全部永久阻塞,线程状态是 BLOCKED / WAITING,CPU 占用为 0。
- 答:活锁是线程都没有阻塞,但不断重试又不断失败,一直在"礼让"。典型场景是两个线程都用
tryLock失败就回退重试,且重试间隔固定,于是永远在同一个节拍上撞车。解法是加随机退避(像以太网的指数退避),让重试时机错开。 - 答:饥饿是某些线程长期拿不到资源。来源有三:非公平锁下的插队(新来的线程直接抢走锁,队列里的老线程一直等)、线程优先级设置不当、持锁线程执行时间过长。解法是改用公平锁、或者保证临界区足够短。
- 答:三者的共性是"业务推进不下去",但诊断手段不同:死锁看 jstack 环路;活锁看 CPU 高但吞吐为 0、日志里重试计数疯涨;饥饿看某些请求的 P999 异常高而平均值正常。
追问:怎么用代码复现一个死锁?
- 答:最小模型是两个线程两把锁反向获取:线程 A 先
synchronized(lockA),sleep一下确保线程 B 也拿到自己的锁,再去synchronized(lockB);线程 B 反过来。那个sleep很关键,它保证两个线程都完成了"持有第一把锁"这一步,否则其中一个可能快速跑完全程不会死锁。 - 答:实际项目里的死锁往往不这么直白,常见变形有:同一个方法被不同调用方以不同顺序传入两个账户对象(转账场景的经典死锁,A 转 B 和 B 转 A 并发);嵌套调用,外层方法持有锁 X 后调用了一个自己也加锁 Y 的服务方法,而另一条链路顺序相反;数据库行锁死锁,两个事务按不同顺序更新同一批行。
- 答:所以 review 时的信号是:一个临界区内又调用了别的加锁方法(锁嵌套),以及锁对象来自方法参数(顺序不可控)。
- 答:最小模型是两个线程两把锁反向获取:线程 A 先
追问:转账场景 A→B 和 B→A 并发怎么解决?
- 答:核心是给锁对象定义一个全局唯一的顺序,让所有线程按同一方向加锁。最常用的是按账户 ID 排序:先锁 ID 小的,再锁 ID 大的。这样 A→B 和 B→A 两条路径实际加锁顺序完全一致,环路被打破。
- 答:如果没有天然可比的 ID,可以用
System.identityHashCode(obj)排序。但要处理 hash 相同的极端情况:此时引入一个全局的"加时赛锁"(tie-breaker lock),先拿到它再按任意顺序加两把锁,保证这种罕见情况下也串行。这是《Java 并发编程实战》里的标准做法。 - 答:更工程化的方案是直接绕开双锁:把转账做成单线程串行(按账户 ID 取模路由到固定的单线程执行器),或者交给数据库用一条带条件的
UPDATE ... WHERE balance >= amount保证原子性,应用层不加锁。
追问:
jstack具体怎么看?输出里哪些信息最关键?- 答:关键有四块。一是 线程状态和锁信息行:
- waiting to lock <0x000000076ab62208> (a java.lang.Object)表示在等这个锁,- locked <0x000000076ab62218>表示已持有这个锁。把"等的地址"和"别人持有的地址"对上就是环。 - 答:二是 末尾的死锁报告。JVM 内置了死锁检测,对
synchronized和ReentrantLock(通过AbstractOwnableSynchronizer记录 owner)都能检出,会打印Found one Java-level deadlock:加上"Thread-0" is waiting to lock monitor ..., which is held by "Thread-1"的完整链条。所以第一步就该直接搜这个关键字。 - 答:三是
parking to wait for行:这是LockSupport.park导致的,后面跟的对象是 AQS 的 Sync 实例。配合at java.util.concurrent.locks.ReentrantLock.lock的栈帧可以确认是锁等待而不是业务等待。 - 答:四是 要连续抓多次(比如间隔 5 秒抓 3 次)。死锁的特征是栈完全不动;如果两次栈不一样,那是慢而不是死。这一招能快速区分死锁和性能问题。
- 答:关键有四块。一是 线程状态和锁信息行:
追问:除了 jstack 还有什么工具?Arthas 怎么用?
- 答:
jconsole/jvisualvm的线程面板有"检测死锁"按钮,图形化最直观,但生产环境一般不开 JMX 远程端口。 - 答:
jcmd <pid> Thread.print和 jstack 等价,是官方更推荐的入口。 - 答:Arthas 最实用:
thread看所有线程概览和 CPU 占比;thread -b直接找出阻塞其他线程的那个线程(它会分析 monitor 持有关系,一条命令定位"罪魁祸首");thread -n 3看最忙的 3 个线程(排查 CPU 飙高);thread <id>看指定线程栈。 - 答:代码层面还可以主动监控:
ThreadMXBean.findDeadlockedThreads()(能检出synchronized和 Lock 死锁)或findMonitorDeadlockedThreads()(只检 synchronized),起一个定时任务每分钟扫一次,发现非空就告警并打印栈。这是给核心服务加的保险。
- 答:
追问:
tryLock带超时能彻底避免死锁吗?有什么代价?- 答:能避免死锁"永久卡死",因为它打破了"持有并等待"——拿不到第二把锁就超时,然后必须释放已持有的第一把锁再整体重试。注意这个"释放已持有的锁"是关键,很多人只写了 tryLock 却忘了失败后 unlock 第一把,等于没解决。
- 答:代价有三个:一是可能变成活锁,需要配随机退避;二是业务要能接受失败/重试,不是所有操作都幂等可重试;三是代码变复杂,嵌套 try/finally 容易写错,容易出现漏 unlock 或多 unlock(
IllegalMonitorStateException)。 - 答:所以我的取舍是:能固定加锁顺序就优先固定顺序(零成本、无副作用),只在锁顺序确实无法统一(比如锁对象动态来自外部)时才用 tryLock 兜底。
追问:从架构设计上怎么从根本上减少死锁的可能?
- 答:第一,减少锁。能用不可变对象(
final字段 + 返回新实例)就不用锁;能用ConcurrentHashMap/AtomicLong/LongAdder就不用synchronized;能把共享状态改成线程本地(ThreadLocal)或消息传递(队列 + 单消费者)就彻底没有竞争。 - 答:第二,一把锁代替多把锁。两把细粒度锁带来的性能收益,常常抵不过死锁风险和维护成本。只有在压测证明锁竞争是瓶颈时,才拆锁。
- 答:第三,禁止在持锁期间调用外部方法(开放调用 open call)。临界区内不要调 RPC、不要查数据库、不要调用可被子类覆盖的方法、不要触发回调——因为你无法知道被调方会不会去拿别的锁。把计算结果拿到锁外再处理。
- 答:第四,分布式场景同理。Redis 分布式锁一定要设过期时间(打破"不可剥夺"),多把分布式锁也要按 key 排序获取,并且要考虑持锁进程宕机后的自动释放。
- 答:第五,加防护网:锁超时时间必须有上限,线程池要隔离(核心链路和非核心链路分池),加上
findDeadlockedThreads的定时巡检告警,让死锁在影响扩大前被发现。
- 答:第一,减少锁。能用不可变对象(
✅ 项目关联话术(按真实经历替换):
我处理过一次典型的死锁告警。现象是某个后台批处理任务的线程池活跃数打满后再也不降,但机器 CPU 只有 5%。
jstack下来直接在末尾看到了Found one Java-level deadlock,两个线程分别卡在"库存服务的synchronized方法里等订单锁"和"订单服务的synchronized方法里等库存锁"——根因是两个 service 互相调用,且各自都在方法上加了synchronized,调用顺序一正一反。
当时先重启恢复,然后做了两层改造:短期把其中一条链路的
synchronized去掉、改成在最外层统一按orderId加一把锁,消除了锁嵌套;中期把"持锁期间调用其他 service"这个模式列为 Code Review 的禁止项,并在核心服务上加了ThreadMXBean.findDeadlockedThreads()的分钟级巡检告警。之后再没有复发。这件事让我总结出一条最省心的经验:宁可用一把粗锁,也不要让两把锁有机会交叉。
题 8:CAS 是什么?有哪些问题?AtomicLong 和 LongAdder 怎么选?
精简口述答案:
CAS 是 Compare-And-Swap,三个参数:内存位置 V、预期旧值 A、要写的新值 B。只有 V 当前等于 A 时才把 V 改成 B,否则什么都不做并返回失败。它的原子性由 CPU 指令保证——x86 上是
lock cmpxchg,lock前缀锁定缓存行(现代 CPU 走缓存一致性协议而非锁总线)。Java 里通过Unsafe(JDK 9+ 推荐VarHandle)暴露,AtomicInteger等原子类就是"volatile 字段 + CAS 自旋"的组合:volatile 保证可见性和有序性,CAS 保证原子性。
CAS 有三个经典问题:ABA 问题(值从 A 变 B 又变回 A,CAS 看不出中间发生过变化),用
AtomicStampedReference加版本号或AtomicMarkableReference加布尔标记解决;循环时间长开销大(高竞争下自旋空转烧 CPU),用Thread.onSpinWait()退避、或竞争激烈时改用锁、或改用分段设计;只能保证一个变量的原子性,多个变量要么封装进一个不可变对象用AtomicReference,要么直接上锁。
AtomicLong和LongAdder的选择标准很清晰:低并发或需要精确实时值用AtomicLong,高并发只做计数/统计用LongAdder。AtomicLong所有线程 CAS 同一个变量,竞争一高就大量自旋失败,而且所有核心都在争抢同一个缓存行(伪共享);LongAdder用base+Cell[]分段,每个线程按 probe 哈希到不同 Cell 上各自累加,sum()时再把 base 和所有 Cell 相加。竞争被打散到多个缓存行(Cell 上有@Contended做填充),吞吐可以高一个数量级。代价是sum()不是原子快照,统计期间有并发写就可能不精确。
连环追问链:
追问:
AtomicInteger.incrementAndGet()的实现是什么样的?- 答:JDK 8 里是
unsafe.getAndAddInt(this, valueOffset, 1),内部是一个 do-while 自旋:先getIntVolatile读当前值 v,然后compareAndSwapInt(o, offset, v, v+1),失败就重新读再试,直到成功。JDK 9+ 换成了VarHandle的getAndAdd。 - 答:
value字段必须是volatile,否则自旋时读到的可能是缓存里的旧值,会导致无限失败或者 CAS 成功但别的线程看不到结果。 - 答:在 x86 上,JIT 会把
getAndAddInt直接内联成lock xadd这一条指令(因为加法有专门的原子指令),比"读-CAS-重试"的循环更快。所以AtomicInteger在低竞争下性能非常好,不要无脑换 LongAdder。
- 答:JDK 8 里是
追问:ABA 问题在实际业务中真的会出事吗?举个例子。
- 答:对于数值累加这类操作,ABA 通常无害——你关心的只是最终结果对不对,中间变成 B 又回来不影响。
- 答:对于引用类型和链式结构就会出事。经典例子是无锁栈:线程 1 读到栈顶是节点 A(A.next = B),准备 CAS 把栈顶改成 B;此时线程 2 把 A 和 B 都弹出、又把 A 压回去(此时 A.next 已经不是 B,B 甚至已经被回收/复用)。线程 1 的 CAS 看到栈顶还是 A,成功了,于是栈顶被改成了一个已经不在栈里的 B,数据结构被破坏。
- 答:业务层面的例子:用 CAS 更新"账户状态",从"正常"改成"冻结"再改回"正常",如果有个任务用 CAS 判断"状态还是正常就执行发放",它就会错过中间的冻结事件。这种"关心是否发生过变化"的场景必须加版本号。
- 答:Java 里 GC 帮我们缓解了一部分(不会出现 C/C++ 那种内存被 free 后重新分配到同一地址的问题),但逻辑层面的 ABA 依然存在。
追问:
AtomicStampedReference怎么用?它和AtomicMarkableReference的区别?- 答:
AtomicStampedReference<V>把引用和一个 int 版本号(stamp)打包成一个不可变的 Pair 对象,CAS 的时候比较的是整个 Pair 的引用,相当于同时比较引用和版本号。调用方式是compareAndSet(expectedRef, newRef, expectedStamp, newStamp),每次更新把 stamp +1,这样 A→B→A 的 stamp 变成了 0→1→2,CAS 会失败。 - 答:
AtomicMarkableReference<V>用的是boolean mark而不是 int stamp,只能表达"有没有被改过/有没有被标记删除",不能表达"改了几次"。它更省空间,典型用途是无锁链表的逻辑删除(先 mark 节点为"已删除",再真正摘除)。 - 答:两者的代价是每次 CAS 都要 new 一个 Pair 对象,有额外的分配和 GC 压力,所以只在确实需要防 ABA 时用。
- 答:
追问:
LongAdder的Cell[]是怎么扩张的?为什么要@Contended?- 答:
LongAdder(继承Striped64)是懒扩张的:一开始只有base,所有线程直接 CAS base。只有当 CAS base 失败(说明出现了竞争),才创建长度为 2 的cells数组。之后线程用自己的threadLocalRandomProbe哈希到某个 Cell,CAS 那个 Cell。如果连 Cell 的 CAS 也失败,先尝试advanceProbe换一个 Cell(相当于重新哈希),还失败才把数组扩容一倍。 - 答:数组长度上限是不超过 CPU 核数的最小 2 的幂——因为真正并行写入的线程最多就是核数,再多的 Cell 也没意义,只是浪费内存。
- 答:
@Contended(JDK 8 的sun.misc.Contended,需要-XX:-RestrictContended才对用户代码生效)的作用是让 JVM 在字段前后填充 128 字节,保证每个 Cell 独占一条缓存行。否则多个 Cell 挤在同一条 64 字节缓存行里,一个核改 Cell[0] 会让其他核缓存的 Cell[1] 整行失效(伪共享 false sharing),分段的收益就全被缓存一致性流量吃掉了。实测伪共享能让性能差好几倍。
- 答:
追问:
LongAdder.sum()为什么不准?什么场景不能用?- 答:
sum()的实现是base加上遍历cells的累加,整个过程没有加锁也没有快照机制。遍历到 Cell[2] 的时候,Cell[0] 可能又被改了,于是结果既不是开始时刻的值也不是结束时刻的值,是个"中间态"。 - 答:所以不能用在需要精确判断的场景。比如"库存减到 0 就停止"、"限流计数超过阈值就拒绝"这种强一致判断,用 LongAdder 会放过一部分请求。这类场景要用
AtomicLong的decrementAndGet返回值判断,或者用 Redis/数据库的原子操作。 - 答:它适合的是监控指标、QPS 统计、总请求数、缓存命中数这类"最终统计、容忍瞬时偏差"的场景。而且注意
sum()本身是 O(cells 长度) 的,高频调用也有成本,一般按秒级采样。 - 答:另外
LongAdder没有compareAndSet,也没有返回自增后的值的方法(increment()返回 void),所以需要"拿到自增后序号"的场景(比如生成递增 ID)只能用AtomicLong。
- 答:
追问:
AtomicReference和AtomicXXXFieldUpdater的区别?什么时候用后者?- 答:
AtomicReference是一个包装对象,每个被保护的字段都要多一个对象,内存开销在有海量实例时很可观(比如每个连接对象都带几个 AtomicReference)。 - 答:
AtomicIntegerFieldUpdater/AtomicReferenceFieldUpdater是基于反射的静态工具:你直接声明一个volatile int state普通字段,然后用一个 static 的 Updater 对它做 CAS。全类只有一个 Updater 实例,零额外内存开销。代价是字段必须是volatile、不能是static、不能是final,而且有访问权限检查。 - 答:典型应用在 JDK 自己的代码和中间件里,比如 Netty 大量使用 FieldUpdater 来给每个 Channel 的状态做无锁更新,就是为了省掉百万连接场景下的对象开销。
- 答:JDK 9+ 的
VarHandle是官方替代品,性能更好(可以被 JIT 内联成直接的内存访问,没有反射开销)、功能更全(支持各种内存语义:plain / opaque / acquire-release / volatile),新代码应该优先用它。
- 答:
追问:CAS 一定比锁快吗?
- 答:不一定,取决于竞争程度和临界区长度。CAS 在低竞争下胜出明显,因为它没有用户态到内核态的切换、没有线程上下文切换、没有线程挂起唤醒的开销。
- 答:但在高竞争下 CAS 会退化:N 个线程里只有 1 个能成功,其余 N-1 个白白自旋烧 CPU,而且每次失败都伴随缓存行在核间来回弹跳(cache line ping-pong),总吞吐可能比锁更差。锁虽然有切换开销,但它让失败者睡眠让出 CPU,整体更"节能"。
- 答:另外 CAS 只适合极短的临界区(就是改一个变量)。如果临界区里有几十行逻辑,CAS 自旋重试意味着那几十行要重算多次,还可能有副作用,根本不能用。
- 答:所以实践结论是:单变量更新用原子类,复杂逻辑用锁,高并发纯计数用 LongAdder,读多写少用
StampedLock的乐观读或ReadWriteLock。而synchronized本身在低竞争下也会走偏向锁/轻量级锁的 CAS 路径,并不慢——不要为了"无锁"硬凑 CAS。
✅ 项目关联话术(按真实经历替换):
我们做过一次网关埋点统计的优化,很能说明 CAS 的适用边界。最初用
AtomicLong给每个接口维度统计调用次数,压测到两万 QPS 以上时,火焰图上getAndAddInt的自旋占了相当比例的 CPU,而且perf能看到明显的缓存未命中。
我们把计数器全部换成
LongAdder,同时把"按秒上报"的逻辑从"每次自增都判断是否跨秒"改成定时任务里统一sum()取值,CPU 占用降了接近一半,吞吐提升了约 30%。但有一个指标我们故意没换——限流用的令牌计数,因为那里需要"减到 0 立刻拒绝"的精确语义,LongAdder.sum()的不精确会导致超额放行,所以保留了AtomicLong的decrementAndGet判断。这个取舍后来被我写进了团队的并发工具选型文档里。
题 9:线上用 @Async 做异步,结果线程数和内存都爆了,你怎么排查和治理?
精简口述答案:
根因几乎都是没有自定义线程池。Spring Boot 下
@Async不指定执行器时,优先找容器里唯一的TaskExecutorBean;找不到就退化成SimpleAsyncTaskExecutor——它每次调用都 new 一条新线程、完全不复用,QPS 一上来线程数直接线性增长,最后OutOfMemoryError: unable to create new native thread。如果引入了自动配置的ThreadPoolTaskExecutor,默认queueCapacity是Integer.MAX_VALUE的无界队列,表现就从"线程爆"变成"堆爆 + 任务积压看不见"。排查三步:
jstack看线程名(SimpleAsyncTaskExecutor-xxx一眼定性)→jmap/dump 看LinkedBlockingQueue里堆了多少任务 → 看ThreadPoolTaskExecutor的getActiveCount/getQueueSize指标。治理:显式声明
ThreadPoolTaskExecutorBean(core/max/queueCapacity 全部有界 + 自定义线程名前缀 +CallerRunsPolicy或业务降级拒绝策略 +setWaitForTasksToCompleteOnShutdown(true)和awaitTerminationSeconds做优雅停机),@Async("bizExecutor")按业务隔离多个池,并把队列深度、活跃线程数打到监控上。
连环追问链:
追问 1:不配线程池时到底用的哪个执行器,判定顺序是什么? 答:
AsyncAnnotationBeanPostProcessor的查找顺序是——① 注解里指定的 Bean 名;② 实现了AsyncConfigurer的getAsyncExecutor();③ 容器中唯一的TaskExecutor类型 Bean;④ 名为taskExecutor的ExecutorBean;⑤ 全都没有则 fallback 到SimpleAsyncTaskExecutor。注意容器里有两个以上TaskExecutor且都没加@Primary时,会按名字taskExecutor兜底,不是报错,所以很容易"以为用了自己的池、其实没用上"。追问 2:为什么同类里调自己的
@Async方法不生效? 答:@Async靠 AOP 代理实现,代理只在"外部调用进入 Bean"时生效。同类自调用this.asyncMethod()走的是原始对象引用,绕过了代理,所以直接同步执行。三种解法:① 把异步方法抽到另一个 Bean;② 注入自身代理(@Lazy注入自己 /ApplicationContext.getBean);③ 开@EnableAspectJAutoProxy(exposeProxy = true)后用AopContext.currentProxy()。同样的坑在@Transactional、@Cacheable上完全一致。追问 3:
@Async方法返回String为什么拿到 null?异常去哪了? 答:返回值只支持void、Future、CompletableFuture、ListenableFuture。返回其他类型时代理直接返回null(JDK 默认值),业务拿到 null 就 NPE。异常方面:返回Future的,异常被包进ExecutionException,只有调用get()才会暴露;返回void的,异常被AsyncUncaughtExceptionHandler吃掉,默认实现只打一条日志,很多项目连这条都没配,表现为"任务静默失败"。治理就是实现AsyncConfigurer#getAsyncUncaughtExceptionHandler统一兜底告警。追问 4:
@Async加在带@Transactional的方法上,事务还在吗? 答:事务上下文绑定在ThreadLocal(TransactionSynchronizationManager)上,不会跨线程传播。异步方法会开自己的新事务(如果它自己标了@Transactional),而且和调用方事务互不影响:主事务回滚了,异步里已提交的数据不会回滚;反过来更危险——主事务还没提交,异步线程已经去查这条数据,查不到(脏读反例)。正确做法是用@TransactionalEventListener(phase = AFTER_COMMIT)让异步动作在主事务提交后才触发。追问 5:拒绝策略怎么选?
CallerRunsPolicy有什么副作用? 答:AbortPolicy(默认抛RejectedExecutionException)适合"宁可失败也不能延迟"的场景,但要有兜底落库/重试;CallerRunsPolicy让调用线程自己执行,起到天然背压,但副作用是Web 容器的工作线程被占用,并发一高 Tomcat 线程被异步任务拖死,接口整体超时,所以只适合内部批处理;DiscardPolicy/DiscardOldestPolicy静默丢任务,生产基本禁用。推荐自定义策略:先落 MQ/DB 做削峰,再打点告警。追问 6:线程池怎么做优雅停机,不然会怎样? 答:不处理的话,容器关闭时线程池被直接丢弃,队列里的任务和正在执行的任务一起消失,表现为"发版时丢了一批通知"。
ThreadPoolTaskExecutor要配setWaitForTasksToCompleteOnShutdown(true)+setAwaitTerminationSeconds(60),Spring 会在容器销毁时先shutdown()再awaitTermination。同时要注意:这个等待会阻塞关闭流程,所以超时时间要小于 K8s 的terminationGracePeriodSeconds,否则还是被 SIGKILL。
✅ 项目关联话术(按真实经历替换):
我们的订单系统用
@Async发履约通知,上线后压测时jstack发现几千条SimpleAsyncTaskExecutor-N线程,定位到就是没声明执行器 Bean。治理时我按业务拆了三个池(通知 / 日志上报 / 对账),全部有界队列 + 线程名前缀,拒绝策略改成"落 MQ 兜底 + 告警",并把activeCount / queueSize / 拒绝数三个指标接到监控看板,设置队列使用率 > 70% 告警。同期还修了两个隐藏 bug:一个是同类自调用导致"异步"其实是同步,压测 TP99 一直下不来;另一个是异步方法里查主事务未提交的数据偶发查不到,改成@TransactionalEventListener(AFTER_COMMIT)后彻底消失。最后补了AsyncUncaughtExceptionHandler和优雅停机配置,发版丢通知的问题也一起解决了。
题 10:什么是对象的"安全发布"?为什么 DCL 单例必须加 volatile,而不可变对象发布却不用?
精简口述答案:
安全发布是指:一个对象被其他线程看到时,它内部的状态已经完整初始化。问题的根源是
new Obj()不是原子操作,它被编译成三步——① 分配内存、② 调用构造器初始化字段、③ 把引用赋给变量。JIT 允许把 ② 和 ③ 重排序(单线程下 as-if-serial 看不出差别),于是另一个线程可能先看到非 null 的引用、但字段还是默认值 0/null。DCL 里第二个线程走的是"第一层 if 判空 → 直接返回"这条无锁路径,它和写线程之间没有任何 happens-before 关系,所以必须给字段加
volatile:写 volatile 前的所有写操作都不能重排到它后面(StoreStore 屏障),读 volatile 后的读也不能提前(LoadLoad),这样读到非 null 就一定能看到完整对象。不可变对象不用 volatile,是因为 JMM 对
final域有专门的两条重排序规则:构造器内对 final 字段的写入,不能重排到构造器之外;读取对象引用和读取该对象的 final 字段也不能重排。相当于 JVM 在构造器末尾插了 StoreStore 屏障,白送一个安全发布。前提是构造期间this不能逸出。
连环追问链:
追问 1:不加 volatile 的 DCL 具体会怎么崩? 答:线程 A 执行
instance = new Singleton(),因为重排序,引用先被赋值、字段还没写。此时线程 B 在第一层if (instance == null)判定为 false,直接return instance,拿到一个"半初始化"对象,读到的config字段是 null,于是 NPE。这个问题的概率极低(取决于 JIT 是否真的重排、CPU 架构是否乱序——x86 上 store 不乱序所以几乎测不出来,ARM 上就可能复现),所以它是典型的"测试测不出、线上偶发一次"的 bug,必须靠规则保证而不是靠测。追问 2:
final的两条重排序规则具体是什么?final字段指向可变集合还安全吗? 答:规则一(写):构造器内对 final 域的写入 + 随后把这个对象引用赋给引用变量,这两步不能重排序。规则二(读):首次读对象引用 + 随后读该对象的 final 域,不能重排序。但 final 只保证"引用本身"的可见性,不保证引用指向的对象内容不可变。private final List<String> names如果直接把外部传入的 List 存起来,外部还持有引用、随时能改,照样线程不安全。必须做防御性拷贝:构造时this.names = List.copyOf(in),getter 也不能直接返回内部集合。追问 3:有哪几种安全发布的方式? 答:① 在静态初始化器里初始化(
static final或静态内部类单例,由 JVM 的类初始化锁保证);② 把引用保存到volatile字段或AtomicReference;③ 把引用保存到正确构造对象的final字段;④ 把引用保存到由锁保护的字段(读写都在同一把锁内)。另外线程安全容器(ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue)的 put/offer 也天然提供安全发布保证。追问 4:什么是
this逸出?举几个真实写法。 答:构造器还没执行完,对象引用就被外部拿到了,此时字段可能还是默认值。三种典型写法:① 构造器里new Thread(this::run).start(),新线程可能先跑起来读到字段为 0;② 构造器里source.register(this)注册监听器,事件可能在构造完成前就回调进来;③ 构造器里调用可被子类重写的方法,子类的 override 实现访问子类字段时,子类构造器还没执行、字段全是默认值,表现为莫名 NPE。解法统一是:构造器只做字段赋值,需要"启动/注册"的动作抽到静态工厂方法里,构造完成后再做。追问 5:不可变对象在"需要更新"的场景怎么用?引用需要 volatile 吗? 答:不可变对象的更新方式是整体换引用(copy-on-write 思路):生成一个新的 Snapshot 对象,然后把持有它的字段整体替换。这时候持有引用的那个字段必须是
volatile或AtomicReference——final规则只保证"对象内部字段的可见性",不保证"引用替换本身对别的线程立刻可见"。这是最容易混的一点:对象不可变解决的是"看到的对象是否完整",volatile 解决的是"是否看到了最新那个对象"。配置热更新、路由表热加载都是这个套路。追问 6:安全发布和线程安全是一回事吗? 答:不是。安全发布只保证"发布瞬间看到的是完整对象",发布之后能不能被多线程并发修改是另一个问题。通常把类分成五级:① 不可变(immutable,发布后无需任何同步);② 线程安全(自己内部同步,如
ConcurrentHashMap);③ 有条件线程安全(单个方法安全,组合操作要外部加锁,如Collections.synchronizedMap的"查了再放");④ 非线程安全(调用方全权负责,如ArrayList);⑤ 线程对立(无论怎么加锁都不安全,极少见)。写类的时候应该在注释里明确标出自己属于哪一级。
✅ 项目关联话术(按真实经历替换):
我们做过一次配置中心的本地缓存,最初是
Map<String, String> config字段,监听到变更后直接config.clear()+putAll(),结果业务线程偶发读到"清空后、还没填充"的中间状态,拿到 null 走了默认分支。重构时改成不可变 Snapshot:把配置封成一个只有final字段、构造器里Map.copyOf的ConfigSnapshot,变更时整体 new 一个新对象,再赋给volatile ConfigSnapshot current。这样读侧完全无锁、不需要加 synchronized,也彻底没有中间态。当时还顺手发现一处this逸出——有个组件在构造器里就把自己register到事件总线上,初始化慢的时候会收到回调读到空字段,改成静态工厂create()里构造完再注册就好了。
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:手写线程安全的单例(DCL + 静态内部类 + 枚举)
核心实现:
java
/* ========== 写法一:双重检查锁 DCL(必须 volatile) ========== */
public class SingletonDCL {
// volatile 的两个作用:1) 禁止 new 的指令重排 2) 保证写入对其他线程立即可见
private static volatile SingletonDCL instance;
private SingletonDCL() {
// 防反射攻击:第二次通过反射调用构造器时直接抛异常
if (instance != null) {
throw new IllegalStateException("单例已存在,禁止反射创建");
}
}
public static SingletonDCL getInstance() {
if (instance == null) { // 第一次检查:无锁快速路径,99.99% 的调用走这里
synchronized (SingletonDCL.class) {
if (instance == null) { // 第二次检查:防止多个线程都通过了第一次检查
instance = new SingletonDCL();
}
}
}
return instance;
}
}
/* ========== 写法二:静态内部类(推荐,无锁且懒加载) ========== */
public class SingletonHolder {
private SingletonHolder() {}
// Holder 类只有在第一次被主动引用时才会被类加载器初始化
private static class Holder {
private static final SingletonHolder INSTANCE = new SingletonHolder();
}
public static SingletonHolder getInstance() {
return Holder.INSTANCE; // 线程安全由 JVM 的类初始化锁保证,零同步开销
}
}
/* ========== 写法三:枚举(Effective Java 首推,天然防反射 + 防序列化) ========== */
public enum SingletonEnum {
INSTANCE;
private final Map<String, String> cache = new ConcurrentHashMap<>();
public void put(String k, String v) { cache.put(k, v); }
public String get(String k) { return cache.get(k); }
}
/* ========== 写法四:饿汉式(简单,但不支持懒加载) ========== */
public class SingletonEager {
private static final SingletonEager INSTANCE = new SingletonEager();
private SingletonEager() {}
public static SingletonEager getInstance() { return INSTANCE; }
}
/* ========== 写法五:基于 CAS 的无锁单例(了解即可,有重复创建开销) ========== */
public class SingletonCas {
private static final AtomicReference<SingletonCas> REF = new AtomicReference<>();
private SingletonCas() {}
public static SingletonCas getInstance() {
for (;;) {
SingletonCas cur = REF.get();
if (cur != null) {
return cur;
}
SingletonCas candidate = new SingletonCas(); // 可能被创建多次然后丢弃
if (REF.compareAndSet(null, candidate)) {
return candidate;
}
}
}
}
/* ========== 配套:DCL 版本防序列化破坏 ========== */
class SingletonSerializable implements Serializable {
private static final long serialVersionUID = 1L;
private static volatile SingletonSerializable instance;
private SingletonSerializable() {}
public static SingletonSerializable getInstance() {
if (instance == null) {
synchronized (SingletonSerializable.class) {
if (instance == null) {
instance = new SingletonSerializable();
}
}
}
return instance;
}
// 反序列化时 JVM 会优先调用这个方法,返回已有实例,从而避免生成新对象
private Object readResolve() {
return getInstance();
}
}三个必须记住的点:
volatile不是为了可见性那么简单:instance = new SingletonDCL()分三步——① 分配内存 ② 执行构造器初始化 ③ 把引用指向内存。②③ 之间允许重排,一旦重排成 ①③②,另一个线程在第一次检查时就会拿到一个非 null 但字段全是默认值的半成品对象。volatile通过插入StoreStore/StoreLoad屏障禁止这次重排。- 两次检查都不能省:省掉第一次检查等于每次
getInstance都进synchronized,性能退化;省掉第二次检查则多个线程都可能通过第一次检查,排队进入同步块后各自 new 一个对象。- 静态内部类的线程安全来自 JVM 规范:类的初始化(
<clinit>)由 JVM 用一把初始化锁保证只执行一次,且对外部线程有 happens-before 保证。它比 DCL 更简洁,而且没有 volatile 读的屏障开销,是实际项目里的首选。
✅ 边界 Case 自测:
| 测试场景 | 期望行为 |
|---|---|
1000 个线程并发调 getInstance() 后收集结果去重 | Set 大小必须为 1,所有引用 == 相等 |
DCL 版本去掉 volatile,在高并发下反复测试 | 理论上可能拿到字段未初始化的对象(x86 下难复现,arm/ppc 概率更高) |
用反射 setAccessible(true) 调私有构造器 | DCL 版抛 IllegalStateException;枚举版抛 NoSuchMethodException(枚举无可用构造器) |
| 序列化到字节流再反序列化,对比引用 | 加了 readResolve 的版本引用相同;没加的版本会产生新对象 |
| 枚举单例序列化反序列化 | 引用始终相同,JVM 对枚举的序列化有特殊保证 |
静态内部类版本:只加载外部类不调 getInstance | 通过 -verbose:class 确认 Holder 类没有被初始化(懒加载生效) |
| CAS 版本并发压测,统计构造器被执行的次数 | 次数可能 > 1(多余对象被丢弃),说明构造器必须无副作用 |
| 构造器内部抛异常 | DCL 版:instance 保持 null,下次调用会重新尝试;静态内部类版:抛 ExceptionInInitializerError,该类永久不可用(后续访问抛 NoClassDefFoundError) |
| 单例对象内部有可变状态,多线程读写 | 单例只保证"对象唯一",不保证内部状态线程安全,成员变量仍需 ConcurrentHashMap / 加锁 |
| 多个类加载器分别加载该类 | 每个类加载器一份实例,"单例"在 JVM 范围内被打破(OSGi / 热部署场景的真实坑) |
🔁 变种拓展题:
- 写一个泛型的"单例注册表":
<T> T getSingleton(Class<T> clazz),用ConcurrentHashMap<Class<?>, Object>+computeIfAbsent保证每个类型只创建一次。注意computeIfAbsent对同一个 key 会加锁,回调里不能再操作同一个 map(否则死锁或IllegalStateException)。 - 实现一个支持懒加载的
Lazy<T>容器:Lazy.of(Supplier<T>),首次get()时计算并缓存,要求线程安全且 Supplier 只被执行一次(参考 Guava 的Suppliers.memoize,内部就是 DCL)。 - 把 DCL 改成用
VarHandle实现,用getAcquire/setRelease替代 volatile 的全屏障,对比两者生成的汇编(JDK 9+ 的性能优化手法)。 - 实现一个"每个 key 一个单例"的对象池:比如按
shardId缓存连接,要求不同 key 不互斥、同一 key 只创建一次。考察点是分段锁或ConcurrentHashMap+FutureTask的经典组合(避免重复创建昂贵对象)。 - 解释为什么 Spring 的单例 Bean 不需要 DCL:Spring 的
DefaultSingletonBeanRegistry.getSingleton用的是synchronized (this.singletonObjects)加上三级缓存,而且容器启动阶段基本是单线程完成的,设计目标是解决循环依赖而不是并发创建。 - 延伸到分布式单例:单 JVM 单例在集群里会变成 N 个实例。如果需要"全局只有一个任务在跑",要用 Redis 分布式锁 / ZooKeeper 临时节点 / 数据库唯一键来做选主,说清楚和本地单例的本质区别。
题 2:手写一个简易线程池(阻塞队列 + Worker + 拒绝策略)
核心实现:
java
public class MyThreadPool {
private final int coreSize; // 核心线程数
private final int maxSize; // 最大线程数
private final long keepAliveNanos; // 非核心线程空闲存活时间
private final BlockingQueue<Runnable> queue; // 任务队列
private final RejectHandler rejectHandler; // 拒绝策略
private final ThreadFactory threadFactory;
/** 用一个 int 的高 3 位存状态、低 29 位存线程数,和 JDK 的 ctl 设计一致 */
private static final int COUNT_BITS = 29;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private final AtomicInteger ctl = new AtomicInteger(RUNNING);
private static int runStateOf(int c) { return c & ~COUNT_MASK; }
private static int workerCountOf(int c) { return c & COUNT_MASK; }
/** 用一把锁 + Set 管理 Worker,用于 shutdown 时遍历中断 */
private final ReentrantLock mainLock = new ReentrantLock();
private final Set<Worker> workers = new HashSet<>();
private final Condition termination = mainLock.newCondition();
public interface RejectHandler {
void reject(Runnable task, MyThreadPool pool);
}
public MyThreadPool(int coreSize, int maxSize, long keepAlive, TimeUnit unit,
BlockingQueue<Runnable> queue, RejectHandler handler) {
if (coreSize < 0 || maxSize <= 0 || maxSize < coreSize || keepAlive < 0) {
throw new IllegalArgumentException("参数非法");
}
this.coreSize = coreSize;
this.maxSize = maxSize;
this.keepAliveNanos = unit.toNanos(keepAlive);
this.queue = Objects.requireNonNull(queue);
this.rejectHandler = Objects.requireNonNull(handler);
AtomicInteger seq = new AtomicInteger();
this.threadFactory = r -> {
Thread t = new Thread(r, "my-pool-worker-" + seq.incrementAndGet());
t.setUncaughtExceptionHandler((th, e) ->
System.err.println("[线程池] 线程 " + th.getName() + " 异常退出: " + e));
return t;
};
}
/* ======================= 提交任务:四级流水线 ======================= */
public void execute(Runnable task) {
Objects.requireNonNull(task, "任务不能为 null");
int c = ctl.get();
// 第 1 级:线程数 < 核心数,直接新建核心线程
if (workerCountOf(c) < coreSize) {
if (addWorker(task, true)) {
return;
}
c = ctl.get(); // 添加失败(并发或状态变化),重新读取
}
// 第 2 级:池是 RUNNING 且队列能放下,就入队
if (runStateOf(c) == RUNNING && queue.offer(task)) {
// 二次校验:入队后池可能已经 shutdown,或者 worker 全死了
int recheck = ctl.get();
if (runStateOf(recheck) != RUNNING && queue.remove(task)) {
rejectHandler.reject(task, this);
} else if (workerCountOf(recheck) == 0) {
addWorker(null, false); // 补一个空 worker 来消费队列,防止任务饿死
}
return;
}
// 第 3 级:队列满了,尝试创建非核心线程
if (addWorker(task, false)) {
return;
}
// 第 4 级:线程数已到 maxSize,执行拒绝策略
rejectHandler.reject(task, this);
}
/* ======================= 创建 Worker ======================= */
private boolean addWorker(Runnable firstTask, boolean core) {
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// 非 RUNNING 状态下只允许"无新任务 + 队列还有残留"的补偿式创建
if (rs != RUNNING && !(rs == SHUTDOWN && firstTask == null && !queue.isEmpty())) {
return false;
}
int wc = workerCountOf(c);
if (wc >= (core ? coreSize : maxSize)) {
return false;
}
if (ctl.compareAndSet(c, c + 1)) { // CAS 抢占一个"名额"
break;
}
// CAS 失败说明状态或数量被改了,重试整个循环
}
Worker w = new Worker(firstTask);
boolean added = false;
mainLock.lock();
try {
if (runStateOf(ctl.get()) == RUNNING
|| (runStateOf(ctl.get()) == SHUTDOWN && firstTask == null)) {
workers.add(w);
added = true;
}
} finally {
mainLock.unlock();
}
if (added) {
w.thread.start();
return true;
}
ctl.decrementAndGet(); // 回滚名额
return false;
}
/* ======================= Worker:线程的载体 ======================= */
private final class Worker implements Runnable {
final Thread thread;
Runnable firstTask;
long completedTasks;
Worker(Runnable firstTask) {
this.firstTask = firstTask;
this.thread = threadFactory.newThread(this);
}
@Override
public void run() {
Runnable task = firstTask;
firstTask = null; // 关键:置空,避免长期持有引用导致内存泄漏
boolean abruptly = true;
try {
while (task != null || (task = getTask()) != null) {
try {
task.run();
} catch (Throwable t) {
// 必须捕获 Throwable:否则异常会冲出 run(),线程直接死亡
System.err.println("[线程池] 任务执行异常: " + t);
} finally {
task = null; // 同样要置空,让任务对象尽早可回收
completedTasks++;
}
}
abruptly = false; // 正常退出(getTask 返回 null)
} finally {
processWorkerExit(this, abruptly);
}
}
}
/* ======================= 取任务:核心阻塞 / 非核心超时 ======================= */
private Runnable getTask() {
boolean timedOut = false;
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// STOP 直接退;SHUTDOWN 且队列已空也退(保证 shutdown 后把残留任务跑完)
if (rs >= STOP || (rs == SHUTDOWN && queue.isEmpty())) {
ctl.decrementAndGet();
return null;
}
int wc = workerCountOf(c);
boolean timed = wc > coreSize; // 超出核心数的线程才需要超时回收
if ((wc > maxSize || (timed && timedOut)) && (wc > 1 || queue.isEmpty())) {
if (ctl.compareAndSet(c, c - 1)) {
return null; // 该线程被回收
}
continue; // CAS 失败重试
}
try {
Runnable r = timed
? queue.poll(keepAliveNanos, TimeUnit.NANOSECONDS)
: queue.take();
if (r != null) {
return r;
}
timedOut = true; // poll 超时,下一轮循环执行回收判断
} catch (InterruptedException e) {
timedOut = false; // 被中断(shutdown 触发),回到循环重新判断状态
}
}
}
private void processWorkerExit(Worker w, boolean abruptly) {
if (abruptly) {
ctl.decrementAndGet(); // 异常退出的线程没有在 getTask 里减过数
}
mainLock.lock();
try {
workers.remove(w);
if (workers.isEmpty()) {
termination.signalAll(); // 唤醒 awaitTermination
}
} finally {
mainLock.unlock();
}
// 异常退出要补一个线程,保证池子不被"消耗"光
int c = ctl.get();
if (runStateOf(c) == RUNNING) {
if (abruptly || workerCountOf(c) < Math.max(coreSize, queue.isEmpty() ? 0 : 1)) {
addWorker(null, false);
}
}
}
/* ======================= 关闭 ======================= */
/** 优雅关闭:不收新任务,已入队的任务继续执行完 */
public void shutdown() {
mainLock.lock();
try {
advanceRunState(SHUTDOWN);
for (Worker w : workers) {
// 只中断空闲线程(正在 take/poll 阻塞的),不打断正在干活的
if (!w.thread.isInterrupted() && w.thread.getState() == Thread.State.WAITING
|| w.thread.getState() == Thread.State.TIMED_WAITING) {
w.thread.interrupt();
}
}
} finally {
mainLock.unlock();
}
}
/** 强制关闭:丢弃队列中未执行的任务,中断所有线程 */
public List<Runnable> shutdownNow() {
List<Runnable> remaining = new ArrayList<>();
mainLock.lock();
try {
advanceRunState(STOP);
for (Worker w : workers) {
w.thread.interrupt();
}
queue.drainTo(remaining);
} finally {
mainLock.unlock();
}
return remaining;
}
private void advanceRunState(int targetState) {
for (;;) {
int c = ctl.get();
if (runStateOf(c) >= targetState
|| ctl.compareAndSet(c, targetState | workerCountOf(c))) {
return;
}
}
}
public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
long nanos = unit.toNanos(timeout);
mainLock.lock();
try {
while (!workers.isEmpty()) {
if (nanos <= 0) {
return false;
}
nanos = termination.awaitNanos(nanos);
}
return true;
} finally {
mainLock.unlock();
}
}
public int getPoolSize() { return workerCountOf(ctl.get()); }
public int getQueueSize() { return queue.size(); }
/* ======================= 四种拒绝策略 ======================= */
public static final RejectHandler ABORT = (task, pool) -> {
throw new RejectedExecutionException("任务被拒绝: " + task);
};
public static final RejectHandler DISCARD = (task, pool) -> { /* 静默丢弃 */ };
public static final RejectHandler DISCARD_OLDEST = (task, pool) -> {
pool.queue.poll(); // 丢掉最老的那个
pool.execute(task);
};
public static final RejectHandler CALLER_RUNS = (task, pool) -> {
if (runStateOf(pool.ctl.get()) == RUNNING) {
task.run(); // 由提交任务的线程自己执行,天然形成背压
}
};
}三个必须记住的点:
while (task != null || (task = getTask()) != null)是整个线程池"复用"的灵魂。线程不会执行完一个任务就结束,而是在循环里不断从队列阻塞获取下一个任务。核心线程用take()永久阻塞,非核心线程用poll(keepAlive)超时后返回 null 跳出循环从而被回收——这就是keepAliveTime的全部实现。task.run()外面必须包try-catch(Throwable)。一旦异常冲出Worker.run(),这个线程就彻底死亡,池子会被慢慢"吃光"。虽然processWorkerExit会补线程,但频繁创建销毁线程的代价很高,而且异常被静默吞掉会丢失关键日志。ctl用一个AtomicInteger同时存状态和线程数,是为了让"状态校验 + 线程数检查 + 占名额"三件事能在一次 CAS 里原子完成。如果拆成两个变量,就必须加锁才能保证一致性,而execute是最热的路径,不能有锁。
✅ 边界 Case 自测:
| 测试场景 | 期望行为 |
|---|---|
| 提交 1 个任务(core=2) | 创建 1 个核心线程执行,getPoolSize() == 1 |
| core=2, max=4, queue 容量 2,连续提交 8 个任务 | 2 核心 + 2 入队 + 2 非核心,第 7、8 个被拒绝 |
core=0, max=2, 用 SynchronousQueue | 每个任务都直接交给线程,队列不存任务(类似 CachedThreadPool) |
任务里抛 RuntimeException | 被 catch 并打日志,线程继续处理下一个任务,池大小不变 |
任务里抛 OutOfMemoryError | 同样被 Throwable 捕获;但 OOM 后行为不可预期,生产上应配 -XX:+ExitOnOutOfMemoryError |
提交 null 任务 | 抛 NullPointerException,不能静默忽略 |
| 非核心线程空闲超过 keepAlive | 线程被回收,getPoolSize() 降回 coreSize |
shutdown() 后再 execute | 走拒绝策略;队列里已有的任务必须全部执行完 |
shutdownNow() 后检查返回值 | 返回所有未执行的任务,队列被清空,正在执行的任务收到中断信号 |
awaitTermination(1, SECONDS) 但任务要跑 5 秒 | 返回 false,且不影响任务继续执行 |
1000 个线程并发 execute,统计实际执行次数 | 执行数 + 拒绝数 == 1000,一个不多一个不少(验证 ctl 的 CAS 正确性) |
| 所有线程都在执行长任务时提交新任务 | 入队 → 队列满 → 扩到 maxSize → 拒绝,顺序不能错 |
队列用无界 LinkedBlockingQueue | maxSize 永远不生效(第 3 级流水线到不了),任务无限堆积直到 OOM |
🔁 变种拓展题:
- 支持
submit返回Future:自己实现一个简化版FutureTask,内部用CountDownLatch或 AQS 保存结果/异常,get()阻塞等待,get(timeout)超时抛TimeoutException。注意异常要包装成ExecutionException。 - 支持动态调参:实现
setCorePoolSize(int),调小时要中断多余的空闲线程,调大时如果队列非空要立即补线程。再实现一个可变容量的队列(LinkedBlockingQueue的capacity是 final 的,要用反射改或自己写),这是美团动态线程池的核心。 - 加监控埋点:记录任务排队时长(提交时打时间戳,执行前算差值)、执行耗时、队列峰值、拒绝次数,并支持
beforeExecute/afterExecute钩子(可以用来做 MDC traceId 传递和 ThreadLocal 清理)。 - 实现定时任务能力:把队列换成
DelayQueue或自己写一个基于小顶堆的DelayedWorkQueue,支持schedule(task, delay)和scheduleAtFixedRate。重点说清scheduleAtFixedRate和scheduleWithFixedDelay的区别,以及任务抛异常会让后续调度永久停止这个大坑。 - 实现任务优先级:队列换成
PriorityBlockingQueue,任务实现Comparable。要指出两个坑:优先级队列无界(容量只会扩容),以及低优先级任务可能饥饿。 - 实现线程池的"预热":
prestartAllCoreThreads(),在服务启动时就把核心线程创建好,避免第一批请求承担线程创建的开销(配合压测预热很有用)。 - 对比 Tomcat 的线程池改造:Tomcat 重写了
TaskQueue.offer,让它在"线程数还没到 max"时故意返回 false,从而先扩容线程再入队,这和 JDK 的"先入队后扩容"策略完全相反。解释为什么 IO 密集的 Web 场景更适合 Tomcat 这种策略。
题 3:三个线程循环交替打印 ABC(五种解法)
核心实现:
java
/* ========== 解法一:synchronized + wait/notifyAll(最朴素,必须用 notifyAll) ========== */
class PrintAbcSync {
private final Object lock = new Object();
private int state = 0; // 0->A, 1->B, 2->C
private final int loop;
PrintAbcSync(int loop) { this.loop = loop; }
public void print(String name, int target) {
for (int i = 0; i < loop; i++) {
synchronized (lock) {
// 必须用 while 而不是 if:防止虚假唤醒 + 被唤醒后条件可能已不满足
while (state % 3 != target) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
System.out.print(name);
state++;
// 只能 notifyAll:notify 随机唤醒一个,可能唤醒的不是下一个该跑的线程 -> 死锁
lock.notifyAll();
}
}
}
}
/* ========== 解法二:ReentrantLock + 三个 Condition(精准唤醒,推荐) ========== */
class PrintAbcCondition {
private final ReentrantLock lock = new ReentrantLock();
private final Condition condA = lock.newCondition();
private final Condition condB = lock.newCondition();
private final Condition condC = lock.newCondition();
private int state = 0;
private final int loop;
PrintAbcCondition(int loop) { this.loop = loop; }
private void doPrint(String name, int target, Condition self, Condition next)
throws InterruptedException {
for (int i = 0; i < loop; i++) {
lock.lock();
try {
while (state % 3 != target) {
self.await(); // 只挂在自己的条件队列上
}
System.out.print(name);
state++;
next.signal(); // 精准唤醒下一个,没有惊群
} finally {
lock.unlock(); // unlock 必须在 finally,否则异常会导致死锁
}
}
}
public void printA() throws InterruptedException { doPrint("A", 0, condA, condB); }
public void printB() throws InterruptedException { doPrint("B", 1, condB, condC); }
public void printC() throws InterruptedException { doPrint("C", 2, condC, condA); }
}
/* ========== 解法三:Semaphore(信号量传递令牌,最简洁) ========== */
class PrintAbcSemaphore {
// A 初始有 1 个许可,B、C 初始 0 个,形成一条环形传递链
private final Semaphore semA = new Semaphore(1);
private final Semaphore semB = new Semaphore(0);
private final Semaphore semC = new Semaphore(0);
private final int loop;
PrintAbcSemaphore(int loop) { this.loop = loop; }
private void doPrint(String name, Semaphore self, Semaphore next)
throws InterruptedException {
for (int i = 0; i < loop; i++) {
self.acquire(); // 拿到许可才能打印
System.out.print(name);
next.release(); // 把许可交给下一个
}
}
public void printA() throws InterruptedException { doPrint("A", semA, semB); }
public void printB() throws InterruptedException { doPrint("B", semB, semC); }
public void printC() throws InterruptedException { doPrint("C", semC, semA); }
}java
/* ========== 解法四:LockSupport park/unpark(无锁,性能最好) ========== */
class PrintAbcLockSupport {
private static Thread tA, tB, tC;
private static final int LOOP = 10;
public static void run() throws InterruptedException {
tA = new Thread(() -> {
for (int i = 0; i < LOOP; i++) {
System.out.print("A");
LockSupport.unpark(tB); // 先 unpark 下一个
LockSupport.park(); // 再把自己挂起,等下一轮
}
}, "A");
tB = new Thread(() -> {
for (int i = 0; i < LOOP; i++) {
LockSupport.park(); // B 先挂起,等 A 叫醒
System.out.print("B");
LockSupport.unpark(tC);
}
}, "B");
tC = new Thread(() -> {
for (int i = 0; i < LOOP; i++) {
LockSupport.park();
System.out.print("C");
LockSupport.unpark(tA); // 闭环,叫醒 A 开始下一轮
}
}, "C");
tA.start(); tB.start(); tC.start();
tA.join(); tB.join(); tC.join();
}
}
/* ========== 解法五:AtomicInteger 自旋(无阻塞,但烧 CPU,仅适合演示) ========== */
class PrintAbcAtomic {
private final AtomicInteger state = new AtomicInteger(0);
private final int loop;
PrintAbcAtomic(int loop) { this.loop = loop; }
public void doPrint(String name, int target) {
for (int i = 0; i < loop; ) {
if (state.get() % 3 == target) {
System.out.print(name);
state.incrementAndGet();
i++;
} else {
Thread.onSpinWait(); // JDK 9+:生成 pause 指令,降低自旋功耗
}
}
}
}三个必须记住的点:
wait必须写在while里,不能写if。原因有两个:一是wait可能发生虚假唤醒(spurious wakeup,POSIX 规范允许);二是notifyAll会唤醒所有等待者,被唤醒的线程重新抢到锁时条件可能已经被别人改掉了。规范写法永远是"循环检查条件谓词"。- 解法一只能用
notifyAll,不能用notify。notify从_WaitSet里随机挑一个唤醒。假设当前该打印 B,而notify唤醒的是 C,C 发现条件不满足又wait回去,于是没有任何线程能继续——整个程序死锁。用notifyAll虽然有惊群开销,但保证正确。想要既精准又高效就得用解法二的多 Condition。LockSupport解法的正确性依赖"许可可以预先发放"。即使 A 的unpark(tB)发生在 B 的park()之前,B 的 permit 已经被置为 1,park()会直接消耗许可并立即返回,不会阻塞。这是wait/notify做不到的(先 notify 后 wait 会永久丢失通知),也是 AQS 选它做底座的根本原因。
✅ 边界 Case 自测:
| 测试场景 | 期望行为 |
|---|---|
三个线程按 C、B、A 的顺序 start() | 输出依然是 ABCABC...,顺序由 state 控制而非启动顺序决定 |
把解法一的 notifyAll 改成 notify | 大概率立刻死锁(jstack 可见三个线程全在 Object.wait) |
把 while 改成 if | 可能出现 AABC、ABBC 这类乱序输出 |
循环 100000 次,用 StringBuilder 收集后校验 | 结果必须严格等于 "ABC" 重复 100000 次,一个字符不差 |
解法二里把 next.signal() 改成 self.signal() | 永久阻塞,因为唤醒的是自己的条件队列(里面没人) |
解法二忘记 lock.unlock() 放 finally,中途抛异常 | 其他线程永久阻塞在 lock() 上,死锁 |
解法三把 semA 初始许可改成 0 | 三个线程全部阻塞在 acquire(),程序挂死(必须有一个"启动令牌") |
解法三把 semA 初始许可改成 2 | 输出乱序(AABBCC 之类),许可数决定了并发度 |
解法四把 A 的 unpark 和 park 调换顺序 | A 先 park 自己,没人 unpark 它,三线程全挂死 |
解法四中 tB 还是 null 时就 unpark(tB) | 抛 NPE,所以必须先把三个 Thread 对象都创建完再 start() |
解法五用 top 观察 CPU | 三个核心接近 100%,印证自旋方案不能用于生产 |
| 主线程中断其中一个打印线程 | 解法一二应能退出循环并恢复中断标志;解法五的自旋无法响应中断(需要额外检查标志位) |
🔁 变种拓展题:
- N 个线程循环打印 1~N:把
state % 3泛化成state % n,用Condition[]数组管理,next = conds[(target + 1) % n]。考察能否把硬编码抽象成通用模型。 - 两个线程交替打印
1A2B3C...26Z:数字线程打印数字后唤醒字母线程,字母线程打印字母后唤醒数字线程。注意最后一轮的收尾,不要让某个线程永久阻塞。 - 按奇偶交替打印 1~100:两个线程,一个只打奇数一个只打偶数。可以用
AtomicInteger+ 自旋,也可以用synchronized+wait/notify(这里可以用notify,因为只有一个等待者)。 - 多个线程按顺序执行(不循环):A 执行完 B 才能开始,B 完 C 才能开始。最佳解法是
CompletableFuture.thenRun链式编排,或者join(),不需要任何锁。 - 三个线程打印 ABC,但要求 B 打印两次(
ABBCABBC):考察状态机设计能力,把state % 3换成一个显式的状态转移表,而不是靠取模硬算。 - 用
BlockingQueue实现:三个容量为 1 的队列串成环,每个线程从自己的队列take一个令牌、打印、往下一个队列put。本质和 Semaphore 一样,但能顺带考察阻塞队列 API。 - 压测对比五种方案的吞吐:用 JMH 测 100 万次交替的耗时,预期排序大致是
LockSupport > Semaphore > Condition > synchronized/notifyAll > AtomicInteger 自旋(自旋在三线程争抢下反而最差)。解释差异来自"唤醒精准度"和"内核态切换次数"。
题 4:手写生产者消费者(三种实现)
核心实现:
java
/* ========== 版本一:synchronized + wait/notifyAll ========== */
class BufferSync<T> {
private final LinkedList<T> buf = new LinkedList<>();
private final int capacity;
BufferSync(int capacity) { this.capacity = capacity; }
public void put(T t) throws InterruptedException {
synchronized (this) {
while (buf.size() == capacity) { // 满了就等
this.wait();
}
buf.addLast(t);
this.notifyAll(); // 唤醒可能在等"非空"的消费者
}
}
public T take() throws InterruptedException {
synchronized (this) {
while (buf.isEmpty()) { // 空了就等
this.wait();
}
T t = buf.removeFirst();
this.notifyAll(); // 唤醒可能在等"未满"的生产者
return t;
}
}
}
/* ========== 版本二:ReentrantLock + 两个 Condition(JDK ArrayBlockingQueue 的做法) ========== */
class BufferLock<T> {
private final Object[] items;
private int putIndex, takeIndex, count;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
BufferLock(int capacity) { this.items = new Object[capacity]; }
public void put(T t) throws InterruptedException {
Objects.requireNonNull(t);
lock.lock();
try {
while (count == items.length) {
notFull.await(); // 生产者只挂在 notFull 队列
}
items[putIndex] = t;
putIndex = (putIndex + 1) % items.length; // 环形数组,避免数据搬移
count++;
notEmpty.signal(); // 精准唤醒一个消费者
} finally {
lock.unlock();
}
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await();
}
T t = (T) items[takeIndex];
items[takeIndex] = null; // 置 null,避免内存泄漏
takeIndex = (takeIndex + 1) % items.length;
count--;
notFull.signal();
return t;
} finally {
lock.unlock();
}
}
/** 带超时的 offer:awaitNanos 返回剩余时间,必须用返回值重新赋值 */
public boolean offer(T t, long timeout, TimeUnit unit) throws InterruptedException {
long nanos = unit.toNanos(timeout);
lock.lockInterruptibly();
try {
while (count == items.length) {
if (nanos <= 0L) {
return false;
}
nanos = notFull.awaitNanos(nanos); // 关键:防止"被唤醒后重新等满一个超时"
}
items[putIndex] = t;
putIndex = (putIndex + 1) % items.length;
count++;
notEmpty.signal();
return true;
} finally {
lock.unlock();
}
}
}
/* ========== 版本三:直接用 BlockingQueue + 毒丸优雅停机(生产推荐) ========== */
class ProducerConsumerApp {
/** 毒丸对象:消费者拿到它就退出,避免用 interrupt 粗暴中断导致数据丢失 */
private static final String POISON = new String("__POISON__");
public static void main(String[] args) throws Exception {
BlockingQueue<String> queue = new ArrayBlockingQueue<>(100);
int consumerCount = 3;
ExecutorService pool = new ThreadPoolExecutor(
4, 4, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(16),
new ThreadFactoryBuilder().setNameFormat("pc-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
// 消费者
for (int i = 0; i < consumerCount; i++) {
pool.execute(() -> {
try {
for (;;) {
String msg = queue.take();
if (msg == POISON) { // 注意:用 == 比较身份,不是 equals
break;
}
handle(msg);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// 生产者
pool.execute(() -> {
try {
for (int i = 0; i < 1000; i++) {
queue.put("msg-" + i);
}
for (int i = 0; i < consumerCount; i++) {
queue.put(POISON); // 每个消费者发一颗毒丸
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
}
private static void handle(String msg) {
try {
// 业务处理:异常必须在这里消化,不能让它冲出消费循环
System.out.println(Thread.currentThread().getName() + " -> " + msg);
} catch (Throwable t) {
System.err.println("处理失败,进死信: " + msg);
}
}
}三个必须记住的点:
- 版本一的
notifyAll不能换成notify。因为生产者和消费者挂在同一个_WaitSet里,notify可能唤醒一个同类:比如缓冲区满时,一个生产者消费掉通知唤醒了另一个生产者,后者发现还是满的又wait回去,于是真正该被唤醒的消费者永远收不到通知,形成信号丢失型死锁。这正是 JDK 要用两个Condition的原因。- 两个
Condition带来的是"精准唤醒"。notFull和notEmpty是两条独立队列,生产者只会唤醒消费者、消费者只会唤醒生产者,避免了同类间的无效唤醒和惊群。这让signal()替代signalAll()成为可能,高并发下吞吐差距非常明显。- 优雅停机优先用毒丸而不是
interrupt。interrupt会让正在take()阻塞的消费者抛InterruptedException,但如果它刚好取出了消息还没处理完,消息就丢了。毒丸是顺序语义的——它排在所有正常消息之后,消费者拿到它时前面的消息必然已经全部处理完毕。
✅ 边界 Case 自测:
| 测试场景 | 期望行为 |
|---|---|
| 容量 1,1 生产者 1 消费者,跑 10 万次 | 消费到的序列严格等于生产顺序,无丢失无重复 |
| 多生产者多消费者,统计总数 | 生产总数 == 消费总数,用 LongAdder 计数校验 |
缓冲区满时继续 put | 生产者阻塞在 notFull.await(),jstack 可见 WAITING,不占 CPU |
缓冲区空时 take | 消费者阻塞在 notEmpty.await() |
把版本一的 notifyAll 改成 notify,多生产者多消费者压测 | 概率性死锁(容量越小、线程越多越容易触发) |
把 while 改成 if | 出现数组越界或 count 为负(条件失效后继续执行) |
offer(t, 100, MILLISECONDS) 在满队列上调用 | 100ms 后返回 false,不抛异常,且不破坏队列状态 |
把 awaitNanos(nanos) 的返回值丢弃(写成 notFull.awaitNanos(nanos);) | 虚假唤醒会导致超时时间被重置,实际等待远超预期 |
| 生产者抛异常提前退出 | 消费者永久阻塞在 take();必须用 try/finally 保证毒丸一定被投递 |
| 消费者处理消息时抛异常且未捕获 | 该消费线程退出,消费能力下降;压测时会观察到队列持续堆积 |
毒丸用 equals 比较而不是 == | 如果业务消息内容恰好等于毒丸字符串,会被误判为结束信号 |
shutdown() 后队列里还有 500 条消息 | 消费者继续消费完所有消息(包括毒丸)后才退出,awaitTermination 返回 true |
用 SynchronousQueue 替换 | 生产者必须等到有消费者接手才能返回,吞吐取决于消费速度,无缓冲 |
🔁 变种拓展题:
- 实现一个有界阻塞队列的完整 API:补上
offer(非阻塞)、poll(timeout)、peek、drainTo(Collection, max)、remainingCapacity。重点是四组方法的语义区分:抛异常 / 返回特殊值 / 阻塞 / 超时。 - 实现
LinkedBlockingQueue的双锁版本:putLock和takeLock分离,count用AtomicInteger。难点是跨锁通知:put 后如果发现队列从 0 变成 1,需要拿takeLock去signal消费者(JDK 里叫signalNotEmpty),以及fullyLock的加锁顺序必须统一以防死锁。 - 实现无锁队列:参考
ConcurrentLinkedQueue的 Michael-Scott 算法,用 CAS 操作 head/tail,处理"tail 滞后"(tail 不保证总指向最后一个节点,用hops减少 CAS 次数)和哨兵节点自引用(p == q时重新从 head 开始)。 - 批量消费优化:消费者一次
drainTo取出 100 条批量处理(比如批量落库),但要加"最长等待 50ms"的兜底,避免低流量时消息长时间不被处理。这是消息中间件客户端的标准做法。 - 加背压与限流:当队列长度超过 80% 时,让生产者主动降速(
CallerRunsPolicy的思想),或者返回"系统繁忙"给上游。对比"无界队列 + OOM"和"有界队列 + 拒绝"两种失败模式,说明为什么后者更好。 - 加监控:暴露队列长度、入队速率、出队速率、消息平均排队时长(入队时打时间戳)。重点说清队列长度持续增长意味着消费能力不足,需要扩消费者或优化处理逻辑。
- 升级成多级流水线:三个阶段(解析 → 计算 → 落库)各自一个线程池和队列,形成 pipeline。讨论各级队列容量和线程数怎么配比,以及最慢的那一级决定整体吞吐(木桶效应),再延伸到 Disruptor 的 RingBuffer 为什么比 BlockingQueue 快(无锁 + 预分配 + 消除伪共享)。
题 5:基于 AQS 手写一把锁 + 手写 CountDownLatch
核心实现:
java
/* ========== 一、不可重入的独占锁(AQS 独占模式入门) ========== */
public class MyNonReentrantLock implements Lock {
/** AQS 的标准用法:静态内部类继承 AbstractQueuedSynchronizer */
private static final class Sync extends AbstractQueuedSynchronizer {
/** state == 0 空闲,== 1 已被占用 */
@Override
protected boolean tryAcquire(int arg) {
assert arg == 1;
if (compareAndSetState(0, 1)) { // 不判断 owner,所以不可重入
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
assert arg == 1;
if (getState() == 0) {
throw new IllegalMonitorStateException("锁未被持有,不能释放");
}
setExclusiveOwnerThread(null); // 先清 owner
setState(0); // 再写 state(volatile 写,保证可见性)
return true;
}
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
Condition newCondition() {
return new ConditionObject(); // 复用 AQS 的条件队列实现
}
}
private final Sync sync = new Sync();
@Override public void lock() { sync.acquire(1); }
@Override public void unlock() { sync.release(1); }
@Override public boolean tryLock() { return sync.tryAcquire(1); }
@Override public Condition newCondition() { return sync.newCondition(); }
@Override public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
@Override public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(time));
}
}
/* ========== 二、可重入版本(对比:只多了 owner 判断和计数累加) ========== */
class MyReentrantSync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int acquires) {
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
} else if (getExclusiveOwnerThread() == Thread.currentThread()) {
int next = c + acquires;
if (next < 0) { // 重入次数溢出
throw new Error("Maximum lock count exceeded");
}
setState(next); // 已持锁,无竞争,不需要 CAS
return true;
}
return false;
}
@Override
protected boolean tryRelease(int releases) {
int c = getState() - releases;
if (getExclusiveOwnerThread() != Thread.currentThread()) {
throw new IllegalMonitorStateException();
}
boolean free = false;
if (c == 0) { // 只有完全释放才唤醒后继
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
}
/* ========== 三、手写 CountDownLatch(AQS 共享模式) ========== */
public class MyCountDownLatch {
private static final class Sync extends AbstractQueuedSynchronizer {
Sync(int count) { setState(count); } // state 就是剩余计数
int getCount() { return getState(); }
/** 共享模式:返回值 >= 0 表示获取成功(可以继续传播唤醒),< 0 表示失败 */
@Override
protected int tryAcquireShared(int acquires) {
return getState() == 0 ? 1 : -1;
}
/** 返回 true 表示"需要唤醒后继节点",即计数刚好减到 0 */
@Override
protected boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState();
if (c == 0) {
return false; // 已经是 0,countDown 多调无副作用
}
int next = c - 1;
if (compareAndSetState(c, next)) {
return next == 0; // 只有减到 0 这一次返回 true
}
}
}
}
private final Sync sync;
public MyCountDownLatch(int count) {
if (count < 0) {
throw new IllegalArgumentException("count < 0");
}
this.sync = new Sync(count);
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public boolean await(long timeout, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
public void countDown() {
sync.releaseShared(1);
}
public long getCount() {
return sync.getCount();
}
}
/* ========== 四、手写一个简易 Semaphore(共享模式 + 许可数) ========== */
class MySemaphore {
private static final class Sync extends AbstractQueuedSynchronizer {
Sync(int permits) { setState(permits); }
@Override
protected int tryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
// remaining < 0 时直接返回负数表示失败,不做 CAS
if (remaining < 0 || compareAndSetState(available, remaining)) {
return remaining;
}
}
}
@Override
protected boolean tryReleaseShared(int releases) {
for (;;) {
int cur = getState();
int next = cur + releases;
if (next < cur) {
throw new Error("Maximum permit count exceeded");
}
if (compareAndSetState(cur, next)) {
return true; // 释放许可后总是尝试唤醒
}
}
}
}
private final Sync sync;
MySemaphore(int permits) { this.sync = new Sync(permits); }
public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); }
public void release() { sync.releaseShared(1); }
public int availablePermits() { return sync.getState(); }
}三个必须记住的点:
- AQS 的使用范式是固定的:写一个继承 AQS 的静态内部类
Sync,只重写tryAcquire/tryRelease(独占)或tryAcquireShared/tryReleaseShared(共享),绝不重写acquire/release本身——排队、park、唤醒、中断处理这些复杂逻辑由 AQS 模板方法负责。外部类只做委托。state的语义由你自己定义,这是 AQS 最精妙的地方:ReentrantLock用它表示重入次数;CountDownLatch用它表示剩余计数;Semaphore用它表示可用许可;ReentrantReadWriteLock把 32 位拆成高 16 位读锁 + 低 16 位写锁。同一套排队框架能支撑完全不同的同步语义。- 独占和共享的核心区别在"唤醒是否传播"。独占模式释放时只唤醒队首一个节点;共享模式通过
setHeadAndPropagate会连锁唤醒后继的所有共享节点——所以CountDownLatch的countDown减到 0 时,所有await的线程会一起被放行,而不是一个一个。
✅ 边界 Case 自测:
| 测试场景 | 期望行为 |
|---|---|
同一线程对 MyNonReentrantLock 连续 lock() 两次 | 自己把自己死锁(state 已为 1,tryAcquire 失败后 park) |
| 同样场景换成可重入版本 | 正常通过,state 变为 2,需要 unlock() 两次才真正释放 |
未加锁直接 unlock() | 抛 IllegalMonitorStateException,不能静默成功 |
A 线程加锁,B 线程 unlock() | 可重入版抛 IllegalMonitorStateException(owner 校验) |
100 个线程各自 lock 后对共享变量 ++,共 100 万次 | 结果精确等于 1000000,无丢失更新 |
tryLock(100, MILLISECONDS) 在已被占用的锁上 | 100ms 后返回 false,且线程从 AQS 队列中正确移除(不留垃圾节点) |
lockInterruptibly() 等待中被 interrupt() | 抛 InterruptedException 并退出队列;而 lock() 只记录标志、继续等待 |
MyCountDownLatch(0) 直接 await() | 立即返回,不阻塞 |
countDown() 调用次数超过初始 count | 多余调用无副作用(tryReleaseShared 在 state==0 时返回 false) |
10 个线程 await,主线程 countDown 到 0 | 10 个线程全部被唤醒(验证共享模式的唤醒传播) |
CountDownLatch 重复使用(减到 0 后想重置) | 不支持,getCount 恒为 0;需要循环栅栏请用 CyclicBarrier |
await(1, SECONDS) 但计数未归零 | 返回 false,不抛异常;超时后 getCount() 仍为原值 |
MySemaphore(2),3 个线程同时 acquire | 2 个成功,第 3 个阻塞直到有人 release |
MySemaphore 的 release 次数多于 acquire | 许可数会虚增(JDK 的 Semaphore 也是这个行为),说明必须在 finally 里配对调用 |
🔁 变种拓展题:
- 手写
CyclicBarrier:用ReentrantLock+Condition+ "代(generation)"对象实现。重点是最后一个线程到达时执行 barrierAction 并调用nextGeneration()重置计数、signalAll放行;以及任一线程被中断/超时时要breakBarrier(),让其他线程抛BrokenBarrierException。 - 手写读写锁:用
state的高 16 位存读锁计数、低 16 位存写锁计数。要处理写锁重入、读锁重入(需要 ThreadLocal 记录每个线程的读计数)、锁降级(持写锁时获取读锁再释放写锁,合法)、以及不支持锁升级(持读锁时获取写锁会死锁)。 - 实现公平与非公平两种
tryAcquire:非公平直接 CAS 抢;公平版要先调hasQueuedPredecessors()检查队列里有没有人排在自己前面。用压测对比两者吞吐差距,并解释非公平为什么更快(减少了一次线程切换)。 - 给锁加上超时自动释放的"看门狗":仿照 Redisson,持锁线程启动一个定时任务续期,超过阈值未续期则强制释放。讨论这在本地锁里为什么通常不需要(进程不会"半死"),但在分布式锁里是必需的。
- 实现一个可重入的"分段锁":
lock(String key)对不同 key 互不阻塞、同一 key 互斥。用ConcurrentHashMap<String, Sync>+ 引用计数管理 Sync 的创建和回收,难点是回收时的竞态(刚判断引用为 0 准备删除时又有人来加锁)。 - 用 AQS 实现一个"限流器":令牌桶,
state存当前令牌数,定时任务补充令牌并releaseShared唤醒等待线程。对比 GuavaRateLimiter的做法(它用的是"预支未来令牌"的 SmoothBursty 算法,不走 AQS)。 - 读
AbstractQueuedSynchronizer源码后回答:为什么 CLH 队列需要一个虚拟头节点?waitStatus的 5 个值(0 / SIGNAL=-1 / CANCELLED=1 / CONDITION=-2 / PROPAGATE=-3)各自在什么时候被设置?shouldParkAfterFailedAcquire为什么要从后往前清理 CANCELLED 节点?(答案:addWaiter里node.prev = pred先于pred.next = node,所以 prev 链始终可靠,next 链可能瞬时断裂。)
题 6:并发实战(并行聚合查询 + CompletableFuture 编排 + 死锁复现与修复)
核心实现:
java
/* ========== 一、CountDownLatch 做并行聚合(详情页三个数据源) ========== */
public class DetailAggregator {
private final ExecutorService pool = new ThreadPoolExecutor(
8, 16, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadFactoryBuilder().setNameFormat("detail-agg-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
public DetailVO query(long itemId) throws InterruptedException {
DetailVO vo = new DetailVO();
CountDownLatch latch = new CountDownLatch(3);
// 并发收集各子任务的异常,主线程统一处理
List<Throwable> errors = Collections.synchronizedList(new ArrayList<>());
pool.execute(wrap(latch, errors, () -> vo.setBase(itemService.getBase(itemId))));
pool.execute(wrap(latch, errors, () -> vo.setStock(stockService.getStock(itemId))));
pool.execute(wrap(latch, errors, () -> vo.setPrice(priceService.getPrice(itemId))));
// 必须用带超时的 await,否则下游挂了主线程就永久阻塞
if (!latch.await(500, TimeUnit.MILLISECONDS)) {
log.warn("聚合查询超时,返回降级数据, itemId={}", itemId);
}
if (!errors.isEmpty()) {
log.error("聚合查询部分失败, itemId={}, errCount={}", itemId, errors.size(), errors.get(0));
}
return vo; // vo 的字段由不同线程写入,必须是 volatile 或在 await 后才读(happens-before 由 latch 保证)
}
/** 关键封装:countDown 必须在 finally,否则异常会让主线程等到超时 */
private Runnable wrap(CountDownLatch latch, List<Throwable> errors, Runnable task) {
return () -> {
try {
task.run();
} catch (Throwable t) {
errors.add(t);
} finally {
latch.countDown();
}
};
}
}
/* ========== 二、CompletableFuture 编排(更优雅,支持依赖关系) ========== */
public class OrderOrchestrator {
private final Executor bizPool = Executors.newFixedThreadPool(16);
public OrderVO createOrder(long userId, long itemId) {
// 两个无依赖的查询并行
CompletableFuture<User> userF = CompletableFuture
.supplyAsync(() -> userService.get(userId), bizPool) // 必须传自定义线程池
.orTimeout(300, TimeUnit.MILLISECONDS) // JDK 9+ 超时控制
.exceptionally(e -> {
log.warn("查询用户失败,降级", e);
return User.guest();
});
CompletableFuture<Item> itemF = CompletableFuture
.supplyAsync(() -> itemService.get(itemId), bizPool)
.orTimeout(300, TimeUnit.MILLISECONDS);
// 有依赖:拿到 item 后再查库存
CompletableFuture<Integer> stockF = itemF
.thenApplyAsync(item -> stockService.getStock(item.getSkuId()), bizPool);
// 三路汇聚
CompletableFuture<OrderVO> result = userF
.thenCombine(itemF, (u, i) -> OrderVO.of(u, i))
.thenCombine(stockF, (vo, stock) -> {
vo.setStock(stock);
return vo;
})
.whenComplete((vo, e) -> {
if (e != null) {
log.error("下单编排失败", e); // whenComplete 不吞异常,只观察
}
});
try {
// join 抛的是运行时的 CompletionException,get 抛的是受检的 ExecutionException
return result.get(800, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
result.cancel(true);
throw new BizException("下单超时");
} catch (ExecutionException e) {
throw new BizException("下单失败", e.getCause()); // 注意要取 getCause
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BizException("下单被中断");
}
}
}
/* ========== 三、死锁复现 ========== */
class DeadlockDemo {
private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (LOCK_A) {
sleep(100); // 关键:确保对方也拿到了自己的第一把锁
synchronized (LOCK_B) {
System.out.println("t1 done");
}
}
}, "t1").start();
new Thread(() -> {
synchronized (LOCK_B) { // 顺序相反 -> 循环等待
sleep(100);
synchronized (LOCK_A) {
System.out.println("t2 done");
}
}
}, "t2").start();
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
/* ========== 四、修复方案 A:固定加锁顺序(转账场景) ========== */
class TransferFixedOrder {
private static final Object TIE_LOCK = new Object(); // 加时赛锁,处理 hash 相同
public void transfer(Account from, Account to, BigDecimal amount) {
if (from.getId() == to.getId()) {
throw new IllegalArgumentException("不能给自己转账");
}
int h1 = System.identityHashCode(from);
int h2 = System.identityHashCode(to);
if (h1 < h2) {
synchronized (from) { synchronized (to) { doTransfer(from, to, amount); } }
} else if (h1 > h2) {
synchronized (to) { synchronized (from) { doTransfer(from, to, amount); } }
} else {
// 极端情况:identityHashCode 相同,用全局锁串行化
synchronized (TIE_LOCK) {
synchronized (from) { synchronized (to) { doTransfer(from, to, amount); } }
}
}
}
private void doTransfer(Account from, Account to, BigDecimal amount) {
if (from.getBalance().compareTo(amount) < 0) {
throw new BizException("余额不足");
}
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
}
}
/* ========== 五、修复方案 B:tryLock 超时 + 回退重试 ========== */
class TransferTryLock {
public boolean transfer(AccountWithLock from, AccountWithLock to,
BigDecimal amount, long timeoutMs) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutMs;
Random random = new Random();
while (System.currentTimeMillis() < deadline) {
if (from.getLock().tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (to.getLock().tryLock(50, TimeUnit.MILLISECONDS)) {
try {
doTransfer(from, to, amount);
return true;
} finally {
to.getLock().unlock();
}
}
} finally {
from.getLock().unlock(); // 拿不到第二把锁必须释放第一把,这是打破"持有并等待"的关键
}
}
// 随机退避:避免两个线程以相同节拍反复互相礼让,形成活锁
Thread.sleep(random.nextInt(20) + 5);
}
return false;
}
private void doTransfer(AccountWithLock a, AccountWithLock b, BigDecimal amt) { /* ... */ }
}
/* ========== 六、死锁巡检告警(定时任务) ========== */
class DeadlockMonitor {
private final ThreadMXBean bean = ManagementFactory.getThreadMXBean();
public void start() {
ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor(
r -> { Thread t = new Thread(r, "deadlock-monitor"); t.setDaemon(true); return t; });
ses.scheduleWithFixedDelay(() -> {
try {
// findDeadlockedThreads 能检测 synchronized 和 Lock;
// findMonitorDeadlockedThreads 只能检测 synchronized
long[] ids = bean.findDeadlockedThreads();
if (ids != null && ids.length > 0) {
ThreadInfo[] infos = bean.getThreadInfo(ids, true, true);
StringBuilder sb = new StringBuilder("检测到死锁!\n");
for (ThreadInfo info : infos) {
sb.append(info.toString());
}
log.error(sb.toString());
alarmService.send("JVM 死锁", sb.toString());
}
} catch (Throwable t) {
log.error("死锁巡检异常", t); // 巡检任务自身必须吞掉异常,否则调度会永久停止
}
}, 1, 1, TimeUnit.MINUTES);
}
}三个必须记住的点:
countDown()永远放在finally里。这是聚合查询最容易踩的坑:子任务抛异常时没减计数,主线程就会一直等到超时(甚至永久阻塞,如果用了无参await())。同理,await()必须带超时,这是服务不被下游拖死的最后一道防线。CompletableFuture必须传自定义线程池。默认用ForkJoinPool.commonPool(),它的并行度是CPU 核数 - 1,所有业务共享;一旦某个业务提交阻塞型 IO 任务,整个 JVM 的所有并行流和异步任务都会被拖慢。更坑的是容器里如果没识别 cgroup 限制,核数可能被算成 1,commonPool 退化成在调用线程执行。- 固定加锁顺序是零成本的死锁根治方案,优先级高于
tryLock。tryLock虽然也能解决,但引入了重试、活锁风险和更复杂的代码。只有在锁对象顺序确实无法统一时才用它,而且失败后必须释放已持有的锁。
✅ 边界 Case 自测:
| 测试场景 | 期望行为 |
|---|---|
| 聚合查询中某个子任务抛异常 | latch 正常减到 0,主线程不超时,该字段为默认值并记录错误 |
| 某个下游 hang 住 5 秒 | await(500ms) 返回 false,接口在 500ms 内返回降级数据 |
去掉 finally 里的 countDown,让子任务抛异常 | 主线程阻塞到超时(或永久阻塞),复现经典线上事故 |
线程池队列满 + CallerRunsPolicy | 任务由调用线程执行,聚合退化成串行,耗时变成三者之和 |
聚合结果 vo 的字段未加 volatile | 实际安全:latch.countDown/await 提供了 happens-before 保证;但若在 await 前读就不安全 |
CompletableFuture 不传线程池,提交 16 个阻塞 IO 任务 | commonPool 被打满,其他业务的 parallelStream 一起变慢 |
exceptionally 放在 thenCombine 之前 vs 之后 | 位置决定它能捕获哪一段的异常;放最后只能捕获整条链的异常 |
result.get(800ms) 超时后调 cancel(true) | 已经在跑的 supplyAsync 任务不会真的被中断(CF 的 cancel 不传播中断) |
运行死锁 Demo 后执行 jstack | 输出末尾出现 Found one Java-level deadlock,列出 t1/t2 环路 |
死锁 Demo 换成 ReentrantLock 后 jstack | 仍能检测到(AQS 记录了 owner),但栈里显示的是 parking to wait for |
| 并发执行 A→B 和 B→A 转账 10 万次(修复前/后) | 修复前概率死锁;修复后全部成功且总余额守恒 |
tryLock 方案去掉随机退避,两线程高频互转 | 可能出现长时间互相礼让(活锁),成功率显著下降 |
DeadlockMonitor 的定时任务里抛未捕获异常 | 不捕获会导致后续调度永久停止(ScheduledExecutorService 的大坑) |
🔁 变种拓展题:
- 把聚合查询改写成
CompletableFuture.allOf:对比CountDownLatch的写法,说明allOf返回CompletableFuture<Void>,需要再join各个子 future 取值;以及anyOf用于"多个数据源取最快的那个"的场景。 - 实现"部分失败可接受"的聚合:给每个子任务单独设超时和降级值,要求任意子任务失败都不影响整体返回。用
handle((r, e) -> e == null ? r : fallback)实现。 - 实现超时控制的兼容写法:JDK 8 没有
orTimeout,用一个单独的ScheduledExecutorService在 N 毫秒后调future.completeExceptionally(new TimeoutException())来模拟,并注意任务完成后要取消定时任务避免定时线程池堆积。 - 给异步任务传递 traceId:实现一个
TraceRunnable包装器,在提交时捕获 MDC 快照,在执行时MDC.setContextMap并在 finally 里MDC.clear()。再升级成用 TTL 做无侵入传递。 - 实现一个带熔断的聚合器:某个下游连续失败 N 次后直接走降级不再发起调用(Sentinel / Resilience4j 的思路),并在一段时间后半开试探。
- 复现并修复"数据库死锁":两个事务按不同顺序
UPDATE同两行,观察 MySQL 的Deadlock found when trying to get lock报错和SHOW ENGINE INNODB STATUS输出。修复思路和内存锁完全一致——统一更新顺序(比如ORDER BY id后批量更新)。 - 把死锁巡检接入可观测体系:除了
findDeadlockedThreads,再暴露线程池的activeCount/queueSize/completedTaskCount/rejectedCount到 Micrometer,配置"队列长度持续 > 80%"和"活跃线程数 == maxPoolSize 持续 1 分钟"的告警规则,做到死锁和线程池耗尽在告警层面就能区分。
题 7:手写一个可取消的长驻 Worker(两阶段终止 + 不可中断资源唤醒 + 优雅停机)
要求: 实现一个常驻消费线程,支持 ① 正常处理队列任务;② 调用 shutdown() 后处理完存量任务再退出(毒丸);③ 调用 shutdownNow() 立即中断并唤醒不可中断的 IO 阻塞;④ 外部可限时等待退出并拿到"是否干净退出"的结果;⑤ 单个任务异常不能让 Worker 死掉。
核心实现:
java
public class CancellableWorker implements AutoCloseable {
/** 毒丸:用唯一对象标识,必须用 == 比较 */
private static final Runnable POISON = () -> {};
private final BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1024);
private final CountDownLatch terminated = new CountDownLatch(1);
private final Thread worker;
/** ★ 必须 volatile:否则 worker 线程可能永远读到缓存里的 true */
private volatile boolean running = true;
/** 不可中断阻塞持有的资源,由 shutdownNow 负责 close 来唤醒 */
private volatile Closeable blockingResource;
private final AtomicLong done = new AtomicLong();
private final AtomicLong failed = new AtomicLong();
public CancellableWorker(String name) {
this.worker = new Thread(this::loop, name);
this.worker.setDaemon(false); // ★ 要优雅停机就不能是守护线程
this.worker.start(); // ★ 注意:构造器里 start 属于 this 逸出,
// 生产代码应抽到静态工厂 create() 里
}
// ---------- 提交 ----------
public boolean submit(Runnable task, long timeout, TimeUnit unit)
throws InterruptedException {
if (!running) {
throw new RejectedExecutionException("worker already shutdown");
}
// ★ 用 offer 带超时,而不是 put:避免生产者被无限期挂住
return queue.offer(task, timeout, unit);
}
// ---------- 主循环 ----------
private void loop() {
try {
while (running && !Thread.currentThread().isInterrupted()) {
Runnable task;
try {
// ★ 用 poll 带超时而非 take:即使毒丸丢了也能靠 running 退出
task = queue.poll(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // ★ 补回被清除的标志
break;
}
if (task == null) { continue; } // 超时空转,重新判定条件
if (task == POISON) { break; } // ★ 存量已消费完
try {
task.run();
done.incrementAndGet();
} catch (Throwable biz) {
// ★ 单任务异常必须在循环内吞掉,否则 Worker 永久消失
failed.incrementAndGet();
log.error("task failed", biz);
}
}
} finally {
drainRemaining(); // ★ 阶段二收尾:上报未执行任务
terminated.countDown(); // ★ 唯一的"真的停了"信号
}
}
// ---------- 优雅关闭:处理完存量 ----------
public void shutdown() {
running = false; // ① 拒绝新任务
queue.offer(POISON); // ② 排在存量任务之后,处理完才生效
// ★ 这里【不】interrupt,否则存量任务会被丢掉
}
// ---------- 强制关闭:立即停 ----------
public List<Runnable> shutdownNow() {
running = false; // ① 置意图
worker.interrupt(); // ② 唤醒 poll / sleep / wait
closeQuietly(blockingResource); // ③ ★ 唤醒不可中断的 socket/stream read
List<Runnable> unExec = new ArrayList<>();
queue.drainTo(unExec); // ④ 把没跑的任务交还调用方
return unExec;
}
/** 限时等待真正退出;true=干净退出,false=超时(线程可能还卡着) */
public boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException {
return terminated.await(timeout, unit);
}
@Override
public void close() throws InterruptedException {
shutdown();
if (!awaitTermination(30, TimeUnit.SECONDS)) {
log.warn("优雅关闭超时,转为强制关闭");
List<Runnable> lost = shutdownNow();
log.warn("丢弃任务数={}", lost.size());
awaitTermination(5, TimeUnit.SECONDS);
}
log.info("worker closed, done={}, failed={}", done.get(), failed.get());
}
// ---------- 任务体里注册不可中断资源 ----------
/** 任务内部读 socket 时调用,把资源登记给 shutdownNow 去 close */
public void readWithCancel(Socket socket) throws IOException {
this.blockingResource = socket; // ★ 登记
try (InputStream in = socket.getInputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) > 0) { handle(buf, n); } // 不响应 interrupt
} catch (IOException e) {
// ★ 区分"被主动取消"和"真的读失败",否则告警全是误报
if (socket.isClosed() || !running) { log.info("read cancelled"); }
else { throw e; }
} finally {
this.blockingResource = null; // ★ 及时摘掉,避免误 close 下一个资源
}
}
private void drainRemaining() {
List<Runnable> rest = new ArrayList<>();
queue.drainTo(rest);
rest.removeIf(r -> r == POISON);
if (!rest.isEmpty()) { log.warn("退出时仍有 {} 个任务未执行", rest.size()); }
}
private static void closeQuietly(Closeable c) {
if (c != null) { try { c.close(); } catch (IOException ignore) {} }
}
}关键设计点对照:
| 设计 | 如果不这么做会怎样 |
|---|---|
running 用 volatile | 工作线程读缓存值,循环永不退出 |
poll(1s) 而不是 take() | 毒丸丢失 / interrupt 被吞时,线程永久阻塞,shutdown 卡死 |
catch (InterruptedException) 里 interrupt() 补回 | 中断信号被吃掉,上层无法感知,shutdownNow 失效 |
任务异常在循环内 catch Throwable | 一次偶发异常让整个 Worker 永久消失,表现"消费莫名停了" |
shutdown() 不 interrupt、只投毒丸 | interrupt 会丢弃队列中存量任务(订单/日志场景不可接受) |
shutdownNow() 额外 close 资源 | 卡在 socket.read() 的线程对 interrupt 免疫,永远不退出 |
terminated 用 CountDownLatch 在 finally 里 countDown | 外部无法判断"是否真的停了",isAlive() 不可靠也不精确 |
submit 用 offer(timeout) | put 在队列满时让生产者(可能是 Web 线程)无限期挂住 |
✅ 边界 Case 自测:
| # | 用例 | 预期 |
|---|---|---|
| 1 | 队列里有 100 个任务时调 shutdown() | 100 个全部执行完才退出,done==100 |
| 2 | 队列里有 100 个任务时调 shutdownNow() | 立即退出,返回的未执行列表 + done 之和为 100 |
| 3 | shutdown() 之后再 submit | 抛 RejectedExecutionException,不会静默丢 |
| 4 | 某个任务抛 RuntimeException | failed+1,Worker 继续处理后续任务 |
| 5 | 某个任务抛 OutOfMemoryError(Error) | 被 catch Throwable 记录,Worker 不死(真 OOM 下可按需改为上报后退出) |
| 6 | 任务里 while(true){} 纯计算,调 shutdownNow() | awaitTermination 返回 false——验证"取消是协作式的" |
| 7 | 任务阻塞在 socket.read(),调 shutdownNow() | 资源被 close,read 抛 IOException,线程正常退出 |
| 8 | 连续调两次 shutdown() | 幂等,不会重复投毒丸导致下一次启动误退出(可加 AtomicBoolean 加固) |
| 9 | 队列满(1024)时 submit(task, 100, MS) | 返回 false 而不是阻塞,生产者可自行降级 |
| 10 | 多个 Worker 共享同一队列时投一个毒丸 | 只有一个退出——需在消费到毒丸后 queue.offer(POISON) 放回传递 |
| 11 | 调用 close() 且任务耗时 40s(> 30s 超时) | 先告警"优雅关闭超时",再强制 shutdownNow |
| 12 | 外部线程调 awaitTermination 时被 interrupt | 抛 InterruptedException,不吞 |
🔁 变种拓展题:
- 支持单任务超时:给每个任务配独立超时,超时后只取消该任务不影响 Worker——用
ScheduledExecutorService延迟触发future.cancel(true),或把任务包进FutureTask并记录 runner 线程。 - 多消费者版本:改成 N 个 Worker 共享队列,
shutdown()需投 N 个毒丸,或消费到毒丸后放回队列传递;注意最后一个消费者要把毒丸取走,避免残留。 - 支持暂停/恢复:加
pause()/resume(),用ReentrantLock + Condition或Semaphore实现,注意暂停期间队列会积压,要有上限保护。 - 改用虚拟线程:JDK21 下每个任务
Thread.ofVirtual().start(),但要注意虚拟线程不适合synchronized长临界区(pinning);取消语义不变,仍然是协作式。 - 结构化并发重写:用
StructuredTaskScope.ShutdownOnFailure替代手写取消,验证"作用域退出时保证无线程泄漏"这一特性。 - 接入监控与健康检查:暴露
queueSize / done / failed / 最后活跃时间,并实现一个isHealthy()——超过 N 秒没有任何进展就认为 Worker 僵死,配合 K8s 探针自动重启。 - 换成
Future.cancel语义测试:把任务包成FutureTask提交,分别验证"排队中 cancel(false)"、"执行中 cancel(false)"、"执行中 cancel(true)" 三种结果,对照深挖 21 的语义表。
⚠️ 当日踩坑实录
| ❌ 坑 | ✅ 解决方案 |
|---|---|
以为 Thread.start() 和 run() 差不多,直接调了 run() | run() 只是普通方法调用,在当前线程执行,没有任何并发效果。只有 start() 才会由 JVM 创建 native 线程。而且 start() 只能调一次,再调抛 IllegalThreadStateException |
用 Runnable 时想拿返回值,到处塞成员变量 | 改用 Callable<V> + FutureTask / ExecutorService.submit,返回值和异常都被封装在 Future 里 |
Executors.newFixedThreadPool(10) 上线后 OOM | 它用的是无界 LinkedBlockingQueue,任务无限堆积直到堆溢出。必须手写 new ThreadPoolExecutor(...) 并传有界队列 + 明确的拒绝策略 |
Executors.newCachedThreadPool() 压测时创建了上万个线程 | maxPoolSize 是 Integer.MAX_VALUE,来多少任务就开多少线程,直接 OutOfMemoryError: unable to create new native thread |
Executors.newSingleThreadScheduledExecutor 的定时任务莫名其妙不跑了 | 任务里抛了未捕获异常,整个调度被永久取消且没有任何日志。必须在任务体最外层 try-catch(Throwable) |
线程池没设线程名,线上 jstack 全是 pool-1-thread-3 | 用 ThreadFactory 设置有业务含义的名字(order-async-%d),排查问题时能一眼定位是哪个池 |
submit() 提交的任务抛异常后完全没有日志 | 异常被封装进 Future,不调 get() 就永远看不到。要么改用 execute(),要么对 Future 统一 get() 并记录,要么重写 afterExecute 钩子 |
给 submit 的线程池设了 UncaughtExceptionHandler 但不生效 | submit 路径下异常被 FutureTask.setException 捕获,根本不会冒泡到线程的 handler。只有 execute 才会触发 |
| 核心线程数拍脑袋设成 200 | CPU 密集型设 核数 + 1,IO 密集型按 核数 × (1 + 等待时间/计算时间) 估算,然后压测验证。盲目设大只会增加上下文切换 |
用 Runtime.getRuntime().availableProcessors() 在容器里拿到了宿主机核数 | 低版本 JDK 8 不识别 cgroup 限制。升级到 8u191+ 并开启 -XX:+UseContainerSupport(默认开),或显式用 -XX:ActiveProcessorCount 指定 |
| 所有异步任务共用一个线程池,一个慢接口拖垮全部 | 按业务重要性隔离线程池:核心交易链路独立池,日志/统计等非核心任务单独池,互不影响 |
| 应用关闭时异步任务丢了一批 | 注册 JVM shutdown hook,调 shutdown() 后 awaitTermination(30s),仍未结束再 shutdownNow()。Spring 里用 @PreDestroy 或 setWaitForTasksToCompleteOnShutdown(true) |
shutdownNow() 以为能立刻停止所有任务 | 它只是发中断信号。如果任务里没有响应中断的代码(没有阻塞方法、没检查 isInterrupted()),任务会一直跑完 |
捕获 InterruptedException 后只打了日志 | 中断标志在抛异常时已被清除,必须 Thread.currentThread().interrupt() 重新设置,否则上层无法感知中断,优雅停机失效 |
volatile 当成能保证原子性,用它做计数器 | volatile 只保证可见性和有序性,i++ 是"读-改-写"三步,依然会丢失更新。计数要用 AtomicInteger / LongAdder / 加锁 |
DCL 单例忘了给 instance 加 volatile | new 的指令重排会让其他线程拿到半初始化对象(非 null 但字段为默认值)。x86 下难复现,到 ARM 服务器上就暴雷 |
synchronized 锁了 Integer 或 String 对象 | Integer 有 -128~127 的缓存池、字符串有常量池,不同业务可能意外共享同一把锁。锁对象要用 private final Object lock = new Object() |
| 锁对象声明成非 final,中途被重新赋值 | 赋值后不同线程锁的是不同对象,互斥完全失效。锁对象必须 final |
对 Thread 对象调 wait/notify 当同步用 | join() 内部就是在 Thread 对象上 wait,JVM 在线程结束时会 notifyAll 它,两者会互相干扰。等待线程结束请用 CountDownLatch |
wait() 写在 if 里 | 虚假唤醒和 notifyAll 的惊群会让条件失效。永远用 while (条件不满足) wait(); |
生产者消费者用了 notify() 导致偶发挂死 | 生产者和消费者在同一个 _WaitSet,notify 可能唤醒同类导致信号丢失。要么 notifyAll,要么用两个Condition |
lock.lock() 写在 try 里面 | 如果 lock() 本身抛异常,finally 里的 unlock() 会抛 IllegalMonitorStateException 掩盖原始异常。正确写法是 lock(); try { ... } finally { unlock(); } |
ReentrantLock 忘了在 finally 里 unlock | 和 synchronized 不同,它不会自动释放,一旦异常直接导致后续线程全部死锁 |
Condition.awaitNanos(nanos) 的返回值被忽略 | 返回值是剩余等待时间,必须 nanos = cond.awaitNanos(nanos) 重新赋值,否则虚假唤醒后超时时间被重置,实际等待时间远超预期 |
以为 ReentrantLock 等锁的线程状态是 BLOCKED | 它底层是 LockSupport.park,状态是 WAITING / TIMED_WAITING。只有 synchronized 竞争失败才是 BLOCKED,看 jstack 时别误判 |
| 以为线程做 socket IO 阻塞时状态是 BLOCKED | Java 里仍然是 RUNNABLE。所以 jstack 里一堆 RUNNABLE 不代表 CPU 忙,要看栈顶是不是 socketRead0 这类 native 方法 |
以为 notify() 后线程立刻变 RUNNABLE | 它先从 _WaitSet 挪到 _EntryList,状态变成 BLOCKED,必须重新抢到锁才能返回 |
| 读写锁里持有读锁时去获取写锁 | 锁升级不被支持,会直接自己死锁。锁降级(持写锁拿读锁再放写锁)才是合法的 |
用 ReentrantReadWriteLock 后写线程长期拿不到锁 | 非公平模式下读锁可以连续插队,写线程饥饿。改用公平模式,或换 StampedLock 的乐观读 |
StampedLock 当成可重入锁用 | 它不可重入,重入会死锁;而且不支持 Condition,悲观读写锁也不响应中断(中断会导致 CPU 飙高的已知问题) |
CountDownLatch 的 countDown() 没放 finally | 子任务异常时计数不减,主线程等到超时甚至永久阻塞。同理 await() 必须带超时 |
CountDownLatch 想重复使用 | 它是一次性的,减到 0就不能重置。需要循环屏障用 CyclicBarrier,需要可增减用 Phaser |
Semaphore.release() 调用次数多于 acquire() | 许可数会虚增,限流完全失效。acquire/release 必须用 try/finally 严格配对 |
ThreadLocal 在线程池里用完没 remove() | 两个后果:线程长期存活导致 value 永久泄漏;更危险的是脏数据污染,下一个请求读到上一个用户的上下文。必须 try/finally + remove() |
以为 ThreadLocal 的 key 是弱引用就不会泄漏 | 弱引用只让 key 被回收,value 仍被 Entry 强引用。清理是启发式的、不保证执行,remove() 才是唯一可靠手段 |
用 InheritableThreadLocal 给线程池传上下文 | 拷贝只发生在创建线程那一刻,线程池复用线程后永远传不进去。用阿里的 TTL(TtlRunnable / TtlExecutors)在提交任务时传递 |
SimpleDateFormat 定义成静态成员多线程共用 | 它内部的 Calendar 有可变状态,非线程安全,会出现日期解析错乱甚至死循环。换 DateTimeFormatter(无状态)或用 ThreadLocal 包装 |
用 HashMap 在并发下 put | JDK 7 的环形链表会导致 CPU 100% 死循环,JDK 8 虽修复了但仍会丢数据、size 错乱。并发场景用 ConcurrentHashMap |
ConcurrentHashMap 的 containsKey + put 组合使用 | 两个原子操作的组合不是原子的,中间有竞态窗口。用 putIfAbsent / computeIfAbsent / merge 这类复合原子方法 |
ConcurrentHashMap.computeIfAbsent 的回调里又操作同一个 map | 会死锁或抛 IllegalStateException: Recursive update,因为该方法对桶加了锁 |
AtomicLong 在高并发计数场景 CPU 飙高 | 所有线程 CAS 同一变量导致大量自旋失败 + 缓存行弹跳。纯计数场景换 LongAdder(分段 CAS + @Contended 消除伪共享) |
把 LongAdder 用在需要精确判断的限流上 | sum() 不是原子快照,统计期间的并发写会导致结果不精确,会放过超额请求。精确判断用 AtomicLong.decrementAndGet() |
| 用 CAS 更新引用类型时遇到 ABA | 值从 A→B→A,CAS 察觉不到。用 AtomicStampedReference 加版本号,或 AtomicMarkableReference 加标记位 |
CompletableFuture 没传自定义线程池 | 默认用 ForkJoinPool.commonPool(),全 JVM 共享且并行度只有 核数-1;容器里核数被算成 1 时甚至退化成同步执行 |
parallelStream() 里做 RPC 调用 | 同样占用 commonPool,一个慢调用会拖垮全局所有并行流。IO 任务一律用自己的线程池 |
CompletableFuture.get() 不带超时 | 下游 hang 住就永久阻塞,线程池被打满。一律用 get(timeout, unit) 或 orTimeout |
CompletableFuture 的异常被静默吞掉 | 链式调用中间抛异常会跳过后续 thenApply,如果没有 exceptionally / handle / whenComplete 就彻底无感知。必须显式处理 |
future.cancel(true) 以为能中断正在执行的任务 | CompletableFuture 的 cancel 不传播中断,已经在跑的任务会跑完。要中断得自己持有 Thread 引用或用标志位 |
| 两把锁的获取顺序在不同代码路径不一致 | 经典循环等待死锁(A→B 和 B→A)。统一按 ID 或 identityHashCode 排序加锁,hash 相同时用全局加时赛锁兜底 |
在 synchronized 块里调 RPC / 查库 / 触发回调 | 临界区被无限拉长,而且被调方可能去拿别的锁导致死锁。遵守"开放调用"原则:把外部调用移到锁外 |
| 死锁后想在运行时打破它 | 死锁满足"不可剥夺",无法在运行时解除,只能重启。所以必须在设计阶段预防 + 加 tryLock 超时兜底 |
jstack 只抓了一次就下结论 | 要连续抓 3~5 次对比:栈完全不动才是死锁/卡死,栈在变化只是慢。这是区分死锁和性能问题最快的方法 |
| 偏向锁在 JDK 15+ 失效了还在依赖它调优 | JEP 374 废弃了偏向锁(JDK 15 默认关闭,18 彻底移除),因为维护成本高且对现代 Java 代码收益为负。别再用 -XX:+UseBiasedLocking 相关参数 |
在虚拟线程里用 synchronized 包住 IO | 会导致 pinning(虚拟线程被钉在载体线程上无法卸载),并发度退化到载体线程数。虚拟线程场景要把 synchronized 换成 ReentrantLock |
| 给虚拟线程配线程池 | 虚拟线程创建极轻量(几百字节),不需要池化,用 Executors.newVirtualThreadPerTaskExecutor() 一个任务一个线程 |
ForkJoinPool 的任务里做阻塞 IO | 工作窃取依赖任务能快速完成,阻塞会导致窃取失效、并行度塌陷。要用 ManagedBlocker 或干脆换普通线程池 |
自旋等待里直接写空 while 循环 | 纯空转烧 CPU 且阻碍超线程兄弟核。用 Thread.onSpinWait()(生成 pause 指令)或加随机退避 |
两个 tryLock 方案上线后出现活锁 | 两线程以相同节拍互相礼让。加随机退避(sleep(random))错开重试时机 |
自定义 AQS 时重写了 acquire() 而不是 tryAcquire() | 排队、park、中断处理都在模板方法里,重写它等于丢掉整个框架。只能重写 tryAcquire/tryRelease/tryAcquireShared/tryReleaseShared |
| 多个状态变量分别用原子类,以为组合起来就线程安全 | CAS 只保证单个变量的原子性。多变量要封装成不可变对象配 AtomicReference,或者直接加锁 |
用了 @Async 但没声明任何线程池 Bean | 退化成 SimpleAsyncTaskExecutor,每次调用 new 一条线程,压测时线程数线性暴涨直到 unable to create new native thread。必须显式声明 ThreadPoolTaskExecutor |
声明了 ThreadPoolTaskExecutor 但没设 queueCapacity | 默认 Integer.MAX_VALUE 无界队列,maxPoolSize 永远用不上,故障从"线程爆"变成"堆爆 + 积压无感知" |
同类里 this.asyncMethod() 调用自己的 @Async 方法 | 绕过 AOP 代理,直接同步执行,压测 TP99 怎么调都下不来。要抽到另一个 Bean 或注入自身代理 |
@Async 方法返回 String/Boolean 等普通类型 | 代理一律返回 null,调用方 NPE。只能返回 void / Future / CompletableFuture |
@Async + void 返回值,方法内抛异常 | 被 AsyncUncaughtExceptionHandler 默认实现吞掉(只打一行日志),表现为"任务静默失败"。要实现 AsyncConfigurer 统一兜底告警 |
@Async 方法里读主事务刚写入的数据 | 事务上下文绑在 ThreadLocal 上不跨线程,主事务未提交时异步线程查不到。改用 @TransactionalEventListener(phase = AFTER_COMMIT) |
指望 @Async 方法里的回滚能带上主事务 | 两个事务完全独立:主事务回滚,异步里已提交的数据不会回滚。涉及一致性的操作不能异步化 |
容器里有两个 TaskExecutor 都没加 @Primary | 不报错,按名字 taskExecutor 兜底选择,很容易"以为用了自己的池其实没用上"。要么加 @Primary,要么 @Async("bizExecutor") 显式指定 |
拒绝策略用 CallerRunsPolicy 后接口整体变慢 | 调用线程(Tomcat 工作线程)被拉去执行任务,并发一高 Web 线程被拖死。只适合内部批处理 |
| 线程池没配优雅停机 | 发版时队列里的任务和正在执行的任务一起消失,表现"丢了一批通知"。要配 setWaitForTasksToCompleteOnShutdown(true) + awaitTerminationSeconds,且小于 K8s terminationGracePeriodSeconds |
@Scheduled 任务互相挤占、定时不准 | 默认调度器只有 1 条线程,一个慢任务阻塞全部定时任务。要配 ThreadPoolTaskScheduler 并设 poolSize |
@Scheduled 方法抛出异常后再也不执行了 | 异常会让该任务的后续调度被永久取消。方法体必须整体 try-catch 兜底,绝不让异常冒出 |
| 多实例部署后定时任务重复执行 | @Scheduled 是进程级的,N 个 Pod 跑 N 次。要用分布式锁 / 调度中心(XXL-JOB 等)/ 选主 |
构造器里 new Thread(this::run).start() | this 逸出:新线程可能先跑起来,读到字段还是默认值 0/null。要抽到静态工厂里"构造完再启动" |
构造器里 eventBus.register(this) | 事件可能在构造完成前回调进来,读到半初始化对象。注册动作放到 create() 静态工厂中 |
| 父类构造器里调用了被子类重写的方法 | 子类 override 实现访问子类字段时,子类构造器还没执行,字段全是默认值 → 莫名 NPE。构造器里只做字段赋值 |
private final List<String> names 直接存外部传入的 List | final 只保证引用可见性,不保证内容不可变,外部仍能改。构造时 List.copyOf() 防御拷贝,getter 也不能返回内部集合 |
不可变 Snapshot 整体换引用,但持有字段没加 volatile | 对象完整 ≠ 别人能看到最新那个对象。持有引用的字段必须 volatile 或 AtomicReference |
DCL 单例忘了给字段加 volatile | 第二个线程走无锁快路径,可能读到"引用非 null 但字段未初始化"的半成品。x86 上几乎测不出,ARM 上偶发 NPE |
用 f.cancel(false) 想停掉正在跑的任务 | 对正在执行的任务毫无约束力,任务跑完只是结果被丢弃。它只能取消还在队列里排队的任务 |
看到 f.cancel(true) 返回 true 就以为任务停了 | 只代表"取消请求已发出",纯计算不检查标志的任务照样死循环。要让任务自己 countDown 上报才算确认 |
CompletableFuture.cancel(true) 以为能中断上游 | 布尔参数被完全忽略,上游任务继续跑完占着线程,只是下游不等了。要靠内部 volatile 标志或 IO 超时 |
catch (InterruptedException e) 里只打日志不处理 | 异常抛出时中断标志已被 JVM 清除,吞掉就等于把取消信号丢了。要么 break,要么 Thread.currentThread().interrupt() 补回 |
长驻线程只置 volatile 标志、不 interrupt() | 线程卡在 sleep(30min)/take() 里迟迟不退出。两个动作必须同时做 |
长驻线程只 interrupt()、不置标志 | 任务 catch 后继续下一轮循环(标志已被清除),取消失效 |
| 长驻循环里业务异常没在循环内部捕获 | 一次偶发异常让整个消费线程永久消失,监控表现为"消费莫名停了" |
任务阻塞在 socket.read() 时调 shutdownNow() | OS 级阻塞对 interrupt 免疫,awaitTermination 一直超时。只能靠另一个线程 socket.close() 唤醒 |
用 lock.lock() 导致线程无法被取消 | 不可中断版本。需要可取消就用 lockInterruptibly() 或 tryLock(timeout) |
| 多消费者共享队列时只投了一个毒丸 | 只有一个消费者退出。消费到毒丸后要 queue.offer(POISON) 放回传递给下一个 |
毒丸用 equals 比较 | 业务任务可能恰好相等导致误退出。毒丸必须是唯一对象并用 == 判定 |
📌 当日复盘任务
- [ ] 能脱稿说出进程与线程的区别,以及 Java 线程与 OS 线程的 1:1 映射关系,并说明虚拟线程为什么是 M:N
- [ ] 默写线程创建的 5 种方式,说清
Runnable与Callable的区别、FutureTask在其中的作用 - [ ] 画出线程 6 大状态的流转图,标明每条边对应的方法调用
- [ ] 解释为什么 Java 把 OS 的"就绪"和"运行"合并成 RUNNABLE,以及 IO 阻塞时为什么还是 RUNNABLE
- [ ] 说清 BLOCKED 与 WAITING 的本质区别,以及
ReentrantLock等锁时为什么是 WAITING - [ ] 列表对比
wait/sleep/join/yield/park五个方法:所属类、是否持锁、是否释放锁、唤醒方式 - [ ] 解释
notify()之后线程进入的是 BLOCKED 而不是 RUNNABLE,并说明原因 - [ ] 说明中断机制的三个方法(
interrupt/isInterrupted/interrupted)的区别,以及为什么捕获InterruptedException后要重新设置中断标志 - [ ] 复述 JMM 的三大特性(原子性、可见性、有序性),并说出
happens-before的八条规则 - [ ] 解释
volatile的两个作用和四种内存屏障,说明 x86 上为什么用lock前缀实现 - [ ] 解释为什么 DCL 单例必须加
volatile,并手写出来 - [ ] 画出对象头 Mark Word 的布局,默写锁升级路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
- [ ] 用 JOL 实际打印一个对象,观察加锁前后 Mark Word 的变化
- [ ] 说清
synchronized的三种用法以及 ObjectMonitor 的_owner/_EntryList/_WaitSet三个结构 - [ ] 解释 JVM 的四种锁优化:锁消除、锁粗化、自适应自旋、偏向锁(及 JEP 374 为什么废弃它)
- [ ] 说明 CAS 的原理和三大问题(ABA、自旋开销、只能保证单变量),以及各自的解决方案
- [ ] 手写一个 ABA 问题的复现代码,并用
AtomicStampedReference修复 - [ ] 解释
LongAdder的分段 CAS 设计、Cell[]的扩张规则、@Contended解决的伪共享问题 - [ ] 说清
AtomicLong与LongAdder的选型标准,以及sum()为什么不精确 - [ ] 默写 AQS 的三大件:
state、CLH 双向队列、ConditionObject,并说明模板方法模式怎么体现 - [ ] 跟读 AQS 的
acquire→tryAcquire→addWaiter→acquireQueued→shouldParkAfterFailedAcquire全链路 - [ ] 回答 AQS 的虚拟头节点有什么用、
waitStatus五个值的含义、为什么清理 CANCELLED 节点要从后往前 - [ ] 说清 AQS 独占模式与共享模式的区别,重点是共享模式的唤醒传播(
setHeadAndPropagate) - [ ] 基于 AQS 手写一把不可重入锁,再改造成可重入锁,跑通并发测试
- [ ] 基于 AQS 手写
CountDownLatch和Semaphore,验证"减到 0 时所有等待线程一起放行" - [ ] 说清
ReentrantLock公平与非公平的实现差异(hasQueuedPredecessors),以及非公平为什么更快 - [ ] 解释
Condition的双队列模型:await时节点从同步队列迁移到条件队列,signal时迁回 - [ ] 对比
CountDownLatch/CyclicBarrier/Semaphore/Phaser的适用场景和是否可重用 - [ ] 默写
ThreadPoolExecutor的 7 个参数和四级执行流水线,并说清第 2 级入队后的"二次校验"为什么必要 - [ ] 说清线程池的 5 种状态流转,以及
ctl为什么要把状态和线程数塞进一个AtomicInteger - [ ] 跟读
runWorker和getTask源码,解释keepAliveTime到底是怎么实现线程回收的 - [ ] 手写一个简易线程池,支持核心/非核心线程、有界队列、四种拒绝策略、优雅关闭
- [ ] 说清为什么禁用
Executors的四个工厂方法,每一个的具体风险是什么 - [ ] 说清
execute与submit的异常处理差异,以及为什么submit的异常不会触发UncaughtExceptionHandler - [ ] 能给出 CPU 密集型与 IO 密集型的线程数估算公式,并说明为什么最终还是要压测验证
- [ ] 说清线程池优雅关闭的标准姿势:
shutdown→awaitTermination→shutdownNow,以及毒丸方案的优势 - [ ] 画出
ThreadLocal的引用链,解释内存泄漏和脏数据污染两个问题,以及为什么remove()是唯一可靠解 - [ ] 说清
ThreadLocalMap为什么用开放寻址、0x61c88647这个魔数的作用、expungeStaleEntry的清理逻辑 - [ ] 说清
InheritableThreadLocal在线程池里为什么失效,以及 TTL 把传递时机从"创建线程"挪到"提交任务"的思路 - [ ] 默写阻塞队列的四组方法(抛异常 / 返回特殊值 / 阻塞 / 超时),对比
ArrayBlockingQueue单锁与LinkedBlockingQueue双锁 - [ ] 手写生产者消费者的三个版本(
wait/notifyAll、双Condition、BlockingQueue+ 毒丸) - [ ] 手写三线程交替打印 ABC 的五种解法,并解释为什么解法一只能用
notifyAll - [ ] 默写死锁的四个必要条件,并给出每个条件对应的打破手段
- [ ] 在本地复现一次死锁,用
jstack找到Found one Java-level deadlock,再用 Arthas 的thread -b定位 - [ ] 说清死锁、活锁、饥饿的区别和各自的诊断特征(CPU、jstack 表现)
- [ ] 手写转账场景的死锁修复:按
identityHashCode排序 + 加时赛锁,以及tryLock+ 随机退避版本 - [ ] 给核心服务加上
ThreadMXBean.findDeadlockedThreads()的定时巡检告警 - [ ] 默写
CompletableFuture的常用 API 分类:创建、串行(thenApply/thenCompose)、汇聚(thenCombine/allOf/anyOf)、异常(exceptionally/handle/whenComplete) - [ ] 说清
CompletableFuture的四个坑:默认线程池、异常静默、get不带超时、cancel不传播中断 - [ ] 用
CountDownLatch和CompletableFuture各写一版并行聚合查询,对比代码可读性和异常处理能力 - [ ] 说清虚拟线程的实现原理(continuation + 载体线程)、适用场景,以及 pinning 的两个触发条件
- [ ] 解释
ForkJoinPool的工作窃取算法,以及为什么parallelStream里不能做阻塞 IO - [ ] 整理一份并发工具选型决策表:单变量计数 / 纯统计 / 读多写少 / 复杂临界区 / 限流 / 并行聚合 各自该用什么
- [ ] 画出
new Obj()的三步字节码(分配 / 构造 / 赋引用),说清哪两步会被重排、as-if-serial 为什么管不住它 - [ ] 默写
this逸出的三种写法(构造器启线程 / 构造器注册监听 / 构造器调可重写方法),并给出静态工厂的正解 - [ ] 默写
final域的两条重排序规则,以及它们生效的前提条件(this不逸出) - [ ] 列出四种安全发布方式,并说清"不可变对象整体换引用时为什么仍需
volatile" - [ ] 把项目里一个可变配置缓存改写成不可变 Snapshot +
volatile引用,对比改造前后的中间态问题 - [ ] 给自己写的核心类标注线程安全级别(不可变 / 线程安全 / 有条件线程安全 / 非线程安全),写进类注释
- [ ] 默写 Spring
@Async执行器的五级查找顺序,并解释为什么"容器里有两个TaskExecutor"会悄悄用错池 - [ ] 写一份标准的
ThreadPoolTaskExecutor@Bean配置(有界队列 + 线程名前缀 + 拒绝策略 + 优雅停机),作为团队模板 - [ ] 复述
@Async的六个坑(无池 / 无界队列 / 自调用失效 / 返回值与异常 / 事务不传播 /@Scheduled单线程),各给一句话解法 - [ ] 检查项目里所有
@Scheduled方法是否都有整体 try-catch,以及多实例部署是否已加分布式锁 - [ ] 用
@TransactionalEventListener(AFTER_COMMIT)重写一处"异步里读主事务数据"的逻辑,验证偶发查不到的问题消失 - [ ] 做实验验证
Future.cancel(true/false)在"排队中 / 执行中 / 已完成"三种状态下的行为,填出语义表 - [ ] 做实验验证
CompletableFuture.cancel(true)不会中断上游任务(上游打印日志自证跑完) - [ ] 手写两阶段终止模式的长驻线程,并分别测试"只置标志"、"只 interrupt"两种残缺写法的失效表现
- [ ] 写一版毒丸关闭的生产者消费者,对比 interrupt 关闭时存量任务的丢失数量
- [ ] 做实验:任务阻塞在
socket.read()时shutdownNow()无效,改用close()唤醒后恢复正常 - [ ] 列出项目中所有"不响应中断"的阻塞点(HTTP 客户端 / JDBC / 本地文件 /
lock()),逐个确认有超时或cancel手段 - [ ] 了解 JDK21
StructuredTaskScope的ShutdownOnFailure/ShutdownOnSuccess,说清它比"一堆 Future 手动 cancel"强在哪 - [ ] 把手撕题 7 的
CancellableWorker补上单任务超时与健康检查(僵死检测),接入监控指标 - [ ] 把今天的 14 个基础原理节点 + 21 个深挖节点各用一句话概括,形成可快速回顾的脑图
- [ ] 复述并发工程的 14 条 checklist,挑出当前项目里实际违反的 3 条并记录改进计划