虚拟线程是 Java 在 Project Loom 中带来的一种轻量级线程实现,适合大量并发的阻塞型任务。要写一个最简单的 HelloWorld,只需在支持虚拟线程的 JDK 上运行:Thread.startVirtualThread(() -> System.out.println(“Hello, virtual thread!”)); 更完整的实践会展示如何用 Executors、try-with-resources、以及结构化并发来管理成千上万的任务,同时注意 CPU 密集型场景与阻塞资源的限制。


先把概念讲清楚:虚拟线程到底是什么?
想象一下线程就像一辆车,操作系统的内核线程是大型卡车,价格高、耗油多;虚拟线程则像自行车,造价低、数量可以很多。Java 的虚拟线程并不是新的语言关键字,而是一种由 JVM 管理的轻量级用户级线程,实现上依赖于继续/恢复(continuations)技术,把阻塞操作从昂贵的内核阻塞转为在用户态上的挂起与恢复。
核心要点(简短)
- 轻量:创建与销毁成本低,能同时存在大量线程。
- 阻塞友好:在虚拟线程上进行阻塞不会消耗内核线程资源。
- 兼容:现有基于 Thread、synchronized、阻塞 IO 的代码通常无需改动就能受益。
- 局限:对 CPU 密集型任务改动不大,仍需合理使用平台线程或线程池。
HelloWorld:最简单的示例和一步步解释
下面先给出一个极简单的示例,然后逐步展开为什么可以这样写、如何在真实项目中使用。
// 版本说明:建议使用 JDK 21 或更高版本以获得稳定支持
public class HelloVirtual {
public static void main(String[] args) {
Thread.startVirtualThread(() -> System.out.println("Hello, virtual thread!"));
}
}
这段代码只调用了一个静态工厂方法 Thread.startVirtualThread,并传入一个 Runnable。这会创建一个虚拟线程并立即启动它。与传统的 new Thread(…).start() 用法类似,但实现成本要低得多。
更结构化的 HelloWorld(多个任务)
在实际中,我们通常不会直接启动很多裸线程,而是使用 Executor 或结构化并发来管理生命周期:
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class HelloMany {
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10; i++) {
int id = i;
executor.submit(() -> {
System.out.println("task " + id + " running on " + Thread.currentThread());
});
}
// 提交完后关闭 executor,等待任务完成
} // try-with-resources 会自动关闭 executor 并等待任务完成
}
}
这里的 Executors.newVirtualThreadPerTaskExecutor() 提供了一个按任务创建虚拟线程的 Executor,通常用于把每个短任务放到独立的虚拟线程中执行,代码看起来非常直观。
为什么选择虚拟线程:生活化的比喻与场景
如果你写的是网络服务,接收大量短连接或请求,每个请求会做一些阻塞 IO(数据库、HTTP 调用等),那么传统的线程池需要预估并发量并为每个并发分配一个内核线程,稍不注意就会耗尽资源。虚拟线程让每个请求都可以有“自己的线程”而不带来巨大的系统开销,就像把每个等待的用户都放在自己的小座位上,而不再占用大型机器资源。
适用场景
- 高并发阻塞 IO(Web 服务、微服务、爬虫、代理、网关)。
- 将同步库或同步风格代码迁移到高并发场景时,几乎不用重写大量业务逻辑。
- 测试或短暂并发任务(并行数据处理、并发集成测试)。
非适用场景
- 长时间的 CPU 密集型计算(大矩阵计算、深度学习训练等),这类任务仍应放到专用的工作线程池或使用并行流/任务划分。
- 占用稀缺系统资源的任务(每个线程都持有大量本地内存或文件句柄),因为资源本身仍然受限。
虚拟线程与平台线程比较(快速对照表)
| 平台线程(传统) | 虚拟线程 | |
| 资源成本 | 高,受限于内核线程数 | 低,可创建成千上万 |
| 阻塞行为 | 阻塞内核线程(昂贵) | 阻塞会挂起虚拟线程,不占内核线程 |
| 适合类型 | CPU 密集型 | IO 密集型、短任务 |
| 兼容老代码 | 天然兼容 | 大多数老代码兼容(绝大多数阻塞调用无需改动) |
进阶用法:结构化并发与错误处理
单纯创建虚拟线程很容易,但当任务数量多、并且需要在多个任务之间协调或处理部分失败时,结构化并发(Structured Concurrency)是很有用的工具。结构化并发提供了一种在一个作用域内启动多个任务并统一等待或取消它们的方式,从而避免资源泄露和复杂的回调逻辑。
import java.util.concurrent.StructuredTaskScope;
public class StructuredExample {
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var future1 = scope.fork(() -> { /* do IO */ return "one"; });
var future2 = scope.fork(() -> { /* do other IO */ return "two"; });
scope.join(); // 等待所有 fork 的任务
scope.throwIfFailed(); // 如果有任务失败则抛出异常
System.out.println(future1.resultNow() + " " + future2.resultNow());
}
}
}
上面示例演示了在同一作用域内并发执行两个任务,并在任何一个任务失败时取消其余任务(ShutdownOnFailure 的语义)。这是比手工管理 Future 更安全的方式。
性能与测量:如何验证“真的快”
别只相信宣传,最好的办法是做个小测试。下面给出一个简单的 micro-benchmark 思路:分别使用平台线程和虚拟线程创建 N 个并发任务,让每个任务做阻塞等待(例如 Thread.sleep 或者阻塞 I/O),统计创建与完成所需时间。
- 实验步骤:固定任务数量(例如 100k),比较两种方式的总时间、内存占用和上下文切换。
- 注意点:真实服务的瓶颈可能在数据库或网络,不在线程数量,所以要把微基准和真实工作负载区分开来。
常见问题与陷阱(别犯这些错误)
- 误用场景:把所有任务都改成虚拟线程无脑并不总是好,CPU 密集型代码仍需要限流或专用线程池。
- 资源泄露:每个线程若持有文件描述符或数据库连接,不论是虚拟还是平台线程,都可能导致资源耗尽。应使用连接池、限流或短生命周期管理。
- 线程本地变量(ThreadLocal):虚拟线程也支持 ThreadLocal,但要注意数据泄露到线程复用的上下文;如果依赖 ThreadLocal 储存上下文,理解生命周期非常重要。
- 调试复杂度:大量虚拟线程可能让日志/堆栈跟踪变得杂乱,建议在出现问题时有明确的追踪 ID 或使用更强的可观测性手段。
- 底层阻塞:如果你的代码调用了会阻塞整个进程(例如某些 JNI 调用或直接的本地阻塞),虚拟线程无法化解这种阻塞。
如何迁移现有应用:一步步来
如果你有一个使用传统线程池的应用,想尝试虚拟线程,这里有个循序渐进的迁移建议:
- 先在开发或测试环境中尝试:把服务的一部分路由到使用虚拟线程的 Executor,观察行为与指标。
- 重点迁移 IO 密集型代码路径:例如 HTTP 请求处理、数据库访问等。
- 添加资源限制和监控:监控连接数、文件描述符、内存与垃圾回收等。
- 处理 ThreadLocal 与安全清理:检查是否有依赖 ThreadLocal 的代码,并在任务结束时做清理。
- 逐步扩大范围,并在每一步做回归测试与性能测试。
实战示例:同时处理成千上万请求(示例代码)
下面是一个简化的示例:模拟 50k 个短连接请求,通过虚拟线程并发处理,每个模拟请求做一个小的网络延迟(Thread.sleep)。目的是演示创建大量线程的可行性。
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class MassiveSim {
public static void main(String[] args) throws Exception {
int tasks = 50_000;
long start = System.currentTimeMillis();
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < tasks; i++) {
ex.submit(() -> {
// 模拟 I/O 延迟
try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
} // 等待所有任务结束
long end = System.currentTimeMillis();
System.out.println("Completed " + tasks + " tasks in " + (end - start) + " ms");
}
}
在大多数现代机器与合适的 JDK 上,这个程序可以运行而不会出现“不能创建更多线程”的错误(前提是不滥用系统资源)。
工具与调试建议
- 使用 jstack/jcmd 来查看线程快照,虚拟线程在工具输出中通常会以明显标识出现。
- 在日志中打印 Thread.currentThread().isVirtual() 来判断运行上下文,便于排查问题。
- 结合现有的 APM(应用性能监控)工具来观察延迟分布与瓶颈。
运行环境与兼容性提示
虚拟线程是在较新的 JDK 中引入的特性。为了避免版本差异带来的困扰,建议:
- 优先使用 JDK 21 或更高版本以获得相对稳定的 API 支持。
- 如果在 JDK 19/20 的早期预览版本尝试,可能需要使用 –enable-preview 或额外的模块选项;请以你使用的 JDK 文档为准。
- 在容器环境(Docker、Kubernetes)中运行时,关注容器的 ulimit(文件句柄数)与内存限制,这些仍然会影响大量并发任务的稳定性。
常用代码片段速查(小抄)
- 快速启动一个虚拟线程:
Thread.startVirtualThread(() -> ...) - 虚拟线程的执行器:
Executors.newVirtualThreadPerTaskExecutor() - 检查当前线程类型:
Thread.currentThread().isVirtual() - 结构化并发示例:使用
StructuredTaskScope来管理一组任务。
最后说几句比较随意的话
写到这里,我自己也有点想起以前用传统线程调优的日子——那时候心里总想着“尽量别把线程数开太大”,现在有了虚拟线程,很多场景确实可以更直观地编写同步风格逻辑而不必担心线程成本。不过别急着把所有旧代码一股脑儿都换成虚拟线程,还是要一点点试、测、改,尤其注意那些与本地资源(文件、连接、JNI)有关的地方。试验一下,让你的服务自然呼吸,不用每次连接都挤占稀缺的内核线程,这种体验还挺舒服的。