Java 21 虚拟线程实战:把商品聚合接口从固定线程池迁到虚拟线程的完整记录

2026-09-12 0 968

去年把服务升级到 Java 21 之后,我一直想找个合适的场景把虚拟线程真正用起来。看文档觉得挺简单,换个 Executor 就完事,但真到代码里改的时候,发现要注意的地方比想象中多得多。这篇文章把整个过程记下来,包括最后发现的那几个坑。

先说场景,不然后面没法聊

我们有个商品详情聚合接口。前端传一个商品 ID 进来,后端要同时去四个地方拿数据:

  • 商品库,走 JDBC,单条查询十几毫秒
  • 库存服务,HTTP 调用,平均 40 到 80 毫秒
  • 促销服务,HTTP 调用,这个最慢,偶尔飙到 300 毫秒以上
  • 评价服务,HTTP 调用,60 毫秒左右

四个调用互相没有依赖,串行做加起来就是四五百毫秒。并发做的话,总耗时取决于最慢的那个,也就是促销服务。

原来的代码用的是固定大小 32 的线程池,每个请求里再嵌套提交三个 Callable,用 Future 收集结果。这套写法在线程池被占满的时候会出问题,而且代码本身也啰嗦。

原来的代码长什么样

private static final ExecutorService POOL = Executors.newFixedThreadPool(32);

public ProductDetail load(long productId) throws Exception {
    Future<BaseInfo> baseF = POOL.submit(() -> repo.find(productId));
    Future<Integer> stockF = POOL.submit(() -> inventory.query(productId));
    Future<Promo> promoF = POOL.submit(() -> promo.active(productId));
    Future<String> reviewF = POOL.submit(() -> reviews.summary(productId));

    try {
        return new ProductDetail(
            baseF.get(800, TimeUnit.MILLISECONDS),
            stockF.get(800, TimeUnit.MILLISECONDS),
            promoF.get(800, TimeUnit.MILLISECONDS),
            reviewF.get(800, TimeUnit.MILLISECONDS)
        );
    } catch (TimeoutException e) {
        baseF.cancel(true);
        stockF.cancel(true);
        promoF.cancel(true);
        reviewF.cancel(true);
        throw e;
    }
}

这段代码有几个说不上好但一直忍着的点。

第一,超时是逐个 get 的,如果第一个就超时了,后面三个还在跑,虽然 cancel 了但到底有没有真的取消掉,取决于下游客户端有没有响应中断,通常是没有的。

第二,四个任务抢 32 个线程。单个请求占 4 个,那这个池子同时只能服务 8 个请求。第 9 个请求进来就得排队,排队的这段时间里它一个线程都没占上,但因为池子满了,连排队的资格都没有——它会直接卡在 submit 上,而且这个卡是没有超时的,可能在队列里躺几秒钟。

第三,32 这个数字是拍脑袋定的。定小了扛不住并发,定大了线程上下文切换开销上来,而且每个线程默认 1MB 栈,32 个还好,你要是敢调到 500,光栈就吃掉 500MB。

换成虚拟线程:先做最简单的替换

Java 21 里虚拟线程已经转正,Executors 提供了一个新方法:

private static final ExecutorService POOL = 
    Executors.newVirtualThreadPerTaskExecutor();

就这一行,其他代码完全不用动。理论上,每个 submit 会立刻创建一个新的虚拟线程,不再有排队等待的情况。

跑了一遍压测,QPS 从 800 左右涨到了 2400,P99 从 1.2 秒降到了 380 毫秒。看起来效果不错,但别急着上线,因为问题才刚开始冒头。

第一个坑:数据库连接池成了新瓶颈

QPS 涨上去之后,日志里开始出现大量 Connection is not available, request timed out after 30000ms

原因很直白:HikariCP 的最大连接数配的是 20。以前线程池只有 32 个线程,最多也就 32 个并发查询在抢 20 个连接,排队还能接受。现在虚拟线程可以无限创建,几千个并发请求同时涌进来,每一个都要从连接池里借一个连接,剩下的全在 getConnection 上死等。

这里有个反直觉的地方:用了虚拟线程之后,你反而更容易打垮自己的数据库。因为请求不再被线程池挡住,压力会实打实地传到下游。数据库连接池的容量,从”反正也到不了”的上限,变成了整个系统真正的吞吐天花板。

解决办法有两个方向。

一是把 HikariCP 的 connectionTimeout 调短,比如 800 毫秒,让它快速失败而不是排 30 秒的队。同时把这个超时纳入整体超时预算里。

二是在数据库这层加信号量,主动限流。我最后两个都做了:

private final Semaphore dbPermits = new Semaphore(20);

private BaseInfo findWithPermit(long id) throws Exception {
    if (!dbPermits.tryAcquire(600, TimeUnit.MILLISECONDS)) {
        throw new DbOverloadException("数据库繁忙,请稍后重试");
    }
    try {
        return repo.find(id);
    } finally {
        dbPermits.release();
    }
}

用一个定时任务在低峰期把 HikariCP 的实际空闲连接数打印出来,配着调整,最后定在 30 个连接加 30 个许可,比原来干巴巴配一个 20 要靠谱得多。

第二个坑:ThreadLocal 变成了内存炸弹

服务里有个 MDC 工具类用来打 traceId,用的是 ThreadLocal。升级之后有一天发告警说老年代内存持续上涨,dump 下来一看,堆里躺着几十万个 MDC$MDCContext 对象。

原因:ThreadLocal 是绑在线程对象上的。平台线程会被复用,你把变量清一下它就没了;虚拟线程不复用,一个任务一个线程,用完就丢。听起来应该更干净才对——问题出在”用完就丢”之前的这段时间。

如果在一个虚拟线程里 set 了 MDC,然后这个虚拟线程因为某个阻塞操作挂着(比如等下游响应),那么这个虚拟线程对象连同它上面挂的所有 ThreadLocal 引用都还活着。几千个这样挂着的虚拟线程,就是几千份 MDC。每个 MDC 里还有几个字符串,累积起来就不少了。

更麻烦的是,虚拟线程的栈本身是存在堆里的(通过 StackChunk 对象),所以虚拟线程的对象占用比你在脑子里估算的要大。一个空转的虚拟线程大概几百字节,但一旦它进了阻塞状态、栈被物化了,就是几 KB 起步。

这里的处理方式是两条腿走路。

短期:把 MDC 的写入点收敛。不要在调用链上层就 set,而是在真正需要打日志的那一层临时 set,打完立刻 remove,用 try-finally 包住。

长期:迁移到 ScopedValue。这是 Java 21 里跟着虚拟线程一起引入的(当时还是 preview),Java 25 已经转正。它的语义是”值只在某个代码块范围内可见,块执行完自动失效”,而且它是靠栈结构而不是线程对象来维持的,天然适合虚拟线程。

public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

// 入口处
ScopedValue.where(TRACE_ID, generateTraceId()).run(() -> {
    handleRequest(request);
});

// 日志里
logger.info("trace={} 商品加载完成", TRACE_ID.get());

要注意 ScopedValue 是单向的,绑定之后在作用域里不能改,也不能把值传递到作用域外启动的线程里。如果你的日志框架深度依赖 MDC,那迁移成本不低,可能得先写一个适配层,把 ScopedValue 的值在打日志前塞进 MDC,打完清掉。

第三个坑:synchronized 会把虚拟线程钉住

这个坑在 Java 21 上存在,Java 24 的 JEP 491 把它修掉了。但如果你跟我一样跑在 21 上,就得当心。

虚拟线程跑在一组叫”载体线程”的平台线程上。当虚拟线程里遇到阻塞操作时,JVM 会把它从载体线程上摘下来,把载体线程腾出去跑别的虚拟线程——这是它高效的核心机制。

但有个例外:如果阻塞发生在 synchronized 块里面,JVM 没法安全地把虚拟线程摘下来,因为监视器锁是绑在平台线程上的。这种情况叫”pinning”。被钉住的虚拟线程会一直占着载体线程不放,如果钉住的多了,整个调度器就没有可用的载体线程了,其他虚拟线程全部停摆。

我们出问题的地方是一个老的本地缓存工具类:

public class LocalCache<K, V> {
    private final Map<K, V> map = new HashMap<>();

    public synchronized V get(K key, Supplier<V> loader) {
        V v = map.get(key);
        if (v == null) {
            v = loader.get();   // 这里可能是一次远程调用,几毫秒到几百毫秒
            map.put(key, v);
        }
        return v;
    }
}

这个 synchronized 是方法级的,锁住整个缓存对象。里面调 loader.get() 的时候持着锁,一旦走到这一步,当前虚拟线程就被钉在载体线程上。如果同时有几百个 key 没命中缓存,几百个虚拟线程全被钉住,载体线程池(默认只有 CPU 核数个)瞬间耗尽。

检测方法很简单,加个启动参数:

-Djdk.tracePinnedThreads=full

它会在虚拟线程被钉住时打印完整堆栈,一眼就能找到罪魁祸首。生产环境不建议常开,日志量会很大,用在压测环境里排查就够。

更精细的做法是用 JFR,打开虚拟线程相关的事件:

-XX:StartFlightRecording:filename=vt.jfr,settings=profile

然后用 JDK Mission Control 打开,看 jdk.VirtualThreadPinned 事件的采样图和堆栈。

修法是换成 ReentrantLock

private final ReentrantLock lock = new ReentrantLock();

public V get(K key, Supplier<V> loader) {
    V v = map.get(key);
    if (v != null) return v;

    lock.lock();
    try {
        v = map.get(key);
        if (v == null) {
            v = loader.get();
            map.put(key, v);
        }
        return v;
    } finally {
        lock.unlock();
    }
}

这里顺手改成双检锁了,因为 synchronized 版本在并发未命中的情况下会让所有线程排队等第一个加载完,其实没必要。这种写法下,拿到锁之后的第一件事是重新查一遍缓存,大概率已经被前面的线程填好了。

需要注意 map 用的是 HashMap,双检锁的写法在线程安全上是有前提的——map.get 在并发读的情况下不会出错,但要保证 map.put 对其他线程可见。稳妥起见换成 ConcurrentHashMap,或者干脆整个缓存用 ConcurrentHashMap.computeIfAbsent 重写一遍,连锁都省了。上面这个写法是为了保留原有结构,实际项目里我重写了。

用结构化并发重写业务流程

前面这些坑处理完之后,聚合部分本身还停留在原来的 Future 写法上。趁着这次改造,把它一起换掉了。

结构化并发在 Java 21 里是 preview 特性,需要加 --enable-preview 编译和运行。它的核心想法是:并发任务应该像代码块一样有明确的生命周期边界,父任务不结束,子任务不能活着;任何一个子任务失败,其他子任务自动取消。

public ProductDetail load(long productId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

        Subtask<BaseInfo> base = scope.fork(() -> findWithPermit(productId));
        Subtask<Integer> stock = scope.fork(() -> inventory.query(productId));
        Subtask<Promo> promo = scope.fork(() -> promo.active(productId));
        Subtask<String> review = scope.fork(() -> reviews.summary(productId));

        scope.joinUntil(Instant.now().plusMillis(700));
        scope.throwIfFailed();

        return new ProductDetail(base.get(), stock.get(), promo.get(), review.get());
    }
}

跟原来的 Future 版本比,变化主要在几处。

超时是一处设置的。joinUntil 给定一个绝对时间点,四个任务共享同一个截止时间。任何一个超时,整个 scope 结束,剩下的子任务会被自动中断。

错误传播是自动的。ShutdownOnFailure 意味着任意一个子任务抛异常,其他子任务会被取消。throwIfFailed() 会把那个异常重新抛出来,不用逐个检查。

子任务的结果不共享。每个 fork 返回的 Subtask 只能被创建它的代码块读取,其他代码拿不到。这看着是个限制,其实防止了很多乱七八糟的跨任务数据传递。

还有一个变体叫 ShutdownOnSuccess,适合”多个数据源谁先返回用谁的”这种场景。比如我们后来接了两个促销服务做互备,用这个就很合适,第一个成功的任务结果会被保留,其余的立刻取消。

要注意 API 变化。Java 21 到 24 之间,StructuredTaskScope 的接口改过几轮。到了 Java 25,ShutdownOnFailure 这个具体类被去掉了,改成通过 StructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow()) 来构造,join 也不需要显式调用了,close 时会自动处理。如果你的项目跨版本升级,这段代码是要重写的。这也是它迟迟没有转正的原因之一。

并发上限还是得有

说了半天虚拟线程的好处,但有个认识得摆正:虚拟线程解决的是”线程太贵”的问题,不是”下游打不垮”的问题。

以前线程池限制并发,其实是在做一件它不该做的事——用线程数量间接限流。换成虚拟线程之后,这个保护没了,你得自己补上。

最直接的方式是 Semaphore。给每个下游服务配一个独立的信号量,容量参考对方能承受的并发上限,或者干脆参考你们之间的服务协议。

public class HttpClients {
    private final Semaphore inventoryPermits = new Semaphore(200);
    private final Semaphore promoPermits = new Semaphore(100);
    private final Semaphore reviewPermits = new Semaphore(150);

    public int query(long id) throws InterruptedException {
        if (!inventoryPermits.tryAcquire(500, TimeUnit.MILLISECONDS)) {
            throw new DownstreamBusyException("inventory");
        }
        try {
            return doQuery(id);
        } finally {
            inventoryPermits.release();
        }
    }
}

tryAcquire 带超时这个细节很重要。用无参的 acquire() 会让虚拟线程无限等待,虽然不占载体线程,但用户请求就挂在那儿了,还不如快速失败给个降级结果。

