Java21虚拟线程实战篇-从原理到Spring-Boot落地

0 阅读16分钟

前言

"Java 终于有了看起来像协程的能力,但它又不完全是 Go 协程。"

虚拟线程是 Java 21 最受关注的新特性之一。很多文章喜欢把它写成“Java 版 goroutine”,或者一句话概括成“底层就是 NIO 多路复用”。

这样的说法传播起来很快,但并不够严谨。

这篇文章我想做两件事:

  1. 讲清虚拟线程到底解决什么问题
  2. 把几处最容易被讲偏的地方说准确

一、什么是虚拟线程

传统平台线程和操作系统线程基本是一对一关系,线程创建和上下文切换成本都比较高。

虚拟线程由 JVM 调度,可以把大量任务复用到少量平台线程上执行。它最核心的价值是:

  • 让阻塞式代码继续保持同步写法
  • 在大量阻塞等待场景下提升并发能力
  • 降低“每个请求一个线程”模型的成本

转存失败,建议直接上传图片文件


二、三种并发模型对比

1. 传统 Java 平台线程

转存失败,建议直接上传图片文件

特点:

  • 与 OS 线程基本 1:1
  • 线程数上去后,内存和调度成本都很明显
  • 阻塞 IO 会直接占住线程

2. Java 21 虚拟线程

特点:

  • 由 JVM 调度,属于 M:N 模型
  • 适合大量阻塞型任务
  • 写法仍然是熟悉的同步代码
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> httpClient.get(url));
    }
}

3. Go 协程

Go 的 goroutine 和 Java 虚拟线程确实有相似目标,都是为了低成本并发,但两者不是简单的一一对应。


三、虚拟线程和 Go 协程,到底像不像

先说结论:

  • 目标相似:都想解决高并发下线程太重的问题
  • 体验不同:Java 更强调兼容现有同步代码
  • 调度实现也不同:不能简单说“本质一样”

对比表如下:

特性Go GMPJava 虚拟线程
调度风格运行时调度 goroutineJVM 调度虚拟线程
目标低成本并发低成本并发
用户代码风格Go 并发范式原生同步阻塞风格
迁移成本需要新语言现有 Java 代码更容易迁移

更准确的理解应该是:

Java 借助虚拟线程,把“高并发 IO 场景下仍然写同步代码”这件事做得更现实了;它不是在语义上变成 Go,也不是在工程上要求你整套迁移到另一种并发范式。


四、调度机制该怎么理解才不容易误导

这里是最容易被写偏的地方。

1. 先把三个概念拆开:虚拟线程、载体线程、OS 线程

  • 虚拟线程(Virtual Thread)是 JVM 管理的轻量级线程
  • 载体线程(Carrier Thread)是实际承载虚拟线程执行的平台线程
  • 平台线程底层通常对应 OS 线程,所以可以把载体线程理解成“JVM 拿来跑虚拟线程的那批真实线程”

一个虚拟线程并不是永久绑定某一个载体线程。

更准确地说:

  • 当虚拟线程真正运行 Java 代码时,它会被挂载到某个载体线程上
  • 当它遇到可卸载的阻塞点,比如很多 JDK 支持的网络 IO、BlockingQueuesleep 等,JVM 可以把它卸载下来
  • 之后等它再次变成可运行状态,再重新挂载到某个载体线程上继续执行

所以,虚拟线程和载体线程的关系,更像是“阶段性借用”,不是“一对一长期绑定”。

2. Java 虚拟线程到底有没有 Work Stealing

有,但要说准确。

JEP 444 的原话是:虚拟线程调度器是一个 work-stealing ForkJoinPool,并且 operates in FIFO mode

这句话至少说明三件事:

  • 它不是“完全没有 Work Stealing”
  • 它也不是“只有一个简单 FIFO 队列”
  • 这里的 FIFO mode 说的是任务队列的调度策略,不等于“像 Go 那样对正在运行的任务做时间片轮转”

更稳妥的理解是:

  • 每个载体线程背后可以有自己的本地任务队列
  • 某个载体线程空闲时,可以去别的队列里偷可运行任务
  • 被偷走的,通常是“已经就绪、等待挂载执行”的虚拟线程任务

3. 载体线程之间会不会“抢虚拟线程”

会,但不是很多人以为的那种抢法。

如果你说的“抢”是:

  • 一个虚拟线程还在某个载体线程上连续执行
  • 另一个载体线程把它硬生生从 CPU 上拽走

那么,JDK 21 里不能这么理解。

原因是:JEP 444 同时明确说了,虚拟线程调度器 does not currently implement time sharing

这句话非常关键,意思是:

  • JDK 21 当前没有对正在运行的虚拟线程做时间片式强制抢占
  • 一个虚拟线程在某个载体线程上运行时,通常会一直跑到它阻塞、让出、结束,或者进入其他调度边界
  • 载体线程之间真正会发生的,是对“可运行但当前未挂载”的虚拟线程进行再分配和 work stealing

