撼动面试官系列 —— ConcurrentHashMap 深入问答

简介: 撼动面试官系列 —— ConcurrentHashMap 深入问答

介绍ConcurrentHashMap

小明:ConcurrentHashMap 是 java 并发包里提供的一个并发集合,它基于了以往 Hashmap 或 Hashtable ,以一种安全高效的方式提供并发,所以我们可以在多线程环境去使用它,且不用担心并发安全


ConcurrentHashMap一定并发安全吗

小明: 严格意义上讲,并不能! 并发安全并不是一个非黑即白的东西,相反,它有多种程度,比如不可变、绝对线程安全、相对线程安全等,


比如 ConcurrentHashMap 就属于其中的 相对线程安全,如果你对它做单次操作,那么它是安全的。但如果以特定顺序的连续调用,就可能无法预料结果,举个简单的例子,100个线程,在 ConcurrentHashMap 里取值后加一再存回,其最终结果可能并不是 +100。如果想在这种场景下做到线程安全,必须添加其他的代码来做安全保障



f411c8f2fe484238aee3609582ef63d8.png

ConcurrentHashMap 是怎么实现

小明:ConcurrentHashMap 的实现采用的是 数组 + 链表 + 红黑树 混合的结构,基本结构是个数组,对键值对里的key进行hash计算,得到个整数,然后对当前容量取余来求得一个下标,然后把键值对放入数组的该位置,我们把数组的每个位置称为“箱”。当然,这一过程中,肯定有数据会下标重复,所以允许每个箱存放多个元素,如果元素少,就用链表排起来,元素多,就转变成红黑树。上述内容同HashMap基本一致,但不同的则是采用了CAS 和 synchronized 来加强并发安全

72924e0622ba4f1e98685f02c3ea254d.png


举几个在ConcurrentHashMap 使用CAS 和 synchronized 来加强并发安全的例子

小明:比如插入元素的时候,当找到桶时,如果桶里没元素,就以CAS方式把该元素放入桶中,如果有元素,则使用synchronized锁住该桶的第一个元素,然后再遍历该桶各个元素进行比对。同理,在删除,或者扩容迁移的时候,也会使用这两种方法


说说 ConcurrentHashMap 在jdk7 到 jdk8 都发生了什么变化

小明:


第一点:jdk7是基于锁分离的思想,把之前的hashmap继续细分为一个个Segment,而每个Segment其实又包含一个数组,里面元素的改动会以Segment为单位进行上锁,然后操作。到了jdk1.8,取消了Segment,意味着两次定位变为一次定位,同时锁的粒度更细了,数组的每一位都可以单独上锁,这其实增加了并发数。

1c1222cc476d4c2f8ac1a93b29443119.png


第二点:在jdk8时,当一个“箱“中的元素大于等于8时,会将链表结构转为红黑树结构。这一点在1.7中是没有的,而1.8里的HashMap 和 ConcurrentHashMap 则使用了此种设计。这种设计优化了极限情况的效率,说是极限情况,是因为因为扩容机制的存在,只要hash算法够均匀,单个箱中的元素很难达到8个。


第三点:在jdk7时,元素个数是由各Segment自己统计并维护的,因此求总数量时,会遍历各Segment记录的数字,累加并记录总和,然后再重复遍历一次又得到一次总和,比较两次总和如果一样就返回,如果不一样就把所有Segment全部上锁后,最后遍历一次并最终返回该值。而jdk8,则是把统计数值分为两部分,一部分是基础值baseCount,一般来说,我们添加1个元素,会使用CAS让basecount自增1。但如果竞争激烈CAS失败,则会使用一个countCell数组,把这个1使用CAS加入到数组中的某个位置,因此最终统计总元素数量时,会遍历累加countCell数组里的值,最后和basecount相加并返回,且过程中不会使用同步手段。因此,不难看出JDK7方法类似于先用乐观锁,不行再悲观锁,兼顾效率与强一致性。而JDK8的统计效率更高,但是结果无法保证强一致性,因为在你遍历累加的过程中,前面的countCell值可能又会变化,导致得到的总数并不实时。


