ThreadLocal 内存泄漏:你的应用正在悄悄 OOM

ThreadLocal 内存泄漏:你的应用正在悄悄 OOM

那天凌晨 3 点,我们的订单服务挂了。不是流量打挂的,是自己把自己撑死的。


一、事故现场

凌晨 3 点,我被电话叫醒:

"订单服务 OOM 了,JVM 刚重启过,但是跑了两个小时又挂了。"

登上机器看了下 GC 日志:

rust 复制代码
Full GC (Allocation Failure) [...] [Eden: 0.0B->0.0B(0.0B) Survivors: 0.0B->0.0B Heap: 1.8G->1.8G(2.0G)]

堆几乎占满了,Full GC 回收不动。2G 的堆,1.8G 赖着不走。

jmap -histo:live 看了一下,排第一的是:

kotlin 复制代码
 num     #instances         #bytes  class name
   1         12847       154016400  [B
   2         12843        92361600  com.xxx.service.UserContext

jmap -histo:live 的输出要这样看:[B 是 byte 数组,包含 String、序列化对象等底层存储;UserContext 是业务对象本身。两者不是简单相加------UserContext 持有的字符串、权限列表等字段,其内部的 byte 数组已经计入 [B] 的 154MB 中。粗略估算,这 12843 个 UserContext 对象本身约占 88MB,其间接引用的 byte\[\] 也包含在 154MB 的 [B] 中,合计占用远超 154MB,而且还在涨。

我们整个系统只有 200 个并发用户,怎么会有 12843 个 UserContext?

排查之后发现:问题出在 ThreadLocal 上。说得更准确点,出在线程池 + ThreadLocal 的组合上。


二、背景:我们怎么用 ThreadLocal 的

我们的订单服务有一个 UserContext,保存当前登录用户的信息,在 Controller 层设置,在 Service 层读取:

java 复制代码
public class UserContext {
    private static final ThreadLocal<UserContext> CONTEXT =
        new ThreadLocal<>();

    private Long userId;
    private String username;
    private List<String> permissions;  // 权限列表,比较大

    public static void set(UserContext ctx) {
        CONTEXT.set(ctx);
    }

    public static UserContext get() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

在拦截器里设置和清理:

java 复制代码
@Component
public class UserContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
            HttpServletResponse response, Object handler) {
        // 从 token 解析用户信息,设置到 ThreadLocal
        UserContext ctx = parseToken(request.getHeader("Authorization"));
        UserContext.set(ctx);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
            HttpServletResponse response, Object handler, Exception ex) {
        UserContext.clear();  // 请求结束,清理 ThreadLocal
    }
}

这套用法看起来没问题。Tomcat 的线程处理完请求后,afterCompletion 会把 ThreadLocal 清掉。

问题出在异步任务上。


三、真正的麻烦:线程池里的"脏数据"

问题 1:线程池复用 → ThreadLocal 脏数据

我们的订单服务有异步处理逻辑:

java 复制代码
@Service
public class OrderService {

    @Autowired
    private ThreadPoolTaskExecutor executor;

    public void createOrder(OrderDTO orderDTO) {
        // 主线程里 UserContext 有值
        UserContext ctx = UserContext.get();  // ✅ 正常

        // 把上下文传给异步任务(标准 ThreadLocal 不会自动传递到子线程)
        UserContext finalCtx = ctx;
        executor.submit(() -> {
            UserContext.set(finalCtx);   // 手动设置到子线程
            doAsyncWork();
            // ⚠️ 如果这里忘了调 UserContext.clear()
            // 这个线程执行完任务后,ThreadLocalMap 里还留着 finalCtx
        });
    }
}

问题在于:标准 ThreadLocal 不会把值自动传给子线程,所以很多人会像上面这样手动 set 进去。但 set 了之后忘记 remove(),线程回到池里,ThreadLocalMap 的数据就留下来了。

下一个任务复用这个线程时:

java 复制代码
// 线程-1 先后执行了三个任务
// 任务A:用户张三的请求 → UserContext.set(张三的上下文) → 没有 remove()
// 任务B:用户李四的请求 → UserContext.get() → 拿到的是张三的数据!
// 任务C:用户王五的请求 → UserContext.get() → 还是张三的数据!

还有一种更隐蔽的情况:项目里如果用了 InheritableThreadLocal(或者用了 TTL 但没正确包装线程池),父线程的值会"继承"给子线程。线程池复用后,这个值跟着传递,造成跨用户的上下文混乱。

用户李四的操作,用的是张三的权限。 这已经不是 Bug 了,是安全漏洞。

问题 2:线程池 + 不 remove → 内存泄漏

这就是 OOM 的根因。

Tomcat 的线程数量有限(默认 200),而且每次请求结束拦截器会调 remove(),所以主线程不会泄漏。

但我们用了 ThreadPoolTaskExecutor,核心线程数 50,最大线程数 200。异步任务里没调 UserContext.clear()

每次异步任务执行,UserContext 被设进线程的 ThreadLocalMap。任务跑完了,线程回到池里,ThreadLocalMap 里的数据没人管。

线程不销毁 → ThreadLocalMap 不销毁 → Entry 的 value 不释放 → 内存泄漏。

而且这个泄漏是渐进式的:同一个 ThreadLocal key 的旧值会被新值覆盖,但如果不同的拦截器/工具类用了不同的 ThreadLocal key,就各自泄漏各自的,互不影响。


四、为什么 ThreadLocal 会泄漏?底层原理

4.1 ThreadLocal 的存储结构

每个 Thread 对象内部有一个 ThreadLocalMap,是个自定义的哈希表。Key 是 ThreadLocal 对象的弱引用,Value 是你存进去的值(强引用):

php 复制代码
Thread 对象
  └── ThreadLocalMap
        └── Entry[]
              └── Entry extends WeakReference<ThreadLocal<?>>
                    ├── key: ThreadLocal 对象(弱引用)──→ 可能被 GC 回收
                    └── value: 你存的对象(强引用)──→ 不会被 GC 回收

4.2 泄漏的两条路径

路径 1:ThreadLocal 实例本身被 GC 回收(key 变成 null)

以下代码演示了路径 1 的触发条件------注意,这种写法在真实项目里几乎不存在 ,因为所有人都是 private static final

java 复制代码
public void process() {
    ThreadLocal<UserContext> tl = new ThreadLocal<>();  // 局部变量
    tl.set(new UserContext(...));
    // 方法执行完,tl 出栈,强引用消失
    // 但 ThreadLocalMap 里 Entry 的 key 是弱引用,GC 会回收 key
    // 此时 Entry 的 key 变成 null,但 value 还在!
    // 这就是 "key 被 GC、value 孤儿" 的泄漏
}

⚠️ 这种场景在实际项目中比较少见,因为绝大多数项目的 ThreadLocal 都是 private static final 的,不会出栈消失。路径 2 才是生产环境真正出事的地方。

路径 2(我们的情况):线程池中线程不销毁

线程池的线程是长期存活的。线程不销毁 → ThreadLocalMap 不销毁 → Entry 不回收 → value 不释放。就算 key 被 GC 了也没用,因为还有一条强引用链:

css 复制代码
线程对象 → ThreadLocalMap → Entry[] → Entry.value(强引用)→ UserContext

4.3 JDK 的"自愈"机制

JDK 在 ThreadLocalMapget()set()remove() 里,会顺带清理 key 为 null 的 Entry(把 value 也置为 null)。对应的方法叫 expungeStaleEntry

但这个"自愈"有两个前提:

  1. 你得调用 get() / set() / remove()------如果你再也不碰这个 ThreadLocal,就不会触发清理
  2. 在线程池场景下,线程可能很久都不执行新的 ThreadLocal 操作------自愈不会及时发生

简单一句话:JDK 的弱引用 + 自愈是"兜底",不是"保证"。正确做法永远是主动 remove()


五、解决方案

5.1 方案一:try-finally 确保清理(最基本)

java 复制代码
executor.submit(() -> {
    try {
        UserContext.set(ctx);  // 设置
        doAsyncWork();
    } finally {
        UserContext.clear();   // 必须清理!
    }
});

这是底线。 不管逻辑多复杂、有没有异常,finally 里必须调 remove()

5.2 方案二:用自定义的 ThreadPoolTaskExecutor 统一清理

如果一个项目里到处都在写 try-finally,迟早有人会忘。不如在线程池层面统一处理:

java 复制代码
@Component
public class ThreadLocalAwareTaskExecutor extends ThreadPoolTaskExecutor {

    @Override
    public void execute(Runnable command) {
        // 捕获提交线程的 ThreadLocal 快照
        Map<ThreadLocal<?>, Object> snapshot = captureThreadLocals();
        super.execute(() -> {
            try {
                // 在工作线程中恢复
                restoreThreadLocals(snapshot);
                command.run();
            } finally {
                // 执行完清理所有 ThreadLocal
                cleanAllThreadLocals();
            }
        });
    }

    private Map<ThreadLocal<?>, Object> captureThreadLocals() {
        // 使用 TransmittableThreadLocal 的快照能力
        // 或自己维护一个 ThreadLocal 注册表
        Map<ThreadLocal<?>, Object> snapshot = new HashMap<>();
        // 遍历注册的 ThreadLocal,get 当前值
        return snapshot;
    }

    private void restoreThreadLocals(Map<ThreadLocal<?>, Object> snapshot) {
        snapshot.forEach((tl, value) -> {
            if (value != null) {
                tl.set(value);
            } else {
                tl.remove();  // snapshot 中为 null,也要清理工作线程可能残留的旧值
            }
        });
    }

    private void cleanAllThreadLocals() {
        // 遍历注册的 ThreadLocal,全部 remove
        UserContext.clear();
        // 其他 ThreadLocal 也在这里统一清理
        TraceContext.clear();
        // ...
    }
}

5.3 方案三(推荐):用 TransmittableThreadLocal

阿里开源的 TransmittableThreadLocal(简称 TTL),就是干这个的。

java 复制代码
// 把 ThreadLocal 换成 TransmittableThreadLocal
public class UserContext {
    private static final TransmittableThreadLocal<UserContext> CONTEXT =
        new TransmittableThreadLocal<>();

    // set / get / remove 方法不变
    public static void set(UserContext ctx) { CONTEXT.set(ctx); }
    public static UserContext get() { return CONTEXT.get(); }
    public static void clear() { CONTEXT.remove(); }
}

然后包装线程池:

java 复制代码
// 方式 1:用 TTL 的 Java Agent,无侵入
// 启动参数加 -javaagent:transmittable-thread-local-2.x.jar

// 方式 2:手动包装
ExecutorService executor = TtlExecutors.getTtlExecutorService(
    rawExecutorService
);

// Spring Boot 中
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(50);
    executor.setThreadNamePrefix("async-");
    executor.setTaskDecorator(TtlRunnable::get);  // ← 关键:TTL 包装
    executor.initialize();
    return executor;
}

