助力面试之ConcurrentHashMap面试灵魂拷问,你能扛多久

  • 多并发下如何实现扩容

  • 扩容时的数据迁移如何保证安全性

  • 总结

前言

===============================================================

本文从 ConcurrentHashMap 常见的面试问题引入话题,并逐步揭开其设计原理,相信读完本文,对面试中的相关问题会有很大的帮助。

HashMap 在我们日常的开发中使用频率最高的一个工具类之一,然而使用 HashMap 最大的问题之一就是它是线程不安全的,如果我们想要线程安全应该怎么办呢?这时候就可以选择使用 ConcurrentHashMapConcurrentHashMapHashMap 的功能是基本一样的,ConcurrentHashMapHashMap 的线程安全版本。

ConcurrentHashMapHashMapjdk1.8 版本中排除线程的安全性方面,其他的设计都很类似,所以有很多相同的设计思想本文不会做太多重复介绍,如果大家不了解 HashMap 底层实现原理,建议在阅读本文可以先阅读 金三银四助力面试-手把手轻松读懂HashMap源码 了解 HashMap 的设计思想。

ConcurrentHashMap 原理

=================================================================================

ConcurrentHashMapHashMap 的线程安全版本,其内部和 HashMap 一样,也是采用了数组 + 链表 + 红黑树的方式来实现。

如何实现线程的安全性?加锁。但是这个锁应该怎么加呢?在 HashTable 中,是直接在 putget 方法上加上了 synchronized,理论上来说 ConcurrentHashMap 也可以这么做,但是这么做锁的粒度太大,会非常影响并发性能,所以在 ConcurrentHashMap 中并没有采用这么直接简单粗暴的方法,其内部采用了非常精妙的设计,大大减少了锁的竞争,提升了并发性能。

ConcurrentHashMap 中的初始化和 HashMap 中一样,而且容量也会调整为 2 的 N 次幂,在这里不做重复介绍这么做的原因。

JDK1.8 版本 ConcurrentHashMap 做了什么改进


JDK1.7 版本中,ConcurrentHashMap 由数组 + Segment + 分段锁实现,其内部分为一个个段(Segment)数组,Segment 通过继承 ReentrantLock 来进行加锁,通过每次锁住一个 segment 来降低锁的粒度而且保证了每个 segment 内的操作的线程安全性,从而实现全局线程安全。下图就是 JDK1.7 版本中 ConcurrentHashMap 的结构示意图:

在这里插入图片描述

但是这么做的缺陷就是每次通过 hash 确认位置时需要 2 次才能定位到当前 key 应该落在哪个槽:

  1. 通过 hash 值和 段数组长度-1 进行位运算确认当前 key 属于哪个段,即确认其在 segments 数组的位置。

  2. 再次通过 hash 值和 table 数组(即 ConcurrentHashMap 底层存储数据的数组)长度 - 1进行位运算确认其所在桶。

为了进一步优化性能,在 jdk1.8 版本中,对 ConcurrentHashMap 做了优化,取消了分段锁的设计,取而代之的是通过 cas 操作和 synchronized 关键字来实现优化,而扩容的时候也利用了一种分而治之的思想来提升扩容效率,在 JDK1.8ConcurrentHashMap 的存储结构和 HashMap 基本一致,如下图所示:

在这里插入图片描述

为什么 key 和 value 不允许为 null


HashMap 中,keyvalue 都是可以为 null 的,但是在 ConcurrentHashMap 中却不允许,这是为什么呢?

作者 Doug Lea 本身对这个问题有过回答,在并发编程中,null 值容易引来歧义, 假如先调用 get(key) 返回的结果是 null,那么我们无法确认是因为当时这个 key 对应的 value 本身放的就是 null,还是说这个 key 值根本不存在,这会引起歧义,如果在非并发编程中,可以进一步通过调用 containsKey 方法来进行判断,但是并发编程中无法保证两个方法之间没有其他线程来修改 key 值,所以就直接禁止了 null 值的存在。

而且作者 Doug Lea 本身也认为,假如允许在集合,如 mapset 等存在 null 值的话,即使在非并发集合中也有一种公开允许程序中存在错误的意思,这也是 Doug LeaJosh BlochHashMap作者之一) 在设计问题上少数不同意见之一,而 ConcurrentHashMapDoug Lea 一个人开发的,所以就直接禁止了 null 值的存在。

ConcurrentHashMap 如何保证线程的安全性


