HashMap(JDK1.7到1.8的过渡)
HashMap
HashMap 是 Java 中常用的一种键值对存储结构,它基于哈希表实现,基本结构为数组+链表或数组+JDK1.8引入的红黑树
Put()方法的原理
1. 根据Key计算哈希值
当调用put(key, value)
方法时,首先会根据key
计算其哈希值。在 Java 中,key
对象的hashCode()
方法会被调用,返回一个int
类型的哈希码。例如:
HashMap<String, Integer> map = new HashMap<>();String key = "example";int hashCode = key.hashCode();
这个哈希码会被进一步处理,以减少哈希冲突的可能性。在 JDK 8 及以后的版本中,会对hashCode
进行扰动处理(hash
方法) ,让哈希分布更加均匀。
1.1二次Hash的目的
在 JDK 1.8 中,HashMap 的put
方法在计算哈希值时, “二次哈希”(哈希扰动)的目的是为了减少哈希冲突 、让哈希值分布更均匀,从而提升存储效率。
1.2二次Hash的具体实现
前置信息:HashMap 涉及的 Hash 值基于int
类型,因此二进制位数为 32 位(包括符号位)
static final int hash(Object key) {int h;// key为null时哈希值为0;否则对key的hashCode进行“扰动”return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
若
key
为null
,直接返回哈希值0
;若
key
不为null
,先获取key
的原始 hashCode(记为h
),再将h
无符号右移 16 位(把高 16 位移到低 16 位),最后与原始的h
进行异或运算(^
),得到最终用于计算下标的哈希值。
2. 通过Hash确定数组下标
通过哈希值计算出在table
数组(哈希表)中的下标。具体是将哈希值与数组长度减 1 进行按位与运算((n - 1) & hash
,其中n
是数组长度),从而得到一个在数组范围内的索引值。例如,数组长度为 16(n = 16
),哈希值为5
,则计算出的下标为 (16 - 1) & 5 = 5
。
int index = (table.length - 1) & hash;
2.1二次Hash解决 “高位信息浪费”,减少哈希冲突
HashMap 的数组容量(table.length
)通常是2 的 n 次幂(如默认初始容量 16,二进制为10000
)。计算数组下标时,用的是(n - 1) & hash
(位与运算,效率比取模高)
思考:当数组容量较小时(比如 16),
n - 1
是15
(二进制01111
),此时只有 hashCode 的 “低 4 位” 参与了下标计算,高 16 位的信息被 “浪费” 了 —— 如果不同key
的低 n 位相同,即使高 16 位不同,也会映射到同一个数组下标,导致哈希冲突。例如:
Key1
的hashCode()
返回值为0b1010110010110011 00001010
(二进制,共 32 位。高 16 位为1010110010110011
,低 4 位为1010
);
Key2
的hashCode()
返回值为0b1100101011001011 00001010
(二进制,高 16 位为1100101011001011
,低 4 位同样为1010
)。低四位相同,高16位不相同,如果没有Hash扰动,他们将出现Hash冲突,之后就需要调用equals()比对,影响效率。
解决方案:二次Hash-----通过 “二次哈希”(
h ^ (h >>> 16)
),可以让高 16 位的特征与低 16 位的特征混合,让更多位的信息参与哈希计算,从而让最终的哈希值分布更分散,降低冲突概率。
3. 检查数组位置是否为空
获取到数组下标后,检查table[index]
对应的位置是否为空:
如果为空:直接在该位置创建一个新的
Node
节点(在 JDK 8 中,Node
是HashMap
中存储键值对的基本结构),将键值对存储在这个节点中。如果不为空:说明该位置已经存在元素,发生了哈希冲突,需要进一步处理。
4. 处理哈希冲突
当发生哈希冲突时,JDK 8 之前采用链表法解决冲突,即新的节点会被添加到链表的头部;JDK 8 及以后,引入了红黑树来优化处理冲突:
检查
key
是否相同:遍历该位置的链表(或红黑树),逐个比较节点的key
。如果找到key
相同的节点,则直接用新的value
覆盖旧的value
。如果
key
不同:链表情况:如果当前结构是链表,且没有达到树化阈值(默认链表长度达到 8,且数组长度大于等于 64 时会进行树化),则将新节点添加到链表的尾部。
树化情况:如果链表长度达到树化阈值,并且数组长度也满足条件,会将链表转换为红黑树,然后将新节点按照红黑树的插入规则插入到树中。
5. 检查容量并扩容
在插入新节点后,会检查当前 HashMap 的容量是否超过了负载因子(默认是 0.75)与数组长度的乘积(Entry数组容量的75%)。如果超过了,就会触发扩容操作,调用resize()
方法。扩容会创建一个新的更大的数组(通常是原来的 2 倍),然后将原数组中的元素重新哈希并复制到新数组中。
JDK1.8HashMap了加入红黑树
JDK 1.8 对 HashMap 的核心优化之一是引入红黑树结构处理哈希冲突,解决了 JDK 1.7 中链表过长导致的性能退化问题。
1.JDK 1.7 的链表性能瓶颈
JDK 1.7 中,HashMap 仅用单链表处理哈希冲突:当多个键哈希冲突时,所有冲突元素会被串联成一条链表,存储在同一数组下标(桶)中。
当哈希冲突严重(如大量键映射到同一桶),链表会变得很长(例如长度为
n
)。此时无论是查询(
get
)还是插入(put
),都需要线性遍历链表,时间复杂度从O(1)退化为 O(n),性能急剧下降。
2.JDK 1.8 的红黑树优化
JDK 1.8 引入红黑树(一种自平衡二叉查找树),当链表长度达到阈值时(默认是:链表长度大于8,且数组长度大于64),自动将链表转换为红黑树。
注:如果数组容量不足 64,即使链表长度超过 8,也不会进行树化,但是会先触发扩容(而非树化),避免在小容量数组上过早树化(因数组扩容后冲突有可能缓解)。
2.1链表退化
当红黑树中的元素数量减少到 6(UNTREEIFY_THRESHOLD = 6
)时,会自动退化为链表,避免红黑树在元素较少时的维护成本(红黑树的平衡操作比链表复杂)。
红黑树的优势是 “元素多时有 O(logn) 性能”,但维护成本(平衡操作、节点存储) 比链表高。当元素数量很少时(比如≤6),红黑树的维护成本已经超过了 “链表遍历 O(n)” 的成本 —— 此时用链表更高效。
3.性能变化
红黑树的查找、插入、删除操作时间复杂度均为 O(log n)(n
为树中元素数),相比链表的 O (n) 有显著提升:
例如,当冲突元素达到 100 个时,链表需要遍历 100 次,而红黑树仅需约 7 次(
log2(100) ≈ 7
)。
jdk1.7到jdk1.8插入元素方法的更改
1.JDK1.7的头插法扩容
HashMap 扩容时,需要创建新数组,并将旧数组中的元素(包括链表节点)迁移到新数组。
迁移链表节点时,JDK 1.7 采用 「头插法」:新节点会被插入到链表头部,原链表的后续节点依次 “后移”。
头插法的效果:会反转链表的顺序(比如旧链表是
A→B→C
,头插后会变成C→B→A
)。
注:它头插法指的是:从旧的HashMap中的某个链表中从头节点开始,没遍历一个节点就将该节点头插到新HashMap对应位置对应链表的头部,所以会造成链表元素位置调换。
1.1多线程下构成“循环链表”
假设两个线程
T1
、T2
同时对同一个 HashMap 进行扩容,且旧数组中有一条链表A→B
(A
是头节点,B
是后续节点)。步骤 1:线程 T1、T2 读取旧链表结构
两个线程都 “看到” 旧链表的结构是
A → B
(A.next = B
,B.next = null
)。步骤 2:线程 T1 先执行扩容(头插法迁移)
T1 开始迁移链表:
先处理节点
A
:将A
插入新数组的对应位置,此时新链表的头是A
(A.next = null
)。再处理节点
B
:头插法将B
插入到新链表头部,此时B.next = A
,新链表变成B → A
(链表顺序反转)。T1 完成扩容,链表顺序从
A→B
变为B→A
。步骤 3:线程 T2 被挂起后恢复,基于 “旧视图” 操作
T2 在 T1 扩容过程中被挂起(比如时间片用完)。当 T2 恢复时,并不知道 T1 已经修改了链表顺序,仍然认为旧链表是
A → B
。T2 开始迁移链表:
先处理节点
A
:将A
插入新数组,A.next = null
(此时新链表头是A
)。再处理节点
B
:头插法试图将B
插入头部,执行B.next = A
。步骤 4:循环链表形成
此时,T1 已经将
B.next
设为A
(步骤 2),而 T2 又执行B.next = A
(步骤 3)。最终可能导致:
A.next
指向B
(T2 处理A
时的旧逻辑残留,或 T1 操作后的意外影响);
B.next
指向A
(T2 头插B
时的操作)。于是,
A
和B
的next
指针形成循环引用(A → B → A → B → …
)。后续若有线程遍历这条链表,会陷入无限循环,导致 CPU 占用 100%。
2.JDk1.8的尾插法扩容
JDK 1.8 扩容时,链表迁移改用 「尾插法」:新节点被追加到链表尾部,链表顺序保持不变(旧链表 A→B
,迁移后还是 A→B
)。
因此,即使多线程扩容(虽然 HashMap 仍非线程安全,但尾插法的 “顺序不变” 特性),也不会出现 “链表逆序 + 并发修改导致循环” 的问题,从根源上消除了循环链表的风险。
jdk1.7和jdk1.8对比对HashMap做了哪些处理?
加入了红黑树,使最坏情况下查询效率从o(n)-->o(logn)
扰动算法(二次Hash):优化Hash值计算
(h=key.hashCode())^(h>>>16)
,减少hash冲突。插入元素方式由
头插法变成尾插法
:
JDK1.7:采用头插法,扩容后链表节点顺序会反转。多线程并发扩容时,易因 “线程切换 + 头插反转” 导致循环链表(死循环风险)。
JDK1.8:采用尾插法,扩容后链表节点顺序保持不变。从根源上避免了 “头插反转 + 多线程并发” 导致的循环链表问题(尽管 HashMap 仍非线程安全,但此设计消除了死循环场景)。
优化resize():
JDK1.7:扩容时需为所有元素重新计算桶下标(因数组容量改变,哈希取模的规则随之变化),计算开销大。
JDK1.8:利用
(e.hash & oldCap) == 0
快速判断新位置:
若结果为
true
,元素在新表中的位置与旧表完全相同;若结果为
false
,元素在新表中的位置为旧位置 + 旧容量
。 无需重新计算哈希,仅通过位运算即可快速定位,大幅提升扩容效率。