【Java 填坑日记】“一次诡异的内存泄漏:ThreadLocal 用完没 remove,Metaspace 被打爆”

部署运行你感兴趣的模型镜像

【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. 为什么报错?

  1. ThreadLocalMap 使用弱引用,但 Key 是弱引用,Value 不是
    当 Thread 长期存活(如线程池),Value 就不会被回收
  2. Spring Boot 可执行 FatJarLaunchedURLClassLoader自定义 ClassLoader
    只要有一条强引用链,整个 ClassLoader 和加载的全部类 都留在 Metaspace。
  3. 高频创建线程 + 大量注册 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. 参考资料

如果本文帮到你,记得点赞收藏!评论区一起交流更多 JVM 坑位~

您可能感兴趣的与本文相关的镜像

ACE-Step

ACE-Step

音乐合成
ACE-Step

ACE-Step是由中国团队阶跃星辰(StepFun)与ACE Studio联手打造的开源音乐生成模型。 它拥有3.5B参数量,支持快速高质量生成、强可控性和易于拓展的特点。 最厉害的是,它可以生成多种语言的歌曲,包括但不限于中文、英文、日文等19种语言

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Neoest

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值