TTL 做了三件事:

  • 自动传递:任务提交时,将父线程的 ThreadLocal 值复制给工作线程
  • 执行后恢复快照:任务跑完后,TTL 会把工作线程的 ThreadLocal 状态恢复到执行前的快照。如果工作线程此前是干净的,效果等同于清除;如果此前就有残留值(历史泄漏),TTL 会恢复那些残留值,不会帮你清掉
  • 不干扰线程原有值:工作线程在接手任务之前如果已经有 ThreadLocal 值,TTL 会在任务执行前保存原值,任务结束后恢复

说白了:TTL 能保证本次任务设置的值不漏给下一个任务,但不会自动修线程池里已有的旧泄漏 。用了 TTL 仍然建议在 finally 中调 remove(),双保险。


六、排查 ThreadLocal 泄漏的方法

如果你怀疑项目里有 ThreadLocal 泄漏,照着这个步骤来:

6.1 确认泄漏

bash 复制代码
# 1. 导出堆 dump
jmap -dump:live,format=b,file=heap.hprof <pid>
# 注意:-dump:live 会先触发一次 Full GC,可能暂时缓解泄漏现象
# 如果需要看到最真实的泄漏状态,可以用 -dump:format=b(不带 live)

# 2. 用 MAT 或 VisualVM 分析
# 搜索你怀疑的类(如 UserContext),看引用链
# 如果引用链是 Thread → ThreadLocalMap → Entry → 你的对象
# 那就是ThreadLocal泄漏

