☰
Java虚拟线程(六)
2026/10/3 2:40:06 网站建设 项目流程

Java 虚拟线程(JDK21+)企业落地坑点 & 注意事项

前提:虚拟线程不是银弹;它解决的是「阻塞等待」的平台线程占用,不会自动变快 CPU 密集任务。很多坑来源于原有基于平台线程的 ThreadLocal、池化、监控、中间件、Agent 链路追踪的旧逻辑。

一、ThreadLocal / InheritableThreadLocal 大坑(最高频)

1)普通 ThreadLocal

虚拟线程也是 Thread,可以存 ThreadLocal,但是虚拟线程数量极多(几十万上百万)。

❗坑:如果业务往 ThreadLocal 放大对象,大量虚拟线程存活就会造成内存暴涨、GC 压力飙升。

平台线程池因为线程数固定,ThreadLocal 对象总数可控;虚拟线程每个都是独立 Thread,生命周期随任务,用完最好手动 remove。

2)InheritableThreadLocal不会继承!(重点)

InheritableThreadLocal只在new Thread()创建子线程的时候复制;Executors.newVirtualThreadPerTaskExecutor()提交任务并不会拷贝父线程的 InheritableThreadLocal。

👉 直接后果:

  • SkyWalking、Sleuth 老版本依靠 InheritableThreadLocal 传递 traceId,手动提交虚拟线程任务直接断链路。

✅解决:

  1. 不要依赖 InheritableThreadLocal 做上下文传播;
  2. 业务代码手动把上下文对象拿出来,作为参数传入任务;
  3. Agent 层面包装 Executor(SkyWalking 新版本提供 VirtualThreadExecutor 包装器)。

补充:Tomcat 全局开启虚拟线程(spring.threads.virtual.enabled=true)这种由 Tomcat 创建虚拟线程接收 http 请求的场景,web 过滤器的 ThreadLocal 是本身就在当前虚拟线程,没问题;坑全部来自业务代码手动 newVirtualThreadPerTaskExecutor 提交新虚拟线程。

二、不要池化虚拟线程!(非常关键)

虚拟线程设计目标:每任务一个虚拟线程,不缓存、不池化。

① 它不是线程池!无论局部 new,还是全局 @Bean 单例 new

不管你在哪创建:

//局部 try(var exec = Executors.newVirtualThreadPerTaskExecutor()){} //全局Bean @Bean ExecutorService exec(){return Executors.newVirtualThreadPerTaskExecutor();}

行为完全一样:每一次 submit 就新建 1 个全新虚拟线程;没有任何线程复用。

区别只在于生命周期和close()语义:

  • try‑with‑resources:离开作用域调用close(),阻塞等待所有已经提交任务全部跑完;之后拒绝新 submit。官方样例主要用于本方法内扇出并行、同步等待结果的场景Oracle。
  • 全局单例 Bean:永远不要调用 close ()!一旦 close,整个工厂直接关闭,后续 submit 直接抛 RejectedExecutionException。它的生命周期跟随 Spring 容器直到进程退出。

👉官方样例优先 try‑with‑resources,不等于禁止全局单例工厂对象;只是 try‑with‑resources 适合结构化的局部扇出场景。

② 那这个直接裸暴露的 @Bean 写法到底是对还是错?

@Bean public ExecutorService virtualThreadExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } @Autowired private ExecutorService virtualThreadExecutor;

✅语法合法,编译运行没问题;Spring 本身也有VirtualThreadTaskExecutor(Spring 自己封装的单例工厂)就是干这件事的。 ❌但是业务上极度危险,不建议直接对外暴露原始 ExecutorService 给业务层。

风险点: 业务任意地方注入后,循环(里面) submit;一瞬间提交成千上万任务 → 瞬间生成上万虚拟线程全部冲向 DB/Redis/HTTP 连接池;引发前面聊过的排队堆积、OOM、下游雪崩。

