高频八股 · Java 并发编程(10):异步、ThreadLocal 与虚拟线程(收官)

高频八股 · Java 并发编程(10):异步、ThreadLocal 与虚拟线程(收官)

写在前面

十期了,这一期收尾。

最后我会用一张表,把 10 期串成一条线。

一、CompletableFuture:把异步任务"编排"起来

先说 Future 的三个痛点:

  1. get() 是阻塞的,拿结果就得干等;
  2. 不能组合,两个异步任务想串起来,只能写回调地狱;
  3. 没有异常处理 ,出了错只能靠 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 / 虚拟线程 编排、隔离、轻量化

三条主线,贯穿始终:

  1. 问题线:JMM 解释"为什么会出错"(可见性 / 原子性 / 有序性);
  2. 武器线:volatile → CAS → synchronized → AQS,从轻到重,逐个补洞;
  3. 组织线:工具类 / 容器 / 线程池,把单点武器变成工程能力;最后用异步、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 期。感谢一路看到这里~)

相关推荐
菩提小狗15 分钟前
每日极客日报 · 2026年11月13日
ai·开源·极客日报·it热点·技术资讯
2601_9679791226 分钟前
pip在Python中的9个必须知道的用法
python·pip·版本控制·包管理工具·requirements.txt
曲径海盗44 分钟前
【Python 学习第 58 天】列表方法
python·学习笔记·编程入门
乌暮1 小时前
Spring AOP 与 Spring 事务:用大白话彻底理解原理与实践
java·数据库·spring boot·后端·sql·学习·spring
DeepVisionary1 小时前
Runway开源机器人大模型Praxis-1,推演延迟压到62毫秒
python·自动化
YDS8291 小时前
Small Spring IOC篇:容器事件及其监听器
java·spring
金銀銅鐵1 小时前
带您看懂字节码指令(三):一行加法里有什么
java·jvm
文丑颜不良啊1 小时前
LeetCode2921~2940题目解析
java·leetcode
效率工作实验室1 小时前
企业级AI Agent平台前十名解析:按部署需求挑对更省心
ai·企业级ai·agent平台·agent平台怎么选·agent私有化部署
北漂人Java2 小时前
基于 Spring AI Alibaba WorkFlow 构建 Java 生产级 AI Agent
java·人工智能·spring