6.2 定位哪个 ThreadLocal 没清理

java 复制代码
// 在运行时打印所有线程的 ThreadLocalMap
for (Map.Entry<Thread, StackTraceElement[]> entry :
        Thread.getAllStackTraces().entrySet()) {
    Thread thread = entry.getKey();
    try {
        // 通过反射获取 ThreadLocalMap
        Field threadLocalsField = Thread.class.getDeclaredField("threadLocals");
        threadLocalsField.setAccessible(true);
        Object threadLocalMap = threadLocalsField.get(thread);
        if (threadLocalMap != null) {
            Field tableField = threadLocalMap.getClass().getDeclaredField("table");
            tableField.setAccessible(true);
            Object table = tableField.get(threadLocalMap);
            int count = 0;
            int length = java.lang.reflect.Array.getLength(table);
            for (int i = 0; i < length; i++) {
                if (java.lang.reflect.Array.get(table, i) != null) count++;
            }
            if (count > 0) {
                log.warn("线程[{}] ThreadLocalMap 中有 {} 个 Entry",
                    thread.getName(), count);
            }
        }
    } catch (Exception e) {
        // 忽略反射异常
    }
}

⚠️ 这段代码只能临时用用,别放生产环境常驻跑。几个注意点:

  1. 反射访问私有字段在 JDK 9+ 的模块系统下需要添加 --add-opens java.base/java.lang=ALL-UNNAMED 启动参数;
  2. ThreadLocalMap 的内部结构在不同 JDK 版本间可能变化,这段代码可能在某些版本上直接抛异常;
  3. 这段代码只打印了 Entry 数量,没有打印具体的 ThreadLocal Key(即哪个 ThreadLocal 变量在泄漏),对定位问题帮助有限。

