在微服务里同时调用几十个下游HTTP接口是家常便饭。过去用标准线程池,哪怕只是等待IO,每个任务也要占一个操作系统线程,切到几百个并发时上下文切换就开始拖后腿,线程栈的内存更是一笔不小的开销。虽然CompletableFuture和反应式编程能缓解,但代码一下子变得难读、难调试。
Java 21正式带来的虚拟线程(Virtual Threads),把问题从根上解决了。你可以像创建普通线程一样,用近乎为零的成本启动成千上万个虚拟线程,它们由JVM在少量操作系统线程上调度,遇到IO自动让出,完全不阻塞底层线程。这篇文章就通过编写一个并发抓取几十个URL的小工具,把虚拟线程的用法、收益和边界全摸一遍。
传统线程的困境
考虑一个典型的场景:你需要从50个不同的API拿到数据,然后聚合。用固定线程池20个线程,最坏情况要2-3轮才能跑完,期间那些等待响应的线程几乎只是占着内存。而且每个平台线程默认1MB栈空间,50个线程就是50MB,如果扩展到500个,光内存就让人犹豫。
虚拟线程把任务从操作系统线程中解耦:一个虚拟线程在等待网络响应时,JVM可以把它从载体线程上卸载,让那个载体线程去执行另一个虚拟线程。于是,成千上万的并发请求也能平缓地共用少量系统线程。
创建虚拟线程的三种方式
虚拟线程的使用方式与普通线程几乎一样,这大大降低了迁移成本。
- 直接创建并启动:
Thread.startVirtualThread(() -> { ... }); - 通过Builder构建:
Thread.ofVirtual().name("worker").start(task); - 从ExecutorService获得:
Executors.newVirtualThreadPerTaskExecutor()返回一个每个任务都分配到新虚拟线程的执行器。
最后一种最适合替换现有的线程池,而且它实现了AutoCloseable,可以放在try-with-resources里自动等待所有任务完成。
实战:并发抓取多个URL
假设我们需要同时请求几十个公开API,收集它们的响应状态和内容长度。以前你可能会担心线程数,现在用虚拟线程搭一个直截了当的解决方案。
1. 准备HTTP请求工具
我们用Java 11+自带的java.net.http.HttpClient,它本就是异步友好的,搭配虚拟线程再合适不过。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class Fetcher {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public static String fetch(String url) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return String.format("SUCCESS [%d] %d chars", response.statusCode(), response.body().length());
} catch (Exception e) {
return "FAILED: " + e.getMessage();
}
}
}
2. 使用虚拟线程执行器并发调用
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
List<String> urls = List.of(
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent",
// 可以放几十个URL
"https://api.github.com",
"https://jsonplaceholder.typicode.com/todos/1"
);
long start = System.currentTimeMillis();
// 关键:每任务一个虚拟线程的Executor
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Callable<String>> tasks = urls.stream()
.map(url -> (Callable<String>) () -> Fetcher.fetch(url))
.toList();
List<Future<String>> futures = executor.invokeAll(tasks);
for (int i = 0; i < urls.size(); i++) {
System.out.printf("URL: %s -> %s%n", urls.get(i), futures.get(i).get());
}
}
long time = System.currentTimeMillis() - start;
System.out.printf("总共耗时: %d ms%n", time);
}
}
这里的invokeAll会为每个任务分配一个新虚拟线程,全部并行执行。尽管URL数量可能上百,但底层只用几个操作系统线程,不会因为线程数爆炸带来调度压力。
3. 跑一下看看效果
执行程序,你会看到所有请求基本在同一时间发出,而在传统固定大小线程池下,当任务数超过线程数时,后面的请求必须排队等待空闲线程。用虚拟线程则没有这种限制,平均完成时间由最慢的请求决定,而不是线程数。
对比:传统线程池 vs 虚拟线程
我们写一个简单的对比实验,固定线程池40个线程,虚拟线程池同样提交100个包含1秒模拟IO的任务(模拟网络延迟),观察耗时差异。
import java.util.concurrent.*;
public class Comparison {
public static void main(String[] args) throws Exception {
int taskCount = 100;
// 模拟1秒IO的任务
Callable<String> ioTask = () -> {
Thread.sleep(1000);
return "done";
};
// 传统固定线程池 40线程
ExecutorService traditional = Executors.newFixedThreadPool(40);
long start1 = System.currentTimeMillis();
traditional.invokeAll(
java.util.Collections.nCopies(taskCount, ioTask)
);
long time1 = System.currentTimeMillis() - start1;
traditional.shutdown();
System.out.println("固定线程池耗时: " + time1 + " ms");
// 虚拟线程执行器
long start2 = System.currentTimeMillis();
try (var virtual = Executors.newVirtualThreadPerTaskExecutor()) {
virtual.invokeAll(
java.util.Collections.nCopies(taskCount, ioTask)
);
}
long time2 = System.currentTimeMillis() - start2;
System.out.println("虚拟线程耗时: " + time2 + " ms");
}
}
典型结果:固定池需要1000*ceil(100/40) ≈ 3000ms,虚拟线程只需要约1000ms出头。而且内存消耗方面,虚拟线程几乎没有明显增长。
虚拟线程的调度与载体线程
JVM内部使用少量ForkJoinPool线程作为载体,当一个虚拟线程遇到IO阻塞(如Socket.read),JVM会将其从载体线程卸载并保持状态,等IO准备就绪再重新挂载。这个过程对用户透明,所以你基本可以像写同步代码一样使用虚拟线程,而无需担心阻塞。
你的代码里完全看不到回调或反应式链,调试堆栈也非常清晰。这标志着Java并发编程从“必须异步”到“可同步”的回归。
注意事项与局限性
- 不要池化虚拟线程:虚拟线程非常轻量,不需要池化来复用,用
newVirtualThreadPerTaskExecutor即可,用完就扔。 - 避免虚拟线程执行长时间CPU计算:虚拟线程适合IO密集型任务,如果里面包含持续占用CPU的操作,会阻塞载体线程,反而降低吞吐量。此时可用传统线程或结构化并发去调度。
- 线程本地变量:虚拟线程支持
ThreadLocal,但大量虚拟线程下很多线程本地数据可能成为内存负担,可以考虑使用ScopedValue(Java 20+ 预览特性)来改进。 - JNI调用:如果虚拟线程执行JNI代码,可能会将载体线程固定(pin)住,影响卸载。应尽量避免在虚拟线程中调用不合作的本地代码。
生产落地建议
如果你的服务已经是Spring Boot 3.2+(基于Java 21),可以通过spring.threads.virtual.enabled=true直接将Tomcat的工作线程替换为虚拟线程,几乎零代码改动享受高并发红利。对于自己写的任务调度器,可以逐步把固定线程池替换为虚拟线程执行器,尤其在IO密集的模块。
使用Executors.newVirtualThreadPerTaskExecutor()时,注意关闭执行器会等待所有任务完成,正好作为优雅停机的一种方式。
总结
Java 21的虚拟线程把并发编程拉回了同步模型的舒适区,却又给予了异步级别的吞吐量。从我们的小型网络抓取器可以看出,代码量没有增加,反而因为不需要CompletableFuture编排而更加简洁。对于绝大多数依赖IO的服务而言,虚拟线程带来的性能提升和编码简化,几乎是立竿见影的。
赶紧把你项目里的那些固定大小线程池捡出来,试着换上虚拟线程执行器跑一次测试,十有八九你会被它的轻巧和速度惊喜。Java的高并发时代,现在才算真正翻开了新一页。

