JDK21:虚拟线程入门:原理与基本使用 📅 2026/7/11 21:19:58 前言Java 后端开发中线程池开多少线程合适是一个永恒的话题。开少了吞吐不够开多了内存撑不住。本质原因在于Java 的线程长期以来和操作系统线程是 1:1 绑定的每个线程都要占用 ~1MB 的栈内存几百个就是几百 MB几千个就可能 OOM。JDK 21 正式引入的虚拟线程Virtual Threads从根本上改变了这个局面——一个 JVM 可以轻松创建数十万甚至百万个虚拟线程每个只占几 KB 内存而且写法和传统线程几乎一样。本文是虚拟线程系列的第一篇聚焦于它是什么、为什么需要它、底层怎么调度、怎么创建使用、什么场景适合 / 不适合。不涉及复杂的生产踩坑后续篇章展开目标是建立对虚拟线程的完整认知。一、传统线程模型的天花板1.1 一个线程 一个 OS 线程在 JDK 21 之前Java 的Thread本质是对操作系统线程的封装称为平台线程 / Platform Thread。每创建一个new Thread()操作系统就会分配一个真实的内核线程。Java Thread ←→ OS Thread1:1 ↓ 栈内存默认 1MB 创建开销~1ms系统调用 上下文切换~1-10μs内核态1.2 为什么不能无限开线程假设你的服务是 IO 密集型的查数据库、调下游接口单个请求处理耗时 200ms其中 180ms 在等待网络Tomcat 默认最大 200 线程 → 最大并发请求数 200 → 理论 QPS 200 / 0.2s 1000 想要 QPS 5000需要 1000 个线程 → 栈内存1000 × 1MB 1GB光栈就吃掉 1GB → 还没算堆内对象、GC 压力、上下文切换开销这就是传统模型的天花板线程数受限于内存而 IO 密集型任务中线程大部分时间在等白白占着内存什么都不干。1.3 现有的解法和局限方案思路局限线程池FixedThreadPool复用有限的线程并发数受限于池大小异步回调CompletableFuture不阻塞线程回调处理代码复杂调试困难响应式编程WebFlux/Reactor事件驱动非阻塞学习曲线陡峭生态迁移成本高这些方案本质上都是在绕开线程贵这个问题而不是解决它。虚拟线程的思路完全不同让线程变得便宜便宜到不需要池化用完即弃。二、虚拟线程的核心原理2.1 一句话定义虚拟线程是由 JVM 管理的轻量级线程不与操作系统线程 1:1 绑定多个虚拟线程共享少量的平台线程载体线程执行。2.2 调度模型┌────────────────────────────────────────────────────────┐ │ JVM │ │ │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ... ┌────┐ │ │ │VT-1│ │VT-2│ │VT-3│ │VT-4│ │VT-5│ │VT-N│ │ │ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ │ │ │ │ │ │ │ │ │ │ └──────┴──┬───┴──────┴───┬──┘ │ │ │ │ │ │ │ │ ┌────▼────┐ ┌────▼────┐ │ │ │ │载体线程-1│ │载体线程-2│ ... │ │ │ │(Platform)│ │(Platform)│ │ │ │ └────┬────┘ └────┬────┘ │ │ │ │ │ │ │ └───────────────┼──────────────┼─────────────────┘ │ │ │ │ ┌────▼────┐ ┌────▼────┐ │ │OS Thread│ │OS Thread│ │ └─────────┘ └─────────┘ │核心机制载体线程Carrier Thread数量很少默认等于 CPU 核数是真正的 OS 线程挂载Mount虚拟线程获得载体线程开始执行代码卸载Unmount虚拟线程遇到阻塞 IO网络等待、Thread.sleep、Lock.lock时自动从载体线程上卸下载体线程立刻去服务其他虚拟线程重新挂载IO 完成后虚拟线程排队等待被再次挂载到某个载体线程上继续执行2.3 为什么能开百万个对比项平台线程虚拟线程内存占用~1MB固定栈~几 KB栈随用随长创建开销~1ms系统调用~1μs纯用户态数量上限数千个就吃紧轻松百万阻塞代价占住一个 OS 线程只是暂停不占任何 OS 资源调度OS 内核态调度JVM 用户态调度ForkJoinPool关键理解虚拟线程的栈不是固定分配 1MB而是按需增长的栈块链表Stack Chunks初始只有几百字节随调用深度增加动态分配。大多数 IO 密集型任务的栈深度很浅所以实际内存远小于平台线程。2.4 一个形象的比喻把载体线程想象成出租车虚拟线程想象成乘客传统模型每个乘客包一辆车乘客在商场购物时IO等待车也在门口空等虚拟线程模型乘客下车逛商场车立刻去接下一位乘客。乘客逛完了再叫一辆车继续车的数量载体线程很少但乘客虚拟线程可以有几十万。三、虚拟线程的创建方式下面这个完整可运行的示例把四种创建方式和如何判断虚拟线程都串在一起复制到 IDEAJDK 21即可直接运行。importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;importjava.util.concurrent.ThreadFactory;publicclassVirtualThreadCreateDemo{publicstaticvoidmain(String[]args)throwsException{// 方式一Thread.startVirtualThread —— 最简单一行启动Threadvt1Thread.startVirtualThread(()-System.out.println([方式一] Thread.currentThread() isVirtualThread.currentThread().isVirtual()));vt1.join();// 方式二Thread.ofVirtual() 构建器 —— 可设置线程名方便排查Threadvt2Thread.ofVirtual().name(my-worker).start(()-System.out.println([方式二] Thread.currentThread()));vt2.join();// 方式三Executors.newVirtualThreadPerTaskExecutor —— 生产推荐try(ExecutorServiceexecutorExecutors.newVirtualThreadPerTaskExecutor()){for(inti0;i3;i){inttaskIdi;executor.submit(()-System.out.println([方式三] task-taskId 运行于 Thread.currentThread()));}}// try-with-resources 结束时会等待所有任务完成// 方式四ThreadFactory —— 适合需要传入工厂的框架场景ThreadFactoryfactoryThread.ofVirtual().name(worker-,0).factory();Threadvt4factory.newThread(()-System.out.println([方式四] Thread.currentThread()));vt4.start();vt4.join();// 判断当前线程是否是虚拟线程System.out.println([主线程] Thread.currentThread() isVirtualThread.currentThread().isVirtual());}}运行结果[方式一] VirtualThread[#21]/runnableForkJoinPool-1-worker-1 isVirtualtrue [方式二] VirtualThread[#23,my-worker]/runnableForkJoinPool-1-worker-1 [方式三] task-0 运行于 VirtualThread[#26]/runnableForkJoinPool-1-worker-1 [方式三] task-2 运行于 VirtualThread[#28]/runnableForkJoinPool-1-worker-3 [方式三] task-1 运行于 VirtualThread[#27]/runnableForkJoinPool-1-worker-2 [方式四] VirtualThread[#30,worker-0]/runnableForkJoinPool-1-worker-1 [主线程] Thread[#1,main,5,main] isVirtualfalse从输出可以看出几个关键信息虚拟线程的toString()格式是VirtualThread[#编号,线程名]/状态载体线程末尾的ForkJoinPool-1-worker-N就是它当前挂载的载体线程方式三的 3 个任务输出顺序不固定task-0/2/1因为它们是并发执行的主线程是平台线程isVirtualfalse而承载虚拟线程的ForkJoinPool-1-worker-N也是平台线程四种方式的选择建议方式适用场景Thread.startVirtualThread临时起一个后台任务Thread.ofVirtual().name().start()需要指定线程名便于排查Executors.newVirtualThreadPerTaskExecutor生产推荐批量提交任务自动等待关闭ThreadFactory需要把工厂传给框架/线程池 API其中最推荐newVirtualThreadPerTaskExecutor接口和传统ExecutorService完全一致迁移成本为零try-with-resources会自动等待所有任务完成而且不需要也不应该配置线程数——每个任务一个虚拟线程用完即弃。四、一段代码感受差异用一个最直观的例子并发发起 1000 个网络请求用Thread.sleep(1000)模拟每个请求等待 1 秒对比平台线程和虚拟线程的耗时。下面这个类可以直接运行。importjava.util.ArrayList;importjava.util.List;importjava.util.concurrent.CountDownLatch;importjava.util.concurrent.Executors;publicclassThreadCompareDemo{privatestaticfinalintTASK_COUNT1000;publicstaticvoidmain(String[]args)throwsException{runWithPlatformThreads();runWithVirtualThreads();}// 平台线程每个任务 new 一个 ThreadprivatestaticvoidrunWithPlatformThreads()throwsException{longstartSystem.currentTimeMillis();ListThreadthreadsnewArrayList();for(inti0;iTASK_COUNT;i){ThreadtnewThread(ThreadCompareDemo::simulateIo);threads.add(t);t.start();}for(Threadt:threads){t.join();}System.out.printf([平台线程] %d 个任务耗时: %d ms%n,TASK_COUNT,System.currentTimeMillis()-start);}// 虚拟线程每个任务一个虚拟线程privatestaticvoidrunWithVirtualThreads()throwsException{longstartSystem.currentTimeMillis();CountDownLatchlatchnewCountDownLatch(TASK_COUNT);try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){for(inti0;iTASK_COUNT;i){executor.submit(()-{try{simulateIo();}finally{latch.countDown();}});}latch.await();}System.out.printf([虚拟线程] %d 个任务耗时: %d ms%n,TASK_COUNT,System.currentTimeMillis()-start);}// 模拟一次耗时 1 秒的阻塞 IOprivatestaticvoidsimulateIo(){try{Thread.sleep(1000);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}}运行结果[平台线程] 1000 个任务耗时: 1087 ms [虚拟线程] 1000 个任务耗时: 1013 ms1000 个任务、每个阻塞 1 秒两种方式耗时都约 1 秒——因为它们都是并行执行的。在 1000 这个量级耗时差不多但代价完全不同指标1000 个平台线程1000 个虚拟线程栈内存~1GB1000 × 1MB~几 MB创建耗时数百 ms系统调用几 ms是否需要池化必须否则 OOM不需要真正拉开差距的是更高的量级。把TASK_COUNT改成1_000_000一百万再跑一次[虚拟线程] 1000000 个任务耗时: 2141 ms而平台线程版本在还没跑完创建循环时就会抛出Exception in thread main java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached一百万个平台线程需要约 1TB 栈内存操作系统根本无法承受而一百万个虚拟线程只用了 2 秒就全部跑完。这就是虚拟线程的核心价值让一个任务一个线程这种最直观的写法在海量并发下依然成立。五、虚拟线程的适用场景与不适用场景5.1 适合的场景IO 密集型虚拟线程的优势在阻塞等待时释放载体线程。以下场景收益最大HTTP 接口处理等数据库、等下游服务响应批量网络请求爬虫、批量调用第三方 API消息队列消费等待消息 处理中的 IO 操作文件 IO读写磁盘尤其是网络文件系统判断标准如果你的任务里大部分时间在等待网络、磁盘、锁虚拟线程就适合。5.2 不适合的场景场景一CPU 密集型计算// 这种任务虚拟线程没有优势longsum0;for(longi0;i1_000_000_000L;i){sumi;// 纯计算不会阻塞不会触发 unmount}虚拟线程的调度是在阻塞点IO、sleep、lock触发的。纯计算不会触发卸载虚拟线程会一直占着载体线程不放和平台线程没有区别。CPU 密集型任务仍然应该使用传统的ForkJoinPool或FixedThreadPool线程数 CPU 核数。场景二需要精确控制并发度传统线程池的池大小天然就是并发度限制——最多 8 个线程就意味着最多 8 个任务同时执行。虚拟线程没有池大小的概念。如果你对下游有并发限制比如数据库连接池只有 50 个连接第三方 API 限制每秒 100 次调用需要额外使用Semaphore来限制SemaphorepermitnewSemaphore(50);// 限制最多 50 个虚拟线程同时访问try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){for(Recordrecord:records){executor.submit(()-{permit.acquire();try{queryDatabase(record);// 最多 50 个同时查DB}finally{permit.release();}});}}场景三重度依赖 synchronizedJDK 21~23在 JDK 21 到 23 中虚拟线程在执行synchronized块内的阻塞操作时会被钉在载体线程上无法卸载Pinning 问题。这个问题在 JDK 24 中已修复JDK 25 中完全解决。具体原理和解法将在本系列后续文章中详解。5.3 决策流程图你的任务主要在做什么 │ ├── 大量等待网络IO、数据库、HTTP调用、文件读写 │ → ✅ 用虚拟线程 │ ├── 大量计算加密、压缩、排序、数学运算 │ → ❌ 用传统线程池线程数 CPU核数 │ └── 混合型IO 计算各占一半 → IO 部分用虚拟线程计算部分用传统线程池隔离六、虚拟线程的几个关键认知6.1 不要池化虚拟线程// ❌ 错误把虚拟线程放进池子里复用ExecutorServicepoolExecutors.newFixedThreadPool(100,Thread.ofVirtual().factory());// 这完全违背了设计初衷// ✅ 正确每个任务一个虚拟线程用完即弃ExecutorServiceexecutorExecutors.newVirtualThreadPerTaskExecutor();池化的意义在于线程贵需要复用。虚拟线程创建成本极低~1μs不需要复用池化反而增加了不必要的队列等待。6.2 虚拟线程不是更好的线程池很多人第一反应是虚拟线程就是一个可以开很多线程的线程池。这个理解方向反了。正确的理解是虚拟线程让线程池这个概念在 IO 密集场景下变得不再必要。你不需要去纠结池大小、队列策略、拒绝策略——每个任务一个虚拟线程JVM 自动调度。6.3 虚拟线程的线程名和调试Thread.ofVirtual().name(order-handler-,0).start(()-{// 线程名order-handler-0, order-handler-1, ...// 方便在日志和 jstack 中识别});jstack可以正常打印虚拟线程的堆栈但数量级可能很大几万个需要配合过滤工具使用。6.4 虚拟线程和已有 API 完全兼容虚拟线程实现了java.lang.Thread接口的所有方法Thread.sleep()→ 正常工作会触发虚拟线程卸载synchronized→ 正常工作但有 Pinning 风险后续篇章详解Lock/ReentrantLock→ 正常工作且不会 PinningFuture.get()→ 正常工作阻塞时自动卸载BlockingQueue.take()→ 正常工作你不需要改写已有的阻塞代码就能享受虚拟线程的好处这是它相比响应式编程最大的优势。七、虚拟线程与响应式编程的对比既然都是解决线程不够用的问题虚拟线程和 WebFlux/Reactor 有什么区别维度虚拟线程响应式编程WebFlux编程模型同步阻塞和传统写法一样异步非阻塞Mono/Flux 链式学习成本极低会用 Thread 就会用高新概念多调试困难代码可读性高顺序逻辑低回调嵌套 / 操作符链性能高IO密集场景高调试体验好堆栈完整差堆栈被切碎生态迁移成本低原有阻塞库直接用高必须用非阻塞驱动适合团队所有 Java 团队有响应式经验的团队结论对于大多数业务团队虚拟线程是比响应式编程更务实的选择——你能用熟悉的同步阻塞写法获得接近响应式的吞吐能力。八、JDK 版本与虚拟线程的演进JDK 版本虚拟线程状态关键变化JDK 19Preview预览首次引入JDK 20Second PreviewAPI 微调JDK 21LTS正式发布可用于生产JDK 22-23持续优化性能提升JDK 24重要修复synchronized 的 Pinning 基本解决JDK 25LTS完全成熟Pinning 完全修复 ScopedValue 正式化如果你的项目能升级到 JDK 21就可以开始使用虚拟线程。如果对稳定性要求极高等 JDK 252025年9月发布的 LTS 版本是最稳妥的选择。常见问题Q虚拟线程和协程Goroutine / Kotlin Coroutine是什么关系本质上是同一类东西——用户态的轻量级线程。Go 的 Goroutine、Kotlin 的协程、Java 的虚拟线程底层思想都是 M:N 调度M 个轻量级线程映射到 N 个 OS 线程。区别在于实现层面Go 是语言级内置Kotlin 是编译器转换Java 是 JVM 运行时支持。Q虚拟线程可以设置优先级吗可以调用setPriority()但实际上虚拟线程的调度器ForkJoinPool不使用优先级所以设置了也不生效。虚拟线程的调度是 FIFO先就绪先执行。Q虚拟线程有 daemon 属性吗虚拟线程始终是 daemon 线程isDaemon()返回 true不能设置为非 daemon。这意味着如果主线程结束了虚拟线程也会被强制终止。所以生产中要用ExecutorServicetry-with-resources来确保任务执行完毕。Q现有项目怎么开始用虚拟线程最小改动是什么如果你用的是 Spring Boot 3.2最小改动是加一行配置spring:threads:virtual:enabled:trueTomcat 会用虚拟线程替代传统线程池来处理每个 HTTP 请求。现有的业务代码一行都不用改。Q虚拟线程开了百万个GC 压力会不会很大虚拟线程本身是普通 Java 对象生命周期结束后正常被 GC 回收。但需要注意如果每个虚拟线程都持有较大的局部变量或 ThreadLocal 数据百万级的虚拟线程确实会给堆内存和 GC 带来压力。建议虚拟线程内的局部变量尽量轻量ThreadLocal 的使用需要格外谨慎本系列后续篇章会详解。总结虚拟线程的核心价值用一张表收敛你之前的做法虚拟线程时代的做法纠结线程池大小不需要池每个任务一个虚拟线程IO 密集型开 8~16 线程开多少个任务就有多少个虚拟线程用 CompletableFuture 异步编排直接写同步代码虚拟线程自动处理阻塞用 WebFlux 提升吞吐用虚拟线程 同步写法达到同等吞吐担心线程泄漏 / OOM虚拟线程极轻量不存在这个问题一句话记忆虚拟线程 JVM 管理的轻量级线程遇到 IO 自动让出载体线程适合 IO 密集型不适合 CPU 密集型不要池化每个任务一个虚拟线程用完即弃JDK 21 可用于生产JDK 25 完全成熟与现有代码完全兼容迁移成本极低