HashMap
是 Java 集合框架中最核心、最常用的数据结构之一。它基于哈希表实现,提供了接近 O(1) 的平均时间复杂度的 put
和 get
操作。然而,当数据量增长时,HashMap
会触发扩容(Resize) 机制,这个过程直接影响性能。更复杂的是,从 Java 8 开始,HashMap
引入了红黑树来优化极端情况下的性能。
一、HashMap
的核心结构
在深入扩容之前,我们必须理解 HashMap
的基本组成。
1. 核心成员变量
// 代码块
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 实际存储元素的数组,每个元素是一个 Node(或 TreeNode)的链表/树的头
transient Node<K,V>[] table;
// 当前 HashMap 中元素的数量
transient int size;
// 扩容的阈值,当 size > threshold 时触发扩容
int threshold;
// 负载因子(Load Factor)
final float loadFactor;
}
table
:哈希桶数组,是HashMap
的物理存储基础。size
:记录当前存储的键值对数量。threshold
:扩容阈值,计算公式为capacity * loadFactor
。loadFactor
:负载因子,衡量哈希表填满程度的指标。
二、扩容机制:何时与如何
1. 触发条件
HashMap
在每次 put
操作后,都会检查是否需要扩容:
// 代码块
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// ... 插入逻辑
if (++size > threshold)
resize(); // 触发扩容
// ...
}
核心公式: threshold = capacity * loadFactor
当 size > threshold
时,HashMap
会调用 resize()
方法进行扩容。
2. 扩容过程(Java 1.8)
resize()
方法是 HashMap
的心脏,其逻辑非常精妙。
步骤 1:计算新容量
- 首次初始化:如果
table
为null
,则使用构造函数指定的初始容量(默认DEFAULT_INITIAL_CAPACITY = 16
)。 - 非首次扩容:新容量是旧容量的 2 倍。
// 代码块 int newCap = oldCap << 1; // 左移一位,等价于乘以 2
步骤 2:重新计算阈值
新的 threshold
也翻倍:
// 代码块
int newThr = oldThr << 1;
步骤 3:创建新数组
// 代码块
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; // 将新数组赋值给 table
步骤 4:元素迁移(Rehashing)
这是最耗时的一步。HashMap
需要将旧数组中的所有元素重新计算哈希值,并放入新数组的正确位置。
关键优化(Java 1.8):由于新容量是旧容量的 2 倍(即 2^n
),HashMap
利用位运算的特性,避免了对每个元素都重新计算完整的哈希值。
- 核心洞察:一个元素在新数组中的索引,要么和在旧数组中一样,要么是
旧索引 + 旧容量
。 - 判断依据:通过检查元素的哈希值在
oldCap
对应的位(即oldCap
的最高位)是 0 还是 1。- 如果该位是 0,新索引 = 旧索引。
- 如果该位是 1,新索引 = 旧索引 + 旧容量。
// 代码块
// Java 1.8 中的迁移逻辑(简化)
Node<K,V> loHead = null, loTail = null; // 存放低位索引的链表
Node<K,V> hiHead = null, hiTail = null; // 存放高位索引的链表
for (Node<K,V> e = oldTab[j]; e != null; e = next) {
int h;
// 关键!利用 hash & oldCap 判断位置
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;
}
}
// 将低位链表放在新数组的 j 位置
newTab[j] = loHead;
// 将高位链表放在新数组的 j + oldCap 位置
newTab[j + oldCap] = hiHead;
这种“高位低位分离”的策略,将 O(n)
的 rehashing
计算简化为了 O(n)
的位运算,性能提升巨大。
三、Java 1.7 与 1.8 的关键差异
特性 | Java 1.7 | Java 1.8 |
---|---|---|
底层结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
扩容后迁移 | 对每个元素重新计算 indexFor() | 利用 hash & oldCap 判断,分为低位/高位链表 |
头插法 vs 尾插法 | 头插法(导致扩容时链表反转,可能形成环) | 尾插法(保持链表顺序,线程安全) |
多线程问题 | 扩容时头插法易形成环,get() 可能死循环 | 尾插法避免了环,但仍非线程安全 |
头插法的危害:在多线程环境下,两个线程同时进行扩容,头插法可能导致两个节点互相指向,形成环形链表,
get()
操作会陷入无限循环。
四、深度解析:那些“神秘”参数的奥秘
1. 负载因子 0.75
- 默认值:
DEFAULT_LOAD_FACTOR = 0.75f
- 含义:当哈希表的填充率达到 75% 时,触发扩容。
- 为什么是 0.75?
- 空间与时间的权衡:负载因子越小,哈希冲突越少,查询性能越好,但浪费的内存空间越多。负载因子越大,空间利用率高,但哈希冲突概率增加,链表变长,性能下降。
- 泊松分布(Poisson Distribution):
HashMap
的作者基于泊松分布的理论分析得出,当负载因子为0.75
时,哈希冲突的期望值和链表长度的分布达到一个非常好的平衡点。在0.75
负载下,链表长度超过 8 的概率极低(远小于百万分之一),这为后续引入红黑树提供了理论基础。 - 经验值:大量的实践和测试表明,
0.75
是一个在绝大多数场景下性能最优的折中值。
2. 链表转红黑树的阈值 8
- 阈值:
TREEIFY_THRESHOLD = 8
- 含义:当一个桶(bucket)中的链表长度达到 8 时,尝试将其转换为红黑树。
- 为什么是 8?
- 泊松分布的推论:如前所述,在负载因子为
0.75
时,一个桶中链表长度达到 8 的概率已经非常非常小(约为 0.00000006)。这意味着,达到 8 个节点的情况非常罕见,通常是哈希函数不够好或数据分布极端导致的。 - 性能拐点:对于短链表(如长度小于 8),遍历的时间复杂度 O(n) 是可以接受的,且链表的内存开销小。而红黑树的节点需要额外的
prev
、left
、right
、red
等字段,内存开销大。当链表长度达到 8 时,红黑树 O(log n) 的查询性能优势开始明显超过其内存开销劣势。 - 避免过度转换:设置一个较高的阈值,可以防止在链表长度波动时频繁地在链表和红黑树之间转换,减少不必要的开销。
- 泊松分布的推论:如前所述,在负载因子为
3. 红黑树转回链表的阈值 6
- 阈值:
UNTREEIFY_THRESHOLD = 6
- 含义:当一个桶中的红黑树节点数减少到 6 时,将其转换回链表。
- 为什么是 6? 这是一个“迟滞”(Hysteresis)设计。如果
treeify
和untreeify
的阈值相同(比如都是 7),那么在节点数在 7 附近频繁增减时,会反复触发转换,造成性能抖动。设置untreeify
阈值(6)低于treeify
阈值(8),形成了一个“缓冲区”,只有当节点数显著减少时才转回链表,避免了不必要的转换。
4. 最小树化容量 64
- 阈值:
MIN_TREEIFY_CAPACITY = 64
- 含义:只有当
HashMap
的总容量(capacity
)达到 64 时,才会将链表转换为红黑树。即使链表长度达到了 8,如果capacity < 64
,也只会进行扩容。 - 为什么是 64?
- 优先扩容:在容量较小时,通过扩容(
resize
)可以更有效地分散哈希冲突,降低链表长度。扩容的代价相对较低。 - 避免过早树化:在小容量下就引入红黑树,会带来不必要的复杂性和内存开销。
64
是一个经验值,认为在此容量以上,哈希分布应该已经比较均匀,如果仍有长链表,则更可能是数据本身的问题,此时树化更有意义。
- 优先扩容:在容量较小时,通过扩容(
五、红黑树的形成:一个完整的流程
让我们用一个例子来说明红黑树是如何形成的:
- 初始状态:
HashMap
创建,capacity=16
,threshold=12
。 - 持续插入:不断
put
元素,size
增加。 - 首次扩容:当
size > 12
时,resize()
,capacity=32
,threshold=24
。 - 继续插入:
size
继续增加。 - 再次扩容:当
size > 24
时,resize()
,capacity=64
,threshold=48
。 - 链表增长:假设由于哈希碰撞,某个桶(bucket)中的链表长度不断增长。
- 达到阈值:当该桶的链表长度达到 8,且此时
capacity >= 64
,HashMap
会调用treeifyBin()
方法,将这个链表转换为红黑树。 - 树化完成:此后,对该桶的
put
和get
操作,都将基于红黑树进行,时间复杂度为 O(log n)。
六、总结
HashMap
的设计是空间、时间、复杂性三者精妙平衡的典范。
- 扩容机制:从 Java 1.7 到 1.8,
resize()
通过尾插法和hash & oldCap
优化,变得高效且安全。 - 参数奥秘:
0.75
是空间与时间的最佳平衡点。8
是链表与红黑树性能的拐点,基于泊松分布。6
是为了避免频繁转换的“迟滞”设计。64
是优先扩容、避免过早树化的最小容量。
- 红黑树:作为极端情况的“安全网”,在链表过长且容量足够大时,将查询性能从 O(n) 提升到 O(log n)。
理解了这些底层原理,你不仅能写出更高效的代码,还能在面试中从容应对各种 HashMap
的灵魂拷问。