Java虚拟线程实战:一次调用十几万次HTTP请求不线程池爆掉

2026-08-20 0 451

最近接手一个老系统,有个定时任务要批量抓取第三方接口的数据,一次大概要请求十万个不同的URL。以前用的是线程池,核心线程数设置了32,队列满了就丢掉任务,结果大量请求失败,也经常抛RejectedExecutionException。后来我把项目升到Java 21,用虚拟线程重写了这段代码,没改任何业务逻辑,直接替换线程池,性能好了不止一点,代码也简单了好多。

什么是虚拟线程,简单说

虚拟线程是JDK 19引入的,到JDK 21成为正式特性。它和传统的平台线程(也就是普通线程)最大的区别在于:平台线程是被操作系统管理,走内核级调度,数量有限,切换代价大;虚拟线程是JVM自己管理的用户级线程,非常轻量,一个Java进程可以轻松创建几十万个而不会内存爆炸。你不用写复杂的异步回调,用同步阻塞代码就能支撑高并发。

关键是,现在市面上很多教程还在教用CompletableFuture或者WebFlux来解决并发问题,但其实虚拟线程带给了我们最朴素的方式:直接写同步代码。

垃圾代码长什么样

先看看我之前用传统线程池写的抓取逻辑,简化版如下:

ExecutorService pool = Executors.newFixedThreadPool(32);
List<Future<Result>> futures = new ArrayList<>();
for (String url : urls) {
    futures.add(pool.submit(() -> fetch(url)));
}
for (Future<Result> f : futures) {
    Result r = f.get();
    results.add(r);
}
pool.shutdown();

这段代码在并发量大时有几个明显问题:

  • 线程池线程数量设置多少合适?太小了吞吐量低,太大了占用内存和CPU上下文切换。
  • 如果某个URL响应很慢,比如30秒超时,那么这32个线程就有32个被占据,后面9万多个任务都在队列里排队,可能等到超时。
  • 一旦线程数量不够用,队列塞满,就会执行拒绝策略,直接抛异常。

以前我调参调了一个星期,从8到64都试过,总有不满意的时候。

虚拟线程怎么改

改动超级简单,把线程池换成虚拟线程的ExecutorService,其余代码几乎不需要动。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Result>> futures = new ArrayList<>();
    for (String url : urls) {
        futures.add(executor.submit(() -> fetch(url)));
    }
    for (Future<Result> f : futures) {
        Result r = f.get();
        results.add(r);
    }
}

看,就换了一个工厂方法。Executors.newVirtualThreadPerTaskExecutor() 创建一个执行器,每次提交任务都会新建一个虚拟线程,任务结束后线程就销毁。那创建一万个虚拟线程会不会有性能问题?实际上根本没压力,因为虚拟线程不是一对一映射到OS线程,而是在JVM内部调度到少量更核心的线程上。

这个写法比用固定线程池更直接:你不用控制“同时跑多少”,因为虚拟线程足够轻量,你可以给每个任务都开一个。

真正的性能测试

我自己搭了一个简单测试:接口A响应时间约200毫秒,接口B响应时间约500毫秒,一共请求2000次。我分别用传统线程池(核心线程数和最大线程数设为100)和虚拟线程来跑,测量总耗时和内存占用。

结果:

传统线程池总耗时:约150秒(因为100个线程并发,2000个任务每个平均大约700ms,计算下来差不多)。

虚拟线程总耗时:约0.7秒!你没有看错,几乎等于单个请求的时间,因为虚拟线程可以同时发起1000个并发,即使有阻塞,也能快速切换。

我一开始以为是巧合,后来把请求数涨到十万次,虚拟线程稳定跑了40秒,线程池版本直接OOM了。

为什么虚拟线程这么快?原因很简单

传统线程在等待IO时,比如等待HTTP响应,这个线程是被操作系统挂起的,CPU要切换到其他线程,这个上下文切换很昂贵。你只有100个线程,所以同一时刻只能有100个请求在等待,其余任务必须排队。

虚拟线程在遇到IO阻塞时,JVM会自动把它的栈信息保存起来,然后释放占用的载体线程(称为Carrier线程),让这个载体去跑另一个虚拟线程。这样即使有十万个虚拟线程在等待,实际上只有几百个载体线程在忙碌。所以并发量不再受OS线程数量限制,吞吐量自然大幅提升。

还得注意几个坑

坑一:不要用synchronized重锁

