如果不了解HashMap基础的同学可以先看一下下面这篇文章:
HashMap原理了解前准备_Strine的博客-CSDN博客
本章节主要深入讲解HashMap在JDK1.8版本的一些问题
为什么链表转换为红黑树的阈值是8而不是其他?
首先我们先了解一下泊松分布:
在日常生活中,大量的事件都是有固定发生频率的,例如:
某地区平均每小时出生xx个孩子;
某包子铺平均每天销售xx个包子;
某公司客服平均每10分钟接xx个电话;
这些事件的特点就是:我们可以预估这些事件的总数,但是没法知道具体的发生时间,例如我们知道客服平均每十分钟接1个电话,但是下一个十分钟会接到多少个电话?有可能3个,也有可能一个都接不到,这个是我们没办法知道的;
因此泊松分布就是描述某段时间内事件具体的发生概率;
好,我们现在再来看为什么链表转换为红黑树的阈值8不是9,10呢,这个和HashCode的碰撞次数和泊松分布有关,主要是为了寻找一种时间和空间的平衡,在负载因子为0.75(默认)的情况下,单个Hash槽内的元素个数为8的概率特别低,因此将7作为一个分水岭,等于7的时候不做转换,大于等于8的时候才转换为红黑树,并且当红黑树的节点小于等于6个以后又会恢复为链表形态;
下图就是在源码中链表中存储的元素个数的概率:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
那么为什么不直接使用红黑树呢?
我们在看下面的这个源码的时候可以发现,它在长度达到8的时候转换为红黑树,调用数的插入方法,长度在降为6的时候又转换回去,这体现了时间和空间平衡的思想;
最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大问题,但是当链表越来越长,时间复杂度O(n)的问题也就体现出来,查询效率变低,因此需要使用红黑树来保证查询的效率,在上表中我们看到,链表达到阈值8的概率非常非常小,因此如果hashCode分布良好,很少会出现链表很长的情况,因此通常情况下,并不会发生从链表转换为红黑树的情况;
并且红黑树需要进行左右旋操作, 而链表不需要,TreeNodes占用空间是普通Nodes的两倍,因此直接使用的话在数据量少的时候空间利用率是没有链表高的。
源码:
如果超过了阈值8则调用转化为红黑树存储:
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
为什么Hash运算使用的是高16位异或运算?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
是为了让Hash值的散列度更高减少Hash冲突的发生,由于HashMap的哈希值是一个32位的整数,而底层数组的长度一般是2的幂次方,因此只有低16位的Hash值对数组长度取模之后才能确定元素在数组中的位置;
但是如果只使用低16位进行hash运算,这样就会造成key的散列度不是很高,那么不同的元素可能会产生相同的hash值,进而发生hash冲突,而将高16位与低16位进行异或运算之后,可以将高低位的信息都加入到hash计算中,减少hash冲突的发生,进而提高HashMap的性能和效率;
为什么负载因子的默认值为0.75而不是其他?
HashMap的负载因子是因为它的容量和元素个数的比值,当元素个数超过容量乘以负载因子的时候就会触发扩容操作,默认负载因子为0.75是通过实验得出的最优值,它既能保证HashMap的性能又能避免过多的空间浪费;
在HashMap中,负载因子为0.75,也就是元素个数达到容量的百分之75的时候就会触发扩容操作
如果负载因子设置的太小:就会导致频繁扩容,如果设置的太大又会导致大量的Hash冲突,使得性能变得很低;
假如负载因子为1:也就是说只有当HashMap的数组装满之后才会进行扩容,虽然这样空间利用率提升了,但是会导致大量的Hash冲突,底层的红黑树变得异常复杂,查询效率变得非常低,时间效率也就降低;
假如负载因子为0.5或者更少:虽然Hash冲突降低了,查询效率提高了也就是说时间效率提高了,但是这样就会导致频繁扩容,空间利用率大大降低;
JDK1.7和1.8版本的链表插入有什么区别,为什么?
在JDK1.7中,HashMap的链表存储使用的是头插法,也就是新节点插入到链表的头部,这是因为在jdk1.7中,当出现hash冲突的时候,会使用链表来解决冲突,新的元素会插入到链表的头部,这样就可以保证最近插入的元素能够被快速访问到;
头插法存在的问题:
JDK1.7进行扩容的时候,每个元素的rehash之后,都会插入到新数组对应索引的链表头,这就导致了原链表的顺序A->B->C,在扩容之后rehash之后可能变成C->B->A,元素的顺序发生了改变;
因此在并发的场景下,扩容的时候就可能会出现循环链表的问题,而JDK1.8之后从头插法改为了尾插法,元素顺序就不会发生改变,避免出现了循环链表的问题;
尾插法:
当发生hash冲突的时候,追加到链表的尾部,扩容的时候元素顺序不会发生改变,并且因为加入了红黑树的原因,链表长度在达到阈值8会转换为红黑树存储,因此我们使用尾插法会计算一个bitCount值,在添加的时候会判断该值是否达到了阈值8,如果达到了就会转换为红黑树,这是头插法没办法做到的,如果使用头插法在每次插入之后还需要进行遍历计算长度,性能特别低;
{
tab[i] = newNode(hash, key, v, first); //生成新的节点
if (binCount >= TREEIFY_THRESHOLD - 1) //判断加入该节点之后是否达到阈值8
treeifyBin(tab, hash); // 如果达到了的话就转换为红黑树存储
}
HashMap的扩容机制
初始化:
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
该对象第一次被new出来执行put方法的时候
首先会根据new的不同构造器(是否指定容量,是否指定初始化容量)决定以不同的方式计算当前map的新容量/阈值
得到新的容量和阈值之后,创建一个新的数组table
判断旧的table是否存在,如果存在就扩容,不存在就直接返回新的数组并结束;
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
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;
}
}
}
}
}
return newTab;
}
扩容
已经初始化完了之后的扩容操作
目的:减少hash碰撞
要做的事情:将旧数组的元素重新迁移到新的数组中去
遍历旧数组的容量,得到当前位置的节点对象
1.判断是否还有下一个节点
如果没有下一个节点,直接重新计算在新数组中的索引位置(hash&newCap-1),并将该元素存入新的数组
2.判断当前节点是否是树节点,如果是树节点
对当前树进行拆分
拆分后判断新的节点数是否<6,如果小于就会恢复为链表;
否则为数的根节点计算新的索引位置,并存入新数组;
新的索引位置=》存放树的根节点
低位:原来的位置
高位:原来的索引+旧数组的长度
3.如果不是单个节点也不是树节点,当做链表来处理
将链表拆分为低/高位链表
拿当前节点的hash与旧数组的长度做与运算,最终得到的结果只会是0或1;
如果为0:该节点会存入低位链表;
如果为1:该节点会存入高位链表;
计算新的索引=》存放链表的头节点
低位:原来的位置
高位:原来的索引+旧数组的长度
简单的来说
扩容操作主要包括以下几个步骤:
当 HashMap 中元素个数达到了负载因子(默认为0.75)与当前容量的乘积时,就会触发扩容操作。
- 将原有的 Entry 数组复制到一个新数组中,新数组的长度是原来的两倍。
- 遍历新数组中的每个位置,如果该位置上有元素,则计算其在新数组中的索引位置,并将其插入到该位置上。
- 如果该位置上只有一个元素,则直接插入。
- 如果该位置上多于一个元素,则判断这些元素是否需要转化成红黑树。如果是,则将它们转化为一棵红黑树;如果不是,则继续使用链表存储。
- 扩容完成后,将原有的 Entry 数组置为 null,等待垃圾回收。
需要注意的是,由于扩容操作比较耗费时间和内存资源,程序在运行过程中会尽量避免频繁扩容。一般来说,初始时HashMap会创建一个小的底层数组,然后随着元素数量的增加,逐渐扩容到预设的最大值。此外,扩容操作也可能会导致并发问题,因此在多线程环境下需要使用线程安全的HashMap实现或者通过同步机制来保证扩容过程的正确性。
文章详细探讨了HashMap在JDK1.8中的实现细节,包括为何链表转红黑树的阈值设为8,如何利用泊松分布理论,以及为何选择0.75作为负载因子。还讨论了头插法和尾插法的区别,以及HashMap的扩容机制,强调了在性能和空间效率之间的平衡设计。
1578

被折叠的 条评论
为什么被折叠?



