Java虚拟线程实测:我把一个笨重的图片爬虫提速了8倍

2026-08-31 0 687

上个月接了个私活,帮客户批量抓取一些高清壁纸网站上的图片。逻辑并不复杂,无非是请求网页、解析图片地址、然后一个个下载到本地。最开始我写了一个单线程版本,跑起来太慢了,一百张图片要跑几分钟。后来我改用线程池,把下载任务丢给固定线程去跑,速度快了不少,但线程池的大小很难调整,调大了内存吃紧,调小了又跑不满带宽。

正好手头项目用了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终于在这方面争了一口气。希望这个案例对你有用。

Java虚拟线程实测:我把一个笨重的图片爬虫提速了8倍
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java虚拟线程实测:我把一个笨重的图片爬虫提速了8倍 https://www.taomawang.com/server/java/2673.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务