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 区别是:
下图左侧体现了虚拟线程调度策略,右侧展示了 OpenJDK21 中的阻塞管理模块。
不得不承认的是,非 FJP 逻辑(如GC、JIT、Java 平台线程)同样需要被调度执行,“零上下文切换”的假设比较理想化——并且,我们仍需注意,当前的虚拟线程设计中 n 并不是一个常数,而会随实际负载情况动态变化,如此设计的原因我们在会在“阻塞管理”中介绍。
阻塞管理
如上图所示,当 CarrierThread 挂载执行的 VT 需要进入阻塞状态,例如获取锁失败、等待 I/O 结果等,如果它直接陷入内核态阻塞,那么该 VT 会带着其 CarrierThread 共同进入阻塞状态,可用于计算执行的线程数下降,CPU 算力无法得到充分使用,这显然不合适。因此,JVM 要有能力中断或恢复 VT 的执行,具体来说,JDK 通过以下机制实现:
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),无法处理
可以看到, 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 虚拟线程选择通过共享线程栈的模式,在上下文切换时动态拷贝栈帧,并通过懒加载技术降低栈帧拷贝的数量与开销。我们从上下文切换耗时、吞吐、内存占用三个角度展开了测试。
1.3.1 切换开销对比
使用 PingPong benchmark 进行测试,将进程绑定在一个 CPU 上
启动参数:-Xmx1g -Xms1g -XX:ActiveProcessorCount=1(虚拟线程的 CarrierThread 数量为 1)
测试结果(取五次结果的平均值)显示:在该 Benchmark 上,JDK21 相较于JDK11 性能提升31倍左右!
1.3.2 吞吐测试
使用 netty-benchmark 进行测试(规格 8C16G):
可以看到,使用虚拟线程后吞吐、时延钧大幅优化,且可以通过提高并发线程数来进一步提高吞吐。
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
由于 OpenJDK21 虚拟线程与小红书生产环境要求还存在一定距离,RedJDK21 进行了一些改造适配工作:
针对 Synchronized 阻塞、Socket I/O 管理模块进行了改造,并提出自动补偿方案解决类加载阻塞、Native 阻塞等潜在风险;结合搜推业务对线程粒度分析的需求,我们成功支持用户像分析平台线程一样分析虚拟线程的 CPU 开销、stacktrace、锁信息等;最终提供了稳定、高效、无感接入的 Java 虚拟线程方案。
2.1 解决 synchronized 阻塞
在阻塞管理章节中提到,Synchronized 在 Java 生态使用广泛,JDK21 虚拟线程遇到 Synchronized 无法 unmount,存在较大“死锁”风险。小红书工程复杂的依赖关系下,很难消除所有 Synchronized 的使用。因此,要想稳定使用虚拟线程,就必须在 JDK 层针对 Synchronized 阻塞实现 VT 的 unmount 与相应阻塞管理能力。
JDK 社区同样看到了这个问题,JDK24 提出 JEP-491 ——重构 Synchronized 底层实现机制,让 Synchronized 可以和 JUC 锁一样支持虚拟线程的调度切换。在 《Netflix 如何使用 Java —— 2025版》中,Netflix 基于 JDK24 的能力全面落地虚拟线程。JDK24 并非 LTS,JDK25 短期内还难保稳定,因此我们决定在 JDK21 上实现 JEP-491 功能,支持虚拟线程使用。
2.1.1 对象头与锁模式
为了帮助大家理解为什么 Synchornized 会导致虚拟线程”死锁“,我们简单介绍下对象头与锁模式在最新 JDK 版本的实现。64 位架构的对象头示例如下:Mark Word表示存储锁、GC、HashCode等状态,Class Word 存储对象指向它的类型元数据的指针。 MarkWord 在不同锁状态下用于存储不同信息。
Synchronized 主要涉及以下四种锁模式:
偏向锁(Biased Lock)
传统轻量级锁(Legacy Mode)
新轻量级锁(Lightweight Mode)
重量级锁(ObjectMonitor)
其中,偏向锁和传统轻量级锁分别在 JDK15 和 JDK24 中被废弃,最新代码只保留了新轻量级锁和重量级锁。偏向锁由于其糟糕的实际生产表现(偏向失败导致的额外开销),被认为大多场景下“弊大于利”而取消。而轻量级锁需要在 MarkWord 中存放锁记录地址,这个机制成为 JDK 进一步压缩对象头内存占用的阻碍,因此诞生了新的轻量级锁模式。
2.1.2 虚拟线程对轻量级锁的冲击
传统 Java 轻量级锁基于“物理线程+固定栈地址”的设计,加锁时生成 BasicLock 并存储在栈上,在锁对象 MarkWord 中直接存储 BasicLock 在栈上的内存地址;由于虚拟线程会在 CarrierThreads 以及堆上(unmount时)之间调度切换,“固定的栈上地址”无法准确表达 BasicLock 实际位置。因此,虚拟线程需要与一种新的轻量级锁机制组合使用,这个机制就是 Lightweight_Lock,其锁状态存储存储在 LockStack 中,与栈地址空间解绑,解决了栈迁移导致的锁指针失效问题,成为支撑虚拟线程 synchronized 高效运行的核心基础。
| | |
| 偏向锁:依赖 Mark Word 快速判断,撤销偏向锁有额外开销; 传统轻量级锁:MarkWord 指向栈帧 BasicLock(物理栈地址) | 新轻量级锁:基于 LockStack(绑定 JavaThread/VirtualThread)存储锁对象; 重量级锁:全局 Hash 表映射锁对象与 Monitor,释放 MarkWord 空间 |
| 极致单机性能(偏向锁)、CAS 轻量竞争(传统轻量级锁) | 支持栈迁移,适配虚拟线程; 并腾出对象头空间,支持对象头进一步压缩(Liliput) |
| 偏向锁:生产中偏向假设不成立,撤销开销大! 轻量级锁:栈迁移会导致锁状态混乱,无法适配虚拟线程 | |
| 不适配,VT在持锁时切换会导致 Crash;不切换则存在死锁风险 | 轻量级锁:通过 可迁移的 LockStack 实现支持栈迁移, 重量级锁:竞争时通过 Monitor 挂起 / 唤醒虚拟线程 |
2.1.3 新锁模式实现
新轻量级锁(Lightweight_Lock)(由 JDK-8291555 引入),通过 LockStack 管理当前线程所持有的锁,LockStack 随虚拟线程的切换一同切换,不再与栈地址绑定,彻底适配虚拟线程上下文切换的需求。
重量级锁新加了一个 UseObjectMonitorTable 模式(JDK-8315884),对象头不再需要存储指向 Monitor 的指针,而是通过一个全局表存储锁对象与 Monitor 对象的映射。这意味重量级锁也跟新轻量级锁一样,不再需要通过对象头存储锁信息,空出来空间可以实现更极致的对象头空间压缩。另外,为了支持虚拟线程,重量级锁中的锁 Owner 需要准确识别是 VT 还是其挂载的 CarrierThread,存放实际 Owner 的线程或虚拟线程ID。
2.2 Adaptive parallelism 机制 —— 解决 JNI 阻塞
解决了 Synchronized 导致的阻塞,仍有一种 Case 需要考虑:如果用户在 JNI 过程中直接进入阻塞应该如何处理?虽然这个 Case 只会出现在用户主动实现大量 JNI 的场景中——概率低且可控,但考虑到业务长期发展迭代的不确定性,需要在系统层提出长期解决方案。
根据前面介绍,VT 在 JNI 中发生阻塞是无法从 CarrierThread 上卸载的,并且也难以同步通知 FJP 补偿额外的 FJP worker,因此 RedJDK21 设计自动补偿机制,用专用线程检测 FJP workers 状态,判断实际可运行 Workers 数量,以 FJP Parallelism 为标准及时补偿额外的 worker 线程,避免整个 FJP 资源耗尽,陷入死锁。
2.3 小红书用户“无感”接入方案
由用户主动创建并使用虚拟线程,需要用户大面积改造代码,通过创建虚拟线程/线程池替换传统线程,并且这种方式会带来新的问题:
【兼容性】小红书传统的并发控制、监控体系与运维手段都是基于线程池模式实现,换成虚拟线程可能导致部分能力失效;
【易用性】:使用虚拟线程 API 将使项目对 JDK 版本强依赖,如果多个项目同时依赖该工程,则所有依赖方需要一并升级;
【可运维】如果虚拟线程机制本身引入稳定性问题,难以快速切换回平台线程,需修改源码;
【稳定性】有些使用姿势可能引入预期外的风险,比如按照官方建议使用 “One vthread per request” 的模式大量创建 vt,可能因为连接数过高而直接打崩下游数据库,也可能发生 vt 泄漏造成 OOME。 Threadlocal 内存开销与虚拟线程数相关,VT 应该被池化管控。
为支持在小红书生产环境大规模落地,除了提供基础虚拟线程能力,还需要考虑用户接入姿势,结合过去经验我们发现,底层优化技术在生产大规模落地的难点,主要来自于:用户改造成本、心智负担、兼容性担忧、可观测性缺乏。
通过梳理小红书线程池使用情况,我们发现业务使用线程池主要有以下几种:
中间件框架线程池,处理RPC请求等;
业务框架线程池,例如图引擎执行线程池;
业务创建线程池,但使用中间件提供的线程池实现;
业务服务直接创建的线程池;
其它依赖库线程池,使用较少;
我们与中间件框架、引擎架构同学合作,替换线程池内的 ThreadFactory 实现,根据 JDK 版本与启动参数识别是否使用虚拟线程——基本不需要改业务代码。支持用户自定义线程池切换到经过封装的 ThreadFactory,统一控制。结合 RedCloud 微服务治理平台能力,可以运行时动态调节线程池配置,便利享受虚拟线程无压力扩容的优势。
2.4 虚拟线程监控诊断建设
小红书长期以来,基于平台线程架构已经建设起了强大、完善的监控诊断体系,虚拟线程作为生产环境使用特性,还需谨慎评估其对公司现有诊断能力的影响,并予以支持。
传统线程模式下,JVM 中主要管理用户创建的线程或线程池、ForkJoinPool 中的 FJP workers,以及 JVM 运行所必须的工作线程,如 GC、JIT 线程等。常用的诊断能力包括:
· Jstack工具:可以相对分析完整的线程堆栈、锁信息、运行状态等
· 线程池监控:监控各线程池利用率、排队数、时延等指标,是定位业务问题的直接窗口
· 定时堆栈分析 SDK:通过调用 ThreadMXBean 提供接口,定时获取线程堆栈,可用于分析历史时刻线程状态
使用虚拟线程后,会额外引入大量的虚拟线程,以及一个作为调度器的 ForkJoinPool。JDK 中配备了基础的虚拟线程栈分析工具,但用于分析实际问题远远不足,RedJDK21 结合小红书实际情况,对诊断分析能力进行了补齐:
· 更完整的堆栈分析工具,支持完整堆栈、锁信息、运行状态、挂载情况、CPU Time分析,能力基本对齐 Jstack
· OpenJDK21 中 ThreadMXBean 不支持虚拟线程,于 RedJDK21 中给予支持,补全 CAT SDK 等工具需要
· 复用原线程池使用虚拟线程,线程池监控体系可以直接使用
新增虚拟线程 FJP 调度器监控,监控虚拟线程死锁问题、负载情况、调度开销;用于监控虚拟线程系统本身是否异常。
小红书 Java 服务全面运行在 RedJDK11 上,使用虚拟线程要求将 JDK 版本进一步升级到 RedJDK21。实际上,除了虚拟线程能力之外,RedJDK21 还具备分代 ZGC、自动向量优化等诸多实用特性,从我们的测试场景看,单纯升级 RedJDK21 可以带来近 10% 的性能提升。经过小红书 JDK 团队的改造支持,从 RedJDK11 升级到 RedJDK21 基本不用进行代码层面改造,整个升级流程只需要 “1个镜像,1个参数,1次Copy”,从我们实践中来看,兼容性表现良好。
3.1 RedJDK21 vs OpenJDK21
从外部信息看到,很多尝试升级 JDK21 会遇到很多问题,我们升级 JDK21 会不会改造成本很大,会不会踩坑?这是很多业务同学最关注的点,若是代码变动大、缺乏稳定性保障,即使性能收益显著可能也要慎重考虑。实际上,为了大家能从平滑升级到 JDK21 上,RedJDK21 做了一些改动,其中对迁移兼容性影响最大的改动包括:
放开匿名类对模块的访问权限
ClassUnloading 秒级卡顿修复
默认关闭 G1GC 动态扩缩容机制
3.2 模块的访问权限
自 JDK9 以来,JDK 一直在通过限制 Java 代码的访问权限,来更好保障系统的安全性与可维护性。其中几个核心变化包括 JDK9 之后限制 internal API 访问(JEP-260)、JDK16 后通过 JEP-396 默认关闭 --illegal-access 行为,禁止无权限的非法访问行为(如通过反射访问 JDK 内部未开放包下的类),以及 JDK17 (JEP-403)删除 “--illegal-access” 控制权限的用法,将权限管理收紧在 “--add-opens” 等语义下。
然而反射的使用非常广泛,例如公司内部组件、Spring 2.X 框架,均有大量通过反射访问 JDK Module 的场景,为了保障更好的兼容性和用户接入便利,RedJDK21 重新开放了 “--illegal-access” 的配置选项,即用户升级 RedJDK21 的时候仅需配置参数 “--illegal-access=warn” 即可。
3.3 ClassUnload 带来秒级卡顿
历史上 JDK 通过 Sweeper 线程并发完成类卸载,可以异步并发的完成类卸载工作。但是在引入虚拟线程后,由于部分 coderoot 放在了堆上,JVM 不得不通过扫描堆上信息来判断某个类能否卸载,这块内存的扫描与GC内存管理方式直接相关,sweeper 这种通用清理机制就不能完全适用,还需要结合 GC 完成类卸载。于是,OpenJDK 索性移除了 sweeper,将 Class Unloading 完全闭环在 GC 算法里,由不同 GC 算法各自实现。
新产生的 G1GC 类卸载机制将大量逻辑实现在 Pause Remark 阶段,在早期 JDK21 版本中,ClassUnload 会产生数秒级的 STW 停顿,经过并行优化、代码重构、以及 GCworkers 负载均衡等一系列优化后,方可消除上述异常(参考 JDK-8326092)。意外的是,我们发现 JDK21 G1GC 每次进行 YoungGC 时,“Ext Root Scanning” 阶段耗时会上涨 10-20ms,这个问题也随 ClassUnload 问题一并得到修复。
3.4 G1GC 动态扩缩容
在 JDK19 中 G1GC 引入了更加灵活的 Adaptive HeapSize Policy,允许 G1GC 实时计算 GC 耗时开销,通过动态调节 HeapSize 来满足预期的 GC 耗时占比,G1GC 根据默认参数(GCTimeRatio)会尽量保证 GC STW 时间占总运行时间的 7.6%,如果 GC 过多,则 JVM 会触发一次 Heap 扩容,反之则会缩容。
相比 JDK11 G1GC “只扩容,不缩容”的方针,JDK21 可以让 GC 实际开销更“符合预期”,从而控制实际内存占用,但相应也会造成 GC 耗时上升,导致业务的响应时延受损。结合小红书搜推广耗时敏感的业务属性,RedJDK21 默认关闭了 Heap 的动态配置,让业务可以充分到其申请的内存资源,避免 GC 抖动。
历经半年时间的测试和推动,我们已经成功在搜索、推荐、广告核心链路,以及 Java AI 服务实现JDK21 + 虚拟线程大规模落地,累计覆盖服务规模近百万核,为搜索链路累计提供 90ms P90 RT 优化,为推荐外流优化 100+ms RT,并支持广告架构迁移升级;另外,我们成功支持分代 ZGC 在直播推荐场景落地,在直播内外流分别降低 P99 RT 80/120ms。通过 JDK 底层技术实现业务成本与性能的显著收益。
4.1 从试点到全量,以实际效果为驱动力
有了充分理论基础和收益预期,我们针对 JDK21 + 虚拟线程展开技术调研,成功研制出小红书生产环境稳定、便捷使用的虚拟线程架构,用户只需要通过配置参数即可动态开启虚拟线程。起初,RedJDK21 + 虚拟线程在推荐网关服务试点测试,网关类服务线程多、重 I/O, 单进程需要占用 5.5K 个线程,十分适用虚拟线程。实际测试结果确实喜人:OS 线程数从 5k 降到 300,CPU 利用率下降20%,内存下降 3G。随后,为支持搜索秒开目标顺利达成,我们在搜索核心链路展开虚拟线程测试,这一次效果又超过了预期:cachemiss 下降 30%,IPC 提升 13%!
4.2 搜索/推荐/广告接入,成本与性能兼收
我们意识到,虚拟线程的优势不仅存在于 I/O 场景下的上下文切换开销收益,他通过降低线程数量,提高了 CPU Cache 命中率与系统吞吐,也就是说,虚拟线程完全可以作为一个实实在在的通用优化手段。
我们和搜索/推荐/广告引擎架构同学梳理各业务 Java 链路,历经四个月的谨慎实验与观察,成功完成对搜索、推荐及广告核心 Java 链路进行的 JDK21 及虚拟线程改造,为各业务带来了约 10% P90 RT优化,CPU 平均下降 24%。
另外,直播推荐场景服务 GC 抖动频繁,严重影响可用性,我们选择从 G1GC 切换到分代 ZGC。JDK21 中首次支持分代 ZGC,大幅缩短了 ZGC 与 G1 的吞吐差异。我们综合分析了直播推荐业务负载和 GC 效率,通过 Pod 规格调整和 GC 参数调优手段,消除或显著降低了晚高峰偶发的 Allocation Stall 问题,最终为直播推荐降低 P99RT 指标 200 ms,可用性得到显著提升。
4.3 小红书虚拟线程 2.0 规划
在虚拟线程生产落地的过程中,我们看到许多未来需要改进的问题,为了让虚拟线程技术在工业生产环境更加健壮,足以支撑小红书复杂场景下的不同需求,我们计划在稳定性、可观察、灵活性三个方向持续深耕。
4.3.1 线程阻塞风险
RedJDK21 虚拟线程经过我们改善,可以在遇到 JUC 或 Synchronized 时触发 unmount,完成上下文切换。但实际上 JVM 底层机制实现更加复杂,由于 unmount 时需要拷贝完整的 VT 栈帧,而在部分情况下是栈帧不允许在栈和堆之间拷贝——想象一下假设调用过程中存在 JNI,存了一个栈上的地址会怎么样?JVM 通过 Pinning VT 的方式避免存在风险的栈帧拷贝发生,但这种方式下 Pinning VT 可能导致 CarrierThreads 耗尽而死锁。常见 Pinning 的情况有:
类加载过程中阻塞
访问一个未完成类加载的类
JNI过程中发生阻塞
单线程的阻塞不会导致“死锁”,但若多个 VT 同时被 Pin 住并阻塞,则有一定概率会耗尽全部 CarrierThreads,引发问题。
4.3.2 类加载阻塞
生产中最可能遇到类加载导致的阻塞,这里用一个典型场景举例:我们假设有一个 VT-1 进入类加载逻辑中,会被标记为 Pinning VT。如果此时获取锁失败并进入等待,由于其 Pinning 状态导致不会从 CarrierThread 上卸载,以及其它想要访问这个类的 VT 同样不会 unmount,它们会等到类加载完成后继续执行。那么这里就产生了问题:假设锁已经被一个 unmounted VT-2 持有,所有挂载在 CarrierThreads 上的 VT 都在等待类加载完成,耗尽了所有线程资源,那么 unmounted VT-2 就不会有机会得到执行,锁也不会得到释放,一个由于资源耗尽导致的死锁就形成了。
这个 Case 一般发生在服务启动时,大量类在短时间内被加载。复现的必要条件是:多线程同时访问同一个类(且类加载时陷入阻塞),出现概率比较低,通过适当扩充 FJP Parallism 可以进一步控制概率。
这个问题的产生源于两个 VT Pinning 问题:第一,是类加载过程中需要 Pin 住当前 VT;第二,是在类加载完成之前,所有访问该类的 VT 会全部等待并被 Pin 住。显然,后者对线程资源耗尽问题影响更大,而且,未实际执行类加载的 VT 理论上不应该被 Pin 住的。JDK 社区在 JDK-8369238 提出该问题,正如 JEP-491 中我们可以通过 ObjectLocker 机制实现 Synchronized 下 VT 的抢占机制,这里完全可以引入类似机制来解决,通过重构类加载触发路径的等待逻辑,解决类加载导致的死锁问题。
4.3.3 自定义调度器
在虚拟线程模式下,所有 VT 会被提交到一个指定 FJP 实例中执行,调度策略、workers 数量没法自定义调整,有没有办法支持更灵活的定制调度器呢?在热部署场景,netty 场景均有自定义调度策略需求,RedJDK21 计划支持用户指定提交虚拟线程到自定义调度器,定制自己的调度策略与独立的 CarrierThreads:
4.3.4 监控体系增强
JVM 虚拟线程允许 JVM 自己管理调度与阻塞,我们因此可以对齐进行更细致的监控,辅助我们完成问题诊断与性能分析:
在 2025 年,小红书中间件团队重磅打造 RedJDK21 产品,打磨、重构 Java 虚拟线程、分代 ZGC 等技术手段,为小红书 Java 服务提供行业内性能领先的 JDK 产品。在基础技术、业务同学的多方努力下,我们成功在搜索、推荐、广告以及 AI 业务上完成架构迭代与技术落地,通过 JDK 技术手段拿到了显著的 RT 与 CPU / 内存收益,支持搜索秒开、推荐首刷、广告工程架构升级等重要目标达成。
RedJDK21 产品展现出优秀的性能表现,我们希望更多 Java 服务可以完成迁移,拿到业务收益——感兴趣的业务同学可以与作者交流接入。未来,我们会以 RedJDK21 为旗舰产品重点打造,提供更丰富的特性与优秀的性能优化手段,为小红书未来业务发展提供强大而稳定的运行时基座,为小红书带来更好的 “Java”!
继才(杨道谈)
小红书中间件负责人,曾就职于快手基础架构团队,目前整体负责小红书中间件的规划、研发和日常维护工作,负责微服务中间件(RPC、注册中心、配置中心、任务调度、服务治理)、消息中间件、JDK Runtime 三个领域方向工作。
凶真(刘侠麟)
小红书JDK研发负责人,毕业于华中科技大学,曾就职于快手JVM团队,目前是小红书 JDK 团队负责人,负责 RedJDK 的日常规划、研发和迭代工作,推进 JDK21、ZGC、多租户、虚拟线程在小红书落地。
沈浪(吕洪武)
小红书JDK研发工程师,毕业于北京理工大学,校招进入小红书 JDK 团队,目前主要从事 RedJDK17 在 Flink/Spark 的升级工作,同时推动 ZGC 在小红书的落地。在虚拟线程改造、监控体系建设中做出卓越贡献。
伊澄(易谦)
小红书JDK研发工程师,毕业于北京大学,校招进入小红书 JDK 团队,目前主要从事 JVM 多租户在热部署场景的落地、以及虚拟线程的工作。为虚拟线程阻塞问题解决、多租户与虚拟线程兼容做出卓越贡献。
添加小助手,了解更多内容
微信号 / REDtech01