HashMap是线程安全的吗?为什么呢?
HashMap是线程不安全的!
线程不安全体现在JDK.1.7时在多线程的情况下扩容可能会出现死循环或数据丢失的情况,主要是在于扩容的transfer方法采用的头插法,头插法会把链表的顺序给颠倒过来,这是引起死循环的关键。在JDK1.8时在多线程的情况下扩容会可能会出现数据覆盖的情况,例如有两个线程A、B都在进行put操作(对HashMap进行put操作实际上是调用了putVal()方法),并且这两个线程hash函数计算出的插入下标是相同的,当线程A执行完putVal()方法中的一句用于判断有没有发生hash碰撞的代码后(下面源代码的15行),由于时间片耗尽导致被挂起(注意这个时候线程A的判断结果是没有发生hash碰撞,保存了这个判断结果,但是还没有执行下一句插入元素代码,这个时候被挂起了),而线程B得到时间片后也是调用putVal()方法插入元素,由于两个线程hash函数计算出的下标是一样的,并且前面线程A因为时间片到了还没来得及插入元素就被挂起了,所以这个时候线程B判断结果也是没有hash碰撞,直接在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,所以HashMap是线程不安全。
原因:
-
JDK1.7 中HashMap线程不安全体现在多线程扩容导致死循环、数据丢失
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]; //线程A执行完这句后因为时间片耗尽就被挂起了
newTable[i] = e;
e = next;
}
}
}
HashMap的扩容就是调用上面的transfer方法来实现的,扩容后要采用头插法将元素迁移到新数组中,头插法会将链表的顺序翻转,这也是形成死循环的关键点。
上面的代码主要看下面的四句代码
//重新定义下标
Entry<K,V> next = e.next;
//下面三句就是头插法的实现
e.next = newTable[i];
newTable[i] = e;
e = next;
模拟扩容造成死循环和数据丢失
假设现在有两个线程A、B同时对下面这个HashMap进行扩容操作:
正常扩容后的结果是下面这样的:
但是当线程A执行完上面transfer函数的第10行代码时,CPU时间片耗尽,线程A被挂起。即如下图中位置所示:
解析:线程A中:e=3、next=7、e.next=null,可以看到图1的链表中第一个是3,下一个是7,所以transfer函数第一次执行中e=3,next=7,本来根据图1的情况应该是e.next=7,但是线程A在transfer函数中执行到了 e.next = newTable[i]; 这句,而newTable[i]由于是刚扩容的所以是为null,所以执行这句后 e.next =null
线程A挂起后,此时线程B得到时间片后正常执行,并完成resize扩容操作,结果如下:
线程B完成扩容后,此时主内存中newTable和table都是最新的
也就是说主内存中7.next=3和3.next=null(这里我一开始就疑惑为什么线程B都扩容完了线程A拿到时间片后还要扩容?其实是因为线程A压根就不知道线程B已经扩容完了,线程A也不管你扩容完了没,反正就是继续执行自己之前没有执行完的代码,由于是根据线程B扩容完后存放在内存中的数据继续扩容的,所以线程A才会出现下面的数据丢失和死循环)
随后线程A获得CPU时间片继续执行newTable[i] = e和e = next;这两句代码,线程A在挂起之前的数据是e=3、next=7、e.next=null,执行这两句后结果是newTable[3] =3,e=7,执行完此轮循环后线程A的情况如下
解析:newTable[3] =3,e=7(这里的newTable[3] =3可以理解为指针,其实就是newTable[3] 是个数组,那它的值就是指向3这个节点,e=7的e可以理解为指针,从原本的指向3到指向7)
接着继续执行下一轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,此时next=3,并将7采用头插法的方式放入新数组中,并继续执行完此轮循环
//此时e=7,内存中7.next=3、3.next=null,newTable[3] =3,e=7
//JMM中规定所有的变量都存储在主内存中每条线程都有自己的工作内存,
//线程的工作内存中保存了该线程所使用的变量的从主内存中拷贝的副本。
//线程对于变量的读、写都必须在工作内存中进行,而不能直接读、写主内存中的变量。
//同时,本线程的工作内存的变量也无法被其他线程直接访问,必须通过主内存完成。
Entry<K,V> next = e.next;------->next=7.next=3;
//下面三句就是头插法的实现
e.next = newTable[i];----------》e.next=3;//注意这里的e还是7,这句就是7的指针指向3
newTable[i] = e; --------------》newTable[3]=e=7;
e = next;----------------------》e=next=3;
注意:JMM中规定所有的变量都存储在主内存(Main Memory)中,每条线程都有自己的工作内存(Work Memory),线程的工作内存中保存了该线程所使用的变量的从主内存中拷贝的副本。线程对于变量的读、写都必须在工作内存中进行,而不能直接读、写主内存中的变量。同时,本线程的工作内存的变量也无法被其他线程直接访问,必须通过主内存完成。
所以上面线程B执行后它的工作内存存储自己的执行结果7.next=3和3.next=null,然后主内存同步过去,最后线程A去主内存复制过来到自己的工作内存中
执行完此轮循环后结果如下
注意:当e=3时3.next=null这个是线程B执行后主内存中的结果
解析:这里唯一要注意的地方就是上面在执行 e.next = newTable[i];的时候结果是e.next=3;而这其中的e是7不是3!这句是把7和3连接起来,从7指向3。这里我一开始就把e.next=3;作为下一次循环的数据依据,然后就觉得应该是下面的图示情况,其实应该是当e=3的时候e.next=null作为数据依据
这种是错误的!
上轮next=3,e=3,执行下一次循环可以发现,3.next=null,所以此轮循环将会是最后一轮循环。
//此时e=3,内存中next=3,newTable[3]=7,e=3,e.next=null
//任何一个线程的执行情况应该是放在内存中的,所以并发的时候才会出问题
//例如这个线程A去内存中拿的数据是线程B执行后的数据,已经不是线程A之前存的数据了
Entry<K,V> next = e.next;------->next=null;
//下面三句就是头插法的实现
e.next = newTable[i];----------》e.next=7;
newTable[i] = e; --------------》newTable[3]=3;
e = next;----------------------》e=3;
执行完此轮循环后结果如下
当执行完上面的循环后发现next=null后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操作时会出现死循环。并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。
- JDK1.8 中HashMap线程不安全主要体现在数据覆盖
如果有两个线程A、B都在进行put操作(对HashMap进行put操作实际上是调用了putVal()方法),并且这两个线程hash函数计算出的插入下标是相同的,当线程A执行完putVal()方法中的一句用于判断有没有发生hash碰撞的代码后(下面源代码的15行),由于时间片耗尽导致被挂起(注意这个时候线程A的判断结果是没有发生hash碰撞,保存了这个判断结果,但是还没有执行下一句插入元素代码,这个时候被挂起了),而线程B得到时间片后也是调用putVal()方法插入元素,由于两个线程hash函数计算出的下标是一样的,并且前面线程A因为时间片到了还没来得及插入元素就被挂起了,所以这个时候线程B判断结果也是没有hash碰撞,直接在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
public V put(K key, V value) {
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;
//如果当前map中无数据,执行resize方法。并且返回n
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//判断有没有发生hash碰撞,没有就直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//否则的话,说明这上面有元素
else {
Node<K,V> e; K k;
//如果这个元素的key与要插入的一样,那么就替换一下,也完事。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//1.如果当前节点是TreeNode类型的数据,执行putTreeVal方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//还是遍历这条链子上的数据,跟jdk7没什么区别
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//2.完成了操作后多做了一件事情,判断,并且可能执行treeifyBin方法
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) //true || --
e.value = value;
//3.
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//判断阈值,决定是否扩容
if (++size > threshold)
resize();
//4.
afterNodeInsertion(evict);
return null;
}