尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Java 21 虚拟线程实战:百万并发不炸内存、阻塞代码免改造与三个坑

Java 21 虚拟线程实战:百万并发不炸内存、阻塞代码免改造与三个坑 Java 21 虚拟线程实战:百万并发不炸内存、阻塞代码免改造与三个坑你的服务要同时处理一万个请求,每个请求里都要调一次下游 HTTP 接口、查一次数据库,都是阻塞 IO。用传统线程池,你会陷入两难:线程池开小了(比如 200),第 201 个请求排队等着,吞吐上不去;开大了(比如 10000),每个平台线程默认吃 1MB 栈内存,光线程就吃掉 10GB,还没算 GC 和上下文切换的开销。Java 21 正式发布的虚拟线程(Virtual Threads,JEP 444)就是来破这个局的:让你用最直白的一个请求一个线程、该阻塞就阻塞的写法,却能扛住百万级并发。平台线程为什么扛不住高并发阻塞传统的Thread是平台线程,一对一映射到操作系统内核线程。内核线程贵在两点:一是每个默认预留约 1MB 栈,二是创建/切换要陷入内核。所以你不敢多开,只能用线程池复用有限的几百个。而这几百个线程处理阻塞 IO 时,大部分时间都在read()上干等——线程被 OS 挂起,CPU 闲着,但线程这个坑位被占着,新请求进不来。本质矛盾是:阻塞的是 IO,却拖住了昂贵的内核线程。虚拟线程:阻塞时把内核线程还回去虚拟线程由 JVM 调度,多个虚拟线程复用少量平台线程(叫载体线程 carrier thread)。关键机制是:当虚拟线程执行到阻塞 IO 时,JVM 会把它从载体线程上卸载,让出载体线程去跑别的虚拟线程;IO 就绪后再挂回来继续。于是你可以创建几十万个虚拟线程,它们大部分时间处于卸载状态,只占极小的堆内存(几百字节起,按需增长),真正跑的载体线程还是那几个(默认等于 CPU 核数)。创建方式很简单:// 单个虚拟线程ThreadvtThread.ofVirtual().start(()-{System.out.println(跑在虚拟线程: Thread.currentThread());});vt.join();实战:一万个并发任务,一行代码搞定真正好用的是配合Executors.newVirtualThreadPerTaskExecutor()——它不是池,而是每个任务开一个新虚拟线程,任务完了线程就没了。因为虚拟线程太便宜,根本不需要复用:importjava.util.concurrent.*;importjava.util.stream.*;publicclassDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{// 关键:每个任务一个虚拟线程,不设上限try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){varfuturesIntStream.range(0,10_000).mapToObj(i-executor.submit(()-fetchUser(i))).toList();for(varf:futures){f.get();// 收集结果}}// try-with-resources 会等所有任务跑完再关System.out.println(一万个请求处理完毕);}staticStringfetchUser(intid){try{Thread.sleep(200);// 模拟一次 200ms 的阻塞 IO(HTTP/DB)}catch(InterruptedExceptione){Thread.currentThread().interrupt();}returnuser-id;}}一万个任务每个阻塞 200ms,用虚拟线程几乎是并行跑完(总耗时接近 200ms 多一点),内存却只涨一点点。换成 200 线程的传统池,得跑10000/200 × 200ms 10s。这就是量级差异。注意用try-with-resources包住 executor,退出时会自动等所有任务结束(相当于隐式shutdownawaitTermination),不用手动 join。最省事的地方:阻塞代码一行不用改虚拟线程最大的诚意在于:你不需要把代码改写成CompletableFuture那种回调/链式的异步风格。原来那些jdbcTemplate.query(...)、httpClient.send(...)、inputStream.read()全部照写,JVM 会在它们阻塞时自动帮你卸载载体线程。同步的写法,异步的性能。Spring Boot 3.2 只要一个配置就能让整个 Web 请求跑在虚拟线程上:spring.threads.virtual.enabledtrue三个必须知道的坑坑一:synchronized 里的阻塞会钉住载体线程(pinning)。如果虚拟线程在synchronized块内部执行阻塞操作,它没法卸载,会把载体线程一起钉死,退化成平台线程的行为。高并发下钉住的多了,载体线程被占光,吞吐反而崩。修法是把synchronized换成ReentrantLock:// 有 pinning 风险synchronized(lock){callRemote();// 阻塞时钉住载体线程}// 推荐:ReentrantLock 阻塞时可以正常卸载lock.lock();try{callRemote();}finally{lock.unlock();}(注:较新的 JDK 版本正在逐步消除 synchronized 的 pinning,但线上跑什么版本要确认,保险起见热点路径优先用 ReentrantLock。)坑二:别再给虚拟线程做池化。传统思路是线程贵所以要池化复用,虚拟线程恰恰相反——它便宜到用完即弃。给它套个固定大小的池(比如newFixedThreadPool),等于人为设了并发上限,把它最大的优势掐掉了。就用newVirtualThreadPerTaskExecutor。坑三:ThreadLocal 要当心内存放大。过去线程数有限,ThreadLocal存点东西无所谓。现在动辄几十万虚拟线程,每个都存一份大对象,内存会被放大几个数量级。虚拟线程场景下优先考虑用ScopedValue(JDK 新增的、面向结构化并发的不可变上下文传递),或者干脆显式传参。小结虚拟线程(Java 21 正式版)由JVM 调度,阻塞 IO 时自动卸载载体线程,让你用同步写法扛住百万并发。用Executors.newVirtualThreadPerTaskExecutor(),每任务一线程、用完即弃,别再池化。最大红利:阻塞代码一行不用改;Spring Boot 3.2 加spring.threads.virtual.enabledtrue即可全局开启。三个坑:synchronized内阻塞会pinning(换ReentrantLock)、别池化、ThreadLocal内存放大(考虑ScopedValue)。记忆点:线程不再是稀缺资源——该阻塞就阻塞,别自己搞异步回调那一套了。
返回列表