昨天我还在为一个爬虫程序发愁。任务很简单:抓取一百个网页,每个网页的响应比较慢,大概500毫秒。我一开始用固定线程池,开了10个线程,结果跑完花了好几秒。后来换成Java 21的虚拟线程,代码改动不超过3行,总耗时直接降到600毫秒左右。这可不是玄学,虚拟线程就是能打。
这篇文章不整虚的,直接给你一个可以跑起来的例子。我们用JDK自带的HttpClient和HttpServer,模拟一个高延迟的网页服务,然后分别用固定线程池和虚拟线程池去抓取100个请求,看看两者的差距到底有多大。
为什么虚拟线程能快这么多?
传统线程池里的线程是平台线程,也就是操作系统线程。每一个平台线程都要占用一部分内核资源,而且数量有限。当线程执行到网络请求、睡眠这类IO阻塞操作时,这个平台线程只能干等着,不能处理别的任务。如果你开了100个线程,那就有100个平台线程在阻塞,代价非常高。
虚拟线程是JVM自己管理的轻量级线程,它不跟操作系统线程一一绑定。虚拟线程在执行过程中如果遇到阻塞,JVM会把底层的平台线程让出来,去执行其他虚拟线程。等你那个IO操作完成了,JVM再找另一个平台线程把虚拟线程调度起来。所以你可以创建几万个虚拟线程,每个线程都阻塞着等响应,底层却只有少数几个平台线程在跑。IO密集型任务就像被开挂了一样。
环境准备
你只需要安装JDK 21,不需要任何第三方依赖。虚拟线程在Java 21中已经是正式功能了,不用加什么启动参数。我用的是命令行直接编译运行,不搞花里胡哨的Maven。
第一步:写一个模拟高延迟的网页服务
为了公平对比,我用JDK自带的HttpServer模拟一个网页。这个服务收到请求后先睡500毫秒,代表一次真实的网络IO等待,然后返回一个OK字符串。
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
public class MockServer {
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8088), 0);
// 服务端也用虚拟线程,避免成为性能瓶颈
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/mock", exchange -> {
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
}
byte[] resp = "ok".getBytes();
exchange.sendResponseHeaders(200, resp.length);
exchange.getResponseBody().write(resp);
exchange.close();
});
server.start();
System.out.println("mock server started at http://localhost:8088/mock");
}
}
把上面这个类保存为MockServer.java,编译运行。
javac MockServer.java
java MockServer
看到 “mock server started” 就说明服务已经起来了。
第二步:用固定线程池和虚拟线程池分别抓取100个请求
下面这个对比类稍微长一点,但是逻辑很简单。我定义了一个runWith方法,接收一个ExecutorService,然后往里面提交100个HTTP请求任务,等所有任务返回后统计耗时。
固定线程池用10个线程,虚拟线程池用Executors.newVirtualThreadPerTaskExecutor(),这个工厂方法会为每一个任务创建一个新的虚拟线程。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Compare {
static final String URL = "http://localhost:8088/mock";
static final int TOTAL = 100;
static HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
static Callable<String> createTask() {
return () -> {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(URL))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
};
}
static void runWith(ExecutorService executor, String name) throws Exception {
long start = System.currentTimeMillis();
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < TOTAL; i++) {
futures.add(executor.submit(createTask()));
}
int success = 0;
for (Future<String> future : futures) {
if ("ok".equals(future.get())) {
success++;
}
}
long cost = System.currentTimeMillis() - start;
System.out.println(name + ": " + cost + " ms, 成功 " + success + " 个请求");
executor.shutdown();
}
public static void main(String[] args) throws Exception {
runWith(Executors.newFixedThreadPool(10), "固定线程池(10个线程)");
runWith(Executors.newVirtualThreadPerTaskExecutor(), "虚拟线程池(每任务一线程)");
}
}
保存为Compare.java,编译运行。
javac Compare.java
java Compare
注意:一定要先运行MockServer,再运行Compare,不然会连不上端口。
实测结果:差距不是一点点
我本机测试的结果是这样的(你的机器可能略有波动):
固定线程池(10个线程): 5021 ms, 成功 100 个请求
虚拟线程池(每任务一线程): 587 ms, 成功 100 个请求
固定线程池用了大约5秒,虚拟线程池只用了不到0.6秒。速度提升了大约8倍。这还是在只有100个请求的情况下。如果请求数量变成1000个,固定线程池可能需要50秒,而虚拟线程池可能还是几秒钟完事。
为什么会这样?因为10个固定线程必须串行处理10个任务,每个任务等500毫秒,那么10个任务就要5秒。而虚拟线程池里的100个虚拟线程可以同时发起请求,所有的500毫秒等待是重叠的,所以总耗时接近500毫秒加一点网络开销。
使用虚拟线程的注意事项
虚拟线程不是银弹。它特别适合IO密集型任务,比如HTTP调用、数据库访问、文件读写。但是如果你做的是CPU密集型计算,比如图像处理、视频编码、复杂算法,虚拟线程不会带来收益,甚至可能因为调度开销让性能变差。
另外,千万不要想着“复用”虚拟线程。虚拟线程的一大优点就是创建成本极低,所以根本没有必要搞一个池子。遇到一个任务就new一个虚拟线程,用完就扔掉,这才是正确姿势。
还有一点,别在虚拟线程里跑同步锁。如果虚拟线程同时申请多个锁,可能会出现“锁竞争导致平台线程被占住”的问题。当然这是比较极端的情况,日常简单的同步块影响不大。如果真遇到了,可以看看JEP 491里的改进。
除了抓网页,虚拟线程还能用在哪儿?
最常用的场景就是Web服务器。比如Spring Boot 3.2之后支持把Tomcat的请求处理线程换成虚拟线程,只需要在配置里加一行:
spring.threads.virtual.enabled=true
这样每个HTTP请求都会跑在一个独立的虚拟线程上,你再也不用为Tomcat最大线程数发愁了。如果你的接口涉及远程调用、数据库查询这类IO操作,虚拟线程会让服务端的并发能力上一个台阶。
还有消息消费者、文件批处理、爬虫任务,只要不是CPU密集,都可以考虑虚拟线程。你只需要把原来的Executors.newFixedThreadPool(n)改成Executors.newVirtualThreadPerTaskExecutor(),其余代码几乎不用动。
总结
Java 21的虚拟线程确实带来了革命性的变化。写并发代码不再需要纠结线程池大小,IO密集场景直接上虚拟线程,效果立竿见影。文章里这个案例是一个很典型的对比实验,你自己跑一遍就能感受到那种“怎么这么快”的惊喜。
以后写HTTP客户端、调用外部接口、处理消息队列,我都优先用虚拟线程。毕竟,能让我少死几亿个脑细胞的技术,就是好技术。