传统平台线程池天然有 max 线程数做闸门;这个 Executor 没有任何闸门,并发上限完全放开。

③ Spring 自己的 VirtualThreadTaskExecutor

✅官方真实结论(SpringBoot3.2+ JDK21+)

spring.threads.virtual.enabled=true一共两块:

1. Tomcat Web 请求线程:无条件生效不管你有没有自定义 Executor Bean,Controller 请求跑虚拟线程,这一块总是开启。

2.@Async(applicationTaskExecutor)是【有条件自动切换】

前置条件:上下文中不存在任何Executor类型 Bean(@ConditionalOnMissingBean(Executor.class))

  • ✅条件满足(项目完全没有自己定义 Executor/TaskExecutor Bean): Boot 自动配置会把applicationTaskExecutor变成SimpleAsyncTaskExecutor(virtual=true),此时 @Async 自动跑虚拟线程!不是只改 Tomcat!⚠️但它默认concurrencyLimit=-1无界,依然有批量任务瞬间创建大量虚拟线程冲击下游的风险;可以用spring.task.execution.simple.concurrency‑limit设置并发上限。
  • ❌条件不满足(只要你项目里自己定义了任意一个ExecutorBean,不管名字、不管是平台线程还是虚拟线程):TaskExecutionAutoConfiguration直接跳过;@Async 就不会自动切虚拟线程,继续使用你自定义的 Bean。此时才需要你手动单独配置 @Async 执行器。

补充:@Scheduled定时任务也是一样逻辑,同样受该开关 +@ConditionalOnMissingBean约束,独立一套SimpleAsyncTaskScheduler。

生产两种正确写法

场景 A:接口内部扇出并行(优先 try‑with‑resources 局部)

try(var exec = Executors.newVirtualThreadPerTaskExecutor()){ var f1 = exec.submit(()->rpcA()); var f2 = exec.submit(()->rpcB()); return List.of(f1.get(),f2.get()); }

场景 B:全局后台异步(封装一层加 Semaphore,不暴露原生 Executor)

@Component public class VtAsyncService { private final ExecutorService vtFactory = Executors.newVirtualThreadPerTaskExecutor(); private final Semaphore semaphore = new Semaphore(40); public <T> Future<T> submit(Supplier<T> task) throws InterruptedException { if(!semaphore.tryAcquire(1,1,TimeUnit.SECONDS)){ throw new RuntimeException("后台任务并发已满"); } return vtFactory.submit(() -> { try { return task.get(); }finally { semaphore.release(); } }); } }

❌不推荐:直接把裸的newVirtualThreadPerTaskExecutor()注册成 @Bean 给整个应用到处注入使用。语法没问题,但工程层面属于高危 API,很容易在后续迭代中写出失控的并发。

三、CPU 密集型任务不适合虚拟线程

虚拟线程的优势:IO 阻塞(DB、RPC、HTTP)的时候,载体平台线程可以逃逸去干别的活。

如果是大量 CPU 计算任务,虚拟线程不会自动并行加速;它会占住载体平台线程不放。

⚠️CPU 密集任务依旧建议扔给固定大小平台线程池(ForkJoinPool)。

误区:全部业务一刀切全部改用虚拟线程。

建议:IO 密集(远程调用、数据库)走虚拟线程;CPU 计算保留平台线程池。

四、synchronized 锁的底层坑(重量级锁会 pin 载体线程!)

JDK21 一个重要坑:虚拟线程进入 synchronized 块的时候,会 pin 住底层载体平台线程(mount pinning)。

pinning 含义:虚拟线程阻塞在 synchronized 里面的时候,JVM不能把这个虚拟线程从载体线程卸载下来;载体线程被占死,失去虚拟线程的优势。

  • 如果 synchronized 内部做很慢的 IO,载体线程就被钉死,退化成普通平台线程效果。

不是说不能用 synchronized,而是synchronized 块里面尽量不要做 IO、远程调用;只做内存内快速操作。

