Java 再升级!JDK21 + 虚拟线程技术大规模落地方案解密(1)
2025 年,RedJDK21 带着其明星特性——Java 虚拟线程,在小红书搜索、推荐、广告 Java 核心服务大规模落地,带来 10% 链路 RT 优化及平均 24% CPU 降低,为业务指标提升、服务器成本优化以及业务架构升级提供了有力支撑。虚拟线程为什么会有这么好的效果?使用虚拟线程需要注意什么?本文从虚拟线程原理、Java 锁实现、小红书落地方案与未来规划等角度展开详细介绍,为大家提供 Java 性能优化新
1.1 Java 虚拟线程概念
1.1.1 多线程的美好理想
在当今的多处理器架构和超线程技术下,硬件能力已经可以很好支持软件层多线程同时完成任务,假设某台机器有 256 个处理器单元,如果我们把所有处理器一起用上,是否我们可以将任务加速 256 倍,或同时完成数百个计算任务?带着这样朴素、美好的想法,我们来看看多线程处理的“理想”与现实之间还有什么阻碍。
小红书的后端 Java 业务需要同时满足大量小红书用户的请求,在微服务架构体系下,一个请求可能需要经过复杂的链路调用才能成功返回。因此我们需要创建大量线程保存调用上下文信息;这些线程大多处在阻塞状态,少量会被调度到 CPU 上执行,线程资源利用率极低。
在传统 Java 线程模型中,每个 Java 线程都会消耗一个内核线程资源,我们经常能看到一台机器上运行着十几个 Pod,每个 Pod 创建数千个线程,为内核带来严重的线程管理负荷、Context Switch 开销和随之引发的 CPU Cache miss;另外,线程数过多直接影响 GC Root 数量,GC STW 耗时也会显著上升。
1.1.2 虚拟线程 vs 平台线程
虚拟线程是 JDK19 中正式引入的重要特性,于 JDK21 生产可用。虚拟线程是由 JVM 管理、调度的轻量级线程。传统的 Java 线程实际上与 OS 线程一一对应,借由内核的线程管理能力完成 Java 线程的执行调度与内存管理。为了与虚拟线程的概念相区分,我们用“平台线程”来指代传统 Java 线程,平台线程占用内核线程资源,不可无限扩张,线程分配达到内核限制会导致 OOME 发生。而虚拟线程不占用内核线程资源,创建虚拟线程只需要在分配少许内存用于存放虚拟线程对象。
下图展示了虚拟线程、平台线程、OS线程、硬件线程以及物理处理器的关系:

1.1.3 使用虚拟线程会带来什么变化?
通过虚拟线程技术,业务执行逻辑和执行上下文保存在用户层,JVM 仅需要预先分配极少量的 OS 平台线程(CarrierThreads),用来调度执行虚拟线程。若使用传统平台线程模式,我们需要超量分配线程来处理高并发任务,内核会基于“完全公平”的策略调度;而在虚拟线程模式下,我们可以避免平台线程超量分配(设置 CarrierThreads 不超过物理核心数),CarrierThreads 持续运行在 CPU 上,大幅降低内核态开销损耗。
根据 JDK 官方说明,JDK 虚拟线程可以轻松支持百万数量级并发,线程数量不再是系统瓶颈。在高并发场景中,开发者可以通过同步编程的思维、方式,开发出高吞吐、高性能的系统。
1.2. JVM 如何实现虚拟线程
想要实现稳定、高效的虚拟线程,替换掉传统的平台线程,JVM 需要解决几个核心问题:
-
正确管理虚拟线程状态流转
-
实现线程调度能力,保存与恢复虚拟线程上下文,并尽可能控制调度开销
-
考虑用户心智,降低用户学习成本——最好可以用使用平台线程的方式,直接使用虚拟线程
1.2.1 调度执行与阻塞管理
调度模块
举个生动的例子来说明调度模块,JDK 开了一家米其林牛排馆,生意火爆,店里有 n 个灶台。为了做出好吃的牛排,需每个牛排需要反复经过煎炒、醒肉(将牛排静置一段时间)、再次煎炒的操作。起初, JDK 聘请了很多厨师,每当牛排煎至半熟,厨师会带着自己的牛排离开拿去静止醒肉,灶台让给其他厨师。可是厨师工资昂贵,数量有限,JDK 就在想,能不能让厨师和牛排解绑,醒肉的时候厨师完全可以立刻煎制另一块牛排,牛排醒肉结束后也可以让空闲的厨师继续煎制,这样厨师就没法偷懒了!
JVM 需要会将可执行的虚拟线程(VT)与平台线程(CarrierThreads)绑定,当 VT 阻塞时与平台线程解绑,也就如同宝贵的厨师资源不用和每块牛排绑定。应用逻辑上下文全部记录保存在 VT,可以按照实际并发需求决定 VT的数量。CarrierThreads 不需要太多——根据并行计算原理,计算的并行度不可能突破物理核数上限(抢不到灶台的厨师也只能干等着),线程数量与物理计算单元数匹配即可。这样,可以做到保证计算能力的同时,尽可能减小内核态损耗。
不难想到,这个要求与 ForkJoinPool 机制不谋而合。JVM 使用一个虚拟线程专用的 FJP 作为虚拟线程调度器,管理 n 个工作线程(作为 CarrierThreads),n 默认对齐 CPU 核数。每当 VT 需要进入阻塞状态,则 Worker 需要将该 VT 转移到阻塞管理模块,保存好其上下文(如栈帧、寄存器),然后尝试从任务队列获取一个新的任务。CarrierThread 优先从本地队列中获取可执行任务,通过 WorkStealing 机制保障负载均衡。与传统 FJP 区别是:
-
传统 FJP 专为分治任务设计,采用后进先出的策略获取任务,而 Virtual FJP 需要保证任务执行顺序公平,采用先进先出
-
传统 FJP 遇到阻塞后线程会直接挂起,而 Virtual FJP 会将阻塞 VT 卸载掉,持续执行其他可执行 VT
下图左侧体现了虚拟线程调度策略,右侧展示了 OpenJDK21 中的阻塞管理模块。
不得不承认的是,非 FJP 逻辑(如GC、JIT、Java 平台线程)同样需要被调度执行,“零上下文切换”的假设比较理想化——并且,我们仍需注意,当前的虚拟线程设计中 n 并不是一个常数,而会随实际负载情况动态变化,如此设计的原因我们在会在“阻塞管理”中介绍。
阻塞管理
如上图所示,当 CarrierThread 挂载执行的 VT 需要进入阻塞状态,例如获取锁失败、等待 I/O 结果等,如果它直接陷入内核态阻塞,那么该 VT 会带着其 CarrierThread 共同进入阻塞状态,可用于计算执行的线程数下降,CPU 算力无法得到充分使用,这显然不合适。因此,JVM 要有能力中断或恢复 VT 的执行,具体来说,JDK 通过以下机制实现:
1.3.1 切换开销对比
使用 PingPong benchmark 进行测试,将进程绑定在一个 CPU 上
启动参数:-Xmx1g -Xms1g -XX:ActiveProcessorCount=1(虚拟线程的 CarrierThread 数量为 1)
测试结果(取五次结果的平均值)显示:在该 Benchmark 上,JDK21 相较于JDK11 性能提升31倍
可以看到, Java 虚拟线程并不能 Cover 所有阻塞点,其中 Synchronized 在 Java 工程中应用极为广泛,例如 Netflix 曾遇到由此导致的“死锁”问题,警示业界谨慎使用:《Netflix: Java21 Virtual Threads - Where's my lock?》; 官方建议 JDK21 用户通过 JUC 锁全量替代 Synchronized,但在拥有复杂依赖的大型工程落地缺乏可行性。
1.3 线程 vs 虚拟线程 开销对比
类似虚拟线程的概念在 C++、golang、kotlin 中均有先例,通常这些轻量级线程也被称为协程(coroutine)或纤程(fiber),无论是哪一种实现,都需要考虑如何管理协程的栈内存数据,并支持协程上下文切换。Java 虚拟线程选择通过共享线程栈的模式,在上下文切换时动态拷贝栈帧,并通过懒加载技术降低栈帧拷贝的数量与开销。我们从上下文切换耗时、吞吐、内存占用三个角度展开了测试。
-
CarrierThread 会及时卸载(unmount)准备进入阻塞状态的VT,并挂载(mount)另一个可运行的 VT
-
被卸载的 VT 通过阻塞管理模块管理,当阻塞管理模块探查到 VT 从可以继续运行,则通过 submit 操作将其塞回就绪队列
-
卸载与挂载的过程需要妥善保管(freeze)或恢复(thaw) VT 执行上下文

为了避免 CarrierThread 陷入阻塞,JVM 需要在阻塞行为之前 hook 住 VT 的执行,并完成卸载、补偿等操作。JDK 枚举所有 VT 的阻塞点,在阻塞点中加入卸载代码。下面列出 JDK 针对不同情况的处理方式:
-
Socket I/O 操作:卸载VT,将 <VT, fd> 映射关系存入表中,通过异步 Poller 线程轮询管理
-
文件 I/O 操作:VT 即将陷入磁盘读写状态(pinned),额外申请一个 CarrierThread 补偿算力损失
-
JUC 锁:卸载 VT,VT 在锁队列中排队,等锁 owner 释放后会 Notify 下一个线程/虚拟线程
-
Synchronized:轻量级锁会 CAS 获取(无阻塞),ObjectMonitor 下会陷入阻塞(pinned)
-
Native 代码阻塞:直接陷入阻塞(pinned),无法处理
|
虚拟线程 |
平台线程(jdk21) |
平台线程(jdk11) |
|
|
耗时 |
802ms |
21051 ms |
24970ms |
1.3.2 吞吐测试
使用 netty-benchmark 进行测试(规格 8C16G):
-
虚拟线程的 latency(p90、p99)远低于线程的 latency
-
虚拟线程可支持 qps 最高提升 5 倍
-
当并发为 64 时,线程达到吞吐瓶颈。并发度达到 256 时虚拟线程达到吞吐瓶颈;
可以看到,使用虚拟线程后吞吐、时延钧大幅优化,且可以通过提高并发线程数来进一步提高吞吐。

1.3.3 内存占用对比
创建 10k 个平台线程和 10k 个虚拟线程对比,使用 NMT、jmap 等工具查看内存消耗
-
线程:通过 NMT 查看 “Thread Commited Memory Size”,评估 Native 栈空间占用
-
虚拟线程: 通过 jmap 查看 StackChunk/Continuation/VirtualThread 等对象内存占用
· Continuation 栈:几百字节到几KB
· 内存开销:每个虚拟线程占用 200-240 字节
实验结果:创建 10k 个平台线程堆外占用约 349MB;使用虚拟线程堆外占用 8.5MB,Java Heap 大小约为 43MB

更多推荐


所有评论(0)