Java 虚拟线程 Virtual Thread:让“每请求一线程”在高并发时代复活

先说结论:Virtual Thread 到底改变了什么?

先把重点摊开说清楚:

1)它不是"新一代 ThreadPoolExecutor" ,而是把原来那个沉重的 Thread 拆成了两层:

  • 底层少量 平台线程(OS 线程)

  • 上面跑着大量 虚拟线程(Virtual Thread)

2)对你来说,最大变化只有一句话:

你可以继续写"看起来是阻塞"的同步代码,但 JVM 会帮你用"协程式"的方式顶住高并发。

换句话说:

1)以前我们都知道:

"每请求一线程"写起来爽,但线程太重,撑不了多少并发。

2)有了虚拟线程之后,JVM 的态度变成了:

"你就放心每请求一线程吧,我把线程变轻一点。"

这会带来几个非常现实的好处:

1)写法不变 / 思维不变

  • Controller / Service 里继续用同步风格: userService.findUser(id)orderService.listOrders(id),没有 MonoCompletableFuture 满天飞。

2)并发能力上一个数量级

  • 原来你不敢把线程池开到几千、几万;

  • 现在用虚拟线程,几万、几十万"每请求一线程"都可以认真考虑。

3)大部分普通 Web / RPC 服务,没那么刚需 WebFlux/Netty 了

  • 不是说 WebFlux/Netty 没用了,而是:

你终于可以用"最简单的写法"解决绝大多数高并发场景。

某位网友说过一句有点夸张又有点道理的话:

"一个 socket 对应一个虚拟线程就是最佳方案。"

这句话"最佳"二字有点吹过头,但有一点是对的:

在今天的 Java 里,"一个连接/请求一个虚拟线程"确实很可能是新的合理默认。

1. 回到老问题:为什么以前"每请求一线程"顶不住?

传统 Spring MVC / Tomcat 的模型,大概是这样:

  1. 客户端发来一个 HTTP 请求;

  2. Tomcat 从线程池里拿出一个 平台线程(OS 线程) 交给这个请求;

  3. 你的 Controller 里写一堆同步代码,里面各种阻塞调用:

    @GetMapping("/user/{id}")
    public UserProfile getUser(@PathVariable String id) {
    // 阻塞式 HTTP 调用
    User user = restTemplate.getForObject("http://user-service/" + id, User.class);
    // 阻塞式数据库调用
    List<Order> orders = jdbcTemplate.query("SELECT * FROM orders WHERE user_id = ?", rs -> {
    // ...
    }, id);
    return new UserProfile(user, orders);
    }

看起来很优雅,对吧?

问题是:这个请求在等待下游服务的时候,那个 OS 线程也在那儿干等。

一个线程默认栈空间几 MB;线程多了以后:

  • 内存被吃光;

  • 上下文切换越来越频繁,CPU 时间都浪费在切线程上。

所以才会有这些"补救方案":

1)调大 Tomcat 线程池:顶一阵,但迟早顶不住;

2)把阻塞调用改成异步:

  • Callable / DeferredResult / CompletableFuture / WebFlux;

  • 代码复杂度和心智负担一起上来。

抽象成一句话:

以前的"每请求一线程"模式,在高并发时代最大的问题就是:

线程太贵,阻塞太浪费。

于是大家才开始写:

  • sendAsync(...)

  • thenApply(...) / thenCompose(...)

  • Mono.zip(...) / flatMap(...)

能顶住并发没错,但可读性、可维护性确实打折扣。

2. Virtual Thread 是什么?和普通 Thread 有啥不一样?

先用一句话把定义讲清楚:

平台线程(Platform Thread) ≈ OS 线程

虚拟线程(Virtual Thread) = JVM 自己调度的"用户态线程"

2.1 关键差异:谁来调度、谁在"阻塞"

传统 Thread:

一个 Thread 就是一个 OS 线程;

当你在里面调 socket.read()jdbcHttpClient.send()

  • 线程卡在内核里等待 I/O;

  • 整个 OS 线程被占住,啥也干不了。

虚拟线程:

虚拟线程自己并不直接对应 OS 线程;

它们是跑在少量 carrier threads(承载线程) 上的一堆"轻量任务";

