🧵 ThreadLocal · CompletableFuture · 虚拟线程 · AQS Condition
并发模块的四个深水区 · ThreadLocalMap 与内存泄漏 · 异步编排 · JDK 21 虚拟线程 · await/signal 全流程
1. ThreadLocal 原理?为什么会内存泄漏?(必考)
一句话:ThreadLocal 让每个线程拥有变量的独立副本,用「空间换安全」,避免共享。
- 每个
Thread对象内部有一个ThreadLocalMap字段(threadLocals) ThreadLocal.set(v)实际是:拿到当前线程的 map,以 ThreadLocal 对象自己为 key,把值存进去get()就是用自己当 key 去当前线程的 map 里查- 所以数据的归属是「线程」,不是「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 的 key | ThreadLocal 对象 | 弱引用 | ThreadLocal 没被外部强引用时,GC 会回收它,key 变成 null |
| Entry 的 value | 你的业务对象 | 强引用 | key 没了但 value 还在,且被 Thread → ThreadLocalMap → Entry 引用链拽着,无法回收 |
泄漏的完整链条
// ① 线程池里的线程是「长活」的(不是用完就死)
// ② ThreadLocal 对象本身(比如局部变量)失去强引用 → key 被 GC 置为 null
// ③ 但 Entry 还在 map 里,value 仍被强引用
// ④ 线程不死 → map 不死 → value 永远回收不了 → 内存泄漏
// 线程池复用还会导致「数据串扰」:下一个任务读到了上一个任务的残留值
- JDK 已经做了补救——
get/set/remove时会顺带清理 key 为 null 的Entry(expungeStaleEntry、cleanSomeSlots),但这只在恰好访问到的时候才触发,不能依赖它 - 正确的做法就一句:用完必须
remove(),而且要用try/finally保证异常时也能清理
标准写法(必须用 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?
synchronized只有一个等待队列,notifyAll会唤醒所有等待线程(包括同类的生产者),造成大量无效竞争(惊群)Condition可以创建多个等待队列,signal()只唤醒指定队列的头节点,精准唤醒、效率更高
await() 的完整流程(记住这 4 步):
- 把当前线程包装成节点,加入该 Condition 的条件队列(这是一个单向链表,与 AQS 的同步队列不是同一个)
- 完全释放锁(
fullyRelease:因为可重入,要把 state 一次性减到 0) - 在条件队列里阻塞等待(
LockSupport.park),被 signal 或中断后唤醒 - 被唤醒后转移到 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. 这一页的面试速记
- ThreadLocal——数据存在 Thread 的 ThreadLocalMap 里,key 是 ThreadLocal(弱引用)、value 强引用;线程池 + 忘记 remove = 内存泄漏 + 数据串扰。必须 try-finally remove
- CompletableFuture——thenCompose 串行依赖、thenCombine 并行合并、allOf 批量收口、exceptionally/handle 兜底。务必传自定义线程池,别用 commonPool
- 虚拟线程——JDK 21,M:N 调度,适合 IO 密集;不需要池化;注意 synchronized 会 pin 住载体线程、ThreadLocal 内存放大
- Condition——await 四步:入条件队列 → 完全释放锁 → park → 转移到同步队列重抢锁。多队列实现精准唤醒;必须 while 包住 await
🎯 延伸阅读(站内)
- 线程基础——线程状态、wait/notify、生产者消费者
- synchronized & volatile——锁升级、JMM、happens-before
- JUC 工具类——AQS 三要素、CAS、原子类
- 线程池——七参数、状态机、调优与排障