所以,更准确的话术是:

载体线程之间会窃取和分配可运行的虚拟线程任务,但不会像 Go 的调度器那样,对一个当前已挂载并正在执行的虚拟线程做时间片式抢占。

4. FIFO 到底在说什么

FIFO mode 很容易被误解成“先进先出,所以没有 stealing”。

其实不是。

这里更应该理解成:

  • 调度器在处理可运行任务队列时,偏向 FIFO 风格
  • 这会影响新任务和已唤醒任务的排队顺序
  • 但这不排斥 work stealing 的存在

再细一点说,ForkJoinPool 里的“FIFO / LIFO”也不是一句话能讲完的:

  • 队列的拥有者(owner)和来偷任务的线程(thief),观察和取任务的方向本来就可能不同
  • 所谓 FIFO mode,主要是说拥有该队列的 worker 以更接近 FIFO 的方式处理自己队列里的任务
  • thief 去偷任务时,关注的是“从对方可窃取的一端拿走任务”,它不是简单复用 owner 的取任务顺序

所以,FIFO mode 不能机械理解成“所有 worker 对所有任务都统一先进先出”。

也就是说,FIFO 解决的是“队列里先后顺序怎么取”,而 work stealing 解决的是“空闲工作线程如何从别处拿活干”,这两个概念不是互斥关系。

5. 和 Go 的 GMP 相比,到底差在哪

Go 的运行时模型通常概括为 G-M-P

  • G 是 goroutine
  • M 是 machine,可以理解为真正执行 goroutine 的 OS 线程
  • P 是 processor,表示运行 goroutine 所需的调度上下文和本地运行队列

可以把它粗略类比成:

  • Java 虚拟线程有点像 Go 的 G
  • Java 的载体线程有点像 Go 的 M
  • 但 Java 没有一个和 Go P 完全一一对应、对外显式暴露的概念

如果从“任务排队和偷任务”的角度粗略类比,可以这样理解:

  • Go 可以比较明确地理解成“每个 P 有本地运行队列,同时还有全局运行队列”
  • Java 虚拟线程调度器则更接近“ForkJoinPool 风格的 worker 本地队列 + 共享提交结构(submission queues) + work stealing”

这里还要再补一句,避免理解过头:

  • ForkJoinPool 里的外部提交任务并不等同于 Go 那种语义清晰的“全局运行队列”
  • worker 会扫描本地队列、其他 worker 的可窃取队列,以及共享的 submission queues
  • 拿到任务后就可以执行,不必机械理解成“总是先搬回自己的本地队列,再开始跑”

还有一个容易混淆的点要分开:

  • 外部线程提交进 ForkJoinPool 的,通常可以理解成“根任务”,它们先进入共享的 submission queues
  • worker 在执行这些任务的过程中,如果继续 fork 出子任务,这些子任务更典型地进入该 worker 管理的本地队列

所以,“外部提交任务怎么到本地队列”这个问题,不能简单回答成“都会先搬过去”。

更准确的理解是:

  • 外部提交的任务先走共享提交通道
  • 某个 worker 认领后可以直接执行
  • 真正大量待在 worker 本地队列里的,往往是这个 worker 后续拆分、派生、维护的任务

但这只是帮助理解的近似说法,不应该把它写成“Java 就是另一套 GMP”。

Go 这边的调度更强调几件事:

  • 每个 P 有本地运行队列
  • 本地队列空了,会去别的 P 那里偷一部分 goroutine
  • 本地队列太满时,也会把一部分任务放到全局队列
  • goroutine 如果长时间占着 CPU,不发生阻塞,也可能被调度器抢占

而 Java 虚拟线程在 JDK 21 下,更准确的总结是:

  • 有 work stealing
  • 有挂载 / 卸载
  • 适合大量等待型任务
  • 但当前没有 Go 那种面向正在运行任务的时间片式 time sharing

6. Go 的“抢占”和“时间片”怎么理解

这也是最容易被讲糊的地方。

Go 早期更多依赖协作式让出,比如:

  • 系统调用
  • channel 操作
  • sleep
  • 函数调用中的调度检查点

后来 Go 引入了异步抢占能力,用来解决“纯 CPU 循环长期不让出”导致别的 goroutine 饿死的问题。

所以今天更准确的说法应该是:

  • Go 既有协作式让出,也有异步抢占
  • 调度器会尽量避免一个 goroutine 长时间独占某个 M/P
  • 运行时存在一个大约 10ms 的调度时间片概念,用来判断一个 goroutine 是否占用 CPU 过久

但这个“时间片”也不要机械理解成传统 OS 那种完全等价的固定片轮转。

