Skip to content

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 : 1M : 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 显示
NEWnew Thread()—不出现
RUNNABLEstart() 之后、IO 阻塞中视代码而定runnable
BLOCKED抢 synchronized 失败没拿到blocked on monitor entry
WAITINGwait()、join()、park()、await()wait 会释放锁in Object.wait() / parking
TIMED_WAITINGsleep(n)、wait(n)、join(n)、parkNanos、offer(timeout)sleep 不释放锁waiting on condition
TERMINATEDrun() 正常返回或抛未捕获异常已释放不出现

易错提醒: 面试常问「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 C

happens-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 做复合操作
对比项volatilesynchronized
原子性❌ 不保证✅ 保证
可见性✅✅
有序性✅(禁止重排)✅(临界区整体串行)
阻塞线程❌ 不阻塞✅ 会阻塞
作用对象变量代码块 / 方法
性能很轻(无上下文切换)升级到重量级锁后较重
适用状态标记、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 四组方法的行为差异(必背):

操作抛异常返回特殊值阻塞超时阻塞
入队addofferputoffer(e, time, unit)
出队removepolltakepoll(time, unit)
查看elementpeek——

生产代码优先用带超时的 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 / thenApplyAsyncT → U结果转换(串行依赖)
thenAccept / thenRunT → void / 无 → void消费结果 / 收尾动作
thenComposeT → CompletableFuture<U>扁平化嵌套 Future(类似 flatMap)
thenCombine两个 Future → 合并两个互不依赖的任务结果合并
allOf / anyOf多个 Future全部完成 / 任一完成
exceptionallyThrowable → T异常兜底返回默认值
handle / whenComplete(result, ex)无论成败都执行;handle 可改返回值
orTimeout / completeOnTimeoutJDK 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);   // 整体兜底超时

三个必须记住的用法要点:

  1. 一定传自定义线程池。不传则用 ForkJoinPool.commonPool(),其并行度为 CPU 核数 - 1,是全 JVM 共享的,一个慢 IO 任务就能把整个应用的所有异步编排拖死。
  2. allOf 返回 CompletableFuture<Void>,拿不到结果,必须在 thenApply 里用 join() 逐个取;且 allOf 不会因为某个子任务异常而立即失败,异常会在 join() 时抛出。
  3. 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_WAITINGTIMED_WAITINGWAITING / TIMED_WAITINGWAITING / TIMED_WAITING
响应中断是,抛 InterruptedException是,抛异常并清标志是是,但不抛异常,park 直接返回且不清除中断标志
许可语义无无无有一个二元许可,unpark 可先于 park 调用

park / unpark 相对 wait / notify 的两个优势:

  1. 不要求持有锁,编程模型更自由(AQS 正是基于它实现的)。
  2. 许可可以提前发放:先 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 文档原话:"wait should 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):

  1. 撤销成本高:偏向锁撤销需要到达安全点并 STW,一旦出现竞争,批量撤销会造成明显停顿。
  2. 收益场景萎缩:现代应用普遍用线程池 + JUC,"单线程独占同一个锁"的场景大幅减少。
  3. 维护代价大:偏向锁逻辑渗透到 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 11GC 标记—

两个实验结论(面试可直接当亮点讲):

  1. 调用 hashCode() 会直接让偏向锁失效。因为 Mark Word 里放线程 ID 的位置正是 hashCode 的位置,二者空间互斥,一旦算出 identity hashCode 就只能降级走轻量级/重量级锁。
  2. 锁升级不可逆(单向),但重量级锁在没有竞争且 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 前缀做了三件事:

  1. 锁定缓存行(现代 CPU 用缓存锁而非总线锁);
  2. 把当前 CPU 的 store buffer 全部写回主内存(或 L3),使修改对其他核可见;
  3. 触发 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);
类携带信息适用场景
AtomicStampedReferenceint 版本戳需要知道改了几次
AtomicMarkableReferenceboolean 标记位只关心有没有被改过,更省

问题二:自旋开销

高竞争下 CAS 大量失败重试,空转烧 CPU。解法:

  1. 换成阻塞锁(ReentrantLock,竞争激烈时挂起比自旋更省);
  2. 用 LongAdder 分段降低竞争(见下节);
  3. JDK 9+ 在自旋循环中调用 Thread.onSpinWait()(编译为 x86 的 pause 指令),降低功耗并缓解内存序惩罚。

问题三:只能保证单个变量原子性

解法:

  1. AtomicReference + 不可变对象,把多个字段打包成一个对象整体 CAS;
  2. 实在不行就用锁。
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].value
java
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);              // ④ 扩容 / 重散列 / 初始化
        }
    }
}

四个关键设计:

  1. getProbe() 读的是 Thread.threadLocalRandomProbe,相当于线程的哈希值;CAS 失败时调用 advanceProbe() 换一个槽再试(而不是死磕同一个 Cell)。
  2. cells 懒初始化:没有竞争时只用 base,退化为 AtomicLong,零额外内存。
  3. 扩容上限≈ CPU 核数:再多也没意义,因为同时真正并行的线程数就那么多。
  4. @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 语义AtomicLongincrementAndGet 返回值精确,compareAndSet 可用
高并发纯计数(QPS、埋点)LongAdder吞吐高一个量级
需要实时精确总数AtomicLongLongAdder.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() 才会真正返回

三个必须记住的点:

  1. signal() 不等于立即运行,只是"从条件队列搬到同步队列",仍要排队抢锁——所以 signal 后应尽快 unlock。
  2. fullyRelease 保存并恢复 state,这是 Condition 支持可重入的关键;若简单 release(1) 则重入计数会丢失。
  3. 一个 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;
        }
    }
}

三个必须记住的点:

  1. "核心线程不回收"的本质不是什么特殊标记,而是走了 take() 这个无限阻塞分支;线程池里没有任何字段区分哪个线程是核心线程,只看"当前线程数是否超过 corePoolSize"。
  2. 开启 allowCoreThreadTimeOut(true) 后核心线程也会被回收,适合流量稀疏的定时任务池。
  3. 线程复用的关键是 task.run():Worker 线程只 start() 过一次,之后在 while 循环里不断取任务直接调用 run(),所以同一个线程会串行执行成千上万个任务——这也是 ThreadLocal 必须 remove 的根本原因。

12. 线程池参数怎么定、怎么动态调、怎么监控 ​

第一步:先分清任务类型

