Administrator
发布于 2023-01-28 / 1565 阅读
34

虚拟线程实战:把线程池换成虚拟线程后发生了什么

把线程池换成虚拟线程

我们有个网关聚合服务,每请求要并发调 5 个下游,原来用 200 个平台线程的池子,峰值打满就排队。用户多的时候,线程池耗尽,请求在队列里干等,RT 从 200ms 飙到 2 秒。听说 JDK 19 的虚拟线程能把"一个任务一个线程"做到近乎免费,就拿来做了对照实验,想看看是不是真能缓解这种 IO 密集型排队。

注意:写这篇文章时是 2023 年 1 月,虚拟线程仍是 JDK 19 的预览特性(Preview),生产环境需 --enable-preview,尚未 GA。JDK 21 才转正,那是后话了,本文不涉及。

改造成本低得惊人

try (var ex =
     Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        ex.submit(() -> fetchDownstream(i));
    }
}

把原来的 fixedThreadPool(200) 换成上面这个,业务代码几乎没动,只是执行器换了。虚拟线程由 JVM 调度在少量载体线程(默认等于 CPU 核数)上,任务 IO 阻塞时自动让出载体,不占线程。这意味着你可以用"每任务一线程"的直观写法,却不必担心线程数爆炸。

吞吐量对比

压测场景:每任务 sleep 100ms 模拟 IO 等待,并发 10000,4C8G:

执行器QPS线程数P99
平台线程池(200)1,9802001100ms
虚拟线程9,400约 10,000(虚拟)215ms

QPS 翻了近 5 倍,因为虚拟线程在 IO 阻塞时自动让出载体线程,不会被池子容量卡住。平台线程池那版,10000 并发但只有 200 线程,每线程服务 50 个任务串行等 IO,自然慢。

不适用的场景

  • CPU 密集型:虚拟线程不增加算力,200 个核还是 200 个。我们拿一个纯计算任务(大数分解)测,虚拟线程和平台线程池吞吐几乎一样,没优势;
  • 线程 pinning:在 synchronized 块里做 IO 会"钉住"载体线程,吞吐骤降。我们实测把一个 synchronized 换成 ReentrantLock 后,pinning 消失,P99 又降了 18%;
  • 大量使用 ThreadLocal 且生命周期长:虚拟线程数量可能巨大,ThreadLocal 占的内存会被放大,要注意清理;
  • 依赖线程池特有语义的代码:比如用 Thread.sleep 做定时、用线程名做监控的,迁移后要重新评估。

怎么判断自己适不适合

核心判断标准就一条:你的瓶颈是不是"线程在等 IO"。如果是,虚拟线程基本白给;如果是 CPU 跑满,它帮不上忙。我们的网关聚合正是典型 IO 等待型,所以收益明显。

迁移时要做的体检:全局搜 synchronized,凡是块内有 IO(网络、锁等待)的,考虑换成 ReentrantLock 或挪到块外;检查 ThreadLocal 有没有在请求结束时不 remove 的;确认没有依赖"平台线程"假设的三方库(个别库会缓存当前线程做优化,虚拟线程下行为异常)。

pinning 的实测定位

怎么知道你有没有踩 pinning?JDK 19 提供了诊断参数,能打印 pinning 发生的栈:

java -Djdk.tracePinnedThreads=full \
     --enable-preview -jar app.jar

我们开着它跑压测,日志里刷出一堆"Thread 在 synchronized 块中被 pin 住"的堆栈,定位到两个地方:一个是老代码里用 synchronized 包住整个 HTTP 调用(图省事),一个是某个第三方 SDK 内部用了 synchronized。前者我们改成 ReentrantLock 把 IO 挪出锁块;后者没法改 SDK,就给它单独配一个平台线程池跑那段,避开虚拟线程。

与线程池混用策略

不是所有代码都适合虚拟线程。我们最终是混合的:聚合这类 IO 等待型用虚拟线程执行器;CPU 密集的计算(比如风控规则求值)仍用固定平台线程池,避免虚拟线程在 CPU 忙时白白占载体。ExecutorService 可以共存,按任务性质选执行器,不必一刀切。Spring 那边我们用 TaskExecutor 适配,@Async 指定不同执行器即可分流。

迁移 checklist

给也想试的人一份清单:

  • 全局搜 synchronized,块内含 IO 的全部排查或换锁;
  • 检查 ThreadLocal 是否在请求结束时不 remove,虚拟线程量大时会放大泄漏;
  • 确认没有依赖"当前是平台线程"假设的库(如用 Thread 做ThreadLocal 传递的日志框架要验证);
  • 用 jdk.tracePinnedThreads 跑一轮压测看 pinning 报告;
  • 生产上线前先在灰度环境用真实流量验证,别直接全量。

