JAVA · Vol.I · DAY 02 · 集合框架源码
HashMap 高频面试题全解:null key、遍历顺序、线程安全
为什么 HashMap 是面试"钉子户"?
如果 Java 面试只能考一道集合题,那一定是 HashMap。我在面试和被面试的十几年里,几乎没有遇到过不考 HashMap 的 Java 岗位。原因很简单——一个 HashMap 能把数据结构、哈希算法、并发安全、JDK 源码设计全串起来,面试官用一个类就能摸清你的功底。
但大多数人准备 HashMap 面试的方式是"背八股":数组+链表+红黑树、负载因子 0.75、扩容翻倍……这些没错,但面试官想听的不是背诵,而是你能把这些设计决策串成一条逻辑链——为什么是这个结构?为什么 null 被特殊对待?为什么遍历顺序会变?为什么多线程会出事?
HashMap 的 table[] 数组,等价于分库分表的 shard 路由表:(n-1) & hash 就是路由算法,桶下标就是 shard 号,链表/红黑树就是每个 shard 内部的冲突处理。面试官问 HashMap 的"为什么",本质上是在考你能否把一个哈希表的每个工程决策讲出 trade-off——这正是后端架构面试的核心能力。
本文不讲 put 流程的每一行细节,我们聚焦面试中最高频的三个追问方向,逐问给出结论 → 源码依据 → 面试话术,并补齐扩容 rehash、JDK7 成环、JDK8 覆盖丢失这些"追问深处"的硬核内容。
本篇路线图
- 数据结构(第 2~3 站):null key 为什么落桶 0、遍历顺序为什么无序
- 核心流程(第 4 站):扩容与 rehash——顺序"变脸"的根源
- 并发剖析(第 5~7 站):线程不安全根因、JDK7 成环、JDK8 覆盖丢失
- 面试与落地(第 8~12 站):连环追问话术、陷阱题、生产最佳实践、选型对比
null key:HashMap 为什么"网开一面"?
面试中经常被问到:"HashMap 和 ConcurrentHashMap 都基于哈希表,为什么前者允许 null key,后者不允许?"——这不是随意的设计,背后有清晰的工程权衡。先给结论:HashMap 允许 null key,是因为 hash(null) 直接返回 0,null key 等价于一个 hash 为 0 的普通 key,永远落在 0 号桶。
hash() 方法:一行代码定乾坤
static final int hash(Object key) {
int h;
// key == null → 直接返回 0,不调用 hashCode(),无空指针风险
return (key == null) ? 0
: (h = key.hashCode()) ^ (h >>> 16); // 高 16 位与低 16 位异或,让高位参与散列
}
注意一个细节:JDK 1.8 把扰动从四次减为一次。JDK 1.7 的 hash() 里是 h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4) 四轮扰动,JDK 1.8 认为经过红黑树优化后碰撞代价已可接受,简化成一轮 h ^ (h >>> 16)。这是面试常考的"版本差异"细节。
当 key == null 时:
- null key 的桶下标永远是
0 & (n-1) = 0,即 0 号桶;而且无论容量 n 怎么扩(16、32、1024……),0 & (n-1)恒等于 0——null key 扩容后依然留在桶 0,这一点可以当追问的加分项 - null key 不需要调用
hashCode(),所以不存在空指针风险 get(null)时同样走hash(null) → 0 → 0 号桶,能正确找到值
方法级源码:null key 在 put / get 里走的是"普通流程"
很多人以为 HashMap 对 null 有专门的分支。实际上 putVal() / getNode() 里没有任何 null 特判——因为 hash 已经是 0 了,null key 就是"一个 hash 为 0 的普通 key":
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // 懒加载:第一次 put 才建表
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 桶空 → 直接挂新节点(null key 也走这里)
else {
// 桶非空:链表/红黑树查找,命中则覆盖 value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
...
}
}
注意命中判断里第一个条件是 p.key == key(引用比较)——当 key 为 null 时,只要桶里节点也是 null key,引用比较直接命中,根本不需要调用 equals()。再看 get 侧:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) { // 定位桶
if (first.hash == hash && ((k = first.key) == key
|| (key != null && key.equals(k)))) // null key:引用比较命中
return first;
if ((e = first.next) != null) { // 桶内继续:链表或红黑树
...
}
}
return null; // 表为空 / 桶为空 / 没找到 → 返回 null
}
为什么 HashMap 选择允许 null?为什么 CHM / Hashtable 拒绝?
允许 null 不是技术限制,而是设计哲学(Joshua Bloch 的取舍):
- 便利性:很多业务场景中 null 是一个有意义的值——"用户未选择""配置项缺失""缓存未命中"。允许 null key 让开发者可以直接
map.put(null, defaultValue),省去额外的判空逻辑 - 单线程场景无歧义:HashMap 设计初衷是单线程使用,null 不会带来语义混乱
- hash 函数做了兜底:null → 0 的映射简单明确,不会引入 bug
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null)
throw new NullPointerException(); // 没有任何商量
...
}
Doug Lea(ConcurrentHashMap 作者)在邮件列表中解释过这个设计决策;Hashtable 同理(put 里 value == null 抛 NPE,key 为 null 时调用 key.hashCode() 也会 NPE):
在单线程的 HashMap 中,如果 map.get(key) 返回 null,你可以再调 map.containsKey(key) 来区分"key 不存在"和"value 是 null"。但在多线程环境下,两次调用之间其他线程可能已经修改了 map,所以 containsKey 的结果不可信。
为了避免这种无法排查的歧义,ConcurrentHashMap 选择了最简单粗暴的方案:禁止 null key 和 null value,从源头消除二义性。这也是《Java 并发编程实战》里明确推荐的思路:不要让调用方去猜"null 到底是什么意思"。
- 结论:HashMap 允许 null key,因为
hash(null)直接返回 0,null key 存储在 0 号桶;HashMap 允许 null value,因为单线程下 get() 返回 null 可以用 containsKey 区分 - 源码依据:
hash()三目运算符对 null 返回 0;putVal/getNode里 null key 靠p.key == key引用比较命中,无空指针;扩容后0 & (n-1) = 0仍落桶 0 - 对比补充:ConcurrentHashMap / Hashtable 不允许 null key/value,因为并发环境下 get() 返回 null 有二义性(key 不存在 vs value 为 null),且无法通过 containsKey 二次确认
遍历顺序:为什么 HashMap "不守规矩"?
答案藏在 HashMap 的哈希分布机制里。先给结论:HashMap 不保证遍历顺序——元素在数组中的位置取决于 hash & (n-1) 而不是插入顺序;遍历器按桶下标从 0 扫到 n-1,所以输出顺序本质是"桶下标序";扩容 rehash 后桶位重排,顺序还会再变。
方法级源码:位置是"算"出来的,不是"排"出来的
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
map.put("date", 4);
// 遍历:从 table[0] 开始逐桶扫描,桶内按链表/树顺序
// 结果取决于每个 key 的 (n-1) & hash —— 可能是 banana, apple, date, cherry
for (String key : map.keySet()) {
System.out.println(key + " → " + map.get(key));
}
散列分布决定了落桶位置:
例如容量 16(n-1 = 1111₂):
"apple".hashCode() = 0x6A9B7CDE → 扰动后 hash → 1111 & hash → 桶 14
"banana" 扰动后 hash → 1111 & hash → 桶 1
所以遍历输出里 banana 一定在 apple 之前——不是因为字母序,而是因为它的桶下标更小。
迭代器(KeyIterator / EntryIterator)的扫描逻辑在 HashMap.HashIterator.hasNextNode():从 table[0] 出发,跳过空桶,遇到非空桶就顺着链表/红黑树把该桶所有节点吐完,再移到下一个桶。JDK 1.8 尾插法下,同一桶内的链表顺序 = 插入顺序,但桶与桶之间完全由 hash 决定。
两个容易踩的"假象"
HashMap 的规范只承诺"不保证顺序",不承诺"每次必变"。给定相同的 key 集合、相同的容量、同一个 JDK 版本(String 的 hashCode 是确定的),遍历结果通常是可复现的——但这属于实现细节,不是规范保证。一旦发生扩容(rehash)、换了 JDK 版本、或 hashSeed 机制生效,顺序就可能变。生产代码如果把业务逻辑依赖在 HashMap 遍历顺序上,就是埋雷。
当 key 是 0~15 的小整数且容量为 16 时,(n-1) & hash = key,遍历恰好输出 0,1,2,3… 看起来"有序"——这是数字太小、没有碰撞的巧合,不是 HashMap 保证了顺序。容量一变或数字一多,立刻打回原形。
遍历还有一个隐藏约束:fail-fast
HashMap 有 transient int modCount 字段记录结构性修改次数(put 新 key、remove、clear 都会 ++modCount,覆盖已有 key 不算)。迭代器创建时快照 expectedModCount,每次 next() 前检查 modCount != expectedModCount 就抛 ConcurrentModificationException。这意味着:遍历过程中不能修改结构(哪怕是单线程),否则直接异常——这是"快速失败"机制。
LinkedHashMap 如何记住插入顺序?
LinkedHashMap 继承自 HashMap,在 HashMap 的 Node 基础上额外维护一条双向链表。每次 put 新元素时,除了照常放入哈希桶,还会把新节点追加到双向链表尾部。遍历 LinkedHashMap 时,走的是双向链表而不是哈希桶数组,所以输出顺序 = 插入顺序。
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 双向链表的前后指针
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
LinkedHashMap 还有一个杀手级特性:accessOrder 模式。构造时传 accessOrder = true,每次 get/put 都会把被访问的节点移到链表末尾(afterNodeAccess()),配合 removeEldestEntry() 就能实现 LRU 缓存——LinkedHashMap 的链表在扩容 rehash 后顺序依旧保持,因为它独立于桶数组维护。
- 结论:元素落桶位置由
(n-1) & hash决定而非插入顺序;迭代器按桶下标 0→n-1 扫描,所以遍历无序;扩容 rehash 重排桶位后顺序可能再变 - 源码依据:
putVal的tab[i = (n - 1) & hash]决定落桶;HashIterator.hasNextNode()逐桶扫描;modCount支撑 fail-fast(遍历中改结构抛 ConcurrentModificationException) - 补充加分:"不保证"≠"每次必变";Integer 小 key 的"有序"是巧合;需要顺序用 LinkedHashMap(插入/访问序)或 TreeMap(排序)
扩容与 rehash:遍历顺序"变脸"的根源
触发条件与核心字段
先记住三个字段:threshold(扩容阈值 = 容量 × 负载因子)、loadFactor(默认 0.75f)、size(元素个数)。每次 put 新 key 成功后 ++size,一旦 size > threshold 就触发 resize(),容量翻倍。0.75 是时间与空间的折中:太小浪费内存,太大增加碰撞——这是教科书级的"空间换时间"权衡。
++modCount; // 结构性修改计数
if (++size > threshold) // 超过阈值 → 扩容
resize();
afterNodeInsertion(evict); // 给 LinkedHashMap 的钩子(LRU)
return null; // put 返回旧 value,没有则 null
方法级源码:resize() 为什么是 2 倍
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { // 1 << 30,极限保护
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY // 左移一位 = ×2
&& oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 阈值也翻倍
}
...
// 新建 newTab = new Node[newCap],然后逐桶搬运(见下方拆分逻辑)
}
关键点:newCap = oldCap << 1,正好是 2 倍。为什么必须是 2 的幂?因为只有容量是 2 的幂时,才有下面三个收益:
- 数学等价:
hash & (n-1)等价于hash % n,位运算比取模快一个数量级(% 是除法指令,& 是一条位运算指令) - 均匀分布:2 的幂减 1 的二进制全是 1(如 15 = 1111₂),hash 的每一位都参与定位,碰撞最小化
- 扩容免重算:1.8 扩容时用
hash & oldCap直接判断新位置(要么原地j,要么j + oldCap),省去重新 hash —— 这个优化依赖"oldCap 是 2 的幂"
方法级源码:JDK 1.8 的 lo / hi 链表拆分(免重 hash)
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 顺手置空旧桶,帮助 GC
if (e.next == null) // 桶里只有一个节点
newTab[e.hash & (newCap - 1)] = e; // 直接算新位置
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 红黑树拆分
else {
Node<K,V> loHead = null, loTail = null; // 低位链:hash&oldCap==0
Node<K,V> hiHead = null, hiTail = null; // 高位链:hash&oldCap==1
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 关键判断:新的一位是 0
if (loTail == null) loHead = e;
else loTail.next = e; // 尾插法,保持原顺序
loTail = e;
} else {
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
e = next;
} while (e != null);
if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原地
if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } // +oldCap
}
}
}
为什么 hash & oldCap 能决定新位置?因为容量翻倍相当于下标二进制多了一位。原来 index = hash & (oldCap-1),现在 newIndex = hash & (2*oldCap-1)——多出的那一位正是 hash & oldCap:为 0 则留在原桶 j,为 1 则移到 j + oldCap。这也是遍历顺序在扩容后"变脸"的数学根源。
树化与退化:边界条件必须背准
| 字段/常量 | 值 | 含义 |
|---|---|---|
TREEIFY_THRESHOLD | 8 | 链表长度 ≥ 8 时尝试树化(先查容量) |
MIN_TREEIFY_CAPACITY | 64 | 容量 < 64 时不树化,先扩容(treeifyBin() 里的判断) |
UNTREEIFY_THRESHOLD | 6 | 扩容拆分后红黑树节点 ≤ 6,转回链表 |
DEFAULT_INITIAL_CAPACITY | 16 | 默认容量 1 << 4 |
DEFAULT_LOAD_FACTOR | 0.75 | 默认负载因子 |
MAXIMUM_CAPACITY | 1 << 30 | 容量上限(约 10.7 亿) |
JDK 源码注释给出依据:在随机 hash 下,桶内链表长度服从泊松分布,长度为 8 的概率约 千万分之六(0.00000006)——达到 8 说明 hash 分布已严重异常(如被恶意构造 collision 攻击),值得升级为红黑树把 O(n) 拉回 O(log n)。退化阈值 6 而不是 8,是为了留出 2 的差值缓冲,避免链表和树在阈值附近来回震荡(频繁转换是性能灾难)。8 和 6 之间隔一个 7,这就是工程上的"滞回区间"。
ArrayList 扩容是 newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍——因为它是"搬数组"(System.arraycopy 整块拷贝),搬一次成本高,扩多了浪费内存,1.5 倍是"均摊 O(1) 且内存浪费可控"的折中;HashMap 扩容是"逐桶搬运 + 重新定位",必须 2 倍才能用 hash & oldCap 免重 hash。所以"为什么是 2 倍"的答案核心不是性能习惯,而是2 的幂让位运算成立。顺带一提:HashMap 初始容量由 tableSizeFor() 保证是 2 的幂——cap-1 后不断 n |= n >>> 1/2/4/8/16,把最高位以下的位全填 1,最后 +1。
- 结论:
size > threshold时触发resize(),容量左移一位(×2),阈值同步 ×2;JDK 8 扩容用hash & oldCap拆 lo/hi 两段,节点要么留原桶要么移j+oldCap,全程不重新调用 hashCode - 为什么 2 倍:容量必须是 2 的幂,
(n-1) & hash等价取模、掩码全 1 分布均匀、扩容免重 hash 三个收益都依赖它 - 边界数据:树化 8 + 容量 64、退化 6、负载因子 0.75、最大容量 1<<30、ArrayList 1.5 倍对比——这些数字必须一字不差
线程安全总览:三个层次的问题与根因
HashMap 在并发环境下至少有三个层次的问题,从轻到重:
问题 1:size 不准确
++size 不是原子操作(读 → 加 → 写三步),两个线程同时执行 put() 时,可能两次 ++size 只生效一次。结果就是 map.size() 比实际元素数少——服务端用 size 做分页/统计时会出现"对不上账"。
问题 2:数据覆盖(丢失更新)
线程 A 和线程 B 同时发现 table[i] == null,都执行了 tab[i] = newNode(...)。后写入的那个会覆盖先写入的——一个 key 被另一个不同 key 静默顶掉,数据悄悄丢了,没有任何异常。这是最隐蔽的:线上不会有报错,只有数据对不上。
问题 3:JDK 1.7 的死循环(CPU 100%)
这是最"经典"的问题(第 6 站展开)。JDK 1.7 使用头插法扩容,两个线程同时 resize 时,链表会被逆序重建,特定交错下会形成环形链表,后续所有经过这个桶的 get() 都变成死循环,CPU 打满。
根因:不是"某个版本有 bug",而是结构性的
看字段声明就知道——transient Node<K,V>[] table;、transient int size;、transient int modCount;:没有 volatile,没有锁,没有任何同步机制。而 put() 至少包含"定位桶 → 判断桶空 → 写入数组 → size++"四步,每一步之间都可能被其他线程插入执行。原子性缺失(覆盖丢失)、可见性缺失(读到过期数据)、有序性缺失(重排序)三样占全了——所以结论是:HashMap 的线程不安全是结构性的,任何版本都救不了,唯一的解法是换并发容器。
| 问题层次 | 现象 | 根因 | JDK 版本 |
|---|---|---|---|
| size 不准 | size() 比实际少 | ++size 非原子 | 1.7 / 1.8 都有 |
| 数据覆盖丢失 | 不同 key 互相顶掉,无异常 | 并发 tab[i] = newNode 后写覆盖先写 | 1.7 / 1.8 都有 |
| 扩容死循环 | get() 卡死,CPU 100% | JDK 7 头插法并发 resize 成环 | 仅 1.7(1.8 尾插修复) |
| 读脏数据 | get 返回过期/中间态值 | table 无 volatile,无 happens-before | 1.7 / 1.8 都有 |
注意第三行:JDK 1.8 修复的是"成环死循环",不是"线程安全"。这是一个非常容易被面试官挖坑的点,下一站我们分别把 1.7 的环和 1.8 的覆盖讲到方法级。
JDK 1.7 头插成环:CPU 100% 的"经典名场面"
先看 JDK 1.7 的 transfer():为什么头插会逆序
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while (null != e) {
Entry<K,V> next = e.next; // ① 先记下下一个节点
if (rehash) e.hash = ...;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; // ② 新节点的 next 指向当前桶头 → 头插
newTable[i] = e; // ③ 当前节点成为新桶头
e = next; // ④ 处理下一个
}
}
}
头插的代价:每搬一个节点都插到桶头,整条链表被逆序重建。单线程下这只是顺序问题,无伤大雅;但并发 resize 时,两个线程各自持有局部变量 e 和 next,它们引用的却是同一批共享的 Entry 节点——灾难由此而来。
成环的三幕剧(必考细节)
假设桶 3 里是链表 A → B → C(A.next=B, B.next=C),两个线程 T1、T2 同时触发扩容:
第三幕的细节:T1 恢复后继续搬 A(e=A, next=B),执行 A.next = newTable1[7](此时为 null)→ 桶头=A;接着搬 B,此时 next = B.next 已经被 T2 改成了 A(不是原来的 C);B 入桶后 B.next = A;再处理 e=A 时 A.next = B。最终 A.next = B 且 B.next = A——环形链表成型。之后任何 get() 沿着环遍历,while (e != null) 永不退出,CPU 直接打满。
生产事故:这是真实发生过的线上事故
// 典型事故场景:全局缓存使用 HashMap(JDK 1.7 时代)
public class CacheService {
// ❌ 错误:多线程环境用了 HashMap
private Map<String, Object> cache = new HashMap<>();
public void refreshCache(List<Config> configs) {
// 多个线程同时调用 → 并发 put → 触发并发 resize → 成环
configs.forEach(c -> cache.put(c.getKey(), c.getValue()));
}
}
// 修复方案:替换为 ConcurrentHashMap(或扩容前加锁)
private Map<String, Object> cache = new ConcurrentHashMap<>();
1.8 改用尾插法(p.next = newNode(...) 追加到链表尾部),扩容时按 lo/hi 两段原序搬运——链表不再被逆序重建,经典的成环路径被从源码层面堵死。这是事实。但:size 不准确、数据覆盖、扩容竞态、读脏数据依然存在。所以"JDK 1.8 的 HashMap 可以安全用在多线程环境"是错误结论。面试时被追问,一定要明确区分:"修复了死循环" ≠ "线程安全",两者差着一整个并发容器。
- 结论:JDK 1.7 头插法扩容,并发 resize 时两个线程共享同一批 Entry,特定交错下 A↔B 互指形成环形链表,get() 死循环、CPU 100%
- 源码依据:
transfer()里e.next = newTable[i]; newTable[i] = e;的头插三步;成环关键是 T2 逆序改写了B.next,而 T1 的局部e/next还是旧引用 - 版本对比:JDK 1.8 尾插 + lo/hi 原序拆分,从源码上消灭成环;但覆盖丢失、size 不准、可见性问题仍在,并发必须用 ConcurrentHashMap
JDK 1.8 数据覆盖丢失:不死循环 ≠ 线程安全
方法级源码:覆盖点在哪
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // 写点①:两个线程可能各自 resize
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 写点②:空桶并发写入,后写覆盖先写
else {
// 桶非空:链表/树内插入或覆盖(写点③:链表结构被并发破坏)
...
}
++modCount;
if (++size > threshold) // 写点④:++size 非原子,计数丢失
resize();
...
}
JDK 1.8 下真实的丢失场景
| 场景 | 交错过程 | 后果 |
|---|---|---|
| 空桶覆盖 | T1、T2 同时发现 tab[i] == null,各自执行 tab[i] = newNode(...)(key1 ≠ key2) |
后写者覆盖先写者,一个 key 静默丢失,无异常 |
| size 计数丢失 | 两个线程并发 put,++size 读-加-写交错,只生效一次 |
size() 偏小;更危险的是可能错过扩容触发,表"超载"运行 |
| 并发 resize 丢数据 | T1、T2 各自 resize 出 newTab,T1 先 table = newTab1,T2 后 table = newTab2;期间 T1 又插入的新节点只存在于 newTab1 |
最终生效的 newTab2 里没有 T1 插入的数据,同样静默丢失 |
| 链表结构破坏 | 两线程同时往同一桶尾插,p.next = newNode 互相覆盖 |
桶内节点"断链",get 遍历不到部分 key |
JDK 1.8 的 resize() 结束后执行 table = newTab(把新表赋给字段)。如果两个线程各扩了一次,后赋值者胜出,前者的表被整体丢弃——而前者在扩容期间插入的新节点只存在于它自己的新表里。换句话说,并发下不仅可能丢单个 key,还可能丢"半张表"的数据。这就是为什么"没有死循环了"远不等于"安全了"。
为什么 ConcurrentHashMap 能扛住?先看它的写路径
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
...
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // 空桶:CAS 原子写入,谁先成功谁赢,不会互相覆盖
}
else {
synchronized (f) { ... } // 桶非空:锁桶头节点,串行化桶内操作
}
}
addCount(1L, binCount); // 计数:baseCount + CounterCell[],原子累加
return null;
}
三个机制正好对上 HashMap 的三个死穴:空桶用 CAS 原子写(消灭覆盖)、非空桶 synchronized 桶头(锁粒度细到单个桶,串行化桶内操作)、addCount 原子计数(消灭 size 丢失)。细节我们在第 12 站选型对比里再展开。
- 结论:1.8 尾插修复了成环,但空桶并发写入互相覆盖、
++size计数丢失、并发 resize 丢新表依然存在——HashMap 仍不是线程安全的 - 源码依据:
putVal的tab[i] = newNode(...)与++size都是非原子多步操作;table/size字段无 volatile,无锁无 CAS - 对比加分:ConcurrentHashMap 用
casTabAt原子写空桶、synchronized(f)桶级锁、addCount原子计数,逐一封堵了这三个点
面试官连环追问:结论 + 源码 + 话术三段式
追问 1:HashMap 为什么允许 null key?
结论:hash(null) 返回 0,null key 永远落在 0 号桶;ConcurrentHashMap 与 Hashtable 出于并发二义性拒绝 null。
源码依据:hash() 三目运算 (key == null) ? 0 : ...;putVal/getNode 命中判断里的 p.key == key 引用比较;扩容后 0 & (n-1) = 0 恒定。
话术:"null key 在 HashMap 里不是一个特殊分支,而是一个 hash 为 0 的普通 key。HashMap 允许 null 是单线程便利性设计;CHM 拒绝 null 是因为并发下 get 返回 null 无法区分'key 不存在'与'value 是 null',containsKey 二次确认在并发下也不可靠。"
追问 2:遍历顺序为什么不保证?
结论:元素落桶由 (n-1) & hash 决定而非插入顺序;迭代器按桶下标扫描;扩容 rehash 后顺序还会变。
源码依据:putVal 的 tab[i = (n - 1) & hash];HashIterator.hasNextNode() 逐桶扫描;resize() 的 lo/hi 拆分让节点从桶 j 移到 j+oldCap。
话术:"HashMap 的遍历顺序本质是'桶下标序',由散列决定,规范只承诺不保证顺序。JDK 8 扩容时节点要么留原桶要么平移 oldCap,所以扩容后顺序必然可能改变。需要稳定顺序用 LinkedHashMap(插入/访问序)或 TreeMap(排序)。"
追问 3:多线程下 HashMap 会出什么问题?
结论:JDK 7 头插扩容并发成环 → get 死循环 CPU 100%;JDK 8 尾插修复成环,但覆盖丢失、size 不准、并发 resize 丢新表仍在;根因是结构性非线程安全。
源码依据:JDK 7 transfer() 的 e.next = newTable[i] 头插逆序;JDK 8 putVal 的 tab[i] = newNode 与 ++size 非原子;字段无 volatile。
话术:"HashMap 的并发问题不是某个版本的偶发 bug,而是 put/get/resize 都是多步非原子操作且无可见性保证。1.8 只是消灭了成环,覆盖和计数问题还在。所以并发场景没有讨论余地,直接上 ConcurrentHashMap——它的 CAS 空桶写入、桶级锁和原子计数正是针对这三个死穴设计的。"
追问 4(进阶):你能复现死循环吗?
能。前提是 JDK 1.7(或 1.6),多个线程同时对同一个 HashMap 做 put 并触发扩容。经典做法是:预填充大量元素逼近扩容阈值,再开多个线程并发 put,跑一段时间后 jstack 能看到多个线程卡在 HashMap.get() 的 while 循环里(栈顶反复出现 HashMap.transfer/getEntry),CPU 飙到 100%。
资深候选人不会停在"HashMap 不安全",而是会补一句:"正因为 HashMap 把复杂度都压给了调用方,JDK 才提供了 LinkedHashMap(顺序)、ConcurrentHashMap(并发)、Collections.synchronizedMap(简单加锁)、Map.of(不可变)四类替代,面试官问 HashMap 其实是想看你会不会在正确场景选正确的容器。" 这一句能把整个回答从"八股"拉高到"架构思维"。
- null key:hash(null)=0 → 桶 0 → 引用比较命中;CHM 拒绝是并发二义性
- 遍历无序:位置 = (n-1) & hash;逐桶扫描;扩容 rehash 后变脸
- 线程不安全:1.7 头插成环死循环;1.8 覆盖丢失 + size 不准;根因是非原子 + 无可见性;解法是 ConcurrentHashMap
equals / hashCode 契约:最经典的 Bug 制造机
先看 HashMap 查找 key 的流程——理解了查找方式,就理解了为什么这两个方法必须成对出现:
① 计算 hashCode → 定位桶下标:hash & (n-1)
② 遍历桶中的链表/红黑树
③ 对每个节点:先比 hash(int 比较),再比 ==(引用比较),最后才 equals()
关键点在第 ③ 步:hashCode 不同的两个对象,HashMap 直接认为它们不相等,根本不会调用 equals()。所以 hashCode 决定了"去哪个桶找",equals 决定了"桶里哪个是你要的"——两者协作,缺一不可。
Bug 实例:只重写 equals 不重写 hashCode
class User {
String name;
int age;
User(String name, int age) {
this.name = name; this.age = age;
}
// 只重写了 equals,没重写 hashCode —— 这是一个 Bug
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User u = (User) o;
return age == u.age && name.equals(u.name);
}
// hashCode() 继承 Object —— 基于内存地址,每次 new 都不同
}
Map<User, String> map = new HashMap<>();
User u1 = new User("Alice", 25);
User u2 = new User("Alice", 25);
map.put(u1, "VIP");
System.out.println(u1.equals(u2)); // true —— equals 认为相等
System.out.println(map.get(u2)); // null —— 但 HashMap 找不到!
// 原因:u1 和 u2 的 hashCode 不同,落到了不同的桶
如果只重写 hashCode 让两个对象落到同一个桶,但 equals 沿用 Object 默认(比引用地址),那么 get(u2) 在桶内遍历时,u1.equals(u2) 返回 false——同样找不到。更糟的是 map.put(u2, ...) 会因为 equals 不相等而在同一桶里新增一个重复节点,map 里出现"逻辑上相同却并存"的两份数据。所以两个方法必须同时重写,且逻辑一致(equals 用哪些字段,hashCode 就应该用哪些字段)。
Java 官方的契约规则
1. 如果 a.equals(b) == true → a.hashCode() == b.hashCode()(必须)
2. 如果 a.hashCode() == b.hashCode() → a.equals(b) 不一定(允许碰撞)
3. 同一个对象多次调用 hashCode() 返回值不变(除非影响 hash 的字段被修改)
- 用 IDE 或
@Data/@EqualsAndHashCode(Lombok)自动生成,避免手写遗漏 - Java 14+ 可用
record类型——自动实现 equals、hashCode、toString,天然不可变,是 HashMap key 的首选 - hashCode 计算时记得用
Objects.hash(field1, field2),不要手写位运算(容易写错) - 作为 HashMap key 的字段必须是 final 或不可变的,否则 put 后修改字段会导致"丢失"(见第 10 站陷阱 3)
高频面试陷阱题
完整答案有三层:
- 数学等价:当 n = 2k 时,
hash & (n-1)等价于hash % n,位运算比取模快几个数量级 - 均匀分布:2 的幂减 1 的二进制全是 1(如 15 = 1111₂),hash 的每一位都参与运算,碰撞最小化;如果 n-1 有 0 位(如 13-1=12 = 1100₂),那几位 hash 永远无法影响下标,等于人为制造碰撞
- 扩容优化:1.8 扩容时利用
hash & oldCap直接判断新位置(原地 or +oldCap),省去重新 hash——这个优化也依赖"2 的幂"的前提
hashCode 相同 ≠ 同一个 key。HashMap 的处理方式:
- 两个 key 的 hash 值相同 → 落到同一个桶
- 桶里形成链表(或红黑树),两个节点共存
get(key)时,先定位到桶,再遍历链表用equals()逐个比对
这就是哈希冲突的正常处理方式(链地址法),不会丢数据。只有当大量 key 碰撞到同一个桶时,性能才从 O(1) 退化为 O(n)(链表)或 O(log n)(红黑树)——这也是"恶意构造 collision 攻击"的原理,树化阈值 8 就是为了兜底这种退化。
class Key {
String value;
public int hashCode() { return value.hashCode(); }
public boolean equals(Object o) {
return o instanceof Key && value.equals(((Key)o).value);
}
}
Map<Key, String> map = new HashMap<>();
Key k = new Key();
k.value = "hello";
map.put(k, "world");
k.value = "changed"; // 修改了影响 hashCode 的字段
System.out.println(map.get(k)); // null —— 找不到!
// 原因:put 时 k 在 hash("hello") 对应的桶里
// get 时用 hash("changed") 去查,去了完全不同的桶
结论:put 之后不要修改 key——不仅 get 找不到,旧节点还会成为永远无法访问的"内存泄漏"(它还在桶里,只是你再也算不出它的下标了)。这也是为什么 String、Integer 这些不可变类是最佳 key 选择——它们的 hashCode 永远不变。加分回答:"这个问题在《Effective Java》里被列为'可变对象不适合做 Map key',Java 8 之后甚至可以在文档里查到明确警告。"
Map<String, String> m = new HashMap<>();
String r1 = m.put("k", "v1"); // null —— 之前没有该 key
String r2 = m.put("k", "v2"); // "v1" —— 返回被覆盖的旧值
// 注意:如果旧值本身就是 null,返回值也是 null,无法区分"新增"与"覆盖为 null"
// 想区分必须用 containsKey() 先判断(单线程下可靠)
- 2 的幂:位运算取模 + 掩码全 1 均匀分布 + 扩容免重 hash,三层都要答
- hashCode 相同:链地址法解决冲突,不丢数据,只退化性能
- 可变 key:put 后改字段 → hashCode 变 → 找不到 + 内存泄漏;key 必须不可变
- put 返回值:返回被覆盖的旧值;旧值为 null 时无法区分新增/覆盖
生产环境最佳实践
初始容量:别偷懒用默认值
阿里开发手册明确要求:初始化 HashMap 时必须指定容量。公式如下:
示例:预计存 100 个元素 → (int)(100 / 0.75) + 1 = 134
HashMap 构造器内部会调 tableSizeFor() 向上取到 2 的幂 → 实际容量 256
全程零扩容,性能最优(100 / 256 ≈ 0.39,远低于 0.75 阈值)
// ❌ 默认容量 16,存 1000 个元素会触发多次扩容(每次都要搬运+rehash)
Map<String, Object> bad = new HashMap<>();
// ✅ 正确:预估元素数量,指定初始容量
int expectedSize = 1000;
Map<String, Object> good = new HashMap<>(
(int)(expectedSize / 0.75f) + 1
);
// ✅ 更简洁:Google Guava(内部同样按 0.75 反推)
Map<String, Object> guava = Maps.newHashMapWithExpectedSize(1000);
直接 new HashMap<>(1000) 是错的:构造参数是"容量",而扩容触发点是 容量 × 0.75。容量 1000 时,存到 751 个就会扩容一次,等于白设。所以要么按公式 (int)(expectedSize / 0.75f) + 1,要么用 Guava 的 newHashMapWithExpectedSize。另外注意 tableSizeFor 会把 134 抬到 256,所以"设 134"实际拿到的是 256——这是预期行为,别当成 bug。
Java 9+ 不可变 Map
如果你的 Map 在创建后不需要修改(配置项、常量映射),Java 9 引入的工厂方法更简洁安全:
// Java 9+ Map.of() —— 不可变,不允许 null key/value
Map<String, Integer> config = Map.of(
"timeout", 3000,
"retries", 3,
"poolSize", 10
);
// Map.copyOf() —— 从已有 Map 创建不可变副本
Map<String, Integer> snapshot = Map.copyOf(config);
// config.put("new", 1) → UnsupportedOperationException
// Map.of("key", null) → NullPointerException(与 CHM 同一哲学)
并发场景:唯一的正解是 ConcurrentHashMap
再次强调:任何"给 HashMap 加个锁"的封装都不如直接用 ConcurrentHashMap。Hashtable(全表锁)性能差,Collections.synchronizedMap 同样全表锁且复合操作(如"先查后写")仍需手动加锁。ConcurrentHashMap 的桶级锁 + CAS 让并发读写吞吐量高一到两个数量级。另外注意:不要用 HashMap 做全局缓存——缓存是典型的"多读少写 + 周期性刷新",正是第 5~7 站所有事故的标准温床。
- 创建时按
(int)(expectedSize / 0.75f) + 1指定容量,避免反复扩容 - key 用不可变对象(String / Integer / record / 枚举),禁止 put 后修改
- 只读配置用
Map.of()/Map.copyOf(),天然线程安全 - 并发读写一律 ConcurrentHashMap;全局缓存禁止 HashMap
- 对外暴露内部 Map 前用
Collections.unmodifiableMap或拷贝,防止调用方改坏内部状态
全家桶对比:HashMap vs LinkedHashMap vs TreeMap vs ConcurrentHashMap
ConcurrentHashMap 的线程安全原理(JDK 1.8)
面试如果聊到"为什么 CHM 安全",下面三个机制是最小完备集:
- CAS 原子写空桶:
casTabAt(tab, i, null, newNode),空桶插入用Unsafe.compareAndSwapObject,谁先成功谁生效,天然消灭"后写覆盖先写" - synchronized 桶头锁:桶非空时锁住桶头节点
f,锁粒度是一个桶而不是整张表,不同桶的读写可以完全并行 - sizeCtl + CounterCell 原子计数:
sizeCtl三态复用(负数且非 -1 表示扩容中、-1 表示初始化中、正数为扩容阈值);计数走baseCount+CounterCell[](高并发下分散到多个 cell 用 CAS 累加,sumCount()汇总),彻底解决++size不原子的问题
CHM 扩容不是单线程搬完的:resizeStamp 记录扩容戳,其他线程在 put/get 时如果发现某个桶是 ForwardingNode(forwarding 标记节点,hash 值为 -1),会主动帮忙搬运该桶再继续自己的操作。所以 CHM 扩容是"多线程协作搬桶",而不是像 HashMap 那样一个线程全包——这也是它在大容量下扩容不卡顿的原因。面试说出 ForwardingNode 这个词,基本就是满分了。
选型决策表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单线程,无顺序要求 | HashMap | 性能最优,零额外开销 |
| 单线程,需保持插入顺序 | LinkedHashMap | 双向链表维护顺序,LRU 缓存 |
| 单线程,需按 key 排序 | TreeMap | 红黑树自然排序,O(log n) |
| 多线程并发读写 | ConcurrentHashMap | CAS + 桶级锁,高吞吐 |
| 创建后只读 | Map.of() / Map.copyOf() | 不可变,线程安全,内存紧凑 |
| 任何新代码 | ❌ 不要用 Hashtable | 全方法 synchronized,锁粒度最大,性能极差 |
全篇速查表
| 问题 | 核心答案 |
|---|---|
| 为什么允许 null key? | hash(null) 返回 0,存 table[0];ConcurrentHashMap 不允许,因为并发下 null 有二义性 |
| 遍历顺序为什么不保证? | 元素位置由 hash & (n-1) 决定,与插入顺序无关;扩容 rehash 后可能再变 |
| 需要有序遍历怎么办? | LinkedHashMap(插入序/访问序)或 TreeMap(排序序) |
| equals/hashCode 的关系? | equals 相等 → hashCode 必相等;必须同时重写,否则 HashMap 找不到 key |
| 多线程下会出什么问题? | size 不准、数据覆盖、JDK 1.7 死循环(1.8 尾插修复死循环但覆盖/计数问题仍在) |
| 容量为什么是 2 的幂? | hash & (n-1) 等价 hash % n;位运算快;掩码全 1 均匀;扩容免重 hash |
| put 后能改 key 吗? | 不能。key 的 hashCode 变了 → 找不到原来的桶 → get 返回 null 且旧节点泄漏 |
| 初始容量怎么设? | (int)(expectedSize / 0.75f) + 1,避免扩容开销 |
| 树化阈值? | 链表 ≥ 8 且容量 ≥ 64 树化为红黑树;≤ 6 退化回链表(滞回区间避免震荡) |
这一篇你掌握了什么
回到开头的三连问,现在你不仅能答出结论,还能讲出源码依据和工程取舍:null key 为什么落桶 0、遍历顺序为什么无序、多线程下 1.7 为什么死循环而 1.8 为什么还会丢数据——并且你知道这些设计决策背后的 trade-off,这才是面试官真正想听到的东西。
核心知识点回顾
- null key 是 HashMap 刻意的工程设计:
hash(null) = 0,永远落桶 0(扩容后不变),命中靠p.key == key引用比较;ConcurrentHashMap / Hashtable 因并发二义性禁止 null - 遍历无序 是哈希分布的必然结果:落桶 =
(n-1) & hash,迭代器逐桶扫描;扩容 lo/hi 拆分重排桶位,顺序随之"变脸";LinkedHashMap 用双向链表补偿顺序 - 扩容 rehash:
size > 容量×0.75触发,容量 ×2;JDK 8 用hash & oldCap免重 hash 拆 lo/hi 两段;树化 8/64、退化 6 是防震荡的滞回设计 - 线程不安全根因:put/resize 是多步非原子操作,字段无 volatile 无锁;JDK 7 头插成环死循环(CPU 100%),JDK 8 尾插修复成环但覆盖丢失、size 不准、并发 resize 丢新表仍在
- equals/hashCode 必须成对重写:先 hash 定位桶、再 equals 匹配;可变 key 是生产大坑
- 生产实践:初始容量按
(int)(expectedSize / 0.75f) + 1、不可变 key、只读用 Map.of、并发一律 ConcurrentHashMap(CAS + 桶级锁 + sizeCtl/CounterCell)
HashMap 面试没有"背诵题",只有"原理题"——把 null → 桶 0、散列 → 无序、非原子 → 并发事故 这条逻辑链串起来,你就已经超过了大多数候选人。
Comments · 评论