任务类型特征经验公式
CPU 密集加解密、压缩、复杂计算core = N + 1(+1 为防止偶发页缺失浪费 CPU)
IO 密集RPC、DB、文件、HTTPcore = 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
维度executesubmit
返回值voidFuture<T>
异常是否可见✅ 打印堆栈 / 触发 UEH❌ 静默,只有 get() 才抛
线程是否被销毁是(随后补充新线程)否(FutureTask 吞掉了,线程继续复用)
入参RunnableRunnable / 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...
大量 BLOCKEDjstack 统计 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 会打印被钉住的栈。

使用准则:

  1. 不要池化虚拟线程。它本身就是"廉价、一次性"的,用 newVirtualThreadPerTaskExecutor() 而不是固定大小池;池化反而把并发度又限制回去了。
  2. 限流不再靠线程数,改用 Semaphore。以前"池子 50 个线程"天然就是对下游的限流;虚拟线程没有这个限制,必须显式加信号量,否则会把下游打崩。
  3. 只对 IO 密集有效。CPU 密集任务的瓶颈是核数,虚拟线程不会让计算变快。
  4. 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-finallylock(); 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
9ThreadLocal 必须 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-beforestatic final Holder H = new Holder();
引用存入 volatile / AtomicReferencevolatile 写-读建立 happens-beforeDCL 的 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 thread

Spring Boot 2.1+ 如果引入了 spring-boot-autoconfigure,会自动配置一个名为 applicationTaskExecutor 的 ThreadPoolTaskExecutor(走分支 ②/③),所以很多人没踩到坑。但它的默认参数是 core=8、max=Integer.MAX_VALUE、queueCapacity=Integer.MAX_VALUE —— 队列无界,max 永远用不上,本质还是会堆到 OOM,只是从"线程爆炸"换成了"队列爆炸"。

坑 2:ThreadPoolTaskExecutor 的参数名和语义都和原生不一样

ThreadPoolTaskExecutor对应原生参数Spring 默认值必须改成
corePoolSizecorePoolSize1(手动 new 时)/ 8(自动配置)按业务类型算
maxPoolSizemaximumPoolSizeInteger.MAX_VALUE必须设有限值
queueCapacityworkQueue 的容量Integer.MAX_VALUE必须设有限值(否则 max 失效)
keepAliveSecondskeepAliveTime60一般不用动
rejectedExecutionHandlerhandlerAbortPolicy按业务选,并打点告警
allowCoreThreadTimeOut同名false稀疏任务可开
setWaitForTasksToCompleteOnShutdown——(Spring 特有)falsetrue + 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 但线程可能还在死循环里跑

三个最容易答错的点:

  1. cancel(false) 对正在执行的任务毫无约束力——任务会一直跑到结束,只是结果被丢弃。它唯一的真实用途是"取消还在队列里排队的任务"。
  2. cancel(true) 只是 interrupt()。如果任务体是 while(true){ compute(); } 这种纯计算且不检查标志的代码,取消完全无效。
  3. 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 查询执行中阻塞在数据库网络 IOstatement.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 + FutureStructuredTaskScope
任务生命周期与提交者无关,容易泄漏严格限定在 try 块内,不可能泄漏
失败传播手动遍历 cancelShutdownOnFailure 自动取消兄弟任务
取第一个成功结果自己写 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。

连环追问链:

  1. 追问:锁升级的完整过程是什么?
    • 答:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,单向不可逆。偏向锁在 Mark Word 里记线程 ID,同一线程再进只需比对 ID;出现第二个线程竞争就升级为轻量级锁,线程在自己栈里建 Lock Record 并 CAS 指向它,失败则自适应自旋;自旋还失败就膨胀为重量级锁,走 ObjectMonitor,线程进内核态挂起变成 BLOCKED。
  2. 追问:JDK 15 对偏向锁做了什么改动?为什么?
    • 答:JEP 374 默认禁用偏向锁,JDK 18 彻底移除。原因有三:偏向锁撤销需要到安全点 STW,代价高;现代应用普遍用线程池 + JUC,"单线程独占一把锁"的场景大幅减少,收益萎缩;偏向锁代码渗透整个 JVM,阻碍 Lilliput 这类对象头压缩优化。所以在新版本上答锁升级要说"现在是无锁 → 轻量级 → 重量级三级"。
  3. 追问:为什么 synchronized 是可重入的?重入计数存在哪里?
    • 答:ObjectMonitor 里有 _owner和 _recursions 两个字段,_owner 记录持有者线程,同一线程再次进入只是 _recursions++,退出时递减,减到 0 才真正释放。ReentrantLock 则是把重入次数累加在 AQS 的 state 上。可重入的意义是避免"自己被自己锁死"——比如一个 synchronized 方法调用同类的另一个 synchronized 方法。
  4. 追问:ReentrantLock 的公平锁和非公平锁差在哪?为什么默认非公平?
    • 答:代码上只差一个 hasQueuedPredecessors() 判断——公平锁抢锁前先看队列里有没有人等,有就乖乖排队;非公平锁直接 CAS 插队。默认非公平是因为吞吐量高一个数量级:唤醒一个 park 的线程需要几微秒上下文切换,这期间锁是空闲的,让正在 CPU 上跑的线程直接拿走能填满这个空窗期。代价是理论上可能饥饿,但实测概率很低。
  5. 追问:synchronized 锁字符串常量会有什么问题?
    • 答:字符串常量在常量池里是同一个对象,不同模块各自 synchronized("lock") 会意外共享同一把锁,造成完全无关的代码互相阻塞,而且极难排查。同理也不能锁 Integer(有缓存池)、Boolean,以及不能锁会被重新赋值的变量(锁对象变了就等于没锁)。正确做法是 private static final Object LOCK = new Object();。
  6. 追问: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。

