Java 虚拟线程 (Project Loom):高并发轻量线程
Java 虚拟线程完整指南 (Project Loom)
什么是虚拟线程?
虚拟线程是 Java 平台在 Project Loom 中引入的轻量级线程实现。它们由 JVM 管理,而不是由操作系统内核调度。与传统的平台线程(操作系统线程)不同,虚拟线程的创建和切换成本极低,使得开发者可以轻松创建数百万个并发任务,而不必担心耗尽系统资源或复杂的线程池管理。
虚拟线程的核心思想是:用廉价的“用户态线程”承载大量阻塞操作,当虚拟线程遇到 I/O、睡眠等阻塞操作时,JVM 会自动将其从底层载体线程(平台线程)上“卸下”,让出 CPU 去执行其他虚拟线程,从而保持高吞吐量。
为什么需要虚拟线程?
传统 Java 并发模型面临两大瓶颈:
- 平台线程昂贵且有限:每个
java.lang.Thread实例都直接映射到一个操作系统线程。操作系统线程数量受限于内存和内核调度能力,通常一台服务器最多只能创建几千个线程。在“一个请求对应一个线程”的编程模型下,处理海量并发请求时很容易达到上限。 - 异步编程复杂难维护:为突破线程限制,开发者被迫使用回调、
CompletableFuture、反应式框架等异步模型。虽然解决了资源问题,但代码变得难以阅读、调试和追踪,也容易产生“回调地狱”。
虚拟线程完美融合了同步编程的简单性与异步执行的高吞吐。你可以用完全同步、阻塞的方式编写代码(就像写单线程程序一样),而 JVM 会负责将这些阻塞调用转化为非阻塞调度,从而使极少数的平台线程支撑起海量虚拟线程。
基础用法
创建一个虚拟线程
在你的项目中,只需使用 Java 21 或更高版本(虚拟线程在 Java 19 中作为预览特性引入,21 正式发布),即可使用以下 API。
// 方法1:通过 Thread 类的静态工厂方法
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> {
System.out.println("运行在虚拟线程中");
});
// 等待该线程执行完毕
vThread.join();
也可以直接创建但不启动,然后手动 start():
Thread vThread = Thread.ofVirtual()
.name("worker")
.unstarted(() -> {
// 线程逻辑
});
vThread.start();
使用 ExecutorService 管理虚拟线程
推荐通过 Executors.newVirtualThreadPerTaskExecutor() 获得一个为每个任务分配新虚拟线程的执行器。这个执行器的行为符合传统线程池的语义,但内部实现极为轻量,不限制虚拟线程数量。
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int taskId = i;
executor.submit(() -> {
// 模拟阻塞操作(比如网络请求)
try {
Thread.sleep(1000);
System.out.println("任务 " + taskId + " 完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
} // executor 关闭时会等待所有任务完成
使用 try-with-resources 确保所有任务提交完毕并等待完成后再释放资源。
虚拟线程与平台线程的映射
虚拟线程在运行时由一组固定的载体线程 (Carrier Thread) 承载。载体线程就是普通平台线程,通常数量等于 CPU 核心数。当虚拟线程执行阻塞操作(如 Thread.sleep、Socket.read、LockSupport.park)时,虚拟线程会从载体线程上被卸载(unmount),载体线程立即去执行另一个就绪的虚拟线程。阻塞操作完成后,虚拟线程会被重新挂载(mount)到可用的载体线程上继续执行。
开发者无需直接管理载体线程,JVM 会自动完成调度。
深入理解:虚拟线程的阻塞处理
虚拟线程的神奇之处在于让几乎所有阻塞 API 都变为可感知虚拟线程的阻塞。以下常见操作在虚拟线程中都不会物理阻塞载体线程:
Thread.sleep()Object.wait()/Condition.await()LockSupport.park()Socket网络 I/O(包括Socket、ServerSocket、DatagramSocket)File I/O(Java 21 中部分文件操作仍会占用载体线程,Java 22+ 逐步优化)Process相关操作
因此你可以用最直观的同步代码实现网络服务,例如:
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 在虚拟线程中会卸载
Thread.ofVirtual().start(() -> {
handleRequest(socket);
});
}
线程局部变量与 ThreadLocal
虚拟线程支持 ThreadLocal,但需要谨慎使用。因为虚拟线程数量可能非常多,如果每个虚拟线程都在 ThreadLocal 中存储大量对象,容易导致内存占用过高。官方建议:可以使用,但不要存储大型数据,或考虑使用作用域值(Scoped Values)替代。
并发限制:不要直接创建无限虚拟线程
虽然虚拟线程本身廉价,但你仍然需要控制并发资源的占用量(如数据库连接数、外部 API 调用频率)。可以使用 Semaphore 对虚拟线程进行并发控制:
Semaphore semaphore = new Semaphore(10);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
try {
semaphore.acquire();
// 调用有限资源的外部服务
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release();
}
});
}
}
结构化并发 (Structured Concurrency)
Project Loom 不仅带来虚拟线程,还引入了结构化并发(仍为预览特性,需使用 --enable-preview)。它的核心思想是将多个并发任务视为一个工作单元,任务的生命周期必须被显式管理,防止线程泄露和遗弃。
基本用法:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> result1 = scope.fork(() -> fetchUser());
Future<Integer> result2 = scope.fork(() -> fetchOrderCount());
scope.join(); // 等待所有子任务完成
scope.throwIfFailed(); // 如果任一任务失败,抛出异常
String user = result1.resultNow();
int orders = result2.resultNow();
System.out.println(user + " 有 " + orders + " 个订单");
}
结构化并发要求通过 StructuredTaskScope 创建子任务,当 try 块结束时,scope 会确保所有未完成的子任务都被取消。这消除了忘记关闭线程池或无限等待的风险。
结构化并发的好处
- 清晰的所有权:创建任务的代码块同时也是等待和取消任务的地方。
- 异常传播:子任务失败可优雅地取消其他同组任务并向上传播异常。
- 可观察性:线程 dump 可以展示父子任务关系,方便调试。
注意:结构化并发目前仍是预览 API,生产环境使用需谨慎,并需添加 --enable-preview 编译和运行时标志。
虚拟线程的局限性
- 不适用于 CPU 密集型任务:如果任务长时间占用 CPU(例如复杂计算),虚拟线程被挂载到载体线程上后也会一直占用该载体线程。此时不可能被自动卸载,从而影响其他虚拟线程的执行。对这种场景,仍然推荐使用有限数量的平台线程或 ForkJoinPool 来避免调度开销。
- Native 代码或长时间持有的监视器:当虚拟线程进入
synchronized块或执行 JNI native 方法时,JVM 无法将其从载体线程上卸载。如果长时间持有监视器,会导致载体线程被 pinned(钉住),影响整体吞吐量。Java 21 中已优化大部分synchronized相关卸载问题,但仍有少数情况无法卸载。建议在高并发场景下使用java.util.concurrent锁(如ReentrantLock)替代synchronized。 - 线程 dump 更庞大:由于可能存在海量虚拟线程,传统的线程 dump 会非常长。JDK 提供了新的线程 dump 格式(JSON、轻量级模式),以便过滤和分析。
- 调试工具的适配:IDE 和 profiler 需要适配虚拟线程的展示方式,否则可能看不到虚拟线程或表现异常。
迁移建议与最佳实践
- 直接替换线程池方案:如果你的应用使用“一个任务一个线程”的模型(如 Servlet 容器),可以尝试将
ExecutorService替换为Executors.newVirtualThreadPerTaskExecutor()。像 Spring Boot 3.2+ 已经内置对虚拟线程的支持,只需简单配置即可启用。 - 重构异步代码:逐步将回调式或响应式代码还原为同步、顺序的写法,利用虚拟线程保持吞吐量。
- 控制资源并发:无论虚拟线程多轻量,连接池、限流器仍必不可少。使用
Semaphore或专用的限流库保护后端资源。 - 监控与调优:关注 JFR (JDK Flight Recorder) 中的虚拟线程事件,监控 pinned 线程发生的频率,逐步优化监视器使用。
- 预览结构化并发:在设计新服务时,尝试使用结构化并发编写更健壮的并发逻辑。
快速试验
你的项目只要配置 Java 21,即可立即体验虚拟线程。例如,编写一个简单的 HTTP 服务器:
import java.net.*;
import java.io.*;
public class VirtualServer {
public static void main(String[] args) throws Exception {
try (ServerSocket server = new ServerSocket(9999)) {
System.out.println("虚拟线程服务器启动,端口 9999");
while (true) {
Socket client = server.accept();
Thread.ofVirtual().start(() -> {
try (client;
BufferedReader in = new BufferedReader(
new InputStreamReader(client.getInputStream()));
PrintWriter out = new PrintWriter(client.getOutputStream(), true)) {
String line;
while ((line = in.readLine()) != null) {
out.println("Echo: " + line);
}
} catch (IOException e) {
e.printStackTrace();
}
});
}
}
}
}
这个服务器能轻松处理数万个并发连接,无需任何线程池配置。编译运行后,用工具测试并发,你会看到惊人的吞吐量。
总结
Project Loom 的虚拟线程彻底改变了 Java 的并发编程模型。你不再需要在同步/简单和异步/高效之间做出取舍。虚拟线程让“为每个任务分配一个线程”重新成为最佳实践,极大地简化了代码,同时保持了 JVM 在高并发场景下的领先性能。搭配结构化并发,Java 正在构建一套更安全、更易维护的并发基础设施。
建议从 Java 21 开始,在新的项目中优先考虑虚拟线程,并逐步改造遗留代码,享受同步编程的纯粹快乐与高并发的强大力量。