ConcurrentHashMap

为什么要使用 ConcurrentHashMap

原因一:线程安全

原因二:执行效率

基于上述两方面的原因 ConcurrentHashMap 应运而生,它的核心就是分段锁,即一个对象拥有多把锁,每一把锁只锁住对象的一部分数据,相比于 HashTable 的所有线程竞争一把锁大大提高了效率

举个简单的例子,假设一个 ConcurrentHashMap 对象的数据有 3 部分 A、B、C,那么就会对应三把锁 LockA、LockB、LockC。若线程 1 只会操作 A 部分的数据,那么它只用获取 LockA 即可,完全不会妨碍其它线程竞争 B、C 部分的数据

由于 JDK7 和 JDK8 中的 ConcurrentHashMap 实现上有区别,所以下面的内容分开介绍!!

JDK7 中的 ConcurrentHashMap

存储结构

ConcurrentHashMap 是由 Segment 数组和 HashEntry 数组组成。Segment 是一种可重入锁,在 ConcurrentHashMap 扮演锁的角色;HashEntry 则是用于存储键值对数据。具体结构如下图所示

一个 ConcurrentHashMap 包含一个 Segment 数组,Segment 的结构和 HashMap 类似,是数组➕链表的形式。每个 Segment 都守护一个 HashEntry 数组,若修改它,必须先获取对应 Segment 锁

注意:Segment 数组一旦初始化就不能改变其大小,默认大小 16,可认为默认大小下最多支持 16 个线程并发;HashEntry 数组支持扩容

1

初始化

初始化过程就是通过 initialCapacity (初始容量大小,HashEntry 数组长度,默认 16),loadFactor (负载因子,默认 0.75),concurrencyLevel (并发级别,Segment 数组长度,默认 16) 三个参数来初始化 Segment 数组、HashEntry 数组、段偏移量 segmentShift、段掩码 segmentMask

ConcurrentHashMap 有四个构造函数:(最主要的是第四个)

根据上面的源码总结一下初始化的流程:

插入

下面开始分析插入操作的源码,也就是put(key, value)方法以及它调用的一些方法:

插入操作的内容有点多,现在来总结一下整个流程:

首先是一个初始化判断,判断对应的 segment 是否初始化,若没有则调用ensureSegment()初始化,它底层使用 CAS 进行自旋检查并更新,确保操作的原子性和可见性

在进行真正的插入操作前需要使用tryLock()非阻塞的获取 segment 锁,它是ReentrantLock()独占锁,若失败则调用scanAndLockForPut()自旋调用tryLock(),若自旋次数超过指定次数,则改用lock()阻塞获取锁

根据 hash 计算得到 HashEntry 数组对应的下标,然后开始遍历。如果插入元素 key 存在则直接覆盖,否则需要头插法插入链表中,在插入前判断是否需要扩容

每次扩容都是原容量的 2 倍,然后开始遍历式移动元素到新 HashEntry 数组中,元素的新下标要么保持不变,要么变为原下标➕原容量,这都是依赖于 HashEntry 数组大小是 2n

查找

相比于插入操作,查找操作就简单很多,只需要两个步骤:

注意:查找操作不涉及数据更新修改操作,所以不需要用锁来保证线程安全,直接裸奔即可

下面开始分析查找操作的源码,也就是get(key)方法:

JDK8 中的 ConcurrentHashMap

存储结构

JDK8 中的 ConcurrentHashMap 相比于 JDK7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表/红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。具体结构如下图所示

2

这里要介绍一个特殊的节点类型:

初始化

ConcurrentHashMap 有五个构造函数:(最主要的是第五个)

注意:可以看出在构造函数中并没有初始化数组相关的对象,仅仅只是计算了一些参数,实际的初始化在第一次插入的时候,这里提前介绍一下

从源码中可以看出 table 的初始化是通过 CAS➕自旋 完成的。sizeCtl 决定着当前的初始状态:

用到的并发技巧:

插入

用到的并发技巧:

帮助扩容

addCount()

putVal()方法的最后,如果插入成功,那么会调用addCount()将计数➕1,这里面还会涉及是否需要扩容

首先有一个全局变量baseCount记录所有元素的数量,其次有一个计数桶数组。该方法给出了两种计数策略

从上面源码可以看出当计数桶没有初始化或者对计数桶更新失败时会调用fullAddCount()方法:

如果发现计数桶中的元素没有初始化,或者已经初始化但在addCount()中 CAS 更新失败,则进入fullAddCount

扩容

ConcurrentHashMap 使用 CAS 操作将扩容的并发性能实现最大化。在扩容过程中,可以安全的get()查询数据,如果有线程进行put()操作,还会协助扩容

前文提到 sizeCtl 决定着当前的状态,如果 table 已经初始化,那么 sizeCtl > 0 就表示扩容阈值,当元素个数超过了 sizeCtl 就会触发扩容

在两个地方会引起扩容,第一个就是在上面介绍的addCount()方法中;第二个就是在treeifyBin()方法中,如果链表节点数 >8 但数组大小 <64,则只会扩容而不会链表转红黑树

这两个地方扩容的步骤差不多,addCount()在上面已经分析过,这里重点分析treeifyBin()中调用的tryPresize()方法

可以看出最主要的还是transfer()方法,它是最最最长也是最最最麻烦的,它主要就是进行数据迁移

查找

相比于上面的put()而言,get()的流程简单太多!!

参考文章