连环追问链:

  1. 追问:为什么核心线程优先于复用空闲线程?
    • 答:这是 execute 第一步的设计——只要 workerCountOf(c) < corePoolSize 就 addWorker,不检查现有线程是否空闲。目的是让线程池尽快"预热"到核心规模,因为核心线程是常驻的,早建好后续就不用再付创建成本;而且判断"有没有空闲线程"本身需要额外的同步开销。
  2. 追问:线程池为什么不回收核心线程?源码里是怎么区分核心线程的?
    • 答:源码里根本没有区分哪个线程是核心线程。getTask() 里判断 timed = allowCoreThreadTimeOut || wc > corePoolSize,为 true 就走 poll(keepAliveTime) 超时返回 null 然后退出,为 false 就走 take() 无限阻塞。所以"核心线程不死"的本质是它走了 take() 这条永不超时的分支。调 allowCoreThreadTimeOut(true) 核心线程也会被回收。
  3. 追问:有哪几种拒绝策略?线上你会怎么选?
    • 答:JDK 自带四种——AbortPolicy 抛 RejectedExecutionException(默认)、CallerRunsPolicy 调用者线程自己执行(天然反压)、DiscardPolicy 静默丢弃、DiscardOldestPolicy 丢最老的再重试。线上我倾向自定义:先打点告警,然后按业务重要性分流——核心链路(下单)落 MQ 或降级到同步执行,非核心(埋点、推送)直接丢弃并记日志。绝对不用 DiscardPolicy,静默丢数据是最可怕的。
  4. 追问:CallerRunsPolicy 有什么隐患?
    • 答:它让提交任务的线程自己跑任务。如果提交者是 Tomcat 的 Web 线程,就会把 Web 线程占住,请求响应变慢,连锁反应是 Tomcat 线程池也打满,整个服务不可用。它的优点是天然反压(提交变慢了,上游自然就慢下来),所以适合离线批处理这种"宁慢不丢"的场景,不适合在线接口。
  5. 追问:线程池的 5 种状态和 shutdown / shutdownNow 的区别?
    • 答:RUNNING(接新任务、处理队列)→ SHUTDOWN(不接新任务但处理完队列)→ STOP(不接新任务、丢弃队列、中断正在执行的任务)→ TIDYING(任务全空、线程全销毁,执行 terminated() 钩子)→ TERMINATED。状态和线程数一起存在 ctl 这个 AtomicInteger 里,高 3 位状态、低 29 位线程数,这样一次 CAS 就能原子地改两个值。shutdown 温和,只中断空闲线程;shutdownNow 会中断所有线程并返回未执行的任务列表。
  6. 追问:怎么做动态线程池?哪些参数能改?
    • 答:corePoolSize、maximumPoolSize、keepAliveTime、handler 都有 setter 可以运行时改;队列容量是 final 改不了,所以要么自己复制 LinkedBlockingQueue 源码把 capacity 改成 volatile 并加 setter,要么用现成的 DynamicTp、Hippo4j。再把参数接到 Nacos / Apollo 上监听变更,同时把 getActiveCount、getQueue().size()、拒绝次数接到 Prometheus 做告警。
  7. 追问: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 的方式。

连环追问链:

  1. 追问:AQS 的队列为什么要从 tail 往前遍历?
    • 答:因为 addWaiter 入队是两步非原子操作:先 node.prev = t,再 compareAndSetTail 成功后才 t.next = node。中间存在一个窗口,此时新节点的 prev 已经接好但前驱的 next 还是 null——这叫尾分叉。所以从 head 向后遍历会漏掉节点,而反向遍历靠 prev 一定能走通,unparkSuccessor 和 cancelAcquire 里的遍历都是反向的。
  2. 追问:head 节点为什么是个"空"节点?
    • 答:head 是哨兵节点(dummy),不关联任何线程,它的作用是承载 waitStatus —— 队列里每个节点的唤醒职责都写在前驱节点的 waitStatus 上,队首线程需要一个前驱来存 SIGNAL 标记。而且线程抢到锁之后会 setHead(node) 把自己变成新的哨兵并清空 thread 字段,这样既复用了节点又帮助了 GC。
  3. 追问:独占模式和共享模式的区别是什么?
    • 答:独占模式(Node.EXCLUSIVE)同一时刻只有一个线程能拿到 state,释放时只唤醒一个后继,典型是 ReentrantLock、写锁。共享模式(Node.SHARED)允许多个线程同时持有,关键差别是 setHeadAndPropagate 的唤醒传播:一个线程拿到共享锁后会继续唤醒后面的共享节点,形成"连锁唤醒",典型是 Semaphore、CountDownLatch、读锁。
  4. 追问:CountDownLatch 和 CyclicBarrier 的实现差别?
    • 答:CountDownLatch 基于 AQS 共享模式,state 初始化为计数,countDown 就是 releaseShared 把 state 减一,减到 0 时唤醒所有在 await 上阻塞的线程;一次性、不可重置。CyclicBarrier 根本不是直接用 AQS,而是用 ReentrantLock + Condition 实现的:每个线程到达就 count-- 并 await,最后一个到达的执行可选的 barrierAction 并 signalAll,然后重置 count 进入下一代(generation),所以可以循环使用。
  5. 追问:ReentrantReadWriteLock 怎么用一个 state 表示两种锁?会有什么问题?
    • 答:把 32 位的 state 拆开,高 16 位记读锁的持有总数、低 16 位记写锁的重入次数,所以读锁最多 65535 个。各线程自己的读锁重入次数则存在 ThreadLocal(HoldCounter)里。它的问题是写饥饿:读多写少时读锁源源不断进来,写线程可能长期拿不到锁,所以它提供了 apparentlyFirstQueuedIsExclusive 判断——非公平模式下如果队首是写线程,新来的读线程也要排队。另外它不支持锁升级(持读锁时直接 writeLock().lock() 会死锁),只支持降级(持写锁时获取读锁再释放写锁)。
  6. 追问: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-before join() 返回),以及传递性。有了 happens-before,我们就不需要去关心底层屏障的细节,只要能推出"A happens-before B"就说明并发是安全的。

