协程这个概念在Kotlin、Go等语言中已经流行多年,而Java长期依赖操作系统的线程模型来处理并发,导致高并发场景下资源消耗巨大、性能瓶颈明显。Project Loom正是为了解决这一问题而生,它引入的虚拟线程让Java开发者终于拥有了轻量级的协程式并发能力。本文将从原理到实践,带你全面理解虚拟线程的设计思想与使用方式。

一、从线程到协程:为什么Java需要虚拟线程
要理解虚拟线程,首先需要明白传统Java线程的本质。java.lang.Thread在JDK 19之前与操作系统的内核线程是一一对应的关系,这种模型被称为平台线程模型。每创建一个平台线程,操作系统就要分配约1MB的栈空间,并且线程之间的上下文切换需要陷入内核态,代价高昂。这就解释了为什么在典型的Web服务器中,线程池大小通常只能设置几百到几千,一旦并发请求数超过线程池容量,请求就会排队甚至被拒绝。
协程的核心思想是把调度的控制权从操作系统收回到用户态。协程是一种用户态的轻量级执行单元,它可以在适当的时机主动挂起,让出执行权给其他协程,之后在条件满足时再恢复执行。由于挂起和恢复都发生在用户空间,不需要内核态切换,成本极低。Go语言的goroutine、Kotlin的coroutine都是这个思路的实现。
虚拟线程正是Java版本的协程实现。它由JVM管理和调度,初始栈空间只有几百字节,可以按需增长,堆内存中存储栈帧。一个普通的笔记本电脑轻松创建上百万个虚拟线程完全不成问题。更重要的是,虚拟线程的最大优势在于不需要改变编程模型:你仍然可以写同步阻塞式的代码,获得异步非阻塞的性能,这就是所谓的同步代码的编写体验、异步代码的运行效率。
二、虚拟线程的底层原理:挂起、恢复与调度
虚拟线程的实现依赖于两个关键组件:调度器和Continuation机制。JDK内部的调度器是一个基于ForkJoinPool的工作窃取式调度器,它负责把虚拟线程分配到数量有限的载体线程上运行。载体线程本身是普通的平台线程,一个载体线程可以先后承载成千上万个虚拟线程,它们之间的关系是类似多路复用的关系。
Continuation可以理解为可暂停、可恢复的执行上下文。当虚拟线程执行到阻塞操作(如Thread.sleep()、网络IO、Lock.lock())时,JVM不会让载体线程一起阻塞,而是把虚拟线程的整个调用栈作为堆对象保存起来,然后释放载体线程去执行其他虚拟线程。等阻塞操作完成后,调度器会重新拾取一个可用的载体线程,恢复之前保存的栈帧,让虚拟线程从挂起点继续执行。
这个过程对开发者完全透明。这也是虚拟线程与响应式编程框架(如Reactor、RxJava)最本质的区别:响应式框架要求你用回调或者链式操作符重写代码逻辑,学习曲线陡峭、调试困难;而虚拟线程让传统的阻塞式代码自动获得了非阻塞的行为,jstack看到的堆栈依然是清晰可读的同步调用链。需要注意的是,只有经过JDK改造的阻塞点才会触发挂起,如果在虚拟线程中调用JNI本地代码发生阻塞,载体线程仍会被一起阻塞,这是实践中要特别警惕的坑。
三、动手实践:虚拟线程的创建与使用
从JDK 21开始,虚拟线程已正式转正。最简单的创建方式是使用Thread.ofVirtual()静态工厂方法,也可以通过Executors.newVirtualThreadPerTaskExecutor()创建一个为每个任务分配一个虚拟线程的执行器。下面通过代码示例演示几种典型用法。
import java.time.Duration;
import java.util.concurrent.*;
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// 方式一:直接创建并启动虚拟线程
Thread vt = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> {
System.out.println("当前线程: " + Thread.currentThread());
});
vt.join();
// 方式二:使用执行器,每个任务一个虚拟线程
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 模拟一万个并发阻塞任务
int taskCount = 10_000;
long start = System.currentTimeMillis();
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(500)); // 阻塞不会占用载体线程
return "done";
});
}
long cost = System.currentTimeMillis() - start;
System.out.println("提交" + taskCount + "个任务耗时: " + cost + "ms");
}
// try-with-resources 会在关闭时等待所有任务完成
}
}
上面的代码中,一万个任务每个都休眠500毫秒,如果用平台线程池(假设200个线程)执行,总耗时大约需要25秒以上;而用虚拟线程执行,理论上500多毫秒就能全部完成,因为休眠期间载体线程被释放去运行其他任务。这种吞吐量的提升在高IO密集型场景中尤为显著。
在实际的Spring Boot应用中,迁移也非常简单。Spring Boot从3.2版本开始支持虚拟线程,只需在配置文件中设置spring.threads.virtual.enabled=true,Tomcat处理请求的线程就会自动切换为虚拟线程,业务代码一行都不用改。下面是一个对比传统线程池和虚拟线程处理性能差异的测试片段:
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.concurrent.*;
public class IoBenchmark {
// 对比:平台线程池 vs 虚拟线程执行大量HTTP请求
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://ipipp.com"))
.GET()
.build();
// 平台线程池:固定200线程
ExecutorService pool = Executors.newFixedThreadPool(200);
runBatch(client, request, pool, "平台线程池");
// 虚拟线程执行器
ExecutorService vtPool = Executors.newVirtualThreadPerTaskExecutor();
runBatch(client, request, vtPool, "虚拟线程");
}
private static void runBatch(HttpClient client, HttpRequest request,
ExecutorService pool, String label) throws Exception {
long start = System.currentTimeMillis();
var futures = new java.util.ArrayList<Future<?>>();
for (int i = 0; i < 5000; i++) {
futures.add(pool.submit(() -> {
client.send(request, HttpResponse.BodyHandlers.discarding());
return null;
}));
}
for (var f : futures) f.get(); // 等待全部完成
System.out.println(label + " 完成5000个请求耗时: "
+ (System.currentTimeMillis() - start) + "ms");
pool.shutdown();
}
}
四、使用建议与常见误区
虚拟线程虽好,但并非银弹,使用时需要遵守一些重要原则。首先,不要池化虚拟线程。虚拟线程的设计初衷就是廉价、用完即弃,每次任务直接创建新的虚拟线程即可,复用反而会破坏其调度特性,这也是为什么推荐使用newVirtualThreadPerTaskExecutor而非固定大小的虚拟线程池。
其次要警惕针住问题。当虚拟线程在载体线程上执行synchronized同步块并发生阻塞时,JDK 21中载体线程无法被释放,会被一起阻塞。如果一个系统中大量虚拟线程都挤在synchronized块内阻塞等待,吞吐量会急剧下降。解决办法是改用ReentrantLock替代synchronized,或者升级到JDK 24,官方已经在后续版本中优化了这一问题。
最后,虚拟线程适合IO密集型任务,如数据库查询、HTTP调用、文件读写等;而CPU密集型计算任务并不能从虚拟线程中获益,因为计算过程中不会发生挂起,载体线程始终被占用,此时虚拟线程和平台线程没有本质区别。此外,ThreadLocal的使用也要克制,百万级虚拟线程如果都持有较大的ThreadLocal对象,内存压力会非常大,可以考虑使用JDK 21提供的ScopedValue作为替代方案。掌握这些原则后,你就可以放心大胆地在生产环境中拥抱虚拟线程,用最熟悉的同步编程风格写出高吞吐的并发应用了。
Java协程虚拟线程Project Loom修改时间:2026-09-02 14:56:51