Java中的HashMap在JDK 7及之前的版本中,多线程并发执行put操作触发扩容时,会出现死循环问题,导致CPU占用率飙升。JDK 8通过重构扩容时的链表重组逻辑,彻底解决了这个经典问题,下面我们来详细分析具体的实现原理。
JDK 7中死循环的产生原因
JDK 7的HashMap扩容时,会将旧数组中的链表节点通过头插法迁移到新数组中。头插法的逻辑是每次把新遍历到的节点放到新数组对应桶的链表头部,这种操作会导致原链表的顺序反转。在多线程并发扩容的场景下,两个线程可能同时操作同一个链表,最终形成环形链表,后续查询该桶的元素时就会陷入无限循环。
头插法扩容的核心代码片段如下:
// JDK 7 扩容迁移节点逻辑(简化版)
void transfer(Entry[] newTable) {
Entry<K,V> e = table[i];
while (e != null) {
Entry<K,V> next = e.next;
int index = indexFor(e.hash, newTable.length);
e.next = newTable[index]; // 头插法,新节点指向原桶的头节点
newTable[index] = e;
e = next;
}
}
JDK 8的扩容链表重组优化
JDK 8对HashMap的实现做了两处关键改动:一是将底层链表的插入方式从头插法改为尾插法,二是扩容时链表重组的逻辑不再反转链表顺序,而是保持原链表的顺序拆分到新数组的两个桶中。
尾插法的引入
JDK 8中新增节点时采用尾插法,即新节点会追加到链表的末尾,不会反转链表顺序。这样单线程下扩容时,链表的顺序和原链表一致,避免了反转带来的问题。
扩容时的链表拆分逻辑
JDK 8扩容时,会通过节点的hash值和旧数组长度进行计算,判断节点应该放到新数组的哪个位置。因为新数组长度是旧数组的2倍,所以每个旧桶中的节点只会被分配到两个新桶中:一个是和旧桶索引相同的新桶,另一个是索引为旧桶索引加旧数组长度的新桶。
扩容时会维护两个链表:低位链表和高位链表,分别存储分配到两个新桶的节点,最后将两个链表分别放到对应的新桶中,整个过程不会反转链表顺序。
JDK 8扩容链表重组的核心代码如下:
// JDK 8 扩容时链表重组逻辑(简化版)
Node<K,V> loHead = null, loTail = null; // 低位链表头尾节点
Node<K,V> hiHead = null, hiTail = null; // 高位链表头尾节点
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;
}
优化后的效果对比
我们可以通过下表对比JDK 7和JDK 8在扩容链表处理上的差异:
| 对比项 | JDK 7 | JDK 8 |
|---|---|---|
| 节点插入方式 | 头插法 | 尾插法 |
| 扩容链表顺序 | 反转原链表顺序 | 保持原链表顺序 |
| 多线程扩容死循环 | 会出现 | 不会出现 |
注意事项
虽然JDK 8解决了HashMap扩容时的死循环问题,但HashMap本身仍然不是线程安全的容器。多线程场景下如果有并发修改操作,还是可能出现数据覆盖、数据不一致等问题,因此多线程环境建议使用ConcurrentHashMap或者Hashtable等线程安全的实现。
需要特别说明的是,JDK 8的优化只是避免了扩容时的死循环,并没有让HashMap变成线程安全容器,日常开发中不要因为知道这个优化就贸然在多线程场景使用HashMap。