连环追问链:

  1. 追问:volatile 为什么不能保证原子性?举个例子。
    • 答:i++ 编译成三步:读取 i、加 1、写回 i。volatile 只保证每次读拿到的是最新值、每次写立刻可见,但读到写之间的这段窗口里,别的线程完全可以插入自己的写——两个线程都读到 5,各自加 1 写回 6,结果丢了一次累加。20 个线程各加 10000 次,结果必然小于 200000。要原子就得用 AtomicInteger(CAS 自旋)或 synchronized。
  2. 追问:什么场景适合用 volatile?
    • 答:三个典型场景。① 状态标志位:volatile boolean running,一个线程写、其他线程读,没有复合操作;② DCL 单例的 instance 字段,靠的是它禁止重排的能力;③ 一写多读且写入值不依赖当前值,比如配置热更新的 volatile Config config = newConfig。判断准则就一句话:写入不依赖当前值,且没有其他变量参与不变式约束。
  3. 追问:DCL 单例为什么必须加 volatile?不加会怎样?
    • 答:new Singleton() 实际是三步:分配内存、初始化对象、把引用赋给变量。JIT 允许后两步重排,变成"先赋引用、后初始化"。这时线程 A 刚赋完引用还没初始化,线程 B 的第一次检查(在锁外,没有任何同步语义)发现 instance != null 就直接返回了一个半初始化对象,访问字段全是默认值,表现为诡异的 NPE 或脏数据。加 volatile 后 StoreStore 屏障禁止了这个重排。注意光加 synchronized 不够,因为它不禁止临界区内部的重排。
  4. 追问:volatile 和 synchronized 的区别?
    • 答:volatile 只能修饰变量,保证可见性和有序性,不保证原子性,不阻塞、无上下文切换、性能开销小;synchronized 可修饰方法和代码块,三个特性全保证,有竞争时会阻塞并可能膨胀为重量级锁。可以理解为 volatile 是轻量级的同步,适合状态通知;synchronized 适合保护临界区。
  5. 追问:happens-before 里哪条最容易被忽略?
    • 答:线程启动规则和终止规则。很多人以为"新线程里读到的变量要加 volatile",其实 t.start() 之前主线程的所有写操作对新线程都是可见的(start 规则);同样 t.join() 返回后,该线程内的所有写对主线程可见。这两条让"主线程准备数据 → 起线程计算 → join 后取结果"这种模式天然安全,不需要额外同步。
  6. 追问:为什么说"x86 上测不出重排 bug"?
    • 答:x86 是 TSO(Total Store Order)强内存模型,只允许 StoreLoad 一种重排,大部分漏写 volatile 的代码在 x86 上跑起来居然是对的。但 ARM / Power 是弱内存模型,允许的重排多得多——同样的代码在 Apple Silicon 的 Mac、鲲鹏或飞腾服务器上就会暴露问题。所以并发正确性必须靠 happens-before 推理,不能靠"我本地跑了一万次没问题"。

✅ 项目关联话术(按真实经历替换):

我们有个消息消费的开关曾经踩过可见性的坑:管理后台调接口把 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 底座的关键。

连环追问链:

  1. 追问:为什么 Java 把操作系统的"就绪"和"运行中"合并成一个 RUNNABLE?

    • 答:因为 JVM 作为跨平台虚拟机,拿不到也不该依赖操作系统线程调度器的细粒度状态。一个线程是在 CPU 上跑还是在就绪队列里排队,由 OS 调度器决定、随时切换,JVM 查询到的瞬时值毫无意义。所以 Java 只区分"我有没有把自己主动挂起"这件自己能控制的事。
    • 答:一个重要的副作用是:做 socket 读写、磁盘 IO 而阻塞的线程,在 Java 里状态仍然是 RUNNABLE。所以线上 jstack 看到一堆 RUNNABLE 不代表 CPU 很忙,要结合栈顶是不是 socketRead0 这类 native 方法来判断。
  2. 追问: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。只看状态猜不出是不是锁竞争,必须看栈。
  3. 追问:notify() 之后线程立刻变 RUNNABLE 吗?

    • 答:不是。notify() 只是把线程从 _WaitSet 挪到 _EntryList,状态从 WAITING 变成 BLOCKED。它必须重新竞争到那把 synchronized 锁,才能从 wait() 里真正返回变成 RUNNABLE。
    • 答:这也解释了为什么 notify() 之后不用急着退出同步块——通知方还持有锁,被唤醒方根本抢不到,得等通知方走出 synchronized 块。所以"notify() 放在同步块最后一行"是个优化习惯,不是正确性要求。
  4. 追问:sleep(0) 和 Thread.yield() 有什么用?

    • 答:yield() 是给调度器的一个建议:"我这个时间片可以让出去"。它不释放锁,状态保持 RUNNABLE,而且调度器完全可以忽略它,下一个被选中的可能还是自己。所以它不能用来做任何同步保证,只适合自旋等待时降低 CPU 空转(Thread.onSpinWait() 是更现代的选择,会生成 pause 指令)。
    • 答:sleep(0) 在 HotSpot 上的效果和 yield() 类似(触发一次调度点),但它会抛 InterruptedException,语义更重。实践中都不建议依赖,它们属于"调优小手段",不是同步原语。
  5. 追问: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 内部约定。
  6. 追问: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 的循环要求是一样的。
  7. 追问:线上 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()。

连环追问链:

  1. 追问:为什么 key 要设计成弱引用?不用弱引用会怎样?

    • 答:弱引用是一道兜底的补救措施,不是泄漏的原因。如果 key 用强引用,那么只要线程活着,Entry 就强引用着 ThreadLocal 实例,即使业务代码里这个 ThreadLocal 变量已经不可达(比如它是个局部变量、或者它所属的类被卸载了),key 和 value 都回收不了,泄漏会更严重。
    • 答:用弱引用后,至少 key 能被回收,然后 ThreadLocalMap 在 set / get / remove 时遇到 key == null 的 Entry 会触发清理,有机会顺带把 value 置 null。注意是"有机会"——清理是启发式的、不保证执行,所以不能依赖它。
    • 答:一句话总结:弱引用让泄漏变得"可能被修复",但 remove() 才是"确定被修复"。
  2. 追问: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,那就永远不会触发清理。这正是线程池场景泄漏的根因。
  3. 追问:ThreadLocalMap 为什么用开放寻址而不是 HashMap 的链地址法?

    • 答:第一,元素极少。一个线程通常只有个位数的 ThreadLocal(traceId、用户上下文、事务连接),数组线性探测的冲突概率低且缓存局部性好,不需要链表/红黑树的额外对象开销。
    • 答:第二,hash 分布很好。ThreadLocal 的 threadLocalHashCode 是用全局 AtomicInteger 以固定步长 0x61c88647(黄金分割数相关)递增生成的,这个魔数能让连续生成的 hash 在 2 的幂长度的数组里近似均匀分布,冲突本来就少。
    • 答:第三,Entry 需要继承 WeakReference。链地址法的 Node 要带 next 指针,而开放寻址下 Entry 可以就是一个纯粹的 WeakReference 子类,结构最简单,也方便 GC 语义的实现。
  4. 追问:线程池里用 ThreadLocal 除了泄漏,还有什么更危险的问题?

    • 答:脏数据污染,而且这个比泄漏危险得多。线程池复用线程,上一个任务 set 的值如果没 remove,下一个任务 get 就会拿到前一个请求的数据。如果里面存的是用户身份,就是实打实的越权/串号事故——A 用户的请求读到了 B 用户的上下文。
    • 答:泄漏只是内存慢慢涨,监控能发现;污染是数据正确性问题,而且只在高并发下偶发,极难复现。所以 remove() 的首要理由是防污染,其次才是防泄漏。
    • 答:工程上的做法:用拦截器/Filter 在请求出口统一 remove,或者封装一个 ContextHolder 工具类,只暴露 set/get/clear,并在 AOP 切面里强制 clear,不让业务代码直接接触 ThreadLocal。
  5. 追问:父线程的 ThreadLocal 怎么传给子线程?线程池场景怎么办?

    • 答:JDK 提供 InheritableThreadLocal,它利用 Thread 的另一个字段 inheritableThreadLocals。在 new Thread() 的构造过程中(Thread.init)会把父线程的 inheritableThreadLocals 浅拷贝一份给子线程,所以子线程能读到父线程的值。
    • 答:但它在线程池里完全失效:因为拷贝只发生在"创建线程"的那一刻,线程池的线程是第一次提交任务时创建的,之后一直复用,后续任务提交时不会再发生任何拷贝,子线程里的值永远是第一个任务那次留下的旧值。
    • 答:正确方案是阿里的 TransmittableThreadLocal (TTL)。思路是把传递时机从"创建线程"挪到"提交任务":用 TtlRunnable.get(task) 包装任务,在提交时捕获当前线程的上下文快照,在任务 run 开始时回放到执行线程、结束时恢复原样。配合 TtlExecutors.getTtlExecutorService() 或 javaagent 可以做到无侵入。
    • 答:补充:浅拷贝还有个坑——父子线程拿到的是同一个对象引用。如果 value 是可变对象(比如 Map),子线程改了父线程也会看到,必须自己做深拷贝或用不可变对象。
  6. 追问:ThreadLocal 存储的 value 是弱引用吗?如果我把 ThreadLocal 声明成 static final 还会泄漏吗?

    • 答:value 是强引用,这是泄漏的必要条件之一。
    • 答:声明成 static final 是推荐做法,因为它保证 key 一直有强引用、不会变成脏 Entry,Entry 结构稳定,也避免了"每个请求 new 一个 ThreadLocal"这种灾难性写法(那会让 ThreadLocalMap 不断膨胀又不断产生脏 Entry)。
    • 答:但它不能免除 remove。static final 只解决了"key 被回收导致 value 不可达"这一种泄漏,而"线程长期存活 + value 一直被引用 + 业务不再需要它"这种泄漏和数据污染问题依然存在。所以结论不变:无论怎么声明,try/finally + remove() 是铁律。
  7. 追问:有哪些框架在用 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 里。我们自己写的时候照抄这个模式就不会错。

