从ThreadLocal到内存泄漏,我踩了一个隐藏很深的坑

从ThreadLocal到内存泄漏,我踩了一个隐藏很深的坑

排查线上内存泄漏,GC 日志显示老年代对象持续增长,堆内存使用率从 60% 慢慢爬到 85%,FGC 次数越来越多。用 MAT 分析 dump 文件,发现有大量 ThreadLocal$ThreadLocalMap$Entry 对象占据了堆内存。

这说明 ThreadLocal 有问题。

问题现场

一个典型的用户上下文传递场景:

public class UserContext {
    private static final ThreadLocal<UserInfo> CONTEXT = new ThreadLocal<>();

    public static void set(UserInfo user) {
        CONTEXT.set(user);
    }

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

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

业务代码里这么用:

@Service
public class OrderService {

    public void createOrder(CreateOrderCmd cmd) {
        UserInfo user = UserContext.get();
        // 使用 user 信息
        Order order = new Order();
        order.setUserId(user.getId());
        // ...
    }
}

用户登录时设置上下文,请求结束时清理:

@Controller
public class UserController {

    @PostMapping("/login")
    public ApiResponse login(@RequestBody LoginRequest request) {
        UserInfo user = userService.login(request);
        UserContext.set(user);  // 设置上下文
        return ApiResponse.success(user);
    }

    @PostMapping("/logout")
    public ApiResponse logout() {
        UserContext.clear();  // 清理上下文
        return ApiResponse.success();
    }
}

问题出在哪?用户可能直接关浏览器,或者长连接断开,根本没机会调用 logout。ThreadLocal 里的 UserInfo 对象永远不会被清理。

ThreadLocal 内存泄漏的原理

ThreadLocal 本身并不会内存泄漏,泄漏的是线程持有的 ThreadLocalMap。

ThreadLocalMap 是 ThreadLocal 的内部实现,每个 Thread 对象内部都有一个 ThreadLocalMap:

public class Thread implements Runnable {
    ThreadLocalMap threadLocals = null;  // Thread 类里的字段
}

ThreadLocalMap 的 Entry 结构:

static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;  // value 是强引用

        Entry(ThreadLocal<?> k, Object v) {
            super(k);  // key 是弱引用
            value = v;
        }
    }

    private Entry[] table;
}

关键点:

  • key(ThreadLocal)是弱引用:当 ThreadLocal 对象没有强引用时,可以被 GC 回收
  • value 是强引用:即使 ThreadLocal 被回收,value 仍然被 Entry 引用,无法回收

正常情况下的清理

ThreadLocal 有自动清理机制:

public class ThreadLocal<T> {
    // get() 时会清理过期的 Entry
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                return (T) e.value;
            }
        }
        return setInitialValue();  // 这里会调用 expungeStaleEntries()
    }
}

expungeStaleEntry() 会清理 key 已过期的 Entry,并重新计算索引位置。

内存泄漏的场景

但如果:

  1. ThreadLocal 变量是 static 的(在类加载时就创建)
  2. 线程池的核心线程是保活的
  3. 没有主动调用 remove()

那么:

// 静态的 ThreadLocal,永不销毁
public class UserContext {
    private static final ThreadLocal<UserInfo> CONTEXT = new ThreadLocal<>();
}

// 线程池里的线程是复用的
private final ExecutorService pool = Executors.newFixedThreadPool(10);

// 请求1:设置用户A的上下文
UserContext.set(userA);

// 请求1 结束,但忘记调用 clear()

// 请求2:复用线程,处理用户B
// 但线程1的 ThreadLocalMap 里还有 userA 的 Entry
// 如果 userA 的 ThreadLocal 被回收了(理论上 static 不会),
// 实际上 static 的 ThreadLocal 不会被回收,所以 Entry 永远在

实际上 static ThreadLocal + 线程池 + 不清理 = 内存泄漏。

线程池场景下的泄漏

线程池里的线程是长久的,ThreadLocalMap 里的 Entry 如果不主动清理,就会一直存在:

public class ThreadPoolLeakDemo {
    private static final ThreadLocal<byte[]> CACHE = new ThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2);

        for (int i = 0; i < 1000; i++) {
            final int userId = i;
            pool.submit(() -> {
                // 每个请求分配 1MB 缓存
                CACHE.set(new byte[1024 * 1024]);

                // 模拟业务处理
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {}

                // 忘记调用 remove()
                // CACHE.remove();
            });
        }
    }
}

每次请求都会在当前线程的 ThreadLocalMap 里放一个 1MB 的 Entry,1000 个请求 = 1GB 内存泄漏。

解决方案

方案一:每次使用后 remove

这是最正确的做法,但需要确保每个入口都清理:

public void handleRequest(HttpRequest request) {
    try {
        UserContext.set(parseUser(request));
        // 业务逻辑
        doBusiness();
    } finally {
        UserContext.remove();  // 无论成功失败都要清理
    }
}

问题:依赖开发者自觉,容易遗漏。

方案二:拦截器/Filter 自动清理

用 Spring 的拦截器统一处理:

@Component
public class UserContextInterceptor implements HandlerInterceptor {

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

配置拦截器:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private UserContextInterceptor userContextInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(userContextInterceptor)
            .addPathPatterns("/**");
    }
}

这样所有请求处理完都会清理 ThreadLocal。

方案三:使用 try-with-resources 自动清理

自定义 AutoCloseable 包装:

public class UserContextHolder implements AutoCloseable {

    public UserContextHolder(UserInfo user) {
        UserContext.set(user);
    }

    @Override
    public void close() {
        UserContext.clear();
    }
}

// 使用
public void handleRequest(HttpRequest request) {
    try (UserContextHolder holder = new UserContextHolder(parseUser(request))) {
        doBusiness();
    }
}

方案四:使用阿里 transmittable-thread-local

Ttl 是阿里开源的库,解决了线程池场景下 ThreadLocal 的传递问题,并且有更完善的清理机制:

// 引入依赖
// <dependency>
//     <groupId>com.alibaba</groupId>
//     <artifactId>transmittable-thread-local</artifactId>
//     <version>2.14.5</version>
// </dependency>

// 使用 TtlRunnable 或 TtlCallable
ExecutorService pool = Executors.newFixedThreadPool(10);

pool.submit(TtlRunnable.get(() -> {
    UserInfo user = UserContext.get();  // 自动传递父线程的值
    // 业务逻辑
}));

Ttl 的原理是在任务提交时 capture 父线程的 ThreadLocal 值,在任务执行时 set 回去,执行完后清理。

InheritableThreadLocal 的坑

除了 ThreadLocal,还有一个 InheritableThreadLocal,可以让子线程继承父线程的值:

public class InheritableDemo {
    private static final InheritableThreadLocal<String> INHERIT =
        new InheritableThreadLocal<>();

    public static void main(String[] args) {
        INHERIT.set("main thread value");

        Thread child = new Thread(() -> {
            // 子线程能拿到父线程的值
            System.out.println(INHERIT.get());  // 输出 "main thread value"
        });
        child.start();
    }
}

但 InheritableThreadLocal 在线程池里有更大的问题:

  1. 子线程的值是创建时复制的,不会随父线程更新
  2. 如果线程被线程池复用,子线程的值是上一次的值
public class InheritableThreadLocalPitfall {
    private static final InheritableThreadLocal<String> INHERIT =
        new InheritableThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(1);

        // 第一次设置
        INHERIT.set("user1");
        pool.submit(() -> System.out.println(INHERIT.get())).join();  // 输出 "user1"

        // 第二次设置
        INHERIT.set("user2");
        // 线程复用,同一个线程打印的值是什么?
        pool.submit(() -> System.out.println(INHERIT.get())).join();  // 输出 "user1"!
    }
}

这就是 InheritableThreadLocal 在线程池里的"值不更新"问题。阿里 transmittable-thread-local 解决了这个问题。

最佳实践

  1. ThreadLocal 尽量不要用 static:static 的 ThreadLocal 生命周期等于类加载器生命周期,更容易泄漏
  2. 在线程池环境下,优先用 Ttl:避免传递和清理问题
  3. 无论如何,请求结束后要清理:用拦截器或 Filter 统一清理
  4. ThreadLocal 只存轻量数据:不要存大对象,如果必须存,确保及时清理

最终的 UserContext 改进

public class UserContext {
    // 使用 Alipay Ttl
    private static final TransmittableThreadLocal<UserInfo> CONTEXT =
        new TransmittableThreadLocal<>();

    public static void set(UserInfo user) {
        CONTEXT.set(user);
    }

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

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

配合拦截器确保清理:

@Component
public class UserContextInterceptor implements HandlerInterceptor {

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

总结

ThreadLocal 内存泄漏的根因:

  1. ThreadLocalMap 的 Entry 对 value 是强引用
  2. 线程池的线程是复用的,不清理就会累积
  3. static ThreadLocal + 线程池 + 不清理 = 必然泄漏

解决方案优先级:

  1. 拦截器统一清理(最推荐):不依赖开发者自觉
  2. TransmittableThreadLocal(线程池必备):解决传递和清理问题
  3. try-with-resources(可选):代码更显式

ThreadLocal 是个好工具,但用错了就是内存泄漏的温床。

最后更新 4/20/2026, 4:48:48 AM