当虚拟线程遇到支持 Loom 的阻塞 I/O 时,JVM 会做两件事:

  1. 把这个虚拟线程的调用栈保存到堆里(挂起 / park);

  2. 让底层那个 OS 线程去执行其他虚拟线程;

  3. 等 I/O 好了,再把虚拟线程"抬回来"继续跑。

所以:

  • 从你代码的角度看,它仍然是"阻塞"调用;

  • 从 OS 的角度看,它更像是"协程挂起 + 任务切换"。

2.2 写法有多简单?看一眼代码就懂

传统线程池写法:

复制代码
ExecutorService executor = Executors.newFixedThreadPool(200);

for (String userId : userIds) {
    executor.submit(() -> {
        handleUser(userId); // 里面各种阻塞 I/O
    });
}

虚拟线程写法:

复制代码
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String userId : userIds) {
        executor.submit(() -> {
            handleUser(userId); // 同样是阻塞 I/O,但跑在虚拟线程上
        });
    }
}

注意:

1)handleUser 这个方法里的业务逻辑 完全不用改,还是同步阻塞;

2)唯一的变化,是"执行载体":

  • 以前:newFixedThreadPool(200) → 你得控制线程数,怕撑爆;

  • 现在:newVirtualThreadPerTaskExecutor()几乎就是"每任务一线程"

再说直白一点:

原来你为了高并发,

要么写复杂的异步 / 响应式代码,

要么死磕线程池参数。

现在换成虚拟线程后,

你可以先回到"最简单的同步写法"

然后把并发调度交给 JVM。

3. 一眼看懂的三段代码:为什么说 Virtual Thread "省心"?

我们用一个 具体小场景,用三段代码对比一下虚拟线程和传统写法的差别。

场景:

有一批 userId,对每个用户要做三件事:

1)HTTP 请求拉用户基本信息

2)查数据库拿订单列表

3)拼成一个 UserProfile 返回

目标:让这些用户并发处理,而不是一个一个顺序跑。

先写几个简单的公共类(方便后面的三段代码复用):

复制代码
record User(String id, String name) {}
record Order(String id, String userId, int amount) {}
record UserProfile(User user, List<Order> orders) {}

class UserRepository {
    List<Order> findByUserId(String userId) {
        // 模拟阻塞的 DB 调用
        sleep(50);
        return List.of(
                new Order("o1", userId, 100),
                new Order("o2", userId, 200)
        );
    }

    private void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
    }
}

HTTP 客户端我们用 JDK 自带同步版:

复制代码
HttpClient httpClient = HttpClient.newHttpClient();

User fetchUserBlocking(String userId) throws Exception {
    HttpRequest req = HttpRequest.newBuilder(
            URI.create("https://example.com/users/" + userId)
    ).build();

    HttpResponse<String> res = httpClient.send(
            req, HttpResponse.BodyHandlers.ofString()
    );

    // Demo:假装 body 就是用户名
    return new User(userId, res.body());
}

下面三种写法,业务逻辑完全一样,只是并发模型不同

3.1 固定线程池 + 阻塞 I/O:简单,但扩不动

这是大家最熟悉的那种:

复制代码
ExecutorService threadPool = Executors.newFixedThreadPool(200);
UserRepository repo = new UserRepository();

UserProfile handleUser(String userId) throws Exception {
    // 阻塞 HTTP
    User user = fetchUserBlocking(userId);
    // 阻塞 DB
    List<Order> orders = repo.findByUserId(userId);
    return new UserProfile(user, orders);
}

List<UserProfile> processUsersWithPlatformThreads(List<String> userIds) throws Exception {
    List<Future<UserProfile>> futures = new ArrayList<>();

    for (String id : userIds) {
        futures.add(threadPool.submit(() -> handleUser(id)));
    }

    List<UserProfile> result = new ArrayList<>();
    for (Future<UserProfile> f : futures) {
        result.add(f.get()); // 阻塞等待
    }
    return result;
}

特点:

写法超级直观:handleUser 就是一行一行的同步代码。

线程池大小必须非常小心:

  • 并发量一大,你要么把线程池开很大,内存和上下文切换扛不住;

  • 要么线程池开小一点,后面的请求都在排队。

这就是"每请求一线程"在高并发时代顶不住的原因。

3.2 CompletableFuture 铺满全场:性能好一点,但"思维变形"

为了少开线程、提高并发,大家会改成"异步式写法":