信号量的容量定多少,取决于下游的承受能力。我们这边库存服务的响应时间在 40 毫秒左右,容忍并发 200,按利特尔法则算下来理论吞吐是 200 / 0.04 = 5000 QPS,比我们实际需求高,所以 200 是安全的。促销服务本身响应慢,给 100 就够,多了对方反而会超时。

怎么观测

虚拟线程的观测和平台线程不太一样,沿用老办法会看不到东西。

线程 dump。jstack 对虚拟线程基本没用,它只打印平台线程。要看虚拟线程得用:

jcmd <pid> Thread.dump_to_file -format=json /tmp/vt-dump.json

输出是一个 JSON 文件,里面按容器分组,能看到每个虚拟线程的状态和栈。文件可能很大,几万个虚拟线程的情况建议先 grep 一下状态分布。

JFR 事件。几个值得关注的事件:

  • jdk.VirtualThreadStartjdk.VirtualThreadEnd:启动数量。如果这个数字远超你的 QPS 乘以平均并发数,说明有哪里在疯狂创建虚拟线程,可能是循环里提交任务之类的写法。
  • jdk.VirtualThreadPinned:被钉住的次数和时长。这个数值不为零就要查。
  • jdk.VirtualThreadSubmitFailed:调度器提交失败,通常是资源不足。

调度器并行度。虚拟线程的载体线程数默认等于 CPU 核数,可以通过系统属性调整:

-Djdk.virtualThreadScheduler.parallelism=16
-Djdk.virtualThreadScheduler.maxPoolSize=64

这两个值一般不用调。只有在你的任务里有大量非阻塞但 CPU 密集的计算时,才会考虑调高 parallelism。纯 IO 型的服务保持默认就行。

几个不应该做的事

不要池化虚拟线程。这是最常见的误解。虚拟线程的创建成本极低,一个大概几百纳秒,比你去池子里借一个还快。写一个虚拟线程池,除了给自己找麻烦没有别的作用。

不要在虚拟线程里做长时间 CPU 计算。虚拟线程的调度器只有 CPU 核数个载体线程,一个虚拟线程跑了 5 秒的密集计算,等于占住了一个载体线程 5 秒,其他虚拟线程全得等。CPU 密集的任务该用平台线程池还是用平台线程池。

不要依赖线程局部状态做跨请求缓存。平台线程池时代确实有人把 ThreadLocal 当缓存用(反正线程会复用),虚拟线程下这招彻底失效。缓存就老老实实用 ConcurrentHashMap 或者 Caffeine。

不要盲目调大下游连接池。前面那个 Hikari 的例子已经说明了,虚拟线程把瓶颈推到了下游,你加连接数只是把问题推给数据库。真正的解法是在自己的服务边界做限流和降级。

上线之后的实际数据

最终版本跑了一个月的生产环境,几个数字供参考。

平均响应时间从 420 毫秒降到 180 毫秒,P99 从 1.6 秒降到 520 毫秒。QPS 峰值从 900 提到 3100,机器配置没变。GC 方面,年轻代回收频率略微上升,因为虚拟线程的栈对象会在堆里分配和回收,但单次时间更短,整体停顿时间反而是下降的。

内存占用从 1.2GB 涨到了 1.6GB,主要多出来的就是虚拟线程的对象。这个代价可以接受。

唯一没变的是数据库负载。因为它本来就在满负荷跑,虚拟线程只是让上游不再排队,数据库该多忙还是多忙。

写在最后

虚拟线程不是银弹,它把”线程”这个资源的成本降下来之后,你的系统里其他所有资源的成本都会暴露出来。数据库连接、下游服务的吞吐、DNS 解析、日志写入、甚至操作系统文件描述符的数量,这些以前被线程池掩盖住的瓶颈,现在会一个个浮上来。

所以迁移的过程,本质上是在重新梳理一遍服务的资源依赖。这个过程会很烦,但梳理完之后,你对这个系统的理解会比之前清楚得多。我个人觉得这是比性能提升更有价值的部分。

如果手里的项目还在 Java 11 或者 17,建议先升到 21。虚拟线程、Record Pattern、Pattern Matching for switch 这些特性组合起来,对日常写业务代码的影响是实打实的。

Java 21 虚拟线程实战:把商品聚合接口从固定线程池迁到虚拟线程的完整记录
收藏 (0) 打赏

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

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

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

淘吗网 java Java 21 虚拟线程实战:把商品聚合接口从固定线程池迁到虚拟线程的完整记录 https://www.taomawang.com/server/java/2749.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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