在 JDK 1.8 中,除了引入红黑树(用于优化链表过长时的查询效率)外,HashMap
还进行了多项重要改动,以下是主要的优化点:
1. 哈希函数的优化
- 改进计算方式:
在 JDK 1.7 中,HashMap
的哈希值计算直接使用key.hashCode()
的值。而在 JDK 1.8 中,通过 扰动函数 对哈希值进行二次处理:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 目的:将哈希值的高位参与低位计算,使得哈希值分布更均匀,减少哈希冲突。
- 效果:降低链表过长的概率,提升性能。
2. 扩容机制优化
- 减少重新计算哈希值的次数:
在 JDK 1.8 中,扩容时不再对每个元素重新计算哈希值,而是通过 原数组长度的高位 判断元素在新数组中的位置: - 如果
(e.hash & oldCap) == 0
,则留在原位置; - 否则,迁移到
oldIndex + oldCap
的位置。 - 原理:由于数组长度是2的幂次,新数组的长度是旧数组的2倍,因此只需判断高位即可确定新位置。
- 效果:显著提升扩容效率,减少计算开销。
3. 链表转红黑树的条件调整
- 触发条件:
当链表长度超过 8 且数组长度大于等于 64 时,链表会转换为红黑树;否则,优先扩容。 - 反向转换:当红黑树节点数小于 6 时,会重新转为链表。
- 目的:
- 避免在数组较小时频繁树化/反树化,减少额外开销。
- 在极端哈希冲突场景下(如恶意构造攻击),将查询时间复杂度从 O(n) 优化到 O(log n)。
4. 初始化方式的优化(懒惰初始化)
- JDK 1.7:在创建
HashMap
时会立即初始化数组(默认容量为16)。 - JDK 1.8:延迟初始化数组,在第一次插入元素时才分配数组空间。
- 优点:减少内存浪费,尤其适合初始化后未立即使用的场景。
5. 头插法 → 尾插法
- JDK 1.7:使用 头插法 插入链表节点(新节点插入到链表头部)。
- JDK 1.8:改为 尾插法(新节点插入到链表尾部)。
- 目的:解决多线程扩容时可能出现的 环形链表 和 死循环 问题。
- 背景:JDK 1.7 多线程扩容时,头插法可能导致链表逆序,进而形成环形链表,引发死循环或
ConcurrentModificationException
。
6. 并发性支持增强
- 细粒度锁机制:
虽然HashMap
本身不是线程安全的,但在某些并发场景下(如put
操作),JDK 1.8 通过 仅锁定特定桶 而非整个表,提高了并发性能。 - 局限性:
HashMap
仍不建议在多线程环境下直接使用,需配合Collections.synchronizedMap
或ConcurrentHashMap
。
7. 其他细节优化
- 键为
null
的处理:- JDK 1.7 中,
null
键会直接存放在table[0]
。 - JDK 1.8 中,
null
键的哈希值为 0,与普通键一样通过(n - 1) & hash
计算索引,逻辑更统一。
- JDK 1.7 中,
- 链表插入顺序:
JDK 1.8 的尾插法保持了插入顺序的稳定性,避免了 JDK 1.7 头插法导致的链表逆序问题。
总结:JDK 1.8 对 HashMap 的改动对比
特性 | JDK 1.7 | JDK 1.8 |
---|---|---|
数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
哈希函数 | 直接使用 hashCode() | 扰动函数混合高低位 |
扩容机制 | 逐个重新计算哈希值 | 根据原数组高位快速定位新位置 |
链表转红黑树 | 无 | 超过阈值(8)且数组长度 ≥ 64 时转换 |
插入方式 | 头插法 | 尾插法 |
初始化方式 | 立即初始化数组 | 懒惰初始化(首次插入时分配数组) |
多线程安全性 | 多线程扩容可能形成环形链表 | 通过尾插法避免环形链表问题 |
实际影响
- 性能提升:在高并发和哈希冲突严重的情况下,
HashMap
的查询和插入效率显著提高。 - 内存优化:延迟初始化和更均匀的哈希分布减少了内存浪费。
- 健壮性增强:通过尾插法和红黑树优化,避免了多线程下的死循环风险。
这些改动使 HashMap
在 JDK 1.8 中成为更高效、稳定的数据结构,适用于更广泛的场景。
THE END