Administrator
发布于 2025-06-26 / 2819 阅读
77

Java 异步编程的终局:虚拟线程还是响应式

我们有个 WebFlux 服务,没人愿意改它

我们有个网关服务是 2021 年用 WebFlux 写的,三个接口、两千多行,但组里除了我没人愿意碰它。原因很简单:那套 Mono/Flux 的链式调用,加上 .flatMap() 里嵌套 .zip() 的写法,改起来要花两倍时间,而且出错后堆栈完全看不懂。

今年四月我们把它迁到了 Spring Boot 3.4 + 虚拟线程。迁移完之后我做了完整的压测对比,这篇记录数据和我的选型判断。

先说结论性的数据

同一个服务、同样的业务逻辑、同样的机器(8 核 16G,K8s Pod),两个版本跑同样的压测场景(调用三个下游服务,其中两个是 HTTP、一个是数据库查询,串行编排):

指标WebFlux(Netty)Spring MVC + 虚拟线程
P5024ms26ms
P9558ms61ms
P99142ms155ms
最大吞吐(P99 < 500ms)8400 QPS7600 QPS
常驻内存480MB690MB
CPU 使用(5000 QPS 时)2.1 核2.4 核
代码行数21401180
栈深度(调用链)平均 47 层平均 19 层

性能上 WebFlux 略胜,差距在 5%~10% 之间,吞吐差了 9.5%。但代码行数少了 45%,而且堆栈从 47 层降到 19 层——这个数字是我决定迁移的关键原因。47 层的响应式堆栈里,真正属于业务逻辑的不到 5 层,其余全是 Reactor 的操作符调用。线上出问题时看这种堆栈是一种折磨。

虚拟线程到底解决了什么

先讲清楚原理,不然选型就是瞎选。虚拟线程的核心价值是:让阻塞变得便宜

传统平台线程(Thread)是 1:1 映射到操作系统线程的,默认栈大小 1MB,创建成本高,几千个就是极限。所以以前遇到 IO 密集场景,要么开大线程池(浪费内存),要么用响应式(改变编程模型)。

虚拟线程是 JVM 管理的,不占用 OS 线程,初始栈只有几百字节且能自动伸缩。当虚拟线程执行阻塞操作(比如 socket.read())时,JVM 会把它从载体线程上卸载,载体线程去跑别的虚拟线程。IO 等待期间不占用任何系统资源。

// 开一百万个虚拟线程,实测 2.3 秒,占用 1.1GB 堆外内存
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    LongStream.range(0, 1_000_000).forEach(i -> executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));
        return i;
    }));
}

这个例子在平台线程下会直接 OOM。所以虚拟线程不是"更快的线程",是"更省的线程"。单个请求的处理速度没变快,变的是系统能同时hold住多少请求。

JDK 24 解决了 pinning 问题

这里有个重要的时间点。早期虚拟线程有个著名的坑:synchronized 块里执行阻塞操作时,虚拟线程无法卸载,会一直占用载体线程。这个现象叫 pinning,会导致虚拟线程退化成平台线程。

JDK 21、22、23 里这个问题的规避办法是把 synchronized 换成 ReentrantLock。我们迁移时确实这么干过,改了十几个地方。

JEP 491(Synchronize Virtual Threads without Pinning)在 JDK 24 里正式落地,改了 JVM 的对象监视器实现,虚拟线程在 synchronized 块里阻塞时也能正常卸载。我们升级到 JDK 24 之后把之前那些 ReentrantLock 改回了 synchronized,代码更自然了。如果你们还在 JDK 21,这个坑要留意。

顺带一提,现在 JDK 21 的虚拟线程也会在 pinning 发生时打 JFR 事件(jdk.VirtualThreadPinned),可以用下面的命令查:

jcmd <pid> JFR.start duration=60s \
  settings=profile filename=pin.jfr

虚拟线程不适合什么

讲了好处,必须讲清楚局限。有三个场景虚拟线程解决不了,甚至有反效果。

一、CPU 密集型任务

虚拟线程只在阻塞时才有收益。如果任务是纯计算(比如大批量数据转换、图像处理、模型推理),虚拟线程不会提升任何性能,反而因为线程数量不受限,可能导致 CPU 争抢更加剧烈。

CPU 密集场景应该用 Executors.newFixedThreadPool(N),N 等于 CPU 核数。我们的文档解析服务就因为这个踩过坑——把解析任务丢到虚拟线程池,开了上万个虚拟线程同时跑 CPU 密集的 OCR 预处理,机器 load 飙到 40。

二、需要背压的场景

这是响应式真正的护城河。Flux 的背压机制让消费者能告诉生产者"慢一点",在流处理、消息队列消费、大数据管道这类场景里是刚需。

虚拟线程没有背压概念。如果生产者速度远超消费者,用虚拟线程的结果就是内存堆积直到 OOM。我们的 Kafka 消费者改回响应式是有道理的——它需要精确控制拉取速率。

三、需要复杂流编排的场景

