WEBKT

Spring Boot 3 虚拟线程火了,但第三方库的 ThreadLocal 正在悄悄榨干你的内存

3 0 0 0

在 Spring Boot 3.2+ 中,只需一行配置 spring.threads.virtual.enabled=true,就能轻松开启 JDK 21 的虚拟线程(Virtual Threads)。这种“高并发神器”允许我们同时运行数百万个线程,极大地提升了系统的吞吐量。

然而,很多团队在欢天喜地地上线虚拟线程后,很快就遭遇了频繁的 OOM(Out Of Memory) 或内存持续攀升。

罪魁祸首往往不是虚拟线程本身,而是那些在经典线程池时代被广泛使用、却在虚拟线程时代沦为“内存杀手”的 ThreadLocal。尤其是第三方依赖库中的 ThreadLocal,我们无法直接修改其源码,这让治理工作变得异常棘手。


为什么虚拟线程 + ThreadLocal = 内存灾难?

在传统的平台线程(Platform Threads)时代,我们使用线程池(如 Tomcat 默认的 200 个线程)。线程数量是受限且稳定的。
此时,第三方库(如 Jackson、Logback、Spring Security)使用 ThreadLocal 缓存一些大对象或上下文,每个线程最多持有一份。即便对象再大,200 倍的内存占用完全在可控范围内。

但在虚拟线程时代,游戏规则变了:

  1. 数量级暴增:虚拟线程是随用随建、用完即毁的。在并发高峰期,系统可能会同时存在 10 万个 活跃的虚拟线程。
  2. 生命周期变化:如果这 10 万个虚拟线程在执行过程中,都调用了某个向 ThreadLocal 写入大对象的第三方库,那么 JVM 内部的 ThreadLocalMap 就会瞬间膨胀 10 万倍。
  3. 无法及时释放:部分第三方库的设计没有在 finally 块中调用 ThreadLocal.remove() 的习惯(因为它们默认线程会被回收到线程池复用,下次还会用到缓存)。这直接导致虚拟线程即便执行完毕,其引用的 ThreadLocalMap 对象也无法被 GC 快速回收。

哪些第三方库是“重灾区”?

在排查 OOM 时,可以重点盯防以下三类库:

  1. JSON 解析库(如 Jackson、Gson)
    Jackson 在 2.16 版本之前,内部使用了 BufferRecyclerThreadLocal 来缓存输入/输出缓冲区。在虚拟线程下,几万个并发解析任务会导致产生几万个大字节数组,瞬间撑爆堆内存。
  2. 日志框架(如 Logback、Log4j2)
    MDC(Mapped Diagnostic Context)底层完全基于 ThreadLocal 实现。如果日志上下文包含大对象,且在线程结束时没有清理,内存泄露在所难免。
  3. 数据库连接池与 ORM 框架(如 MyBatis、Hibernate)
    为了保证同一个事务在同一个线程内,这些框架大量使用 ThreadLocal 存储连接和 Session 信息。
  4. 底层通信框架(如 Netty 相关的旧版本工具类)
    一些第三方自研组件中基于 Netty FastThreadLocal 实现的缓存对象。

优雅防范与治理方案

针对无法直接修改源码的第三方库,我们可以采用以下几种渐进式的优雅治理策略。

方案一:升级依赖,激活“虚拟线程友好的”无缝替代

这是成本最低、最优雅的手段。许多主流开源库在得知 JDK 21 发布后,都已经对其内部的 ThreadLocal 进行了重构。

  • Jackson 升级至 2.16.0+
    从 2.16 版本开始,Jackson 引入了 VirtualThreadFriendly 机制。当检测到当前处于虚拟线程中时,它会避开传统的 ThreadLocal 缓存,转而采用一种更温和的、基于 SoftReference 或新机制的缓冲区复用策略。
  • Spring Boot 升级至 3.2.x+ / 3.3.x
    Spring 官方对内部的 RequestContextHolderTransactionSynchronizationManager 等核心上下文进行了虚拟线程适配,确保在虚拟线程销毁时能自动、干净地释放资源。

方案二:利用 TaskDecorator 强制清理第三方 MDC

对于日志框架的 MDC,我们通常会自定义线程池或使用 Spring 的虚拟线程 TaskExecutor。可以通过实现 TaskDecorator,在虚拟线程执行完毕的最后关头,强行清空 MDC

import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import java.util.Map;

