WEBKT

升级 Spring Boot 3 并开启虚拟线程,JVM 内存模型到底发生了什么变化?

2 0 0 0

在 Spring Boot 3.x 中,只需一行配置 spring.threads.virtual.enabled=true,就能让整个 Web 容器(如 Tomcat)跑在 Java 21 的虚拟线程(Virtual Threads,即 Loom 项目)之上。

很多开发者在压测时发现,并发量确实上去了,但 JVM 的内存波动、GC 频率以及堆内存的占用情况,和以前基于线程池的经典模型截然不同。

我们需要明确一个前提:Java 内存模型(JMM,即 Happens-Before 规则、可见性、重排序等语义)并没有因为虚拟线程的引入而改变。发生本质变化的是 JVM 的“物理内存布局”与“分配策略”。


1. 线程栈:从“操作系统原生内存”转向“JVM 堆”

在传统的平台线程(Platform Thread)模型中,每个线程都直接映射到一个操作系统内核线程。

传统模型的内存开销

  • 物理边界:每个平台线程都有一个固定大小的栈空间(默认通常是 -Xss1m)。这部分内存是在 JVM 堆外(Native Memory)分配的。
  • 硬性限制:如果你起 1000 个线程,哪怕它们什么都不干,操作系统也必须雷打不动地分出将近 1GB 的物理内存作为线程栈。这也是为什么以前单机并发连接数很难破万的物理瓶颈。

虚拟线程的内存重塑

虚拟线程不再直接绑定内核线程,而是作为 java.lang.VirtualThread 实例存在。

  • 栈在堆上(Stack on Heap):虚拟线程的栈帧是以对象数组的形式存储在 JVM 堆内存(Heap)中的。
  • 按需动态伸缩:虚拟线程启动时,它的栈非常小,通常只有几百个字节或几 KB,随着调用深度的增加动态扩容,调用结束方法返回时又会自动缩减。
  • 调度载体(Carrier Thread):只有当虚拟线程被调度器(通常是 ForkJoinPool)执行时,它的栈帧信息才会被“挂载”(Mount)到具体的平台线程(也就是 Carrier Thread)的 Native Stack 上。一旦遇到 I/O 阻塞,虚拟线程会被“卸载”(Unmount),其栈帧信息会被写回 JVM 堆中。

变化结果
你不再需要为“线程数”预留大量的堆外 -Xss 内存。理论上,几百兆的堆内存就可以轻松容纳数十万个虚拟线程。但代价是,JVM 堆内存需要频繁应对虚拟线程栈帧对象的创建和销毁


2. 致命的 ThreadLocal 陷阱与内存泄露风险

在 Spring 生态中,ThreadLocal 被极其广泛地用于传递上下文(如 Spring Security 的 SecurityContextHolder、事务管理、Logback 的 MDC、甚至是数据库连接缓存)。

在虚拟线程时代,ThreadLocal 的物理机制依然有效,但其带来的内存副作用呈指数级放大。

// 传统模式下的危险代码,在虚拟线程下会直接演变为 OOM 灾难
private static final ThreadLocal<byte[]> contextCache = ThreadLocal.withInitial(() -> new byte[1024 * 1024]); // 1MB

为什么会 OOM?

  1. 数量级的差异:在传统线程池(如 Tomcat 最大 200 线程)中,ThreadLocal 对应的 Map 结构最多也就存在 200 个实例,占用 200MB 内存。
  2. 虚拟线程的无节制性:Spring Boot 开启虚拟线程后,每一个 HTTP 请求都会创建一个新的虚拟线程,生命周期极短。如果你在这些虚拟线程里使用了 ThreadLocal 绑定了大对象,且没有在 finally 块中显式调用 .remove(),即使虚拟线程消亡了,其引用的对象也会在堆中存活更久,甚至由于线程数量瞬间达到数万,直接撑爆堆内存(OOM)。
  3. 替代方案:Java 21 引入了作用域值(Scoped Values)(目前处于 Preview 阶段),旨在解决虚拟线程共享大对象时的内存开销问题。对于 Spring Boot 3 开发者,最安全的做法是严格检查并及时 remove() 任何自定义的 ThreadLocal 变量,或者升级第三方依赖以适配虚拟线程。