它更像是:

  • 调度器判断“这个 goroutine 已经跑得太久了,该让别人也有机会了”
  • 然后在合适的安全点或抢占机制下,把执行权转给别的 goroutine

所以,把 Go 和 Java 放在一起时,最稳妥的一句话是:

Java 虚拟线程和 Go goroutine 都是轻量级并发单元,也都有 work stealing;但 Go 的运行时在“对正在运行任务的抢占与时间片控制”上更积极,而 JDK 21 虚拟线程更强调挂载 / 卸载,以及对阻塞等待场景的高效复用。


五、它是不是“底层就是 NIO 多路复用”

这个说法也太满了。

更准确的理解是:

  • 用户代码层面,虚拟线程通常表现为同步阻塞风格
  • 对很多 JDK 层面的阻塞操作,虚拟线程可以在阻塞时卸载,不把载体线程一直卡死
  • 但不能把所有阻塞都机械理解为“自动变成 epoll/selector/NIO”

换句话说,虚拟线程确实让“同步代码 + 高并发 IO”这件事更可行,但它不等于一句口号式的“JVM 把 BIO 自动全部改写成 NIO”。

如果一定要一句话总结,我更建议这样说:

你写的是同步阻塞风格代码,JVM 会尽量让等待型阻塞别浪费宝贵的平台线程。


六、为什么虚拟线程对 Java 生态意义很大

1. 兼容现有代码习惯

public User getUser(Long id) {
    User user = userDao.findById(id);
    List<Order> orders = orderDao.findByUserId(id);
    user.setOrders(orders);
    return user;
}

这类同步写法,Java 开发者非常熟。

2. 不必默认走 Reactive

很多团队并不是不想要高并发,而是不想把整套代码都改成响应式链式调用。

虚拟线程的价值就在这里:在大量阻塞 IO 场景下,它给了你另一条更平滑的路。

3. 对老项目更友好

如果系统本来就是 thread-per-request 模型,那么虚拟线程带来的迁移阻力通常比全面改造成异步或响应式低得多。


七、怎么创建虚拟线程

1. 直接创建

Thread thread = Thread.ofVirtual().start(() -> {
    System.out.println("Hello from virtual thread");
});

2. 用 ExecutorService

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(this::doBlockingIO);
}

这是业务代码里最常见、也最顺手的方式。


八、两个非常实用的落地场景

场景一:批量 HTTP 请求

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<String> results = urls.stream()
        .map(url -> executor.submit(() -> httpClient.get(url)))
        .toList()
        .stream()
        .map(future -> {
            try {
                return future.get();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        })
        .toList();
}

场景二:批量数据库查询

public List<Document> fetchDocuments(List<Long> ids) {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        return ids.stream()
            .map(id -> executor.submit(() -> documentRepository.findById(id)))
            .toList()
            .stream()
            .map(future -> {
                try {
                    return future.get();
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            })
            .filter(Objects::nonNull)
            .toList();
    }
}

这类“任务很多,但大多数时间都在等 IO”的场景,正是虚拟线程的主战场。


九、Spring Boot 里到底怎么用

很多文章喜欢一句话写成:

spring.threads.virtual.enabled=true

然后再补一句“Tomcat、@Async、异步请求都会自动切到虚拟线程”。

这个说法太粗糙。

更准确的理解应该是:

  • Spring Boot 3.2+ 提供了 spring.threads.virtual.enabled=true
  • 开启后,如果上下文里没有你自己定义的 Executor bean,Spring Boot 自动配置的 AsyncTaskExecutor 会变成使用虚拟线程的 SimpleAsyncTaskExecutor
  • 它会影响到依赖该执行器的一些场景,比如 @EnableAsync、Spring MVC 异步处理等
  • 但如果你自己定义了 Executor、线程池或容器相关配置,就要按你自己的配置为准

配置示例:

spring:
  threads:
    virtual:
      enabled: true

所以,正确姿势不是“开一个配置就万事大吉”,而是:

  1. 先确认你的应用是不是阻塞 IO 为主
  2. 再确认关键链路有没有自定义线程池
  3. 最后通过压测和监控验证收益

1. 老项目是不是可以完全不配线程池了

不能一刀切。

如果你原来的线程池,主要只是为了“多开线程扛阻塞 IO”,那么迁到 Java 21 + Spring Boot 3.2+ 后,很多默认线程池配置确实可以删掉。

但如果你原来的线程池承担的是“治理职责”,那就不应该简单删除。

典型包括:

  • 业务隔离,比如订单、通知、报表各跑各的,互不拖累
  • 限流和限并发,防止数据库、Redis、下游 HTTP 接口被打爆
  • CPU 密集任务,需要严格控制并发度
  • 指定队列容量、拒绝策略、线程命名、监控指标、任务装饰器等
  • 保护遗留系统、脆弱依赖或外部中间件

