Java虚拟线程落地实录:用Loom重构订单服务的高并发困境与ScopedValue实践

2026-07-08 0 636

几个月前的一次大促压测,我们有个看起来平平无奇的订单服务差点把整条链路拖垮。这个服务负责在用户下单时并行调用库存、风控、积分三个接口,然后把结果拼装返回。原先用的是一套标准的线程池方案,核心线程数设在20,最大线程数80,依靠Future和CountDownLatch来汇总。流量正常的时候相安无事,压力一上来,线程池的队列迅速堆满,大量请求被拒绝,同时已经进入池子的线程又在等待三个下游的响应,整个服务就像塞车的高速公路入口,入口堵了,里面的人也动不了。运维捞出来的线程dump里满屏都是WAITING状态的线程,盯着那密密麻麻的堆栈,当时我就意识到,这已经不是单纯调高线程数能解决的问题了。

正好那时候项目组的JDK刚刚升到21,之前浏览过虚拟线程的提案但没认真想过要搬到生产上。出了这次事故以后,重新翻出JEP 444,花了两天时间把整个调度逻辑从传统线程池换成了虚拟线程,顺便把并行编排改成结构化并发,把ThreadLocal传参换成了ScopedValue。改完之后,同样的压测流量进来,服务吞吐量涨了大约三倍,线程数从峰值两百多降到了几十个平台线程。这篇文章就复盘一下整个迁移过程,把关键的设计决策和踩过的坑整理出来。

一、重新理解虚拟线程:它不是更快的线程,而是更便宜的线程

在真正动手之前,比较容易产生的一个误解是把虚拟线程当成某种“更快的线程”。实际上单个虚拟线程的计算速度并不会比平台线程快,它解决的是“大量并发任务在等待I/O时占用操作系统线程”的问题。以前为了不耗尽系统线程,我们不得不引入线程池来限制并发数,这个限制本身就成了高并发场景下的瓶颈。

虚拟线程由JVM调度,底层复用一个比较小的平台线程池(即载体线程)。当一个虚拟线程遇到阻塞操作(比如数据库查询、HTTP调用)时,它会自动从载体线程上卸下来,把载体线程让给其他虚拟线程使用,自己挂起等待。等到阻塞结束,JVM再把它挂到可用的载体线程上继续执行。整个过程对开发者透明,但带来的直接结果是:我们可以为每一个请求都创建一个独立的虚拟线程,而不用再担心线程数爆炸。

这个特性天然适合我们订单服务的场景。那个服务几乎就是纯I/O密集型——接收请求、发起几个下游调用、等结果、返回。每个任务的CPU消耗很小,大部分时间都花在等待上。对虚拟线程来说这简直是最理想的目标。

二、第一步:把线程池换成虚拟线程执行器

旧代码的核心调度大致长这样:

private final ExecutorService executor = new ThreadPoolExecutor(
    20, 80, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue(200),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

public OrderResult processOrder(OrderRequest request) throws Exception {
    Future inventoryFuture = executor.submit(() -> checkInventory(request));
    Future riskFuture = executor.submit(() -> assessRisk(request));
    Future pointsFuture = executor.submit(() -> calculatePoints(request));

    InventoryResult inventory = inventoryFuture.get(3, TimeUnit.SECONDS);
    RiskResult risk = riskFuture.get(3, TimeUnit.SECONDS);
    PointsResult points = pointsFuture.get(3, TimeUnit.SECONDS);

    return assembleResult(inventory, risk, points);
}

迁移到虚拟线程最简单的一步就是把线程池替换掉。Java 21 提供了 `Executors.newVirtualThreadPerTaskExecutor()`,它每次提交任务都会创建一个新的虚拟线程,没有池化概念,也没有队列,提交了就立刻开始执行(在载体线程有空闲的情况下)。

private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

// processOrder 方法的其余部分完全不变
public OrderResult processOrder(OrderRequest request) throws Exception {
    Future inventoryFuture = executor.submit(() -> checkInventory(request));
    Future riskFuture = executor.submit(() -> assessRisk(request));
    Future pointsFuture = executor.submit(() -> calculatePoints(request));

    InventoryResult inventory = inventoryFuture.get(3, TimeUnit.SECONDS);
    RiskResult risk = riskFuture.get(3, TimeUnit.SECONDS);
    PointsResult points = pointsFuture.get(3, TimeUnit.SECONDS);

    return assembleResult(inventory, risk, points);
}

就这样改了一行代码之后,我们先把服务灰度到一台机器上跑了一下。最直观的变化是,线程池的拒绝策略不会再触发了——因为不存在队列和上限,每个请求都立刻被虚拟线程接住。线程dump里也看不到满屏的线程池工作线程,取而代之的是为数不多的平台线程和大量轻量级的虚拟线程(虚拟线程默认不出现在传统线程dump的输出里,需要用 `jcmd` 的特定命令才能观察到)。

三、第二步:引入结构化并发,让子任务的生命周期不再失控

只替换执行器虽然解决了吞吐瓶颈,但代码里依然有一个隐患:`Future.get` 的三个调用虽然加了超时,但如果某个子任务抛出异常,另外两个子任务并不会被主动取消,它们会继续占用资源直到自然执行完毕或者在超时后被动丢弃。这在虚拟线程场景下问题更突出——因为虚拟线程极其廉价,请求一多,那些被遗忘的子任务可能会产生大量无用的调用,拖累下游服务。

Java 21 提供了结构化并发的预览特性(JEP 453),它的核心思想是:把一组并行的子任务绑定在一个父作用域里,当父作用域退出时,确保所有子任务都被取消和回收。刚好和我们的需求完美吻合。

启用结构化并发需要添加编译参数 `–enable-preview`,运行时也需要带上这个标志。核心类是 `StructuredTaskScope`。把前面的并行调用改成这样:

public OrderResult processOrder(OrderRequest request) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // 分别派发三个子任务
        Supplier inventoryTask = () -> checkInventory(request);
        Supplier riskTask = () -> assessRisk(request);
        Supplier pointsTask = () -> calculatePoints(request);

        StructuredTaskScope.Subtask inventorySub = scope.fork(inventoryTask);
        StructuredTaskScope.Subtask riskSub = scope.fork(riskTask);
        StructuredTaskScope.Subtask pointsSub = scope.fork(pointsTask);

        // 等待所有子任务完成或任一失败
        scope.join(); // 如果任何子任务抛出异常,这里会抛出
        scope.throwIfFailed(); // 显式抛出收集到的异常

        // 获取结果
        InventoryResult inventory = inventorySub.get();
        RiskResult risk = riskSub.get();
        PointsResult points = pointsSub.get();

        return assembleResult(inventory, risk, points);
    }
}

