WEBKT

Spring Boot 3 开启虚拟线程后,为什么内存突然爆了?

1 0 0 0

在 Java 21 正式发布和 Spring Boot 3.2+ 提供了开箱即用的虚拟线程(Virtual Threads)支持后,很多团队在第一时间将 spring.threads.virtual.enabled 设为了 true

虚拟线程极其轻量(在堆中仅占用几百字节到几 KB),支持数百万级别的并发创建,这让大家产生了一种“高并发银弹”的错觉。然而,许多项目在上线或者压测时,迎来的不是 QPS 的飞跃,而是频繁的 OOM(Out Of Memory)崩溃。

本文将复盘虚拟线程在 Spring Boot 3 适配落地时最容易踩中、也最致命的三个“内存溢出”深坑,并给出可落地的排查与修复方案。


坑一:ThreadLocal 导致的“幽灵存活”与堆内存暴涨

1. 根源分析

在传统的平台线程(Platform Threads)模型中,我们通常使用线程池(如 Tomcat 的 200 个线程)。由于线程是复用的,我们在 ThreadLocal 里存放一些上下文信息(如 UserInfo、LogContext),其生命周期是可控的。即使忘记调用 remove(),也只是占用这 200 个线程对应的内存空间。

但在虚拟线程时代,**“一请求一线程(Thread-per-request)”**成为了现实。一次高并发压测,可能会瞬间创建 10 万个虚拟线程。

  • 如果你的代码、依赖的第三方 SDK(如 Spring Security、MyBatis 拦截器、日志框架的 MDC)依然在使用 ThreadLocal 存放临时大对象。
  • 每一个虚拟线程都会持有一个独立的 ThreadLocalMap
  • 如果这 10 万个虚拟线程在并发执行,意味着内存中同时存在 10 万个 ThreadLocalMap 的副本。堆内存会在瞬间被这些“幽灵对象”塞满,直接触发 GC 甚至 OOM。

2. 代码示例

public class FinancialController {
    // 假设这是一个保存用户解析数据的 ThreadLocal
    private static final ThreadLocal<byte[]> contextHolder = new ThreadLocal<>();

    public void handleRequest() {
        // 模拟每次请求在 ThreadLocal 中放入 1MB 的临时数据
        contextHolder.set(new byte[1024 * 1024]); 
        try {
            doBusiness();
        } finally {
            // 如果这里漏掉了 remove(),或者业务抛出异常导致没有执行到 remove()
            contextHolder.remove(); 
        }
    }
}

3. 破局方案

  • 降低 ThreadLocal 的粒度:只存储极简的 ID 或 Token,严禁存放大型报文、DTO 实体或大字节数组。
  • 拥抱 ScopedValue(作用域值):Java 21 引入了 ScopedValue(目前处于 Preview 阶段),它是专门为了解决虚拟线程下 ThreadLocal 内存开销和不可变性问题而设计的。一旦作用域结束,数据会被立即回收。
// ScopedValue 声明与使用示例
public final static ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

ScopedValue.where(CURRENT_USER, user).run(() -> {
    // 在这个作用域内,可以直接读取 CURRENT_USER.get()
    doBusiness();
}); // 退出作用域后立即释放,对垃圾回收极其友好

坑二:Synchronized 锁导致的 Carrier Thread “钉死”(Pinning)

1. 根源分析

虚拟线程不是直接运行在 CPU 上的,而是由 JVM 的 ForkJoinPool 线程池(称为 Carrier Thread,载体线程)进行调度。

当虚拟线程执行到阻塞操作(如 JDBC 读写、HTTP 请求)时,它本应该主动让出(Yield)载体线程,让其他虚拟线程运行。但是,如果虚拟线程在 synchronized 块或 synchronized 方法内部执行阻塞操作,它就会被“钉死”(Pinning)在载体线程上。

一旦发生 Pinning,载体线程就无法被释放。如果并发请求量极大,所有的载体线程(默认数量等于 CPU 核心数)都会被锁死。此时:

  • JVM 为了维持运转,可能会被迫创建更多的平台线程作为临时的 Carrier 线程(虽然有上限,但依然会消耗大量系统内存和线程栈空间)。
  • 大量虚拟线程在队列中积压,它们持有的上下文对象无法释放,导致 JVM 堆内存持续攀升,最终 OOM。

2. 诊断与监控

我们可以通过在 JVM 启动参数中添加以下配置,在虚拟线程被“钉死”时输出栈轨迹:

-Djdk.tracePinnedThreads=short
# 或者输出详细的堆栈
-Djdk.tracePinnedThreads=full

运行后,如果控制台出现类似下方的日志,说明你已经中招了:

Thread[#23,ForkJoinPool-1-worker-1,5,CarrierThreads]
    java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)
    java.base/java.lang.VirtualThread.park(VirtualThread.java:593)
    java.base/java.lang.System$2.park(System.java:2643)
    ...
    at com.example.MyService.queryDatabase(MyService.java:45) - pinned

3. 修复方案

