Java 虚拟线程 Virtual Thread 让“每请求一线程”支持高并发

1.Virtual Thread 改变了什么?

先说重点:

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

底层少量 平台线程(OS 线程);上面跑着大量 虚拟线程(Virtual Thread)

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

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

换句话说:

1)以前我们都知道:

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

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

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

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

1)写法不变 / 思维不变

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

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

原来你不敢把线程池开到几千、几万;现在用虚拟线程,几万、几十万“每请求一线程”都可以认真考虑。

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

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

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

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

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

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

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

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

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

客户端发来一个 HTTP 请求;Tomcat 从线程池里拿出一个 平台线程(OS 线程) 交给这个请求;你的 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()、jdbc、HttpClient.send():

线程卡在内核里等待 I/O;整个 OS 线程被占住,啥也干不了。

虚拟线程:

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

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

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

把这个虚拟线程的调用栈保存到堆里(挂起 / park);让底层那个 OS 线程去执行其他虚拟线程;等 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)再用一个简单压测工具(wrk、ab、JMeter)跑一跑, 体会一下“线程数几乎不用担心、代码又保持同步风格”的感觉。

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

你会发现:

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

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

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

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...