首页 > 编程开发 > Java    日期:2026-08-19 / 浏览

在 JDK 21 之前写高并发 Java 服务,基本就是跟 ThreadPoolExecutor 死磕。线程开多了 OOM,开少了请求排队超时。微服务架构下,网络 IO 和下游 RPC 调用占掉大半时间,传统 1:1 平台线程模型里,大量线程卡在 WAITING 状态干耗内存。1 万个并发请求光线程栈就要吃掉近 10GB 堆外内存,CPU 时间全花在上下文切换上,业务逻辑反而跑不动。

虚拟线程(Virtual Threads)出来就是为了解这个死结。它不是语法糖,是 JVM 层面把调度权从 OS 内核抢回来了。底层走的是 M:N 映射:大量虚拟线程挂在数量等于 CPU 核数的 ForkJoinPool 载体线程上。遇到阻塞,JVM 直接把当前虚拟线程的栈帧和局部变量打包成 Continuation 扔进 Java 堆,载体线程立刻切去跑别的就绪任务。没有内核态切换,没有页表刷新,同一颗 CPU 核心扛几十万虚拟线程跟玩一样。

下面直接聊怎么在 Spring Boot 3.2+ 里落地,以及线上真实踩过的坑。

Spring Boot 集成与配置陷阱

Boot 3.2 开始内置了虚拟线程支持,开关就一行:

spring:
  threads:
    virtual:
      enabled: true

打开后,Spring Boot 会自动把 Tomcat/Undertow 的请求处理线程、@Async、默认 TaskExecutor 全部换成虚拟线程工厂。你业务代码里不需要到处调 Executors.newVirtualThreadPerTaskExecutor(),框架帮你兜底了。

但有几个配置细节必须注意:

server.tomcat.max-threads 会直接失效。连接器底层改用虚拟线程后,这个参数被忽略,并发上限不再受线程数限制,而是转移到了文件描述符(ulimit -n)、网卡带宽、以及下游数据库/Redis 的连接池上限。Tomcat 自身的 max-connections(默认 10000)建议根据实际 QPS 调高。

Web 过滤器和拦截器里的 ThreadLocal 是重灾区。载体线程是复用的,虚拟线程阻塞卸载再恢复时,可能挂载到另一个带残留数据的载体上。线上出现过 A 请求的用户上下文串到 B 请求,直接越权。

改造就两条路:要么在所有 finallyafterCompletion 里死命令 ThreadLocal.remove(),要么直接上 ScopedValue(JDK 21 引入,天然隔离上下文,注意需要加 --enable-preview 启动参数):

private static final ScopedValue<RequestContext> REQ_CTX = ScopedValue.newInstance();

ScopedValue.runWhere(REQ_CTX, new RequestContext(req), () -> {
    chain.doFilter(req, res);
});

连接池与线程池的重新标定

很多人以为开了虚拟线程,DB 连接池也可以无脑开大,这是典型误区。虚拟线程等连接不费资源,但数据库连接数上限是物理死的。池子开太大,连接池本身的管理开销和数据库端的上下文切换反而会把服务拖垮。

HikariCP 的 maximum-pool-size 建议从默认的 20 降到 8~15 之间。虚拟线程在池边排队时内存消耗趋近于 0,连接一释放立马被新线程接走,利用率远高于传统模型。Redis 客户端别再用 Jedis 的阻塞 IO 了,换 Lettuce,底层走 Netty,对虚拟线程的调度兼容得最好。

业务线程池别一刀切。线上跑下来,最稳妥的做法是按任务类型分池

@Configuration
public class ThreadPoolConfig {
    // I/O 密集型:虚拟线程直接无上限派发
    @Bean("ioExecutor")
    public Executor ioExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }

    // CPU 密集型:老老实实用平台线程池,别跟虚拟线程抢载体
    @Bean("cpuExecutor")
    public Executor cpuExecutor() {
        return new ThreadPoolExecutor(
            Runtime.getRuntime().availableProcessors(),
            Runtime.getRuntime().availableProcessors() * 2,
            60L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(2000),
            new ThreadFactoryBuilder().setNameFormat("cpu-task-%d").build()
        );
    }
}

注意 @EnableAsync 默认走的是 ThreadPoolTaskExecutor,如果想全局接管,记得用 @Primary 或者直接声明 Bean 名为 taskExecutor

纯虚拟线程不是万能药。生产环境最好搞个路由分发:HTTP 网关、DB 查询、RPC 调用、消息推送、文件读写这些 IO 等待长的,扔给虚拟线程;图像压缩、加密解密、复杂公式计算、或者调用了带 synchronized 的老旧 Native 库的,老老实实走平台线程池。写个简单的 DelegatingExecutor 按方法注解或参数特征做路由,能避开载体线程被 CPU 任务饿死的情况。

