1. 可伸缩性
为什么普通线程的可伸缩性差?
每个传统线程(Thread)都是一个具有独立栈(通常为 1–2 MB)与状态结构的操作系统实体。尝试创建例如 10 000 个普通线程,往往会触发 OutOfMemoryError。因此传统服务器通常使用有限的线程池。
虚拟线程:可伸缩性的魔法
虚拟线程(Java 21+)是由 JVM 管理的“轻量级”线程。其栈存放在堆上,可按需伸缩。当线程在 I/O 上阻塞时,JVM 会“冻结”它并切换去执行其他任务。
JVM 内部运行着一个小型“载体线程”池(carrier threads)——这些是操作系统的平台线程,虚拟线程会在其上轮流执行。这使得创建 100 000+ 个任务也不会带来内存灾难。JVM 会自行规划何时执行哪些虚拟线程。
演示:100_000 个虚拟线程 vs 1_000 个平台线程
示例:创建 1000 个普通线程
// 试图创建 1000 个普通线程
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
threads.add(t);
t.start();
}
System.out.println("已创建线程数:" + threads.size());
结果:在大多数系统上可创建 1 000–2 000 个线程;更大的数量会引发内存问题与明显卡顿。
示例:创建 100 000 个虚拟线程
// 创建 100_000 个虚拟线程
List<Thread> vThreads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
vThreads.add(t);
}
System.out.println("已创建虚拟线程数:" + vThreads.size());
结果:程序可轻松创建 100 000 个虚拟线程而不会崩溃,也无明显卡顿。所需内存大幅降低。
可视化对比
| 线程类型 | 最大线程数(约) | 内存占用 | 启动时间 |
|---|---|---|---|
| 普通(Thread) | 1 000 – 10 000 | 高 | 较长 |
| 虚拟 | 100 000 – 1 000 000+ | 低 | 几乎瞬时 |
事实:虚拟线程让“每个任务一个线程”的编码方式成为可能,无需复杂线程池,也不易压垮系统。
2. 性能:虚拟线程的优势适用场景
以 I/O 为瓶颈的任务(I/O-bound)
虚拟线程非常适合网络请求、文件 I/O 与数据库操作。当操作阻塞时,虚拟线程会释放“载体线程”,JVM 则去执行其他任务。对于大量并发等待的场景,这能显著提升吞吐。
示例:模拟 10 000 个并发 HTTP 请求
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpClient client = HttpClient.newHttpClient();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("响应:" + response.statusCode());
} catch (Exception e) {
System.out.println("错误:" + e.getMessage());
}
});
threads.add(t);
}
// 等待所有线程结束
for (Thread t : threads) {
t.join();
}
结果:所有 10 000 个请求并行执行;程序不会崩溃,代码保持简洁。
CPU-bound:虚拟线程并不会加速计算
若任务主要消耗 CPU,虚拟线程不会带来速度提升:内核数量是固定的。此时应使用与核心数相等的固定线程池,以避免过度竞争。
// 每个任务都对一个很大的范围求和
Runnable cpuTask = () -> {
long sum = 0;
for (int i = 0; i < 100_000_000; i++) {
sum += i;
}
System.out.println("求和结果:" + sum);
};
// 启动 1000 个执行计算的虚拟线程
for (int i = 0; i < 1000; i++) {
Thread.ofVirtual().start(cpuTask);
}
结果:线程会争夺 CPU,但并不会更快——这不是 Virtual Threads 的用武之地.
3. 限制与注意事项
同步与虚拟线程的坑
- 谨慎使用本机阻塞/锁。使用 synchronized 可能会将虚拟线程固定到载体线程上(pin),从而降低收益。优先使用 ReentrantLock、Semaphore 以及 java.util.concurrent 中为虚拟线程优化的其他原语。
- 老旧库。某些 JDBC 驱动与本机库尚未针对 Virtual Threads 优化。务必仔细测试所有阻塞操作。
不适合长生命周期任务
Virtual Threads 非常适合“短工”:处理一个请求、执行一次操作然后结束。创建百万个永不结束的任务(例如无休止的计算)并无意义——此类场景请使用平台线程。
4. 最佳实践:何时使用虚拟线程
- I/O-bound 任务:网络调用、文件、数据库——凡是线程经常处于等待的场景。
- Web 服务器:为每个 HTTP 请求使用一个独立的虚拟线程。
- 集成测试:快速模拟成千上万的客户端。
- 异步处理:可以继续书写熟悉的“阻塞式”代码——JVM 会进行智能调度。
不建议使用:
- 持续高 CPU 负载的任务。
- 对低层库兼容性要求很高的场景(并非所有库都已适配)。
推荐在内部使用执行器:Executors.newVirtualThreadPerTaskExecutor()——“每个任务一个虚拟线程”,无需固定线程池。
5. 监控与度量:如何观测虚拟线程
JVisualVM 与 Flight Recorder
JVisualVM 可展示活动线程、其状态与内存;从 Java 21 起虚拟线程会单独显示。Java Flight Recorder(JFR)记录详尽的“黑盒”运行数据,包括 Virtual Threads 的统计信息——非常适合定位瓶颈。
在代码中查看线程数量
查看 JVM 中线程数量的简单方法:
System.out.println("线程总数:" + Thread.activeCount());
统计其中有多少是虚拟线程:
long vCount = Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.count();
System.out.println("虚拟线程数:" + vCount);
6. 使用虚拟线程的常见错误
错误 1:将虚拟线程用于重型计算。为 CPU-bound 任务启动数百万个虚拟线程不会让处理器更快。Virtual Threads 不是计算“加速器”。
错误 2:盲目沿用旧模式。不要为虚拟线程创建固定大小的线程池。请使用 Executors.newVirtualThreadPerTaskExecutor(),让 JVM 自动扩展。
错误 3:使用未适配的库。本机阻塞以及未针对 Loom 适配的库可能导致卡顿与性能下滑。请提前检查兼容性。
错误 4:过早优化。如果你只有少量线程、常规并发能满足需求,不必急于全面迁移到 Virtual Threads。此工具主要适用于大量 I/O 与等待的场景。
错误 5:忽视监控。创建上百万个任务很容易,但如果缺少监控与异常处理,得到的可能只是“漂亮的基准测试”,而非可靠系统。请使用 JVisualVM 与 JFR。
GO TO FULL VERSION