✅ 项目关联话术(按真实经历替换):

我们线上出过一次 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 / 不可变对象 / 单线程串行化)。

连环追问链:

  1. 追问:死锁、活锁、饥饿有什么区别?

    • 答:死锁是互相持有对方需要的锁,全部永久阻塞,线程状态是 BLOCKED / WAITING,CPU 占用为 0。
    • 答:活锁是线程都没有阻塞,但不断重试又不断失败,一直在"礼让"。典型场景是两个线程都用 tryLock 失败就回退重试,且重试间隔固定,于是永远在同一个节拍上撞车。解法是加随机退避(像以太网的指数退避),让重试时机错开。
    • 答:饥饿是某些线程长期拿不到资源。来源有三:非公平锁下的插队(新来的线程直接抢走锁,队列里的老线程一直等)、线程优先级设置不当、持锁线程执行时间过长。解法是改用公平锁、或者保证临界区足够短。
    • 答:三者的共性是"业务推进不下去",但诊断手段不同:死锁看 jstack 环路;活锁看 CPU 高但吞吐为 0、日志里重试计数疯涨;饥饿看某些请求的 P999 异常高而平均值正常。
  2. 追问:怎么用代码复现一个死锁?

    • 答:最小模型是两个线程两把锁反向获取:线程 A 先 synchronized(lockA),sleep 一下确保线程 B 也拿到自己的锁,再去 synchronized(lockB);线程 B 反过来。那个 sleep 很关键,它保证两个线程都完成了"持有第一把锁"这一步,否则其中一个可能快速跑完全程不会死锁。
    • 答:实际项目里的死锁往往不这么直白,常见变形有:同一个方法被不同调用方以不同顺序传入两个账户对象(转账场景的经典死锁,A 转 B 和 B 转 A 并发);嵌套调用,外层方法持有锁 X 后调用了一个自己也加锁 Y 的服务方法,而另一条链路顺序相反;数据库行锁死锁,两个事务按不同顺序更新同一批行。
    • 答:所以 review 时的信号是:一个临界区内又调用了别的加锁方法(锁嵌套),以及锁对象来自方法参数(顺序不可控)。
  3. 追问:转账场景 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 保证原子性,应用层不加锁。
  4. 追问: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 次)。死锁的特征是栈完全不动;如果两次栈不一样,那是慢而不是死。这一招能快速区分死锁和性能问题。
  5. 追问:除了 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),起一个定时任务每分钟扫一次,发现非空就告警并打印栈。这是给核心服务加的保险。
  6. 追问:tryLock 带超时能彻底避免死锁吗?有什么代价?

    • 答:能避免死锁"永久卡死",因为它打破了"持有并等待"——拿不到第二把锁就超时,然后必须释放已持有的第一把锁再整体重试。注意这个"释放已持有的锁"是关键,很多人只写了 tryLock 却忘了失败后 unlock 第一把,等于没解决。
    • 答:代价有三个:一是可能变成活锁,需要配随机退避;二是业务要能接受失败/重试,不是所有操作都幂等可重试;三是代码变复杂,嵌套 try/finally 容易写错,容易出现漏 unlock 或多 unlock(IllegalMonitorStateException)。
    • 答:所以我的取舍是:能固定加锁顺序就优先固定顺序(零成本、无副作用),只在锁顺序确实无法统一(比如锁对象动态来自外部)时才用 tryLock 兜底。
  7. 追问:从架构设计上怎么从根本上减少死锁的可能?

    • 答:第一,减少锁。能用不可变对象(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() 不是原子快照,统计期间有并发写就可能不精确。

连环追问链:

  1. 追问: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。
  2. 追问: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 依然存在。
  3. 追问: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 时用。
  4. 追问: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),分段的收益就全被缓存一致性流量吃掉了。实测伪共享能让性能差好几倍。
  5. 追问:LongAdder.sum() 为什么不准?什么场景不能用?

    • 答:sum() 的实现是 base 加上遍历 cells 的累加,整个过程没有加锁也没有快照机制。遍历到 Cell[2] 的时候,Cell[0] 可能又被改了,于是结果既不是开始时刻的值也不是结束时刻的值,是个"中间态"。
    • 答:所以不能用在需要精确判断的场景。比如"库存减到 0 就停止"、"限流计数超过阈值就拒绝"这种强一致判断,用 LongAdder 会放过一部分请求。这类场景要用 AtomicLong 的 decrementAndGet 返回值判断,或者用 Redis/数据库的原子操作。
    • 答:它适合的是监控指标、QPS 统计、总请求数、缓存命中数这类"最终统计、容忍瞬时偏差"的场景。而且注意 sum() 本身是 O(cells 长度) 的,高频调用也有成本,一般按秒级采样。
    • 答:另外 LongAdder 没有 compareAndSet,也没有返回自增后的值的方法(increment() 返回 void),所以需要"拿到自增后序号"的场景(比如生成递增 ID)只能用 AtomicLong。
  6. 追问: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),新代码应该优先用它。
  7. 追问: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 不指定执行器时,优先找容器里唯一的 TaskExecutor Bean;找不到就退化成 SimpleAsyncTaskExecutor——它每次调用都 new 一条新线程、完全不复用,QPS 一上来线程数直接线性增长,最后 OutOfMemoryError: unable to create new native thread。如果引入了自动配置的 ThreadPoolTaskExecutor,默认 queueCapacity 是 Integer.MAX_VALUE 的无界队列,表现就从"线程爆"变成"堆爆 + 任务积压看不见"。