`ShutdownOnFailure` 的行为是:如果任何一个子任务失败,作用域在 `join` 之后会收集该异常,并在 `throwIfFailed` 时抛出,同时自动取消尚未完成的其他子任务。这个“自动取消”由JVM保证,不需要手动遍历Future调用cancel。对于我们这种有多个下游依赖且需要快速失败的场景,结构化并发不仅让代码意图更清晰,也避免了由于某个下游超时而拖累其他下游的问题。

还有一个很实用的变体是 `StructuredTaskScope.ShutdownOnSuccess`,适用于多个备选下游的竞速场景,比如同时查三个缓存节点,谁先返回就用谁的结果,其他自动取消。不过我们订单服务暂时没用到,就先不展开了。

四、第三步:解决上下文传递——告别ThreadLocal,迎接ScopedValue

迁移到虚拟线程之后,第一个被我忽略的坑很快就浮出水面。订单服务里原本用 `ThreadLocal` 传递请求级别的上下文,比如当前请求的traceId、操作人信息等。这在平台线程池里勉强能工作,因为线程池的线程是复用的,需要手动在任务开始时设置、结束时清理。切换到虚拟线程之后,由于一个请求对应一个虚拟线程,理论上环境更干净了,但实际上问题更隐蔽。

有两类比较典型的问题:第一个是在虚拟线程执行过程中,如果内部调用的某个方法又启动了一个新的虚拟线程(或者复用载体线程执行了其他任务),`ThreadLocal` 的值在新的虚拟线程里是拿不到的,导致上下文丢失。第二个是如果 `ThreadLocal` 里存了一些大对象,在虚拟线程生命周期结束后(虚拟线程数量远比平台线程多),如果没有显式清理,这些对象可能被虚拟线程对象本身持有更长时间,造成内存压力。虽然虚拟线程也是可以被GC回收的,但生命周期不可控。

Java 21 引入的 `ScopedValue` (JEP 446)正是为了在虚拟线程环境下提供一种更安全、更轻量的上下文传递方式。它的用法和 `ThreadLocal` 不太一样,是基于作用域绑定的,而不是线程绑定。在一个代码块内,`ScopedValue` 被绑定到一个值,进入这个块的所有虚拟线程(包括子任务)都能读到这个值,一旦离开块,绑定自动解除。

老代码里使用 ThreadLocal 传递 traceId:

public class ContextHolder {
    private static final ThreadLocal<String> traceIdHolder = new ThreadLocal<>();
    public static void setTraceId(String traceId) { traceIdHolder.set(traceId); }
    public static String getTraceId() { return traceIdHolder.get(); }
    public static void clear() { traceIdHolder.remove(); }
}

迁移到 ScopedValue 之后:

public class ContextHolder {
    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
}
// 在请求入口处绑定
ScopedValue.where(ContextHolder.TRACE_ID, traceId)
    .run(() -> {
        // 在这个代码块及其派生的虚拟线程中都可以读到traceId
        processOrder(request);
    });

// 在业务代码中取值
String traceId = ContextHolder.TRACE_ID.get();