和 CompletableFuture 的对比

有人问:原来用 CompletableFuture 池不也能并发吗?区别在于心智负担。CompletableFuture 要手写 thenCompose、异常处理、组合,代码绕;虚拟线程让你直接用"同步写法 + 阻塞"拿到并发效果,代码直读。我们对比同一段"并发调 5 个下游聚合":CompletableFuture 写了 40 行带回调,虚拟线程版本 15 行直平。可读性提升明显,bug 也更少。但 CompletableFuture 胜在能精细控制线程池、背压,虚拟线程下这些得靠信号量自己限流。

限流在虚拟线程下更重要

虚拟线程让"无脑起 10 万任务"变得容易,但也容易把下游打爆——毕竟下游还是那个并发能力。我们给虚拟线程执行器外层套了 Semaphore,限制同时发起的下游调用数:

Semaphore sem = new Semaphore(200);
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var req : reqs) {
        sem.acquire();
        ex.submit(() -> {
            try { fetch(req); }
            finally { sem.release(); }
        });
    }
}

这样虚拟线程负责"不占平台线程",信号量负责"不过载下游",各司其职。虚拟线程不替你做限流,这点别误会。

监控指标怎么看

虚拟线程下线程数指标失真(虚拟线程数可能巨大且意义不大),要看的是载体线程(carrier thread)的利用率和任务排队时长。我们用自定义指标记录"任务从提交到真正执行"的等待时间,超过阈值说明载体线程不够或 pinning 了。JDK 19 的 jcmd 也加了虚拟线程的 dump 能力,排查时能看到哪些虚拟线程卡在啥处。

虚拟线程的调度原理浅析

简单说,虚拟线程是用户态线程,JVM 自己调度在少量载体线程(默认等于 CPU 核数的 ForkJoinPool)上。当虚拟线程遇到 IO 阻塞(网络、文件、锁等待),JVM 自动把它从载体线程"卸载",载体去跑别的虚拟线程;IO 完成再"挂载"回来。所以 10 万虚拟线程阻塞,只占 10 万份很小的栈内存(几 KB),不占平台线程。这正是它比"一任务一平台线程"省的原因。但要注意:只有"可卸载点"(如网络 IO)能释放载体,CPU 计算不会,所以 CPU 密集任务还是占满载体。

什么时候坚决不用

反面清单:一是纯计算任务(如报表聚合、加解密),虚拟线程零收益还可能因调度开销略慢;二是用了大量 synchronized 且块内含 IO 的老代码,pinning 会让载体被占,吞吐不升反降,得先改造;三是依赖平台线程局部存储做上下文传递的框架(部分老日志/追踪框架),虚拟线程下上下文会丢,要验证。我们上线前专门扫了一遍这三类,确认聚合服务是 IO 等待型、无 pinning 隐患,才敢试。选型前先对清单,比直接换执行器稳。

等 GA 那天的计划

我们账面已经清完 pinning 隐患、抽象好执行器选型,就等 JDK 21 虚拟线程 GA(预览期之后)再正式切。切的时候会先在灰度环境用真实流量跑一周,对比 P99 和错误率,确认优于平台线程池才全量。不抢这几个月预览期的 Production 红利,是因为预览 API 可能变、且不支持商业支持,赌不起。但准备做在前面,GA 当天就能上,这才是稳妥的姿势。顺便说,虚拟线程也改变了我们写异步代码的习惯——以后"一个请求一个线程"的直观写法可以放心用,不用再纠结线程池大小。

我们账面已经清完 pinning 隐患、抽象好执行器选型,就等 JDK 21 虚拟线程 GA(预览期之后)再正式切。切的时候会先在灰度环境用真实流量跑一周,对比 P99 和错误率,确认优于平台线程池才全量。不抢这几个月预览期的Production 红利,是因为预览 API 可能变、且不支持商业支持,赌不起。但准备做在前面,GA 当天就能上,这才是稳妥的姿势。

小结

虚拟线程对 IO 密集型是降维打击,但前提是别在 synchronized 里阻塞、别指望它救 CPU 瓶颈。作为预览特性,我们只在压测环境验证,生产暂未启用——等它 GA(JDK 21)再正式上。提前把代码里的 pinning 隐患清掉,等 GA 那天就能平滑切换,不用临时救火。

参考