根本原因是弱引用key与强引用value组合叠加线程长期存活,导致value无法被gc;threadlocalmap中entry的key为弱引用、value为强引用,线程池复用使引用链持续存在,且清理机制仅探测式触发,无法彻底解决泄漏。

ThreadLocal 在线程池中发生内存泄漏,根本原因在于弱引用 key 与强引用 value 的组合,叠加线程长期存活导致的引用链无法断开。这不是设计缺陷,而是机制特性在特定场景下的必然结果。
ThreadLocalMap 的 Entry 引用结构是起点
每个线程的 threadLocals 字段持有一个 ThreadLocalMap 实例,该 Map 的底层是 Entry[] 数组。关键点在于:
-
Entry继承自WeakReference<threadlocal></threadlocal>,所以它的 key 是弱引用,指向 ThreadLocal 实例 -
value字段是普通对象引用,即 强引用,指向你存进去的实际数据(如 UserContext、byte[]、Connection 等) - 因此完整引用链为:Thread → ThreadLocalMap → Entry → value
线程池复用让这条链“永不断裂”
普通线程执行完就销毁,整个 ThreadLocalMap 随之回收;而线程池中的核心线程会一直运行,只要线程活着,它持有的 ThreadLocalMap 就不会被 GC。
- 若业务中声明了局部
ThreadLocal<t> local = new ThreadLocal()</t>,方法结束后外部强引用消失 - 下次 GC 时,key(ThreadLocal 实例)因是弱引用被回收,Entry 变成
key=null, value=xxx - 但
value仍被 Entry 强引用,Entry 又被线程的threadLocals强引用——value 彻底卡死在堆里
静态 ThreadLocal 也不能绕过 value 泄漏
有人改用 private static ThreadLocal<t> local = new ThreadLocal()</t>,认为 key 不会变 null 就安全了。其实不然:
- static 声明确实让 key 永远不为 null,避免了 stale entry
- 但只要你没调用
local.remove(),value 就一直留在当前线程的 map 中 - 在线程池里,一次请求 set 一个 1MB 对象,100 次后就是 100MB 垃圾,且无法被任何 GC 触达
清理机制只是“尽力而为”,不是保险绳
ThreadLocalMap 确实在 get、set、remove 时尝试扫描并清理 key=null 的 Entry,但有严重局限:
- 只清理哈希槽附近有限个位置(探测式清理),不是全表扫描
- 如果某个 ThreadLocal 只 set 一次、后续再没访问,就不会触发任何清理逻辑
- 线程空闲时完全不干活,stale entry 可以沉睡数小时甚至数天










