1. ThreadLocal 原理?为什么会内存泄漏?(必考)

一句话:ThreadLocal 让每个线程拥有变量的独立副本,用「空间换安全」,避免共享。

  1. 每个 Thread 对象内部有一个 ThreadLocalMap 字段(threadLocals)
  2. ThreadLocal.set(v) 实际是:拿到当前线程的 map,以 ThreadLocal 对象自己为 key,把值存进去
  3. get() 就是用自己当 key 去当前线程的 map 里查
  4. 所以数据的归属是「线程」,不是「ThreadLocal」——ThreadLocal 只是一个访问入口
结构示意
Thread t1 ──> ThreadLocalMap ──> [ Entry(key=ThreadLocalA, value=副本1),
                                          Entry(key=ThreadLocalB, value=副本3) ]
Thread t2 ──> ThreadLocalMap ──> [ Entry(key=ThreadLocalA, value=副本2) ]

// key 是 ThreadLocal 对象(弱引用),value 是业务数据(强引用)
// static final ThreadLocal<User> CTX = new ThreadLocal<>();

为什么会内存泄漏?关键在于 Entry 继承自 WeakReference<ThreadLocal>:

引用指向强度后果
Entry 的 keyThreadLocal 对象弱引用ThreadLocal 没被外部强引用时,GC 会回收它,key 变成 null
Entry 的 value你的业务对象强引用key 没了但 value 还在,且被 Thread → ThreadLocalMap → Entry 引用链拽着,无法回收
泄漏的完整链条
// ① 线程池里的线程是「长活」的(不是用完就死)
// ② ThreadLocal 对象本身(比如局部变量)失去强引用 → key 被 GC 置为 null
// ③ 但 Entry 还在 map 里,value 仍被强引用
// ④ 线程不死 → map 不死 → value 永远回收不了 → 内存泄漏

// 线程池复用还会导致「数据串扰」:下一个任务读到了上一个任务的残留值
标准写法(必须用 try-finally)
private static final ThreadLocal<User> CTX = new ThreadLocal<>();

public void handle() {
    CTX.set(currentUser());
    try {
        doSomething();          // 业务逻辑
    } finally {
        CTX.remove();           // ← 这一行是关键,不能省
    }
}

🎯 面试要点

  • 为什么 key 用弱引用?——这是一种「兜底」设计:万一使用者忘了 remove,至少让 ThreadLocal 对象能被回收,并给后续的清理逻辑(expungeStaleEntry)留下线索。但 value 是强引用,所以泄漏只减轻没有根除
  • 典型应用场景——事务连接绑定(Spring 的 TransactionSynchronizationManager)、用户上下文/TraceId 透传、SimpleDateFormat 线程隔离、MyBatis 的 SqlSession 绑定
  • 父子线程怎么传?——InheritableThreadLocal 可以,但线程池里会失效(线程是复用的,创建时机不对)。要解决就用阿里的 TransmittableThreadLocal(TTL)
  • 为什么线程池里 InheritableThreadLocal 失效?——它只在线程创建时从父线程拷贝一次。线程池的线程是提前创建好的,提交任务时不会再拷贝

2. CompletableFuture 怎么做异步编排?

Future 的问题:只能阻塞地 get(),无法编排、无法组合、无法方便地处理异常。CompletableFuture(JDK 8)解决了这些。

方法含义典型用途
thenApply对结果做转换(有入参有返回)a -> a + 1
thenAccept消费结果(有入参无返回)打印、写库
thenRun不关心结果,接着做(无入参无返回)收尾动作
thenCompose把结果再变成一个 CompletableFuture(串行依赖)查用户 → 用用户 ID 查订单
thenCombine合并两个独立的 future(并行)同时查库存和价格
allOf等全部完成批量并行调用后统一处理
anyOf任一完成即可多源竞速(取最快的)
exceptionally / handle异常兜底降级返回默认值
① 串行编排:thenCompose 解决「回调地狱」
// 需求:查用户 → 用 userId 查订单 → 用订单查物流
CompletableFuture<Logistics> f =
    getUserAsync(uid)                                  // CF<User>
        .thenCompose(u -> getOrderAsync(u.getId()))     // CF<Order>  ← 注意是 compose
        .thenCompose(o -> getLogisticsAsync(o.getId())); // CF<Logistics>

