threadlocalmap 底层采用开放地址法(线程内数组+线性探测),非hashmap结构;其数组元素为entry类型,且entry继承自weakreference。

ThreadLocalMap 的底层结构不是 HashMap
它用的是开放地址法(线程内数组 + 线性探测),不是链表或红黑树。数组元素类型是 Entry,而 Entry 继承自 WeakReference<threadlocal>></threadlocal> —— 这意味着 key 是弱引用,value 却是强引用。
常见错误现象:ThreadLocal 泄漏往往不是因为 key 没被回收,而是 value 长期持有大对象,且 Entry 本身没被清理;尤其在线程复用场景(如 Tomcat 线程池)中,不手动 remove() 就容易堆积。
- 每次
set()或get()都会触发探测,从起始槽位开始线性找空位或匹配的ThreadLocal - 数组长度始终是 2 的幂,hash 计算用的是
threadLocalHashCode & (len - 1),不是hashCode() % len - 扩容阈值是
len * 2 / 3(即负载因子约 0.66),不是 HashMap 的 0.75
哈希冲突时怎么找下一个位置
冲突发生后,ThreadLocalMap 不拉链,而是从当前下标开始往后线性探测,直到遇到 null 槽位才停。这个过程里,它还会顺手做两件事:清理 key 为 null 的“陈旧条目”(stale entry)、把后面连续的非空但 hash 不在该区间的 Entry 往前挪(rehash 行为)。
使用场景:高并发下多个 ThreadLocal 实例共存时,如果 hash 分布不均,探测链会变长,get() 性能退化明显。
立即学习“Java免费学习笔记(深入)”;
- 探测步长固定为 1,没有二次哈希或其他扰动策略
- 一旦某次
set()触发了清理 stale entry,就可能引发一次 rehash,把后续若干Entry往前挤,这会略微增加写开销 - 如果线程长期运行且反复
set()不同ThreadLocal,又不remove(),数组里会残留大量key == null的Entry,影响后续探测效率
为什么 remove() 不只是清 value
remove() 的核心动作是:先定位到对应 Entry,设 key = null,再向后扫描清理所有连续的 stale entry。它不直接删数组元素,而是靠后续的 get()/set() 来真正腾出空间。
容易踩的坑:只调 tl.set(null) 没用,这只会把 value 设为 null,key 还活着,entry 不会被视为 stale;必须调 tl.remove()。
- 不调
remove()的典型后果:GC 能回收 key,但 value 和Entry对象仍留在数组里,直到下次探测路过并主动清理 - 在线程池中,若每个请求都 new 一个
ThreadLocal并 set 后不 remove,哪怕 key 被回收,value 仍挂着,最终 OOM -
remove()是线程安全的,但仅限当前线程操作自己的ThreadLocalMap,不涉及跨线程同步
ThreadLocalMap 的初始化和扩容机制
它延迟初始化:第一次 set() 才创建 table 数组,默认长度 16。扩容不是按需增长,而是翻倍(如 16→32),且只在 set() 中探测失败、清理 stale entry 后发现仍超阈值时才触发。
性能影响:扩容要 rehash 全量有效 entry,对长生命周期线程来说,如果早期塞了大量 ThreadLocal,后期扩容代价不小;但日常小规模使用几乎无感。
- 扩容后老数组的
Entry会重新计算位置插入新数组,过程中同样会跳过key == null的条目 - 没有缩容逻辑 —— 数组只会变大,不会变小
- 如果你在单个线程里频繁 new
ThreadLocal实例(比如循环中),即使立即remove(),也可能因 hash 冲突多、探测链长,导致实际插入位置分散,间接加剧后续查找成本
事情说清了就结束。真正难处理的从来不是结构本身,而是弱引用 key 和强引用 value 之间那层微妙的生命周期错位——它不报错,只悄悄吃内存。










