高频八股 · 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 期。感谢一路看到这里~)

相关推荐
想做小南娘,发现自己是女生喵1 小时前
LAN Device Monitor 第二周学习文档
java·服务器·数据库
念何架构之路1 小时前
Go reflect包值操作 Value 的读写、可设置性与 interface 布局
开发语言·后端·golang
@嵌入式扫地僧1 小时前
实战教程:NVIDIA Jetson Nano 8GB 搭建本地 RAG 大语言模型知识库完整指南
人工智能·ai·jetson nano·1024程序员节·rag
JustNow_Man1 小时前
【Harness】本地文本向量化搜索解决方案--qmd
git·python·算法
小林ixn1 小时前
用 FastAPI + SQLAlchemy 从零手搓一个博客系统:一套能落地、能讲清楚的 RESTful 后端
后端·python·fastapi
小年糕是糕手1 小时前
【AI】用 Python 做一个 AI 截图检索助手:把收藏夹里的截图找回来
linux·人工智能·python·chatgpt·agent·codex·deepseek
云中飞鸿1 小时前
.Net和C#有什么关系?(从C++视角观察)
开发语言·c#·.net
another heaven1 小时前
【C/C++ C++ 新特性中的那些内存优化的特性】
c语言·开发语言·c++
ifenxi爱分析1 小时前
宇树地位不稳、MiniMax掉队、豆包手机遭拒|AI周评
ai