【Java 填坑日记】“一次诡异的内存泄漏:ThreadLocal 用完没 remove,Metaspace 被打爆”
作者:@Neoest
时间:2025-08-12
关键词:ThreadLocal、内存泄漏、Metaspace、ClassLoader、线上 OOM
1. 问题现场
上周 预发布环境 毫无征兆地开始 间歇性 Full GC,曲线图呈锯齿状,持续 3 天后终于:
java.lang.OutOfMemoryError: Metaspace
Dump 下来一看,Metaspace 已占用 1.2 GB,而 -XX:MaxMetaspaceSize=512m 形同虚设。
MAT 里排名前 N 的支配树清一色:
class com.xxx.util.JsonUtil$1 (Loaded by org.springframework.boot.loader.LaunchedURLClassLoader @ 0x7c0a0008)
-> java.lang.ThreadLocal$ThreadLocalMap$Entry[]
-> ... 5000+ 条
2. 先定位代码
JsonUtil 里的“高性能”封装:
public static final ThreadLocal<ObjectMapper> MAPPER = ThreadLocal.withInitial(() -> {
ObjectMapper mapper = new ObjectMapper();
// 一大堆自定义模块、过滤器、Mixin...
mapper.registerModule(new JavaTimeModule());
return mapper;
});
每次导出 Excel 都会 异步起 50 条线程,每条线程里:
ObjectMapper om = JsonUtil.MAPPER.get();
String json = om.writeValueAsString(data);
执行完业务逻辑后线程被 线程池复用,但 ThreadLocal 里的 ObjectMapper 依旧牢牢抱住当前线程的 ThreadLocalMap。
ObjectMapper 又间接引用到大量 ClassLoader 加载的 动态代理类,于是 ClassLoader + Class 元数据 无法被 GC,Metaspace 被撑爆。
3. 为什么报错?
- ThreadLocalMap 使用弱引用,但 Key 是弱引用,Value 不是!
当 Thread 长期存活(如线程池),Value 就不会被回收。 - Spring Boot 可执行 FatJar 的
LaunchedURLClassLoader是 自定义 ClassLoader,
只要有一条强引用链,整个 ClassLoader 和加载的全部类 都留在 Metaspace。 - 高频创建线程 + 大量注册 Jackson Module,重复加载匿名类,雪上加霜。
4. 三种解决方案
✅ 方案 A:用完即 remove(最正统)
ObjectMapper om = null;
try {
om = JsonUtil.MAPPER.get();
return om.writeValueAsString(data);
} finally {
JsonUtil.MAPPER.remove(); // 显式清理
}
缺点:侵入业务代码,容易忘;需要团队共识。
✅ 方案 B:自定义 TransmittableThreadLocal + 线程池包装
阿里开源 TransmittableThreadLocal
支持 任务结束后自动清理,无需业务方关心:
public static final TransmittableThreadLocal<ObjectMapper> MAPPER =
TransmittableThreadLocal.withInitial(() -> createObjectMapper());
线程池改用 TtlExecutors:
ExecutorService executor = TtlExecutors.getTtlExecutorService(
Executors.newFixedThreadPool(50)
);
✅ 方案 C:直接弃用 ThreadLocal,用对象池 / 单例
如果 ObjectMapper 线程安全(官方保证,只要配置不变),完全可以:
public static final ObjectMapper MAPPER = createObjectMapper();
- 无 ThreadLocal,无泄漏风险
- 只需注意 不要在运行时动态修改配置(否则需加锁或复制)
5. 小结
| 方案 | 适用场景 | 侵入性 | 备注 |
|---|---|---|---|
remove | 代码可控,线程池固定 | 中 | 容易遗漏 |
| TTL | 跨线程传递 & 强隔离 | 低 | 需引入依赖 |
| 单例 | 配置不变 | 低 | 官方推荐 |
6. 一键复制版补丁(Spring Boot 场景)
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
return JsonUtil.createObjectMapper();
}
}
@Component
public class JsonUtil {
private static final ObjectMapper GLOBAL = createObjectMapper();
public static String toJson(Object obj) {
try {
return GLOBAL.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new IllegalStateException(e);
}
}
public static ObjectMapper createObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
彻底告别 ThreadLocal,Metaspace 曲线瞬间拉平。
7. 参考资料
- ThreadLocal 内存泄露分析与实战
- Spring Boot & Jackson 线程安全官方说明
- 《深入理解 Java 虚拟机》第 3 版 —— 第 8 章 虚拟机字节码执行引擎
如果本文帮到你,记得点赞收藏!评论区一起交流更多 JVM 坑位~
1355

被折叠的 条评论
为什么被折叠?