ConcurrentHashMap 中,采用了大量的分而治之的思想来降低锁的粒度,提升并发性能。其源码中大量使用了 cas 操作来保证安全性,而不是和 HashTable 一样,不论什么方法,直接简单粗暴的使用 synchronized关键字来实现,接下来的原理分析中,部分和 HashMap 类似之处本文就不在重复,本文主要从安全性方面来分析 ConcurrentHashMap 的设计。

如何用 CAS 保证数组初始化的安全

下面就是初始化的方法:

在这里插入图片描述

这里面有一个非常重要的变量 sizeCtl,这个变量对理解整个 ConcurrentHashMap 的原理非常重要。

sizeCtl 有四个含义:

  • sizeCtl<-1 表示有 N-1 个线程正在执行扩容操作,如 -2 就表示有 2-1 个线程正在扩容。

  • sizeCtl=-1 占位符,表示当前正在初始化数组。

  • sizeCtl=0 默认状态,表示数组还没有被初始化。

  • sizeCtl>0 记录下一次需要扩容的大小。

知道了这个变量的含义,上面的方法就好理解了,第二个分支采用了 CAS 操作,因为 SIZECTL 默认为 0,所以这里如果可以替换成功,则当前线程可以执行初始化操作,CAS 失败,说明其他线程抢先一步把 sizeCtl 改为了 -1。扩容成功之后会把下一次扩容的阈值赋值给 sc,即 sizeClt

put 操作如何保证数组元素的可见性

ConcurrentHashMap 中存储数据采用的 Node 数组是采用了 volatile 来修饰的,但是这只能保证数组的引用在不同线程之间是可用的,并不能保证数组内部的元素在各个线程之间也是可见的,所以这里我们判定某一个下标是否有元素,并不能直接通过下标来访问,那么应该如何访问呢?源码给你答案:

在这里插入图片描述

可以看到,这里是通过 tabAt 方法来获取元素,而 tableAt 方法实际上就是一个 CAS 操作:

在这里插入图片描述

如果发现当前节点元素为空,也是通过 CAS 操作(casTabAt)来存储当前元素。

如果当前节点元素不为空,则会使用 synchronized 关键字锁住当前节点,并进行对应的设值操作:

在这里插入图片描述

精妙的计数方式

HashMap 中,调用 put 方法之后会通过 ++size 的方式来存储当前集合中元素的个数,但是在并发模式下,这种操作是不安全的,所以不能通过这种方式,那么是否可以通过 CAS 操作来修改 size 呢?

直接通过 CAS 操作来修改 size 是可行的,但是假如同时有非常多的线程要修改 size 操作,那么只会有一个线程能够替换成功,其他线程只能不断的尝试 CAS,这会影响到 ConcurrentHashMap 集合的性能,所以作者就想到了一个分而治之的思想来完成计数。

作者定义了一个数组来计数,而且这个用来计数的数组也能扩容,每次线程需要计数的时候,都通过随机的方式获取一个数组下标的位置进行操作,这样就可以尽可能的降低了锁的粒度,最后获取 size 时,则通过遍历数组来实现计数:

//用来计数的数组,大小为2的N次幂,默认为2

private transient volatile CounterCell[] counterCells;

@sun.misc.Contended static final class CounterCell {//数组中的对象

volatile long value;//存储元素个数

CounterCell(long x) { value = x; }

}

addCount 计数方法

接下来我们看看 addCount 方法:

在这里插入图片描述

首先会判断 CounterCell 数组是不是为空,需要这里的是,这里的 CAS 操作是将 BASECOUNTbaseCount 进行比较,如果相等,则说明当前没有其他线程过来修改 baseCount(即 CAS 操作成功),此时则不需要使用 CounterCell 数组,而直接采用 baseCount 来计数。

假如 CounterCell 为空且 CAS 失败,那么就会通过调用 fullAddCount 方法来对 CounterCell 数组进行初始化。

fullAddCount 方法

这个方法也很长,看起来比较复杂,里面包含了对 CounterCell 数组的初始化和赋值等操作。

初始化 CounterCell 数组

我们先不管,直接进入初始化的逻辑:

在这里插入图片描述

这里面有一个比较重要的变量 cellsBusy,默认是 0,表示当前没有线程在初始化或者扩容,所以这里判断如果 cellsBusy==0,而 as 其实在前面就是把全局变量 CounterCell 数组的赋值,这里之所以再判断一次就是再确认有没有其他线程修改过全局数组 CounterCell,所以条件满足的话就会通过 CAS 操作修改 cellsBusy1,表示当前自己在初始化了,其他线程就不能同时进来初始化操作了。

