ConcurrentHashMap面试灵魂拷问,你能扛多久?
程序员的成长之路
共 5274字,需浏览 11分钟
·
2022-03-15 15:21
阅读本文大概需要 12 分钟。
来自:blog.csdn.net/zwx900102/article/details/114547968
前言
ConcurrentHashMap
常见的面试问题引入话题,并逐步揭开其设计原理,相信读完本文,对面试中的相关问题会有很大的帮助。ConcurrentHashMap
,ConcurrentHashMap
和 HashMap
的功能是基本一样的,ConcurrentHashMap
是 HashMap 的线程安全版本。ConcurrentHashMap
和 HashMap 在 jdk1.8 版本中排除线程的安全性方面,其他的设计都很类似,所以有很多相同的设计思想本文不会做太多重复介绍。ConcurrentHashMap 原理
ConcurrentHashMap
是 HashMap 的线程安全版本,其内部和 HashMap 一样,也是采用了数组 + 链表 + 红黑树的方式来实现。HashTable
中,是直接在 put 和 get 方法上加上了 synchronized
,理论上来说 ConcurrentHashMap
也可以这么做,但是这么做锁的粒度太大,会非常影响并发性能,所以在 ConcurrentHashMap
中并没有采用这么直接简单粗暴的方法,其内部采用了非常精妙的设计,大大减少了锁的竞争,提升了并发性能。ConcurrentHashMap
中的初始化和 HashMap 中一样,而且容量也会调整为 2 的 N 次幂,在这里不做重复介绍这么做的原因。JDK1.8 版本 ConcurrentHashMap 做了什么改进
ConcurrentHashMap
由数组 + Segment + 分段锁实现,其内部分为一个个段(Segment)数组,Segment 通过继承 ReentrantLock
来进行加锁,通过每次锁住一个 segment 来降低锁的粒度而且保证了每个 segment 内的操作的线程安全性,从而实现全局线程安全。下图就是 JDK1.7 版本中 ConcurrentHashMap
的结构示意图:通过 hash 值和 段数组长度-1 进行位运算确认当前 key 属于哪个段,即确认其在 segments 数组的位置。 再次通过 hash 值和 table 数组(即 ConcurrentHashMap 底层存储数据的数组)长度 - 1进行位运算确认其所在桶。
ConcurrentHashMap
做了优化,取消了分段锁的设计,取而代之的是通过 cas 操作和 synchronized
关键字来实现优化,而扩容的时候也利用了一种分而治之的思想来提升扩容效率,在 JDK1.8 中 ConcurrentHashMap
的存储结构和 HashMap 基本一致,如下图所示:为什么 key 和 value 不允许为 null
ConcurrentHashMap
中却不允许,这是为什么呢?get(key)
返回的结果是 null,那么我们无法确认是因为当时这个 key 对应的 value 本身放的就是 null,还是说这个 key 值根本不存在,这会引起歧义,如果在非并发编程中,可以进一步通过调用 containsKey
方法来进行判断,但是并发编程中无法保证两个方法之间没有其他线程来修改 key 值,所以就直接禁止了 null 值的存在。ConcurrentHashMap
是 Doug Lea 一个人开发的,所以就直接禁止了 null 值的存在。ConcurrentHashMap 如何保证线程的安全性
如何用 CAS 保证数组初始化的安全
ConcurrentHashMap
的原理非常重要。sizeCtl<-1
表示有 N-1 个线程正在执行扩容操作,如 -2 就表示有 2-1 个线程正在扩容。sizeCtl=-1
占位符,表示当前正在初始化数组。sizeCtl=0
默认状态,表示数组还没有被初始化。sizeCtl>0
记录下一次需要扩容的大小。
put 操作如何保证数组元素的可见性
ConcurrentHashMap
中存储数据采用的 Node 数组是采用了 volatile 来修饰的,但是这只能保证数组的引用在不同线程之间是可用的,并不能保证数组内部的元素在各个线程之间也是可见的,所以这里我们判定某一个下标是否有元素,并不能直接通过下标来访问,那么应该如何访问呢?源码给你答案:synchronized
关键字锁住当前节点,并进行对应的设值操作:精妙的计数方式
++size
的方式来存储当前集合中元素的个数,但是在并发模式下,这种操作是不安全的,所以不能通过这种方式,那么是否可以通过 CAS 操作来修改 size 呢?ConcurrentHashMap
集合的性能,所以作者就想到了一个分而治之的思想来完成计数。//用来计数的数组,大小为2的N次幂,默认为2
private transient volatile CounterCell[] counterCells;
@sun.misc.Contended static final class CounterCell {//数组中的对象
volatile long value;//存储元素个数
CounterCell(long x) { value = x; }
}
addCount 计数方法
CounterCell
数组是不是为空,需要这里的是,这里的 CAS 操作是将 BASECOUNT
和 baseCount 进行比较,如果相等,则说明当前没有其他线程过来修改 baseCount(即 CAS 操作成功),此时则不需要使用 CounterCell
数组,而直接采用 baseCount 来计数。CounterCell
为空且 CAS 失败,那么就会通过调用 fullAddCount
方法来对 CounterCell
数组进行初始化。fullAddCount 方法
CounterCell
数组的初始化和赋值等操作。初始化 CounterCell 数组
cellsBusy==0
,而 as 其实在前面就是把全局变量 CounterCell
数组的赋值,这里之所以再判断一次就是再确认有没有其他线程修改过全局数组 CounterCell
,所以条件满足的话就会通过 CAS 操作修改 cellsBusy
为 1,表示当前自己在初始化了,其他线程就不能同时进来初始化操作了。ConcurrentHashMap
的元素数量。CounterCell 如何赋值
fullAddCount
方法的另一个分支:CounterCell
数组不为空,然后会再次判断数组中的元素是不是为空,因为如果元素为空,就需要初始化一个 CounterCell
对象放到数组,而如果元素不为空,则只需要 CAS 操作替换元素中的数量即可。CounterCell
对象的时候也需要将 cellBusy
由 0 改成 1。计数数组 CounterCell 也能扩容吗
当前 CounterCell
数组已经初始化完成。当前通过 hash 计算出来的 CounterCell
数组下标中的元素不为 null。直接通过 CAS 操作修改 CounterCell
数组中指定下标位置中对象的数量失败,说明有其他线程在竞争修改同一个数组下标中的元素。当前操作不满足不允许扩容的条件。 当前没有其他线程创建了新的 CounterCell
数组,且当前CounterCell
数组的大小仍然小于 CPU 数量。
CounterCell
数组也进行扩容,这个扩容的方式和 ConcurrentHashMap
的扩容一样,也是将原有容量乘以 2,所以其实 CounterCell
数组的容量也是满足 2 的 N 次幂。ConcurrentHashMap 的扩容
ConcurrentHashMap
的大小是否达到了扩容的阈值,如果达到,需要扩容。扩容也能支持并发吗
ConcurrentHashMap
扩容也支持多线程同时进行,这又是如何做到的呢?接下来就让我们回到 addCount 方法一探究竟。>=0
才开始检查是否需要扩容,紧挨之后是一个 while 循环,主要是满足两个条件:>=sizeCtl
表示的就是是否达到扩容阈值。扩容戳有什么用
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 的个数。1 << (RESIZE_STAMP_BITS - 1)
在当前版本就是 1<<15,也就是得到一个二进制数 1000000000000000
,这里也是要做一件事,把这个 1 移动到第 16 位。最后这两个数通过 | 操作一定得到的结果就是第 16 位是 1,因为 int 是 32 位,最多也就是 32 个 0,而且因为 n 的默认大小是 16(ConcurrentHashMap
默认大小),所以实际上最多也就是 27(11011),也就是说这个数最高位的 1 也只是在第五位,执行 | 运算最多也就是影响低 5 位的结果。注意:这里之所以要保证第 16 位为 1,是为了保证 sizeCtl 变量为负数,因为前面我们提到,这个变量为负数才代表当前有线程在扩容,至于这个变量和 sizeCtl 的关系后面会介绍。
首次扩容为什么计数要 +2 而不是 +1
RESIZE_STAMP_SHIFT
)位,然后加上一个 2,这个代表什么意思呢?为什么是加 2 呢?高 16 位代表当前扩容的标记,可以理解为一个纪元。 低 16 位代表了扩容的线程数。
扩容条件
(sc >>> RESIZE_STAMP_SHIFT) != rs 这个条件实际上有 bug,在 JDK12 中已经换掉。 sc == rs + 1 表示最后一个扩容线程正在执行首位工作,也代表扩容即将结束。 sc == rs + MAX_RESIZERS 表示当前已经达到最大扩容线程数,所以不能继续让线程加入扩容。 扩容完成之后会把 nextTable(扩容的新数组) 设为 null。 transferIndex <= 0 表示当前可供扩容的下标已经全部分配完毕,也代表了当前线程扩容结束。
多并发下如何实现扩容
ConcurrentHashMap
中采用的是分段扩容法,即每个线程负责一段,默认最小是 16,也就是说如果 ConcurrentHashMap
中只有 16 个槽位,那么就只会有一个线程参与扩容。如果大于 16 则根据当前 CPU 数来进行分配,最大参与扩容线程数不会超过 CPU 数。transferIndex
代表的就是推进下标,默认为旧数组的大小。扩容时的数据迁移如何保证安全性
fwd 节点: 这个代表的是占位节点,最关键的就是这个节点的 hash 值为 -1,所以一旦发现某一个节点中的 hash 值为 -1 就可以知道当前节点已经被迁移了。 advance: 代表是否可以继续推进下一个槽位,只有当前槽位数据被迁移完成之后才可以设置为 true finishing: 是否已经完成数据迁移。
transferIndex
为旧数组大小 16,而在第二个 if判断中,transferIndex
赋值给了 nextIndex,所以 nextIndex
为 1,而 stride 代表的是每个线程负责的槽位数,最小就是 16,所以 stride 也是 16,所以 nextBound= nextIndex > stride ? nextIndex - stride : 0
皆可以得到:nextBound=0
和 i=15
了,也就是当前线程负责 0-15 的数组下标,且从 0 开始推进,确认边界后立刻将 advance 设置为 false,也就是会跳出 while 循环,从而执行下面的数据迁移部分逻辑。PS:因为 nextBound=0
,所以 CAS 操作实际上也是把transferIndex
变成了 0,表示当前扩容的数组下标已经全部分配完毕,这也是前面不满足扩容的第 5 个条件。
synchronized
关键字对当前节点进行加锁,也就是说锁的粒度精确到了每一个节点,可以说大大提升了效率。加锁之后的数据迁移和 HashMap 基本一致,也是通过区分高低位两种情况来完成迁移,在本文就不重复讲述。nextTable
设置为 null,所以上面不满足扩容的第 4 个条件就是在这里设置的。总结
ConcurrentHashMap
中是如何保证安全性的,并且挑选了一些比较经典的面试常用问题进行分析解答,在整个 ConcurrentHashMap
中,整个思想就是降低锁的粒度,减少锁的竞争,所以采用了大量的分而治之的思想,比如多线程同时进行扩容,以及通过一个数组来实现 size 的计数等。内容包含Java基础、JavaWeb、MySQL性能优化、JVM、锁、百万并发、消息队列、高性能缓存、反射、Spring全家桶原理、微服务、Zookeeper、数据结构、限流熔断降级......等技术栈!
⬇戳阅读原文领取! 朕已阅
评论