⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/mianshiguanThreadLocalMap-he-HashMap-dequbieshishenme/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

这道题面试官真正想筛什么

字面是「列几个不同点」,实际是三件事:

  1. 你有没有真打开过 ThreadLocal 源码——能说出 Entry extends WeakReference<ThreadLocal<?>> 这一行的人,比只会背「弱引用 key」的多得多。
  2. 数据结构的取舍直觉——为什么不复用 JDK 现成的 HashMap?这是设计题不是知识题。
  3. 内存泄漏的真实理解——「弱引用 key 就不会泄漏」是错的,知道 value 泄漏在哪、怎么兜底,才是 90 分门槛。

只答「一个开放定址、一个链地址」就只有 30 分——能记住但没读过源码。

L1:30 秒答案过基础线

ThreadLocalMap 是 ThreadLocal 内部专用的极简 Map:开放定址法解冲突、key 用弱引用、单线程访问无并发;HashMap 是通用 Map:链地址法、强引用 key、需要自己保证线程安全。两者根本不是同一类东西。

L2:2 分钟答案显原理

一张表把核心差异讲完:

维度 ThreadLocalMap HashMap
哈希冲突 开放定址法(线性探测) 链地址法(链表 + 红黑树)
数据结构 纯 Entry[] 数组 Node[] + 链表 / 红黑树
Key 类型 ThreadLocal<?>,弱引用 任意 Object,强引用
Null Key 不允许 允许(落桶 0)
负载因子 2/3(≈0.667) 0.75
实现 Map 接口 ❌ ✅
过期清理 自带启发式清理 无
使用场景 单线程,每线程几个变量 通用,多线程需外部同步

两个关键设计差异:

  • 开放定址法(ThreadLocalMap):冲突时往后找下一个空位。数据全在数组里,缓存局部性好,缺点是负载高时退化快——所以负载因子调到 2/3 抑制冲突。
  • 链地址法(HashMap):冲突时挂链表,超过 8 转红黑树。对负载因子不敏感,但链表节点内存不连续。

为什么 ThreadLocalMap 选开放定址?因为典型用法是「每个线程几个变量」——数据量小,开放定址更轻、更快。

L3:5 分钟答案显源码深度

读过源码的人看 Entry 这一行就懂了:

static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k); // key 走 WeakReference
value = v; // value 走强引用
}
}

而 HashMap.Node:

static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key; // 强引用
V value;
Node<K,V> next; // 链表后继
}

弱引用 key 不是为了「省内存」,是为了 ThreadLocal 对象自身能被回收——使用方把外部强引用置 null 后,GC 能把 ThreadLocal 对象本身收掉,Entry 的 key 变成 null,剩下一条「脏 Entry」(stale entry)。

但 value 仍然是强引用——只能等 Map 自己清理。线程没死、ThreadLocal 没显式 remove(),value 就一直挂着——线程池里线程几乎不死,泄漏越攒越多。

上图是 ThreadLocalMap 的三层兜底清理(按代价从轻到重):

  • expungeStaleEntry(int):从某槽位向后扫,遇到 null 就停。途中清脏 Entry 并把后续 Entry rehash 回理想位置。每次 get/set/remove 都会顺手触发。
  • cleanSomeSlots(int, int):启发式扫描 log2(n) 个槽位,找到脏 Entry 就调 expungeStaleEntry 扩散清理。set() 写入后调用,平摊代价。
  • expungeStaleEntries()(复数):全表扫描,最重,只在 rehash() 准备扩容前触发。

这是兜底,不是免死金牌——核心使用纪律永远是 try { ... } finally { tl.remove(); }。

直接掉分的几种答法

  • ❌ 「ThreadLocalMap 也支持 null key」 → 不支持。和 HashMap 这一点是反的。
  • ❌ 「弱引用 key 就不会内存泄漏了」 → 错。value 是强引用,线程池里照样泄漏。这一句答出来面试官就知道你没踩过这坑。
  • ❌ 「ThreadLocalMap 实现了 Map 接口」 → 没实现。它根本不是 java.util.Map,是 ThreadLocal 的内部类。
  • ❌ 「负载因子是 0.75,和 HashMap 一样」 → 是 2/3。开放定址对冲突敏感,必须用更小的负载因子。
  • ❌ 「ThreadLocalMap 也用红黑树」 → 没有。它就是裸数组 + 线性探测,没有任何链表 / 树结构。

高频追问怎么接

追问一:为什么 ThreadLocalMap 不直接复用 HashMap?

三个理由:① 功能过度(HashMap 支持 null key、Map 接口、迭代器一堆 ThreadLocal 用不到的东西);② 内存浪费(多了 hash 字段和 next 指针);③ HashMap 不支持弱引用 key,要用就得改源码。自己实现一个 200 行的极简版更划算。

追问二:负载因子为啥是 2/3?

开放定址法对冲突敏感——一旦冲突就要线性往后探测,越满探测链越长。牺牲点空间(更早扩容)换更稳定的查找性能。这正是 HashMap 用 0.75 不能照搬的原因。

追问三:那 value 泄漏怎么彻底解决?

唯一可靠的方式是 remove():

private static final ThreadLocal<UserContext> TL = new ThreadLocal<>();

public void doWork() {
TL.set(currentUser());
try {
// 业务逻辑
} finally {
TL.remove(); // 必须在 finally 里
}
}

线程池环境下不 remove,下一次任务复用线程时还能读到上一次的脏数据,不仅是泄漏,是数据串号。

追问四:可以用 InheritableThreadLocal 解决吗?

那是「父线程值传给子线程」用的,和泄漏无关——线程池里线程被复用,没有「父子」关系。要在线程池传上下文用阿里的 TransmittableThreadLocal。

说到底

ThreadLocalMap 不是 HashMap 的弟弟,是它的反例样本——通用 vs 专用、强引用 vs 弱引用、链地址 vs 开放定址,每一刀都精准服务于「单线程少量变量」这个使用场景。读懂它,比记住八条不同点更接近 90 分。

文章目录
  1. 1. 这道题面试官真正想筛什么
  2. 2. L1:30 秒答案过基础线
  3. 3. L2:2 分钟答案显原理
  4. 4. L3:5 分钟答案显源码深度
  5. 5. 直接掉分的几种答法
  6. 6. 高频追问怎么接
  7. 7. 说到底