✅替代方案:

优先用java.util.concurrent.locks.ReentrantLock;ReentrantLock 不会发生 pinning,阻塞时可以卸载载体线程。

后续 JDK 版本会逐步改善 pinning,但 JDK21 LTS 仍然存在,生产 JDK21 必须注意。

五、监控、线程 dump 问题

1. 线程 dump (jstack) 会打印全部虚拟线程!

当系统几十万个虚拟线程,jstack 直接输出巨量日志,文件爆炸,甚至 jstack 工具卡顿。

排查建议:尽量不要直接 jstack 全量 dump;使用 JFR(Java Flight Recorder)来观测虚拟线程,JFR 对虚拟线程支持更好。

2.线程名称:默认虚拟线程名字是无意义,排查日志很难定位;提交任务时建议自己命名虚拟线程。

Thread.ofVirtual().name("rpc-batch-"+i).start(task);

3.旧的监控系统:很多监控工具原来基于平台线程指标,线程数指标会暴涨;告警阈值要调整,不能再拿线程总数做告警条件。

虚拟线程总数高≠故障;载体平台线程数量才是关键指标。

六、中间件、SDK 兼容性坑

很多老 SDK 内部做线程池、ThreadLocal 缓存、线程状态判断:

1.部分旧连接池(老版本 HttpClient、旧 redis 客户端)内部假设线程数很少;大量虚拟线程访问可能引发连接池耗尽。

注意:连接池本身的 maxTotal 依然要设置合理;虚拟线程只是调用方,连接资源还是受连接池限制,并不会凭空变出连接。

👉就算你有 100000 个虚拟线程,redis maxTotal=8,同一时刻最多 8 个请求跑,其余全部阻塞等待连接。

2.第三方 Agent(APM 链路追踪 SkyWalking、Pinpoint)

  • Tomcat 自动虚拟线程(spring 配置开启)新版本 agent 支持良好;
  • 业务手动 newVirtualThreadPerTaskExecutor 提交任务,老版本 APM 不会自动包装 Executor,traceId 断链!

解决方案:

①升级 APM 到新版本;

②自己包装 Executor,手动传递上下文;

③SkyWalking 提供 agent 参数开启虚拟线程 executor 增强。

七、SpringBoot 场景专属坑(spring.threads.virtual.enabled=true 全局开启)

1. 仅 Tomcat 的 http 工作线程变成虚拟线程;@Async 默认还是平台线程池!

很多人误以为打开全局开关后 @Async 自动跑虚拟线程,并不会!

@Async 仍然使用默认的 ThreadPoolTaskExecutor 平台线程池;

如果希望 @Async 跑虚拟线程,需要自己自定义 AsyncTaskExecutor 返回虚拟线程执行器。

@Bean public AsyncTaskExecutor virtualTaskExecutor(){ return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); }

2.Web 请求控制器已经跑在虚拟线程里;控制器内部再手动开一批虚拟线程做并行 rpc 是可以的,但记得处理上下文传递 traceId。

八、异常与资源关闭坑 try‑with‑resources

Executors.newVirtualThreadPerTaskExecutor()返回的 ExecutorService 实现了 AutoCloseable。

// try‑with‑resources离开作用域会自动await所有任务全部完成,才退出 try(var exec = Executors.newVirtualThreadPerTaskExecutor()){ exec.submit(task1); exec.submit(task2); } // 这里会阻塞,等待全部任务结束!

❗很多新手踩坑:以为 try 块退出 executor 只是关闭,不会等待;实际上close () 会等待全部任务执行完成。

如果想要后台 “fire‑and‑forget” 异步任务,不要用 try‑with‑resources;直接用 Thread.ofVirtual ().start () 启动,不要包在 try 资源块。

