TheadLocal专题一

 
 

那我把 ThreadLocal 的底层结构、弱引用原因、线程池为什么必须 remove() 一次讲透,你面试时基本就够用了。


1. ThreadLocal 的整体结构图

先看最核心的关系:

 
Thread
 └── ThreadLocalMap threadLocals
      ├── Entry(ThreadLocalA, valueA)
      ├── Entry(ThreadLocalB, valueB)
      └── Entry(ThreadLocalC, valueC)
 

注意:

  • 每个线程 Thread 自己内部都有一个 ThreadLocalMap
  • ThreadLocal 对象本身并不存值
  • 真正的值是存在 当前线程的 ThreadLocalMap

所以 ThreadLocal 更像一个“操作当前线程局部变量的句柄 / key”。


2. set/get 到底发生了什么

2.1 set()

例如:

 
private static final ThreadLocal<String> TL = new ThreadLocal<>();

TL.set("hello");
 

大致流程:

第一步:拿到当前线程

 
Thread t = Thread.currentThread();
 

第二步:拿到当前线程里的 ThreadLocalMap

 
ThreadLocalMap map = t.threadLocals;
 

第三步:以当前 ThreadLocal 作为 key,存 value

 
map.set(TL, "hello");
 

所以本质是:

 
当前线程.threadLocals.put(TL, "hello")
 

2.2 get()

 
String v = TL.get();
 

大致流程:

  1. 拿到当前线程 Thread.currentThread()
  2. 拿到线程内部的 threadLocals
  3. 用当前 TL 作为 key 去查 value

也就是:

 
value = 当前线程.threadLocals.get(TL)
 

3. 一个线程里可以有多个 ThreadLocal

比如:

 
ThreadLocal<Long> userIdTL = new ThreadLocal<>();
ThreadLocal<String> traceIdTL = new ThreadLocal<>();
ThreadLocal<String> tenantTL = new ThreadLocal<>();

userIdTL.set(1001L);
traceIdTL.set("trace-001");
tenantTL.set("tenant-A");
 

那么当前线程的 ThreadLocalMap 可能就是:

 
ThreadLocalMap
   userIdTL  -> 1001L
   traceIdTL -> "trace-001"
   tenantTL  -> "tenant-A"
 

所以你可以把它理解为:

每个线程有一个自己的 Map,Map 的 key 是 ThreadLocal 对象,value 是线程独享的数据。


4. ThreadLocalMap 的 Entry 为什么是弱引用

这是 ThreadLocal 最容易被问的点。

ThreadLocalMap 里的每个元素大致长这样:

 
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
}
 

也就是说:

  • Entry 的 key = 弱引用的 ThreadLocal
  • Entry 的 value = 你存进去的对象

可以理解成:

 
Entry {
    weakReference(ThreadLocal<?> key)
    Object value
}
 

5. 为什么 key 要设计成弱引用

先说结论:

为了避免 ThreadLocal 对象本身不用了以后,因为被 ThreadLocalMap 强引用着,导致它无法被 GC。


5.1 如果 key 是强引用,会怎样?

假设:

 
public void test() {
    ThreadLocal<byte[]> tl = new ThreadLocal<>();
    tl.set(new byte[10 * 1024 * 1024]); // 10MB
}
 

执行完 test() 后,局部变量 tl 已经没了,按理说这个 ThreadLocal 对象应该可以回收。

但是如果 ThreadLocalMap.Entry 里对 key 是强引用,那会变成:

 
当前线程 Thread
   -> threadLocals
      -> Entry
         -> key(ThreadLocal对象)
         -> value(10MB数组)
 

由于 Thread -> threadLocals -> Entry -> key 这条引用链还在,ThreadLocal 对象就回收不了。

所以 JDK 把 key 设计成弱引用


5.2 弱引用之后会怎样?

如果外部没有强引用再指向这个 ThreadLocal 对象,比如方法执行完后 tl 消失了,那么下次 GC 时:

  • ThreadLocal key 会被回收
  • Entry.get() 会变成 null

于是这个 Entry 会变成这样:

 
Entry {
    key = null
    value = 10MB数组
}
 

这种 Entry 就叫 stale entry(过期条目)


6. 这里为什么还会内存泄漏?

这也是最关键的点:
key 被回收了,不代表 value 自动就没了。

此时结构是:

 
Thread
 └── ThreadLocalMap
      └── Entry
           ├── key = null
           └── value = 10MB数组
 

注意:

  • key 没了
  • value 还被 Entry 强引用
  • Entry 又被 ThreadLocalMap 引用
  • ThreadLocalMap 又被 Thread 引用

所以只要线程还活着,这个 value 就还活着,GC 回收不掉。

这就是很多人说的:

ThreadLocal 会内存泄漏

更准确地说应该是:

ThreadLocal 使用不当时,ThreadLocalMap 中 key 已失效但 value 仍存活,在线程长期不销毁的情况下会造成内存泄漏。


7. 为什么在线程池里尤其危险

因为普通线程执行完任务可能就结束了,线程一死,整个 Thread -> threadLocals 都会被回收,问题没那么大。

线程池线程不会轻易销毁,会被反复复用。

比如:

 
static ThreadLocal<String> TL = new ThreadLocal<>();

executor.submit(() -> {
    TL.set("用户A的数据");
});
 

如果你没 remove(),线程执行完任务后,这个线程回到线程池,并不会死。