复制代码
CompletableFuture<User> fetchUserAsync(String userId) {
    HttpRequest req = HttpRequest.newBuilder(
            URI.create("https://example.com/users/" + userId)
    ).build();

    return httpClient
            .sendAsync(req, HttpResponse.BodyHandlers.ofString())
            .thenApply(res -> new User(userId, res.body()));
}

ExecutorService dbExecutor = Executors.newFixedThreadPool(50);
UserRepository repo = new UserRepository();

CompletableFuture<List<Order>> fetchOrdersAsync(String userId) {
    return CompletableFuture.supplyAsync(
            () -> repo.findByUserId(userId),
            dbExecutor
    );
}

CompletableFuture<UserProfile> handleUserAsync(String userId) {
    return fetchUserAsync(userId)
            .thenCombine(
                    fetchOrdersAsync(userId),
                    (user, orders) -> new UserProfile(user, orders)
            );
}

List<UserProfile> processUsersWithCompletableFuture(List<String> userIds) {
    List<CompletableFuture<UserProfile>> futures = userIds.stream()
            .map(this::handleUserAsync)
            .toList();

    CompletableFuture<Void> all =
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));

    return all.thenApply(v ->
            futures.stream()
                    .map(CompletableFuture::join)
                    .toList()
    ).join();
}

特点:

不再是"一个请求占一个 OS 线程":

  • HTTP 使用真正的异步 I/O(sendAsync);

  • DB 查询放在有限的线程池里跑。

代价是:整个世界变成了 CompletableFuture<T>

  • 业务函数签名从 UserProfile 变成了 CompletableFuture<UserProfile>

  • 一堆 thenApply / thenCombine / allOf

  • 调试异常、添加超时/重试都变得更绕。

一句话:性能更好一点,但"逻辑变成了 Future 流水线"。

3.3 虚拟线程 + 阻塞写法:写回最简单,性能又上去了

现在换成虚拟线程:

核心思想只一句话:

"我们故意回到最朴素的'阻塞 + 每请求一线程',

但这个线程不再是 OS 线程,而是虚拟线程。"

复制代码
UserRepository repo = new UserRepository();
HttpClient httpClient = HttpClient.newHttpClient();

// 业务逻辑:完全是同步写法
UserProfile handleUserBlocking(String userId) throws Exception {
    User user = fetchUserBlocking(userId);             // 阻塞 HTTP
    List<Order> orders = repo.findByUserId(userId);    // 阻塞 DB
    return new UserProfile(user, orders);
}

// 用虚拟线程并发处理所有 userId
List<UserProfile> processUsersWithVirtualThreads(List<String> userIds) throws Exception {
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {

        List<Future<UserProfile>> futures = new ArrayList<>();
        for (String id : userIds) {
            // 每个任务一个虚拟线程
            futures.add(executor.submit(() -> handleUserBlocking(id)));
        }

        List<UserProfile> result = new ArrayList<>();
        for (Future<UserProfile> f : futures) {
            result.add(f.get());
        }
        return result;
    }
}

你会发现几件事:

1)业务代码几乎就是 3.1 版本的复制粘贴

  • handleUserBlocking 的写法,和最传统的阻塞版本一模一样;

  • 没有 CompletableFuture / Mono / flatMap 之类的心智负担。

2)而执行器这一行,彻底改变了并发模型

复制代码
Executors.newVirtualThreadPerTaskExecutor()
  • 以前:newFixedThreadPool(200) → 你必须控制线程数,怕炸;

  • 现在:几乎就是"每任务一个虚拟线程",虚拟线程由 JVM 以协程方式调度;

  • fetchUserBlocking 在等网络、findByUserId 在等 DB 时,这个虚拟线程会被挂起,不占用 OS 线程。

3)调试体验回到同步世界

  • 栈跟同步代码一样完整;

  • try / catch / finally 照常工作;

  • 不用在脑子里"模拟 Future 链"。

小结:为什么说 Virtual Thread "省心"?

把这三段代码放一起看,你会发现一个很有趣的结论:

3.1 固定线程池 + 阻塞

  • 写起来最顺手,但高并发扛不住。

3.2 CompletableFuture + 异步

  • 能扛住高并发,但代码变成"回调/Future 流水线"。

3.3 虚拟线程 + 阻塞写法

  • 写法和 3.1 差不多简单, 而扩展能力更接近 3.2。

这就是虚拟线程真正"省心"的地方:

它没有发明一套新语法,

只是让你可以 继续用同步思维写高并发

把"怎么调度、怎么挂起、怎么复用 OS 线程"这堆麻烦事,

丢给 JVM 去做了。

4. 那 WebFlux / Netty 还香吗?本质差异到底在哪?

讲到这里,另一个经典问题就出来了:

"那 WebFlux / Netty 这套 NIO 非阻塞栈,还香吗?

Virtual Thread 上来,是要把它们淘汰吗?"

答案很简单:不会被淘汰,但"默认位置"在变化。

4.1 高层对比:两个世界的"并发心智模型"完全不同

Virtual Thread + Spring MVC 的世界:

模型:每请求 / 每连接一个虚拟线程

1)写法:同步阻塞风格,Controller / Service 基本不用改;

2)遇到 I/O 阻塞时:

  • 虚拟线程挂起,

  • 承载它的 OS 线程去执行别的虚拟线程;

3)调度者:JVM

你脑子里的画面:

"一堆轻量线程在跑,写代码就像以前那样写同步逻辑,JVM 在后台帮你把阻塞变挂起。"

WebFlux / Netty 的世界:

1)模型:少量事件循环线程 + 非阻塞 I/O

2)写法:一切变成 Mono / Flux / 回调 / Handler;

3)I/O 准备好后,通过回调 / operator 推动数据流继续往前;

4)调度者:框架 + 事件循环

你脑子里的画面:

"几个事件循环线程盯着 Selector(epoll/kqueue),哪个 socket 有事件,就调用对应 Handler/Subscriber。"

用一句话概括本质差异:

  • Virtual Thread:"阻塞是假的,JVM 会帮你挂起虚拟线程"

  • WebFlux/Netty:"不准阻塞,一切都要变成事件/流"

4.2 从"你写代码"的角度看,还有两个实质差别

1)代码风格:同步 vs 响应式

Virtual Thread:

  • Controller:返回普通对象;

  • Service:写同步方法,try/catch 即可。

WebFlux:

  • Controller:返回 Mono<T> / Flux<T>

  • Service:到处是 map / flatMap / zip

  • 异常、超时、重试都变成流上的 operator。

2)生态适配成本

Virtual Thread:

  • 大量历史库本身就是阻塞式(JDBC、MyBatis、很多第三方 SDK),用起来天然契合;

WebFlux:

  • 想真正"全响应式",要用 R2DBC、响应式 Redis/Kafka/...,改造成本不小。

4.3 "Selector 没用了 / 一个 socket 一个虚拟线程是最佳方案吗?"

这里顺带澄清两个常见误解:

1)"Selector 没用了"是错的

  • 对你来说:是的,你不再需要手写 Selector/NIO/epoll;

  • 但对 JVM 实现来说:底层还是得用 epoll/kqueue/Selector 这类机制,只是封装在虚拟线程调度里了。

2)"一个 socket 一个虚拟线程 = 最佳方案"是过于绝对

  • 对大部分普通 Web / RPC / 后台服务,"一个连接 / 请求一个虚拟线程"确实很合理

  • 但极端高频、极致延迟、细粒度优化的场景(自研网关、高频交易、海量长连接推送)里,事件驱动 + Netty 依然是王道。

5. 实战选型建议:什么场景用 Virtual Thread,什么场景继续用 WebFlux/Netty?

直接给"拍板"式建议,方便你在实际项目里选。

5.1 普通业务后端(CRUD + 多个下游 HTTP/DB 调用)

特征:

  • Spring Boot + MVC 为主;

  • 大量数据库访问、缓存、HTTP 调用;

  • 团队多数人更熟悉同步风格。

推荐:Spring MVC + 虚拟线程(优先考虑)

  • 改造成本极低: 原来的 Controller / Service 基本不动,只是换一个执行器。

  • 调试、排错、带新人都简单。

  • 高并发时,虚拟线程可以帮你撑住"每请求一线程"的模式。

一句话:

这类场景基本可以把"Virtual Thread + MVC"当成 新默认

5.2 已经在跑 WebFlux / 响应式全家桶的项目

特征:

  • Controller 已经是 Mono / Flux

  • 用的是 WebClient、R2DBC、响应式 Redis / Kafka 等;

  • 团队已经习惯 Reactor 响应式思维。