九、总结一张落地检查清单(上线前自查)

  1. ❌禁止池化虚拟线程;并发限流改用 Semaphore;
  2. ❌synchronized 块内部不要放 IO;IO 锁改用 ReentrantLock;
  3. ❌不要依赖 InheritableThreadLocal 自动继承上下文 (traceId);手动传参或者 APM 新版增强 Executor;
  4. ❌ThreadLocal 用完尽量 remove,防止海量虚拟线程堆大对象内存泄漏;
  5. ❌CPU 密集任务不要交给虚拟线程;保留固定平台线程池;
  6. ⚠️jstack 慎用,优先 JFR 观测;调整监控告警(不要告警总线程数);
  7. ⚠️try‑with‑resources 包装 VirtualThreadPerTaskExecutor 会阻塞等待所有任务结束;
  8. ⚠️中间件连接池配置依旧要管控,虚拟线程不会突破连接池上限。



旧连接池 + 海量虚拟线程:会发生什么 & 解决方案

先厘清本质:

连接池本身有最大连接上限maxTotal。

虚拟线程本身不会创建更多连接;但是虚拟线程可以一瞬间生成成千上万个阻塞的调用方去抢连接池。

平台线程池场景:调用线程总数本身就少;虚拟线程场景:调用者可以瞬间膨胀到几万。

一、会引发哪些严重线上问题

1. 大量虚拟线程全部阻塞在连接池内部的等待队列(最常见)

当maxTotal=10,瞬间来了 5000 个虚拟线程去拿连接:

  • 10 个拿到连接执行 RPC/Redis;
  • 剩下4990 个虚拟线程全部卡在连接池的内部等待队列,park 阻塞,排队等待归还连接。

✅现象 1:JVM 线程总数飙升到 5k+(全部是虚拟线程);JVM 载体平台线程本身并不高。 ✅现象 2:业务接口响应时间变长,大量排队超时;但是 CPU、载体线程并不高,看平台线程指标看不出异常,问题很难排查。

✅现象 3:连接池的等待队列积压持续上涨;监控如果没采集连接池等待数,告警完全不会触发。

⚠️这还不是最恶劣;这只是排队。

2. 部分老旧连接池:没有阻塞等待,直接拒绝获取连接

一些老版本 HttpClient / 老 jedis 版本的逻辑:

获取连接时,如果池已满,不阻塞等待,直接抛出NoSuchElementException/pool exhausted 异常

后果:高并发下直接业务报错,大量请求直接失败,并不是排队,直接雪崩。

老 Jedis(2.x)就有这个坑;老 Apache HttpClient 3.x。

3. 更危险:连接池内部的等待队列无界(内存泄漏 / OOM 风险)

重点坑:部分旧连接池获取连接时,等待队列是无界 LinkedList。

当上万虚拟线程同时抢连接:

每一个排队的虚拟线程,以及任务栈、局部变量都驻留在内存;无界队列不断保存等待请求对象。

队列持续堆积 →堆内存持续上涨,最终 OOM。

平台线程池时代不会触发:因为调用线程本身就几十,排队队列不会涨很大;虚拟线程把这个缺陷暴露出来。

4. 连锁超时雪崩

上游网关设置了接口超时;大量虚拟线程排队拿不到连接,一直阻塞;

还没拿到连接,http 客户端就已经超时。

释放连接的时候任务已经超时作废,白白占用连接;恶性循环。

5. 容易误判问题

排查迷惑点:

  • 操作系统线程数(平台线程)很低,CPU 不高;
  • 但是业务 RT 暴涨,报错增多;
  • jstack 直接 dump 会打印成千上万阻塞的虚拟线程,日志爆炸;

很多运维看平台线程指标以为系统空闲,实际已经堵死在连接池排队。


二、区分根因

并不是虚拟线程有 bug;是老连接池的假设前提:「调用线程数量是少量可控的(来自平台线程池)」。 虚拟线程打破了这个前置假设:调用者可以瞬间成千上万个。

新的连接池(apache httpclient4.5+, lettuce, hikaricp)是设计了有界等待 + 获取超时,相对友好; 老 Jedis2.x、HttpClient3.x 风险最高。

