目录
一、JDK1.7的HashMap
1.1 Java内存模型简介
1.2 扩容造成死循环分析过程
1.3 扩容造成数据丢失分析过程
二、JDK1.8的HashMap
2.1 并发环境下红黑树结构上的死循环问题
2.1.1 balanceInsertion()方法中的死循环
2.1.2 root()方法中的死循环
2.2 并发环境下添加操作出现数据覆盖导致数据缺失
2.3 总结
java.lang.Thread.State 类
三、如何保证HashMap的并发安全
3.1 synchronized关键字
3.2 可重入锁
四、JDK提供的线程安全的Map容器
4.1 synchronizedMap
4.2 ConcurrentHashMap
4.3 Hashtable
4.4 总结
面试官问:HashMap在并发情况下为什么造成死循环?这个问题是在面试时常问的几个问题,一般在问这个问题之前会问Hashmap和HashTable的区别?面试者一般会回答:Hashtable是线程安全的,HashMap是线程不安全的。
那么面试官就会紧接着问道,为什么HashMap不是线程安全的,会造成什么问题么?于是面试者就回答:HashMap在并发情况下的put操作会造成死循环。
这时候就会被面试官问:HashMap在并发为什么造成死循环?
很多面试者这时候就会一脸懵。没有过相关经验和深入的理解源码是很难回答这个问题的。
其实JDK1.7和JDK1.8的HashMap都是存在并发安全问题的,但是这两者的并发安全问题是不同的,所以需要分别去分析。下面我们就通过这两个JDK版本的HashMap源码,来深入分析两者所面对的并发安全问题,利用画图来更加直观的明白为什么HashMap会存在并发安全问题。
大家肯定都知道JDK1.7的HashMap在并发环境下,会出现两种可能的并发问题:
下面我们就先通过HashMap源码来验证下,多线程并发put操作为何会生成环形链表,产生死循环。
先用代码来模拟出现死循环的情况:
public class HashMapTest {
public static void main(String[] args) {
HashMapThread thread0 = new HashMapThread();
HashMapThread thread1 = new HashMapThread();
HashMapThread thread2 = new HashMapThread();
HashMapThread thread3 = new HashMapThread();
HashMapThread thread4 = new HashMapThread();
thread0.start();
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
class HashMapThread extends Thread {
private static AtomicInteger ai = new AtomicInteger();
private static Map map = new HashMap<>();
@Override
public void run() {
while (ai.get() < 1000000) {
map.put(ai.get(), ai.get());
ai.incrementAndGet();
}
}
}
上述代码比较简单,就是开多个线程不断进行put操作,并且HashMap与AtomicInteger都是全局共享的。在多运行几次该代码后,出现如下死循环情形:
其中有几次还会出现数组越界的情况:
这里我们着重分析为什么会出现死循环的情况,通过jps和jstack命名查看死循环情况,结果如下:
从堆栈信息中可以看到出现死循环的位置,通过该信息可明确知道死循环发生在HashMap的扩容函数中,根源在transfer函数中,jdk1.7中HashMap的transfer函数如下:
/**
* 分析:transfer(newTable);
* 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
* 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
* @param rehash 如果这里传入的是true,说明Hash种子已经更新,需要对所有的元素进行rehash重新计算Hash值。该操作比较消耗资源,这也是JDK1.7相对JDK1.8执行效率较低的原因
*/
void transfer(Entry[] newTable, boolean rehash) {
// 获取新数组的大小 = 获取新容量大小
int newCapacity = newTable.length
// 通过遍历 旧数组,将旧数组上的数据(键值对)转移到新数组中
for (Entry e : table) {
// 遍历桶中所有元素
while(null != e) {
Entry next = e.next;
// 如果是重新Hash,则需要重新计算hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 重新计算每个元素的存储位置,这里再次按照之前计算元素所在位置的方法重新进行一遍 Hash值 &(新数组长度 - 1)的计算,也是相当消耗资源的操作。1.8就采用扩容之后运用运算规律来对元素重新定位,这样相对要高效很多。
int i = indexFor(e.hash, newCapacity);
// 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将新插入数据的next指向原数组位置的链表头节点,然后将需放入的数据放到数组桶位置中,这样就实现了头插法将数据插入链表
// 即 扩容后,可能出现逆序:按旧链表的正序遍历链表、在新链表的头部依次插入
e.next = newTable[i];
// newTable[i]的值总是最新插入的值
newTable[i] = e;
// 访问下一个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点
e = next;
}
}
}
// 计算元素应该在table数组的哪个下标位置 直接用元素的hash值 和(数组长度 - 1)进行与运算,得到的结果就是该元素在table数组中的下标
static int indexFor(int h, int length) {
// 将对哈希码扰动处理后的结果 与运算(&) (数组长度-1),最终得到存储在数组table的位置(即数组下标、索引)
return h & (length-1);
}
总结下该函数的主要作用:
在对table进行扩容到newTable后,需要将原来数据转移到newTable中,注意最后三行代码,这里可以看出在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转,这里也是形成死循环的关键点。下面会进行详细分析。
在进行后续的详细分析之前,我们需要一定的多线程相关的前置知识,详细可以看这篇笔记
【Java内存模型】Java内存模型(JMM)详解以及并发编程的三个重要特性(原子性,可见性,有序性)_小七mod的博客-CSDN博客https://blog.csdn.net/cy973071263/article/details/104318355?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522166002758416780366577334%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=166002758416780366577334&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-104318355-null-null.nonecase&utm_term=JMM&spm=1018.2226.3001.4450简单点说就是在Java中分为主内存和工作内存:
由于JVM运行程序的实体是线程,而每个线程,JVM都会为其创建一个工作内存,工作内存是每个线程的私有数据区域,而Java内存模型规定中的变量都存储在主内存。主内存是共享数据,所有线程都能访问,但线程对变量的操作(读写值)都必须在工作内存中完成。简单说,就是线程修改共享数据时,会先把数据复制到工作空间中去,在工作空间中修改,修改完成以后,再刷新回内存中的数据 。即先读取,再操作,再写回。工作内存存放的是主内存中的副本,线程间的通信都需要通过主内存来完成。
如果线程去修改私有的数据,也就是线程自己的局部变量,那么直接在工作空间修改即可。
回到HashMap的问题,我们通过JDK1.7源码直到,HashMap中存储数据的成员属性
transient Entry[] table
并没有使用volatile修饰,并且它也是一个全局的变量,是存在主内存当中的。所以当线程去操作HashMap的时候,对于这个数组中的任何一个对象的修改,都是先从主内存读取到工作内存中,然后在工作内存中修改了这个对象,修改完成之后才会写回到主内存,如果在工作内存中没有修改从主内存上拷贝出来的数据,那么就不会做任何回写到主内存的操作。由此我们就能发现,在线程操作HashMap的过程中,看似好像是一个原子操作,但实际这其中包括了三个独立的操作,因为这些操作之间的间隙,在多个线程对HashMap进行操作的时候,就有可能出现错误,这也是导致HashMap线程不安全的最根本原因。
了解了上述的前置知识,我们就可以更好地理解下面要讲的源码流程。
JDK1.7在插入元素之前,会先判断当前是否需要扩容,如果需要,就先进行扩容,扩容完成之后再插入数据。
下面我们就只考虑扩容的过程,如下图,是还未执行扩容之前(还未执行resize()方法)HashMap的状态
最开始HashMap的size=2,key=3,7,5,则都在table[1]中。
如果在单线程环境下进行扩容,会创建一个size为4(扩大两倍)的新数组,并且将旧数组中的数据迁移到新数组中,最后的结果如下:
这里的转移过程,不再进行详述,只要理解transfer函数在做什么,其转移过程以及如何对链表进行反转应该不难。
下面假设在多线程环境下,有两个线程A和B都在进行put操作。线程A在第一次执行到transfer函数中第11行代码处挂起(还未执行第十一行代码),因为该函数在这里分析的地位非常重要,因此再次贴出来。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
Entry 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;
}
}
}
在线程A的工作空间中,从主内存中拷贝下来了创建好的新的扩容数组和旧数组,去将旧数组中的数据迁移到新数组中。此时新创建的数组每一个位置都是NULL。
线程A准备进入循环遍历旧数组,准备将每一个数组元素取出,并遍历挂在数组桶上的链表节点,将所有的数据都迁移到新数组对应的新位置上。
线程A在挂起前的执行步骤如下:
此时线程A中运行结果如下:
执行完成后,所有的数据节点就从旧数组上脱离了。这里要注意e和next都是线程A自己的私有变量,是不和其他线程共享的,这两个变量是一直存储在线程A的工作内存中的。
在线程A挂起前的所有操作中,只有e指向的节点3进行过修改操作,在工作内存中修改完数据之后应该是会回写到主内存的,但是因为在工作内存中修改数据和将修改后的数据回写到主内存是两步操作,所以这两者无法保证原子性,必然存在一定的时间间隙,这里我们就假定在工作内存中刚刚完成了对拷贝过来的3节点的next指向的需改,还没来得及回写,线程A就被挂起了。也就是说此时线程A做的修改操作,并没有同步到主线程上,并且除了节点3之外,线程A没有对其他任何数据进行修改操作(3节点的地址仍然是存储在旧数组table的桶中,上面图中的数组是新数组newTable,整个扩容操作,是不涉及到对旧数组的修改的,只不过是将旧数组中存储的节点地址在新数组的桶中再存储一份而已)。所以此时主线程上的所有数据还是和线程A进行操作之前是一样的。
在这种情况下,线程A自己以为已经修改了数据,但是实际主线程并没有修改,然后又有其他线程将主线程的数据成功修改了,当线程A继续执行自己剩下的逻辑时,就可能会出现错误。所以这就是HashMap有并发安全问题的原因,而且这个现象并不一定是每次执行都会出现,比如上面的测试样例代码,只有多执行几次的时候,才有可能碰到一次,因为必须两个线程之间的操作正好卡在那个时间点上才会出现问题,本质就是不同线程之间的拿到的数据信息不一致导致的。
线程A挂起后,此时线程B正常执行,并完成resize扩容操作,结果如下:
线程B完成了全部的扩容迁移过程,并且将修改后的数据全部都同步给了主内存,此时主内存中的数据也就已经迁移完成了。根据Java内存模型,现在newTable和table中的Entry都是主存中最新值:7.next=3,3.next=null。并且这些数据节点都已经存储在了新数组中。但是在线程A的工作内存中,线程A认为所有的数据还是保持着它挂起之前的那个状态,因为它还没有将主内存中新的数据同步到自己的工作空间中。
此时再切换回线程A上,在线程A挂起时工作内存中值如下:e=3,next=7,newTable[3]=null,当线程A被唤醒后,因为e和next是线程A的局部变量,它本身就是存储在线程A的工作内存中的,所以唤醒之后还是保持原来的样子。但是newTable数组、table旧数组和那些数据节点都是从主内存中拷贝下来的数据,所以根据MESI协议(一致性缓存协议),当主内存的数据修改之后,会通过总线通知其他的线程,告诉它们这些数据更新了,你们持有的旧数据已经失效了,线程的到这些通知之后,就会重新从中内存中更新最新的数据。不了解MESI一致性缓存协议的可以继续看将JMM的那篇文章。
所以线程A被唤醒之后,newTable数组桶中的数据、那些数据节点都已经发生了修改,线程A会从主内存中更新这些数据为最新的(这里我们假设为一次性把所有修改的数据都更新成最新的,只是为了方便我们后面的过程讲解。但实际情况不是一次性更新的,而是在线程A执行的过程中,需要用到哪些数据,在读取该数据的时候,发现总线上已经通知自己持有数据已经失效了,线程A才回去主内存中更新数据。不过我们这样假设并不影响最后的结果),也就是线程B处理完成之后的数据。但是table旧数组在transfer()迁移过程中,并没有涉及到对其的修改,也就是说table数组桶中的数据并没有发生改变,所以table旧数组不需要从主内存中拷贝更新。而且我们知道Java对象都是进行的地址传递,在table旧数组的桶中存储的都是那些数据节点的地址,在扩容过程中,数据节点的地址都是没有变化的,只是将扩容后的新数组桶中存储这些数据节点的地址而已。所以在线程A唤醒之后,遍历旧数组,还是会按照最初的旧数组的数据情况遍历到每一个桶上的节点,但是当遍历到桶上节点然后如果再想去遍历挂在这个桶上的链表,就无法实现了,因为数据节点是已经修改过了,已经不再是最初的那种链表结构了。
我们回到之前的分析,当线程A被唤醒,继续向下执行后,此时的数据情况如下:
此时线程A的数据情况如下图所示:
线程A就会在这个基础上,继续向下执行,代码执行过程如下:
1、执行第十一行:newTable[i] = e; 此时在线程A中,局部变量e为3,局部变量i为3,所以就相当于将newTable[3] = 3
2、执行第十二行:e = next; 此时在线程A中局部变量next为7,所以就相当于将e = 7
此时e != null,则继续进行下一次while循环
3、执行第五行:Entry
4、执行第九行:int i = indexFor(e.hash, newCapacity); 计算当前e = 7节点应该放在新数组的哪个下标位置,并赋值给局部变量i,计算得出i = 3
5、执行第十行:e.next = newTable[i]; 将e.next指向newTable[i],也就是e.next = newTable[3],这个newTable[3]也是直接从主内存获取的最新值,所以7.next = 3
6、执行第十一行:newTable[i] = e; 将newTable[3]设置为7
7、执行第十二行:e = next; 将局部变量next赋值给局部变量e,也就是e = 3。此时局部变量e和next都指向3
此时e != null,则继续进行下一次while循环
8、执行第五行:Entry
9、执行第九行:int i = indexFor(e.hash, newCapacity); 计算当前e = 3节点应该放在新数组的哪个下标位置,并赋值给局部变量i,计算得出i = 3
10、执行第十行:e.next = newTable[i]; 将e.next指向newTable[i],也就是3.next = newTable[3],所以3.next = 7
11、执行第十一行:newTable[i] = e; 将newTable[3]设置为3。本来3.next就是指向7,7.next就是指向3,这里修改的只是newTable[3]这个桶上存储的地址指向,并不影响3和7已经形成了环路这个事实,所以这一步操作就相当于把3和7对调了一下位置
12、执行第十二行:e = next; 将局部变量next赋值给局部变量e,也就是e = null。
此时e == null,所以结束循环,线程A完成了它的数据迁移流程。上述的所有修改,再修改完之后马上都回写给了主内存,也就是将主内存的数据都给更新了。
但是线程A迁移的结果是3和7形成了环路,在后续操作中只要涉及轮询HashMap的数据结构,遍历搜索到这个位置,就会在这里发生死循环,导致程序异常终止。这就是JDK1.7的HashMap扩容造成死循环原因。
最开始HashMap的状态如下:
下面假设在多线程环境下,有两个线程A和B都在进行put操作。线程A在第一次执行到transfer函数中第11行代码处挂起(还未执行第十一行代码),因为该函数在这里分析的地位非常重要,因此再次贴出来。注意这里假定线程A刚刚执行完第十一行代码,在工作区完成了对e.next的修改,但是还没有将修改结果同步回主线程,也就第此时在工作区7.next = null,但是在主内存中,7的next指向的还是最初的5节点。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
Entry 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;
}
}
}
线程A整体的执行过程和之前的分析是一样的,线程A在挂起前的执行步骤如下:
注意上述的结果,仅仅是在线程A的工作空间中如此,但是在主内存中,并不是这样的,因为线程A对主内存中拷贝下来的数据进行修改操作之后,还没来得及将修改结果同步给主内存就被挂起了。
此时线程B已获得CPU时间片,并完成resize扩容操作,线程B的运行结果如下:
此时线程B已经完成扩容,并且将修改结果都同步给了主内存,此时主内存的数据也是这样的。
此时切换回线程A,在线程A挂起时:e=7,next=5,newTable[3]=null。
这里注意一下,e本身是线程A的局部变量,但是它其中存储的地址是主内存的地址,也就是7节点,在HashMap的操作过程中,每个数据节点在主内存的地址都是不会改变的,改变的只是其next的指向,进而改变不同节点的连接顺序,但节点本身在主内存中的位置是不变的。所以此时e指向的7节点就是在主内存中真正的7节点,修改e后再将修改数据同步回主内存就相当于修改了节点7。
切换回线程A后,主内存中的数据已经被线程B修改了,线程A后续在工作空间中使用到被修改的主内存数据时,会得到总线的通知,告诉线程A此时在它工作空间中的数据已经过时了,需要从主内存中更新最新数据,然后线程A就会把相应的数据进行更新。线程A对已修改数据的更新应该不是同时发生的,而是线程A用到哪个数据的时候,才会去更新,但是这里为了方面后面的讲解,我们就一次性把所有的数据都更新好,这并不影响最后的分析结果。
线程A被唤醒后,线程A所面临的数据情况如下图:
唤醒线程A后,首先线程A会将之前在工作内存中修改的7.next = newTable[3]回写到主内存中,将主内存的7.next = null
1、执行第十一行newtable[i]=e:就将7放在了newTable[3]的位置,Java对象是地址传递,在数组中存储的都是地址。注意此时没有改变7.next的指向,7.next仍然指向null。此时局部变量next=5,然后将e = next = 5。接着进行下一次循环。在这一步中,原本在newTable[3]上的3节点就被7给覆盖了,3节点就丢失了。
2、进入下一轮循环,执行第五行 Entry
3、执行第十行 e.next = newTable[i]:接着将e.next=newTable[1],此时enewTable[1]也是从主内存中获取到的最新值,所以e.next=5,也就是5.next = 5,形成了一个环路。
4、执行第十一行 newTable[i] = e:然后将newTable[1]=e,也就是newTable[1]=5。
5、执行第十二行 e = next:最后e=next ,也就是 e=null,至此e==null,就会结束循环。
3元素丢失,并形成环形链表,在后续操作hashmap时会造成死循环。
JDK1.8插入元素的流程和JDK1.7有所不同,JDK1.7是先扩容,再插入数据,而JDK1.8是先插入数据,插入完数据之后再去检查当前元素数量是否达到了扩容阈值,达到了则去执行扩容,也就是说JDK1.8是先插入,再扩容。
并且JDK1.8插入数据和扩容迁移数据都使用的尾插法。如果数组桶上挂的是链表,那么插入的数据就会尾插到链表上;如果数组桶上挂的是红黑树,那么插入的数据会先插入到红黑树相应的叶子节点下,然后再去维护红黑树结构。而JDK1.7的插入数据和扩容迁移数据的时候都是用的头插法。
虽然JDK1.8做了一些优化,但是它仍然是一个并发不安全的类,在多线程环境他,它存在如下问题:
上面讲JDK1.7的时候,讲过扩容导致的死循环问题,究其原因是因为扩容迁移数据的时候采用的是头插法,因为如果采用头插法,必然会涉及到让遍历到节点的next指向数组桶位置的节点(也就是链表头节点),但是标识当前遍历到的节点的标识e和标识其下一个节点next这两个变量都是线程自己独有的私有变量,所以当有多个线程同时操作同一个对象时,就可能出现不同线程之间内部属性不一致的情况,进而导致让某一个节点e的next指向数组桶上的节点,但是此时数组桶上的节点已经指向了e了,这就造成了一个链表上的死循环,导致了并发错误。
但是JDK1.8扩容迁移数据的时候,采用的是尾插法,直接将数据插入到链表或者红黑树的尾部。这样每次将节点加入到链表的时候,都是先遍历到链表尾部,这个过程中肯定遍历的是正确的链表,一直遍历到链表尾部null节点时才回去执行插入操作,也就不会出现那种链表死循环的情况了。
但是!!需要注意的一点是,JDK1.8虽然解决了并发环境下链表上出现死循环的问题,但是如果是将数据加入到红黑树中,在红黑树结构上还是有可能出现死循环问题的。下面我们就来分析一下。
我们就不再用例子去分析了,而是直接用实验的方法去验证死循环的情况。
实验环境是jdk1.8.0_60,我们程序的含义是两个线程向同一个map添加元素,分别添加50000个不重复的元素,程序如下:
public class HashMapMultiThread {
static Map map = new HashMap<>();
public static class AddThread implements Runnable{
int start;
public AddThread(int start){
this.start=start;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
//添加元素
for(int i = start ; i<10000000;i+=2){
map.put(Integer.toString(i),Integer.toBinaryString(i));
}
}
}
public static void main(String[] args) throws InterruptedException {
//开启两个线程
Thread t1 = new Thread(new AddThread(0));
Thread t2 = new Thread(new AddThread(1));
t1.start();
t2.start();
//主线程等待两个线程执行完
t1.join();
t2.join();
System.out.println(map.size());
}
}
该程序预测会产生三种结果:
经过多次实验,没有出现第一种结果;第二种结果和第三种结果可以得到,这时就可以得出一个结论多线程并发操作共享HashMap是线程不安全的,多个线程操作HashMap同一个位置,由于HashMap没有线程可见性,此时后一个线程会将前一个线程添加的元素覆盖掉(第二种结果的原因),有时会产生死循环(第三种结果的原因)
2.1.1.1 验证死循环的结果
我们使用jps和jstack拿到线程dump,观察该java进程下各个线程的运行状态
C:\Users\SJS>jps
30336 Main
21048 HashMapMultiThread
C:\Users\SJS>jstack 21048
打印出的堆栈信息如下:
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.60-b23 mixed mode):
//重点看这里
"Thread-1" #15 prio=5 os_prio=0 tid=0x000000001d389000 nid=0x1340 runnable [0x000000001ddce000]
java.lang.Thread.State: RUNNABLE
at java.util.HashMap$TreeNode.balanceInsertion(HashMap.java:2221)
at java.util.HashMap$TreeNode.treeify(HashMap.java:1930)
at java.util.HashMap$TreeNode.split(HashMap.java:2153)
at java.util.HashMap.resize(HashMap.java:713)
at java.util.HashMap.putVal(HashMap.java:662)
at java.util.HashMap.put(HashMap.java:611)
at com.thinkcoder.concurrenterror.HashMapMultiThread$AddThread.run(HashMapMultiThread.java:38)
at java.lang.Thread.run(Thread.java:745)
"Thread-0" #14 prio=5 os_prio=0 tid=0x000000001d38b000 nid=0x98c4 runnable [0x000000001dcce000]
java.lang.Thread.State: RUNNABLE
at java.util.HashMap$TreeNode.putTreeVal(HashMap.java:2002)
at java.util.HashMap.putVal(HashMap.java:637)
at java.util.HashMap.put(HashMap.java:611)
at com.thinkcoder.concurrenterror.HashMapMultiThread$AddThread.run(HashMapMultiThread.java:38)
at java.lang.Thread.run(Thread.java:745)
//主线程在等待,是由于join的效果
"main" #1 prio=5 os_prio=0 tid=0x0000000003644000 nid=0x96c8 in Object.wait() [0x000000000343e000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x0000000702a2f420> (a java.lang.Thread)
at java.lang.Thread.join(Thread.java:1245)
- locked <0x0000000702a2f420> (a java.lang.Thread)
at java.lang.Thread.join(Thread.java:1319)
at com.thinkcoder.concurrenterror.HashMapMultiThread.main(HashMapMultiThread.java:49)
从线程堆栈信息中可以看出,Thread0和Thread1处于运行状态而main(主)线程处于等待状态,就是等着Thread0和Thread1执行完。但是无奈啊,这两个线程都在正常运行但是程序一直结束不了,这就是死循环的现象。
我们按照Thread1的线程信息定位到balanceInsertion方法第2221行代码
断点调试该行代码,发现该方法中的for循环不会终止,确实发现了死循环现象
2.1.1.2 分析死循环的原因
由上面的实验分析可以知道,出现循环的是balanceInsertion()方法,这个方法是插入红黑树节点的方法,在插入红黑树节点的同时,还需要重新维护红黑树结构,使其继续符合红黑树的性质,保证其相对平衡。
分析一下balanceInsertion方法源代码
static TreeNode balanceInsertion(TreeNode root, TreeNode x) {
// 新插入的节点标为红色
x.red = true;
//无限for循环,定义xp、xpp、xppl、xppr变量,在循环体进行赋值,p就是parents
//- root:当前根节点
//- x :新插入的节点
//- xp :新插入节点的父节点
//- xpp :新插入节点的祖父节点
//- xppl:新插入节点的左叔叔节点
//- xppr:新插入节点的右叔叔节点
for (TreeNode xp, xpp, xppl, xppr;;) {
// 为定义的各个变量赋值的过程
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
else if (!xp.red || (xpp = xp.parent) == null)
return root;
// 重点看这里
// 如果父节点是爷爷节点的左孩子
if (xp == (xppl = xpp.left)) {
// 如果右叔叔不为空且为红色
if ((xppr = xpp.right) != null && xppr.red) {
// 右叔叔变为黑色
xppr.red = false;
// 父节点变为黑色
xp.red = false;
// 爷爷节点变为黑色
xpp.red = true;
// 将爷爷节点当作起始节点,再次循环,请注意再次循环!!!
x = xpp;
}
// 省略其他代码
}
// 省略其他代码
}
}
总结一下上边的源码就是,新插入一个节点,该方法要保持红黑树的五个性质:
性质1. 节点是红色或黑色。
性质2. 根节点是黑色。
性质3. 每个叶节点(NIL节点,空节点)是黑色的。
性质4. 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
性质5. 从任一节点到其每个叶子的路径上包含的黑色节点数量都相同。
解释下上面的例子为什么会产生死循环,我们把上面的图片复制下来
发现一个问题,根节点、爷爷节点、父节点、左叔叔节点、右叔叔节点、新插入的节点这几个变量都指向的是同一个元素667700,证明当前循环的树只有一个节点,并且永远不会退出,因为它永远满足下面两个判断条件
// 如果父节点是爷爷节点的左孩子
if (xp == (xppl = xpp.left)) {
// 如果右叔叔不为空且为红色
if ((xppr = xpp.right) != null && xppr.red)
红黑树中的死循环还会出现在别的方法中,下面我们继续利用上述的样例执行结果,通过查看线程状态信息来定位死循环位置并分析原因。
多个线程全部全部在代码块的1816行,一直循环,无法退出for循环。
通过查看源码,发现程序是卡在了这个for循环中,看代码情况只可能是两个红黑树节点的父亲节点相互引用才可以导致无法走出这个for语句。
final TreeNode root() {
for (TreeNode r = this, p;;) {
// 当节点没有父节点的时候,该节点即为根节点
if ((p = r.parent) == null)
return r;
// 当前遍历到的节点设置为其父节点,实现向上层遍历
r = p;
}
}
这个方法用来查找红黑树的根节点。向上层遍历,通过判断有没有父节点来找出根节点
我们在详细的去看一下我们的猜想对不对,dump下堆内存信息,通过jhat 命令生成html的内存信息页面
然后输入http://localhost:7000查看
我先找业务代码中持有这个HashMap的对象,然后点进去查询内部信息
因为数据都放在table中,点击Table字段,查看其内容
table中存在唯一的一个TreeNode节点,这肯定是已经变成了红黑树了
点进去查看
点击parent字段信息,查看parent对象的信息
我们发现0x72745d828与0x72745d7b8两个TreeNode节点的Parent引用都是对方。所以就找到了出现死循环的原因,就是两个红黑树节点的父节点相互依赖。
这个情况出现在多个线程向同一个HashMap中添加数据的时候,这里我们看一下jdk1.8中HashMap的put操作源码:
/**
* Implements Map.put and related methods.
* 实现了map的put和相关方法
* @param hash key的hash值(key的hash高16位+高16位与低16位的异或运算)
* @param key 键
* @param value 值
* @param onlyIfAbsent onlyIfAbsent为true的时候不要修改已经存在的值,如果onlyIfAbsent为false,当插入的元素已经在HashMap中已经拥有了与其key值和hash值相同的元素,仍然需要把新插入的value值覆盖到旧value上。如果onlyIfAbsent为true,则不需要修改
* @param evict evict如果为false表示构造函数调用
* @return 返回旧的value值(在数组桶或链表或红黑树中找到存在与插入元素key值和hash值相等的元素,就返回这个旧元素的value值),如果没有发现相同key和hash的元素则返回null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab用来临时存放数组table引用 p用来临时存放数组table桶中的bin
// n存放HashMap容量大小 i存放当前put进HashMap的元素在数组中的位置下标
Node[] tab; Node p; int n, i;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞,则直接插入元素
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else {
// e记录当前节点 k记录key值
Node e; K k;
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录。直接将插入的新元素覆盖旧元素
e = p;
// hash值不相等,即key不相等并且该节点为红黑树结点,将元素插入红黑树
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode)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);
// 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
// 这个treeifyBin()方法会根据 HashMap 数组情况来决定是否转换为红黑树。
// 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少执行效率。否则,就是只是对数组扩容。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 树化操作
treeifyBin(tab, hash);
// 跳出循环 此时e=null,表示没有在链表中找到与插入元素key和hash值相同的节点
break;
}
// 判断链表中结点的key值和Hash值与插入的元素的key值和Hash值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 若相等,则不用将其插入了,直接跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 当e!=null时,表示在数组桶或链表或红黑树中存在key值、hash值与插入元素相等的结点。此时就直接用原有的节点就可以了,不用插入新的元素了。此时e就代表原本就存在于HashMap中的元素
if (e != null) {
// 记录e的value,也就是旧value值
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null,则需要用新的value值对旧value值进行覆盖
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 替换旧值时会调用的方法(默认实现为空)
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改,记录HashMap被修改的次数,主要用于多线程并发时候
++modCount;
// 实际大小大于阈值则扩容 ++size只有在插入新元素才会执行,如果发现HashMap中已经存在了相同key和hash的元素,就不会插入新的元素,在上面就已经执行return了,也就不会改变size大小
if (++size > threshold)
resize();
// 插入成功时会调用的方法(默认实现为空)
afterNodeInsertion(evict);
// 没有找到原有相同key和hash的元素,则直接返回Null
return null;
}
这是jdk1.8中HashMap中put操作的主函数, 注意这一行代码if ((p = tab[i = (n - 1) & hash]) == null),如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入第6行代码中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。
JDK1.8的HashMap中出现这些并发问题,归根结底还是多线程下操作同一对象时,对象内部属性的不一致性导致的。
上述讲的主要是排查HashMap出现问题时的方法,并且定位到出现问题的具体位置,进而分析原因。这种排查手段是很值得大家去学习的。
下面补充一下线程状态的相关知识:
public static enum Thread.Stateextends Enum
1.NEW
至今尚未启动的线程的状态。
2.RUNNABLE
可运行线程的线程状态。处于可运行状态的某一线程正在 Java 虚拟机中运行,但它可能正在等待操作系统中的其他资源,比如处理器。
3.BLOCKED
受阻塞并且正在等待监视器锁的某一线程的线程状态。处于受阻塞状态的某一线程正在等待监视器锁,以便进入一个同步的块/方法,或者在调用 Object.wait 之后再次进入同步的块/方法。
4.WAITING
某一等待线程的线程状态。某一线程因为调用下列方法之一而处于等待状态:
处于等待状态的线程正等待另一个线程,以执行特定操作。 例如,已经在某一对象上调用了 Object.wait() 的线程正等待另一个线程,以便在该对象上调用 Object.notify() 或 Object.notifyAll()。已经调用了 Thread.join() 的线程正在等待指定线程终止。
5.TIMED_WAITING
具有指定等待时间的某一等待线程的线程状态。某一线程因为调用以下带有指定正等待时间的方法之一而处于定时等待状态:
6.TERMINATED
已终止线程的线程状态。线程已经结束执行。
注意:在给定时间点上,一个线程只能处于一种状态。这些状态是虚拟机状态,它们并没有反映所有操作系统线程状态。
通过上面的分析,我们知道了在HashMap在进行添加/取出操作时未进行线程安全的控制,为了使HashMap是线程安全的,我们可以在对HashMap进行操作时加锁,使用synchronized关键字或者可重入锁,进而实现并发安全。
为HashMap的操作加synchronized关键字以保证其线程安全,由于synchronized有两种用法,即可以使用在代码块上,也可以作用于方法上,这里演示作用于方法上。
HashMap map1 = new HashMap();
public synchronized void putMap() {
map1.put("test", "test");
}
使用ReentrantLock可重入锁控制HashMap的插入。
HashMap map1 = new HashMap();
public void putMapUseLock() {
ReentrantLock rl = new ReentrantLock();
try {
rl.lock();
map1.put("test", "test");
} finally {
rl.unlock();
}
}
以上时两种解决HashMap线程不安全的解决思路,但是如果必须要在多线程环境下使用Map容器,不如直接用JDK提供的线程安全的Map类。
由于HashMap使用的范围很广,所以JDK提供了线程安全的HashMap,说两个常用的ConcurrentHashMap和synchronizedMap,这两个都是线程安全的,但其实现原理不尽相同。
synchronizedMap是Collections类的静态内部类,使用方法如下:
HashMap map1 = new HashMap();
Map map = Collections.synchronizedMap(map1);
使用其静态方法synchronizedMap(),传入一个Map对象
public static Map synchronizedMap(Map m) {
return new SynchronizedMap<>(m);
}
返回的synchronizedMap对象,其构造方法如下:
private final Map m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
第一步判断参数m是否为null,第二步把this赋值给mutex,那么mutex代表什么意思。下面看一下put操作源码:
public V put(K key, V value) {
synchronized (mutex) {
return m.put(key, value);
}
}
看到上面的方法,想必都很惊讶,使用了synchronized代码块,而mutex相当于共享的对象,调用的还是参数m的put方法,所以这里如果m的指向为不安全的HashMap,那么加上synchronized之后便是安全的。
get方法如下:
public V get(Object key) {
synchronized (mutex) {
return m.get(key);
}
}
总结下来,SynchronizedMap是使用Synchronized关键字和HashMap实现的。
通过这个类的名字,可以看出其在java.util.concurrent包下,且是为HashMap提供并发操作的类。下面我们以JDK1.8的ConcurrentHashMap为例子讲解,其使用方式如下:
ConcurrentHashMap map2=new ConcurrentHashMap();
下面看其构造方法:
/**
* Creates a new, empty map with the default initial table size (16).
*/
public ConcurrentHashMap() {
}
很简洁,通过注释可得知构造一个空的Map,其容量为16,和HashMap是一样的,但是这里没有负载因子。再看其他的构造方法
看下其put方法:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key和value都不能为null
if (key == null || value == null) throw new NullPointerException();
// 计算hash值
int hash = spread(key.hashCode());
// 记录新插入元素所在桶的元素个数
int binCount = 0;
// 死循环,结合CAS使用(如果CAS失败,则会重新取整个桶进行下面的流程)
for (Node[] tab = table;;) {
// 记录新插入元素所在的桶上的元素
Node f;
// n:数组长度
// i:新插入元素所在桶的数组下标
// 记录新插入元素所在的桶上的元素的Hash值
int n, i, fh;
// 判断数组是否初始化
if (tab == null || (n = tab.length) == 0)
// 如果桶未初始化或者桶个数为0,则初始化桶
tab = initTable();
// 判断新插入元素所在桶上是否存在元素
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果要插入的元素所在的桶还没有元素,则把这个元素插入到这个桶中
if (casTabAt(tab, i, null, new Node(hash, key, value, null)))
// 如果使用CAS插入元素时,发现已经有元素了,则进入下一次循环,重新操作
// 如果使用CAS插入元素成功,则break跳出循环,流程结束
break; // no lock when adding to empty bin
}
// hash值为MOVED=-1,表示当前节点已经被迁移了,即处于扩容状态
else if ((fh = f.hash) == MOVED)
// 如果要插入的元素所在的桶的第一个元素的hash是MOVED,则当前线程帮忙一起迁移元素
tab = helpTransfer(tab, f);
else {
// 如果这个桶不为空且不在迁移元素,则锁住这个桶,使用synchronized进行加锁
// 并查找要插入的元素是否在这个桶中
// 存在,则替换值(只有当传参onlyIfAbsent = false时,才进行替换,否则不替换)
// 不存在,则插入到链表结尾或插入到红黑树中
// 记录旧value
V oldVal = null;
// 对当前的桶进行加锁(传参为f对象,只要将这个同上的对象加锁了,那么这个桶上挂的链表或者红黑树就都不能被操作修改了,因为头(根)节点已经被加锁了,无法去遍历链表或红黑树了)
// 这个就是一个对象锁,锁的粒度只有当前桶,比JDK1.7的分段锁锁粒度变小了,所以并发性能会更好
synchronized (f) {
// 再次检测桶中的元素是否有变化,如果有变化则进入下一次循环,从头来过
if (tabAt(tab, i) == f) {
// 如果桶中元素的hash值大于等于0(说明不是在迁移,也不是树)
// 那就是桶中的元素使用的是链表方式存储
if (fh >= 0) {
// 桶中元素个数赋值为1
binCount = 1;
// 遍历整个桶上的链表,每遍历一个节点就将binCount加1
for (Node e = f;; ++binCount) {
// 记录链表上节点的key
K ek;
// 如果链表上节点的Hash值、key值都和新插入元素的相同,则说明在链表上已经存在相同key和Hash值的元素了
if (e.hash == hash &&
((ek = e.key) == key || (ek != null && key.equals(ek)))) {
// 如果找到了这个元素,则赋值了新值(onlyIfAbsent=false)
// 并退出循环
// 记录旧值
oldVal = e.val;
// 根据方法传参的onlyIfAbsent来决定是不是要用新value覆盖旧value
if (!onlyIfAbsent)
e.val = value;
// 结束遍历链表的循环
break;
}
// 向后遍历
Node pred = e;
if ((e = e.next) == null) {
// 如果到链表尾部还没有找到元素
// 就把它插入到链表结尾并退出循环(尾插法)
pred.next = new Node(hash, key,
value, null);
break;
}
}
}
// 当前桶上的节点类型如果是红黑树类型,则说明挂在桶上的是红黑树结构
else if (f instanceof TreeBin) {
// 如果第一个元素是树节点
Node p;
// 桶中元素个数赋值为2
binCount = 2;
// 调用红黑树的插入方法插入元素
// 如果成功插入则返回null
// 否则返回寻找到的节点
if ((p = ((TreeBin)f).putTreeVal(hash, key, value)) != null) {
// 如果找到了这个元素,则赋值了新值(onlyIfAbsent=false)
// 并退出循环
oldVal = p.val;
// 根据方法传参的onlyIfAbsent来决定是不是要用新value覆盖旧value
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 执行完上面的代码块之后,就会自动释放锁
// 如果binCount不为0,说明成功插入了元素或者寻找到了元素
if (binCount != 0) {
// 如果链表元素个数达到了8,则尝试树化
// 因为上面把元素插入到树中时,binCount只赋值了2,并没有计算整个树中元素的个数
// 所以不会对已经成为红黑树的结构进行重复树化
if (binCount >= TREEIFY_THRESHOLD)
// 进行树化
treeifyBin(tab, i);
// 如果要插入的元素已经存在,则返回旧值
if (oldVal != null)
return oldVal;
// 退出外层大循环,流程结束
break;
}
}
}
// 成功插入元素,元素个数加1(是否要扩容的判断操作在这个方法里面)
addCount(1L, binCount);
// 新元素在以前并不存在,并且成功插入新元素,则返回null
return null;
}
方法名称和HashMap是一样的,从putVal方法中看到了synchronized关键字,即也是使用synchronized关键字实现,但是肯定比synchronizedMap要高效,所以建议在多线程环境下,如有使用Map的需求,可以使用ConcurrentHashMap。
Hashtable也是一个线程安全的类,它是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,效率比较低,这个方法就不建议使用了。
相关文章:【Java集合】HashMap系列(一)——底层数据结构分析
【Java集合】HashMap系列(二)——底层源码分析
【Java集合】HashMap系列(三)——TreeNode内部类源码分析
【Java集合】一文快速了解HashMap底层原理
【数据结构】史上最好理解的红黑树讲解,让你彻底搞懂红黑树
【Java内存模型】Java内存模型(JMM)详解
参考文章:
https://blog.csdn.net/shang_0122/article/details/117423601
https://blog.csdn.net/qq_33330687/article/details/101479385