排查三步:jstack 看线程名(SimpleAsyncTaskExecutor-xxx 一眼定性)→ jmap/dump 看 LinkedBlockingQueue 里堆了多少任务 → 看 ThreadPoolTaskExecutor 的 getActiveCount/getQueueSize 指标。

治理:显式声明 ThreadPoolTaskExecutor Bean(core/max/queueCapacity 全部有界 + 自定义线程名前缀 + CallerRunsPolicy 或业务降级拒绝策略 + setWaitForTasksToCompleteOnShutdown(true) 和 awaitTerminationSeconds 做优雅停机),@Async("bizExecutor") 按业务隔离多个池,并把队列深度、活跃线程数打到监控上。

连环追问链:

  • 追问 1:不配线程池时到底用的哪个执行器,判定顺序是什么? 答:AsyncAnnotationBeanPostProcessor 的查找顺序是——① 注解里指定的 Bean 名;② 实现了 AsyncConfigurer 的 getAsyncExecutor();③ 容器中唯一的 TaskExecutor 类型 Bean;④ 名为 taskExecutor 的 Executor Bean;⑤ 全都没有则 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();
    }
}

三个必须记住的点:

  1. volatile 不是为了可见性那么简单:instance = new SingletonDCL() 分三步——① 分配内存 ② 执行构造器初始化 ③ 把引用指向内存。②③ 之间允许重排,一旦重排成 ①③②,另一个线程在第一次检查时就会拿到一个非 null 但字段全是默认值的半成品对象。volatile 通过插入 StoreStore / StoreLoad 屏障禁止这次重排。
  2. 两次检查都不能省:省掉第一次检查等于每次 getInstance 都进 synchronized,性能退化;省掉第二次检查则多个线程都可能通过第一次检查,排队进入同步块后各自 new 一个对象。
  3. 静态内部类的线程安全来自 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 / 热部署场景的真实坑)

🔁 变种拓展题:

  1. 写一个泛型的"单例注册表":<T> T getSingleton(Class<T> clazz),用 ConcurrentHashMap<Class<?>, Object> + computeIfAbsent 保证每个类型只创建一次。注意 computeIfAbsent 对同一个 key 会加锁,回调里不能再操作同一个 map(否则死锁或 IllegalStateException)。
  2. 实现一个支持懒加载的 Lazy<T> 容器:Lazy.of(Supplier<T>),首次 get() 时计算并缓存,要求线程安全且 Supplier 只被执行一次(参考 Guava 的 Suppliers.memoize,内部就是 DCL)。
  3. 把 DCL 改成用 VarHandle 实现,用 getAcquire / setRelease 替代 volatile 的全屏障,对比两者生成的汇编(JDK 9+ 的性能优化手法)。
  4. 实现一个"每个 key 一个单例"的对象池:比如按 shardId 缓存连接,要求不同 key 不互斥、同一 key 只创建一次。考察点是分段锁或 ConcurrentHashMap + FutureTask 的经典组合(避免重复创建昂贵对象)。
  5. 解释为什么 Spring 的单例 Bean 不需要 DCL:Spring 的 DefaultSingletonBeanRegistry.getSingleton 用的是 synchronized (this.singletonObjects) 加上三级缓存,而且容器启动阶段基本是单线程完成的,设计目标是解决循环依赖而不是并发创建。
  6. 延伸到分布式单例:单 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();           // 由提交任务的线程自己执行,天然形成背压
        }
    };
}

三个必须记住的点:

  1. while (task != null || (task = getTask()) != null) 是整个线程池"复用"的灵魂。线程不会执行完一个任务就结束,而是在循环里不断从队列阻塞获取下一个任务。核心线程用 take() 永久阻塞,非核心线程用 poll(keepAlive) 超时后返回 null 跳出循环从而被回收——这就是 keepAliveTime 的全部实现。
  2. task.run() 外面必须包 try-catch(Throwable)。一旦异常冲出 Worker.run(),这个线程就彻底死亡,池子会被慢慢"吃光"。虽然 processWorkerExit 会补线程,但频繁创建销毁线程的代价很高,而且异常被静默吞掉会丢失关键日志。
  3. 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 → 拒绝,顺序不能错
队列用无界 LinkedBlockingQueuemaxSize 永远不生效(第 3 级流水线到不了),任务无限堆积直到 OOM

🔁 变种拓展题:

  1. 支持 submit 返回 Future:自己实现一个简化版 FutureTask,内部用 CountDownLatch 或 AQS 保存结果/异常,get() 阻塞等待,get(timeout) 超时抛 TimeoutException。注意异常要包装成 ExecutionException。
  2. 支持动态调参:实现 setCorePoolSize(int),调小时要中断多余的空闲线程,调大时如果队列非空要立即补线程。再实现一个可变容量的队列(LinkedBlockingQueue 的 capacity 是 final 的,要用反射改或自己写),这是美团动态线程池的核心。
  3. 加监控埋点:记录任务排队时长(提交时打时间戳,执行前算差值)、执行耗时、队列峰值、拒绝次数,并支持 beforeExecute / afterExecute 钩子(可以用来做 MDC traceId 传递和 ThreadLocal 清理)。
  4. 实现定时任务能力:把队列换成 DelayQueue 或自己写一个基于小顶堆的 DelayedWorkQueue,支持 schedule(task, delay) 和 scheduleAtFixedRate。重点说清 scheduleAtFixedRate 和 scheduleWithFixedDelay 的区别,以及任务抛异常会让后续调度永久停止这个大坑。
  5. 实现任务优先级:队列换成 PriorityBlockingQueue,任务实现 Comparable。要指出两个坑:优先级队列无界(容量只会扩容),以及低优先级任务可能饥饿。
  6. 实现线程池的"预热":prestartAllCoreThreads(),在服务启动时就把核心线程创建好,避免第一批请求承担线程创建的开销(配合压测预热很有用)。
  7. 对比 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 指令,降低自旋功耗
            }
        }
    }
}

