参考文章:
-
https://www.cnblogs.com/myseries/p/10876828.html Java8 HashMap详解(转)
-
https://blog.csdn.net/ddou_pan/article/details/98514764 java集合HashMap中链表树化的条件
-
https://mp.weixin.qq.com/s?__biz=MzIxMjE5MTE1Nw==&mid=2653191907&idx=1&sn=876860c5a9a6710ead5dd8de37403ffc 漫画:什么是HashMap?
-
https://zhuanlan.zhihu.com/p/33714985 HashMap扩容
目录
结论
HashMap的线程不安全主要体现在下面两个方面:
-
在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。 -
在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。
Java7&Java8 HashMap
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。
根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
来一张图简单示意一下吧:

回答几个问题:
1.HashMap的什么时候扩容,哪些操作会触发
当向容器添加元素的时候,会判断当前数据存储的数量(不是节点的数量,而是占用数组的长度),如果大于等于阈值,即当前数组的长度乘以加载因子的值的时候,就要自动扩容。(HashMap.size >= Capacity * LoadFactor
)默认容量为16,扩容因子是0.75,阈值为12。
有参构造方法和put、merge操作都有可能导致扩容。
2.HashMap push方法的执行过程?
最先判断桶的长度是否为0,为0的话则需要先对桶进行初始化操作,接着,求出hashCode并通过扰动函数确定要put到哪个桶中,若桶中没有元素直接插入,若有元素则判断key是否相等,如果相等的话,那么就将value改为我们put的value值,若不等的话,那么此时判断该点是否为树节点,如果是的话,调用putTreeVal方法,以树节点的方式插入,如果不是树节点,那么就遍历链表,如果找到了key那么修改value,没找到新建节点插到链表尾部,最后判断链表长度是否大于8 是否要进行树化。
具体源码在下方
3.进行树化的条件
在HashMap具体实现类中,有两个成员变量,分别是TREEIFY_THRESHOLD = 8;
,MIN_TREEIFY_CAPACITY = 64;
,第一个变量是链表树化的阈值,在每次插入操作时,会检查链表中元素的个数是否达到阈值,达到阈值以后才会进行树化操作。而进行树化操作的第一个判断就是哈希数组的容量是否大于MIN_TREEIFY_CAPACITY,如果小于,进行扩容操作,而不是树化操作。

综上:
哈希表的链表树化成红黑树有 两个条件:
-
链表长度大于TREEIFY_THRESHOLD
-
哈希数组的长度大于MIN_TREEIFY_CAPACITY
3.HashMap检测到hash冲突后,将元素插入在链表的末尾还是开头?
因为JDK1.7是用单链表进行的纵向延伸,采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
4.JDK1.8还采用了红黑树,讲讲红黑树的特性,为什么人家一定要用红黑树而不是AVL、B树之类的?
在CurrentHashMap中是加锁了的,实际上是读写锁,如果写冲突就会等待,如果插入时间过长必然等待时间更长,而红黑树相对AVL树B树的插入更快,AVL树查询确实更快一些,但是对于操作密集型,红黑树的旋转更少,效率更高。
5.HashMap get方法的执行过程?
首先和put一样,确定对应的key在哪一个桶中,如果桶容量为0或者该桶内没有元素直接返回空,反之会判断该桶会检查桶中第一个元素是否和要查的key相等,相等的话直接返回,不相等的话判断该节点是否为树节点,是的话以树节点方式遍历整棵树来查找,不是的话那就说明存储结构是链表,以遍历链表的方式查找。
下面难度开始提高,属于了解内容,目前如果看不懂没关系
源码与其中的算法技巧
HashMap开胃菜
HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。
HashMap数组每一个元素的初始值都是Null。

对于HashMap,我们最常使用的是两个方法:get 和put
1.put方法
调用Put方法的时候发生了什么呢?比如调用 hashMap.put(“apple”, 0) ,插入一个Key为“apple”的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):
index = Hash("apple")
假定最后计算出的index是2,那么结果如下:

但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:

这时候该怎么办呢?我们可以利用链表来解决。
HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:

需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”。至于为什么不插入链表尾部,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大
思路讲解完毕,源码在下面
2.get方法
使用Get方法根据Key来查找Value的时候,发生了什么呢?首先会把输入的Key做一次Hash映射,得到对应的index:
index = Hash("apple")
由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:

第一步,我们查看的是头节点Entry6,Entry6的Key是banana,显然不是我们要找的结果。
第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果。
哈希表的初始长度
HashMap的默认初始长度是16,并且每次自动扩展或是手动初始化时,长度必须是2的幂,那16有什么特殊意义呢?

