香蕉大香蕉(06敬畏市场)头像
高频八股 · Java 并发编程(10):异步、ThreadLocal 与虚拟线程(收官)封面图

高频八股 · 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 一个典型链路

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 密集任务,一旦阻塞会拖垮整个池。

生产建议:自己传一个业务线程池。

CompletableFuture.supplyAsync(() -> doSomething(), myExecutor);

② allOf 不会"等异常"

allOf 只保证所有任务都结束,任何一个抛异常,allOf 本身不会立刻失败。要拿结果得自己 join 每个子任务,逐个处理异常。

二、ThreadLocal:让数据跟着线程走

2.1 它是干什么的

ThreadLocal 给每个线程一份独立的变量副本,互不干扰。

典型场景:把用户信息、traceId 放到当前线程,一路透传,不用在每个方法里传参。

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 残留问题。

// 推荐:直接起
Thread.startVirtualThread(() -> doSomething());

// 或者用结构化并发 / Executors.newVirtualThreadPerTaskExecutor()

② synchronized 会"钉住"载体线程(JDK 21)

在 JDK 21 里,虚拟线程如果在 synchronized 块内阻塞,会把载体线程一起钉住(pinning),退化成平台线程的效果。

规避:把 synchronized 换成 ReentrantLock(第 5 期讲的那个)。
补充:这个限制在后续版本(JDK 24 / JEP 491)已经改善,但如果你用的是 21,还是要注意。

③ 它救不了 CPU 密集任务

虚拟线程优化的是"等待",不是"计算"。CPU 跑满的场景,它帮不上忙。

四、10 期总复盘:把知识串成一条线

期主题一句话记住
1并发基础进程管资源、线程管调度、协程管轻
2JMM 与 volatile管可见性和有序性,管不了原子性
3CAS 与原子类硬件指令保证原子,自旋重试,小心 ABA
4synchronizedMark Word + Monitor,锁只能升不能降
5AQS 与 Lockvolatile 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 期。感谢一路看到这里~)

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2401_88601046/article/details/169068536

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--