三、解决方案(分优先级)

✅方案 1:升级客户端(优先,根治)

优先升级到新版本客户端:

  • Redis:放弃 Jedis2.x → Jedis3.x+ 或者直接用 Lettuce(异步 native,对虚拟线程友好)
  • Http:Apache HttpClient 升级到 4.5.x/5.x;不要用 3.x;Okhttp3/4

新版本特性:

  1. 获取连接支持获取等待超时(borrowTimeout),拿不到连接直接快速失败,不无限排队;
  2. 内部等待队列是有界;防止无界队列堆积;
  3. 对大量调用线程做过适配。

HikariCP 本身很早就考虑大量线程争抢,borrowTimeout 强制设置,数据库这块一般问题不大。

✅方案 2:业务侧增加一层信号量 Semaphore 做上游限流(非常关键!业务层防护)

即使连接池内部有 borrowTimeout;依然建议上层虚拟线程任务外面再加一层 Semaphore。

作用:在进入连接池之前就限制并发请求数,不要放任上万虚拟线程全部冲到连接池去抢资源。

信号量的许可数 ≤ 连接池 maxTotal,不要超过连接池上限。

示例伪代码:

// sem许可数 <= redis/http连接池的maxTotal private static final Semaphore SEMAPHORE = new Semaphore(30); public RpcResp callRemote(){ try { // 在调用客户端之前先拿许可 SEMAPHORE.acquire(); // 执行redis / http rpc return httpClient.doGet(url); }finally { SEMAPHORE.release(); } }

上层先拦住并发,就不会有成千上万任务冲进连接池排队。

注意:acquire 是阻塞;也可以用 tryAcquire (timeout),拿不到立刻返回业务快速失败,避免长时间阻塞。

✅方案 3:连 接池参数本身的配置优化(不能只靠这个,配合上面)

1. 设置borrow 获取超时(从池拿连接的超时),不等于业务请求超时;

拿连接的超时时间要设置短,不要无限等待。

  • HikariCP:connectionTimeout
  • Apache HttpClient:connectionRequestTimeout
  • Jedis:maxWait

目的:拿不到连接快速抛异常,不要在连接池内部无限堆积等待任务。

2. maxTotal 不要盲目调大;连接本身是远端服务资源,受下游服务的承载限制,不能一味放大。

✅方案 4:监控补充(一定要新增监控指标)

原来平台线程时代很多团队忽略这组指标;虚拟线程环境必须采集:

  1. 连接池等待获取连接的排队数量;
  2. 连接池 borrow 等待时长;

不要用操作系统平台线程总数作为并发判断依据!这个指标已经失效。重点监控:连接池等待队列、borrow 耗时、业务层面 Semaphore 等待耗时。

❌错误做法

  1. 一味调大 maxTotal:下游服务扛不住,把压力全部打垮下游;治标不治本。
  2. 放任海量虚拟线程直接裸奔访问旧连接池;寄希望连接池自己限流。

四、总结落地策略

完整防护链路:

虚拟线程任务 →【业务层Semaphore限流】 → 连接池(设置borrow获取超时) → 下游服务

  1. 优先升级所有 http、redis 客户端版本,淘汰老旧 SDK;
  2. IO 调用入口增加 Semaphore 做前置并发管控,许可数不超过连接池 maxTotal;
  3. 强制配置连接池 borrow 超时,快速失败;
  4. 补充连接池等待队列监控;
  5. 排查时优先看连接池 borrow 指标,不要再看平台线程数量判断系统压力。

补充一句话版:

虚拟线程允许瞬时生成大量调用者冲向连接池;老旧连接池无界等待队列会堆积大量阻塞虚拟线程,引发 RT 飙升甚至 OOM;不能靠虚拟线程自己限流;上层业务必须加 Semaphore 前置拦截,同时升级客户端、配置 borrow 超时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询