// 如果错用 thenApply,会得到 CF<CF<Order>> 嵌套两层,非常难处理
② 并行合并:thenCombine / allOf
// 需求:同时查「库存」和「价格」,两个请求互相独立 → 应该并行
CompletableFuture<Stock> fs = supplyAsync(() -> stockService.get(id), pool);
CompletableFuture<Price> fp = supplyAsync(() -> priceService.get(id), pool);

// 两个都完成后合并(总耗时 ≈ max(两者),不是相加)
CompletableFuture<Detail> detail =
    fs.thenCombine(fp, (s, p) -> new Detail(s, p));

// 批量并行 + 统一收口
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.thenRun(() -> System.out.println("三个都完成了"));
③ 异常处理与超时(生产必备)
CompletableFuture<String> f = supplyAsync(() -> remoteCall(), pool)
    // 兜底:异常时返回默认值,但会「吞掉」异常
    .exceptionally(ex -> "默认值")
    // 更好的写法:handle 同时拿到结果和异常
    .handle((result, ex) -> ex != null ? fallback(ex) : result)
    // 超时控制(JDK 9+):超时抛 TimeoutException
    .orTimeout(500, TimeUnit.MILLISECONDS)
    // 超时后用默认值继续(JDK 9+)
    .completeOnTimeout("超时默认值", 500, TimeUnit.MILLISECONDS);

🎯 面试要点

  • 最大的坑:默认共用 ForkJoinPool.commonPool()——supplyAsync(fn) 不传线程池就用公共池,池大小默认是 CPU 核数 - 1,一旦有任务阻塞(比如同步 RPC),整个池会被拖死,还影响其他业务。生产必须传自定义线程池
  • thenApply vs thenCompose——前者结果还是普通值,后者结果本身又是 CompletableFuture(用于串行依赖)。记法:compose 就是「扁平化」,避免嵌套
  • thenApply vs thenApplyAsync——不加 Async 的会在上一个任务完成的那个线程里执行;加了 Async 会提交到线程池。有耗时逻辑就用 Async 版
  • allOf 拿不到结果怎么补?——allOf 返回的是 CompletableFuture<Void>,要自己 join 每个子 future 收集结果
  • 异常传播——任一步抛异常,后续的 thenApply 会被跳过,直到遇到 exceptionally/handle。不处理就静默丢失(除非最后 join 时抛出)

3. 虚拟线程(JDK 21)是什么?和线程池什么关系?

虚拟线程(Virtual Threads,JEP 444)是 JDK 21 正式发布的轻量级线程,目标是用「同步的写法」拿到「异步的性能」。

维度平台线程(Platform Thread)虚拟线程(Virtual Thread)
映射1:1 映射到 OS 线程M:N,多个虚拟线程跑在少量载体线程上
创建成本高(约 1MB 栈 + 内核资源)极低(堆上的对象,几百字节起步)
数量级几千个就吃力轻松上百万
调度操作系统调度JVM 调度(ForkJoinPool 作为载体)
阻塞代价阻塞 OS 线程,很贵阻塞时卸载(unmount),载体线程去跑别的
适用CPU 密集型IO 密集型(大量等待)
三种创建方式
// ① 直接启动
Thread.startVirtualThread(() -> System.out.println("跑在虚拟线程上"));

// ② Builder 方式(可以命名,便于排查)
Thread.ofVirtual().name("vt-", 0).start(task);

// ③ 每个任务一个虚拟线程的 Executor(推荐用法)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> f = executor.submit(() -> remoteCall());
    System.out.println(f.get());
}

