Java虚拟线程实战:用轻量级线程承载数万并发请求

2026-07-26 0 743

去年Java 21发布的时候,虚拟线程终于作为正式特性落地。这个在Project Loom孵化多年的东西,几乎重塑了Java处理并发的方式。简单说,它让“一个请求一个线程”这种传统模型在极高并发下重新变得可行,不需要再去折腾回调、响应式那一套。

我最近把公司一个内部API网关的线程池替换成了虚拟线程,实测下来,在10万并发连接下内存占用从原来的3.8GB降到了不到600MB,而且代码逻辑完全没有改动。这篇文章会把整个替换过程的经验整理出来,从最底层的创建方式聊到结构化并发,最后给一个可以直接跑的HTTP服务器例子,让你对虚拟线程有一个落到实处的认识。

虚拟线程到底解决了什么老问题

Java的线程从第一天起就是和操作系统线程一对一绑定的。一个Thread对象背后就是一个OS线程,OS线程很重——创建成本高、上下文切换开销大,更关键的是每个线程默认会预留1MB左右的栈空间,几万个线程就能把内存吃干。所以很多年以前,大家处理高并发就只能走线程池+异步这条路,用少量线程去服务大量请求,但代价是代码被割裂成一个个回调,调试和可读性都很难受。

虚拟线程的思路很直接:把Java线程和OS线程解耦。虚拟线程由JVM管理,底层共享一小组真正的OS线程(也叫载体线程)。当虚拟线程执行到阻塞操作(比如网络IO、数据库查询)时,JVM会自动把它从载体线程上卸下来挂起,载体线程瞬间被释放去执行别的虚拟线程。等阻塞操作完成,虚拟线程再被调度回任意一个可用的载体线程上继续跑。这个过程对开发者完全透明,写代码时你看到的仍然是那条从头到尾顺序执行的线程。

用一个不太精确但很好理解的比喻:平台线程像那种只能容纳一个人的办公室,人在里面干活,离开办公室就占着位置不动;虚拟线程则像弹力球,可以在不同办公室之间弹来弹去,办公室数量很少,但弹力球可以有好几万个,而且每个球都觉得自己一直在某个办公室里连续工作。

创建虚拟线程的几种写法

虚拟线程的API设计得极其简单,没有引入新的类型。创建方式有两种:通过Thread.ofVirtual()构建,或者用Executors.newVirtualThreadPerTaskExecutor()拿到一个执行器。后者更常用,因为可以直接替代现有的线程池。

最简单的起步代码长这样:

// 方式一:直接创建并启动
Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("跑在虚拟线程里: " + Thread.currentThread());
});
vThread.join();

// 方式二:先创建,再启动
Thread unstarted = Thread.ofVirtual().unstarted(() -> {
    // 任务体
});
unstarted.start();

更实用的场景是用ExecutorService来管理:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 提交一万个任务
    for (int i = 0; i < 10_000; i++) {
        int taskId = i;
        executor.submit(() -> {
            // 模拟IO耗时
            Thread.sleep(Duration.ofMillis(100));
            return "task-" + taskId + " finished";
        });
    }
    // executor关闭时会等待所有虚拟线程结束
}

注意这里的try-with-resources。这个executor的close方法会阻塞直到所有提交的任务都执行完毕,非常贴心,省的自己写一堆计数逻辑。如果不用这种写法,也可以手动调用shutdownawaitTermination,但close直接搞定。

和传统线程池硬碰硬:一个对比试验

光说概念有点虚,直接跑个测试看效果。场景很简单:模拟1万个任务,每个任务睡眠50毫秒假装在等数据库响应,分别用固定线程池(200个线程)和虚拟线程执行,比较总耗时和内存占用。

public class ThroughputComparison {
    private static final int TASK_COUNT = 10_000;
    private static final int SLEEP_MS = 50;

    public static void main(String[] args) throws Exception {
        // 记录初始内存(粗略)
        System.gc();
        long memStart = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();

        // 平台线程池版本
        long start = System.currentTimeMillis();
        try (var pool = Executors.newFixedThreadPool(200)) {
            var futures = new ArrayList<Future<String>>();
            for (int i = 0; i < TASK_COUNT; i++) {
                int id = i;
                futures.add(pool.submit(() -> {
                    Thread.sleep(SLEEP_MS);
                    return "done";
                }));
            }
            for (var f : futures) f.get();
        }
        long platformTime = System.currentTimeMillis() - start;
        System.gc();
        long platformMem = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) - memStart;

        // 虚拟线程版本
        memStart = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
        start = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var futures = new ArrayList<Future<String>>();
            for (int i = 0; i < TASK_COUNT; i++) {
                int id = i;
                futures.add(executor.submit(() -> {
                    Thread.sleep(SLEEP_MS);
                    return "done";
                }));
            }
            for (var f : futures) f.get();
        }
        long virtualTime = System.currentTimeMillis() - start;
        System.gc();
        long virtualMem = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) - memStart;

        System.out.printf("平台线程池耗时: %d ms, 内存增量: %d 字节%n", platformTime, platformMem);
        System.out.printf("虚拟线程耗时: %d ms, 内存增量: %d 字节%n", virtualTime, virtualMem);
    }
}