那么线程内部还可能留着:

 
线程池线程1:
   TL -> "用户A的数据"
 

后面另一个任务又复用了这个线程:

 
executor.submit(() -> {
    System.out.println(TL.get());
});
 

这时就可能读到上一个任务残留的数据。

所以线程池里 ThreadLocal 有两个风险:


7.1 风险一:内存泄漏

旧 value 一直挂在线程上,线程不销毁,就一直占内存。


7.2 风险二:脏数据 / 数据串线程任务

线程池线程被复用,后续任务可能读到上个任务遗留的数据。

这在下面这些场景特别危险:

  • 用户登录信息 userId
  • 租户信息 tenantId
  • traceId
  • 数据源切换标记
  • 权限上下文

如果不清理,可能直接串用户、串租户。


8. 所以为什么必须 remove()

标准写法就是:

 
private static final ThreadLocal<String> TL = new ThreadLocal<>();

public void doBiz() {
    try {
        TL.set("some value");
        // 业务逻辑
    } finally {
        TL.remove();
    }
}
 

为什么要放在 finally

  • 正常执行完,要清理
  • 中途抛异常,也要清理

这样才能保证线程被线程池复用时是干净的。


9. remove() 到底做了什么

remove() 本质上是把当前线程 ThreadLocalMap 中以当前 ThreadLocal 为 key 的那条记录删掉。

也就是:

 
当前线程.threadLocals.remove(当前ThreadLocal)
 

删掉后:

  • 这个 Entry 没了
  • value 没有强引用链了
  • 后续就能被 GC

10. 一个完整的生产例子:登录用户上下文

10.1 定义上下文

 
public class UserContext {
    private static final ThreadLocal<Long> USER_ID_HOLDER = new ThreadLocal<>();

    public static void setUserId(Long userId) {
        USER_ID_HOLDER.set(userId);
    }

    public static Long getUserId() {
        return USER_ID_HOLDER.get();
    }

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

10.2 在拦截器里设置

 
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        Long userId = 1001L; // 假设从 token 解析出来
        UserContext.setUserId(userId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler,
                                Exception ex) {
        UserContext.clear();
    }
}
 

业务代码中:

 
Long userId = UserContext.getUserId();
 

这样就能在同一个请求线程里随处拿到用户信息。


11. 面试时最标准的一套回答

你可以按这个逻辑说:


11.1 ThreadLocal 是什么

ThreadLocal 用来给每个线程提供独立副本,做到线程隔离。
不同线程访问同一个 ThreadLocal,拿到的是各自线程里的值,互不影响。


11.2 底层实现

底层并不是 ThreadLocal 自己存值,而是:

  • 每个 Thread 内部有一个 ThreadLocalMap
  • ThreadLocalMap 的 key 是 ThreadLocal 对象
  • value 是通过 set() 存进去的值

所以:

 
threadLocal.set(x)
 

本质是:

 
Thread.currentThread().threadLocals.put(threadLocal, x)
 

11.3 为什么 key 是弱引用

ThreadLocalMap.Entry 继承了 WeakReference<ThreadLocal<?>>,也就是 key 是弱引用。
这样当外部没有强引用指向某个 ThreadLocal 时,GC 可以回收这个 key,避免 ThreadLocal 对象本身无法回收。


11.4 为什么还会内存泄漏

因为 key 被 GC 回收后,Entry 里可能变成:

  • key = null
  • value 还在

而 value 仍然被 Thread -> ThreadLocalMap -> Entry 引用着。
如果线程长期存活,尤其在线程池中,这些 value 就可能一直无法释放,从而导致内存泄漏。


11.5 为什么线程池必须 remove

线程池线程会复用,如果不 remove()

  1. value 可能长期挂在线程上,导致内存泄漏
  2. 下一个任务复用这个线程时,可能读到上一个任务遗留的数据,造成脏数据问题

所以正确写法是:

 
try {
    threadLocal.set(value);
    // 业务逻辑
} finally {
    threadLocal.remove();
}
 

12. 你可以这样理解 ThreadLocal 的本质

我给你一个最容易记住的类比:

ThreadLocal 不是“把值存在线程变量里”

而是:

给每个线程发一个私有的 Map 柜子,ThreadLocal 对象就是柜子的钥匙编号。

  • 线程 = 一个员工
  • Thread.threadLocals = 这个员工自己的储物柜
  • ThreadLocal = 某个抽屉的标签
  • value = 抽屉里的东西

所以不同员工(线程)即使用同一个标签(同一个 ThreadLocal),打开的也是自己柜子里的那份东西


13. 最后给你一个面试加分点:ThreadLocal 适用场景

典型场景:

1)保存用户上下文

  • userId
  • 登录用户信息
  • 权限信息

2)保存链路信息

  • traceId
  • requestId

3)保存租户信息

  • tenantId

4)数据库动态数据源切换

  • 在 AOP 中把数据源标识放入 ThreadLocal

5)SimpleDateFormat 老版本线程安全问题

以前有人会给每个线程一个自己的 SimpleDateFormat


如果你愿意,我下一步可以继续给你讲 “ThreadLocalMap 为什么要用开放寻址法而不是链表法”,这个是很多 Java 面试官特别爱追问的点,我可以直接按面试问答模式给你讲。

posted on 2026-07-08 11:48  日思日睿  阅读(5)  评论(0)    收藏  举报