🔥 深入解析 Java HashMap 的高性能扩容机制与树化优化
Java 中的 HashMap 是一个基于哈希表实现的键值对存储数据结构,广泛应用于 Java Collections Framework 中,用于高效地存储和检索数据。为了在大数据量和高并发场景下保持高性能,HashMap 在设计中采用了多种优化手段,包括合理的扩容策略、冲突处理机制以及链表与红黑树之间的动态转换。本文将从基本特性、主要操作、内部工作原理、扩容机制和树化优化等方面进行详细解析,并在最后讨论设计权衡及常见注意事项。
1. 基本特性
-
键值对存储
HashMap 存储键值对,每个键对应一个值,键和值可以是任意类型的对象。
-
键的唯一性
HashMap 中的键是唯一的,不允许重复;如果插入已存在的键,则会更新其对应的值。
-
无序存储
HashMap 不保证键值对的存储顺序,插入顺序与遍历顺序可能不同。
2. 主要操作
-
put(K key, V value)
将指定的键值对插入到 HashMap 中;如果键已存在,则更新对应的值。
-
get(Object key)
根据键获取对应的值,若键不存在则返回 null。
-
remove(Object key)
移除指定键的键值对。
-
containsKey(Object key) / containsValue(Object value)
判断 HashMap 中是否包含指定的键或值。
-
size() / isEmpty()
分别返回 HashMap 中键值对的数量以及判断是否为空。
3. 内部工作原理
HashMap 的核心在于哈希表的实现,通过对键的 hashCode()
进行扰动和取模运算,确定键值对在内部数组中的存放位置。遇到哈希冲突时,HashMap 采用链表存储;而在 Java 8 中,当单个桶中的链表长度超过一定阈值(默认 8)时,会将链表转换为红黑树,以提高查询效率。
3.1 哈希函数与冲突处理
- 哈希函数
利用键的hashCode()
方法计算哈希值,再通过扰动函数处理后,用(n - 1) & hash
将哈希值映射到数组索引范围内。 - 冲突处理
当不同键计算得到的哈希值相同时,HashMap 使用链地址法(链表方式)处理冲突;当链表过长时,根据情况转换为红黑树。
3.2 putVal 方法详解
下面的代码摘自 HashMap 的核心方法 putVal
,展示了如何将键值对插入到哈希表中,并处理初始化、冲突、树化和扩容操作。
java
/**
* 将键值对插入到 HashMap 中的核心逻辑。
* 根据给定的键值对,计算其哈希值,并将其插入到哈希表的相应位置。
* 如果发生哈希冲突,则处理链表或树形结构中的冲突情况。
*
* @param hash 键的哈希值
* @param key 键
* @param value 值
* @param onlyIfAbsent 如果为 true,则仅在键不存在时才插入
* @param evict 如果为 false,表示表处于创建模式
* @return 返回之前映射的值,如果没有则返回 null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; // 哈希表数组
Node<K,V> p; // 当前桶中的节点
int n, i; // n 为哈希表的长度,i 为计算得到的索引
// 如果哈希表为空(未初始化)或者长度为 0,则调用 resize 方法进行初始化或扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算桶索引:(n - 1) & hash
if ((p = tab[i = (n - 1) & hash]) == null)
// 桶为空时,直接创建新节点并插入
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e;
K k;
// 判断桶中首节点是否与插入键相同(通过 hash 值和 equals 方法判断)
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果桶中的节点为红黑树节点,则调用树结构的 putTreeVal 方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历链表寻找是否存在相同键的节点
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 到达链表末尾后插入新节点
p.next = newNode(hash, key, value, null);
// 链表长度达到 TREEIFY_THRESHOLD - 1 时触发树化操作
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果找到了相同键的节点,则更新其值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; // 修改计数器加 1(用于快速失败机制)
// 如果插入后大小超过扩容阈值,则触发扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
详细说明:
- 首先检查内部数组是否初始化;若未初始化则调用
resize()
进行初始化。 - 通过
(n - 1) & hash
快速定位桶索引。 - 如果桶为空,则直接插入新节点;否则,判断桶中是否存在相同键的节点。
- 对于存在冲突的情况:
- 如果首节点匹配,则直接更新;
- 如果是树形结构,则调用红黑树插入方法;
- 否则遍历链表,若到达末尾则插入新节点,同时当链表长度达到一定阈值时触发树化操作。
- 插入完成后,更新修改计数器并检查是否需要扩容。
4. 扩容机制与树化优化
在 HashMap 中,扩容与树化分别是解决哈希冲突的两种优化手段。当实际存储的键值对数量超过 capacity * loadFactor
时,会触发扩容;而当某个桶中链表长度超过阈值(默认8)时,会尝试将链表转换为红黑树以提高查询效率。
4.1 treeifyBin 方法解析 🌳
下面是将链表转换为红黑树的核心方法 treeifyBin
的代码及详细解释。
java
/**
* 将哈希表中指定索引位置的链表转换为红黑树。
* 如果哈希表的容量小于 MIN_TREEIFY_CAPACITY,则先进行扩容。
*
* @param tab 哈希表数组
* @param hash 键的哈希值,用于计算链表或红黑树的位置
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index;
Node<K,V> e;
// 如果哈希表为空或容量小于 MIN_TREEIFY_CAPACITY(默认64),则扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 否则,将指定索引位置的链表转换为红黑树
else if ((e = tab[index = (n - 1) & hash]) != null) {
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);
// 将转换后的红黑树头节点放入桶中,并调用 treeify 方法完成树构建
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
详细说明:
- 首先判断当前哈希表的容量是否达到树化的最小要求(MIN_TREEIFY_CAPACITY,默认 64)。如果容量不足,则先扩容以分散冲突。
- 如果容量满足要求,则根据
(n - 1) & hash
定位到目标桶,并遍历桶内链表。 - 遍历过程中,每个普通节点通过
replacementTreeNode
方法转换为红黑树节点(TreeNode),同时构建双向链表。 - 最后将构建好的红黑树结构放回桶中,并调用
treeify
方法完成树平衡操作。
下面是 replacementTreeNode
方法示例:
java
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next, null);
}
4.2 扩容机制与 resize 方法 🚀
当 HashMap 中存储的键值对数量超过 capacity * loadFactor
时,会触发扩容操作。扩容过程不仅扩大数组容量(通常为原来的两倍),还需要重新将所有节点散列到新数组中。下面是扩容的核心代码。
java
/**
* 对哈希表进行扩容,并重新映射节点。
*
* @return 扩容后的新哈希表数组
*/
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) {
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;
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 利用数组容量为 2 的幂,通过 e.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;
}
} 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;
}
详细说明:
- 根据旧数组容量及负载因子计算新容量和新阈值,新容量通常为旧容量的两倍。
- 创建新的数组,并将内部引用
table
指向新数组。 - 遍历旧数组中每个桶:
- 对于只有单个节点的桶,直接将节点插入新数组中对应位置;
- 对于链表结构的桶,利用
(e.hash & oldCap)
判断节点的新位置,将链表拆分为两部分(低位与高位); - 对于树节点,则调用
TreeNode.split()
方法拆分树结构后再重新映射。
4.3 TreeNode 的 split 方法解析 🔍
扩容过程中,对于已树化的桶,需要将红黑树拆分成两个部分,并重新映射到新数组中。下面是 TreeNode.split
方法的代码及详细解释。
java
/**
* 将当前红黑树节点拆分成两个链表,重新分配到哈希表的不同位置。
* 这是在哈希表扩容后用于分裂树节点的关键步骤,通过判断哈希值的特定位,
* 将原来的树节点分成两个部分,并根据节点数量决定是否需要将链表重新转化为红黑树。
*
* @param map 当前哈希表
* @param tab 扩容后的哈希表数组
* @param index 当前树节点所在的数组索引
* @param bit 扩容前的旧容量,用于判断节点新位置的关键位
*/
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this; // 当前红黑树根节点
TreeNode<K,V> loHead = null, loTail = null; // 低位链表头尾
TreeNode<K,V> hiHead = null, hiTail = null; // 高位链表头尾
int lc = 0, hc = 0; // 分别记录低位和高位链表的节点数
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next; // 保存下一个节点
e.next = null; // 断开当前节点与后续节点的链接
// 判断节点根据 (e.hash & bit) 应放入低位或高位链表
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
} else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
详细说明:
- 遍历当前红黑树的所有节点,通过
(e.hash & bit)
判断每个节点在新数组中的位置,并分别链接成低位链表和高位链表。 - 将低位链表放入新数组的原索引位置,将高位链表放入
index + bit
位置。 - 根据拆分后各链表的节点数量:
- 如果数量低于或等于 UNTREEIFY_THRESHOLD(默认 6),则调用
untreeify
方法将红黑树退化为链表; - 否则调用
treeify
方法重新构建红黑树以保持高效查询。
- 如果数量低于或等于 UNTREEIFY_THRESHOLD(默认 6),则调用
5. 设计权衡 以及 常见注意事项 💡
5.1 设计权衡
Java 8 中 HashMap 的优化主要体现在以下几方面:
-
扩容优先
当某个桶中链表过长时,如果哈希表容量较小,会优先进行扩容而不是直接树化。这样做利用扩容降低了哈希冲突的概率,只有当容量达到 MIN_TREEIFY_CAPACITY(默认 64)时,才会考虑将链表转换为红黑树。
-
树化机制
通过将链表转换为红黑树,在冲突严重的情况下提高查询效率,将最坏情况下的时间复杂度从 O(n) 降为 O(log n)。
-
扩容过程中的拆分策略
利用数组容量总是 2 的幂这一性质,通过与运算判断节点在新数组中的位置,实现高效重新散列,同时对树节点进行拆分处理,保证扩容后各桶分布均匀。
这些机制充分体现了设计者在性能、空间利用以及实现复杂度之间的平衡,通过充分利用 JVM 的类加载和内存管理特性,使得 HashMap 能在保持高效访问的同时,也能在极端情况下降低性能退化的风险。
5.2 常见问题与注意事项
-
散列函数的设计
应保证键对象的
hashCode()
方法具有良好分布,避免大量冲突。设计不当可能导致所有键落入同一桶,从而严重影响性能。 -
线程安全问题
HashMap 不是线程安全的。在多线程环境下,建议使用 👉CourrentHashMap或在外部加锁,以避免数据竞争和不一致问题。
-
扩容的代价
扩容操作会重新计算所有键的存储位置,开销较大。因此,在预计数据量较大时,可以通过构造函数指定合适的初始容量,以降低扩容次数。
-
树化与退化
当桶中链表长度超过 TREEIFY_THRESHOLD(默认 8)时,会进行树化操作;当节点数量降低到 UNTREEIFY_THRESHOLD(默认 6)以下时,红黑树可能退化为链表。设计者利用这两个阈值在树结构维护开销与查询效率之间实现了有效平衡。
-
调优建议
根据实际应用场景、数据量和键的分布情况,合理设置初始容量和负载因子,可以有效提升 HashMap 的性能。
通过本文的详细解析,相信你对 Java HashMap 的扩容机制与树化优化有了更深入的理解。这些源码细节和设计权衡不仅有助于你在阅读源码和调优性能时提供参考,也能为设计高效数据结构提供宝贵思路。