前言
2022 年 9 月,Java 19 正式引入了虚拟线程(Virtual Threads)作为预览特性,并在 Java 21 中转为正式特性。这一被称为"Project Loom"核心成果的技术,被誉为 Java 并发编程领域近十年来最重要的变革。它让开发者可以用简单的同步代码,获得接近异步编程的吞吐量。
本文将深入剖析虚拟线程的原理、使用方式、适用场景以及常见陷阱,帮助你真正掌握这项技术。
一、为什么需要虚拟线程?
1.1 传统平台线程的困境
在 Java 21 之前,我们使用的线程都是平台线程(Platform Thread),它们与操作系统内核线程一一对应。这带来两个核心问题:
-
创建成本高:每个平台线程需要分配约 1MB 的栈内存,创建和销毁开销大。
-
数量受限:一台机器能创建的线程数通常只有几千个,远不能满足高并发需求。
考虑一个典型的 Web 服务场景:
// 传统方式:每个请求一个线程serverSocket.accept(); // 阻塞等待// 处理请求,可能涉及数据库查询、HTTP 调用等阻塞操作
当并发请求达到上万时,线程池会被耗尽,请求只能排队等待,吞吐量急剧下降。
1.2 异步编程的复杂性
为了突破线程数量限制,业界转向了异步编程(如 CompletableFuture、Reactive Streams):
public CompletableFuture<User> getUser(Long id) {
return userRepository.findByIdAsync(id)
.thenCompose(user -> orderService.getOrdersAsync(user.getId())
.thenApply(orders -> {
user.setOrders(orders);
return user;
}));}
这种"回调地狱"虽然提升了吞吐量,但代码可读性差、调试困难、异常处理复杂,严重损害了开发效率。
虚拟线程的目标就是:让同步代码拥有异步代码的吞吐量。
二、虚拟线程的原理
2.1 核心机制:M:N 调度
虚拟线程采用 M:N 调度模型:大量虚拟线程(M)被调度到少量平台线程(N)上执行。
虚拟线程1 ─┐ 虚拟线程2 ─┼─→ ForkJoinPool 调度器 ─→ 平台线程1 虚拟线程3 ─┤ ─→ 平台线程2 ... ┘ ─→ 平台线程3
关键在于:当虚拟线程遇到阻塞操作时,JVM 会将其挂起(unmount),释放底层平台线程去执行其他虚拟线程,而不是像平台线程那样阻塞内核线程。
2.2 栈内存的存储
平台线程的栈存储在操作系统内存中,而虚拟线程的栈存储在 Java 堆中(称为 StackChunk)。这意味着:
-
虚拟线程初始只占用几百字节,按需增长。
-
可以轻松创建数百万个虚拟线程。
2.3 阻塞时的状态转换
Thread.startVirtualThread(() -> {
// 1. 运行中,挂载(mount)到平台线程
var result = blockingIoCall();
// 2. 遇到阻塞,JVM 自动 unmount,平台线程被释放
// 3. IO 完成后,重新 mount 到某个平台线程继续执行
System.out.println(result);});
整个过程对开发者完全透明,无需修改任何业务代码。
三、快速上手
3.1 创建虚拟线程的几种方式
// 方式一:直接启动Thread.startVirtualThread(() -> {
System.out.println("Hello from virtual thread");});// 方式二:使用 Builder(推荐,可命名便于调试)Thread thread = Thread.ofVirtual()
.name("my-virtual-thread-", 0)
.start(() -> {
System.out.println("Running");
});// 方式三:使用 ExecutorServicetry (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return "done";
});
}} // 自动关闭
3.2 一个完整的对比示例
public class VirtualThreadDemo {
// 模拟一次耗时 100ms 的 IO 操作
static String fetchData(int id) throws InterruptedException {
Thread.sleep(100);
return "data-" + id;
}
public static void main(String[] args) throws Exception {
int taskCount = 10_000;
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < taskCount; i++) {
int id = i;
futures.add(executor.submit(() -> fetchData(id)));
}
for (Future<String> f : futures) {
f.get();
}
}
System.out.println("虚拟线程耗时: " +
(System.currentTimeMillis() - start) + "ms");
}}
对比结果(参考值):
| 线程类型 | 10000 个任务耗时 | 内存占用 |
|---|---|---|
| 平台线程(池大小 200) | ~5000ms | 高 |
| 虚拟线程 | ~150ms | 极低 |
四、深入理解:Pinning(钉住)问题
虚拟线程并非在所有阻塞场景下都能释放平台线程。当虚拟线程被"钉住"(pinned)时,它会一直占用平台线程,失去优势。
4.1 两种会导致 Pinning 的情况
情况一:在 synchronized 块中阻塞
// 危险:在 synchronized 中执行阻塞操作synchronized (lock) {
Thread.sleep(1000); // 此处会 pin 住平台线程!}
情况二:调用 native 方法或 foreign function
// JNI 调用会 pin 住线程nativeMethod();
4.2 解决方案
用 ReentrantLock 替代 synchronized:
private final ReentrantLock lock = new ReentrantLock();public void doWork() {
lock.lock();
try {
Thread.sleep(1000); // 不会 pin
} finally {
lock.unlock();
}}
4.3 检测 Pinning
JVM 提供了系统属性来检测 pinning:
-Djdk.trackAllThreads=false \-Djdk.virtualThreadScheduler.maxPoolSize=256
在 JDK 21+ 中,可以通过 JFR(Java Flight Recorder)事件 jdk.VirtualThreadPinned 监控。
五、适用场景与不适用场景
✅ 适合虚拟线程的场景
-
IO 密集型任务:HTTP 调用、数据库查询、文件读写、消息队列消费。
-
高并发服务端:每个请求一个虚拟线程,代码简单直观。
-
替代传统线程池:
Executors.newVirtualThreadPerTaskExecutor()几乎可以无脑替换。
❌ 不适合的场景
-
CPU 密集型任务:虚拟线程无法提升 CPU 利用率,应使用固定大小线程池。
-
长时间持有 synchronized 锁:会导致 pinning。
-
依赖线程局部变量的重型缓存:因为虚拟线程数量巨大,
ThreadLocal可能导致内存爆炸。
关于 ThreadLocal 的替代
由于虚拟线程数量可达百万级,滥用 ThreadLocal 会带来严重内存问题。推荐使用 Scoped Values(Java 21 预览):
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();ScopedValue.where(CURRENT_USER, user).run(() -> {
// 在此作用域内可访问 CURRENT_USER.get()
handleRequest();});
六、最佳实践总结
-
优先使用
Executors.newVirtualThreadPerTaskExecutor(),而不是手动创建虚拟线程。 -
避免使用线程池复用虚拟线程:虚拟线程本身就是廉价的,无需池化(池化的目的是复用昂贵资源)。
-
将
synchronized替换为ReentrantLock,特别是在持有锁期间有阻塞操作时。 -
谨慎使用
ThreadLocal,考虑迁移到ScopedValue。 -
不要为虚拟线程设置过大的栈,它会自动增长。
-
CPU 密集型任务仍使用平台线程池,虚拟线程不是万能的。
七、结语
虚拟线程是 Java 并发编程的一次范式转变。它让"一个请求一个线程"的简单模型重新成为高并发场景的可行方案,让开发者摆脱回调地狱,同时保持接近异步的性能。
当然,虚拟线程并非银弹。理解其调度机制、pinning 问题和内存特性,才能真正发挥它的威力。建议在新项目中尝试虚拟线程,但也要在生产环境中做好压测和监控,特别是关注 pinning 事件。
Java 的并发之路还在继续演进,Scoped Values、Structured Concurrency(结构化并发)等特性将进一步丰富这一生态。掌握虚拟线程,就是掌握了 Java 未来十年的并发基础。
参考资料
-
JEP 444: Virtual Threads
-
JEP 446: Scoped Values (Preview)
-
JEP 453: Structured Concurrency (Preview)
-
Oracle 官方文档:Virtual Threads