ConcurrentHashMap
简介
- ConcurrentHashMap是J.U.C包中的提供高效和线程安全性高的 HashMap ,所以ConcurrentHashMap 在并发场景使用性高。
- ConcurrentHashMap是 Map 的派生类,所以 api 基本和 Hashmap 是类似,主要就是 put 、get 这些方法。
JDK1.7 与 JSDK1.8 的比较
-
JDK1.7 的 ConcurrentHashMap 是由一个Segment 数组,它通过继承 ReentrantLock 来进行加锁,通过每次锁住一个 segment 来保证每个 segment 内的操作的线程安全性从而实现全局线程安全。
-
JDK1.8 相比于JDK1.7 做了两个改进
- 取消了 segment 分段设计,直接使用 Node 数组来保存数据,并且采用 Node 数组元素作为锁来实现每一行数据进行加锁来进一步减少并发冲突的概率
- 将原本数组 单向链表的数据结构变更为了数组 单向链表 红黑树的结构。为什么要引入红黑树呢?在正常情况下, key hash 之后如果能够很均匀的分散在数组中,那么 table 数组中的每个队列的长度主要为 0 或者 1. 但是实际情况下,还是会存在一些队列长度过长的情况。如果还采用单向列表方式,那么查询某个节点的时间复杂度就变为 O (n); 因此对于队列长度超过 8 的列表, JDK 1.8 采用了红黑树的结构,那么查询的时间复杂度就会降低到O(logN), 可以提升查找的性能
源码分析
- put()方法源码分析
-
第一步判断 key 和 value 的值是否为空,如果为空就抛出异常
-
第二步计算这个结点的hash值
-
第三步初始化这个链表的结点数
-
第四步进行循环,也就是自旋,当出现线程竞争时不断自旋
- 第一步判断数组是否为空,如果为空,就初始化
- 第二步当数组不为空的时候,进入第二个循环,将 key - value 值加入数组中
-
tabAt()方法:因为node数组是由volatile可见性修饰的,又因为 volatile可见性只用于数组的引用,所以当你写入数组的时候如果数组中的元素对你不可见,系统就会以为没有元素就会进行覆盖,所以才会有这个方法。
-
casTabAt()方法进行添加
-
- 第三步 如果对应的节点存在,判断这个节点的 hash 是不是等于 MOVED( -1),说明当前节点是 ForwardingNode 节点,意味着有其他线程正在进行扩容,那么当前现在直接帮助它进行扩容,因此调用 helpTransfer 方法
- 调用 helpTransfer(tab.f) 方法
- 第四步如果被添加的节点的位置已经存在节点的时候,需要以链表的方式加入到节点中,如果当前节点已经是一颗红黑树,那么就会按照红黑树的规则将当前节点加入到红黑树中
- 进入到这个分支,说明f是当前nodes数组对应位置节点的头节点,并且不为空
- 第一步给对应的头节点加锁,防止别的线程干扰
- 第二步再次判断对应下标位置是否为f节点
- 第三步头结点的hash值大于0,说明是链表
- 创建一个记录链表长度的binCount
- 遍历链表
- 判断相同的结点是否需要覆盖
- 没有相同的结点,到了最后一个结点,加入到最后一个结点后面
- 第四步也有可能是红黑树
- 调用红黑树的方法插入新的值
- 第一步判断数组是否为空,如果为空,就初始化
-
第五步,addCount()方法,主要用于计数,记录map中有多少个元素,分为两个阶段
-
第一阶段:增加元素,通过countercell来记录元素个数
-
第一个判断,判断counterCells是否为空
- 如果为空,就通过cas操作尝试修改baseCount变量,对这个变量进行原子累加操作(做这个操作的意义是:如果在没有竞争的情况下,仍然采用baseCount来记录元素个数)
- 如果cas失败说明存在竞争,这个时候不能再采用baseCount来累加,而是通过CounterCell来记录
- 原因:因为如果都用 cas 操作去修改 baseCount 的话会导致性能下降,一个在修改,其他的都在等待,所以第一次尝试 CAS 操作,成功就修改,不成功就使用分而治之(也就是使用 CounterCell 来记录)来记录元素。
-
第二个判断,就需要判断三种情况
- 第一种,如果计数表为空则直接调用 fullAddCount()方法
- 第二种,如果从计数表中随机取出一个数组的位置为空,直接调用 fullAddCount()方法
- 第三种,通过CAS修改CounterCell随机位置的值
-
根据情况调用 fullAddCount()方法,主要是用来初始化 CounterCell ,来记录元素个数,里面包含扩容,初始化等操作,下面分析一下 fullAddCount()方法
- 第一部分,初始化随机数,获取当前线程的probe的值,如果值为0,则初始化当前线程的probe的值,probe就是随机数
- 第二部分,一个 for 循环的自旋,也分为三种情况
-
第一种,说明counterCells已经被初始化过了,没有初始化就进入第二种情况
-
第一个判断,判断通过该值与当前线程probe求与,获得cells的下标元素是否为空
- 如果为空就判断 cellsBusy 值是否等于0,等于0说明没有线程在对这个数组进行初始化或者扩容
- 构造一个CounterCell的值,传入元素个数
- 通过cas设置cellsBusy标识,防止其他线程来对counterCells并发处理
- 将初始化的 r 对象的元素个数放在对应下标的位置
- 然后恢复标志
- 创建成功退出循环
- 如果为空就判断 cellsBusy 值是否等于0,等于0说明没有线程在对这个数组进行初始化或者扩容
-
第二个判断,说明在addCount方法中cas失败了,并且获取probe的值不为空
- 设置为未冲突标识,进入下一次自旋
-
第三个判断,由于指定下标位置的cell值不为空,则直接通过cas进行原子累加,如果成功,则直接退出
-
第四个判断,如果已经有其他线程建立了新的counterCells或者CounterCells大于CPU核心数(很巧妙,线程的并发数不会超过cpu核心数)
- 设置当前线程的循环失败不进行扩容
-
第五个判断,恢复collide状态,标识下次循环会进行扩容
-
第六个判断,说明CounterCell数组容量不够,线程竞争较大,所以先设置一个标识表示为正在扩容
- 扩容一倍,
n << 1
,直接*2 - 将原先的元素存入 rs 数组中
- 把 rs 数组赋值给 CounterCells 数组中
- 恢复表示
- 继续自旋
- 扩容一倍,
-
-
第二种,cellsBusy=0表示没有在做初始化,通过cas更新cellsbusy的值标注当前线程正在做初始化操作
- 当 CountCells 数组没有给初始化,就创建一个容量为2的数组 rs
- 在随机数为下标的 rs 数组中,存入CounterCell(x) 元素
- 然后让CountCells 数组等于 rs 数组
- 设置初始化完成的标志
-
第三种,竞争激烈,其它线程占据cell 数组,直接累加在baseCount变量中
-
- 以上操作的图
- 第一部分,初始化随机数,获取当前线程的probe的值,如果值为0,则初始化当前线程的probe的值,probe就是随机数
-
然后调用 sumcount()方法记录元素个数
-
总结一下,ConcurrentHashMap是采用 CounterCell 数组来记录元素个数的,像一般的集合记录集合大小,直接定义一个 size 的成员变量即可,当出现改变的时候只要更新这个变量就行。为什么ConcurrentHashMap 要用这种形式来处理呢?
- 因为问题还是处在并发上,ConcurrentHashMap 是并发集合,如果用一个成员变量来统计元素个数的话,为了保证并发情况下共享变量的难全新,势必会需要通过加锁或者自旋来实现,如果竞争比较激烈的情况下, size 的设置上会出现比较大的冲突反而影响了性能,所以在ConcurrentHashMap 采用了分片的方法来记录大小。
-
-
第二阶段:判断是否需要扩容,如果binCount>=0,标识需要检查扩容
- 第一步进行判断,首先判断集合的大小是否大于或者等于扩容的阈值,如果是就说明需要扩容,同时还要判断 table 不能为空,并且小于最大容量
-
第一步 使用
int resizeStamp(int n)
生成唯一的扩容戳
Integer.numberOfLeadingZeros
这个方法是返回无符号整数 n 最高位非 0 位前面的 0 的个数,比如说:10 的二进制是 0 000 0000 0000 0000 0000 0000 0000 1010,那么这个方法返回的值就是28。根据 resizeStamp 的运算逻辑,我们来推演一下,假如 n =16 ,那么r esizeStamp(16)=32796
转化为二进制就是[0000 0000 0000 0000 1000 0000 0001 1100] 接着再来看,当第一个线程尝试进行扩容的时候,会执行下面这段代码U. this , SIZECTL , sc, (rs << RESIZE_STAMP_SHIFT ) + 2
,rs 左移 1 6 位,相当于原本的二进制低位变成了高位 1000 0000 0001 1100 0000 0000 0000 0000然后再加 2,就变成1000 0000 0001 1100 0000 0000 0000 0000+10= 1000 0000 0001 1100 0000 00000000 0010
,所以高16 位代表扩容的标记、低 16 位代表并行扩容的线程数。- 这样存储的好处:
- 首先在 CHM 中是支持并发扩容的,也就是说如果当前的数组需要进行扩容操作,可以由多个线程来共同负责。
- 可以保证每次 扩容都 生成 唯一的生成戳 每次新的扩容,都有一个不同的 n ,这个生成戳就是根据 n 来计算出来的一个数字, n 不同,这个数字也不同
-
第二步 进行对 sc 的判断,如果小于0 就说明有其他线程在进行扩容,所以就需要等待。
- 第一步进行判断五个条件,如果有一个为true就说明这个线程不能进行扩容操作,跳出循环
sc >>> RESIZE_STAMP_SHIFT!=rs
表示比较高RESIZE_STAMP_BITS位生成戳和rs是否相等sc==rs+1
表示扩容结束sc==rs+MAX_RESIZERS
表示线程已经到达最大值(nt = nextTable) == null
表示扩容结束transferIndex <= 0
表示所有的 transfer 任务都被领取完了,没有剩余的hash桶来给这个线程来做transfer
- 第二步当前线程正在尝试帮助此次扩容,如果成功就调用transfer
- 第一步进行判断五个条件,如果有一个为true就说明这个线程不能进行扩容操作,跳出循环
-
第三步 如果当前没有在扩容,那么 rs 肯定是一个正数,通过
rs<<RESIZE_STAMP_SHIFT
将 sc 设置为一个负数, +2 表示有一个线程在执行扩容- 调用
transfer()
方法(还没理清楚)
- 调用
-
第四步 计算元素个数,判断是否需要进行下一次扩容
-
- 第一步进行判断,首先判断集合的大小是否大于或者等于扩容的阈值,如果是就说明需要扩容,同时还要判断 table 不能为空,并且小于最大容量
-
-
总结
- ConcurrentHashMap是Java中的一个线程安全且高效的HashMap实现。平时涉及高并发如果要用map结构,那第一时间想到的就是它。相对于hashmap来说,ConcurrentHashMap就是线程安全的map,其中利用了锁分段的思想提高了并发度。
- JDK 1.8 之后,采用了 CAS + synchronized 来保证并发安全性
- ConcurrentHashMap 记录元素是通过 CounterCell 数组来记录的,而这个数组可以利用分布式的思想极大的加强并发效率
- ConcurrentHashMap 添加元素是通过CAS 的操作去完成的,但是如果当前数组下标有元素,说明 hash 碰撞了,就要使用synchronized 锁住,然后进行链表的添加