ThreadLocalMap 的 stale entry 清理依赖线性探测路径上的被动访问,仅清理连续遇到的 null-key Entry 且不全表扫描,弱引用 key 被回收后 value 仍强引用驻留内存,remove() 是唯一可靠、主动释放 value 并触发即时清理的手段。
ThreadLocalMap 的线性探测不自动清理所有 stale entry
ThreadLocalMap 在
、
、
过程中,确实会顺路清理探测路径上遇到的
(即
的
),但这个清理是「有范围限制」的:只清理从起始索引开始、沿探测方向连续碰到的 stale entry,且不会绕回数组开头,更不会全表扫描。
这意味着:如果某个 stale entry 恰好卡在长探测链中间,而后续所有操作都从别的 hash 位置开始(比如新 ThreadLocal 的
落点远离它),那它就永远不被路过,也永远不会被清理。
线性探测路径是单向的:
只往后走,到末尾就跳回 0,但清理逻辑在遇到第一个
就停,不会继续扫完一圈
清理时,只把从
开始向后所有非 null key 的
重新哈希填回更靠近原始位置的地方,不保证覆盖全部 stale entry
没有后台线程、没有定时任务、没有扩容触发的全局 rehash —— 清理完全依赖「有人路过」
弱引用 key 被回收后 value 仍强引用,泄漏源头就在 stale entry
继承自
,所以 key 是弱引用,value 是强引用。一旦 ThreadLocal 实例超出作用域(如局部变量方法结束),GC 可以回收 key,但 value 仍被
强引用着,只要该
还在
数组里,value 就无法释放。
这种泄漏不是瞬时的,而是累积的:尤其在线程池场景下,线程复用导致
长期存活,stale entry 堆积 → value 对象持续驻留堆中 → 最终
。
常见泄漏模式:
存了大集合,或
持有未关闭资源
GC 只能清 key,清不了 value —— 因为 value 的唯一持有者就是那个 stale
是唯一能主动切断 value 强引用的操作:它先清 value,再置
,为后续探测清理铺路
set() 和 get() 的“顺手清理”不可靠,尤其在低频访问场景
很多开发者误以为只要频繁调用
或
,stale entry 就会被“慢慢清掉”。但实际效果高度依赖访问模式:
如果某 ThreadLocal 仅在初始化时
一次,之后长期不访问,它对应的 slot 就成了“死区”,其后的 stale entry 根本没人路过
多个 ThreadLocal 的 hash 码若集中在数组前半段,后半段的 stale entry 可能数小时都不被探测
线性探测本身性能随 stale entry 增多而下降:每次
都要多扫几个 slot,还可能因 stale entry 占位导致本可短链的查找被迫拉长
换句话说,“等它被路过再清理”是一种被动策略,而内存泄漏是主动发生的 —— 时间差越大,风险越高。
remove() 是唯一可控的强干预点
不仅清 value、置 key 为 null,还会立即触发
,从当前 slot 开始向前压缩有效 entry,缩短后续探测距离。更重要的是,它把清理时机完全交到开发者手上:
在 try-finally 或 try-with-resources 中配对使用:
在线程池任务结束前统一清理:
避免依赖“它总会被别的 ThreadLocal 操作顺路清掉”这种侥幸心理
真正棘手的从来不是怎么写
,而是那些你根本没意识到需要
的地方 —— 比如匿名内部类捕获的 ThreadLocal、AOP 切面中隐式使用的上下文、甚至日志 MDC 的绑定。这些地方一旦漏掉,stale entry 就成了静默的内存黑洞。
get()set()remove()stale entrykey == nullEntrythreadLocalHashCodenextIndex(i, len)nullexpungeStaleEntry(int staleSlot)staleSlotEntryThreadLocalMap.EntryWeakReferenceEntry.valueEntrytableThreadLocalMapOutOfMemoryError: Java heap spaceThreadLocal
ThreadLocalEntryremove()Entry.key = nullget()set()set()get()remove()expungeStaleEntry()tl.set(x); ... tl.remove();try { runTask(); } finally { tl.remove(); }remove()remove()