关于 CHM 中核心问题和文献

涉及到内部数据结构、get操作+forward和put操作+. 协助搬运(针对resize的优化)+ 2. 如何遍历 + 3. 计数器
现懒惰初始化,空间占用友好

spread 扰动函数:扰动函数,和HashMap中的hash()方法功能类似. CHM中的扰动函数除了将高16位于低16位异或之外又与上HASH_BITS,可以有效降低哈希冲突的概率,使元素分散更加均匀.

在put操作上:具有无锁cas上的应用+多线程协助扩容+加锁粒度(扩容和正常添加过程只对单个节点加锁)而jdk7是加juc的tryLock阻塞直到扩容完成

① 在 put 的时候,如果table[i]=null 则采用cas方式。向这个table的每个空bin(bin指的是数组中的每个元素)插入第一个Node是用CAS操作完成,其次,每个bin中的第一个Node结点作为一个synchronized锁,保护后面的insert, update, remove操作的线程安全性。

② 在 put 的时候,如果 table[i]=ForwardingNode时,先协助迁移,在调用helpTransfer返回新的table进行添加。(先在原数组中找到bin,如果遇到FowardingNode,则在新数组中插入);执行get操作的线程不参与转移工作,遇到FordwardingNode则到新数组查询。
优化手段就是让并发执行put操作的线程协助搬运bin中的Node,把数据项从老数组转移到新数组,从而加速resize操作。在源码中为了防止多线程迁移有遗漏,最后一个线程会做做一次从头到尾的检查。

③ 在 put 的时候,出上述两种条件外,会进行加synchronize锁,在连表或者红黑树上进行操作

================================== 1 扩容的调用点 ===========================

//调用该扩容方法的地方有:这一点和redis的渐进式扩容方式很像
//java.util.concurrent.ConcurrentHashMap#addCount 向集合中插入新数据后更新容量计数时发现到达扩容阈值而触发的扩容
//java.util.concurrent.ConcurrentHashMap#helpTransfer 扩容状态下其他线程对集合进行插入、修改、删除、合并、compute 等操作时遇到 ForwardingNode 节点时触发的扩容
//java.util.concurrent.ConcurrentHashMap#tryPresize putAll批量插入或者插入后发现链表长度达到8个或以上,但数组长度为64以下时触发的扩容
https://blog.csdn.net/zokekai/article/details/90051567

虽然我们之前说的 tryPresize 方法中多次调用 transfer 不涉及多线程,但是这个 transfer 方法可以在其他地方被调用,典型地,我们之前在说 put 方法的时候就说过了,请往上看 put 方法,是不是有个地方调用了 helpTransfer 方法,helpTransfer 方法会调用 transfer 方法的。

此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。

注意在扩容迁移的源码中,每个put线程,每次迁移 stride 个桶位,相当于每次不断向Kfaka一样轮训消费transferIndx坐标,当一个线程消费万一个stride后,会继续消费下一个stride区间,直到发现i<0, 当最后一个线程将tranferIndex=0后会自动将i=-1,满足前面的条件,其他线程也就能发现i<0条件,尝试退出了,然后退出减少sc的线程数,当发现最后一个线程消费万0-nextIndex区间时,bound=0,i=nexIndex, 会再次将i=n,将区间重置。

ps: 也就是说,chm8只是增加了多线程扩容的能力,但是如果目前只有一条线程,还是都有一条put线程来完成

具体怎么转移:参与转移的线程通过transferIndex字段声明自己要转移哪些bin。同时,为了防止这些线程所转移的bin有重叠的,转移线程会在sizeCtl变量中保存一个stamp提供区分的作用。
https://www.jianshu.com/p/487d00afe6ca

================================== 2 计数优化 ===========================
即把对一个原子变量的写入拆分到多个原子变量,这样就用空间换取了时间性能。更进一步,上图中只把一个AtomicLong替换成了3个AtomicLong。这个拆分个数是固定的,当并发访问的负载进一步提高,又会有性能瓶颈。因此,JDK 为了把LongAdder的未来改进空间全部榨干,引入了在这几年很火的"弹性扩展"思想,让拆分数量根据CPU核数和并发负载动态扩展,如下图所示。
https://www.toutiao.com/a6658830291622167051/?log_from=e9d0c52943ca_1634463671411

size 计算实际发生在 put,remove 改变集合元素的操作之中
没有竞争发生,向 baseCount 累加计数
有竞争发生,新建 counterCells,向其中的一个 cell 累加计数
counterCells 初始有两个 cell
如果计数竞争比较激烈,会创建新的 cell 来累加计数

因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,
ConcurrentHashMap 的工作原理及源码分析,如何统计所有的元素个数: https://blog.csdn.net/striveb/article/details/84106768

​ counterCells数组的设计理念和用来存储Map中元素的数组是一样的。首先,数组的长度一定是2的次方(counterCells起始大小是2);其次,计算下表是利用某个值和数组长度减一进行&操作求得。在HashMap中这个值就是hashcode,而在ConcurrentHashMap中是ThreadLocalRandom中的Probe。一般情况下,该值对应不同的线程都是不同的,同一个线程获取到的也同时相同的值,这样可以保证一个线程都是对同一个counterCells数组中的对象进行计数操作,提高cas操作的成功率。

https://blog.csdn.net/ljcgit/article/details/118802810