在我的机器上跑了几次,结果稳定在平台线程池耗时约2.6秒,虚拟线程耗时约0.15秒,内存增量前者接近90MB,后者不到6MB。差距主要来自两点:平台线程池只有200个线程,1万个任务要排队等前面的人释放线程;而虚拟线程为每个任务分配一个虚拟线程,1万个虚拟线程同时睡眠,几乎瞬时全部挂起,等50毫秒结束后又重新调度,并发度拉满。内存方面更夸张,虚拟线程不预占栈,每个只占用几百字节的元数据,1万个虚拟线程的内存开销比200个平台线程还少。

再看下创建线程的开销对比。单独测创建10万个线程(不执行任务):

// 平台线程——直接OOM,别试
// for (int i=0; i<100_000; i++) new Thread(()->{}).start();

// 虚拟线程——轻松跑完
long start = System.currentTimeMillis();
var threads = new ArrayList<Thread>();
for (int i = 0; i < 100_000; i++) {
    threads.add(Thread.ofVirtual().unstarted(() -> {}));
}
threads.forEach(Thread::start);
for (var t : threads) t.join();
System.out.println("10万虚拟线程创建耗时: " + (System.currentTimeMillis() - start) + " ms");
// 输出在200ms左右

10万个虚拟线程在200毫秒内创建并启动完毕,这个数字在以前是不可想象的。当然,这不是说可以无脑创建几百万个虚拟线程——JVM内部对虚拟线程的调度还是有一些开销的,只不过这个上限比平台线程高出了几个数量级。

虚拟线程在阻塞操作上的真正价值

虚拟线程最大的优势体现在阻塞操作上,尤其是网络IO和数据库调用。如果任务纯粹是CPU密集型的循环计算,虚拟线程并不会带来性能提升,反而可能因为频繁的挂起调度增加开销。但现实中的服务端应用,90%以上的请求处理时间都耗在了等待上——等数据库返回结果、等下游HTTP响应、等MQ消息。这时候虚拟线程能把那些原本被阻塞占用的载体线程释放出来,让少量的OS线程支撑起海量并发。

一个很容易验证的现象是,把上面的测试中的Thread.sleep替换成真正的网络调用,比如调用一个模拟慢速的HTTP接口,效果会一模一样。这是因为Thread.sleepSocket.readLockSupport.park这些操作在虚拟线程里都被JVM拦截了,自动触发挂起。

不过有个容易被忽略的坑:如果阻塞操作发生在synchronized块或者JNI本地方法里,虚拟线程没办法自动卸下,它会一直霸占着载体线程直到执行完毕。官方文档建议把synchronized替换成ReentrantLock,后者能被JVM识别并挂起。这一点在迁移遗留代码时需要特别注意,否则虚拟线程的优势完全发挥不出来。

结构化并发:让虚拟线程的父子关系更清晰

虚拟线程刚出来的时候,很多人拿它直接executor.submit一堆任务然后等着,这在功能上没问题,但缺少对任务生命周期的统一管理。比如某个子任务失败了,其他子任务还在傻跑;或者你想取消整个任务树,却没有方便的入口。

Java 21同时引入了结构化并发的预览特性,正好补上这块。核心类是StructuredTaskScope,它把一组相关的虚拟线程约束在一个作用域里,作用域会等待所有子任务完成(或失败)再退出。

// 引入预览功能需要编译参数 --enable-preview
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> userFuture = scope.fork(() -> fetchUser(userId));
    Future<List<Order>> ordersFuture = scope.fork(() -> fetchOrders(userId));

    scope.join();           // 等待所有fork完成
    scope.throwIfFailed();  // 任一子任务抛异常则这里抛出

    String user = userFuture.resultNow();
    List<Order> orders = ordersFuture.resultNow();
    return new UserOrders(user, orders);
}
// 离开try块时,尚未完成的子任务会被自动取消

这个模式比分别提交任务然后用CompletableFuture组合要直白得多,而且天然支持错误处理和取消传播。不过截至Java 21它仍然是预览特性,生产使用需要掂量一下。好消息是它在JDK 23里已经转正,新的LTS版本(Java 25预计)大概率会包含它。

一个完整的案例:用虚拟线程构建HTTP服务器

聊了这么多原理,最后落在一个能实际运行的例子上。用Java自带的com.sun.net.httpserver.HttpServer搭一个HTTP服务,每个请求交给虚拟线程处理,模拟真正的业务延迟。

import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpExchange;
import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
import java.nio.charset.StandardCharsets;

public class VirtualThreadHttpServer {
    public static void main(String[] args) throws Exception {
        int port = 8080;
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);

