expungeStaleEntry 是被动式线性扫描清理,仅在 get/set 哈希冲突探测中遇到 key==null 时触发,清理从该位置起连续脏 Entry 并 rehash 填坑;无冲突则不执行,故无法覆盖长期空闲线程的内存泄漏。

ThreadLocal 的 expungeStaleEntry 不是“自动兜底清理”,而是哈希冲突探测过程中的被动式、线性向后扫描式回收 —— 它只在 get/set 遇到 key==null 的 Entry 时才触发,且只清理从该位置开始连续的脏 Entry,不扫全表。
为什么 expungeStaleEntry 只在哈希冲突路径上被调用
ThreadLocalMap 使用开放地址法(线性探测)解决哈希冲突,getEntryAfterMiss 和 replaceStaleEntry 是两个关键入口:
-
get()没有直接命中(即tab[i]的 key 不等于目标 ThreadLocal),进入线性探测循环;一旦遇到k == null的 Entry,立即调用expungeStaleEntry(i) -
set()在探测空位过程中,若发现某个tab[i]的 key 为 null,会调用replaceStaleEntry,后者内部也会调用expungeStaleEntry - 也就是说:**没有哈希冲突 → 不走探测路径 →
expungeStaleEntry根本不会执行**
expungeStaleEntry 的实际清理行为:清空 + rehash + 填坑
它不是简单地把 tab[staleSlot] 设为 null,而是一次小范围重组:
- 先将
tab[staleSlot].value = null,断开强引用,再置tab[staleSlot] = null - 从
nextIndex(staleSlot, len)开始向后遍历,直到遇到nullEntry 为止 - 途中每遇到一个非空 Entry:
k = e.get(),若k == null,同样清理其 value 并设为 null;若k != null,则对其重新计算索引(rehash),并尝试插入到更靠近其理想位置的空槽中(即“填坑”) - 这个过程会改变原有 Entry 的位置,但保证所有存活 key 仍能被后续 get 正确探测到
cleanSomeSlots 启发式清理和 expungeStaleEntry 的关系
cleanSomeSlots 是 set 操作中的一次轻量级辅助清理,它不保证清完,但会提高 expungeStaleEntry 的触发效率:
-
cleanSomeSlots(i, sz)内部会随机选几个起始点,对每个点调用一次expungeStaleEntry - 它只清理「部分」脏 entry,扫描次数上限是 log₂(size),不是全量遍历
- 它的存在是为了降低
rehash()触发概率 —— 因为rehash要遍历整个 table,成本远高于几次expungeStaleEntry - 所以:**
cleanSomeSlots是“防患”,expungeStaleEntry是“救火”,两者都依赖哈希冲突路径才能生效**
容易忽略的关键限制:它救不了长期空闲的线程
如果一个线程池线程执行完任务后不再调用任何 get() 或 set(),哪怕它内部 ThreadLocalMap 已堆满 key==null 的 Entry,expungeStaleEntry 也永远不会运行:
- 没有哈希冲突探测 → 没有 entry 访问 → 没有清理触发点
- 这种线程就像“静默僵尸”,持续持有已泄漏的 value 引用
- 这也是为什么所有规范文档都强调:
remove()必须由业务方显式调用,不能依赖自动机制

