推荐:继续 WebFlux,不要为了"追新"硬切回 MVC

  • 你已经为响应式模型付出了学习成本和改造成本;

  • 在流式处理、背压控制方面,响应式模型本来就很合适;

  • 可以在非 HTTP 请求路径(比如任务调度、内部计算)引入虚拟线程,不冲突。

5.3 极端性能敏感 / 自研协议 / 高频系统

特征:

  • 追求 p99 / p999 延迟;

  • 对 buffer、零拷贝、内存布局、线程模型有极致追求;

  • 很多时候都不是 HTTP,而是自定义协议。

推荐:Netty / 手写 NIO / 事件驱动仍然是首选

  • 在这类场景里,你就是要 100% 掌控线程和 I/O 模型;

  • 虚拟线程虽然很强,但多一层抽象就少一点"死抠性能"的空间;

  • Loom 可以在内部某些子任务上加持,但不会替代底层 I/O 的精细控制。

5.4 大量历史阻塞库 / 无法一步切到响应式

特征:

  • JDBC / MyBatis / Hibernate 为主;

  • 各种第三方 SDK 只有阻塞 API(云厂商、支付、IM 等)。

推荐:虚拟线程几乎是"天选解"

WebFlux 世界里,这些阻塞库都得丢到独立线程池,很容易污染事件循环;

虚拟线程完全不介意你阻塞:

  • 你就安心地 xxxClient.call()

  • JVM 会在阻塞点把虚拟线程挂起,并复用 OS 线程。

6. 结尾:Virtual Thread 不是"银弹",但值得成为你的新默认选项

最后用几句话收个尾:

1)Virtual Thread 不是魔法,也不是银弹

  • 它不会让你的 SQL 自动变快,也不会消灭网络延迟;

  • 它解决的是一个非常具体的问题:

"Java 里同步阻塞风格的代码,怎么在高并发时代继续活下去?"

2)它让"最简单的写法"重新变得可扩展

  • 以前:每请求一线程很爽,但只能做小并发;

  • 现在:有了虚拟线程,"每请求一虚拟线程"可以认真当成设计选项。

3)它改变的是"默认姿势",不是要消灭别的栈

  • WebFlux / Netty 依然有用,尤其是在响应式流处理、极端性能场景;

  • 但对绝大多数普通 Java 后端开发者来说,你可以先问自己一句:

"这件事,我能不能先用 Virtual Thread + 简单同步写法搞定?"

如果你正在做一个新项目,或者维护一个典型的 Spring Boot 服务,非常建议你做这么一件小事:

1)搭一个最小 Demo:

  • Spring Boot 3.x + Spring MVC;

  • 配上虚拟线程执行器;

  • 写几个"会慢一点"的 HTTP / DB 调用;

2)再用一个简单压测工具(wrkab、JMeter)跑一跑, 体会一下"线程数几乎不用担心、代码又保持同步风格"的感觉。

当你真的亲手跑过一次之后,

你会发现:

Java 并发这件事,确实有点"时代变了"。

不再是"要性能就得写一堆回调",

而是 ------ "先写最简单的同步代码,再交给虚拟线程和 JVM 去撑高并发"。

相关推荐
一勺菠萝丶40 分钟前
解决 SLF4J 警告问题 - 完整指南
java·spring boot·后端
济南壹软网络科技有限公司43 分钟前
架构深潜:通霸IM——私有化部署、全链路开源的高可用企业级即时通讯技术基座
java·架构·开源·即时通讯源码·即时通讯im
小股虫1 小时前
手搓限流第二版:限流算法与动态阈值的深度整合
java
TracyCoder1231 小时前
大白话讲Java NIO
java·开发语言·nio
零日失眠者1 小时前
【文件管理系列】001:文件批量重命名工具
后端·shell
Together_CZ1 小时前
FlowFormer: A Transformer Architecture for Optical Flow——一种用于光流估计的Transformer架构
架构·transformer·光流·architecture·光流估计·flowformer·optical flow
社恐的下水道蟑螂1 小时前
深度探索 JavaScript 的 OOP 编程之道:从基础到进阶
前端·javascript·架构
魂梦翩跹如雨1 小时前
P8615 [蓝桥杯 2014 国 C] 拼接平方数——Java解答
java·c语言·蓝桥杯
申阳1 小时前
Day 19:02. 基于 SpringBoot4 开发后台管理系统-项目初始化
前端·后端·程序员