方法流程梳理如下:

判断数组是否为空,为空的话初始化数组
如果数组存在,通过探针hash定位桶中的位置,如果桶中为空,新建节点,通过CAS锁插入数组,如果成功,结束,如果失败转到第5步
如果定位到桶中有值,通过CAS修改,如果成功,结束,如果失败向下走
如果数组大小小于CPU核数,扩容数组
重新计算探针hash
https://www.cnblogs.com/gunduzi/p/13653505.html
================================== 3 结构中的一些小细节 ===========================

在get和put等操作实现中,都将共享变量转为临时变量来使用,主要是由于被多个线程共享的table变量及节点属性等可能也在被其他线程修改,所以需要一开始就把它们保存到局部变量,然后再使用,这样就不必担心有其他线程修改了。
ConcurrentHashMap迭代器不支持并发访问,如果不小心让多个线程并发访问从ConcurrentHashMap创建的同一个iterator,那么遍历提前结束,导致不能访问到所有元素。

================================== 4 ReservationNode节点含义 ===========================

简单来说computeIfAbsent会在bucket为null的时候初始化一个ReservationNode来占位,然后等待后面的计算结果出来,再替换当前的占位对象,
ConcurrentHashMap.computeIfAbsent引发的bug:https://www.jianshu.com/p/5f6a77baf0eb

// ReservationNode的hash值,ReservationNode是一个保留节点,就是个占位符,不会保存实际的数据,正常情况是不会出现的,
// 在jdk1.8新的函数式有关的两个方法computeIfAbsent和compute中才会出现
https://blog.csdn.net/u011392897/article/details/60479937

================================== 5 resizeStamp 函数含义 ===========================
//为每个size生成一个独特的stamp 这个stamp的第16为必为1 后15位针对每个n都是一个特定的值 表示n最高位的1前面有几个零
int rs = resizeStamp(n);

  • (nt = nextTable) == null 首先nextTable是在扩容中间状态才使用的数组(这一点和redis的渐进式扩容方式很像) 当nextTable 重新为null时 说明transfer 已经finish
    如上文所说,这个方法有两个作用,一是更新元素个数,二是判断是否需要resize()。
    /**
  • 分别对五个条件进行说明
  • sc >>> RESIZE_STAMP_SHIFT != rs 取sc的高16位 如果!=rs 则说明HashMap底层数据的n已经发生了变化
  • sc == rs + 1 此处可能有问题 我先按自己的理解 觉得应该是 sc == rs << RESIZE_STAMP_SHIFT + 1; 因为开始transfer时 sc = rs << RESIZE_STAMP_SHIFT + 2(一条线程在扩容,且之后有新线程参与扩容sc均会加1,而一条线程完成后sc - 1)说明是参与transfer的线程已经完成了transfer
  • 同理sc == rs + MAX_RESIZERS 这个应该也改为 sc = rs << RESIZE_STAMP_SHIFT + MAX_RESIZERS 表示参与迁移的线程已经到达最大数量 本线程可以不用参与
  • (nt = nextTable) == null 首先nextTable是在扩容中间状态才使用的数组(这一点和redis的渐进式扩容方式很像) 当nextTable 重新为null时 说明transfer 已经finish
  • transferIndex <= 0 也是同理
  • 遇上以上这些情况 说明此线程都不需要参与transfer的工作
  • PS: 翻了下JDK16的代码 这部分已经改掉了 rs = resizeStamp(n) << RESIZE_STAMP_SHIFT 证明我们的猜想应该是正确的
    */
    https://www.cnblogs.com/insaneXs/p/13586928.html
    resizeStamp个人理解是扩容标识,第一个进行扩容的线程,会通过数组长度将sizeCtl的高16位设置成扩容标识,这样后面进来的线程只需要判断一下扩容标识就知道是否有线程正在扩容,因为扩容完成,数组长度就变了,标识就不相等了。为什么只用高16位呢?因为低16位正好可以用来存扩容线程的数量。

================================== 5 源码干货文章 ===========================

Java集合类框架学习 5.3—— ConcurrentHashMap(JDK1.8):https://blog.csdn.net/u011392897/article/details/60479937
零、主要改动、一、基本性质、二、常量和变量、三、基本类(Node,TreeNode,ForwardingNode,TreeBin,ReservationNode)、计数操作、扩容(transfer,resizeStamp以及扩容重叠相关)

================================== 7 参考文献 ===========================

3.4 扩容检查的 BUG :https://segmentfault.com/a/1190000039315702
面试必问的ConcurrentHashMap实现原理:数据结构、get与put操作:https://i.cnblogs.com/articles/edit
Java高级程序员必备ConcurrentHashMap实现原理:扩容遍历与计数:https://www.toutiao.com/a6658830291622167051/?log_from=e9d0c52943ca_1634463671411
Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析:https://javadoop.com/post/hashmap
ConcurrentHashMap源码分析(JDK8) 扩容实现机制:https://www.jianshu.com/p/487d00afe6ca
ConcurrentHashMap1.8 - 扩容详解:https://blog.csdn.net/zokekai/article/details/90051567
ConcurrentHashMap源码分析-Java8:https://blog.csdn.net/caoxiaohong1005/article/details/79873745

posted @ 2021-10-17 19:40  TomStudio  阅读(141)  评论(0)    收藏  举报