注意
为什么加载因子等于0.75?
作为一般规则,默认负载系数(0.75)提供了时间和空间成本之间的良好权衡。较高的值会减少空间开销,但会增加查找成本(反映在HashMap
类的大多数操作中,包括get和put)。在设置映射的初始容量时,应该考虑映射中预期的条目数量及其负载因子,以减少重新散列操作的数量。如果初始容量大于最大条目数除以负载因子,则不会发生重新散列操作。
HashMap是存储的是Key-Value键值,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,数组初始化长度为16,数组中的每一个元素初始值都是null,这个数组就是我们常说的桶或坑位。
HashMap
是线程不安全的,不安全的具体原因就是在高并发场景下,扩容可能产生死锁(Jdk1.7存在)以及get操作可能带来的数据丢失。
线程不安全的主要原因:
多线程扩容(resize方法)导致形成的链表环( transfer方法将所有表项从当前表转移到newTable
使链表成环造成线程不安全问题)!
看源码先看一下重要的几个成员变量:
put方法原理:
当调用HashMap.put(“qijian”,0),插入一个key为"qijian"的元素。Entry(key-value)插入的位置(index,table下标)由hash函数来决定。index = Hash(“qijian”)
当发生hash冲突时,当我们插入一个Entry的时候发现数组的对应下标中已经存在元素(链表的头节点)。此时新来的Entry节点插入链表,使用头插法插入。next一次指向下一个元素。使用头插法的原因:设计者认为后插入的元素被get的几率更大。
get方法原理:
首先对key做一次hash映射,得到index ( index = Hash(“qijian”) ) , 此时使用的hash需要与put时的hash函数是一样的。这时候只需要顺着对应链表的头节点,一个一个向下来查找,查找到key为qijian的Entry就是我们要查找的Entry。
扩容方法:
//将此映射的内容重新散列到一个容量更大的新数组中。当此映射中的键数达到其阈值时,将自动调用此方法。如果当前容量为MAXIMUM_CAPACITY,此方法不会调整map的大小,但将threshold设置为Integer.MAX_VALUE。这有防止未来调用的效果
//参数:newCapacity—新容量,必须是2的幂;必须大于当前容量,除非当前容量是MAXIMUM_CAPACITY(在这种情况下值无关)。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer方法源码:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
//当部位空时
while(null != e) {
//记录当前hash表的e.next
Entry<K,V> next = e.next;
if (rehash) {
//rehash计算出数组的位置(hash表中桶的位置)
e.hash = null == e.key ? 0 : hash(e.key);
}
//Returns index for hash code e.hash
int i = indexFor(e.hash, newCapacity);
//将e.nehfxt指向new hash表中的第一个元素
e.next = newTable[i];
//将e放入到new hash表的头部
newTable[i] = e;
//转移e到下一个节点, 继续循环下去
e = next;
}
}
}
假设:hash算法就是简单的key与length(数组长度)求余。hash表数组长度为2,如果不扩容, 那么元素key为3,5,7按照计算(key%table.length
)的话都应该碰撞到table[1]上。
扩容:hash表数组长度会扩容为4重新hash,key=3 会落到table[3]上(3%4=3), 当前e.next
为key(7), 继续while循环重新hash,key=7 会落到table[3]上(7%4=3), 产生碰撞,这里采用的是头插入法,所以key=7的Entry会排在key=3前面(这里可以具体看while语句中代码)当前e.next
为key(5), 继续while循环重新hash,key=5 会落到table[1]上(5%4=3), 当前e.next
为null, 跳出while循环,resize结束。
假设有两个线程正在进行扩容,且同时进入了transfer()方法。
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;
}
下面我们通过走读源码分析一下为什么多线程下HashMap是线程不安全的。
HashMap线程不安全主要体现在扩容多线程的扩容。这里详细分析一下。其主要分为两步:
1,扩容:创建一个新的Entry空数组,长度是原来数组的2倍。
2,Rehash:遍历数组,把所有的Entry重新Hash到新的数组。Hash公式:index = hashCode(key) & (Length - 1);
假设HashMap的负载因子为0.75f,且HashMap已经到了Resize()的临界点,数组长度为4,这时有两个线程对HashMap进行put操作:
此时,发现空间利用率达到了0.75,此时HashMap将进行扩容。
线程B执行到Entry
时被挂起,此时对于线程B来说:e = Entry3 ,next = Entry2
。这时候线程A也在进行着Rehash,完成后如下:
接下来,线程B恢复继续没有完成的操作,newTable[i] = e; e = next;
紧接着,进行下一轮的while循环,又执行到Entry
这时e = Entry2; next = Entry3
,如下:
接下来继续执行,利用头插法把Entry2插入到线程B数组的头节点,代码如下:
e.next = newTable[i];
newTable[i] = e;
e = next;
此时,e = Entry3;next = Entry3
.
接下来,第三次循环开始,再次执行到Entry
,此时:
e = Entry3 ; next = Entry3.next = null;
继续执行, e.next = newTable[i];
此时指向如下:
newTable[i] = Entry2; e = Entry3;
Entry2.next = Entry3 ; Entry3.next = Entry2;
很明显这里已经成环。此时。当调用Get查找一个不存在的Key,而这个Key的Hash结果恰好等于3的时候,由于位置3带有环形链表,所以程序将会进入死循环!
当链表长度大于阈值(默认为8)并且容量大于64时,将链表转化为红黑树,以减少搜索时间(空间换时间)。同时采用尾插法来解决扩容操作下的循环死链问题。 如果阈值大于8,但是容量小于等于64时,优先选择扩容。
红黑树就是为了解决二叉查找树的缺陷,二叉查找树在某些情况下会退化成一个线性结构。
在 JDK 1.8 中对 HashMap 进行了优化,在发生 hash 碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程的情况下仍然不安全
引入了新的数据结构,同时对put方法做了修改。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v0k0VXnu-1635672200016)(HashMap.assets/image-20211030234054834.png)]
和 HashMap 的扩容机制有关。数组容量是有限的,数据多次插入的,到达一定的数量就会进行扩容,也就是 resize。进行扩容时需要将扩容前的元素重新 hash 到新的数组中。
单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,旧数组中同一链表中元素可能被放到了新数组的不同位置上,一旦多线程并发调整完成,就可能出现环形链表。
使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。
参考:https://my.oschina.net/muziH/blog/1596801