高频八股 · Java 并发编程(10):异步、ThreadLocal 与虚拟线程(收官)
写在前面
十期了,这一期收尾。
最后我会用一张表,把 10 期串成一条线。
一、CompletableFuture:把异步任务"编排"起来
先说 Future 的三个痛点:
get()是阻塞的,拿结果就得干等;- 不能组合,两个异步任务想串起来,只能写回调地狱;
- 没有异常处理 ,出了错只能靠
get()抛ExecutionException。
CompletableFuture 就是来解决这三件事的。
1.1 四个最常用的能力
| 能力 | 方法 | 说明 |
|---|---|---|
| 异步执行 | supplyAsync / runAsync |
有返回值 / 无返回值 |
| 串行编排 | thenApply / thenCompose |
前一个的结果喂给后一个 |
| 并行合并 | thenCombine / allOf / anyOf |
多任务合并 |
| 异常处理 | exceptionally / handle |
出错时兜底 |
1.2 一个典型链路
java
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> queryUser(id)) // ① 异步查用户
.thenApply(user -> user.getCity()) // ② 取城市(同步转换)
.thenCompose(city -> queryWeatherAsync(city))// ③ 再异步查天气(扁平化)
.exceptionally(e -> { // ④ 任何一步出错都兜底
log.error("查询失败", e);
return "未知";
});
String result = future.join(); // 只在最后拿一次结果
thenApply 和 thenCompose 的区别(高频考点):
thenApply:把结果转换 成另一个值,返回CompletableFuture<新值>;thenCompose:返回的又是一个CompletableFuture,会把它"摊平",避免套娃。
1.3 两个必须注意的坑
① 默认线程池是 ForkJoinPool.commonPool()
supplyAsync 不传线程池时,用的是全局公共池 (就是第 9 期讲的那个)。它不适合 IO 密集任务,一旦阻塞会拖垮整个池。
生产建议:自己传一个业务线程池。
java
CompletableFuture.supplyAsync(() -> doSomething(), myExecutor);
② allOf 不会"等异常"
allOf 只保证所有任务都结束,任何一个抛异常,allOf 本身不会立刻失败 。要拿结果得自己 join 每个子任务,逐个处理异常。
二、ThreadLocal:让数据跟着线程走
2.1 它是干什么的
ThreadLocal 给每个线程一份独立的变量副本,互不干扰。
典型场景:把用户信息、traceId 放到当前线程,一路透传,不用在每个方法里传参。
java
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
CURRENT_USER.set(user); // 存
User u = CURRENT_USER.get(); // 取
CURRENT_USER.remove(); // 一定要清!
2.2 原理:结构决定了一切
关键结构是:
Thread
└── threadLocals : ThreadLocalMap
└── Entry[] { key: ThreadLocal(弱引用), value: 值(强引用) }
注意这两个引用的强弱:
- key 是弱引用 (指向
ThreadLocal对象); - value 是强引用。
2.3 为什么会内存泄漏?
想象这条链:
Thread→ThreadLocalMap→Entry→ 强引用 value
当 ThreadLocal 对象外部不再被引用 时,GC 会把 key 回收掉,于是 Entry 变成 (null, value)。
但 value 还被 Entry 强引用着,而 Entry 又被 Thread 引用着。
如果这个线程一直活着 (比如线程池里的核心线程),value 就永远回收不了------这就是内存泄漏。
ThreadLocalMap在get/set/remove时会顺带清理key == null的 Entry(expungeStaleEntry),但不保证及时 。所以:手动
remove()才是唯一可靠的做法。
2.4 为什么在线程池里更危险?
因为线程是复用的。
上一个任务 set 了值没清,下一个任务进来 get(),拿到的还是上一个人的数据 ------这不只是内存泄漏,还是数据串号,可能是安全事故。
这也是第 9 期"线程池的坑"里提到的那一条,本质上是同一件事。
2.5 父子线程怎么传?两个方案
InheritableThreadLocal:在创建子线程时 把父线程的值复制过去。
⚠️ 但线程池里线程是复用的,不会再"创建",所以它失效。TransmittableThreadLocal(TTL) :阿里开源,通过装饰任务 (TtlRunnable/TtlCallable)在任务提交和执行时做传递,专治线程池场景。
三、虚拟线程:让线程变"轻"
JDK 21 正式引入(JEP 444),这是 Java 并发模型这些年最大的一次变化。
3.1 它解决什么问题?
传统 Java 线程是平台线程,一对一映射到操作系统线程:
- 创建成本高(默认栈约 1MB);
- 数量受限,几千个就到顶;
- 一阻塞就占着一个 OS 线程,白白浪费。
而真实业务多是 IO 密集 ------查数据库、调接口,绝大部分时间在等。线程都在"等",却把资源占死了。
虚拟线程就是来解决这个的。
3.2 核心原理:M:N 调度
| 对比 | 平台线程 | 虚拟线程 |
|---|---|---|
| 映射关系 | 1:1 对应 OS 线程 | M:N,多个虚拟线程复用少量载体线程 |
| 栈存储 | 固定大小(约 1MB) | 存在堆上,按需增长(初始几百字节) |
| 创建成本 | 高 | 极低(可创建百万级) |
| 阻塞行为 | 占住 OS 线程 | 自动挂起,让出载体线程 |
| 调度者 | 操作系统 | JVM (默认 ForkJoinPool) |
关键点 :虚拟线程遇到阻塞(如 Thread.sleep、IO 等待)时,JVM 会把它的栈帧挪到堆上,让出底层的载体线程去干别的活。等 IO 回来了,再恢复执行。
所以"阻塞"不再等于"浪费线程"。
3.3 两个必须知道的坑
① 别池化虚拟线程
平台线程要池化是因为创建贵;虚拟线程创建极便宜,每个任务 new 一个就行。池化反而增加复杂度、还可能引入 ThreadLocal 残留问题。
java
// 推荐:直接起
Thread.startVirtualThread(() -> doSomething());
// 或者用结构化并发 / Executors.newVirtualThreadPerTaskExecutor()
② synchronized 会"钉住"载体线程(JDK 21)
在 JDK 21 里,虚拟线程如果在 synchronized 块内阻塞 ,会把载体线程一起钉住(pinning),退化成平台线程的效果。
规避:把
synchronized换成ReentrantLock(第 5 期讲的那个)。补充:这个限制在后续版本(JDK 24 / JEP 491)已经改善,但如果你用的是 21,还是要注意。
③ 它救不了 CPU 密集任务
虚拟线程优化的是"等待 ",不是"计算"。CPU 跑满的场景,它帮不上忙。
四、10 期总复盘:把知识串成一条线
| 期 | 主题 | 一句话记住 |
|---|---|---|
| 1 | 并发基础 | 进程管资源、线程管调度、协程管轻 |
| 2 | JMM 与 volatile | 管可见性和有序性,管不了原子性 |
| 3 | CAS 与原子类 | 硬件指令保证原子,自旋重试,小心 ABA |
| 4 | synchronized | Mark Word + Monitor,锁只能升不能降 |
| 5 | AQS 与 Lock | volatile state + CAS + CLH 队列 |
| 6 | 并发工具类 | CountDownLatch 一次性、CyclicBarrier 可循环、Semaphore 限流 |
| 7 | 阻塞队列与容器 | CHM 细粒度锁、COW 写时复制、阻塞队列靠双 Condition |
| 8 | 线程池(上) | 核心 → 队列 → 扩容 → 拒绝 |
| 9 | 线程池(下) | 队列有界、拒绝留痕、线程有名、用完关闭 |
| 10 | 异步 / ThreadLocal / 虚拟线程 | 编排、隔离、轻量化 |
三条主线,贯穿始终:
- 问题线:JMM 解释"为什么会出错"(可见性 / 原子性 / 有序性);
- 武器线:volatile → CAS → synchronized → AQS,从轻到重,逐个补洞;
- 组织线:工具类 / 容器 / 线程池,把单点武器变成工程能力;最后用异步、ThreadLocal、虚拟线程解决"怎么用得更好"。
如果你只有 5 分钟复习:背第 3 期(CAS)、第 5 期(AQS)、第 8 期(线程池流程)这三块,面试命中率最高。
五、高频面试题速答
Q1:CompletableFuture 相比 Future 好在哪?
支持链式编排(thenApply / thenCompose)、多任务组合(allOf / anyOf)、以及非阻塞的异常处理(exceptionally / handle)。
Q2:thenApply 和 thenCompose 的区别?
thenApply 是值的转换;thenCompose 用于返回值仍是 CompletableFuture 的场景,会把它摊平。
Q3:ThreadLocal 为什么会内存泄漏?
ThreadLocalMap 的 Entry 中,key 是弱引用、value 是强引用。key 被回收后 value 仍被 Thread 强引用,线程不销毁则 value 无法回收。解决办法:用完 remove()。
Q4:为什么 ThreadLocal 在线程池里更危险?
线程被复用,未清理的旧值会被下一个任务读到,造成数据串号,不只是泄漏。
Q5:InheritableThreadLocal 有什么问题?
只在创建子线程 时复制值;线程池的线程是复用的,不会重新创建,所以失效。需要线程池传递时用 TTL。
Q6:虚拟线程的原理和适用场景?
M:N 调度,多个虚拟线程复用少量载体线程,栈存在堆上、阻塞时自动让出载体线程。适合 IO 密集、高并发场景 ;不适合 CPU 密集,也不要池化。
六、写在最后
并发这块知识,最难的从来不是"背下来",而是把它们连起来。
你现在回头看会发现:
AQS的 state 是volatile+CAS;ConcurrentHashMap用的是CAS+synchronized+volatile;- 线程池的
workQueue是阻塞队列,阻塞队列靠ReentrantLock+Condition; CompletableFuture默认用的是ForkJoinPool;- 虚拟线程的调度器,也是
ForkJoinPool。
它们不是十个孤立的知识点,是同一套东西的不同切面。
如果你能顺着这条线讲下来,面试官问到哪里,你都能接得住。
这个系列就到这里。祝大家都能拿下心仪的 offer。
(全系列完,共 10 期。感谢一路看到这里~)