根据JDK源码理解hashmap的并发死循环

本文章,不是用来介绍所谓的hashmap的实现原理或者使用方法,而是就几个常见问题进行一下个人详细理解的阐述!

有关hashmap的几个问题:

1.hashmap的容量为什么是2的幂次方?
2.hashmap在插入元素的时候,采用的是头插还是尾插?
3.hashmap的默认容量是多少?什么时候扩容?
4.为什么hashmap在多线程情况下是不安全的?(有可能产生死循环)
  其在什么情况下会产生死循环?(扩容的时候)
  为什么会产生死循环?

我们稍微进行一下hashmap基础知识的阐述:hashmap是基于散列表的逻辑进行实现的。底层是一个数组和为了解决冲突引申出来的链表(jdk1.8之后有改进了红黑树)。当我们添加元素的时候,先用散列算法进行散列,也就是hashcode()方法,得到该元素所在数组中的位置下标,如果该位置已经有元素了,成为hash冲突,为了解决这个冲突,我们在当前位置出引申出一条链表解决这个冲突。jdk1.8之后,又改进了红黑树的方法解决了一下hash冲突。

我们现在来看一下上述的几个问题:这里多说一句,作者在学习的过程中,一直有一个困惑,很多书籍或者博客都是照搬结论,模棱两可的直接复制某些看似正确的总结。其实很多实现细节和底层原因都得自己去琢磨。这也是作者写博客的原因,想要自己以后回顾的时候,能够参考目前的理解,也希望真正的能帮助到一些朋友。

言归正传,我们来详细分析一下这些问题:

1.hashmap的容量为什么总是2的幂次方?

我们先来这样思考,我们使用hashmap的时候,什么时候hashmap才能把其数据结构的作用发挥的尽可能大?hashmap最大的优点就是键值对的逻辑,然后在查找key的时候是相当快的,因为只要把要查找的key计算一下散列值,判断数组该位置是否为空就行了,那么我们get一个键值对的时候,先计算出key所在位置,然后遍历链表直到找到我们所需要的键值对。

这时候,自然而然的产生了一个问题,如果我们能尽可能的减少hash冲突,也就尽可能的提升了hashmap的操作性能。也就是刚才提到的,我们在遍历key所在位置的链表时,尽可能的让链表的长度不是很大。最理想的状态,就是只有一个元素,就是我们要查找的元素,这时候的时间复杂度只有O(1).

此时,步入正题,我们知道hashcode()的逻辑是什么?首先hashmap使用的就是取模的逻辑锁定所在数组的位置。举个例子,数组长度为16,然后我们添加的元素的key的hashcode值是17,此时,散列位置应该是1。

此时,我们来考虑,为什么容量总是为2的幂次方?

①当我们的容量恰好是2的幂次方的时候,我们发现,数组的索引下标是0到(2的n次方-1)。我们以16为例,此时数组的下标最大值是1111.如果你足够敏感,你会发现,此时15的二进制恰好全部为1。这能说明什么呢?很简单,假如我们添加的元素的key值的hashcode()的值为x,那么有这样一个等式:x % m = x & (m - 1),当m等于 2的n次方的时候成立。这说明了,当容量为2的n次幂的时候,我们采用逻辑&运算可以替代取模运算,而逻辑&运算在性能上是绝对优于取模运算的。

②上述介绍了其在计算时候的性能提升,我们再来仔细思考,2的n次方-1 恰好在二进制上是所有位置全为1。这种特殊情况,还能说明个什么问题呢?我们此时做一下对比,假如我们的容量不是2的n次方。我们用容量15来举个例子,此时发现,容量15的时候,其数组索引为0到14,其最大值为14.二进制表示为1110。那么我们很容易明白一个事实,无论你key的散列值是多少,在进行&运算的时候,所得到的结果的最后一位永远是0。这个分析给我们的启示是什么呢?也就是进行与运算的时候,只有所有的位上全是1的时候,才能最好的避免冲突。因为 &0 永远是0,而&1的时候,永远是本身。我们还是用例子说话,当容量是15的时候,此时&操作应该是1110(14)。那么 abc0 和 abc1所得到的结果是一致的。

2.hashmap进行put操作,是头插还是尾插?

这个作者通过源码得到了一个结论:JDK1.7之前(包括1.7)所采用的是头插法,而之后从1.8开始,采用尾插法。这里不做详细解释,具体的源码,下面我们会看到。

3.hashmap的默认容量是多少,什么时候扩容?

hashmap的默认容量是16,而扩容的时机是其达到了扩容因子下的容量。其扩容因子是0.75。也就是说当达到16*0.75=12的时候,此时的hashmap要进行扩容了,扩容一倍,也就是变为32。

4.这个问题才是本文章最重点的一个问题,之前的问题都在为其做铺垫。为什么hashmap在多线程情况下是不安全的?其在什么情况下会产生死循环?为什么会产生死循环?

