ThreadLocalMap 和 HashMap 有什么区别?
结论先行:ThreadLocalMap 是 ThreadLocal 的专用私有结构,不是通用容器:它用开放寻址(线性探测)解决冲突、key 是弱引用的 ThreadLocal、阈值是 2/3、并且在读写路径上顺带清理脏 Entry。它甚至没有实现 Map 接口。
1. 逐项对比
| 维度 | ThreadLocalMap | HashMap |
|---|---|---|
| 归属 | Thread 的成员字段,随线程生灭 | 独立对象,可自由传递 |
| key 类型 | 只能是 ThreadLocal,且是弱引用 | 任意 Object,强引用 |
| 冲突解决 | 开放寻址,线性探测 i+1 | 拉链法,链表 + 红黑树(JDK 8) |
| 初始容量 | 16 | 16 |
| 扩容阈值 | len * 2 / 3 ≈ 66% | len * 0.75 |
| 扩容后 | 双倍扩容 + rehash + 清理 key == null 的槽 | 双倍扩容 + 链表拆分/树化 |
| 线程安全 | 天然安全(线程私有,无锁) | 不安全,需外部同步 |
是否实现 Map | 否,ThreadLocal 的静态内部类 | 是 |
| 迭代器 | 无迭代器 | fail-fast 迭代器 |
2. 两种冲突解决方式
开放寻址(ThreadLocalMap):冲突就往后找空槽
下标: 0 1 2 3 4
[k1] [k2] [k3] [空] [k4]
↑ k4 的 hash 落在 2,被占,线性探测到 3 空位
拉链法(HashMap):冲突挂到同一个桶的链表上
下标: 0 1 2
[k1]→[k5] [k2] [k3]→[k4]→[k9]
为什么 ThreadLocalMap 选开放寻址?因为一个线程里的 ThreadLocal 数量通常个位数到几十个,数据量极小。开放寻址没有 Node 对象、没有指针跳转,数组连续内存对 CPU 缓存更友好;而拉链法要为每个 Entry 额外分配一个 Node,在小规模场景下纯属浪费。
3. 关键代码:探测与清理
// 线性探测查找(简化)
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key) return e;
return getEntryAfterMiss(key, i, e); // 冲突:从 i 往后逐个比对
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key) return e;
if (k == null) expungeStaleEntry(i); // 顺手清理 key 已被回收的脏槽
else i = nextIndex(i, len); // i = (i + 1) & (len - 1)
e = tab[i];
}
return null;
}
expungeStaleEntry 是这个结构最"聪明"的地方:它把 key == null 的槽清空,并把后续可能"探测错位"的元素往前挪,防止线性探测链断裂导致查不到数据。
4. 为什么它敢不实现 Map 接口
因为它根本不需要通用性:没有 putAll、没有迭代、没有 keySet,只有 getEntry、set、remove、rehash 四个内部操作。不实现接口反而少了一层虚方法调用,也避免了它被当成普通 Map 传来传去。
5. 常见追问
追问一:为什么阈值是 2/3 而不是 0.75?
开放寻址对负载因子更敏感:装载率越高,探测链越长,性能下降越陡。2/3 是在"内存占用"和"探测长度"之间的经验折中。阈值和容量都用 len * 2 / 3 这样的整数运算,避免浮点开销。
追问二:容量为什么必须是 2 的幂?
为了用 hash & (len - 1) 代替取模,同时保证线性探测 (i + 1) & (len - 1) 能绕回数组头部。这也是 HashMap 容量为 2 的幂的同一套理由。
追问三:ThreadLocalMap 会 OOM 吗?
会。它挂在 Thread 上,如果线程是线程池里的长命线程,且 ThreadLocal 数量不断增长(比如每个请求 new ThreadLocal),map 会一直扩容,且 value 无法回收,最终堆溢出。所以永远用 static final 声明 ThreadLocal,别在方法里 new。
追问四:为什么不直接复用 HashMap 加同步?
那样每次 get/set 都要锁,违背了 ThreadLocal"零竞争"的设计初衷;而且 HashMap 的 key 是强引用,ThreadLocal 对象无法被回收,泄漏只会更严重。
小结
ThreadLocalMap 是为"一个线程、少量 key、绝不共享"这个极端场景定制的迷你哈希表:开放寻址省内存、弱引用 key 防泄漏、探测路径顺手清脏数据。把它当 HashMap 用(比如指望它存几千个 key、或者跨线程访问)就完全跑偏了。