HashMap 几乎是必问题。只背「数组 + 链表 + 红黑树」只能拿到及格分,能讲清为什么这样设计、并发时具体坏在哪里,才是区分度所在。以下以 JDK 8 的实现为基准,JDK 17/21 的实现主体没有变化。
1. HashMap 的底层结构是什么?
JDK 8 使用「数组 + 链表 + 红黑树」:数组的每个槽位称为桶,哈希冲突的元素先以链表挂载,链表足够长时转为红黑树。
关键常量:默认容量 16、最大容量 1 << 30、默认负载因子 0.75、树化阈值 8、退化阈值 6、树化的最小容量 64。
节点是 Node<K,V>,保存 hash、key、value 和 next;树化后变成 TreeNode,额外维护红黑树所需的父子指针和颜色,所以单个节点的内存开销明显更大。
JDK 7 只有数组加链表,且插入使用头插法;JDK 8 改为尾插,并引入红黑树,这是两个版本最值得说明的差异。
2. 哈希扰动和下标计算为什么这样设计?
取 key 的 hashCode() 后,先做一次扰动:h ^ (h >>> 16),把高 16 位异或到低 16 位。随后用 (n - 1) & hash 定位桶,其中 n 是数组长度。
因为容量是 2 的幂,(n - 1) 的低位全是 1,与运算等价于取模但更快。问题在于:如果不扰动,只有低位参与定位,高位差异被完全丢弃,大量 key 会挤进少数桶。
扰动让高位也参与运算,以很小的成本改善分布。注意这并不能消除冲突,hashCode 设计糟糕时依旧会退化,所以不要试图用 HashMap 抵抗恶意构造的哈希碰撞。
3. put 的完整流程是什么?
先判断 table 是否为空,为空则初始化;桶为空就直接放新节点;桶首节点的 hash 相同且 key 相等,就覆盖 value。
否则分两种情况:树节点走 putTreeVal;链表则从头部遍历到尾部,途中发现相等 key 就覆盖,没找到就追加到尾部,并检查链表长度是否达到树化门槛。
插入完成后,如果元素总数超过阈值 容量 × 0.75,执行扩容。putIfAbsent、computeIfAbsent、merge 都复用了这套流程,区别只在命中时是否覆盖以及返回值的语义。
面试还可以补一句:key 为 null 时固定放在 0 号桶,这也是 HashMap 与 ConcurrentHashMap 的一个行为差异。
4. 扩容是怎么做的?JDK 7 和 8 有什么差别?
扩容时容量翻倍,阈值也翻倍;元素数超过 容量 × 0.75 触发,树化失败但容量不足 64 时也会先扩容。
JDK 8 不需要重新计算哈希:把旧桶拆成低位和高位两条链,判断 (e.hash & oldCap),为 0 留在原下标 j,否则移到 j + oldCap。这样既省掉重算,又保持了链表内部的相对顺序。
JDK 7 会对每个节点重新计算索引,并且用头插法迁移,并发扩容时可能让两个线程把链表连成环,之后 get 死循环。JDK 8 改尾插后环的问题基本消失,但数据覆盖、size 不准、丢更新依然存在。
5. 为什么容量是 2 的幂?负载因子为什么是 0.75?
容量为 2 的幂有两个直接好处:(n - 1) & hash 可以替代取模,位运算更快;扩容时只需看哈希的某一位,就能决定元素留在原位还是移动旧容量个位置。
负载因子 0.75 是时间与空间的折中:调低会减少冲突但浪费空间、增加扩容次数;调高则节省空间但链表变长。源码注释给出的依据是,在 0.75 附近、哈希均匀分布时,单个桶内节点数近似服从参数 0.5 的泊松分布。
实际使用时,如果能预估元素数量,直接指定初始容量更有效。注意构造函数会把容量向上取整到 2 的幂,但 HashMap(int initialCapacity) 并不会自动除以 0.75,所以想放 n 个元素,初始容量应给到 n / 0.75 + 1。
6. 链表树化的阈值为什么是 8,退化又是 6?
树化需要同时满足两个条件:链表长度达到 8,且数组容量不小于 64。后者不满足时只扩容、不树化,因为小表里冲突更多是容量不足造成的。
阈值 8 来自概率:哈希均匀时桶内节点数服从泊松分布,长度达到 8 的概率约为千万分之六,正常情况几乎不会触发。红黑树节点约为普通节点的两倍,只有极端冲突时树化才划算。
退化阈值设为 6 而不是 8,是为了留出缓冲带,避免在 7、8 之间反复树化与退化。节点删除后,当树的结构不足以维持时会退化为链表;扩容拆分时,如果某条链上的节点数不超过 6,也会退回链表。
7. HashMap 为什么线程不安全?
并发 put 可能同时判断某个桶为空,然后各自写入,后写覆盖先写;两个线程同时扩容时,可能基于旧表复制,导致部分节点丢失或 size 计数错误。
JDK 7 的头插式扩容还可能形成环形链表,CPU 打满;JDK 8 改为尾插并保留相对顺序,环的问题基本不复现,但「不安全」这个结论并没有改变。
Hashtable 是方法级 synchronized,粒度太粗;Collections.synchronizedMap 也是整表锁。并发场景优先 ConcurrentHashMap,它的读基本无锁,写只锁桶,并且通过 sizeCtl 协调扩容。
还要注意:即使换成 ConcurrentHashMap,if (!map.containsKey(k)) map.put(k, v) 仍不是原子的,应改用 putIfAbsent 或 computeIfAbsent。
8. 作为 key 的对象要满足什么条件?如何用 LinkedHashMap 实现 LRU?
key 必须正确实现 equals 和 hashCode,并且哈希值在放入后保持稳定。如果用可变对象做 key,修改了参与哈希计算的字段,之后按原值就查不到了,等于在表里制造了一个「幽灵条目」。
LinkedHashMap 在哈希表之上维护一条双向链表。构造时传 accessOrder = true,每次 get 或 put 命中都会把节点移到链表尾部,头部自然成为最久未使用的元素。
再重写 removeEldestEntry,在元素数超过容量时返回 true,就得到一个简单的 LRU。生产环境要注意:访问顺序模式下 get 也会修改结构,遍历时需要额外同步;容量估算、过期时间和并发控制通常还需要外面再包一层。
易错点与自测
- 「链表长度到 8 就树化」不准确,还要容量至少 64;「缩到 6 就退化」也取决于删除路径与树结构。
- 初始容量不等于能装多少元素,阈值是容量乘以负载因子。
HashMap线程不安全的表现不止死循环,数据覆盖和计数错误更常见。
自测:① 手写 (n - 1) & hash 并解释为什么等价于取模;② 说明扩容时高低位拆分的判断条件;③ 给出一个用 LinkedHashMap 实现 LRU 的最小骨架。
参考资料
- HashMap 源码(OpenJDK) — 常量、
putVal、resize与treeifyBin - java.util.HashMap API(Java 21) — 允许 null 键值与非线程安全的官方说明
- java.util.concurrent.ConcurrentHashMap API — 并发语义与复合操作