第四点:扩容实现不一样,jdk1.7里Segment数量是不会变的,扩容的是每个Segment里面的小数组,而每个Segment内部的数据更新都需要竞争一把锁,扩容也同样如此,因此这个扩容只会有一个线程独自完成。而jdk1.8的扩容就是数组扩容成2倍,且扩容过程允许其他线程帮忙,每个线程一次会选定至少16个“箱”,然后依次将这些“箱”的数据迁移至新表,在移一个“箱”前会先对这个箱打上标签后再移,此后若有其他的线程试图往该“箱”插数据,发现这个标志后也会尝试参与到迁移过程中,然后该线程也会被分至少16个“箱”来迁移


调整扩容因子会有什么影响?

小明:装载因子从hashmap 时期就有,指的是容器内元素个数和容器长度的比值,通过调节这个参数,我们可以调整hashmap在什么时候扩容。但是…),在ConcurrentHashMap 里,这个扩容因子不能被调整,尽管我们可以在创建容器时,传入一个扩容因子,但这个值仅仅在初始化计算容量时会被用到,并不会保存,这与hashmap是不一样的,在后续的扩容中,判断是否扩容的条件总是元素个数是否大于 sizeCtl 变量,而这个变量的值与当前容器长度n有关,结果是0.75n,所以扩容因子实际上还是0.75.


扩容因子默认为什么要固定成0.75呢

小明:这个问题 Doug Lea 其实在注释里就有说明,0.75其实是个数学和计算问题,我们假设hash结果足够均匀,在指定发生哈希冲突数学期望为0.5的前提下,可以倒推出一个合适的扩容因子。具体的推算过程应属于离散数学的范畴,假定扩容因子为0.75时,那它的冲突的期望大概是0.53,比较符合预期的。当然扩容因子如果是0.7或0.8,其实也不会差多少。但是ConcurrentHashMap里用到了大量的位运算,而在计算容器扩容阈值时,0.75却是可以直接用位运算和加减法得出的,比如0.75n = n - (n >>> 2),从性能和风格角度考虑,也达成了一种均衡

ConcurrentHashMap用的“头插法”,扩容会有环化问题吗

小明:扩容死锁发生在jdk1.7版本的hashmap中,其原因是因为采用了头插法,在两个线程并发对hashmap扩容时,有概率导致“箱”里的链表循环。但1.8中,采用了尾插法,且各线程自己会记录迁移的尾节点,因此不会再出现环化。而ConcurrentHashMap从源码看,到了jdk1.8时,ConcurrentHashMap在做迁移时其实是直接将“桶”的数据预先分割成低位、高位两个链表,然后再挂到新容器的两个位置中,在生成两个链表的时候,确实仍然采用的头插,也因此会产生元素的倒序。但不管是1.7还是1.8,都不会有环化问题,因为ConcurrentHashMap限制了一个“箱”的数据只会由一个单一的线程来迁移,因此根本不会有此类问题。


什么时候链表转为红黑树

小明:当前“箱”的元素个数达到8,并且数组长度达到或超过64,那么才会对本“箱”的元素进行树化,后一个条件表明,在容量不大的时候,作者更推荐扩容来解决哈希冲突,而不是树化


树化的阈值为什么是8,从树退化下来阈值又是6呢

小明:在数组每个位置发生哈希冲突时,优先使用的是链表来存储。但我们都知道链表的时间复杂度为o(n),一旦链表长了,效率是比较低的。所以在达到8时,会转换成红黑树,红黑树时间复杂度o(logN)就比较好了。至于这个阈值为8,(思索片刻)主要是红黑树的构建和维护比较耗时和复杂,当可以建起一个三层的满树时,使用红黑树才算有效率上的优势。树的退化阈值是6,则主要是为了和8拉开间隔,这种例子是为了降低在边缘阈值波动时,反复建树和去树。这种例子生活里也常见,比如手机CPU到50℃就会降频,但却温度降低到45℃时才恢复频率也是一样的道理


