一. HashMap简介
1.1 什么是 HashMap
HashMap 是 Java 集合框架中基于哈希表(Hash Table)实现的 Map 接口实现类,自 JDK 1.2 引入。它以 key-value(键值对) 的形式存储数据,通过 key 的哈希值快速定位存储位置,在理想情况下实现 O(1) 时间复杂度的插入、查询和删除。
一句话总结:HashMap 是一个以哈希函数为核心、用空间换时间的键值对容器。
1.2 HashMap 的特点
使用之前,需要先明确它的几个核心特点:
- key 唯一,value 可重复:不允许重复的 key,对已存在的 key 再次 put 会覆盖旧值;value 则没有这个限制。
- 允许 null:允许 null key 和 null value。null key 最多只能有一个(JDK 8 中它的哈希值为 0,落在 table0 位置),null value 可以有多个。
- 不保证顺序:HashMap 不保证元素的存储顺序,且扩容(resize)后顺序还可能发生改变。需要有序时可选 LinkedHashMap(保持插入顺序)或 TreeMap(按键排序)。
- 非线程安全:多线程并发读写会引发数据不一致,JDK 7 甚至在并发扩容时可能出现死循环。并发场景应使用 ConcurrentHashMap。
- 性能:平均时间复杂度 O(1);极端哈希冲突下,JDK 8 之前会退化为 O(n),JDK 8 之后链表过长会自动转红黑树,最坏为 O(log n)。
1.3 基本用法
java
Map<String, Integer> map = new HashMap<>();
// 插入
map.put("apple", 1);
map.put("banana", 2);
// 重复 key 会覆盖旧值
map.put("apple", 3); // apple 的值变为 3
// 查询
Integer value = map.get("apple"); // 3
boolean has = map.containsKey("banana"); // true
// 删除
map.remove("banana");
// 遍历方式一:entrySet
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + " = " + entry.getValue());
}
// 遍历方式二:forEach(JDK 8+)
map.forEach((k, v) -> System.out.println(k + " = " + v));
// 其他常用方法
int size = map.size(); // 键值对数量
boolean empty = map.isEmpty();
map.clear();
适用场景
- 缓存:按 ID 缓存用户信息、配置项;
- 计数与去重:词频统计、元素去重;
- 索引映射:将业务对象与唯一标识互相映射;
- 作为基础组件:HashSet 的底层实现就是 HashMap,很多框架源码也大量使用它。
二. HashMap 的底层数据结构
JDK 8 的 HashMap 底层采用 数组 + 链表 + 红黑树 的三层结构,这也是它最核心的设计:

各部分职责:
数组(table):Node<K,V>\[\] table,每个位置称为一个桶(bucket),默认初始容量16;链表(Node):当多个 key 哈希后落到同一个桶,就以链表形式串起来,解决哈希冲突;红黑树(TreeNode):当链表过长时转换为红黑树,把查询性能从 O(n) 优化到 O(log n)。
2.2 数据是如何落位的
插入时,HashMap 通过 key 的哈希值计算桶下标,核心两步:
java
// 第一步:扰动 hash,让高位也参与运算(后面源码章节详解)
hash = (h = key.hashCode()) ^ (h >>> 16);
// 第二步:用位运算代替取模,得到桶下标
index = (n - 1) & hash; // n 为数组长度,恒为 2 的幂
两个 key 的哈希值相同(或计算出的下标相同),就发生了哈希冲突,此时数据以链表(或红黑树)的形式挂在同一个桶下。
2.3 为什么引入红黑树
JDK 8 之前,HashMap 只有「数组 + 链表」。一旦哈希函数设计不好或 key 的 hashCode() 分布不均匀,链表会越来越长,查询退化成 O(n),性能断崖式下降。
JDK 8 引入红黑树的思路是:链表过长时,用自平衡二叉查找树替换链表,把最坏情况下的查询、插入、删除从 O(n) 降到 O(log n)。
但树化不是无条件的,必须同时满足两个阈值:
| 阈值常量 | 值 | 含义 |
|---|---|---|
TREEIFY_THRESHOLD |
8 | 链表长度达到 8 时尝试树化 |
MIN_TREEIFY_CAPACITY |
64 | 数组长度必须 ≥ 64 才真正树化,否则先扩容 |
2.4 JDK 7 与 JDK 8 的结构差异
| 对比项 | JDK 7 | JDK 8 |
|---|---|---|
| 存储结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 节点类型 | Entry<K,V> |
Node<K,V> / TreeNode |
| 新节点插入方式 | 头插法 | 尾插法 |
| 哈希函数 | 4 次扰动 | 1 次扰动(>>> 16) |
| 扩容后 rehash | 重新计算每个 key 的 hash | 利用 2 的幂特性高位拆分,效率更高 |
其中最有故事性的是头插法改尾插法:JDK 7 在并发扩容时,链表反转可能形成环,导致 get 陷入死循环(CPU 100%)。JDK 8 改成尾插法后,这个问题从结构上被消除了,但 HashMap 依旧不是线程安全的------并发下仍有数据丢失、覆盖等问题。
三. HashMap 的继承体系