三个必须记住的点:

  1. wait 必须写在 while 里,不能写 if。原因有两个:一是 wait 可能发生虚假唤醒(spurious wakeup,POSIX 规范允许);二是 notifyAll 会唤醒所有等待者,被唤醒的线程重新抢到锁时条件可能已经被别人改掉了。规范写法永远是"循环检查条件谓词"。
  2. 解法一只能用 notifyAll,不能用 notify。notify 从 _WaitSet 里随机挑一个唤醒。假设当前该打印 B,而 notify 唤醒的是 C,C 发现条件不满足又 wait回去,于是没有任何线程能继续——整个程序死锁。用 notifyAll 虽然有惊群开销,但保证正确。想要既精准又高效就得用解法二的多 Condition。
  3. 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%,印证自旋方案不能用于生产
主线程中断其中一个打印线程解法一二应能退出循环并恢复中断标志;解法五的自旋无法响应中断(需要额外检查标志位)

🔁 变种拓展题:

  1. N 个线程循环打印 1~N:把 state % 3 泛化成 state % n,用 Condition[] 数组管理,next = conds[(target + 1) % n]。考察能否把硬编码抽象成通用模型。
  2. 两个线程交替打印 1A2B3C...26Z:数字线程打印数字后唤醒字母线程,字母线程打印字母后唤醒数字线程。注意最后一轮的收尾,不要让某个线程永久阻塞。
  3. 按奇偶交替打印 1~100:两个线程,一个只打奇数一个只打偶数。可以用 AtomicInteger + 自旋,也可以用 synchronized + wait/notify(这里可以用 notify,因为只有一个等待者)。
  4. 多个线程按顺序执行(不循环):A 执行完 B 才能开始,B 完 C 才能开始。最佳解法是 CompletableFuture.thenRun 链式编排,或者 join(),不需要任何锁。
  5. 三个线程打印 ABC,但要求 B 打印两次(ABBCABBC):考察状态机设计能力,把 state % 3 换成一个显式的状态转移表,而不是靠取模硬算。
  6. 用 BlockingQueue 实现:三个容量为 1 的队列串成环,每个线程从自己的队列 take 一个令牌、打印、往下一个队列 put。本质和 Semaphore 一样,但能顺带考察阻塞队列 API。
  7. 压测对比五种方案的吞吐:用 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);
        }
    }
}

三个必须记住的点:

  1. 版本一的 notifyAll 不能换成 notify。因为生产者和消费者挂在同一个 _WaitSet 里,notify 可能唤醒一个同类:比如缓冲区满时,一个生产者消费掉通知唤醒了另一个生产者,后者发现还是满的又 wait 回去,于是真正该被唤醒的消费者永远收不到通知,形成信号丢失型死锁。这正是 JDK 要用两个 Condition 的原因。
  2. 两个 Condition 带来的是"精准唤醒"。notFull 和 notEmpty 是两条独立队列,生产者只会唤醒消费者、消费者只会唤醒生产者,避免了同类间的无效唤醒和惊群。这让 signal() 替代 signalAll() 成为可能,高并发下吞吐差距非常明显。
  3. 优雅停机优先用毒丸而不是 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 替换生产者必须等到有消费者接手才能返回,吞吐取决于消费速度,无缓冲

🔁 变种拓展题:

  1. 实现一个有界阻塞队列的完整 API:补上 offer(非阻塞)、poll(timeout)、peek、drainTo(Collection, max)、remainingCapacity。重点是四组方法的语义区分:抛异常 / 返回特殊值 / 阻塞 / 超时。
  2. 实现 LinkedBlockingQueue 的双锁版本:putLock 和 takeLock 分离,count 用 AtomicInteger。难点是跨锁通知:put 后如果发现队列从 0 变成 1,需要拿 takeLock 去 signal 消费者(JDK 里叫 signalNotEmpty),以及 fullyLock 的加锁顺序必须统一以防死锁。
  3. 实现无锁队列:参考 ConcurrentLinkedQueue 的 Michael-Scott 算法,用 CAS 操作 head/tail,处理"tail 滞后"(tail 不保证总指向最后一个节点,用 hops 减少 CAS 次数)和哨兵节点自引用(p == q 时重新从 head 开始)。
  4. 批量消费优化:消费者一次 drainTo 取出 100 条批量处理(比如批量落库),但要加"最长等待 50ms"的兜底,避免低流量时消息长时间不被处理。这是消息中间件客户端的标准做法。
  5. 加背压与限流:当队列长度超过 80% 时,让生产者主动降速(CallerRunsPolicy 的思想),或者返回"系统繁忙"给上游。对比"无界队列 + OOM"和"有界队列 + 拒绝"两种失败模式,说明为什么后者更好。
  6. 加监控:暴露队列长度、入队速率、出队速率、消息平均排队时长(入队时打时间戳)。重点说清队列长度持续增长意味着消费能力不足,需要扩消费者或优化处理逻辑。
  7. 升级成多级流水线:三个阶段(解析 → 计算 → 落库)各自一个线程池和队列,形成 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(); }
}

三个必须记住的点:

  1. AQS 的使用范式是固定的:写一个继承 AQS 的静态内部类 Sync,只重写 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享),绝不重写 acquire/release 本身——排队、park、唤醒、中断处理这些复杂逻辑由 AQS 模板方法负责。外部类只做委托。
  2. state 的语义由你自己定义,这是 AQS 最精妙的地方:ReentrantLock 用它表示重入次数;CountDownLatch 用它表示剩余计数;Semaphore 用它表示可用许可;ReentrantReadWriteLock 把 32 位拆成高 16 位读锁 + 低 16 位写锁。同一套排队框架能支撑完全不同的同步语义。
  3. 独占和共享的核心区别在"唤醒是否传播"。独占模式释放时只唤醒队首一个节点;共享模式通过 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 到 010 个线程全部被唤醒(验证共享模式的唤醒传播)
