HashMap 是 Java 集合框架中最核心、最常用的数据结构之一。它基于哈希表实现,提供了接近 O(1) 的平均时间复杂度的 put 和 get 操作。然而,当数据量增长时,HashMap 会触发扩容(Resize) 机制,这个过程直接影响性能。更复杂的是,从 Java 8 开始,HashMap 引入了红黑树来优化极端情况下的性能。
HashMap 的核心结构在深入扩容之前,我们必须理解 HashMap 的基本组成。
// 代码块
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:负载因子,衡量哈希表填满程度的指标。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() 方法进行扩容。
resize() 方法是 HashMap 的心脏,其逻辑非常精妙。
首次初始化:如果 table 为 null,则使用构造函数指定的初始容量(默认 DEFAULT_INITIAL_CAPACITY = 16)。
非首次扩容:新容量是旧容量的 2 倍。
// 代码块
int newCap = oldCap << 1; // 左移一位,等价于乘以 2新的 threshold 也翻倍:
// 代码块
int newThr = oldThr << 1;// 代码块
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; // 将新数组赋值给 table这是最耗时的一步。HashMap 需要将旧数组中的所有元素重新计算哈希值,并放入新数组的正确位置。
关键优化(Java 1.8):由于新容量是旧容量的 2 倍(即 2^n),HashMap 利用位运算的特性,避免了对每个元素都重新计算完整的哈希值。
旧索引 + 旧容量。oldCap 对应的位(即 oldCap 的最高位)是 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 | Java 1.8 |
|---|---|---|
底层结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
扩容后迁移 | 对每个元素重新计算 indexFor() | 利用 hash & oldCap 判断,分为低位/高位链表 |
头插法 vs 尾插法 | 头插法(导致扩容时链表反转,可能形成环) | 尾插法(保持链表顺序,线程安全) |
多线程问题 | 扩容时头插法易形成环,get() 可能死循环 | 尾插法避免了环,但仍非线程安全 |
头插法的危害:在多线程环境下,两个线程同时进行扩容,头插法可能导致两个节点互相指向,形成环形链表,
get()操作会陷入无限循环。
0.75DEFAULT_LOAD_FACTOR = 0.75fHashMap 的作者基于泊松分布的理论分析得出,当负载因子为 0.75 时,哈希冲突的期望值和链表长度的分布达到一个非常好的平衡点。在 0.75 负载下,链表长度超过 8 的概率极低(远小于百万分之一),这为后续引入红黑树提供了理论基础。0.75 是一个在绝大多数场景下性能最优的折中值。8TREEIFY_THRESHOLD = 80.75 时,一个桶中链表长度达到 8 的概率已经非常非常小(约为 0.00000006)。这意味着,达到 8 个节点的情况非常罕见,通常是哈希函数不够好或数据分布极端导致的。prev、left、right、red 等字段,内存开销大。当链表长度达到 8 时,红黑树 O(log n) 的查询性能优势开始明显超过其内存开销劣势。6UNTREEIFY_THRESHOLD = 6treeify 和 untreeify 的阈值相同(比如都是 7),那么在节点数在 7 附近频繁增减时,会反复触发转换,造成性能抖动。设置 untreeify 阈值(6)低于 treeify 阈值(8),形成了一个“缓冲区”,只有当节点数显著减少时才转回链表,避免了不必要的转换。64MIN_TREEIFY_CAPACITY = 64HashMap 的总容量(capacity)达到 64 时,才会将链表转换为红黑树。即使链表长度达到了 8,如果 capacity < 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。capacity >= 64,HashMap 会调用 treeifyBin() 方法,将这个链表转换为红黑树。put 和 get 操作,都将基于红黑树进行,时间复杂度为 O(log n)。HashMap 的设计是空间、时间、复杂性三者精妙平衡的典范。
resize() 通过尾插法和 hash & oldCap 优化,变得高效且安全。0.75 是空间与时间的最佳平衡点。8 是链表与红黑树性能的拐点,基于泊松分布。6 是为了避免频繁转换的“迟滞”设计。64 是优先扩容、避免过早树化的最小容量。理解了这些底层原理,你不仅能写出更高效的代码,还能在面试中从容应对各种 HashMap 的灵魂拷问。