10000 个并发任务,虚拟线程 1 秒 vs 传统线程池 50 秒,差距在哪?
大家好,我是Java1234_小锋老师。
同样是让 10000 个任务各等待 1 秒,固定大小为 200 的传统线程池大约要 50 秒,虚拟线程却可能只用 1~2 秒。它并不是把 CPU 变快了,而是让“等待”不再白白占着宝贵的平台线程。
先说结论
这个标题里的“1 秒”和“50 秒”,有一个很重要的前提:任务大部分时间都在等待,例如等待数据库、远程接口、文件或者消息返回。
如果 10000 个任务都要等待 1 秒,传统线程池只有 200 个线程,那么它只能分 50 批处理:
10000 ÷ 200 × 1 秒 = 50 秒虚拟线程则可以近似做到“一个任务一个线程”。当某个任务开始等待时,它会暂时让出底层平台线程,让平台线程继续执行别的任务。因此,10000 个等待可以大量重叠,总耗时会接近单次等待时间,而不是把每一批的时间累加起来。
不过,这不是一条通用性能定律。真实耗时会受到机器配置、JDK 版本、数据库连接池和下游接口承载能力等因素影响。
为什么会出现 1 秒和 50 秒的差距
传统 Java 线程通常会对应到操作系统线程。操作系统线程并不轻:创建它需要内存,切换它也有成本,所以项目里一般会使用固定大小的线程池。
线程池能控制资源,却也形成了“窗口数量”的限制。200 个窗口同时只能接待 200 个任务,剩下的任务只能排队。
虚拟线程由 JVM 管理,比平台线程轻得多。我们可以创建成千上万个虚拟线程,再由少量平台线程负责真正执行。它更像是给每位顾客发一个号码牌:顾客等待资料时,窗口可以先服务其他人,不必陪着一起等。
用一段代码实际对比
下面的示例需要JDK 21 或更高版本。Thread.sleep(1000)用来模拟一次耗时 1 秒的网络或数据库请求。
importjava.time.Duration;importjava.time.Instant;importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;importjava.util.concurrent.TimeUnit;/** * 传统线程池与虚拟线程吞吐量对比示例。 */publicclassVirtualThreadBenchmark{privatestaticfinalintTASK_COUNT=10_000;/** * 程序入口:分别执行两种线程模型。 */publicstaticvoidmain(String[]args)throwsInterruptedException{run("传统固定线程池",Executors.newFixedThreadPool(200));run("虚拟线程",Executors.newVirtualThreadPerTaskExecutor());}/** * 提交任务并统计全部任务完成所需的时间。 * * @param name 测试名称 * @param executor 待测试的执行器 */privatestaticvoidrun(Stringname,ExecutorServiceexecutor)throwsInterruptedException{Instantstart=Instant.now();try(executor){for(inti=0;i<TASK_COUNT;i++){executor.submit(()->{try{// 模拟等待远程接口、数据库或文件返回Thread.sleep(1_000);}catch(InterruptedExceptionexception){// 恢复中断标记,交给上层决定如何处理Thread.currentThread().interrupt();}});}executor.shutdown();executor.awaitTermination(2,TimeUnit.MINUTES);}longelapsed=Duration.between(start,Instant.now()).toMillis();System.out.printf("%s:%.2f 秒%n",name,elapsed/1_000.0);}}一次典型的测试结果可能是:
传统固定线程池:50.18 秒 虚拟线程:1.12 秒这里快的不是sleep本身,而是 10000 次等待几乎同时发生了。若把任务换成复杂计算,例如压缩视频或计算哈希,虚拟线程通常不会带来这种数量级的提升,因为 CPU 依然只有那么多核心。
虚拟线程到底做了什么
可以把平台线程看作真正干活的“工人”,虚拟线程则是轻量的“任务单”。
当虚拟线程执行普通 Java 代码时,JVM 会把它挂载到某个平台线程上。遇到支持虚拟线程的阻塞操作后,虚拟线程会被卸载,平台线程转身处理其他任务。等数据返回,原来的虚拟线程再找合适的平台线程继续运行。
整个过程对业务代码很友好:我们仍然可以按照从上到下的同步写法编程,不需要为了提高并发量,把代码拆成许多回调。
适合放到哪些业务里
虚拟线程最适合“请求很多、等待很多、计算不重”的场景,例如:
- 调用多个远程接口:订单详情需要同时查询商品、库存、优惠和物流。
- 数据库访问:大量请求都在等待 SQL 返回,但要注意连接池仍然有容量上限。
- 批量文件处理:读取大量小文件,或者上传、下载对象存储中的文件。
- 消息消费和网关服务:每个任务逻辑不复杂,却经常等待网络响应。
例如,同时查询三个远程服务时,可以直接为每个查询创建一个虚拟线程:
importjava.util.concurrent.Executors;importjava.util.concurrent.Future;/** * 使用虚拟线程并行查询订单相关信息。 */publicclassOrderDetailService{/** * 并行获取订单、库存和物流信息。 * * @return 汇总后的订单详情 */publicStringloadOrderDetail()throwsException{try(varexecutor=Executors.newVirtualThreadPerTaskExecutor()){Future<String>order=executor.submit(()->queryOrder());Future<String>stock=executor.submit(()->queryStock());Future<String>delivery=executor.submit(()->queryDelivery());returnorder.get()+","+stock.get()+","+delivery.get();}}/** 查询订单信息。 */privateStringqueryOrder()throwsInterruptedException{Thread.sleep(300);return"订单信息";}/** 查询库存信息。 */privateStringqueryStock()throwsInterruptedException{Thread.sleep(400);return"库存信息";}/** 查询物流信息。 */privateStringqueryDelivery()throwsInterruptedException{Thread.sleep(500);return"物流信息";}}三个查询顺序执行大约需要 1.2 秒,并行后则接近最慢的那一次,也就是 0.5 秒。
虚拟线程也不是万能药
使用前还要留意几件事:
- 它不能突破下游限制。数据库连接池只有 50 个连接,创建 10000 个虚拟线程也不会变出更多连接。
- CPU 密集任务不会凭空变快。计算量没减少,过多并发反而可能增加调度开销。
- 不要把虚拟线程池化。虚拟线程本身很轻,通常直接按任务创建;需要限制并发时,应使用信号量等方式保护下游资源。
- 压测要贴近真实业务。
sleep只是帮助理解原理,生产环境还要观察超时、内存、连接数和错误率。
最后总结
“虚拟线程 1 秒,传统线程池 50 秒”的核心,不是虚拟线程拥有更强的计算能力,而是它大幅降低了线程的使用成本,让大量 I/O 等待可以同时发生。
对于高并发、I/O 密集型 Java 服务,虚拟线程让我们既能保留清晰的同步代码,又能获得接近异步编程的吞吐能力。升级到 JDK 21 后,它很值得放进真实接口和压测环境里试一试——但别只盯着线程数量,也要一起检查数据库、连接池和下游服务能不能接得住。