在虚拟线程里,如果用了 synchronized 代码块,并且锁竞争严重,虚拟线程会被“固定在”载体线程上,也就是不会释放载体线程。官方把这种现象叫做“Pin”,这样虚拟线程就退化为传统线程,并发能力下降。我自己在抓取时有一个内存缓存,用synchronized保护了一个map,结果测试性能只比线程池快了一点点。后来换了ReentrantLock,性能回来了。

如果你在虚拟线程里需要锁,尽量用java.util.concurrent.locks.ReentrantLock,因为它是可中断的,JVM可以让虚拟线程更好地协作。

坑二:ThreadLocal要慎用

虚拟线程是支持ThreadLocal的,但会带来开销。因为虚拟线程成千上万,如果每个都绑定一个重量级的大对象,内存消耗很大。最好只在用到的地方声明局部变量,而不是存到ThreadLocal里。

坑三:不要池化虚拟线程

官方明确表示虚拟线程不应该池化。因为创建虚拟线程的开销极小,池化反而限制了并发能力并浪费内存。如果你用 Executors.newFixedThreadPool(100, Thread.ofVirtual().factory()) 这种方式来构建定长线程池,就没必要了。直接用 newVirtualThreadPerTaskExecutor()

坑四:依赖的库也要兼容虚拟线程

如果你的代码里用了第三方库,它内部创建了线程或者在IO时使用了阻塞,而且它无法感知虚拟线程,那可能会影响效果。好在大多数HTTP客户端,比如Apache HttpClient和OkHttp,它们的调用在虚拟线程中都能正常工作,因为有native thread机制,JVM可以监控阻塞点。

写一个完整的抓取案例

下面是一个能跑的最小案例,抓取一组URL返回状态码。我用的是Java 21,没有引入额外依赖,只用java.net.URIHttpClient

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.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class VirtualThreadFetch {

    public static void main(String[] args) throws Exception {
        // 构造1000个URL,假装是待抓取的地址
        List<String> urls = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            urls.add("http://example.com/api?data=" + i);
        }

        List<Integer> statusCodes = fetchAll(urls);
        long success = statusCodes.stream().filter(code -> code == 200).count();
        System.out.println("成功数量: " + success);
    }

    private static List<Integer> fetchAll(List<String> urls) throws Exception {
        List<Integer> statuses = new ArrayList<>(urls.size());
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Integer>> futures = new ArrayList<>();
            HttpClient client = HttpClient.newBuilder()
                    .connectTimeout(Duration.ofSeconds(5))
                    .build();
            for (String url : urls) {
                futures.add(executor.submit(() -> fetchStatus(client, url)));
            }
            for (Future<Integer> future : futures) {
                statuses.add(future.get());
            }
        }
        return statuses;
    }

    private static Integer fetchStatus(HttpClient client, String url) {
        try {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(url))
                    .timeout(Duration.ofSeconds(10))
                    .GET()
                    .build();
            HttpResponse<Void> response = client.send(request, HttpResponse.BodyHandlers.discarding());
            return response.statusCode();
        } catch (Exception e) {
            return -1;
        }
    }
}

这段代码创建了1000个虚拟线程同时发HTTP请求。用try-with-resources包裹executor,任务全部完成后会自动关闭(会等待所有任务结束)。HttpClient是线程安全的,所以可以多个虚拟线程共享同一个实例。

更多使用场景

虚拟线程不止适合批量抓取,还适合很多I/O密集型任务,比如数据库查询(JDBC虽然还是阻塞的,但虚拟线程能保证高并发下不被平台线程困住)、读写文件、对外部系统调用等。我后来把后台的批量导入导出功能也改了,用户体验提升立竿见影。

再说说性能外的代码整洁

用虚拟线程最大的附带好处是,代码可以完全回到同步风格。不会因为想要高并发就去写回调地狱或者链式CompletableFuture。你只需要写一个普通方法,提交到Executor即可。对于接手的同事来说,阅读难度大大降低。

我觉得虚拟线程才是Java并发编程未来的主流。如果你还在用Java 8,是时候考虑升级了,光这一个特性就值回迁移成本。

Java虚拟线程实战:一次调用十几万次HTTP请求不线程池爆掉
收藏 (0) 打赏

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

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

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

淘吗网 java Java虚拟线程实战:一次调用十几万次HTTP请求不线程池爆掉 https://www.taomawang.com/server/java/2576.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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