响应式的操作符是声明式的流组合:合并、分流、窗口、重放、超时回退、指数退避重试。这些用 Reactor 写是一两行,用命令式代码写得写几十行。

// 响应式:三路并发、取最快、超时回退,一行搞定
Mono.firstWithValue(
    callServiceA(), callServiceB(), callServiceC())
    .timeout(Duration.ofMillis(800))
    .onErrorResume(e -> Mono.just(FALLBACK));

// 虚拟线程 + 结构化并发(JDK 24 预览)
try (var scope = StructuredTaskScope.open(
        Joiner.<Result>anySuccessfulResultOrThrow())) {
    scope.fork(this::callServiceA);
    scope.fork(this::callServiceB);
    scope.fork(this::callServiceC);
    return scope.join();
}

需要说明的是结构化并发(StructuredTaskScope)在 JDK 24 里还是第五次预览(JEP 505),API 还在变,生产上我没敢用。这类场景我们目前还是老老实实用 ExecutorService + CompletableFuture 或者保留响应式。

迁移要注意的几件事

如果你打算从 WebFlux 迁到虚拟线程,这是我们踩过的坑:

  • 连接池要重新配。以前 WebFlux 的连接池很小(Netty 默认连接池大小跟事件循环数相关),换成虚拟线程后并发能力暴涨,连接池会先成为瓶颈。我们的 HTTP 客户端连接池从 50 调到 500,数据库连接池从 20 调到 120;
  • 必须加限流。虚拟线程可以无限创建,如果下游慢了,请求会无限堆积而不是快速失败。我们用 Resilience4j 的 Bulkhead 限制并发,超出直接拒绝。这一条不做,虚拟线程会让你的系统在压力下雪崩得更彻底;
  • ThreadLocal 要小心。虚拟线程支持 ThreadLocal,但因为数量巨大,用 ThreadLocal 存大对象会吃内存。更重要的是,线程池复用场景下"忘记清理 ThreadLocal"的习惯性 bug 在虚拟线程下不明显了,反而容易掩盖问题。链路追踪这块我们统一迁移到了 ScopedValue(JDK 24 的 JEP 487,预览)或者干脆显式传参;
  • 同步的监控埋点要检查。有些监控 SDK 用了 synchronized 或者线程池,在虚拟线程下行为会变。我们的 SkyWalking agent 就遇到过一段时间和虚拟线程不兼容,导致 trace 丢失,升级到 9.3 才解决;
  • 不要混用。在虚拟线程里调用 Mono.block() 是能跑的,但会损失响应式的调度优势,还容易死锁。要么全响应式,要么全命令式。

我的选型建议

按场景给个明确的判断:

场景推荐理由
传统 Web 服务 / 微服务虚拟线程代码简单,性能足够,维护成本低
API 网关、BFF 聚合层看情况编排简单用虚拟线程,复杂流编排用响应式
流处理、消息管道响应式背压是刚需
CPU 密集批处理固定线程池两者都不合适
高吞吐低延迟的内部中间件响应式5%~10% 的性能差在极致场景有意义
新项目、团队水平参差虚拟线程响应式的学习曲线是真实成本

我的总体判断是:虚拟线程应该成为默认选择,响应式退回到它真正擅长的细分场景。

理由不是性能,是总拥有成本。响应式带来的那 5%~10% 性能优势,在绝大多数业务系统里换不来它带来的复杂度和人力成本。我们那个 WebFlux 服务,新人接手平均要两周才能独立改需求,同复杂度的 MVC 服务只要两天。这个差距乘以人数乘以时间,远远超过省下的那几台机器。

响应式的未来

有人问响应式是不是要死了,我觉得不会,但它的定位会收缩。它在这些地方仍有不可替代的价值:

  • 背压:这是虚拟线程给不了的语义;
  • 流式数据:SSE、WebSocket 这类长连接推送场景,Flux 的模型天然契合;
  • 复杂的异步流编排:重试、超时、合并、限流这一套操作符的成熟度,命令式代码短期内追不上;
  • 存量系统:已经跑了好几年的 WebFlux 服务,只要团队能维护,没必要为了迁移而迁移。

我们公司目前的状态是:新服务全部虚拟线程,存量的 WebFlux 服务里,只有那个 Kafka 流处理相关的保留了响应式,其余的在业务改动较大时逐步迁移。这个节奏我觉得比较健康。

小结

虚拟线程不是"更快的线程",是"更省的线程",它的价值在于让你用同步的写法获得接近异步的吞吐。这个价值在业务系统里几乎是压倒性的:45% 的代码量减少、一半的栈深度、新人上手时间从两周降到两天。

但它不是万能药。CPU 密集、需要背压、复杂流编排这三个场景,响应式依然更合适。而且用虚拟线程必须配上限流和合理的连接池配置,否则系统会在压力下崩得更快。

最后给个可操作的建议:如果你在 JDK 21 上,先升到 JDK 24 再上虚拟线程,JEP 491 解决的 pinning 问题值得这个升级。然后新项目默认用虚拟线程,存量响应式服务按"改动成本 vs 维护成本"的权衡决定是否迁移,别搞运动式重构。

参考