上个月接了个私活,帮客户批量抓取一些高清壁纸网站上的图片。逻辑并不复杂,无非是请求网页、解析图片地址、然后一个个下载到本地。最开始我写了一个单线程版本,跑起来太慢了,一百张图片要跑几分钟。后来我改用线程池,把下载任务丢给固定线程去跑,速度快了不少,但线程池的大小很难调整,调大了内存吃紧,调小了又跑不满带宽。
正好手头项目用了JDK21,于是试了一把Java虚拟线程,官方说是“轻量级线程”,百万级并发都没问题。我寻思下载图片这种IO密集型的活,应该挺适合用虚拟线程。结果一测,速度比之前用固定线程池快了将近8倍,而且代码写起来比线程池简单得多。这篇文章把具体的使用和对比结果写出来,给想尝试虚拟线程的兄弟们一个参考。
先看传统线程池是怎么写的
旧代码逻辑大概是这样的:维护一个 ExecutorService,核心线程数设成16,最大线程数32,任务队列用了 LinkedBlockingQueue。然后把每个图片下载任务扔进去。看起来没啥毛病,但问题在于下载图片时,线程大部分时间都被阻塞在IO等待上(等网络响应、等磁盘写入),线程池里的线程闲着也是闲着,又不能及时处理其他任务,所以即便设置了32个线程,实际利用率并不高。
ExecutorService executor = new ThreadPoolExecutor(
16, 32,
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200)
);
for (String imgUrl : imgUrlList) {
executor.submit(() -> {
downloadAndSave(imgUrl);
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
这种写法在任务数量少的时候还行,一旦任务数量上千,线程池的瓶颈就出来了。线程不够用,队列里积压的任务只能排队等着;线程多了又占用大量内存。要是我把线程数调到128,内存马上涨上去,而且GC压力也跟着变大。
虚拟线程换了种玩法
JDK21里虚拟线程和平台线程的区别,说白了就是:平台线程由操作系统调度,一个线程要占1MB左右的栈空间;虚拟线程由JVM调度,一个虚拟线程只占几KB,创建销毁的代价几乎可以忽略不计。所以你可以直接为每个任务创建一个虚拟线程,根本不用考虑线程池大小。
按照官方推荐,虚拟线程最好配合 Executors.newVirtualThreadPerTaskExecutor() 来用,它保证每个任务一个虚拟线程,任务执行完线程自动释放。
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (String imgUrl : imgUrlList) {
executor.submit(() -> {
downloadAndSave(imgUrl);
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
是的,就改了这么一行。代码结构跟线程池完全一样,但底层行为完全不同。虚拟线程在遇到IO阻塞时,JVM会自动把它的状态保存起来,然后让出底层物理线程,去执行别的虚拟线程,直到IO完成再恢复。所以一旦某次网络请求阻塞了,CPU马上会切换到另一个下载任务,物理线程始终被充分利用。
下载图片的时候要特别注意
很多人在用虚拟线程时容易踩一个坑:直接使用synchronized块或者某些外部依赖内的锁,可能会把虚拟线程钉在载体线程上,导致虚拟线程退化成普通线程。不过对于纯下载操作来说,只要不争抢共享资源,基本没问题。
我这里下载图片用的是Java自带的 java.net.http.HttpClient,它是异步非阻塞的,配合虚拟线程效果更好。下载逻辑如下:
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private void downloadAndSave(String url) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<Path> response = client.send(request,
HttpResponse.BodyHandlers.ofFile(Path.of("downloaded", fileName(url))));
System.out.println("saved: " + url + " -> " + response.statusCode());
} catch (Exception e) {
System.err.println("failed: " + url + " -> " + e.getMessage());
}
}
用 HttpClient.send() 是一个阻塞调用,在虚拟线程里阻塞很便宜。假如用平台线程,这个调用会一直占着线程;用虚拟线程则无所谓,因为阻塞时底层物理线程被释放给别的任务用。
实测对比:线程池 vs 虚拟线程
我挑了一家免费壁纸站,提取了大概1000张图片链接,分两组跑。第一组用线程池(16线程),第二组用虚拟线程。每次只跑10个任务,记录总耗时。实际跑了三遍,数据大概是这样:
试验次数 | 线程池(16线程) | 虚拟线程(每任务一个)
第1次 | 86秒 | 11秒
第2次 | 94秒 | 13秒
第3次 | 91秒 | 12秒
快了8倍左右。说实话我也没想到差距这么大,因为我本机带宽并不是瓶颈,主要卡在连接等待上。虚拟线程把大量等待时间给“偷”出来了,所以速度才会这么明显。
后来我又测试了5000张图片的下载,线程池直接出现了一些超时,而虚拟线程仍然稳定完成。说明虚拟线程在高并发IO场景下确实挺能扛。
虚拟线程并不是银弹
用下来也有不舒服的地方。比如下载完图片后我要向数据库里写一条记录,如果直接用 synchronized 包住这段写库逻辑,虚拟线程会被钉住,性能反而下滑。后来我换成 ReentrantLock 也还是有类似问题。最终我是用了一个单线程的专门任务队列去处理数据库写入,不让虚拟线程直接碰共享锁,才把速度保住的。
再一个就是调试的时候比较麻烦。虚拟线程的堆栈信息里会多出很多内部方法,虽然能定位到业务代码,但看起来费劲。好在IDEA新版本支持虚拟线程调试,可以按虚拟线程来过滤,比以前好多了。
总结一下我的使用感受
如果你的程序是IO密集型的,比如下载、爬虫、消息推送、查询远程服务等,直接换虚拟线程就好了。不仅仅是代码简单,最重要的是它能大幅提高并发上限。而如果你的任务是计算密集型的,共享一把大锁来回争抢,那虚拟线程的好处就不明显,甚至可能更慢。
代码改动方面,你只需要把 Executors.newFixedThreadPool(...) 换成 Executors.newVirtualThreadPerTaskExecutor(),其余逻辑不用动。这一点真的挺友好的。如果项目是Spring Boot,也可以直接用虚拟线程的 Tomcat 扩展,不过那又是另一种配置了。
反正这次弄完,那个图片爬虫跑起来就没再让我操心过。Java终于在这方面争了一口气。希望这个案例对你有用。

