HashMap底层是数组+链表+红黑树:数组长度必为2的幂以支持高效位运算定位;链表解决哈希冲突,长度≥8且容量≥64时树化为红黑树防退化;null键固定存于桶0;hash()二次扰动提升分布均匀性;并发下put覆盖、resize丢数据、JDK7死循环等体现其线程不安全本质。

大厂面试问 HashMap 底层,不是想听你复述“数组加链表”,而是看你能不能从现象反推机制、从代码看到设计权衡、从 bug 看出并发本质。真正拉开差距的,是三个层次:结构怎么搭、操作怎么走、出错怎么崩。
结构设计:为什么非得是“数组 + 链表 + 红黑树”?
这不是堆砌组件,而是分层防御:
-
数组是主干定位器:长度必须是 2 的幂(如 16、32),才能用
(n - 1) & hash快速取模——比 % 运算快,且能保证哈希值低位充分参与索引计算; - 链表是轻量冲突兜底:解决哈希碰撞,插入快、内存开销小,但长度一过 8 就查得慢;
- 红黑树是性能守门员:只在 链表长度 ≥ 8 且数组长度 ≥ 64 时才树化——前者防退化,后者防小数组过早树化浪费空间;退化条件是树节点 ≤ 6,不是 7 或 8,这是为避免频繁切换的抖动。
核心操作:put 和 get 背后藏着哪些关键步骤?
不能只说“算 hash → 找桶 → 插入”,要讲清每一步的意图和副作用:
-
hash() 方法做了两次扰动:
h ^ (h >>> 16),把高 16 位混入低 16 位,防止 key 的 hashCode 本身集中在低位(比如对象 ID 只变末几位)导致大量冲突; - put 时先检查 null key 单独处理:null 的 hash 固定为 0,永远落在 table[0],且只允许一个;
- get 查找必须同时满足两个条件:hash 值相等 且 key.equals() 为 true——缺一不可,这也是为什么自定义 key 必须重写 equals 和 hashCode;
- 扩容 resize 不只是复制:JDK 1.8 改为头插改尾插,避免 JDK 1.7 多线程下链表反转成环,但仍未解决并发 put 的数据覆盖问题。
并发失效:为什么“线程不安全”不是一句空话?
面试官追问“哪里不安全”,是在考你是否见过真实崩坏现场:
立即学习“Java免费学习笔记(深入)”;
- put 覆盖:两个线程同时发现桶为空,都用 CAS 插入新 Node,后到者直接覆盖先到者;
- resize 丢失元素:线程 A 扩容中将某链表拆成高位/低位两组,正拷贝时被线程 B 中断;B 完成自己的 put 后触发另一次 resize,A 恢复后继续拷贝,可能漏掉已由 B 处理过的节点;
-
CPU 飙升根因:JDK 1.7 下多线程 put 触发扩容,多个线程反复链表反转,最终形成环形链表;get 时死循环遍历,CPU 拉满——这种线上事故,光背结论没用,得能结合 thread dump 定位到
getEntry()里的 while 循环卡住。
参数与调优:别让默认值成为你的盲区
生产环境不能靠碰运气,几个关键数字必须心里有数:
- 初始容量 16、负载因子 0.75:不是随便定的,16 是平衡内存占用与首次扩容成本,0.75 是数学上在空间利用率和冲突率之间取得的较优解;
-
threshold = capacity × loadFactor:这个阈值决定何时扩容,如果预估要存 100 万条,别 new HashMap(),应构造时指定
new HashMap(1 (约 1048576),避免多次 resize; - treeify_threshold = 8,untreeify_threshold = 6:差值为 2 是为了防止在阈值附近反复树化/退化,属于典型的“滞后控制”设计。


















