Administrator
发布于 2026-02-17 / 337 阅读
3

虚拟线程三年:生产落地的经验与教训

从 2023 年 9 月到今天,虚拟线程我们用了两年半

JDK 21 发布是 2023 年 9 月,我们当年 11 月就把一个内部服务切到了虚拟线程,算是比较早的一批。到今天两年半,虚拟线程跑在我们 14 个服务上,处理过峰值 4.7 万 QPS 的流量,也引发过两次 P2 故障。

这篇不写原理,只写结论:哪些场景我们确认好用,哪些坑我们踩过,以及现在和传统线程池怎么共存。

先说结论:适用场景收敛到两类

两年下来,虚拟线程在我们这里的适用场景收敛得非常窄,就两类:

场景收益我们的实际数据
阻塞式 IO 密集型(HTTP 调用、DB 查询、RPC)极大网关聚合服务 800 线程 → 虚拟线程,P99 从 840ms 降到 130ms
批量并行任务(扇出调用、数据同步)对账批处理从 47 分钟降到 6 分钟
CPU 密集型计算无收益,有害规则引擎压测反而降了 3%
长连接保持(WebSocket、SSE)不适合换回 EventLoop,内存降 62%

中间那行「CPU 密集型无收益」值得展开。虚拟线程在 CPU 密集场景下不会变快,只会白白多一层调度开销。我们有个规则引擎服务,同事看虚拟线程「很火」,一把全切了,压测结果:

# 32 核机器,纯计算任务,各跑 5 分钟
平台线程池(32)   : 吞吐量 18,400 ops/s   CPU 96%   P99   42ms
虚拟线程(无界)   : 吞吐量 17,850 ops/s   CPU 99%   P99  118ms

降了 3% 吞吐,P99 涨了近三倍。原因很简单:虚拟线程数量不受控,几万个任务同时抢 32 个核,调度队列变长,尾延迟恶化。这个服务我们一周后就切回去了

坑一:synchronized 导致的线程固定

这是 JDK 21 时代最著名的问题,JDK 24 之后(JEP 491)基本解决了。但我们是在 JDK 21 上线的,踩得很实。

现象是网关服务切到虚拟线程后,压测到 3000 QPS 就上不去了,CPU 才 40%,线程数卡在 256 不动。那 256 看着眼熟,就是 ForkJoinPool 的默认并行度,也就是承载虚拟线程的平台线程数。

原因:代码里有个本地缓存用了 synchronized 方法,虚拟线程进入 synchronized 块后无法卸载,会一直占用平台线程(pinning)。定位手段是 JFR 的 jdk.VirtualThreadPinned 事件:

java -XX:+EnableDynamicAgentLoading \
     -Djdk.tracePinnedThreads=full \
     -jar gateway.jar

