面向 Java / Android 高级开发岗位的复习文档。本文以 JDK 8 的
java.util.HashMap为主线,重点讲清数据结构、hash扰动、put/get、冲突处理、扩容、红黑树、线程安全与高频追问。版本提醒: 不同 JDK 版本的实现细节可能变化。面试回答时最好明确"以 JDK 8 为例"。
目录
- [HashMap 是什么](#HashMap 是什么)
- 底层数据结构
- [Node 节点结构](#Node 节点结构)
- [hash 扰动函数](#hash 扰动函数)
- [为什么容量通常是 2 的幂](#为什么容量通常是 2 的幂)
- [put 的完整流程](#put 的完整流程)
- [hash 冲突与 equals](#hash 冲突与 equals)
- [get 的查找流程](#get 的查找流程)
- 扩容条件、负载因子与阈值
- [resize 扩容迁移原理](#resize 扩容迁移原理)
- 链表为什么转红黑树
- [JDK 7 与 JDK 8 的主要区别](#JDK 7 与 JDK 8 的主要区别)
- [HashMap 为什么线程不安全](#HashMap 为什么线程不安全)
- [HashMap 的 null 规则](#HashMap 的 null 规则)
- 时间复杂度与性能
- [equals 和 hashCode 的契约](#equals 和 hashCode 的契约)
- [迭代器与 fail-fast](#迭代器与 fail-fast)
- 如何正确初始化容量
- [与 ConcurrentHashMap 的区别](#与 ConcurrentHashMap 的区别)
- 高频面试题速答
- 源码阅读路线与总结
1. HashMap 是什么
HashMap<K, V> 是 Java 集合框架中的键值映射容器。它根据 key 的哈希值定位存储桶,再通过 key 的相等性判断找到具体映射。
java
Map<String, Integer> scores = new HashMap<>();
scores.put("Tom", 90);
scores.put("Jerry", 85);
Integer score = scores.get("Tom"); // 90
主要特点(以常见 JDK 8 实现为准):
- 不保证 key 的迭代顺序。
- 允许一个
nullkey,允许多个nullvalue。 - 非线程安全;多个线程并发修改需要额外同步或使用并发容器。
- 平均情况下,
put、get、remove接近 O(1)。 - 容量不足时会扩容;冲突严重的桶可能树化。
面试回答模板:
HashMap 是基于哈希表的 Map 实现。JDK 8 中底层是 Node 数组,桶内元素通过链表或红黑树组织。它先对 key 的 hashCode 做扰动,再通过
(n - 1) & hash定位桶;发生冲突时比较 hash 并结合==、equals()判断 key 是否相同。元素数量超过阈值时会扩容。HashMap 不保证顺序,也不是线程安全容器。
2. 底层数据结构
JDK 8 的主要结构:
text
HashMap
|
+-- table: Node<K,V>[]
|
+-- bucket[0] -> null
+-- bucket[1] -> Node -> Node -> ...
+-- bucket[2] -> TreeNode(树化后)
+-- ...
- 数组
table:根据 hash 快速定位桶。 - 链表:多个元素映射到同一桶时,且该桶尚未树化,就通过链表保存。
- 红黑树:桶内冲突严重且满足条件时,用平衡树降低查找复杂度。
为什么采用这种组合?
- 单纯链表查找可能退化到 O(n)。
- 单纯红黑树会增加节点结构和维护成本。
- 数组 + 链表 + 红黑树在常规场景下兼顾效率和空间。
3. Node 节点结构
JDK 8 中普通节点的核心结构可概括为:
java
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final V setValue(V newValue) {
V old = value;
value = newValue;
return old;
}
}
重点:
hash:插入时计算并保存的扰动后哈希值。key:声明为final,节点创建后不能换成另一个 key 引用。value:可以更新。next:链表中的下一个节点;树化节点使用TreeNode扩展结构。
同一个 key 再次 put 时,一般是更新已有节点的 value,而不是插入重复 key。
4. hash 扰动函数
JDK 8 的核心逻辑:
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
4.1 为什么不直接使用 key.hashCode()?
桶索引计算为:
java
index = (n - 1) & hash;
当容量 n = 16 时,n - 1 = 15,二进制是 0000...1111。与它做 AND,实际上主要取 hash 的低 4 位。
如果多个 key 的低位相同,即使高位不同,也可能被放进同一个桶。
4.2 (h >>> 16) 是什么?
>>>是无符号右移。- 对 32 位
int右移 16 位,相当于把原来的高 16 位移到低 16 位。 - 再执行
h ^ (h >>> 16),让原 hash 的高位信息参与低位计算。
简化示意:
text
原 h: [ 高 16 位 ][ 低 16 位 ]
h >>> 16: [ 0 ][ 原高16位 ]
异或结果: [ 高 16 位 ][ 低位与高位混合 ]
目的不是保证绝不冲突,而是让 hash 的低位分布更充分,降低部分冲突概率。
5. 为什么容量通常是 2 的幂
HashMap 用下面的方式计算桶索引:
java
index = (n - 1) & hash;
当 n 是 2 的幂时,n - 1 的二进制低位全为 1。例如:
text
n = 16 -> 00010000
n - 1 = 15 -> 00001111
这样可以通过位运算快速取出低位作为索引,避免使用取模运算。
JDK 8 在构造时传入非 2 的幂容量,通常会通过 tableSizeFor() 将其调整为大于等于目标值的最小 2 的幂(受最大容量限制)。
注意:容量为 2 的幂是为了让索引计算与扩容优化成立;不是说只要容量为 2 的幂就能完全避免哈希冲突。
6. put 的完整流程
调用:
java
map.put(key, value);
总体流程:
text
put(key, value)
|
v
计算扰动后的 hash
|
v
table 是否初始化?-- 否 --> 初始化数组
|
v
index = (n - 1) & hash
|
+-- 桶为空:直接创建节点
|
+-- 桶非空:
|
+-- 首节点 key 相同:更新 value
|
+-- 桶是红黑树:调用树插入逻辑
|
+-- 桶是链表:遍历查找
|
+-- 找到相同 key:更新 value
|
+-- 没找到:追加新节点
|
+-- 达到树化判断条件:尝试树化
|
v
若是新映射:size++,modCount++
|
v
size > threshold?-- 是 --> resize()
6.1 延迟初始化
java
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
new HashMap<>() 通常不会立即分配默认的 16 个桶。第一次插入时才初始化内部数组,这叫延迟初始化。
6.2 桶为空
java
tab[i] = newNode(hash, key, value, null);
直接将新节点放到该桶。
6.3 首节点是否为相同 key
java
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) {
e = p;
}
判断顺序:
- 先比较 hash,hash 不同则一定不是同一个映射 key。
- 再判断引用是否相同
==。 - 最后用
equals()判断逻辑相等。
6.4 链表遍历
遍历链表:
- 如果找到 hash 相同且 key 相等的节点,更新其 value。
- 如果走到链表末尾仍未找到,则创建新节点追加。
- 新节点插入后,可能触发树化判断。
6.5 更新 value 的返回值
put 返回旧 value;如果之前没有该 key,则返回 null。因此,返回 null 不一定能区分"原来没有 key"和"原来的 value 就是 null"。
7. hash 冲突与 equals
7.1 什么是 hash 冲突?
不同 key 的 hash 值可能相同;即使 hash 值不同,也可能在取桶索引时得到同一个 index。
text
keyA -> hashA -> bucket[3]
keyB -> hashB -> bucket[3]
这就是桶冲突。哈希值是有限位数,而 key 的可能数量远远更多,因此冲突不可避免。
7.2 为什么必须调用 equals?
hashCode() 不是唯一标识。HashMap 不能仅凭 hash 相同就认定 key 相同。
java
String a = new String("abc");
String b = new String("abc");
System.out.println(a == b); // false:不同对象
System.out.println(a.equals(b)); // true:内容相等
System.out.println(a.hashCode() == b.hashCode()); // true
HashMap 通过 hash 缩小范围,再通过 == / equals() 精确判断。
7.3 自定义 key 示例
java
final class UserKey {
private final int id;
UserKey(int id) {
this.id = id;
}
@Override
public int hashCode() {
return 1; // 故意制造冲突,仅用于演示
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof UserKey)) return false;
UserKey other = (UserKey) obj;
return id == other.id;
}
}
java
Map<UserKey, String> map = new HashMap<>();
map.put(new UserKey(1), "A");
map.put(new UserKey(2), "B");
System.out.println(map.size()); // 2:hash 相同,但 equals 为 false
map.put(new UserKey(1), "C");
System.out.println(map.size()); // 2:逻辑上相同的 key,更新 value
8. get 的查找流程
java
V get(Object key)
核心步骤:
- 计算 key 的扰动 hash。
- 根据
(n - 1) & hash找到桶。 - 检查桶的第一个节点。
- 如果首节点不匹配:
- 若为树节点,按红黑树逻辑查找;
- 否则遍历链表。
- 找到 hash 相同且 key 相等的节点后返回 value;找不到返回
null。
逻辑示意:
text
get(key)
|
计算 hash 与 index
|
定位 table[index]
|
比较 hash + == / equals
|
链表遍历或红黑树查找
|
返回 value / null
9. 扩容条件、负载因子与阈值
常见默认值:
java
DEFAULT_INITIAL_CAPACITY = 16;
DEFAULT_LOAD_FACTOR = 0.75f;
阈值通常为:
java
threshold = capacity * loadFactor;
默认容量 16 时,阈值通常是 12。新映射插入后,如果 size > threshold,会触发扩容。
9.1 负载因子是什么?
负载因子控制数组空间利用率和冲突程度之间的折中:
- 较低:更早扩容,通常冲突更少,但占用更多数组空间。
- 较高:空间利用率更高,但桶冲突可能更多。
0.75 是 JDK 的常用默认折中,并不是所有业务下的数学最优值。
9.2 是不是每次 put 都可能扩容?
不是。通常只有新增映射后 size 超过 threshold 才会触发容量扩展;更新已有 key 的 value 不增加 size,不会因为这次更新而触发该扩容判断。
9.3 构造参数注意事项
java
new HashMap<>(64);
参数表示初始容量需求,不是"插入 64 个元素才扩容"的阈值。内部容量会调整为合适的 2 的幂,阈值再根据负载因子计算。
10. resize 扩容迁移原理
这是 JDK 8 的高频重点。
10.1 容量翻倍
常规情况下:
text
16 -> 32 -> 64 -> 128
源码通过:
java
newCap = oldCap << 1;
实现容量翻倍。
10.2 为什么元素只会留在原位置或移动 oldCap?
旧索引:
java
oldIndex = hash & (oldCap - 1);
新容量为 newCap = oldCap * 2,新索引:
java
newIndex = hash & (newCap - 1);
新掩码相比旧掩码只多出 oldCap 对应的那一位。因此新索引只有两种可能:
text
newIndex = oldIndex
或
newIndex = oldIndex + oldCap
10.3 (e.hash & oldCap) == 0 是什么含义?
java
if ((e.hash & oldCap) == 0) {
// 留在原索引 j
} else {
// 移到 j + oldCap
}
oldCap 本身是 2 的幂,它对应扩容时新增的那一位。
- 与运算结果为 0:新增位为 0,留在
j。 - 与运算结果非 0:新增位为 1,移动到
j + oldCap。
10.4 示例:16 扩到 32
旧桶索引为 5,扩容后只可能去 5 或 21:
text
原位置:5
新位置:5 + 16 = 21
源码通过 hash & 16 判断,不需要重新调用 key.hashCode(),也不必重新计算完整 hash。
10.5 链表拆分
JDK 8 将旧桶链表拆成两条:
lo链表:留在原索引j。hi链表:移动到j + oldCap。
通过 head/tail 方式拼接,保持各自链表中节点的相对顺序,并在结尾将尾节点的 next 置为 null。
10.6 扩容成本
扩容需要创建新数组并迁移已有节点,因此一次扩容是 O(n) 级别操作。不过在容量逐步翻倍的情况下,普通 put 的摊还时间复杂度仍通常为 O(1)。
11. 链表为什么转红黑树
JDK 8 常见常量:
java
TREEIFY_THRESHOLD = 8;
UNTREEIFY_THRESHOLD = 6;
MIN_TREEIFY_CAPACITY = 64;
需要理解的细节:
TREEIFY_THRESHOLD = 8是触发树化判断的阈值,不应简单说成"链表一到 8 个节点就一定树化"。- 在
putVal的链表插入逻辑中,binCount的计数方式会影响触发时机。 - 如果 table 容量小于 64,
treeifyBin()通常优先尝试扩容,而不是立即树化。 - 扩容拆分树桶时,如果拆分后的某一侧节点数较少,可能退化为链表;
UNTREEIFY_THRESHOLD常用于相关判断。
为什么红黑树可以改善性能?
- 链表查找最坏 O(n)。
- 红黑树保持近似平衡,查找通常为 O(log n)。
引入树结构也有额外内存和维护成本,所以只在冲突严重时采用。
12. JDK 7 与 JDK 8 的主要区别
| 对比点 | JDK 7(典型实现) | JDK 8 |
|---|---|---|
| 桶内结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 冲突严重时 | 链表可能很长 | 满足条件时树化 |
| 插入链表 | 典型实现使用头插 | 使用尾部追加,便于保持拆分顺序 |
| 扩容迁移 | 重新计算/定位元素位置 | 利用 hash & oldCap 将节点拆到原位置或 原位置 + oldCap |
| 并发安全 | 不安全 | 仍然不安全 |
不要把"JDK 8 解决了 HashMap 线程安全问题"当成结论:JDK 8 的 HashMap 依旧不是线程安全容器。
13. HashMap 为什么线程不安全
HashMap 没有为普通并发读写提供完整的同步保障。
多个线程同时修改可能出现:
- 更新覆盖、数据丢失。
size等内部状态不符合调用方预期。- 并发扩容或链表修改期间出现不一致的结构与读取结果。
- 读线程可能看不到另一个线程刚写入的值;没有同步关系时也不具备所需的可见性保证。
JDK 7 的某些并发扩容交错可能导致链表成环、读取时循环;JDK 8 改了迁移实现,但不代表并发修改变安全。
解决方式:
- 有同步需求时,使用
ConcurrentHashMap。 - 简单场景可用
Collections.synchronizedMap(new HashMap<>()),但遍历时也要按其要求同步。 - 多线程只读且安全发布、无后续修改的场景,需要根据具体对象生命周期判断。
14. HashMap 的 null 规则
HashMap 允许:
java
map.put(null, "A"); // 允许
map.put("name", null); // 允许
map.put("age", null); // 允许
nullkey 最多一个,因为 Map 中 key 不能重复。nullvalue 可以有多个。- JDK 8 的
hash(null)返回 0,null key 会映射到索引 0(因为索引计算为(n - 1) & 0)。
注意:get(key) == null 可能表示 key 不存在,也可能表示该 key 对应的 value 就是 null。可以用 containsKey(key) 区分。
15. 时间复杂度与性能
| 操作 / 情况 | 常见复杂度 | 说明 |
|---|---|---|
get / put 平均情况 |
O(1) | hash 分布较好、冲突较少 |
| 链表桶查找 | O(k) | k 为该桶链表长度 |
| 树化桶查找 | 通常 O(log k) | 红黑树结构 |
| 扩容 | O(n) | 需要迁移已有节点 |
| 全量遍历 | O(capacity + size) | 需要扫描 table 与元素 |
面试时不要无条件回答"HashMap 查询永远是 O(1)"。更准确的说法是:平均 O(1),具体取决于 hash 分布、桶结构和扩容等情况。
16. equals 和 hashCode 的契约
Java 的基本约定:
- 如果
a.equals(b)为true,则a.hashCode() == b.hashCode()必须成立。 hashCode()相同,不要求equals()为true。- 在参与比较的字段未改变时,多次调用
hashCode()应保持一致。
错误示例:只重写 equals(),却没有正确重写 hashCode()。逻辑上相等的对象可能被分配到不同桶,导致 Map 查找异常。
更重要的实践建议:不要修改已经作为 HashMap key 的对象中参与 equals() / hashCode() 计算的字段。
例如,插入时 id = 1,后来把 id 改成 2,查找时新 hash 可能定位到另一个桶,原映射就可能难以通过该 key 找到。
17. 迭代器与 fail-fast
HashMap 的迭代器通常使用 modCount 检测结构性修改。
- 新增或删除映射通常属于结构性修改。
- 只更新已有 key 的 value 通常不属于结构性修改。
- 迭代过程中检测到未通过迭代器自身允许的结构性修改,可能抛出
ConcurrentModificationException。
这叫 fail-fast(快速失败)。
重要:fail-fast 是尽力而为的错误检测机制,不能作为线程同步手段,也不保证每次并发修改都一定抛异常。
18. 如何正确初始化容量
如果预估要放入 expectedSize 个元素,且希望在插入期间尽量避免扩容,可以按负载因子估算所需容量,再让 HashMap 调整到合适的 2 的幂。
java
int expectedSize = 100;
float loadFactor = 0.75f;
int initialCapacity = (int) (expectedSize / loadFactor) + 1;
Map<Integer, String> map = new HashMap<>(initialCapacity);
这只是常见估算方式;极大容量要注意整数溢出和最大容量限制。不要简单认为 new HashMap<>(100) 就一定能无扩容存放 100 个元素,因为构造参数和最终阈值不是同一个概念。
19. 与 ConcurrentHashMap 的区别
| 对比点 | HashMap | ConcurrentHashMap(JDK 8) |
|---|---|---|
| 线程安全 | 否 | 是,支持并发访问 |
| 并发控制 | 无内置完整同步 | CAS、synchronized 等机制 |
null key/value |
允许 | 不允许 |
| 冲突结构 | 链表 / 红黑树 | 链表 / 红黑树 |
| 扩容 | 普通单线程迁移逻辑 | 可由多个线程协助扩容 |
| 适用场景 | 无并发修改或外部同步 | 多线程共享映射 |
ConcurrentHashMap 不是简单地给整个 HashMap 加一把大锁。JDK 8 通过 CAS、桶级同步以及协助扩容等机制提高并发度。
20. 高频面试题速答
Q1:HashMap 底层是什么?
JDK 8 中是 Node 数组,桶内使用链表或红黑树。数组用于快速定位桶,链表和红黑树处理冲突。
Q2:(h = key.hashCode()) ^ (h >>> 16) 是什么?
hash 扰动函数。将高 16 位信息混入低位,改善低位分布,降低部分冲突概率。
Q3:为什么用 (n - 1) & hash?
容量为 2 的幂时,n - 1 的低位全为 1,可以用位运算快速得到桶索引。
Q4:hash 相同就认为 key 相同吗?
不是。hash 只用于定位或缩小范围;还要通过引用相同 == 或 equals() 判断 key 是否相同。
Q5:为什么要重写 equals 和 hashCode?
让逻辑相等的对象具有一致的 hash 值,并能按业务定义判断相等,否则 Map 的查找和去重行为可能不符合预期。
Q6:默认容量和负载因子是多少?
常见默认值是容量 16、负载因子 0.75;内部 table 通常延迟到首次插入时初始化。
Q7:什么时候扩容?
新映射插入后,如果 size > threshold,通常触发扩容。默认容量 16、负载因子 0.75 时,初始阈值通常为 12。
Q8:JDK 8 扩容为什么看 hash & oldCap?
容量翻倍只新增一个索引位。该位为 0,节点留在原位置;为 1,节点移动到 原位置 + oldCap。
Q9:链表达到 8 个节点就一定树化吗?
不一定。树化触发判断还与插入计数有关;若数组容量小于 64,通常先尝试扩容。
Q10:HashMap 线程安全吗?
不安全。并发修改可能导致更新丢失、状态不一致等问题;共享并发场景应考虑 ConcurrentHashMap 或适当同步。
Q11:HashMap 为什么允许一个 null key?
JDK 8 对 null key 的 hash 直接返回 0,因此会映射到索引 0。Map 的 key 唯一性规则仍然适用,所以最多一个 null key。
Q12:get() 返回 null 代表什么?
可能 key 不存在,也可能 key 存在但 value 为 null。使用 containsKey() 可进一步判断。
Q13:HashMap 的迭代顺序稳定吗?
不保证。不要依赖它的遍历顺序;需要可预测顺序时,考虑 LinkedHashMap 或 TreeMap,具体按需求选择。
Q14:扩容时为什么不用重新调用 key.hashCode()?
节点已经保存了扰动后的 hash 字段。JDK 8 可以利用这个 hash 和新增索引位完成迁移,不需要再次调用 key 的 hashCode()。
Q15:把可变对象当 key 有什么风险?
如果参与 equals() / hashCode() 的字段在插入后发生变化,查找时可能定位到不同桶,从而无法按预期找到原映射。
21. 源码阅读路线与总结
建议按以下顺序阅读 JDK 8 的 HashMap:
hash(Object key):看扰动函数。put()/putVal():看桶定位、key 相等判断、链表与树插入。get()/getNode():看查找。resize():看扩容与链表拆分。treeifyBin():看树化条件。TreeNode相关方法:了解红黑树插入、查找、拆分。HashIterator:看迭代器和modCount。
最后把整个过程背成一条链:
text
put(key, value)
|
v
hashCode()
|
v
h ^ (h >>> 16)
|
v
(n - 1) & hash
|
v
定位 table[index]
|
+-- 空桶:创建节点
|
+-- 冲突:hash + == / equals
|
+-- 相同 key:更新 value
|
+-- 不同 key:链表 / 红黑树插入
|
v
新映射使 size 增加
|
v
size > threshold?
|
+-- 是:resize()
|
v
hash & oldCap
|
+-- 0:留在原位置
|
+-- 非 0:移动到原位置 + oldCap
面试优先级建议:
- 必须讲熟: 底层结构、hash 扰动、索引计算、
putVal()、equals/hashCode、扩容。 - 高级开发要会: 红黑树条件、负载因子、JDK 7/8 区别、null 规则、fail-fast。
- 专家岗继续深挖:
resize()的链表拆分、红黑树退化、并发问题、ConcurrentHashMap 的 CAS /synchronized/ 协助扩容。
复习时尽量结合源码自己手推一个例子:容量从 16 扩到 32,某个桶中的多个节点分别根据 hash & 16 留在原位置或移动到 原位置 + 16。能解释清楚这一步,就真正理解了 JDK 8 HashMap 扩容的关键设计。