Java 21虚拟线程实战:用HttpClient并发抓取100个网页的性能对比教程

2026-08-11 0 492

昨天我还在为一个爬虫程序发愁。任务很简单:抓取一百个网页,每个网页的响应比较慢,大概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客户端、调用外部接口、处理消息队列,我都优先用虚拟线程。毕竟,能让我少死几亿个脑细胞的技术,就是好技术。

Java 21虚拟线程实战:用HttpClient并发抓取100个网页的性能对比教程
收藏 (0) 打赏

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

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

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

淘吗网 java Java 21虚拟线程实战:用HttpClient并发抓取100个网页的性能对比教程 https://www.taomawang.com/server/java/2523.html

常见问题

相关文章

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

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