文章目录
- 1. HashMap概述
- 2. 哈希冲突
- 3. 树化与退化
- 3.1 树化的意义
- 3.2 树的退化
- 4. 二次哈希
- 5. put方法源码分析
- 6. key的设计
- 7. 并发问题
参考
- 如何防止因哈希碰撞引起的DoS攻击_hashmap dos攻击_双子孤狼的博客-CSDN博客
- 为什么 HashMap 要用 h^(h >>>16) 计算hash值?槽位数必须是 2^n?_一行Java的博客-CSDN博客
- HashMap面试,看这一篇就够了_苦味代码的博客-CSDN博客
1. HashMap概述
HashMap
是Java中最常用的集合框架。
在JDK1.7的时候,HashMap
的底层由数组和链表组成。数组是HashMap
的主体,而链表是为了解决哈希冲突而存在的。
在JDK1.8的时候,HashMap
的底层由数组、链表和红黑树组成。红黑树的出现是为了解决因哈希冲突导致的链表长度过长影响HashMap
性能的问题。红黑树搜索的空间复杂度为O(logn)
,而链表却是O(n)
。
也就是当链表的长度达到一定长度后,链表就会进行树化,当然这是一种笼统说法,具体细节待会深究。
2. 哈希冲突
哈希冲突是指对不同的关键字通过一个哈希函数进行计算得出相同的哈希值,这样使得它们存在的数组时候发生了冲突。
解决哈希冲突通常有以下四种方法。
- 开放定址法
开放定址法,也称为再散列地址法。基本思想就是,如果p=H(key)
出现冲突时,则以p
为基础,再次hash
,p1=H(p)
,如果p1
再次出现冲突,则以p1
为基础,以此类推,直到找到一个不冲突的哈希地址pi
。
就是说当发生哈希冲突的时候,对哈希值进行求哈希值,只要哈希表足够大,那么总能找到一个这样的空地址。
因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素。
- 再哈希法
再哈希法,也称双重散列,多重散列。基本思想就是提供多个不同的哈希函数,当R1=H1(key1)
发生冲突时,再计算R2=H2(key1)
,直到没有冲突为止。这样做虽然不易产生堆集,但增加了计算的时间。
- 链地址法
链地址法,也称拉链法,将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。
- 建立公共溢出区
将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
而HashMap
采用的就是链地址法。
并且JDK1.7和JDK1.8的链表插入节点时,采用的方式不一样
- JDK1.7采用的是头插法。
- JDK1.8采用的是尾插法。
3. 树化与退化
树化是指将链表转化为红黑树的过程。
在JDK1.8的HashMap
中,树化的规则是这样的:当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化
所以,HashMap
并不是一开始就进行树化的。
阈值设置为8主要是因为泊松分布,具体原因HashMap
作者在源码中也有解释
意思就是说,理想情况下使用随机的哈希码,容器中节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表,可以看到链表中元素个数为 8 时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了 8,是根据概率统计而选择的。
也就是说哈希 值如果足够随机,则在 哈希表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小。
3.1 树化的意义
首先,树化成红黑树可以避免DOS攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略。
DOS攻击是指恶意的攻击者通过使用以下精心构造的数据,使得所有数据经过哈希函数之后,都映射到了一个位置,导致了大量数据出现了哈希冲突,通过
HashMap
查询的效率从O(1)
变成了O(n)
。这样就有可能发生因为查询操作消耗大量CPU
或者线程资源,而导致系统无法响应其他请求的情况,从而达到拒绝服务攻击(DoS
)的目的。
其次,哈希表的查找,更新的时间复杂度是 O(1)
,而红黑树的查找,更新的时间复杂度是 O(log2n)
。
但是由于TreeNode
占用空间也比普通 Node
的大,如非必要,尽量还是使用链表。
3.2 树的退化
树的退化主要是发生在这两种情况下
HashMap
在扩容时,如果拆分树,树元素个数小于等于6,则会退化成链表remove
树节点时,若root
、root.left
、root.right
、root.left.left
有一个为 null ,也会退化为链表
4. 二次哈希
在JDK1.8的源码的put
方法中,会将key
进行二次哈希
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
通过源码可以了解到,首先会调用key
对象的hashCode
方法进行求哈希值,然后将该哈希值的低16位与高16位进行异或运算,这样做的目的是为了综合高位数据,让哈希分布更为均匀,减少哈希碰撞。
并且这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
操作 | 值 |
---|---|
hashCode() | 1,794,106,052 |
二进制 | 01101010 11101111 11100010 11000100 |
h >>> 16 | 00000000 00000000 01101010 11101111 |
5. put方法源码分析
查看put
方法源码
transient Node<K,V>[] table;
public V put(K key, V value) {
// 调用上文我们已经分析过的hash方法
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
// 第一次put时,会调用resize进行桶数组初始化
n = (tab = resize()).length;
// 根据数组长度和哈希值相与来寻址,原理上文也分析过
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果没有哈希碰撞,直接放到桶中
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 哈希碰撞,且节点已存在,直接替换
e = p;
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);
// 链表过长,转换为树结构
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 如果节点已存在,则跳出循环
break;
// 否则,指针后移,继续后循环
p = e;
}
}
if (e != null) { // existing mapping for key
// 对应着上文中节点已存在,跳出循环的分支
// 直接替换
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
// 如果超过阈值,还需要扩容
resize();
afterNodeInsertion(evict);
return null;
}
put
方法的大体思路如下
- 调用
key
的hashCode
方法计算哈希值,并据此计算出数组下标index - 如果发现当前的桶数组为
null
,则调用resize()
方法进行初始化 - 如果没有发生哈希碰撞,则直接放到对应的桶中
- 如果发生哈希碰撞,且节点已经存在,就替换掉相应的
value
- 如果发生哈希碰撞,且桶中存放的是树状结构,则挂载到树上
- 如果碰撞后为链表,添加到链表尾,如果链表超度超过
TREEIFY_THRESHOLD
默认是8,则将链表转换为树结构 - 数据put完成后,如果
HashMap
的总数超过threshold
就要resize
put
方法中比较重要的一个知识点莫过于计算索引了。
- 首先,先调用
hash
方法。- 在
hash
方法中,计算对象的hashCode
方法 - 再进行调用
HashMap
的hash()
方法进行二次哈希
- 在
- 接着,将
hash
方法返回的二次哈希值记为hash
,用(n - 1)
也就是数组长度减一对hash
进行与运算((n - 1) & hash
)
这里使用n-1
是因为以默认数组长度16为例子,那么数组下标为0-15,哈希值计算hash%(2^4)
,其本质就是和长度取余。也就等价于 (2^4 - 1) & hash
在JDK1.8和JDK1.7中,它们的put
方法实现有所不同
-
链表插入节点时,1.7 是头插法,1.8 是尾插法
-
1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
-
1.8 在扩容计算 Node 索引时,会优化
6. key的设计
HashMap
的 key
可以为 null
,但 Map
的其他实现就不一定了
其key
应当符合下面的要求
- 作为
key
的对象,必须实现hashCode
和equals
,并且key
的内容不能修改(不可变) - key 的
hashCode
应该有良好的散列性
7. 并发问题
- 扩容死链(1.7 会存在)
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
e
和next
都是局部变量,用来指向当前节点和下一个节点- 线程1(绿色)的临时变量
e
和next
刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移
- 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移
- 第一次循环
- 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
- e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
- 当循环结束是 e 会指向 next 也就是 b 节点
- 第二次循环
- next 指向了节点 a
- e 头插节点 b
- 当循环结束时,e 指向 next 也就是节点 a
- 第三次循环
- next 指向了 null
- e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
- 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出
- 数据错乱(1.7,1.8 都会存在)
假设现在有两个线程A和B,这两个线程都分别将数据C和D放进HashMap
中,并且C和D的二次哈希值都一样。
线程A和线程B同时执行到检查有无哈希冲突的那一段代码。A和B检查均无发现有哈希冲突。
假设线程A比较快,于是线程A将tab[i]
指向数据C。
这时候线程B将tab[i]
指向数据D。
最终tab[i]
指向数据D,导致了数据C丢失