总结对比表
特性
JDK 1.7
JDK 1.8
变化原因/优势
数据结构
数组 + 链表
数组 + 链表 + 红黑树
解决哈希冲突严重时查询慢的问题 (O(n) -> O(log n))
插入方式
头插法
尾插法
解决多线程扩容死循环问题
哈希扰动
9 次移位异或
1 次 移位异或
简化计算,提升性能
扩容逻辑
重新计算 Hash 并迁移
复用 Hash,仅判断高位
提升扩容速度 (O(n) -> 更快)
初始化时机
构造方法的 “伪初始化“,仅被赋值为一个空数组
懒加载 (第一次 put 时初始化)
节省内存,避免无用的数组分配
链表转树
无
有 (长度>8 且 容量>64)
平衡时间与空间复杂度
线程安全
不安全 (死循环 + 数据覆盖)
不安全 (仅修复死循环,仍会数据覆盖)
仍需使用 ConcurrentHashMap 处理并发
链表节点名 Entry<K,V> Node<K,V> (链表) / TreeNode<K,V> (树)
1. 底层数据结构(最核心的区别)
-
JDK 1.7:仅由数组 + 单链表组成。当发生哈希冲突时,所有冲突的元素都挂在同一个链表上。
-
缺陷
:当链表长度过长(例如极端情况下达到 1000),查询效率会从 O(1) 退化为 O(n)。
-
-
JDK 1.8:引入了红黑树优化。
-
当链表长度 >= 8 且 数组长度 >= 64 时,将链表转换为红黑树。
-
当红黑树节点数量 <= 6 时,退化为链表。
-
优势
:树化后,查询、插入、删除的时间复杂度稳定在 O(logn),解决了哈希碰撞导致的性能问题。
-
2. 链表插入方式(导致死循环的根源)
-
JDK 1.7 - 头插法 (Head Insert):
-
新节点会插入到链表的头部。
-
目的
:设计者认为新插入的元素被查询的概率更高。
-
致命缺陷
:在多线程并发扩容时,会导致链表形成环,进而引发死循环(
Infinite Loop)。
-
-
JDK 1.8 - 尾插法 (Tail Insert):
-
新节点插入到链表的尾部。
-
优势
:扩容时保持了链表元素的原始顺序,解决了多线程扩容死循环的问题(但 HashMap 依然不是线程安全的,并发修改仍可能导致数据丢失)。
-
3. 扩容机制的优化(rehash 过程)
数组扩容时,需要重新计算元素在新数组中的下标(rehash)。
-
JDK 1.7:所有元素都必须重新计算哈希值,再进行取模运算,过程较为繁重。
-
JDK 1.8:利用扩容是原容量 2 倍的特性,做了位运算优化。
- 新下标 =
原下标或原下标 + 原容量。 - 只需判断哈希值的某一个特定二进制位是 0 还是 1,即可确定新位置,无需重新计算哈希,效率大幅提升。
- 新下标 =
4. 哈希扰动函数(hash 算法)
为了减少哈希冲突,需要将 hashCode() 的高 16 位和低 16 位混合(扰动)。
-
JDK 1.7:
hash()方法做了 4 次 位运算和 5 次 异或运算,扰动剧烈。 -
JDK 1.8:简化为 1 次 异或运算(
(h = key.hashCode()) ^ (h >>> 16))。-
原因
:引入红黑树后,即使发生冲突,性能也有保障,因此不再需要过度的扰动,以换取计算速度。
-
5. 初始化时机(细节补充)
-
JDK 1.7:在第一次调用
put()时,执行inflateTable()初始化数组。 -
JDK 1.8:在第一次调用
putVal()时初始化数组。逻辑更内聚,直接在核心方法中处理。
关键源码对比(伪代码)
1. 节点结构
JDK 1.7 (Entry)
java
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next; // 单链表指针
int hash;
// ...
}
JDK 1.8 (Node & TreeNode)
java
// 链表节点
static class Node<K,V> implements Map.Entry<K,V> { /* ... */ }
// 树节点,继承自 LinkedHashMap.Entry
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; // 左子树
TreeNode<K,V> right; // 右子树
TreeNode<K,V> prev; // 前驱节点
boolean red; // 红黑树颜色标记
// ...
}
2. 插入逻辑
JDK 1.7
java
// 头插法,newEntry.next = e;
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e); // 新节点指向旧头
// ...
}
JDK 1.8
java
// 尾插法
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// ...
if (e == null) {
p.next = newNode(hash, key, value, null); // 插入尾部
// 检查是否需要树化
if (++binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
}
// ...
}
总结与最佳实践
- 为什么 JDK 1.8 性能更好? 红黑树解决了最坏情况的性能问题,尾插法避免了扩容死循环,简化的 hash 算法提升了计算速度。
- 线程安全问题:JDK 1.8 虽然修复了死循环,但依然线程不安全(可能出现数据覆盖)。并发场景请使用
ConcurrentHashMap。 - 开发建议:在使用 HashMap 时,尽量指定初始容量。特别是当你知道存储元素的大致数量时(初始容量 = 预计元素数 / 0.75 + 1),可以避免频繁扩容带来的性能损耗。
疑问与解答:
HashMap存储key、value是否可以为null的问题,以及与Hashtable、LinkedHashMap的区别?
答:HashMap 存储的 key 和 value 都可以为 null,但有明确约束(JDK 1.7 和 JDK 1.8 规则一致,无版本差异)
1. Value 可以为 null(无数量限制)
规则:value 允许为 null,且可以有多个 key 对应 null 值(只要这些 key 不重复)。
2. Key 可以为 null(仅允许 1 个)
-
规则:key 允许为 null,但全局仅能存在 1 个 null 键(HashMap 会将 null 键的哈希值视为 0,存入数组下标为 0 的位置,重复 put null 键会覆盖原有值)。
-
. 补充注意事项(避坑重点)
- 与 Hashtable 区别:Hashtable 是线程安全的,但禁止 key 和 value 为 null(会抛出 NullPointerException),这是 HashMap 与 Hashtable 的核心区别之一。
- 查询 null 键 / 值:可以直接通过
map.get(null)获取 null 键对应的值(若未存 null 键,返回 null);无法通过get(null)区分 “不存在 null 键” 和 “null 键对应的值为 null”,需用map.containsKey(null)判断。 - 遍历影响:遍历 HashMap 时,null 键和 null 值会正常被遍历到(无过滤),需注意判空避免空指针异常。
简单总结:HashMap 对 null 友好,key 可存 1 个 null,value 可存多个 null;Hashtable 完全禁止 null,开发中需根据场景区分使用。