更靠谱的方式 :用 jmap -dump 导出堆 dump,用 MAT 分析 Thread → ThreadLocalMap → Entry 的引用链,可以直接看到是哪个 ThreadLocal 的 value 在泄漏。

6.3 线上监控

真正有用的监控不是看线程池活跃数,而是看 set/remove 的调用比例。set 比 remove 多,就说明有任务没清理。

java 复制代码
public class UserContext {
    private static final ThreadLocal<UserContext> CONTEXT =
        new ThreadLocal<>();

    // 用 Micrometer 计数器
    private static final Counter SET_COUNTER =
        Metrics.counter("threadlocal.usercontext.set");
    private static final Counter REMOVE_COUNTER =
        Metrics.counter("threadlocal.usercontext.remove");

    public static void set(UserContext ctx) {
        CONTEXT.set(ctx);
        SET_COUNTER.increment();
    }

    public static void clear() {
        CONTEXT.remove();
        REMOVE_COUNTER.increment();
    }
}

在 Grafana 配告警:set 计数 - remove 计数 > 阈值,一旦 set 远多于 remove,说明有任务没调 clear()


七、一张 CheckList:ThreadLocal 使用规范

# 检查项 常见错误 正确做法
1 是否调了 remove() 忘记清理,依赖 GC try-finally 中必须调 remove()
2 线程池场景 线程复用导致"脏数据" 用 TTL 传递上下文 + finally 清理
3 异步任务 @Async 方法里 ThreadLocal 为空 配合 TTL 或手动传递
4 ThreadLocal 存大对象 权限列表、JSON 字符串等 只存必要信息,大对象用后立即 remove()
5 多个 ThreadLocal 各自清理容易遗漏 统一注册表 + 线程池层面批量清理
6 线上监控 OOM 了才知道 监控 set/remove 调用比例,差距过大就告警

八、总结

回到开头的事故:12843 个 UserContext 实例把堆撑爆了,根因就一条------线程池里的异步任务 set 了 ThreadLocal 但没 remove

修复后我们做了三件事:

  1. 所有异步任务加 try-finally { UserContext.clear(); }
  2. ThreadLocal 换成 TransmittableThreadLocal,用 TtlRunnable 包装线程池
  3. 把 ThreadLocal 使用规范加进代码审查 CheckList

ThreadLocal 的 3 条规则,记住就行:

  1. 只要 set() 了,就必须在 finallyremove()
  2. 线程池场景下,ThreadLocal 的值不会自动清理,得手动处理
  3. 需要跨线程传递上下文,用 TTL,别手动传参

你在项目里用过 ThreadLocal 吗?有没有遇到过"脏数据"或者 OOM 的问题?评论区聊聊 👇


这是「Java 生产环境踩坑实录」系列的第 3 篇。上篇聊了 @Transactional 的 7 个坑,第 1 篇聊了 Redis 分布式锁的正确姿势。

如果这篇文章帮到了你,点个「在看」,让更多人少踩坑 💪

相关推荐
小撒的私房菜1 小时前
Multi-Agent 里谁来指挥?我用一个调度员,让多个 Agent 开始协作
人工智能·后端·agent
范什么特西1 小时前
Spring boot细节
java·spring boot·后端
苍何2 小时前
高考填志愿,我做了个 Skill,300 个 Agent 同时查公司
后端
yspwf2 小时前
NestJS 配置管理完整方案
后端·架构·node.js
雪隐2 小时前
个人电脑玩AI-03让5060 Ti给你打工——paddleOCR
人工智能·后端
AskHarries2 小时前
Shell Tool:命令执行、输出读取和长任务管理
后端
苍何2 小时前
开源项目想出海,我让 AI 员工帮我找海外达人
后端
长栎2 小时前
你在 Controller 里注入 8 个 Service,其实是想请一个中介者
后端
小闹5492 小时前
Docker 如何才能学的更扎实
后端·程序员