public class VirtualThreadMdcDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // 捕获提交线程(主线程)的 MDC 上下文
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        
        return () -> {
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                runnable.run();
            } finally {
                // 必须在 finally 中清除,防止虚拟线程由于垃圾回收延迟导致内存不释放
                MDC.clear();
            }
        };
    }
}

接着将该 Decorator 配置到 Spring Boot 的虚拟线程执行器中:

import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
import java.util.concurrent.Executors;

@Configuration
public class VirtualThreadConfig {

    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    public AsyncTaskExecutor applicationTaskExecutor() {
        // 创建基于虚拟线程的 Executor
        var executor = new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
        executor.setTaskDecorator(new VirtualThreadMdcDecorator());
        return executor;
    }
}

方案三:使用 JVM 参数禁用特定第三方库的 ThreadLocal 缓存

部分库提供了系统属性(System Properties)来关闭内部的 ThreadLocal 缓存。

例如,针对 Netty 相关的组件,如果其在虚拟线程中频繁分配直接内存或局部缓存,可以通过以下 JVM 参数进行限制或关闭:

-Dio.netty.recurrence.maxNumElements=0
-Dio.netty.customThreadLocalMap=false

针对不确定来源的 ThreadLocal 溢出,可以开启 JDK 21 的诊断参数,在本地或测试环境揪出是哪个类在频繁创建 ThreadLocal

-XX:+TracePinnedThreads

虽然该参数主要用于排查虚拟线程被平台线程锁死(Pinning)的问题,但通常频繁 Pinning 的地方,也是 ThreadLocal 密集交互的重灾区。

方案四:在拦截器层/过滤器层进行“兜底扫尾”

如果 OOM 发生在 Web 请求的生命周期内,而我们又无法控制某些 SDK 在 Filter/Controller 内部写入的 ThreadLocal,可以通过自定义 HandlerInterceptorFilter 进行全局扫尾。

通过反射机制或第三方库暴露的 reset() / cleanup() 方法,在请求结束时强行将其清理。

import jakarta.servlet.*;
import java.io.IOException;

public class ThreadLocalCleanupFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } finally {
            // 1. 清理自研或遗留的 ThreadLocal
            MyLegacyContextHolder.clear();
            
            // 2. 强行清理一些不守规矩的第三方 SDK 遗留上下文
            // SomeThirdPartySDK.getHolder().reset();
        }
    }
}

方案五:拥抱 JDK 21 的 ScopedValue(终极避坑指南)

如果你正在重构自己的公共库,或者有权限修改导致 OOM 的底层二方库源码,应当果断放弃 ThreadLocal,改用 JDK 21 引入的 ScopedValue(作用域值)。

ScopedValue 是专门为虚拟线程设计的数据共享机制。它的特点是:

  1. 单向不可变:一旦绑定无法修改,避免了并发修改的安全问题。
  2. 生命周期绑定:它的生命周期伴随着代码块的退出而自动结束,不需要也不允许手动 remove(),从根本上杜绝了内存泄露。
import java.lang.ScopedValue;

public class UserService {
    // 声明一个全局的作用域值
    public static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

    public void processRequest() {
        User user = fetchUser();
        
        // 将 user 绑定到当前作用域,并在该作用域内执行业务逻辑
        ScopedValue.where(CURRENT_USER, user).run(() -> {
            // 在这里的任何下游方法中,都可以安全地获取 user
            doSomething();
        }); 
        // 出了这个大括号,CURRENT_USER 自动释放,绝对不会发生 OOM
    }

    private void doSomething() {
        User user = CURRENT_USER.get();
        System.out.println("Processing user: " + user.getName());
    }
}

避坑 Checklist 建议

在将 Spring Boot 3 项目全面铺开虚拟线程前,建议团队建立如下 Checklist:

  1. 依赖清扫:检查 pom.xml,凡是涉及 JSON 解析(Jackson)、日志(Logback)、XML解析、加密算法等工具库,一律升级到 2024 年之后发布的稳定版本。
  2. 连接池调整:虚拟线程虽然可以开几万个,但数据库连接池(如 HikariCP)是硬上限。避免在虚拟线程中长时间占着连接不放,否则会引起连接池枯竭,变相引发线程阻塞与内存堆积。
  3. 慎用本地缓存:严格禁止在虚拟线程内部使用诸如 WeakHashMap 或自定义 ThreadLocal 来做临时对象的 Cache。
  4. 压力测试:在测试环境使用 jmap -histo:live <pid>jprofiler 观察 java.lang.ThreadLocal$ThreadLocalMap 实例的数量和大小是否随着并发请求的结束而回归基线。
码界架构师 虚拟线程

评论点评