也就是说:

  • 为了“顶住大量等待型任务”而建的线程池,可以优先评估是否删掉
  • 为了“隔离、治理、限流、观测”而建的线程池,通常仍然值得保留

2. @Async 会不会自动走虚拟线程

也不能一句话回答“会”。

更准确的规则是:

  • @Async 生效的前提,仍然是你启用了 @EnableAsync
  • 如果你没有自定义 TaskExecutor / Executor / AsyncConfigurer,那么开启 spring.threads.virtual.enabled=true 后,@Async 通常会走 Spring Boot 自动配置的虚拟线程执行器
  • 如果你自己定义了唯一的 TaskExecutor bean,或者定义了名为 taskExecutorExecutor bean,Spring 会优先使用它
  • 如果你通过 AsyncConfigurer#getAsyncExecutor() 明确返回了一个执行器,那么 @Async 就按这个执行器来

所以,关键不在 @Async 这个注解本身,而在于:

@Async 最终绑定到的是哪个 Executor

3. 自定义线程池后,是不是就一定不用虚拟线程了

也不是。

你完全可以自定义一个“虚拟线程执行器”,而不是传统平台线程池。

例如:

@Bean("vtExecutor")
public Executor vtExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

然后:

@Async("vtExecutor")
public void doSomething() {
    // ...
}

这时,@Async 依然走的是虚拟线程,只不过它走的是你显式指定的虚拟线程执行器,而不是 Spring Boot 默认提供的那个。

4. 大型项目里,什么时候建议继续保留自定义执行器

下面这些场景,我通常建议继续保留:

  • 报表导出、文件转换、批处理这类重任务
  • 消息消费、重试补偿、外部回调等需要分舱治理的异步流程
  • 访问下游容量很敏感的系统,比如老数据库、老 ES、第三方收费接口
  • 混合型项目里既有大量阻塞 IO,也有 CPU 密集计算
  • 你希望不同模块使用不同的线程命名、监控口径和拒绝策略

而下面这些场景,更适合优先尝试默认虚拟线程执行器:

  • 面向请求的阻塞式 Web 服务
  • 典型的数据库查询、HTTP 调用、缓存访问
  • 原来大量使用线程池,主要只是为了提高等待型任务的并发能力

一句话总结:

Java 21 不是让你彻底告别执行器配置,而是让你不必再把“线程池调参”当成处理阻塞 IO 并发的默认武器;默认场景可以少配很多,但涉及隔离、限流和治理的地方,仍然应该显式配置执行器。


十、虚拟线程适合什么,不适合什么

适合不适合
大量阻塞 IO长时间 CPU 密集计算
高并发短任务长时间占用载体线程的任务
传统同步风格服务把所有问题都指望虚拟线程解决
thread-per-request 架构缺少监控和压测就直接上线

虚拟线程不是“更便宜的万能线程”,它更像是“为等待型任务量身优化的线程模型”。


十一、使用虚拟线程时最容易踩的坑

1. synchronized 不是绝对不能用,但要避免长时间 pinning

很多文章会写成“不要用 synchronized,一律换 ReentrantLock”。这太绝对。

更准确的说法是:

  • 短暂、低频的 synchronized 不一定有问题
  • 真正要警惕的是:在持有监视器期间发生长时间阻塞,导致虚拟线程无法及时卸载

2. ThreadLocal 要控制体积和数量

private static final ThreadLocal<HeavyObject> cache = new ThreadLocal<>();

如果你创建了大量虚拟线程,又在每个线程上挂很重的 ThreadLocal,内存压力会很快上来。

3. 不要跳过压测

虚拟线程非常适合 IO 密集型场景,但是否真的提升吞吐、降低延迟,必须让真实压测说话。


十二、一个更靠谱的结论

虚拟线程最值得兴奋的地方,不是“Java 终于也有协程了”,而是:

Java 终于在不强迫大家整体迁移编程范式的前提下,把高并发 IO 这件事做得更自然了。

你仍然可以写熟悉的同步代码,但在合适场景下拿到更好的并发能力。

这才是它真正的工程价值。


参考资料

Oracle 官方虚拟线程文档: docs.oracle.com/en/java/jav…

JEP 444: openjdk.org/jeps/444

Spring Boot 任务执行与调度文档: docs.spring.io/spring-boot…

Spring Framework @EnableAsync 文档: docs.spring.io/spring-fram…

Spring Boot 3.2 Release Notes(虚拟线程): github.com/spring-proj…

Go 调度器设计: go.dev/s/go11sched

Go 1.14 发布说明(异步抢占): go.dev/doc/go1.14

Go runtime 源码(时间片与抢占相关实现入口): cs.opensource.google/go/go/+/ref…


欢迎关注公众号 FishTech Notes,一块交流使用心得!