最后可以看到,默认是一个长度为 2 的数组,也就是采用了 2 个数组位置进行存储当前 ConcurrentHashMap 的元素数量。

CounterCell 如何赋值

初始化完成之后,如果再次调用 put 方法,那么就会进入 fullAddCount 方法的另一个分支:

在这里插入图片描述

这里面首先判断了 CounterCell 数组不为空,然后会再次判断数组中的元素是不是为空,因为如果元素为空,就需要初始化一个 CounterCell 对象放到数组,而如果元素不为空,则只需要 CAS 操作替换元素中的数量即可。

所以这里面的逻辑也很清晰,初始化 CounterCell 对象的时候也需要将 cellBusy0 改成 1

计数数组 CounterCell 也能扩容吗

最后我们再继续看其他分支:

在这里插入图片描述

主要看上图红框中的分支,一旦会进入这个分支,就说明前面所有分支都不满足,即:

  • 当前 CounterCell 数组已经初始化完成。

  • 当前通过 hash 计算出来的 CounterCell 数组下标中的元素不为 null

  • 直接通过 CAS 操作修改 CounterCell 数组中指定下标位置中对象的数量失败,说明有其他线程在竞争修改同一个数组下标中的元素。

  • 当前操作不满足不允许扩容的条件。

  • 当前没有其他线程创建了新的 CounterCell 数组,且当前 CounterCell 数组的大小仍然小于 CPU 数量。

所以接下来就需要对 CounterCell 数组也进行扩容,这个扩容的方式和 ConcurrentHashMap 的扩容一样,也是将原有容量乘以 2,所以其实 CounterCell 数组的容量也是满足 2 的 N 次幂。

ConcurrentHashMap 的扩容

接下来我们需要回到 addCount 方法,因为这个方法在添加元素数量的同时,也会判断当前 ConcurrentHashMap 的大小是否达到了扩容的阈值,如果达到,需要扩容。

扩容也能支持并发吗

这里可能令大家有点意外的是,ConcurrentHashMap 扩容也支持多线程同时进行,这又是如何做到的呢?接下来就让我们回到 addCount 方法一探究竟。

在这里插入图片描述

这里 check 是传进来的链表长度,>=0 才开始检查是否需要扩容,紧挨之后是一个 while 循环,主要是满足两个条件:

  • 前面我们提到,sizeCtl在初始化的时候会被赋值为下一次扩容的大小(扩容之后也会),所以 >=sizeCtl 表示的就是是否达到扩容阈值。

  • table 不为 null 且当前数组长度小于最大值 2 的 30 次方。

扩容戳有什么用

当满足扩容条件之后,首先会先调用一个方法来获取扩容戳,这个扩容戳比较有意思,要理解扩容戳,必须从二进制的角度来分析。resizeStamp 方法就一句话,其中 RESIZE_STAMP_BITS 是一个默认值 16

static final int resizeStamp(int n) {

return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));

}

这里面关键就是 Integer.numberOfLeadingZeros(n) 这个方法,这个方法源码就不贴出来了,实际上这个方法就是做一件事,那就是获取当前数据转成二进制后的最高非 0 位前的 0 的个数

这句话有点拗口,我们举个例子,就以 16 为准,16 转成二进制是 10000,最高非 0 位是在第 5 位,因为 int 类型是 32 位,所以他前面还有 27 位,而且都是 0,那么这个方法得到的结果就是 271 的前面还有 270)。

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

面试是跳槽涨薪最直接有效的方式,马上金九银十来了,各位做好面试造飞机,工作拧螺丝的准备了吗?

掌握了这些知识点,面试时在候选人中又可以夺目不少,暴击9999点。机会都是留给有准备的人,只有充足的准备,才可能让自己可以在候选人中脱颖而出。

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
ed303032d36.jpg" alt=“img” style=“zoom: 33%;” />

最后

面试是跳槽涨薪最直接有效的方式,马上金九银十来了,各位做好面试造飞机,工作拧螺丝的准备了吗?

掌握了这些知识点,面试时在候选人中又可以夺目不少,暴击9999点。机会都是留给有准备的人,只有充足的准备,才可能让自己可以在候选人中脱颖而出。

[外链图片转存中…(img-3BY2cWQH-1713551552030)]

[外链图片转存中…(img-ZnY3cB0S-1713551552030)]

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值