线上高频故障点

1.synchronized引发的 Pinning(钉住)

虚拟线程一碰到阻塞就应该卸载,但有些情况它卸不下来,死死绑在载体线程上,这叫 Pinning。JDK 21 修复了 JDK 内置库的大部分 Pinning,但第三方 Jar 包里的 synchronized 块、部分 Native 方法内部的锁、还有老旧 JDK 版本的 FileInputStream.read() 依然会触发。

一旦载体线程被 Pin 住,整个调度器吞吐量就会断崖式下跌。排查不用猜,直接加 JVM 参数:

-Djdk.tracePinnedThreads=short  # 打印简短堆栈,定位锁位置
-Djdk.tracePinnedThreads=full   # 生产环境慎用,日志量太大

解法很直接:把 synchronized 换成 ReentrantLockSemaphore。如果是调别人的包改不了,就用平台线程池做隔离,别让它污染载体。

2. 监控指标得换思路

传统的 jvm.threads.count 看虚拟线程毫无意义。得靠 JFR 和 Micrometer。

JFR 启动参数带上:-XX:+FlightRecorder -XX:StartFlightRecording=duration=60s,filename=vt.jfr。重点关注 jdk.VirtualThreadPinned 事件,看哪个方法在阻塞载体线程。

Spring Boot Actuator 配合 Micrometer 会自动暴露虚拟线程指标。Grafana 里拉这几个面板:

  • jvm.threads.virtual.count:当前存活数
  • jvm.threads.virtual.pinned.count:当前被 Pin 的数量
    线上盯紧 Pinned / Total 的比例。平时在 0 附近飘,如果持续超过 3%~5%,说明有锁竞争或 Native 阻塞在作妖,赶紧翻日志定位。

另外,虚拟线程的 CPU 使用率通常很低(因为大量时间在等 IO)。HPA 如果还按 CPU 利用率扩缩容,基本会失效。得改成基于请求延迟(P99)或内部队列长度来触发扩缩容。

场景选型与平滑升级路线

虚拟线程确实香,但别什么服务都往里塞。

适合闭眼上的场景: HTTP/REST 网关、Webhook 回调、大量下游 RPC 调用聚合、消息队列消费者。这类服务 IO 等待占比高,切虚拟线程后 P99 延迟通常会掉一半以上,吞吐量翻几倍是常态。

可以上但得调参的场景: 数据库批量读写。收益取决于连接池调优和事务隔离级别。长事务在虚拟线程下依然会占用 DB 连接,该拆的微事务还是得拆。

建议直接放弃的场景: 纯 CPU 计算(视频转码、加密、矩阵运算)、重度依赖 ThreadLocal 且无法重构的老系统、调用包含大量 Native 阻塞 IO 的第三方 SDK。这些场景切虚拟线程不仅没收益,反而会因为调度开销和 Pinning 把服务拖垮。

JDK 21 往上的组合拳建议:ThreadLocal 慢慢替成 ScopedValue,配合即将毕业的结构化并发(StructuredTaskScope)。任务组的失败回滚、上下文传递会变得非常干净,比手写 CompletableFuture 组合省心太多。

企业级升级别搞一刀切。 按这个顺序走最稳:

  1. JDK 升到 21.0.2 以上,避开早期版本的调度 Bug 和 GC 兼容问题。
  2. 代码层先过一遍 ThreadLocalsynchronized,能改的先改。
  3. 网关按 Header 或 IP 切 5% 影子流量到虚拟线程实例。不压并发,先看 P99 延迟波动和 JFR 里的 Pinning 率。
  4. 灰度扩量。先切查询服务、消息消费这类读多写少、无强事务依赖的模块。核心交易链路等连接池行为和事务回滚验证透了再动。
  5. 固化基线。把压测报告和 HPA 策略同步更新,运维告警阈值跟着调整。

虚拟线程把并发复杂度从 OS 拉回了 JVM,代价是开发者得重新审视阻塞、锁和上下文传递的写法。底层调度再怎么进化,线上稳定性永远取决于对边界的把控。别盲从,先在小流量里跑透,再放量。

觉得上面的内容有用吗?快来点个赞吧!

点赞() 我要打赏

温馨提示 : 本站内容来自会员投稿以及互联网,所有源码及教程均为作者总结编辑,请大家在使用过程中提前做好备份,以免发生无法预知的错误,源码类教程请勿直接用于生产环境!

 可能感兴趣的文章