# 输出
Thread[#82,ForkJoinPool-1-worker-3,5,CarrierThreads]
    com.internal.cache.LocalCache.get(LocalCache.java:47) <== monitors:1
    com.internal.gateway.RouteFilter.doFilter(RouteFilter.java:88)

或者在压测时开 JFR 更准:

jcmd <pid> JFR.start name=vt \
     settings=profile \
     duration=60s filename=/tmp/vt.jfr
jfr summary /tmp/vt.jfr | grep -i pinned
# jdk.VirtualThreadPinned  184,722 events  duration sum = 41.2s

60 秒内有 41 秒处在 pinning 状态,等于白切了。修复方式是把 synchronized 换成 ReentrantLock

// 改前
public synchronized V get(K key) { ... }

// 改后
private final ReentrantLock lock = new ReentrantLock();

public V get(K key) {
    lock.lock();
    try { return doGet(key); }
    finally { lock.unlock(); }
}

改完之后同样压测,QPS 从 3000 上到 1.2 万,CPU 68%,pinning 事件归零。

现在(JDK 21/25 生产基线)这个问题已经不是问题了。JEP 491 在 JDK 24 里改写了 synchronized 的实现,虚拟线程在监视器里也能卸载。我们 JDK 25 的服务上再跑 JFR,jdk.VirtualThreadPinned 事件数量是 0。但如果你还在 JDK 21,这仍然是第一排查项。

坑二:ThreadLocal 的内存放大

这个坑比 pinning 隐蔽,我们付出的代价也更大。

2024 年 6 月,一个批处理服务切到虚拟线程后,堆内存从 2G 涨到 7G 然后 OOM。这个服务一次处理 40 万条数据,每条起一个虚拟线程。

堆转储分析下来,是 ThreadLocal。我们有个链路追踪的自定义 ThreadLocal,存了一个约 3 KB 的上下文对象。平台线程时代线程池只有 50 个线程,50 × 3 KB = 150 KB,完全无所谓。虚拟线程时代同时存在 40 万个虚拟线程,40 万 × 3 KB = 1.2 GB

$ jcmd <pid> GC.heap_dump /tmp/dump.hprof
$ jhat ... # 或者直接用 MAT

# MAT 的 dominator tree 顶部
java.lang.Thread                              4.1 GB  (38%)
 └─ java.lang.VirtualThread                    4.0 GB
     └─ java.lang.ThreadLocal$ThreadLocalMap   3.9 GB
         └─ com.internal.trace.TraceContext    1.2 GB  (412,318 instances)

更麻烦的是虚拟线程本身也有成本:每个虚拟线程的栈是按需增长的 StackChunk 对象,40 万个并发时这部分占了另外 2 GB 多。

我们做了两件事。第一,把 ThreadLocal 换成 ScopedValue(JDK 25 已转正):

// ScopedValue 的生命周期是「作用域」,不是「线程」
private static final ScopedValue<TraceContext> TRACE = ScopedValue.newInstance();

void handle(Record record) {
    ScopedValue.where(TRACE, buildContext(record))
               .run(() -> process(record));   // run 结束时自动解绑
}

void process(Record record) {
    TraceContext ctx = TRACE.get();   // 只在作用域内可见
}

ScopedValue 是不可变的,且绑定随作用域结束自动释放,不会跟着虚拟线程的生命周期一直挂着。

第二,也是更关键的:不要无节制地起虚拟线程。「虚拟线程便宜所以可以随便起」这句话害人不浅。便宜是相对的,起几十万个照样撑爆内存。我们加了个信号量控制并发上限:

// 批处理并发控制在 5000,不是 40 万
try (var executor = Executors.newVirtualThreadPerTaskExecutor();
     var sem = new Semaphore(5000)) {

    for (Record r : records) {
        sem.acquire();
        executor.submit(() -> {
            try { handle(r); }
            finally { sem.release(); }
        });
    }
}

并发从 40 万降到 5000,内存峰值从 7G 降到 2.3G,总耗时反而从 38 分钟降到 31 分钟——因为原来 40 万个任务抢下游连接池,反而大量超时重试。

坑三:连接池才是真瓶颈,虚拟线程不解决它

这是最容易误解的一点。很多人以为切了虚拟线程,并发能力就无限了。不对,瓶颈只是从「线程数」转移到了「下游连接数」

我们的订单聚合服务切完之后压测:

并发QPSP99DB 连接池等待
5001,84096ms0.4ms
2,0003,120210ms18ms
5,0003,0901,240ms412ms
10,0002,7603,800ms1,860ms

QPS 在 2000 并发就到顶了,再往上不仅不涨,P99 爆炸。原因就是 HikariCP 连接池只有 50 个连接,5000 个虚拟线程排队等 50 个连接,等待时间直接进了 P99。

这件事的本质是:虚拟线程把「线程不够用」这个瓶颈消除了,然后立刻暴露出下一个瓶颈。我们最后把连接池从 50 提到 200,同时给数据库加了读写分离,QPS 才上到 6800。

顺便说一句,HikariCP 在虚拟线程下的一个坑:connectionTimeout 默认 30 秒,高并发下大量虚拟线程堆积在等待队列,30 秒后集体超时,形成「超时风暴」。我们把它降到 3 秒,配合快速失败,反而更稳:

spring:
  datasource:
    hikari:
      maximum-pool-size: 200
      connection-timeout: 3000      # 从默认 30000 降下来
      validation-timeout: 1000
      leak-detection-threshold: 10000

共存策略:两套线程池,按任务类型分派

现在我们服务的标准配置是虚拟线程和平台线程并存,不是二选一。分工很清楚:

@Configuration
public class ExecutorConfig {

    /** IO 密集:HTTP 调用、DB 查询、RPC —— 走虚拟线程 */
    @Bean("ioExecutor")
    ExecutorService ioExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }

    /** CPU 密集:加解密、规则计算、序列化 —— 走固定平台线程池 */
    @Bean("cpuExecutor")
    ExecutorService cpuExecutor() {
        int n = Runtime.getRuntime().availableProcessors();
        return Executors.newFixedThreadPool(n,
            new ThreadFactoryBuilder()
                .setNameFormat("cpu-worker-%d")
                .setUncaughtExceptionHandler((t, e) -> log.error(t.getName(), e))
                .build());
    }
}

选型的判断标准只有一个:任务会不会阻塞等待。会阻塞(等 IO)就用虚拟线程,纯计算就用平台线程。这条规则简单到近乎粗暴,但两年下来没出过错。

还有一类特殊场景:需要 ThreadLocal 做线程池隔离的(比如某些老的三方库依赖 ThreadLocal 传递上下文),我们也保留平台线程池,不做强迁。

结构化并发:治好了我们的超时泄漏

最后说一个 2025 年才用上的东西。扇出调用我们用 CompletableFuture 写了两年,一直有个问题:子任务超时了,父任务不知道,还在傻等

// 老写法:taskA 超时抛异常后,taskB 仍在后台跑,没人管
CompletableFuture<A> fa = supplyAsync(() -> callA(), ioExecutor);
CompletableFuture<B> fb = supplyAsync(() -> callB(), ioExecutor);
A a = fa.get(2, TimeUnit.SECONDS);   // 超时了,fb 还在跑
B b = fb.get(2, TimeUnit.SECONDS);

换成结构化并发(JDK 25 已第五次预览,API 稳定可用):

try (var scope = StructuredTaskScope.open(
        Joiner.<Result>allSuccessfulOrThrow()
             .timeout(Duration.ofSeconds(2)))) {

    Subtask<A> ta = scope.fork(() -> callA());
    Subtask<B> tb = scope.fork(() -> callB());

    scope.join();          // 任一失败或超时,整个作用域取消
    return combine(ta.get(), tb.get());
}
// 出了 try 块,所有子任务保证已结束,不会泄漏

压测下的差异很明显:在 20% 下游超时的故障注入场景,老写法会有残留任务持续占用连接,30 秒后连接池耗尽;结构化并发下任务被及时取消,连接池水位稳定在 40% 左右。

唯一的问题是 API 还在预览,需要 --enable-preview。我们现在的做法是:新代码用结构化并发,但集中封装在一个 FanoutTemplate 类里,API 真变了只改一处。

先到这

《虚拟线程三年:生产落地的经验与教训》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考