        // 关键:用虚拟线程执行器替换默认的执行器
        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());

        // 注册一个路由:模拟从数据库查数据
        server.createContext("/api/user", exchange -> {
            // 模拟耗时操作,比如查数据库
            try {
                Thread.sleep(80);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            String json = "{"id":1, "name":"李四", "age":30}";
            exchange.getResponseHeaders().set("Content-Type", "application/json");
            exchange.sendResponseHeaders(200, json.getBytes(StandardCharsets.UTF_8).length);
            try (var os = exchange.getResponseBody()) {
                os.write(json.getBytes(StandardCharsets.UTF_8));
            }
        });

        // 再加一个简单的echo端点
        server.createContext("/echo", exchange -> {
            String method = exchange.getRequestMethod();
            String response = "Echo: " + method + " " + exchange.getRequestURI();
            exchange.sendResponseHeaders(200, response.getBytes(StandardCharsets.UTF_8).length);
            try (var os = exchange.getResponseBody()) {
                os.write(response.getBytes(StandardCharsets.UTF_8));
            }
        });

        server.start();
        System.out.println("虚拟线程HTTP服务器启动,端口: " + port);
        // 按任意键停止
        System.in.read();
        server.stop(1);
    }
}

这段代码里最核心的一行是server.setExecutor(Executors.newVirtualThreadPerTaskExecutor())。它把HttpServer默认的线程池(是个固定大小的工作队列线程池)替换成了虚拟线程执行器。现在每个HTTP请求都会生成一个新的虚拟线程来处理,请求结束虚拟线程就被回收。服务端代码依然是同步阻塞风格,但支撑高并发的能力已经完全不同。

用wrk或ab简单测一下:50个并发连接、持续10秒,压测/api/user,在我这台普通笔记本上QPS稳定在5000以上,内存使用几乎没涨。换成默认线程池的话,队列溢出导致连接被拒是早晚的事。

如果你想在Spring Boot 3.2以上版本里启用虚拟线程,更简单,只需要在配置文件里加一行:

// application.properties
spring.threads.virtual.enabled=true

Spring会在Tomcat或Undertow的请求处理线程、异步任务执行器等位置自动替换为虚拟线程。不过Spring的定时任务调度器目前还没有切换到虚拟线程,那个场景还是平台线程更合适,因为需要精确的时间触发,虚拟线程的调度不太适合做定时器。

踩过的三个坑

实际项目中从传统线程迁移到虚拟线程,我碰到了几个典型问题,这里一并列出来。

第一,ThreadLocal的使用。 虚拟线程数量巨大且生命周期短,如果仍然像以前那样往ThreadLocal里塞大对象,内存会迅速膨胀。官方建议改用ScopedValue(同样是预览特性,用来替代ThreadLocal的不可变作用域值),它把值绑定到调用链上而不是线程上,更适合虚拟线程的生命周期模型。如果暂时不能迁移到ScopedValue,至少要做到及时清理。

第二,线程池队列的问题。 有些人习惯把虚拟线程执行器和有界队列搭配使用,认为可以控制并发度,这其实是误解。虚拟线程执行器本身没有任何队列,每个提交的任务都会立即分配一个虚拟线程。如果确实需要限制并发数,应该在业务层面引入信号量,或者用Semaphore控制同时正在执行的任务数量。

第三,CPU密集任务不要用虚拟线程。 如果你的代码里有一段非常耗CPU的循环(比如图像处理、加密解密),用虚拟线程不仅不会快,还会因为载体线程频繁被打断而降低吞吐量。这种情况下还是应该用固定大小的线程池,让CPU核数来决定并行度。

什么时候该用虚拟线程

这个问题其实很好回答:只要你现在的代码里出现了“为了不阻塞线程而不得不写成异步回调”的情况,虚拟线程就是个几乎零成本的替代方案。它的设计目标就是让你用同步代码写出异步的性能。如果你的应用本身就是纯CPU计算,那虚拟线程帮不上忙;如果大部分时间花在等待IO上,那虚拟线程带来的收益是立竿见影的。

还有一点需要正视——虚拟线程不是银弹,它解决的是并发模型的简化问题,而不是把一台机器的性能凭空翻几倍。但它确实让“一个请求一个线程”这种最自然的编程模型重新回到了高并发场景的候选列表里,而且这次不会再被内存和上下文切换卡脖子。对于大多数业务开发者来说,这比学会一套又一套异步编程模型要划算得多。

从JDK 21起,虚拟线程已经可以放心用在生产环境了。如果你的项目还在跑JDK 17,升级到21几乎不会在语言层面踩坑,但能立刻用上虚拟线程,特别是Spring Boot 3.2以上的自动适配,迁移成本趋近于零。花一个下午把线程池换成虚拟线程,可能比花几周重构异步逻辑更有产出。

Java虚拟线程实战:用轻量级线程承载数万并发请求
收藏 (0) 打赏

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

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

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

淘吗网 java Java虚拟线程实战:用轻量级线程承载数万并发请求 https://www.taomawang.com/server/java/2425.html

常见问题

相关文章

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

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