ThreadLocal 要解决的不是“如何让共享对象变得线程安全”而是换一条思路既然多个线程共享同一份可变状态会产生竞争那就让每个线程各自持有一份数据。这样一来线程之间没有共享也就不需要通过锁或 CAS 去协调访问顺序。但这个模型有一个隐含前提线程的生命周期最好和任务的生命周期一致。在线程池里线程会被反复复用一个任务结束并不代表线程结束挂在线程身上的 ThreadLocal 数据也不会自动消失。本文会从 ThreadLocal 的存储结构讲起逐步解释它为什么能实现线程隔离以及为什么在线程池中必须使用try-finally调用remove()。1. 为什么共享对象会带来数据竞争先看一个最朴素的场景。多个线程同时操作同一个User对象UserusernewUser();// Thread-Auser.setName(A);// Thread-Buser.setName(B);这段代码的问题不在setName()本身而在于 Thread-A 和 Thread-B 操作的是同一个user。两个线程谁先执行、谁后执行并不确定所以 Thread-A 后续读到的name可能是自己刚写入的A也可能已经被 Thread-B 改成了B。这就是并发编程里最基础的一类问题多个执行流共享同一份可变状态就可能发生数据竞争。前面讨论过的synchronized、CAS都是在“共享状态”这个前提下解决问题既然多个线程都要改同一个对象那就通过互斥、原子更新或可见性规则保证修改过程是正确的。ThreadLocal 换了一个角度如果每个线程都有一份自己的数据不再共享同一个对象那么竞争自然就不存在了。所以 ThreadLocal 的核心思路可以概括为一句话把共享变量变成线程私有变量。2. ThreadLocal 里有哪些关键角色2.1 表面上是 ThreadLocal 存数据ThreadLocal 的基本用法很简单ThreadLocalUsertlnewThreadLocal();tl.set(userA);Userusertl.get();如果只看 API很容易以为tl这个对象内部维护了一张映射表也就是说好像是 ThreadLocal 负责记录“哪个线程对应哪个 User”。但真实结构正好相反数据不是存在 ThreadLocal 里而是存在当前线程自己身上。2.2 真实结构是 Map 挂在 Thread 上每个Thread对象内部都有一个ThreadLocalMap。这张 Map 的 key 是ThreadLocal实例value 是业务代码存进去的数据。因此下面这行代码tl.set(userA);大致可以理解为Thread.currentThread().threadLocalMap.put(tl,userA);get()也是同样的逻辑先找到当前线程再从当前线程自己的ThreadLocalMap中用tl这把 key 查出对应的 value。Userusertl.get();大致可以理解为Thread.currentThread().threadLocalMap.get(tl);所以 ThreadLocal 能实现线程隔离不是因为ThreadLocal对象内部做了复杂同步而是因为每个线程查的是自己的 Map。同一个tl变量在不同线程中会进入不同的ThreadLocalMap自然不会互相干扰。这也解释了为什么 ThreadLocal 变量经常声明成staticprivatestaticfinalThreadLocalUserUSER_CONTEXTnewThreadLocal();ThreadLocal实例本身只是一个 key。所有线程共享同一把 key 没有问题因为真正的数据分别存在不同线程自己的 Map 里。2.3 同一个 ThreadLocal 在同一线程里只对应一个值ThreadLocalMap定位 Entry 时靠的是ThreadLocal对象本身。也就是说同一个ThreadLocal实例在同一个线程的 Map 中只会对应一个 Entry。所以对同一个tl反复调用set()不是不断新增记录而是替换原来的 valueThreadLocalUsertlnewThreadLocal();tl.set(userA);tl.set(userB);Userusertl.get();// userB这个过程可以理解为原来的userA被替换掉了。如果没有其他引用继续指向userA它后续就可以被 GC 回收。这个细节在线程池场景下很重要如果某个任务开始时没有重新set()而是直接get()它拿到的可能不是null而是这个线程上一次执行任务后留下的旧值。3. 为什么线程池会改变 ThreadLocal 的风险如果是普通线程线程执行完任务后就结束挂在Thread对象上的ThreadLocalMap也会随着线程一起消失。在这种模型下ThreadLocal 数据的生命周期大致等于线程生命周期也等于任务生命周期问题不大。但线程池不是这样。线程池的核心特点是线程不会随着单个任务结束而销毁而是会被反复复用。问题就出在这里Task-A 结束了但 Thread-1 没有结束Thread-1 没有结束它内部的ThreadLocalMap也不会销毁。如果 Task-A 执行时调用了tl.set(A);但任务结束时没有清理那么这个值会继续留在 Thread-1 的ThreadLocalMap中。等 Thread-1 后续执行 Task-B 时Task-B 使用的是同一个线程也就能看到同一张ThreadLocalMap里残留的数据。3.1 数据串用数据串用是线程池中最直接的问题。示例代码如下privatestaticfinalThreadLocalStringUSER_IDnewThreadLocal();ExecutorServicepoolExecutors.newFixedThreadPool(1);pool.execute(()-{USER_ID.set(user-A);System.out.println(Task-A: USER_ID.get());});pool.execute(()-{System.out.println(Task-B: USER_ID.get());});线程池只有一个工作线程所以 Task-A 和 Task-B 会复用同一个线程。Task-A 设置了user-A但没有清理Task-B 自己没有设置值却仍然可能打印出Task-A: user-A Task-B: user-A这不是 Task-B 的业务逻辑写错了而是线程复用导致 ThreadLocal 数据从上一个任务残留到了下一个任务。在线上系统里这类问题可能表现为某个请求读到了上一个请求的用户信息、租户信息、TraceId 或权限上下文。代码看起来每个请求都是独立的但底层执行线程其实被复用了。3.2 内存占用累积另一个问题是内存占用。如果 Task-A 存进去的是一个较大的对象USER_CONTEXT.set(largeContext);任务结束后没有调用remove()那么这个 value 会继续被ThreadLocalMap.Entry引用。只要线程池里的工作线程不退出这个 value 就不会随着任务结束而释放。这会导致一个生命周期错位对象期望生命周期实际生命周期请求上下文单次任务结束后释放跟随线程池工作线程长期存活用户信息单次请求结束后释放可能残留到后续请求大对象缓存当前任务使用完释放被 ThreadLocalMap 持续引用所以线程池中的 ThreadLocal 问题本质上不是 API 用法复杂而是生命周期模型变了任务结束了但线程还活着线程还活着线程上的 ThreadLocalMap 就还活着。4. 为什么 key 是弱引用value 是强引用要理解 ThreadLocal 的内存泄漏需要先看ThreadLocalMap.Entry的引用设计。一个 Entry 里包含两部分但 key 和 value 的引用强度不同字段引用类型指向对象作用key弱引用ThreadLocal 实例用来定位 valuevalue强引用业务对象保存真正的数据这个设计是 ThreadLocal 内存泄漏问题的关键。4.1 GC 只根据强引用判断对象是否可达Java 判断一个对象能不能被回收核心依据是可达性分析。简单说就是从 GC Root 出发沿着引用链往下找。能找到的对象是可达的不能回收找不到的对象就是垃圾可以回收。常见的 GC Root 包括线程栈里的局部变量、类的静态字段等。强引用是最普通的引用ObjectobjnewObject();只要obj还在并且从 GC Root 可以沿着强引用链找到这个对象它就不会被回收。弱引用不同。一个对象如果只剩弱引用指向它而没有任何强引用链能到达它那么下一次 GC 时这个对象就会被回收。4.2 ThreadLocal 实例通常有两条引用链假设代码中有一个 ThreadLocal 变量ThreadLocalUsertlnewThreadLocal();tl.set(newUser());此时ThreadLocal实例可能同时被两条引用链指向第一条是业务代码里的tl变量它通常是局部变量、成员变量或static字段。这条是强引用。第二条是ThreadLocalMap.Entry.key它也指向同一个ThreadLocal实例但它是弱引用。关键点在于GC 判断 ThreadLocal 实例是否可回收时只看强引用链不会因为 Entry.key 这个弱引用还存在就阻止回收。所以一旦外部的tl强引用断开例如方法执行结束、对象被回收或者变量被置为nullThreadLocal实例就只剩下 Entry.key 这条弱引用。下一次 GC 时这个ThreadLocal实例会被回收Entry.key 会变成null。4.3 key 为什么不能是强引用如果 Entry.key 是强引用就会出现另一个问题。假设外部业务代码已经不再持有tl但 Entry.key 还强引用着这个 ThreadLocal 实例这样一来即使业务代码已经不用这个 ThreadLocal 了它也仍然会被线程内部的 Map 强行保留下来无法被 GC 回收。更麻烦的是业务代码已经拿不到原来的tl变量也就没有入口调用remove()去清理对应的 Entry。这个 ThreadLocal 实例会被长期挂在线程上形成更严重的泄漏。所以key 设计成弱引用是为了避免 ThreadLocal 实例本身被 ThreadLocalMap 强行延长生命周期。4.4 value 为什么不能是弱引用那 value 能不能也设计成弱引用也不行。value 是业务真正要使用的数据。例如tl.set(newUser());如果 value 是弱引用而这个new User()没有被其他强引用指向那么它可能在下一次 GC 时被回收。这样业务代码刚刚set()完后面再get()就可能得到null。tl.set(newUser());// 如果 value 是弱引用这里可能拿不到刚才的 UserUserusertl.get();这会让 ThreadLocal 的行为变得不可预测。业务代码使用 ThreadLocal本来就是希望“当前线程在这段逻辑里能稳定拿到这个值”。因此value 必须是强引用。所以ThreadLocalMap 的设计可以总结为设计目的代价key 使用弱引用外部不再持有 ThreadLocal 时ThreadLocal 实例可以被 GC 回收key 可能变成null产生 stale entryvalue 使用强引用保证业务数据不会在使用过程中莫名消失如果不清理value 可能被长期引用这个设计本身不是错误而是在两个目标之间做取舍既要避免 ThreadLocal 实例被 Map 强行保留又要保证业务 value 在使用期间稳定存在。5. 内存泄漏到底是怎么发生的现在可以把内存泄漏的过程串起来。假设某个任务中创建并使用了一个 ThreadLocalvoidhandle(){ThreadLocalUsertlnewThreadLocal();tl.set(newUser());// 业务逻辑}当handle()方法执行结束后局部变量tl随着栈帧出栈而消失。此时外部强引用断开只剩下 Entry.key 这条弱引用指向 ThreadLocal 实例。下一次 GC 发生时ThreadLocal 实例会被回收Entry.key 变成null。这类 key 已经为null但 value 还在的 Entry通常称为 stale entry也就是陈旧条目。关键问题是GC 只回收了 ThreadLocal 实例并不会自动删除 Entry也不会自动断开 Entry.value 对 User 对象的强引用。从业务角度看这个 value 已经没用了。因为 key 已经没了业务代码再也无法通过原来的 ThreadLocal 找到它。但从 GC 角度看value 仍然被强引用链指向只要线程还活着这条强引用链就还在value 就不会被回收。这就是 ThreadLocal 内存泄漏的准确含义对象从业务逻辑上已经不可再使用但因为仍然被强引用链指向GC 无法回收它。它和 C/C 里“忘记 free”不是一回事。Java 中的问题不是引用丢了而是无意义的引用还在。6. 为什么不能依赖 ThreadLocalMap 自己清理ThreadLocalMap 并不是完全不清理 stale entry。它在后续执行某些操作时会顺带检查并清理 key 为null的 Entry例如get()、set()、remove()或扩容整理过程中。但这个清理是懒删除不是即时清理。所谓懒删除就是 Entry 变成 stale entry 后不会立刻被删除而是等下一次 ThreadLocalMap 被操作时顺路清理一部分。问题在于这个“后续操作”是不确定的。在线程池里某个线程可能长期存活但后续任务再也不访问同一个 ThreadLocalMap 中相关位置。这样 stale entry 就可能一直挂着value 也会一直被引用。所以依赖 ThreadLocalMap 的懒删除是不可靠的。它只能作为内部补救机制不能作为业务代码的清理策略。真正可靠的做法只有一个业务逻辑使用完 ThreadLocal 后主动调用remove()。7. 在线程池中应该如何使用 ThreadLocal线程池中使用 ThreadLocal必须让数据生命周期跟随任务而不是跟随线程。标准写法是privatestaticfinalThreadLocalUserContextUSER_CONTEXTnewThreadLocal();publicvoidhandle(UserContextcontext){try{USER_CONTEXT.set(context);// 业务逻辑doBusiness();}finally{USER_CONTEXT.remove();}}这里的关键不是set()而是finally里的remove()。remove()做的事情是从当前线程的ThreadLocalMap中删除这个 ThreadLocal 对应的 Entry断开 Entry.value 对业务对象的强引用。这样任务结束后value 不会继续挂在线程上也不会污染下一个复用该线程的任务。为什么一定要放在finally里因为业务逻辑可能抛异常。如果只在正常路径最后调用remove()USER_CONTEXT.set(context);doBusiness();USER_CONTEXT.remove();一旦doBusiness()中途抛异常remove()就不会执行ThreadLocal 数据仍然会残留在线程上。finally的意义就是保证无论业务逻辑正常结束还是异常退出都能执行清理动作。如果任务开始时需要读取 ThreadLocal也要先确保当前任务已经设置了自己的上下文而不是直接相信线程里已有的值publicvoidhandle(UserContextcontext){try{USER_CONTEXT.set(context);UserContextcurrentUSER_CONTEXT.get();doBusiness(current);}finally{USER_CONTEXT.remove();}}这个写法把边界对齐了阶段操作目的任务开始set(context)建立当前任务自己的上下文任务执行get()在当前线程中读取上下文任务结束remove()清理上下文避免残留到下一个任务ThreadLocal 不是不能在线程池中使用而是必须明确它的数据默认跟着线程走。在线程池里线程会跨任务复用所以开发者必须主动把数据的生命周期缩短到任务范围内。8. 总结ThreadLocal 的起点是共享可变状态带来的数据竞争。它没有沿着“给共享对象加锁”的方向继续解决问题而是换成“每个线程保存自己的数据”。实现上ThreadLocal 本身不是数据容器而是一把 key真正的数据存在线程自己的ThreadLocalMap中。因此同一个 ThreadLocal 实例在不同线程里会对应不同的 value从而实现线程隔离。这个模型在普通线程中比较自然线程结束挂在线程上的 ThreadLocalMap 也随之消失。但线程池改变了前提。线程池中的工作线程会长期存活并反复执行不同任务ThreadLocalMap 也会跟着线程长期存在。如果任务结束后不清理数据就可能残留到下一个任务造成数据串用value 也可能因为仍被 Entry 强引用而无法释放造成内存占用累积。ThreadLocalMap 把 key 设计成弱引用是为了避免外部已经不用的 ThreadLocal 实例被 Map 强行保留把 value 设计成强引用是为了保证业务数据在使用期间不会被 GC 提前回收。这个设计本身有合理性但它也带来了 stale entry 问题key 被 GC 回收后会变成nullvalue 却仍然可能被 Entry 强引用。由于 ThreadLocalMap 的清理是懒删除不能保证及时发生所以不能把清理责任交给内部机制。因此线程池中使用 ThreadLocal 的核心原则只有一个用try-finally包住任务逻辑在任务开始时set()在任务结束时remove()。这样才能让 ThreadLocal 数据的生命周期跟随任务而不是跟随线程。