之前说过,从Key映射到HashMap数组的对应位置,会用到一个Hash函数:
index = Hash("apple")
如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算。

index = HashCode(Key) % Length ?
错!取模运算的方式固然简单,但是效率很低。为了实现高效的Hash算法, HashMap的发明者采用了位运算的方式
如何进行位运算呢?有如下的公式(Length是HashMap的长度):
index = HashCode(Key) & (Length - 1)
( 这是JDK1.8的,对于公式,网上有另一种说法:index = HashCode(key)% Length,这是JDK1.7的,当你经过运算的时候,你就会发现,两种方式得到的结果是一致的,所以都正确。实际上在jdk1.7中使用的是取模算法,而jdk1.8中使用的是高位与运算。因为&运算比%运算速度更快)
下面我们以值为“book”的Key来演示整个过程:
-
计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
-
假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
-
把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。

这样做不但效果上等同于取模,而且还大大提高了性能。至于为什么采用16,我们可以试试长度是10会出现什么问题,假设HashMap的长度是10,重复刚才的运算步骤:

单独看这个结果,表面上并没有问题,我们再来尝试一个新的HashCode 101110001110101110 1011 :

让我们再换一个 HashCode 101110001110101110 1111 试试 :

是的,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现,比如0111
显然不符合 Hash 算法均匀分布的原则。
而反观长度16或者其他2的幂,Length-1 的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的
难度继续加大,进入到源码分析
构造方法
public HashMap(int initialCapacity, float loadFactor) {
// 当指定的 initialCapacity (初始容量) < 0 的时候会抛出 IllegalArgumentException 异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 当指定的 initialCapacity (初始容量)= MAXIMUM_CAPACITY(最大容量) 的时候
if (initialCapacity > MAXIMUM_CAPACITY)
// 初始容量就等于 MAXIMUM_CAPACITY (最大容量)
initialCapacity = MAXIMUM_CAPACITY;
// 当 loadFactory(负载因子)< 0 ,或者不是数字的时候会抛出 IllegalArgumentException 异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
// tableSizeFor()的主要功能是返回一个比给定整数大且最接近2的幂次方整数
// 比如我们给定的数是12,那么tableSizeFor()会返回2的4次方,也就是16,因为16是最接近12并且大于12的数
this.threshold = tableSizeFor(initialCapacity);
}
注意:创建HashMap对象的时候没有初始化table,要到put的时候才会初始化
执行顺序注释写的很清楚了,但有些同学对最后对 tableSizeFor 方法很有疑问,这是用来求传入容量的最小2的幂次方整数的。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这是一系列的或操作,举个例子,假设cap = 65
cap = 65;
n = cap - 1 = 64 =100 0000 // n = cap - 1;
100 0000|010 0000=110 0000 // n |= n >>> 1
110 0000|001 1000=111 1000 // n |= n >>> 2
111 1000|000 0111=111 1111 // n |= n >>> 4
111 1111|111 1111=111 1111 // n |= n >>> 8
111 1111|111 1111=111 1111 // n |= n >>> 16
111 1111 + 1 = 1000 0000 // return 128
看出规律来了吧,右移多少位,就把最高位右边的第x位设置为1;第二次,就把两个为1的右边xx位再设置为1;第n次,就把上一步出现的1右边xxxx位置为1;
这样执行完,原来是1000000,变成了1111111,最后加1,就变成2的整数次方数了。之所以先减一是因为有可能本身就是最小2的幂次方整数。
put方法
put方法的核心就是 putVal,源码和执行过程如下。
最先判断桶的长度是否为0,为0的话则需要先对桶进行初始化操作,接着,求出hashCode并通过扰动函数确定要put到哪个桶中,若桶中没有元素直接插入,若有元素则判断key是否相等,如果相等的话,那么就将value改为我们put的value值,若不等的话,那么此时判断该点是否为树节点,如果是的话,调用putTreeVal方法,以树节点的方式插入,如果不是树节点,那么就遍历链表,如果找到了key那么修改value,没找到新建节点插到链表尾部,最后判断链表长度是否大于8 是否要进行树化。
//实现 put 和相关方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果table为空或者长度为0,则进行resize()(扩容)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//确定插入table的位置,算法是上面提到的 HashCode(Key) & (Length - 1),在 n 为 2 的时候,相当于取模操作
if ((p = tab[i = (n - 1) & hash]) == null)
//找到key值对应的位置并且是第一个,直接插入
tab[i] = newNode(hash, key, value, null);
//在table的 i 的位置发生碰撞,分两种情况
// 1、key值是一样的,替换value值
// 2、key值不一样的
// 而key值不一样的有两种处理方式:1、存储在 i 的位置的链表 2、存储在红黑树中
else {
Node<K,V> e; K k;
//第一个Node的hash值即为要加入元素的hash
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 {
//如果不是TreeNode的话,即为链表,然后遍历链表
for (int binCount = 0; ; ++binCount) {
//链表的尾端也没有找到key值相同的节点,则生成一个新的Node
//并且判断链表的节点个数是不是到达转换成红黑树的上界达到,则转换成红黑树
if ((e = p.next) == null) {
//创建链表节点并插入尾部
p.next = newNode(hash, key, value, null);
//超过了链表的设置长度(默认为8)则转换为红黑树
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;
}
}
//如果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;
}
为了方便理解,配图

扩容方法
putVal中有一段代码提到了resize(),也就是扩容方法
HashMap的容量是有限的。当经过多次元素插入的时候,使得HashMap达到一定的饱和度,Key映射位置的几率不断变大。这个时候,HashMap就需要扩容了,也就是Resize
扩容方法的发生条件
当向容器添加元素的时候,会判断当前数据存储的数量(不是节点的数量,而是占用数组的长度),如果大于等于阈值,即当前数组的长度乘以加载因子的值的时候,就要自动扩容。(
HashMap.size >= Capacity * LoadFactor
)默认容量为16,扩容因子是0.75,阈值为12。有参构造方法和put、merge操作都有可能导致扩容。
影响发生Resize的因素有两个,分别是capacity
和loadFactor
capacity
Capacity是HashMap的当前长度,HashMap的长度必是2的幂。
// 测试一下:
// 请问如下map的长度分别为多少呢?
HashMap<String,String> map = new HashMap<String, String>(); // 16
HashMap<String,String> map = new HashMap<String, String>(8);// 8
HashMap<String,String> map = new HashMap<String, String>(5);// 5
原理很简单,就是HashMap的长度必是2的幂,默认大小是16(创建HashMap对象的时候没有初始化table,要到put的时候才会初始化,看上面两个源码就知道啦)
loadFactor
LoadFactor是HashMap负载因子,默认是0.75f。
当 HashMap.size >= Capacity * LoadFactor
时,HashMap可能会进行Resize。
也就是说Hashmap的扩容需要满足两个条件:当前数据存储的数量(即size())大小必须大于等于阈值;当前加入的数据是否发生了hash冲突。
因为上面这两个条件,所以存在下面这些情况
-
就是hashmap在存值的时候(默认大小为16,负载因子0.75,阈值12),可能达到最后存满16个值的时候,再存入第17个值才会发生扩容现象,因为前16个值,每个值在底层数组中分别占据一个位置,并没有发生hash碰撞。 -
当然也有可能存储更多值(超多16个值,最多可以存26个值)都还没有扩容。原理:前11个值全部hash碰撞,存到数组的同一个位置(这时元素个数小于阈值12,不会扩容),后面所有存入的15个值全部分散到数组剩下的15个位置(这时元素个数大于等于阈值,但是每次存入的元素并没有发生hash碰撞,所以不会扩容),前面11+15=26,所以在存入第27个值的时候才同时满足上面两个条件,这时候才会发生扩容现象。
我们来看下源码
思路都写在代码中啦,有点难度哦,慢慢理解哈
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) {
//判断旧表长度,如果当前长度超过 MAXIMUM_CAPACITY(最大容量值)
if (oldCap >= MAXIMUM_CAPACITY) { // MAXIMUM_CAPACITY = 1 << 30;
//直接更改新增阀值为 Integer.MAX_VALUE并返回旧表
threshold = Integer.MAX_VALUE;
return oldTab;
}
// newCap扩大到原来的两倍
// 同时如果小于这个 MAXIMUM_CAPACITY(最大容量值)并且大于 DEFAULT_INITIAL_CAPACITY (默认16)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
// 阀值也进行2倍扩大
newThr = oldThr << 1; // double threshold
}
// 如果旧表长度为0,但是阈值不为0,指定新增阀值
// 初始容量是放置在阈值,还记得构造方法最后一行吗
else if (oldThr > 0)
newCap = oldThr;
// 如果旧表长度为0,并且阈值为0,指定默认阈值和默认容量
else {
// 指定数组长度为默认长度 = 16
newCap = DEFAULT_INITIAL_CAPACITY; // DEFAULT_INITIAL_CAPACITY = 1 << 4 = 16
//使用默认的加载因子(0.75)
//指定新增的阀值为默认大小 = 0.75 * 16 = 12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新阈值等于0,给定一下
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;
//哈希值和原数组长度进行&操作,为0的节点则在原数组的索引位置
//非0的节点 则在原数组索引位置 + 原数组长度的新位置
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冲突(碰撞)
就是根据key即经过一个函数f(key)得到的结果的作为地址去存放当前的key,value键值对(这个是hashmap的存值方式),但是却发现算出来的地址上已经有数据。这就是所谓的hash冲突。
hash冲突的几种情况:
-
两个节点的key值相同(hash值一定相同),导致冲突 -
两个节点的key值不同,由于hash函数的局限性导致hash值相同,导致冲突 -
两个节点的key值不同,hash值不同,但hash值对数组长度取模后相同,导致冲突
hash冲突的方法
如何解决hash冲突?解决hash冲突的方法主要有两种,一种是开放寻址法,另一种是链表法 。
开放寻址法–线性探测
开放寻址法的原理很简单,就是当一个Key通过hash函数获得对应的数组下标已被占用的时候,我们可以寻找下一个空档位置
比如有个Entry6通过hash函数得到的下标为2,但是该下标在数组中已经有了其它的元素,那么就向后移动1位,看看数组下标为3的位置是否有空位
比如有个Entry6通过hash函数得到的下标为2,但是该下标在数组中已经有了其它的元素,那么就向后移动1位,看看数组下标为3的位置是否有空位

但是下标为3的数组也已经被占用了,那么久再向后移动1位,看看数组下标为4的位置是否为空
数组下标为4的位置还没有被占用,所以可以把Entry6存入到数组下标为4的位置。这就是开放寻址的基本思路,寻址的方式有很多种,这里只是简单的一个示例
链表法
链表法也正是被应用在了HashMap中,HashMap中数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可

JDK7和JDK8中的HashMap都是线程不安全的,主要体现在哪些方面?
只要是对于集合有一定了解的一定都知道HashMap是线程不安全的,我们应该使用ConcurrentHashMap。但是为什么HashMap是线程不安全的呢,之前面试的时候也遇到到这样的问题,但是当时只停留在知道是的层面上,并没有深入理解为什么是。于是今天重温一个HashMap线程不安全的这个问题。
首先需要强调一点,HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中会有数据覆盖这样的问题。
扩容引发的线程不安全
JDK1.7中的线程不安全
HashMap
的线程不安全主要是发生在扩容函数中,即根源是在transfer函数中,JDK1.7中HashMap
的transfer
函数如下:
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;
}
}
}
这段代码是HashMap的扩容操作,重新定位每个桶的下标(因为每个节点的下标都是通过HashCode(Key) & (Length - 1)
计算而得到,桶扩大了,length自然发生变化,也就导致了计算出的结果发生变化),并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。理解了头插法后再继续往下看是如何造成死循环以及数据丢失的。
现在进入烧脑过程,准备好了吗
扩容造成死循环和数据丢失的分析过程
假设现在有两个线程A、B同时对下面这个HashMap进行扩容操作:
正常扩容后的结果是下面这样的:

现在进行第一次循环

当线程A执行到上面transfer
函数的第11行代码时,CPU时间片耗尽,线程A被挂起。即如下图中位置所示:

此时线程A中:e=3、next=7、e.next=null

当线程A的时间片耗尽后,CPU开始执行线程B,并在线程B中成功的完成了数据迁移

重点来了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTable和table都是最新的,也就是说:7.next=3、3.next=null(上图)
随后线程A获得CPU时间片,数据恢复(e=3、next=7、e.next=null)继续执行newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:
接着继续执行第二轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,于是乎next=3(看上面注意部分)

然后并将7采用头插法的方式放入新数组中

并继续执行完此轮循环,结果如下:

执行第三次循环可以发现,next=e.next=null(对象关系还是前面说的,不要懵),所以此轮循环将会是最后一轮循环。
接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中

执行结果如下图所示:

上面说了此时e.next=null即next=null,当执行完e=null后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操作时会出现死循环。
并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。
怎么样,还看得懂吧,后面就简单啦~~
JDK1.8中的线程不安全
根据上面JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。
为什么说JDK1.8会出现数据覆盖的情况喃,我们来看一下下面这段JDK1.8中的put操作代码:
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)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
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;
}
其中第六行代码是判断是否出现hash碰撞

假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
图片解释一下吧~





除此之前,还有就是代码的第38行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。(这段话和上面的图解意思相似哦,仔细看看上面图片再来理解这段话会更好一些)
如果看完了,别忘了对这篇文章的结论在开头哦
end
您的点赞和观看是对本公众号的最大力支持~~
原文始发于微信公众号(一个调皮的bug):Java HashMap详解
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/43591.html