为什么选用红黑树,如果用AVL、B+、跳表等结构呢

小明:自然是出于时间复杂度考虑,实际上平衡二叉树也有同样的效果,但平衡二叉树为了做到平衡,有时会有不必要的调整,虽然平衡二叉树高度可能低于红黑树,这让其查询更快,但是对于频繁的增改来说,平衡二叉树效率就不如红黑树,红黑树的特征就是增、删、查均衡。

对于B或者B+等结构,因为map并非数据库,数据库通常将数据存储在硬盘中,所以B或者B+主要目的是降低树的高度,从而减少硬盘访问次数,但是其查找路径实际是更长的,而map存储于内存,就是要缩短查找路径才能体现效率

如果支持范围查找,跳表更合适,但对于单次只查一个数据的map来说,其实际效率低于红黑树,而且跳表占用的空间更大


目录
相关文章
|
19天前
|
存储 缓存 安全
大厂面试高频:ConcurrentHashMap 的实现原理( 超详细 )
本文详细解析ConcurrentHashMap的实现原理,大厂高频面试,必知必备。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:ConcurrentHashMap 的实现原理( 超详细 )
|
5月前
|
缓存 安全 算法
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
50 0
|
4月前
|
存储 安全 Java
Java集合类面试十七】、介绍一下ConcurrentHashMap是怎么实现的?
ConcurrentHashMap在JDK 1.7中通过分段锁实现线程安全,在JDK 1.8中则采用Node数组配合链表和红黑树,并使用Synchronized和CAS操作提高并发性能。
Java集合类面试十七】、介绍一下ConcurrentHashMap是怎么实现的?
|
4月前
|
算法 Java
【Java集合类面试十八】、ConcurrentHashMap是怎么分段分组的?
ConcurrentHashMap通过分段锁(Segment)实现高效并发访问,get操作无需加锁,而put操作首先判断是否需要扩容,然后通过两次hash定位并尝试使用CAS和锁机制安全地添加元素。
|
4月前
|
安全 Java
【Java集合类面试十六】、HashMap与ConcurrentHashMap有什么区别?
HashMap是非线程安全的,而ConcurrentHashMap通过减少锁粒度来提高并发性能,检索操作无需锁,从而提供更好的线程安全性和性能。
|
4月前
|
存储 缓存 JavaScript
10 个简单但不能不会的 Vue 面试问答
10 个简单但不能不会的 Vue 面试问答
|
4月前
|
机器学习/深度学习 算法 数据中心
【机器学习】面试问答:PCA算法介绍?PCA算法过程?PCA为什么要中心化处理?PCA为什么要做正交变化?PCA与线性判别分析LDA降维的区别?
本文介绍了主成分分析(PCA)算法,包括PCA的基本概念、算法过程、中心化处理的必要性、正交变换的目的,以及PCA与线性判别分析(LDA)在降维上的区别。
96 4
|
4月前
|
机器学习/深度学习 算法 Python
【机器学习】面试问答:决策树如何进行剪枝?剪枝的方法有哪些?
文章讨论了决策树的剪枝技术,包括预剪枝和后剪枝的概念、方法以及各自的优缺点。
59 2
|
5月前
|
安全 算法 Java
Java面试题:如何使用并发集合,例如ConcurrentHashMap?
Java面试题:如何使用并发集合,例如ConcurrentHashMap?
56 1
|
5月前
|
设计模式 并行计算 安全
Java面试题: 如何使用装饰器模式来增强ConcurrentHashMap的功能?在什么情况下应该使用CopyOnWriteArrayList而不是ArrayList?
Java面试题: 如何使用装饰器模式来增强ConcurrentHashMap的功能?在什么情况下应该使用CopyOnWriteArrayList而不是ArrayList?
34 0