ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java虚拟线程(六)

Java虚拟线程(六) Java 虚拟线程JDK21企业落地坑点 注意事项前提虚拟线程不是银弹它解决的是「阻塞等待」的平台线程占用不会自动变快 CPU 密集任务。很多坑来源于原有基于平台线程的 ThreadLocal、池化、监控、中间件、Agent 链路追踪的旧逻辑。一、ThreadLocal / InheritableThreadLocal 大坑最高频1普通 ThreadLocal虚拟线程也是 Thread可以存 ThreadLocal但是虚拟线程数量极多几十万上百万。❗坑如果业务往 ThreadLocal 放大对象大量虚拟线程存活就会造成内存暴涨、GC 压力飙升。平台线程池因为线程数固定ThreadLocal 对象总数可控虚拟线程每个都是独立 Thread生命周期随任务用完最好手动 remove。2InheritableThreadLocal不会继承重点InheritableThreadLocal只在new Thread()创建子线程的时候复制Executors.newVirtualThreadPerTaskExecutor()提交任务并不会拷贝父线程的 InheritableThreadLocal。 直接后果SkyWalking、Sleuth 老版本依靠 InheritableThreadLocal 传递 traceId手动提交虚拟线程任务直接断链路。✅解决不要依赖 InheritableThreadLocal 做上下文传播业务代码手动把上下文对象拿出来作为参数传入任务Agent 层面包装 ExecutorSkyWalking 新版本提供 VirtualThreadExecutor 包装器。补充Tomcat 全局开启虚拟线程spring.threads.virtual.enabledtrue这种由 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 本身也有VirtualThreadTaskExecutorSpring 自己封装的单例工厂就是干这件事的。 ❌但是业务上极度危险不建议直接对外暴露原始 ExecutorService 给业务层。风险点 业务任意地方注入后循环里面 submit一瞬间提交成千上万任务 → 瞬间生成上万虚拟线程全部冲向 DB/Redis/HTTP 连接池引发前面聊过的排队堆积、OOM、下游雪崩。传统平台线程池天然有 max 线程数做闸门这个 Executor 没有任何闸门并发上限完全放开。③ Spring 自己的 VirtualThreadTaskExecutor✅官方真实结论SpringBoot3.2 JDK21spring.threads.virtual.enabledtrue一共两块1. Tomcat Web 请求线程无条件生效不管你有没有自定义 Executor BeanController 请求跑虚拟线程这一块总是开启。2.AsyncapplicationTaskExecutor是【有条件自动切换】前置条件上下文中不存在任何Executor类型 BeanConditionalOnMissingBean(Executor.class)✅条件满足项目完全没有自己定义 Executor/TaskExecutor Bean Boot 自动配置会把applicationTaskExecutor变成SimpleAsyncTaskExecutor(virtualtrue)此时 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不暴露原生 ExecutorComponent public class VtAsyncService { private final ExecutorService vtFactory Executors.newVirtualThreadPerTaskExecutor(); private final Semaphore semaphore new Semaphore(40); public T FutureT submit(SupplierT 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.ReentrantLockReentrantLock 不会发生 pinning阻塞时可以卸载载体线程。后续 JDK 版本会逐步改善 pinning但 JDK21 LTS 仍然存在生产 JDK21 必须注意。五、监控、线程 dump 问题1. 线程 dump (jstack) 会打印全部虚拟线程当系统几十万个虚拟线程jstack 直接输出巨量日志文件爆炸甚至 jstack 工具卡顿。排查建议尽量不要直接 jstack 全量 dump使用 JFRJava Flight Recorder来观测虚拟线程JFR 对虚拟线程支持更好。2.线程名称默认虚拟线程名字是无意义排查日志很难定位提交任务时建议自己命名虚拟线程。Thread.ofVirtual().name(rpc-batch-i).start(task);3.旧的监控系统很多监控工具原来基于平台线程指标线程数指标会暴涨告警阈值要调整不能再拿线程总数做告警条件。虚拟线程总数高≠故障载体平台线程数量才是关键指标。六、中间件、SDK 兼容性坑很多老 SDK 内部做线程池、ThreadLocal 缓存、线程状态判断1.部分旧连接池老版本 HttpClient、旧 redis 客户端内部假设线程数很少大量虚拟线程访问可能引发连接池耗尽。注意连接池本身的 maxTotal 依然要设置合理虚拟线程只是调用方连接资源还是受连接池限制并不会凭空变出连接。就算你有 100000 个虚拟线程redis maxTotal8同一时刻最多 8 个请求跑其余全部阻塞等待连接。2.第三方 AgentAPM 链路追踪 SkyWalking、PinpointTomcat 自动虚拟线程spring 配置开启新版本 agent 支持良好业务手动 newVirtualThreadPerTaskExecutor 提交任务老版本 APM 不会自动包装 ExecutortraceId 断链解决方案①升级 APM 到新版本②自己包装 Executor手动传递上下文③SkyWalking 提供 agent 参数开启虚拟线程 executor 增强。七、SpringBoot 场景专属坑spring.threads.virtual.enabledtrue 全局开启1. 仅 Tomcat 的 http 工作线程变成虚拟线程Async 默认还是平台线程池很多人误以为打开全局开关后 Async 自动跑虚拟线程并不会Async 仍然使用默认的 ThreadPoolTaskExecutor 平台线程池如果希望 Async 跑虚拟线程需要自己自定义 AsyncTaskExecutor 返回虚拟线程执行器。Bean public AsyncTaskExecutor virtualTaskExecutor(){ return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); }2.Web 请求控制器已经跑在虚拟线程里控制器内部再手动开一批虚拟线程做并行 rpc 是可以的但记得处理上下文传递 traceId。八、异常与资源关闭坑 try‑with‑resourcesExecutors.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 资源块。九、总结一张落地检查清单上线前自查❌禁止池化虚拟线程并发限流改用 Semaphore❌synchronized 块内部不要放 IOIO 锁改用 ReentrantLock❌不要依赖 InheritableThreadLocal 自动继承上下文 (traceId)手动传参或者 APM 新版增强 Executor❌ThreadLocal 用完尽量 remove防止海量虚拟线程堆大对象内存泄漏❌CPU 密集任务不要交给虚拟线程保留固定平台线程池⚠️jstack 慎用优先 JFR 观测调整监控告警不要告警总线程数⚠️try‑with‑resources 包装 VirtualThreadPerTaskExecutor 会阻塞等待所有任务结束⚠️中间件连接池配置依旧要管控虚拟线程不会突破连接池上限。旧连接池 海量虚拟线程会发生什么 解决方案先厘清本质连接池本身有最大连接上限maxTotal。虚拟线程本身不会创建更多连接但是虚拟线程可以一瞬间生成成千上万个阻塞的调用方去抢连接池。平台线程池场景调用线程总数本身就少虚拟线程场景调用者可以瞬间膨胀到几万。一、会引发哪些严重线上问题1. 大量虚拟线程全部阻塞在连接池内部的等待队列最常见当maxTotal10瞬间来了 5000 个虚拟线程去拿连接10 个拿到连接执行 RPC/Redis剩下4990 个虚拟线程全部卡在连接池的内部等待队列park 阻塞排队等待归还连接。✅现象 1JVM 线程总数飙升到 5k全部是虚拟线程JVM 载体平台线程本身并不高。 ✅现象 2业务接口响应时间变长大量排队超时但是 CPU、载体线程并不高看平台线程指标看不出异常问题很难排查。✅现象 3连接池的等待队列积压持续上涨监控如果没采集连接池等待数告警完全不会触发。⚠️这还不是最恶劣这只是排队。2. 部分老旧连接池没有阻塞等待直接拒绝获取连接一些老版本 HttpClient / 老 jedis 版本的逻辑获取连接时如果池已满不阻塞等待直接抛出NoSuchElementException/pool exhausted 异常后果高并发下直接业务报错大量请求直接失败并不是排队直接雪崩。老 Jedis2.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对虚拟线程友好HttpApache HttpClient 升级到 4.5.x/5.x不要用 3.xOkhttp3/4新版本特性获取连接支持获取等待超时borrowTimeout拿不到连接直接快速失败不无限排队内部等待队列是有界防止无界队列堆积对大量调用线程做过适配。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:connectionTimeoutApache HttpClient:connectionRequestTimeoutJedis:maxWait目的拿不到连接快速抛异常不要在连接池内部无限堆积等待任务。2. maxTotal 不要盲目调大连接本身是远端服务资源受下游服务的承载限制不能一味放大。✅方案 4监控补充一定要新增监控指标原来平台线程时代很多团队忽略这组指标虚拟线程环境必须采集连接池等待获取连接的排队数量连接池 borrow 等待时长不要用操作系统平台线程总数作为并发判断依据这个指标已经失效。重点监控连接池等待队列、borrow 耗时、业务层面 Semaphore 等待耗时。❌错误做法一味调大 maxTotal下游服务扛不住把压力全部打垮下游治标不治本。放任海量虚拟线程直接裸奔访问旧连接池寄希望连接池自己限流。四、总结落地策略完整防护链路虚拟线程任务 →【业务层Semaphore限流】 → 连接池设置borrow获取超时 → 下游服务优先升级所有 http、redis 客户端版本淘汰老旧 SDKIO 调用入口增加 Semaphore 做前置并发管控许可数不超过连接池 maxTotal强制配置连接池 borrow 超时快速失败补充连接池等待队列监控排查时优先看连接池 borrow 指标不要再看平台线程数量判断系统压力。补充一句话版虚拟线程允许瞬时生成大量调用者冲向连接池老旧连接池无界等待队列会堆积大量阻塞虚拟线程引发 RT 飙升甚至 OOM不能靠虚拟线程自己限流上层业务必须加 Semaphore 前置拦截同时升级客户端、配置 borrow 超时。
返回列表