# ThreadLocal原理与内存泄漏,面试必问

0 阅读3分钟

面试官问:"了解ThreadLocal吗?为什么会内存泄漏?"

很多人只知道"每个线程独立",再深问就答不上了。今天把ThreadLocal从原理到坑都讲清楚。

ThreadLocal解决什么问题

多线程共享变量会有线程安全问题,但有时候我们需要的不是共享,而是每个线程有自己的副本。

public class UserContext {
    private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();

    public static void set(User user) {
        CURRENT_USER.set(user);
    }

    public static User get() {
        return CURRENT_USER.get();
    }

    public static void remove() {
        CURRENT_USER.remove();
    }
}

典型场景:把登录用户信息在请求处理的各个层之间传递,不用一路传参数。

// Controller层设置
UserContext.set(loginUser);
// Service层直接取
UserContext.get().getId();

底层原理

每个Thread对象内部都有一个ThreadLocalMap:

public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
}
static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;
        Entry(ThreadLocal<?> k, Object v) {
            super(k);        // key是弱引用
            value = v;       // value是强引用
        }
    }
    private Entry[] table;
}

关系图:

Thread
 └── ThreadLocalMap
      └── Entry[]
           ├── key: WeakReference(ThreadLocal)  ← 弱引用
           └── value: Object                    ← 强引用

注意这个设计:key是弱引用,value是强引用。

为什么会内存泄漏

ThreadLocal ref (强) ──→ ThreadLocal对象
                              ↑
                              │ 弱引用
Entry.key ────────────────────┘

Entry.value (强) ──→ 大对象

当外部不再持有ThreadLocal的强引用时:

  1. 弱引用的key会被GC回收,变成null
  2. 但value是强引用,Entry本身还被ThreadLocalMap持有
  3. 这块value就成了"key为null但value还在"的僵尸Entry

如果线程是线程池里的常驻线程,这个Thread不会结束,ThreadLocalMap也不会被回收,value就永远泄漏了。

单个ThreadLocal泄漏可能就几十字节,但如果有大量线程、大量使用,内存会持续上涨。

正确用法:用完remove

try {
    UserContext.set(user);
    doBusiness();
} finally {
    UserContext.remove();   // 必须放在finally里
}

为什么放finally?因为就算业务抛异常,也能保证清理。

Spring MVC的DispatcherServlet就是这么处理的——请求结束后清理各种ThreadLocal。

怎么在框架里更优雅地做

用拦截器统一处理:

@Component
public class UserContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        String token = request.getHeader("Authorization");
        User user = tokenService.parse(token);
        UserContext.set(user);
        return true;
    }

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

这样业务代码完全不用管ThreadLocal的生命周期。

子线程能拿到父线程的值吗

默认不能:

ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("main");

new Thread(() -> {
    System.out.println(tl.get());  // null
}).start();

需要InheritableThreadLocal:

InheritableThreadLocal<String> tl = new InheritableThreadLocal<>();
tl.set("main");

new Thread(() -> {
    System.out.println(tl.get());  // main
}).start();

但InheritableThreadLocal在线程池里不好用——线程是复用的,不会每次都继承。

阿里开源的TransmittableThreadLocal(TTL)解决了这个问题,支持线程池场景的上下文传递。

常见使用场景

场景说明
用户上下文登录信息跨层传递
请求追踪traceId贯穿全链路
数据库连接Spring的事务管理
SimpleDateFormat每个线程一个实例,避免线程安全问题
分库分表路由分片键在ThreadLocal里传

一个经典的正确用法:SimpleDateFormat

// 错误:SimpleDateFormat不是线程安全的
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

// 正确:每个线程一个
private static final ThreadLocal<SimpleDateFormat> SDF =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 使用
SDF.get().format(new Date());

withInitial是Java 8加的方法,省去了重写initialValue()的麻烦。

面试怎么答

被问到ThreadLocal,按这个思路回答:

  1. 每个Thread内部有ThreadLocalMap,以ThreadLocal为key,以变量副本为value
  2. Entry的key是弱引用,value是强引用
  3. 弱引用key被回收后,value会泄漏,所以用完必须remove
  4. 线程池场景下泄漏影响更大,因为线程不销毁
  5. 实际项目用拦截器+finally统一管理生命周期

把这五点说清楚,基本就稳了。