Spring Boot 3 虚拟线程火了,但第三方库的 ThreadLocal 正在悄悄榨干你的内存
在 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 倍的内存占用完全在可控范围内。
但在虚拟线程时代,游戏规则变了:
- 数量级暴增:虚拟线程是随用随建、用完即毁的。在并发高峰期,系统可能会同时存在 10 万个 活跃的虚拟线程。
- 生命周期变化:如果这 10 万个虚拟线程在执行过程中,都调用了某个向
ThreadLocal写入大对象的第三方库,那么 JVM 内部的ThreadLocalMap就会瞬间膨胀 10 万倍。 - 无法及时释放:部分第三方库的设计没有在
finally块中调用ThreadLocal.remove()的习惯(因为它们默认线程会被回收到线程池复用,下次还会用到缓存)。这直接导致虚拟线程即便执行完毕,其引用的ThreadLocalMap对象也无法被 GC 快速回收。
哪些第三方库是“重灾区”?
在排查 OOM 时,可以重点盯防以下三类库:
- JSON 解析库(如 Jackson、Gson):
Jackson 在 2.16 版本之前,内部使用了BufferRecycler的ThreadLocal来缓存输入/输出缓冲区。在虚拟线程下,几万个并发解析任务会导致产生几万个大字节数组,瞬间撑爆堆内存。 - 日志框架(如 Logback、Log4j2):
MDC(Mapped Diagnostic Context)底层完全基于ThreadLocal实现。如果日志上下文包含大对象,且在线程结束时没有清理,内存泄露在所难免。 - 数据库连接池与 ORM 框架(如 MyBatis、Hibernate):
为了保证同一个事务在同一个线程内,这些框架大量使用ThreadLocal存储连接和 Session 信息。 - 底层通信框架(如 Netty 相关的旧版本工具类):
一些第三方自研组件中基于 NettyFastThreadLocal实现的缓存对象。
优雅防范与治理方案
针对无法直接修改源码的第三方库,我们可以采用以下几种渐进式的优雅治理策略。
方案一:升级依赖,激活“虚拟线程友好的”无缝替代
这是成本最低、最优雅的手段。许多主流开源库在得知 JDK 21 发布后,都已经对其内部的 ThreadLocal 进行了重构。
- Jackson 升级至 2.16.0+:
从 2.16 版本开始,Jackson 引入了VirtualThreadFriendly机制。当检测到当前处于虚拟线程中时,它会避开传统的ThreadLocal缓存,转而采用一种更温和的、基于SoftReference或新机制的缓冲区复用策略。 - Spring Boot 升级至 3.2.x+ / 3.3.x:
Spring 官方对内部的RequestContextHolder、TransactionSynchronizationManager等核心上下文进行了虚拟线程适配,确保在虚拟线程销毁时能自动、干净地释放资源。
方案二:利用 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,可以通过自定义 HandlerInterceptor 或 Filter 进行全局扫尾。
通过反射机制或第三方库暴露的 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 是专门为虚拟线程设计的数据共享机制。它的特点是:
- 单向不可变:一旦绑定无法修改,避免了并发修改的安全问题。
- 生命周期绑定:它的生命周期伴随着代码块的退出而自动结束,不需要也不允许手动
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:
- 依赖清扫:检查
pom.xml,凡是涉及 JSON 解析(Jackson)、日志(Logback)、XML解析、加密算法等工具库,一律升级到 2024 年之后发布的稳定版本。 - 连接池调整:虚拟线程虽然可以开几万个,但数据库连接池(如 HikariCP)是硬上限。避免在虚拟线程中长时间占着连接不放,否则会引起连接池枯竭,变相引发线程阻塞与内存堆积。
- 慎用本地缓存:严格禁止在虚拟线程内部使用诸如
WeakHashMap或自定义ThreadLocal来做临时对象的 Cache。 - 压力测试:在测试环境使用
jmap -histo:live <pid>或jprofiler观察java.lang.ThreadLocal$ThreadLocalMap实例的数量和大小是否随着并发请求的结束而回归基线。