CountDownLatch 重复使用(减到 0 后想重置)不支持,getCount 恒为 0;需要循环栅栏请用 CyclicBarrier
await(1, SECONDS) 但计数未归零返回 false,不抛异常;超时后 getCount() 仍为原值
MySemaphore(2),3 个线程同时 acquire2 个成功,第 3 个阻塞直到有人 release
MySemaphore 的 release 次数多于 acquire许可数会虚增(JDK 的 Semaphore 也是这个行为),说明必须在 finally 里配对调用

🔁 变种拓展题:

  1. 手写 CyclicBarrier:用 ReentrantLock + Condition + "代(generation)"对象实现。重点是最后一个线程到达时执行 barrierAction 并调用 nextGeneration() 重置计数、signalAll 放行;以及任一线程被中断/超时时要 breakBarrier(),让其他线程抛 BrokenBarrierException。
  2. 手写读写锁:用 state 的高 16 位存读锁计数、低 16 位存写锁计数。要处理写锁重入、读锁重入(需要 ThreadLocal 记录每个线程的读计数)、锁降级(持写锁时获取读锁再释放写锁,合法)、以及不支持锁升级(持读锁时获取写锁会死锁)。
  3. 实现公平与非公平两种 tryAcquire:非公平直接 CAS 抢;公平版要先调 hasQueuedPredecessors() 检查队列里有没有人排在自己前面。用压测对比两者吞吐差距,并解释非公平为什么更快(减少了一次线程切换)。
  4. 给锁加上超时自动释放的"看门狗":仿照 Redisson,持锁线程启动一个定时任务续期,超过阈值未续期则强制释放。讨论这在本地锁里为什么通常不需要(进程不会"半死"),但在分布式锁里是必需的。
  5. 实现一个可重入的"分段锁":lock(String key) 对不同 key 互不阻塞、同一 key 互斥。用 ConcurrentHashMap<String, Sync> + 引用计数管理 Sync 的创建和回收,难点是回收时的竞态(刚判断引用为 0 准备删除时又有人来加锁)。
  6. 用 AQS 实现一个"限流器":令牌桶,state 存当前令牌数,定时任务补充令牌并 releaseShared 唤醒等待线程。对比 Guava RateLimiter 的做法(它用的是"预支未来令牌"的 SmoothBursty 算法,不走 AQS)。
  7. 读 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);
    }
}

三个必须记住的点:

  1. countDown() 永远放在 finally 里。这是聚合查询最容易踩的坑:子任务抛异常时没减计数,主线程就会一直等到超时(甚至永久阻塞,如果用了无参 await())。同理,await() 必须带超时,这是服务不被下游拖死的最后一道防线。
  2. CompletableFuture 必须传自定义线程池。默认用 ForkJoinPool.commonPool(),它的并行度是 CPU 核数 - 1,所有业务共享;一旦某个业务提交阻塞型 IO 任务,整个 JVM 的所有并行流和异步任务都会被拖慢。更坑的是容器里如果没识别 cgroup 限制,核数可能被算成 1,commonPool 退化成在调用线程执行。
  3. 固定加锁顺序是零成本的死锁根治方案,优先级高于 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 的大坑)

🔁 变种拓展题:

  1. 把聚合查询改写成 CompletableFuture.allOf:对比 CountDownLatch 的写法,说明 allOf 返回 CompletableFuture<Void>,需要再 join 各个子 future 取值;以及 anyOf 用于"多个数据源取最快的那个"的场景。
  2. 实现"部分失败可接受"的聚合:给每个子任务单独设超时和降级值,要求任意子任务失败都不影响整体返回。用 handle((r, e) -> e == null ? r : fallback) 实现。
  3. 实现超时控制的兼容写法:JDK 8 没有 orTimeout,用一个单独的 ScheduledExecutorService 在 N 毫秒后调 future.completeExceptionally(new TimeoutException()) 来模拟,并注意任务完成后要取消定时任务避免定时线程池堆积。
  4. 给异步任务传递 traceId:实现一个 TraceRunnable 包装器,在提交时捕获 MDC 快照,在执行时 MDC.setContextMap 并在 finally 里 MDC.clear()。再升级成用 TTL 做无侵入传递。
  5. 实现一个带熔断的聚合器:某个下游连续失败 N 次后直接走降级不再发起调用(Sentinel / Resilience4j 的思路),并在一段时间后半开试探。
  6. 复现并修复"数据库死锁":两个事务按不同顺序 UPDATE 同两行,观察 MySQL 的 Deadlock found when trying to get lock 报错和 SHOW ENGINE INNODB STATUS 输出。修复思路和内存锁完全一致——统一更新顺序(比如 ORDER BY id 后批量更新)。
  7. 把死锁巡检接入可观测体系:除了 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
3shutdown() 之后再 submit抛 RejectedExecutionException,不会静默丢
4某个任务抛 RuntimeExceptionfailed+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,不吞

🔁 变种拓展题:

  1. 支持单任务超时:给每个任务配独立超时,超时后只取消该任务不影响 Worker——用 ScheduledExecutorService 延迟触发 future.cancel(true),或把任务包进 FutureTask 并记录 runner 线程。
  2. 多消费者版本:改成 N 个 Worker 共享队列,shutdown() 需投 N 个毒丸,或消费到毒丸后放回队列传递;注意最后一个消费者要把毒丸取走,避免残留。
  3. 支持暂停/恢复:加 pause()/resume(),用 ReentrantLock + Condition 或 Semaphore 实现,注意暂停期间队列会积压,要有上限保护。
  4. 改用虚拟线程:JDK21 下每个任务 Thread.ofVirtual().start(),但要注意虚拟线程不适合 synchronized 长临界区(pinning);取消语义不变,仍然是协作式。
  5. 结构化并发重写:用 StructuredTaskScope.ShutdownOnFailure 替代手写取消,验证"作用域退出时保证无线程泄漏"这一特性。
  6. 接入监控与健康检查:暴露 queueSize / done / failed / 最后活跃时间,并实现一个 isHealthy()——超过 N 秒没有任何进展就认为 Worker 僵死,配合 K8s 探针自动重启。
  7. 换成 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 才会触发
核心线程数拍脑袋设成 200CPU 密集型设 核数 + 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 加 volatilenew 的指令重排会让其他线程拿到半初始化对象(非 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 阻塞时状态是 BLOCKEDJava 里仍然是 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 在并发下 putJDK 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 直接存外部传入的 Listfinal 只保证引用可见性,不保证内容不可变,外部仍能改。构造时 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 条并记录改进计划