将传统代码中的 synchronized 替换为 JDK 的 ReentrantLock。虚拟线程在遇到 ReentrantLock 时,能够正常让出底层载体线程,不会发生 Pinning。

重构前:

public synchronized String fetchData() {
    return restTemplate.getForObject("https://api.example.com/data", String.class); // 阻塞操作
}

重构后:

private final ReentrantLock lock = new ReentrantLock();

public String fetchData() {
    lock.lock();
    try {
        return restTemplate.getForObject("https://api.example.com/data", String.class);
    } finally {
        lock.unlock();
    }
}

坑三:Netty 内存池与虚拟线程的“双向奔赴”灾难

这是最隐蔽、也最容易导致**堆外内存溢出(Direct Memory OOM)**的场景。

1. 根源分析

Spring Boot 3 的 Reactive 栈(WebFlux)或网关(Spring Cloud Gateway)底层严重依赖 Netty。
Netty 为了提高 I/O 效率,默认采用了一套复杂的内存池机制(PooledByteBufAllocator)。为了减少多线程竞争,Netty 内部会为每个访问它的线程分配并缓存一个 PoolThreadCache

在平台线程时代,Netty 的 EventLoop 线程数量是固定的(通常是 CPU 核心数 * 2)。所以只会有几十个 PoolThreadCache,占用的堆外内存非常有限。

然而,当你开启虚拟线程后,如果你的下游客户端(如 WebClient、Lettuce 缓存、Redisson 客户端、gRPC)在虚拟线程中发起了 I/O 操作:

  1. 每个虚拟线程在通过 Netty 发送数据时,Netty 都会认为这是一个全新的线程。
  2. Netty 会尝试为这个虚拟线程初始化一个独立的 PoolThreadCache 并分配一块堆外内存。
  3. 如果高并发下有 10 万个虚拟线程在轮番运行,Netty 就会创建 10 万个 PoolThreadCache
  4. 结果:系统物理内存瞬间被吃光,进程直接被系统内核的 OOM Killer 杀掉。

2. 修复方案

如果你的项目中混用了 WebFlux/Netty 并且启用了虚拟线程,必须显式对 Netty 的线程本地缓存进行限制。

可以通过设置以下 JVM 启动参数,关闭 Netty 对非 FastThreadLocalThread(包括虚拟线程)的线程本地缓存:

-Dio.netty.allocator.useCacheForAllThreads=false

或者,强制指定 Netty 内存分配器不使用 thread-local cache:

-Dio.netty.allocator.numDirectArenas=0
-Dio.netty.allocator.numHeapArenas=0

虽然关闭缓存会带来轻微的性能损耗,但在虚拟线程的大并发场景下,这是保障系统不发生物理内存溢出的必要妥协。


实战:三步定位虚拟线程 OOM 工具链

当你的 Spring Boot 3 服务在测试环境压测中内存异常时,请按照以下步骤排查:

第一步:获取 Heap Dump 寻找“大户”

通过 jmap 或在 JVM 启动参数中配置自动导出:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/oom.hprof

使用 MAT (Eclipse Memory Analyzer) 打开 dump 文件:

  • 关注 java.lang.VirtualThread 对象的数量。
  • 查看 java.lang.ThreadLocal$ThreadLocalMap 的持有者,确认是哪个类产生的 ThreadLocal 无法释放。

第二步:开启 NMT 监控堆外内存

如果是物理内存耗尽但 JVM 堆内 GC 正常,怀疑是堆外内存(如 Netty)泄漏,启动参数加上:

-XX:NativeMemoryTracking=detail

然后通过 jcmd 实时查看内存分配变化:

jcmd <pid> VM.native_memory detail

重点查看 InternalThread 段的内存暴涨情况。

第三步:利用 JFR (Java Flight Recorder) 定位锁竞争

使用 JFR 录制高并发下的系统行为,尤其是寻找 jdk.VirtualThreadPinnedjdk.VirtualThreadSubmitFailed 事件:

jcmd <pid> JFR.start name=vt_leak settings=profile filename=vt.jfr duration=60s

将生成的 vt.jfr 导入 JDK Mission Control (JMC),查看“事件浏览器”下的虚拟线程 Pinning 统计,可以直接定位到具体的业务类和代码行数。


总结

Spring Boot 3 适配虚拟线程是一次伟大的演进,它极大地释放了阻塞式 I/O 服务的并发潜力。但在享受红利之前,必须对底层的基础设施有清晰的认知。

  1. 不要盲信虚拟线程无消耗:它们虽然便宜,但它们引用的对象依然在堆里。
  2. 清理代码中的 ThreadLocal:能不用就不用,重构为参数传递或考虑 Java 21 的 ScopedValue
  3. 消除 Synchronized:用 ReentrantLock 彻底替换底层所有的阻塞同步锁,消除 Pinning 隐患。
  4. 小心 Netty 堆外内存:在有大量虚拟线程调用的 I/O 密集型应用中,务必关闭 Netty 的全局线程缓存。
极客架构说 虚拟线程内存溢出

评论点评