Java 21虚拟线程实战:用协程级并发构建高吞吐量网络抓取器

2026-06-20 0 233

在微服务里同时调用几十个下游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的高并发时代,现在才算真正翻开了新一页。

Java 21虚拟线程实战:用协程级并发构建高吞吐量网络抓取器
收藏 (0) 打赏

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

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

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

淘吗网 java Java 21虚拟线程实战:用协程级并发构建高吞吐量网络抓取器 https://www.taomawang.com/server/java/2255.html

常见问题

相关文章

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

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