这个改动带来的一个额外好处是,绑定关系由 `ScopedValue.where` 的执行上下文严格决定,一旦 `run` 方法返回,绑定就消失了,不存在“忘记清理”的风险。对于虚拟线程这种短暂且大量创建的对象,这种自动管理是极其省心的。

不过有一点要注意,`ScopedValue` 目前也还是预览特性,需要在编译和运行时开启 `–enable-preview`。我们项目中同时启用了结构化并发和ScopedValue,构建脚本里统一加了参数,团队成员在IDE里也要手动勾选一下。

五、压测数据与性能感受

改造完成之后,我们在同一套压测环境里重新跑了之前导致雪崩的那个流量模型:持续五分钟的阶梯式加压,峰值QPS约为原来的2.5倍。结果比较直观:在旧线程池方案下,服务在到达最大线程数和队列容量之后就开始疯狂抛出RejectedExecutionException,P99延迟飙升到十几秒;换成虚拟线程加结构化并发之后,同样的峰值QPS下几乎没有错误,P99延迟稳定在800毫秒以内,而且平台线程的CPU使用率反而下降了——因为不再有大量线程在等待I/O时白白占用CPU时间片。

当然这个提升主要是因为服务本身是I/O密集型。如果是计算密集型的任务,虚拟线程的帮助不会这么明显,因为瓶颈在CPU上,线程的轻量切换反而可能增加上下文切换开销。但对于现在微服务架构里动辄几十个下游调用的常态,绝大多数服务的瓶颈都在I/O上,虚拟线程的适用面其实非常广。

六、不能忽视的几个使用限制

任何技术都有边界,虚拟线程也不例外。下面这几个点在正式使用之前需要心中有数。

1. synchronized 块中的虚拟线程不会释放载体线程。这是目前虚拟线程最大的一个坑。如果一个虚拟线程在执行 `synchronized` 块时遇到阻塞(比如在同步块里调用HTTP请求),它不会从载体线程上卸下来,而是会占着载体线程直到阻塞结束。这会导致载体线程池迅速耗尽,其他虚拟线程全部停滞。遇到这种情况需要用 `ReentrantLock` 替代 `synchronized`,因为 `ReentrantLock` 的锁实现支持虚拟线程的卸载机制。OpenJDK 团队正在尝试解决这个问题,但在当前版本中需要开发者自己注意。

2. 不推荐在虚拟线程里执行长时间的CPU密集型计算。前面提过,虚拟线程适合I/O密集场景。如果某个请求里包含了一个没有阻塞的大循环计算,它会长时间占用载体线程,同样影响其他虚拟线程的执行。这类任务建议还是走传统的平台线程池。

3. ThreadLocal 依然可以使用但需要格外注意清理。如果你无法一下子迁移所有 `ThreadLocal` 到 `ScopedValue`(比如依赖的第三方库内部用了 ThreadLocal),那就要确保在每个请求结束时显式调用 `remove()`,否则在虚拟线程的生命周期中可能积累大量无用对象。比较推荐的做法是在代码入口处使用 try-finally 清理。

七、一步步迁移的路线建议

如果你们团队也打算试试虚拟线程,我个人建议不要一上来就全量铺开。可以先找一个和订单服务类似的、典型的I/O密集型模块作为试点,只替换线程池为 `newVirtualThreadPerTaskExecutor()`,然后观察一段时间。这个改动是最小风险、最大收益的一步。稳定之后,再逐步引入结构化并发来优化子任务的生命周期管理。ScopedValue 可以放在最后,因为它涉及上下文传递方式的全局变更,需要和所有依赖方对齐。

同时,预览特性虽然可用,但线上启用时最好提前和运维沟通清楚,确保JDK版本和启动参数都在管控范围内。如果生产环境暂时不能开启 `–enable-preview`,那结构化并发和ScopedValue就先放在测试环境验证,只把虚拟线程本身作为非预览特性上线——因为虚拟线程在Java 21中已经是正式特性,不需要任何额外参数。

八、写在最后

虚拟线程并没有让Java一夜之间变得和Go或者Kotlin协程一样,但它的确在“保持Java编程模型不变”的前提下,解决了一个长期以来被线程池模式掩盖的并发难题。以前我们总是下意识地认为“线程很贵,所以要池化”,然后花很大力气去调优队列和拒绝策略。现在这个前提在一定程度上被打破了,写高并发服务可以回到一种更简单的、一个请求一个线程的思维模式里,那些围绕线程池的复杂设计和故障排查也顺带少了一大截。

如果你们的服务还在被线程池的OOM或者雪崩困扰,不妨拉一个新分支,把执行器换一行代码,跑一次压测看看效果。有些改变不需要翻天覆地,换一个执行器就够了。

Java虚拟线程落地实录:用Loom重构订单服务的高并发困境与ScopedValue实践
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java虚拟线程落地实录:用Loom重构订单服务的高并发困境与ScopedValue实践 https://www.taomawang.com/server/java/2333.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务