🎯 面试要点

  • 虚拟线程和线程池的关系?——虚拟线程不需要池化!创建成本极低,用完即弃。newVirtualThreadPerTaskExecutor() 是「每个任务一个新虚拟线程」,不是传统意义的池。继续用 newFixedThreadPool 反而会限制并发度,失去意义
  • 虚拟线程快在哪?——不是跑得快,而是等待时不占线程。传统模型下 1000 个并发请求要 1000 个 OS 线程(大多在阻塞等 IO);虚拟线程用少量载体线程就能撑住,吞吐量大幅提升
  • 什么场景不适合?——① CPU 密集型(没有等待,卸载无意义,反而多了调度开销);② synchronized 块里阻塞——会钉住(pin)载体线程,导致无法卸载(应改用 ReentrantLock);③ 大量使用 ThreadLocal 的场景(每个虚拟线程一份,内存会爆)
  • ThreadLocal 在虚拟线程下的问题?——虚拟线程数量可达百万,每份 ThreadLocal 副本都会占内存。JDK 提供了 ScopedValue(预览)作为替代
  • 怎么做压测对比?——同一个 IO 密集接口,分别用固定线程池和虚拟线程压测,观察吞吐量和 P99 延迟。面试时能说出「我用什么方式验证过」很加分

4. AQS 的 Condition:await / signal 是怎么实现的?

前面讲过 AQS 的三要素(state、CLH 同步队列、模板方法),但 Condition 才是区分「背过」和「读过源码」的分水岭。

Condition 的典型用法:生产消费(精准唤醒)
ReentrantLock lock = new ReentrantLock();
Condition notFull  = lock.newCondition();   // 队列不满的条件
Condition notEmpty = lock.newCondition();   // 队列不空的条件

// 生产者
lock.lock();
try {
    while (queue.size() == MAX) {
        notFull.await();            // 满了:释放锁并等待
    }
    queue.add(item);
    notEmpty.signal();              // 只唤醒「等非空」的消费者
} finally {
    lock.unlock();
}

为什么用两个 Condition 而不是 synchronized + notifyAll?

await() 的完整流程(记住这 4 步):

  1. 把当前线程包装成节点,加入该 Condition 的条件队列(这是一个单向链表,与 AQS 的同步队列不是同一个)
  2. 完全释放锁(fullyRelease:因为可重入,要把 state 一次性减到 0)
  3. 在条件队列里阻塞等待(LockSupport.park),被 signal 或中断后唤醒
  4. 被唤醒后转移到 AQS 同步队列(transferForSignal),重新竞争锁(acquireQueued)——注意:唤醒 ≠ 拿到锁
等待队列与同步队列的关系
// Condition 内部维护「条件队列」(单向链表)
ConditionObject ──> firstWaiter ──> Node1 ──> Node2 ──> null

// await():从同步队列「出来」,进入条件队列
// signal():把条件队列的头节点「转移」到 AQS 同步队列尾部,等它重新抢锁

// 所以一次 await/signal 涉及两次队列迁移,这也是它比 park/unpark 复杂的地方

🎯 面试要点

  • await() 为什么必须先持有锁?——因为要保证「判断条件」和「进入等待」是原子的,否则可能在判断完、还没等待时,条件就被别的线程改变了(错过信号)。不持锁调用会抛 IllegalMonitorStateException
  • 为什么唤醒后要重新抢锁?——signal() 只是把节点挪到同步队列,线程要真正继续执行必须重新 acquire 拿到锁。所以 await() 返回不代表条件一定成立了
  • 为什么必须用 while 而不是 if 包住 await()?——① 被唤醒后条件可能又变了;② 虚假唤醒(spurious wakeup);③ 多个线程被唤醒后要排队抢锁,后来者可能条件又不满足。标准写法一定是 while
  • await() 被中断会怎样?——抛 InterruptedException,并且会把节点从条件队列转移到同步队列(transferAfterCancelledWait),保证不会「丢失」节点
  • 和 Object.wait/notify 的对比?——wait/notify 绑在 monitor 上且只有一个等待队列;Condition 支持多队列、可中断、可超时、更灵活。但两者语义一致,都要在锁内调用、都要 while 包裹

5. 这一页的面试速记

🎯 延伸阅读(站内)

⚠️ 本页由人工整理,仍建议以官方文档和实际源码为准。