3. 垃圾回收(GC)压力的转移

在传统的阻塞式 Web 应用中,GC 压力通常来自于业务对象(如 JSON 解析、DTO 转换、数据库查询结果)。

开启虚拟线程后,GC 器的行为会发生微妙的变化:

+-------------------------------------------------------------+
|                     JVM Heap (堆内存)                       |
|                                                             |
|   +-------------------+       +-------------------------+   |
|   |   业务对象 (DTO)   |       |  虚拟线程栈帧 (Stack Chunks) |   |
|   +-------------------+       +-------------------------+   |
|                                (高并发下频繁创建、卸载、销毁)   |
+-------------------------------------------------------------+
  • 年轻代(Young Generation)晋升压力:因为虚拟线程的栈帧是堆对象,高并发的 I/O 交互意味着大量的虚拟线程在不断地 Mount/Unmount 和销毁。这会产生海量的短寿命 StackChunk 对象。
  • GC 算法的选择:默认的 G1 垃圾回收器在面对这种高频的短寿命对象时,可能会增加 Minor GC 的频率。如果你的 Spring Boot 3 应用承载了极高的并发,建议配置为 ZGC(Generational ZGC)。分代 ZGC(在 JDK 21 中正式可用)极其擅长处理这种高吞吐量、短生命周期的堆内存分配,能将停顿时间控制在毫秒级以内。

4. 锁膨胀与“固定”(Pinning)导致的内存滞留

这是 Spring Boot 3 迁移到虚拟线程时最经典的“踩坑”点。

当虚拟线程在执行一个被 synchronized 关键字修饰的同步块,或者调用了本地方法(Native Method)时,如果此时发生阻塞(例如等待数据库响应),虚拟线程会被**固定(Pinning)**在它的载体线程(Carrier Thread)上。

内存层面的连带影响

  • 无法释放的栈帧:一旦被 Pin 住,该虚拟线程的栈帧就无法被卸载回堆内存,而是继续霸占着 Carrier Thread(平台线程)的操作系统栈空间。
  • 线程饥饿与临时线程暴涨:为了防止 Carrier Thread 跑满导致系统卡死,ForkJoinPool 调度器会临时补偿创建新的平台线程。这会导致物理线程数在短时间内飙升,原本想节省的 Native Memory(-Xss)又成倍地涨了回来。

监控与排查

在启动 Spring Boot 3 应用时,可以通过添加 JVM 参数来监控这种不健康的内存和线程行为:

-Djdk.tracePinnedThreads=full

如果控制台频繁打印出由于 synchronized 导致的虚拟线程固定堆栈,建议使用 Java 的 ReentrantLock 替代 synchronized。Spring Boot 3 自身的很多底层组件(以及主流的数据库驱动如 PgSQL、MySQL)都在近期版本中完成了这一重构。


总结:Spring Boot 3 内存调优新策略

启用虚拟线程后,Spring Boot 3 应用的内存调优思路需要进行一次彻底的升维:

  1. 放弃传统的 -Xss 调优:不要寄希望于通过限制线程栈大小来节省内存,因为绝大部分栈已经转移到了堆上。
  2. 倾斜资源给堆(Heap):把原本预留给物理线程栈的堆外内存,匀给 JVM 堆(-Xmx)。因为虚拟线程本身的生命周期和栈帧都在堆里竞争空间。
  3. 拥抱分代 ZGC:在 JVM 参数中开启 -XX:+UseZGC -XX:+ZGenerational,以应对高并发虚拟线程带来的年轻代内存瞬时抖动。
  4. 警惕连接池与外部资源:虚拟线程消除了应用内的并发限制,但数据库连接池(如 HikariCP)和 HTTP 客户端连接池依然是物理有限的。如果不做限流,数十万虚拟线程瞬间争抢 10 个数据库连接,会导致连接池等待队列过长,反而间接导致大量的虚拟线程及其持有的业务对象在堆中积压,最终引发 OOM。
TechLoomer 虚拟线程JVM内存模型

评论点评