这个问题本质上就一个问题,就是hashmap为什么会产生所谓的并发问题?当然最直接的原因就是其根本就没有进行任何的同步操作。文章开始就已经表明了,所谓的并发问题就是在多线程扩容的时候会产生一个死循环的环。

需要说明的是,下述所有的理解全部基于JDK1.7:(还没有红黑树的逻辑)

采用头插法,在扩容操作的时候,会造成一个情况,就是位置倒置。

我们先来以单线程的角度分析一下,所谓的头插法的扩容操作:

此时我们根据源码,先来看一下put操作,以及需要扩容时候的操作:

从put源码中我们可以看到这样一个逻辑, 第一个箭头所指向的for循环是在当前数组位置处的链表进行查重操作,先记住这个查重操作,后续会用到。然后当没有重复的时候,进行真正的插入操作,addEntry()。

这里根据put源码有这么几个小的结论:①hashmap是允许插入null的②hashmap的put有查重逻辑

我们继续看这个addEntry()的方法:

 我们很明显的看到此处的resize方法的参数是2倍的关系。我们继续看resize的方法

 我们看到这个逻辑是什么?先构造一个新的容量的数组,然后把原来数组中的元素进行迁移。我们继续看这个迁移元素的方法,也就是上述箭头所指的transfer()方法。

这两个箭头所指,我们看到了什么?哈哈,没错,头插法!也就是说在扩容的时候,对旧数组的元素进行迁移操作的时候,也是采用的头插法,还有隐藏的一个非常重要的逻辑: 就是迁移操作并不是调用的put方法,而是在方法内部又自己控制进行了插入,并且上述transfer方法的插入逻辑并没有进行查重!(一定要理解这一点,非常重要)。

此时,你也许会问,你这不是多此一举吗,本来就不需要查重啊,原hashmap在put的时候已经查重了,所以在扩容的时候,原hashmap一定是没有重复的,这时候直接依次扔到新hashmap中不就行了啊。对的,这个逻辑在单线程下完全没有问题,但是也正是如此,hashmap在多线程情况下出现了死循环。(可能此处的观点跟你所看到的所有博客或者书籍都不一样,哈哈,其实是一样的,只不过,很多地方都没有指出这一点)

基于上述的所有分析,我们来看一下,所谓的多线程下怎么出现死循环的?

我们用例子来说明,我们考虑这样一种情况,现在的hashmap的底层数组的某一个索引位置只有两个元素,然而,在进行扩容操作后的新hashmap中,这两个元素在散列的时候,又在同一位置,只不过由于头插法,产生了位置倒置。

上述情况是在单线程情况下正确的逻辑。此时我们考虑有两个线程:T1和T2。

此时我们先提出两个源码中的变量,e和next。其中e代表此时进行扩容迁移的元素,而next指向下个要进行迁移的元素。

(理解以下例子的前提是,搞清楚哪些是线程私有的,哪些是全局共享的。例如:元素1和元素2是从旧数组中拿的,是全局的)(还有就是明白,最终ABA环问题导致的是,get的时候一直没有尽头)

①首先,T!和T2在某一时刻都要进行put,但是,此时的hashmap恰好已经达到了最大承载,需要扩容,于是两个线程都对其进行扩容。

②T!开始扩容,在自己的线程中创建了一个新的容量为原容量的两倍的数组,然后此时操作元素1进行迁移,也就是e指向元素1,把next指向了元素2。结果是,新的数组中的某个位置指向了元素1,我们假设这个位置是a。

③很巧合的是,此时,cpu调度了T2,且T2正确的完成了整个扩容操作, 那么此时的结果是什么呢?哈哈,此时元素2的下一个元素已经不是null了(原表中的元素2后面就是null),元素2的下一个元素在T2的操作中已经变为了元素1。

④这时候T1又继续执行,执行谁呢?执行元素2的迁移,那怎么执行呢?可以看到源码中,先把e变量指向元素2,然后next指向元素2的next,哈哈,此时T!很自信的把元素2头插到a位置,且元素2的下一个元素指向了元素1.

⑤哈哈,问题来了,next此时不是null,在T1把e指向元素2的时候,next被指向元素2的next,而那个时候T2已经改变了元素2的next,所以元素2的next不是null,而被多线程下改为了元素1。这说明了什么呢?也就是T1在迁移完元素2的时候,判断此时的next是否为null,如果是,表示迁移完成,但是很可惜,此时的next指向了元素1。

⑥最关键的分析来了,next指向元素1,表明T1继续去迁移元素1,怎么迁移呢?哈哈,还记得迁移函数tranfer吗?两个细节,一个是头插法,一个是不查重!哈哈,把元素1又一次插到了元素2之前,导致了元素1的next指向元素2,元素2的next指向元素1。

整个过程到此结束~~

这里谈一下自己的心得:其实真正造成这种情况的原因:还是在于所谓的头插法,它在扩容操作的时候造成了位置倒置的情况,而一旦有可能产生位置倒置,就造成了元素1和元素2之间的来回互指,最后形成了环!

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值