3.1 继承 AbstractMap 的意义
AbstractMap<K,V> 是一个模板抽象类,它实现了 Map 接口,并基于唯一抽象方法 entrySet() 提供了大多数操作的默认实现:
- get()、containsKey():遍历 entrySet() 查找;
- put():默认直接抛 UnsupportedOperationException,要求子类必须重写;
- size()、isEmpty()、containsValue() 等:都有基于 entrySet() 的骨架实现。
也就是说,写一个新的 Map 实现类时,只要实现 entrySet() 和 put(),就能获得一整套基础功能。HashMap 虽然几乎把所有方法都重写成了高性能版本,但骨架复用的设计思想值得借鉴。
3.2 实现的三个接口
Map<K,V>:定义键值对容器的契约,包括 put、get、remove、entrySet 等核心方法,以及内部接口 Map.Entry<K,V>(键值对节点);Cloneable:标记接口,表示支持克隆。HashMap 重写了 clone(),返回一个浅拷贝:新 Map 拥有独立的 table 结构,但 key 和 value 对象本身是共享的;Serializable:支持序列化。注意 HashMap 的 table 字段声明为 transient,并自定义了 writeObject / readObject------因为桶的下标由哈希函数和容量共同决定,直接序列化内部数组既浪费空间又容易受 JVM 版本影响,所以改为只序列化键值对,反序列化时再重建哈希表。
3.3围绕 HashMap 的两个重要类
- LinkedHashMap(继承)
java
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
LinkedHashMap 在 HashMap 基础上额外维护了一条双向链表记录插入顺序(或访问顺序),并重写了 newNode()、afterNodeAccess()、afterNodeInsertion()、afterNodeRemoval() 等钩子方法来实现。这些方法在 HashMap 源码里几乎都是空实现,设计目的就是留给子类扩展------这也是为什么 HashMap 内部节点被称为 Node、而树节点 TreeNode 继承自 LinkedHashMap.Entry。
- HashSet(组合,而非继承)
java
public class HashSet<E> extends AbstractSet<E> {
private transient HashMap<E,Object> map; // 内部持有一个 HashMap
private static final Object PRESENT = new Object(); // 所有 value 共用的占位对象
}
HashSet.add(e) 本质就是 map.put(e, PRESENT)。所以 HashSet 的迭代顺序、性能特性都完全取决于底层的 HashMap,这也是理解集合框架时「Set 与 Map 相通」的关键。
四. 核心源码深度解析
4.1 成员变量
先总览全部成员变量:
| 类型 | 成员变量 | 值/默认值 | 作用 |
|---|---|---|---|
| 常量 | serialVersionUID |
362498820763181265L |
序列化版本号 |
| 常量 | DEFAULT_INITIAL_CAPACITY |
1 << 4(16) |
默认初始容量 |
| 常量 | MAXIMUM_CAPACITY |
1 << 30 |
容量上限 |
| 常量 | DEFAULT_LOAD_FACTOR |
0.75f |
默认加载因子 |
| 常量 | TREEIFY_THRESHOLD |
8 | 链表转红黑树阈值 |
| 常量 | UNTREEIFY_THRESHOLD |
6 | 红黑树转链表阈值 |
| 常量 | MIN_TREEIFY_CAPACITY |
64 | 允许树化的最小数组容量 |
| 字段 | table |
null |
存储节点的数组(桶) |
| 字段 | entrySet |
null |
entrySet() 视图缓存 |
| 字段 | size |
0 | 键值对数量 |
| 字段 | modCount |
0 | 结构性修改次数 |
| 字段 | threshold |
0 | 扩容临界值 |
| 字段 | loadFactor |
0.75f |
加载因子(final) |
4.1.1 序列化版本号
java
private static final long serialVersionUID = 362498820763181265L;
serialVersionUID 是 Java 序列化机制要求的版本号。序列化和反序列化时,JVM 会比对两边的版本号,不一致会抛出 InvalidClassException。HashMap 自定义了序列化逻辑(table 是 transient,见下文),所以需要显式声明它,保证版本升级后仍能反序列化旧数据。
4.1.2 容量相关:为什么必须是 2 的 n 次幂
java
// 默认初始容量 16:1 << 4 相当于 1 * 2^4
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 容量上限:2 的 30 次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
容量上限是 2^30 而不是 2^31,是因为 int 是带符号的,最高位是符号位。
为什么容量必须是 2 的 n 次幂? 因为 HashMap 定位桶下标时用的不是取模运算 hash % length,而是位运算:
index = hash & (length - 1);
两者在 length 是 2 的幂时完全等价(hash & (length - 1) == hash % length),但位运算效率远高于取模。更深一层,length - 1 的二进制形式是「n 个 1」,这样 hash 的低位都能参与定位,结果可以覆盖 0 ~ length-1 的全部位置,数据分布更均匀。
举个反例对比:
length = 8(2^3),length - 1 = 7 = 0111
hash=3: 0011 & 0111 = 0011 → 下标
3 hash=2: 0010 & 0111 = 0010 → 下标 2
(落在不同位置,不碰撞)
length = 9(非 2 的幂),length - 1 = 8 = 1000
hash=3: 0011 & 1000 = 0000 → 下标 0
hash=2: 0010 & 1000 = 0000 → 下标 0
(都落在 0,碰撞)
可以看到,length 不是 2 的幂时,length - 1 的低位存在恒为 0 的位,导致部分桶永远不会被使用,既浪费数组空间,又加剧哈希冲突、拉长链表,性能随之下降。这就是「容量必须是 2 的 n 次幂」的根本原因。
如果用户传入的不是 2 的幂呢? 比如 new HashMap(10),HashMap 不会报错,而是通过 tableSizeFor() 把它"修正"为大于等于该值的最小 2 的幂:
java
public HashMap(int initialCapacity) { // initialCapacity = 10
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 构造方法内部:
this.threshold = tableSizeFor(initialCapacity); // 10 → 16
static final int tableSizeFor(int cap) { // cap = 10
int n = cap - 1; // n = 9
n |= n >>> 1; // 9 → 13
n |= n >>> 2; // 13 → 15
n |= n >>> 4; // 15 → 15
n |= n >>> 8; // 不变
n |= n >>> 16; // 不变
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这个算法的精妙之处在于:
- 先 cap - 1:防止 cap 本身是 2 的幂时结果翻倍。比如 cap = 8,如果不减 1,最终会得到 16;减 1 后得到 8。
- 连续无符号右移 + 按位或:把最高位的 1 不断向右"复制",让二进制变成「连续的 1」,即 2^k - 1 的形式,最后 + 1 得到 2^k。
4.1.3 树化相关:为什么是 8、6、64
java
// 桶上链表长度达到 8 时,尝试转红黑树(JDK 8 新增)
static final int TREEIFY_THRESHOLD = 8;
// 桶上红黑树节点数降到 6 时,转回链表
static final int UNTREEIFY_THRESHOLD = 6;
// 只有数组长度 ≥ 64 时才允许树化,否则先扩容
static final int MIN_TREEIFY_CAPACITY = 64;
为什么选 8? 官方注释给出的理由是概率统计。在加载因子 0.75、哈希函数良好的前提下,桶内链表长度服从泊松分布,长度为 k 的期望出现次数为:
(exp(-0.5) * pow(0.5, k)) / k!
代入 k = 8,概率约为 0.00000006,即千万分之六,属于几乎不可能的事件。也就是说:链表长到 8 说明哈希冲突已经极其严重,此时牺牲一点空间换性能是值得的。
补充一个工程视角的解读:红黑树平均查找长度为 log(n),链表为 n/2。长度为 8 时,log(8)=3 < 8/2=4,转树划算;而长度为 6 时,log(6)≈2.6 与 6/2=3 差距不大,转树收益有限,还要承担构建树的开销。8 和 6 之间留出 1 的缓冲,可以避免在阈值附近反复「树化 ↔ 退化」造成的性能抖动。
为什么树化还要看数组长度? MIN_TREEIFY_CAPACITY = 64,且官方要求它不能小于 4 * TREEIFY_THRESHOLD。含义是:即使某个桶的链表长度到了 8,只要数组长度还小于 64,就先走扩容而不是树化------因为扩容会把链表拆散,往往比建树更划算,两者不能互相干扰。
4.1.4 加载因子与扩容临界值
java
// 默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 实例字段:加载因子,构造时指定后不可修改
final float loadFactor;
// 扩容临界值:threshold = capacity * loadFactor
int threshold;
loadFactor 用来衡量 HashMap 的"疏密程度",实时加载因子 = size / capacity(注意分子是键值对总数,而不是被占用的桶数)。
- 加载因子太大(趋近 1):数据越密,链表变长,查找效率下降;
- 加载因子太小(趋近 0):数据稀疏,数组利用率低,频繁扩容;
- 默认 0.75 是官方经过大量测试得出的平衡点。
hreshold 就是「容量 × 加载因子」算出的临界值。当 size >= threshold 时触发 resize() 扩容,新容量是旧容量的 2 倍。扩容涉及 rehash、复制数据,非常消耗性能,所以开发中可以通过指定初始容量来减少扩容次数:
java
// 预估存储 1000 条数据时,直接给足容量,避免中途多次扩容
Map<String, Integer> map = new HashMap<>(1000 / 0.75 + 1);
4.2 构造方法
HashMap 一共有 4 个构造方法,初看都很简单,但有两个共同点值得先记住:
- 所有构造方法都不初始化数组 table,真正的数组是在第一次 put 时才创建(懒加载);
- 构造阶段的主要工作只有两件:校验参数、设置 loadFactor 和 threshold。
| 构造方法 | 初始容量 | 加载因子 | 适用场景 |
|---|---|---|---|
HashMap() |
16(默认) | 0.75(默认) | 无法预估数据量 |
HashMap(int initialCapacity) |
指定值(自动修正为 2 的幂) | 0.75 | 能预估数据量 |
HashMap(int, float) |
指定值 | 指定值 | 需要自定义加载因子 |
HashMap(Map<? extends K, ? extends V> m) |
由传入 Map 决定 | 0.75 | 拷贝/转换已有 Map |
4.2.1 无参构造方法
java
public HashMap() {
// 只把默认加载因子 0.75 赋值给 loadFactor,并没有创建数组
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
无参构造只做一件事:设置 loadFactor = 0.75。此时 table = null、threshold = 0、size = 0,数组留到第一次 put 时才初始化。
4.2.2 指定初始容量
java
// 指定"初始容量大小"的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
它没有自己的逻辑,直接委托给下面的 HashMap(int, float) 构造方法,加载因子沿用默认值 0.75。
4.2.3 指定初始容量和加载因子
这是校验逻辑最完整的构造方法,也是面试常考的一个:
java
public HashMap(int initialCapacity, float loadFactor) {
// 1. 容量不能为负数
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 2. 容量超过上限时,直接截断为最大值 2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 3. 加载因子必须大于 0 且是合法数值
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 4. 赋值加载因子
this.loadFactor = loadFactor;
// 5. 把容量修正为 2 的幂,并"暂时"存入 threshold
this.threshold = tableSizeFor(initialCapacity);
}
三步校验分别对应三种非法输入:负容量、超大容量、非法加载因子。这里有个小细节:为什么单独判断 Float.isNaN(loadFactor)?因为 NaN 与任何数比较结果都是 false,loadFactor <= 0 根本拦不住它。
经典疑问:为什么结果是tableSizeFor(initialCapacity),而不是tableSizeFor(initialCapacity) * loadFactor?按照 threshold = 容量 × 加载因子 的定义,很多人第一反应是这里写错了,应该乘上 loadFactor才对。其实这是 JDK 8 的刻意设计: 构造方法不初始化 table,数组初始化被推迟到第一次 put 时执行的 resize() 中;
resize() 的第一件事就是 newCap = oldThr,把 threshold 里"暂存"的容量值取出来当新数组长度;然后再重新计算真正的 threshold = newCap * loadFactor。
所以这里的 threshold 只是临时借宿了一下容量值,等 resize() 执行时就会"物归原主"。
4.2.4 包含另一个 Map 的构造方法
java
// 构造一个映射关系与指定 Map 相同的新 HashMap
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
加载因子用默认值 0.75,核心逻辑在 putMapEntries() 里:
java
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size(); // 源 Map 的元素个数
if (s > 0) {
// 情况一:table 尚未初始化(当前就是通过构造方法调用进来的)
if (table == null) { // pre-size
// 预估容量:元素数 ÷ 加载因子,+1.0F 向上取整,避免容量不够
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 算出的容量比当前 threshold 大,才需要更新(防止覆盖构造时已设好的值)
if (t > threshold)
threshold = tableSizeFor(t);
}
// 情况二:table 已初始化(通过 clone/其他途径调用),元素数超过阈值则扩容
else if (s > threshold)
resize();
// 逐个把元素放入新 HashMap
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
为什么 ft 要加 1.0F?
(int)ft 是向下取整,而 s / loadFactor 得到的是"刚好装下 s 个元素"的理论容量。如果恰好是个 2 的幂(比如 6 / 0.75 = 8),tableSizeFor(8) = 8,容量 8、阈值 8 × 0.75 = 6------插入 6 个元素后 size 就顶到阈值了,下次再 put 立刻触发扩容,白白损失性能。
加 1.0F 做一次向上取整,能保证容量留出余量:s = 6
不加 +1.0F:ft = 8.0 → tableSizeFor(8) = 8 → 容量 8,阈值 6,马上又要扩容
加上 +1.0F:ft = 9.0 → tableSizeFor(9) = 16 → 容量 16,阈值 12,余量充足
(evict 参数与 LinkedHashMap 的"最近最少使用"淘汰钩子有关,普通使用场景传 false,可以忽略。)
4.3 成员方法
4.3.1 hash():哈希函数
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
两个关键点:
支持 null key。 key 为 null 时哈希值固定为 0,所以 null key 会落在 table0 这个桶里,且只能有一个。而 Hashtable 直接调用 key.hashCode(),key 为 null 直接抛 NullPointerException。扰动函数:高 16 位与低 16 位异或。 为什么多此一举?因为桶下标的计算是:
index = (n - 1) & hash;
当数组很小时(比如默认 16),n - 1 只有低 4 位是 1,意味着只有 hash 的低 4 位真正参与了定位,高位信息全部浪费。如果某个自定义对象的 hashCode() 高位变化很大、低位却几乎不变,就会大量碰撞到同一个桶。
举个例子(n = 16,未扰动):
hashCode(): 1111 1111 1111 1111 1111 0000 1110 1010
n - 1 = 15: 0000 0000 0000 0000 0000 0000 0000 1111
& 结果: 0000 0000 0000 0000 0000 0000 0000 1010 → 下标 10
只要低位是 1010,无论高位怎么变,算出来都是下标 10。而 h ^ (h >>> 16) 把高位"混入"低位后,高位的变化就能影响最终下标,让分布更均匀。相比 JDK 7 的多次扰动,JDK 8 只做一次异或,配合红黑树已经足够,兼顾了性能与散列质量。
4.3.2 put() / putVal():插入元素

对外只暴露 put,真正干活的是私有的 putVal:
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal 的完整流程可以归纳为五步:
-
定位:i = (n - 1) & hash 算出桶下标;
-
桶为空:直接 newNode 放入;
-
桶非空(冲突):桶首节点 key 相同 → 记录旧节点;
- 桶是红黑树 → 调用 putTreeVal() 插入树中;
- 桶是链表 → 尾插遍历,链表长度达到 8 调用 treeifyBin() 转树;
-
存在相同 key:替换 value,返回旧值;
-
新增完成:++size > threshold 时调用 resize() 扩容。
完整源码解读:
java
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// ① table 为空:第一次 put,触发 resize() 完成数组初始化(懒加载)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// ② 桶为空:无冲突,直接创建新节点放入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else { // ③ 桶非空:发生哈希冲突
Node<K,V> e; K k;
// 桶首节点:hash 和 key 都相等,说明是同一个 key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 红黑树节点:走树的插入逻辑
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 链表节点:尾插法遍历
else {
for (int binCount = 0; ; ++binCount) {
// 到达链表尾部,说明没有重复 key,插入新节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表长度达到 8(binCount = 7 时恰好第 8 个节点)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 链表中间找到相同 key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 继续向后遍历
}
}
// ④ 找到了相同 key:替换 value,返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // LinkedHashMap 钩子
return oldValue; // 覆盖不算结构性修改,modCount 不加
}
}
// ⑤ 真正新增了一个键值对
++modCount; // 结构性修改 +1
if (++size > threshold) // 超过阈值则扩容
resize();
afterNodeInsertion(evict); // LinkedHashMap 钩子
return null; // 新增返回 null
}
几个容易忽略的细节:
尾插法(JDK 8):新节点一律追加到链表末尾,而 JDK 7 是头插法。头插法在并发扩容时会把链表反转成环,导致死循环,尾插法从结构上规避了这个问题(但 HashMap 依然非线程安全);binCount 从 0 开始:桶首节点算第 1 个,binCount = 7 时插入的是第 8 个节点,所以"链表长度到 8 触发树化";覆盖旧值不算结构性修改:modCount 不变,所以遍历过程中"更新已有 key"不会抛 ConcurrentModificationException;- onlyIfAbsent 对应 putIfAbsent():为 true 时只在 key 不存在时写入;evict 是留给 LinkedHashMap 的淘汰钩子。
4.3.3 treeifyBin():链表转红黑树
java
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 数组太短:先扩容,不树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 把链表节点逐个替换为 TreeNode,先串成双向链表
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// 交给 TreeNode.treeify() 真正构建红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
注意这里再次体现了 4.1 讲过的权衡:数组长度 < 64 时,先扩容而不是树化。因为扩容后哈希被重新分散,链表大概率会被拆短,比建树更划算;只有数组足够长还发生严重冲突,才值得用红黑树兜底。
4.3.4 resize():扩容机制(重点)
先回答两个问题:
什么时候扩容? 当 size > threshold 时(threshold = capacity × loadFactor,默认 16 × 0.75 = 12)。扩容是什么? 容量翻倍,并把所有元素重新分配到新数组------这一步遍历全部元素,非常耗时,所以开发中应尽量预估容量、减少扩容次数。
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。

既不用重新计算 hash,又能把一条链表均匀拆成两条,只需要看看原来的hash值新增的那个bit是1还是0就可以了,是0的话索引没变,是1的话索引变成"原索引+oldCap(原位置+旧容量)"。

下面看源码:
java
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) {
// 已达上限:不再扩容,阈值放大到 int 最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 常规扩容:容量翻倍,阈值也翻倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// 构造方法"暂存"的容量此时派上用场
else if (oldThr > 0)
newCap = oldThr;
// 无参构造首次 put:使用默认值
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
// ② 补充计算阈值(如 oldCap < 16 或走 oldThr 分支时)
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// ③ 创建新数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// ④ 迁移旧数据
if (oldTab != null) {
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;
// 红黑树:split 拆分(节点数降到 6 会退化为链表)
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 链表:按 (e.hash & oldCap) 拆成 lo/hi 两条
else {
Node<K,V> loHead = null, loTail = null; // 留在原下标
Node<K,V> hiHead = null, hiTail = null; // 移到 j + oldCap
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null) loHead = e;
else loTail.next = e;
loTail = e;
} else {
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { loTail.next = null; newTab[j] = loHead; }
if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
}
}
}
}
return newTab;
}
这段代码同时回答了两个伏笔:
- 构造方法里 threshold = tableSizeFor(initialCapacity) 暂存的容量,正是在 else if (oldThr > 0) newCap = oldThr 这里"物归原主";
- UNTREEIFY_THRESHOLD = 6 则是在红黑树 split() 时决定是否退化为链表。
4.3.5 remove():删除元素
remove 的实现在 removeNode 中:
java
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 定位桶,桶为空直接返回 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 桶首节点就是要删的 key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 红黑树:从树中查找
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// 链表:遍历查找
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 找到节点(且 value 匹配,仅 remove(key, value) 双参版本校验)
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) // 删的是头节点
tab[index] = node.next;
else // 删的是链表中间节点
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
删除分三种情况:头节点直接 tabindex = node.next;中间节点通过 p.next = node.next 摘除;树节点走 removeTreeNode(节点数降到 6 以下会自动退化为链表)。
4.3.6 get():查找元素
java
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))))
return first;
if ((e = first.next) != null) {
// 红黑树:getTreeNode → find 折半查找,O(log n)
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 链表:逐个比较,O(n)
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
查找逻辑与插入完全对称:先比 hash,hash 相同再比 key(地址相同或 equals 相等)。红黑树的 find() 按 hash 大小走左/右子树,基本是折半查找,效率远高于链表。这也提醒我们:自定义对象作为 key 时必须正确重写 hashCode() 和 equals(),否则相同的业务 key 会算成不同的桶或永远匹配不上。
4.3.7 遍历方式
| 方式 | 代码 | 适用场景 |
|---|---|---|
| 分别遍历 key / value | for (K k : map.keySet()) / map.values() |
只需要 key 或 value |
| entrySet | for (Map.Entry<K,V> e : map.entrySet()) |
同时需要 key 和 value(首选) |
| Iterator | Iterator<Map.Entry<K,V>> it = map.entrySet().iterator() |
遍历中需要 remove() |
| forEach | map.forEach((k, v) -> ...) |
JDK 8+,最简洁 |