1.1.1 *高并发下的HashMap*

1.1.1.1 *rehash操作*

随着操作的不断执行, 哈希表保存的键值对会逐渐地增多或者减少, 为了让哈希表的负载因子(load factor)维持在一个合理的范围之内, 当哈希表保存的键值对数量太多或者太少时, 程序需要对哈希表的大小进行相应的扩展或者收缩。

扩展和收缩哈希表的工作,可以通过执行 rehash (重新散列)操作来完成

Java——HashMap——3、高并发下的HashMap_高并发

影响发生Resize的因素有两个:

1、CapacityHashMap的当前长度。HashMap的长度是2的幂。

2、LoadFactorHashMap负载因子,默认值为0.75f。

衡量HashMap是否进行Resize的条件如下:

HashMap.Size >= Capacity * LoadFactor

resize不是简单的将长度扩大,而是做了两件事情:

1、扩容:创建一个新的Entry空数组,长度是原数组的2倍。

2、ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。

1.1.1.2 *高并发下的HashMap为什么会出现死锁*

hashmap不是线程安全的,多线程环境下,同时进行resize,,可能出现链表环,在下一次读操作时,出现死循环

元素从原表遍历后,保存到新表的过程,显示如下:

Java——HashMap——3、高并发下的HashMap_高并发_02

hashmap插入元素,是采用“头插法”的方式,即默认单线程下rehash就是把原来链表遍历,从新的链表头部挨个放入,hashmap的发明者认为,后插入的entry被遍历到的可能性更大

相当于,原表中的元素顺序为:1→2→3→4,保存到新表后,采用头插法,每个元素都从头开始插入,导致元素保存顺序是:4→3→2→1,相当于是队列的方式

这种方式,多线程下,就会出现死锁,查看源码:

源码中遍历原哈希表中的元素,保存到新的,扩容后的哈希表中,代码如下:

do { Entry<K,V> next = e.next;//记录原表当前节点e的下一个节点e.next,保存在next节点变量中, int i = indexFor(e.hash, newCapacity);//在新表中对e进行rehash,得打新表中e的哈希值i e.next = newTable[i];//将新表中计算出来的这个位置上本来的值,记录为e.next newTable[i] = e;//将原表当前节点e保存在新表中计算出来的节点位置上 e = next;//将原表记录的当前e节点的下一个节点e.next,赋值给e,作为下一轮的当前节点} while (e != null);

死锁情况:

Java——HashMap——3、高并发下的HashMap_数组_03

线程1此时记录的原表中,当前节点e是key3,当前节点的下一个节点e.next是key7

线程1被挂起,线程2进行执行,且线程2执行完成,记录的状态如下:

Java——HashMap——3、高并发下的HashMap_死锁_04

因为线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程2 rehash 后,就指向了线程2 rehash 后的链表。

第一步

然后线程1被唤醒了:

执行e.next = newTable[i],于是 key(3)的 next 指向了线程1的新 Hash 表,因为新 Hash 表为空,所以e.next = null,

执行newTable[i] = e,所以线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。

执行e = next,将 e 指向 next,所以新的 e 是 key(7)

第二步

然后该执行 key(3)的 next 节点 key(7)了:

现在的 e 节点是 key(7),首先执行Entry<K,V> next = e.next ,那么 next 就是 key(3)了

执行e.next = newTable[i],于是key(7) 的 next 就成了 key(3)

执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(7)

执行e = next,将 e 指向 next,所以新的 e 是 key(3)

这时候的状态图为:

Java——HashMap——3、高并发下的HashMap_多线程_05

第三步

然后又该执行 key(7)的 next 节点 key(3)了:

现在的 e 节点是 key(3),首先执行Entry<K,V> next = e.next,那么 next 就是 null

执行e.next = newTable[i],于是key(3) 的 next 就成了 key(7)

执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(3)

执行e = next,将 e 指向 next,所以新的 e 是 key(7)

这时候的状态如图所示:

Java——HashMap——3、高并发下的HashMap_数组_06

很明显,环形链表出现了!!当然,现在还没有事情,因为下一个节点是 null,所以transfer()就完成了,等put()的其余过程搞定后,HashMap 的底层实现就是线程1的新 Hash 表了。

没错,put()过程虽然造成了环形链表,但是它没有发生错误。它静静的等待着get()这个冤大头的到来。

死锁吧,骚年!!!

现在程序被执行了一个hashMap.get(11),这时候会调用getEntry(),这个函数就是去找对应索引的链表中有没有这个 key。然后。。。。悲剧了。。。Infinite Loop~~

1.1.1.3 *环链表是如何形成的*

在HashMap中,到底是怎样形成环形链表的?这个问题,得从HashMap的resize扩容问题说起!

当HashMap中的元素个数超过数组大小(数组总大小length,不是数组中个数size)loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12(这个值就是代码中的threshold值,也叫做临界值)的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

总得来说,就是拷贝旧的数据元素,从新新建一个更大容量的空间,然后进行数据复制!

那么关于环形链表的形成,则主要在这扩容的过程。当多个线程同时对这个HashMap进行put操作,而察觉到内存容量不够,需要进行扩容时,多个线程会同时执行resize操作,而这就出现问题了,问题的原因分析如下:

首先,在HashMap扩容时,会改变链表中的元素的顺序,将元素从链表头部插入。后插入的元素会最先被使用到。

而环形链表就在这一时刻发生,以下模拟2个线程同时扩容。

假设,当前hashmap的空间为2(临界值为1),hashcode分别为0和1,在散列地址0处有元素A和B,这时候要添加元素C,C经过hash运算,得到散列地址为1,这时候由于超过了临界值,空间不够,需要调用resize方法进行扩容,那么在多线程条件下,会出现条件竞争,模拟过程如下:

Java——HashMap——3、高并发下的HashMap_数组_07

Java——HashMap——3、高并发下的HashMap_链表_08

Java——HashMap——3、高并发下的HashMap_多线程_09

这个过程为,先将A复制到新的hash表中,然后接着复制B到链头(A的前边:B.next=A),本来B.next=null,到此也就结束了(跟线程二一样的过程),但是,由于线程二扩容的原因,将B.next=A,所以,这里继续复制A,让A.next=B,由此,环形链表出现:B.next=A; A.next=B

JDK7 中 HashMap成环的时机:

1:HashMap 扩容时。

2:多线程环境下。

java 8 是等链表整个 while 循环结束后,才给数组赋值,所以多线程情况下,也不会成环

1.1.1.4 *如何用程序判断出这个链表是有环链表*

方法一:首先从头节点开始,依次遍历单链表的每一个节点。每遍历到一个新节点,就从头节点重新遍历新节点之前的所有节点,用新节点ID和此节点之前所有节点ID依次作比较。

如果发现新节点之前的所有节点当中存在相同节点ID,则说明该节点被遍历过两次,链表有环;

如果之前的所有节点当中不存在相同的节点,就继续遍历下一个新节点,继续重复刚才的操作。

例如这样的链表:A->B->C->D->B->C->D, 当遍历到节点D的时候,我们需要比较的是之前的节点A、B、C,不存在相同节点。这时候要遍历的下一个新节点是B,B之前的节点A、B、C、D中恰好也存在B,因此B出现了两次,判断出链表有环。

方法二:首先创建一个以节点ID为键的HashSet集合,用来存储曾经遍历过的节点。然后同样是从头节点开始,依次遍历单链表的每一个节点。每遍历到一个新节点,就用新节点和HashSet集合当中存储的节点作比较,如果发现HashSet当中存在相同节点ID,则说明链表有环,如果HashSet当中不存在相同的节点ID,就把这个新节点ID存入HashSet,之后进入下一节点,继续重复刚才的操作。

这个方法在流程上和方法一类似,本质的区别是使用了HashSet作为额外的缓存。

方法三:首先创建两个指针1和2(在java里就是两个对象引用),同时指向这个链表的头节点。然后开始一个大循环,在循环体中,让指针1每次向下移动一个节点,让指针2每次向下移动两个节点,然后比较两个指针指向的节点是否相同。如果相同,则判断出链表有环,如果不同,则继续下一次循环。

例如链表A->B->C->D->B->C->D,两个指针最初都指向节点A,进入第一轮循环,指针1移动到了节点B,指针2移动到了C。第二轮循环,指针1移动到了节点C,指针2移动到了节点B。第三轮循环,指针1移动到了节点D,指针2移动到了节点D,此时两指针指向同一节点,判断出链表有环。

此方法也可以用一个更生动的例子来形容:在一个环形跑道上,两个运动员在同一地点起跑,一个运动员速度快,一个运动员速度慢。当两人跑了一段时间,速度快的运动员必然会从速度慢的运动员身后再次追上并超过,原因很简单,因为跑道是环形的。

1.1.1.5 *解决高并发下HashMap的安全问题*

避免HashMap的线程安全问题有多种方法,比如:

1、使用HashTable;

2、使用Collections.synchronizedMap (hashMap);

3、使用ConcurrentHashMap

方法1和方法2有着共同的缺点:性能

读写操作会给整个集合加锁,导致同一时间的其他操作堵塞,只能顺序执行,严重影响性能