上面介绍完了对象属性,我们继续来看看 ConcurrentHashMap 的构造方法,源码如下:
this
调用对应的构造方法,源码如下:
从源码上可以看出,ConcurrentHashMap 初始化方法有三个参数,initialCapacity(初始化容量)为 16、loadFactor(负载因子)为 0.75、concurrentLevel(并发等级)为 16,如果不指定则会使用默认值。
其中,值得注意的是 concurrentLevel 这个参数,虽然 Segment 数组大小 ssize 是由 concurrentLevel 来决定的,但是却不一定等于 concurrentLevel,ssize 通过位移动运算,一定是大于或者等于 concurrentLevel 的最小的 2 的次幂!
通过计算可以看出,按默认的 initialCapacity 初始容量为 16,concurrentLevel 并发等级为 16,理论上就允许 16 个线程并发执行,并且每一个线程独占一把锁访问 Segment,不影响其它的 Segment 操作!
从之前的文章中,我们了解到 HashMap 在多线程环境下操作可能会导致程序死循环,仔细想想你会发现,造成这个问题无非是 put 和扩容阶段发生的!
那么这样我们就可以从 put 方法下手了,来看看 ConcurrentHashMap 是怎么操作的?
3.1、put 操作
ConcurrentHashMap 的 put 方法,源码如下:
从源码可以看出,这部分的 put 操作主要分两步:
- 定位 Segment 并确保定位的 Segment 已初始化;
- 调用 Segment 的 put 方法;
真正插入元素的 put 方法,源码如下:
从源码可以看出,真正的 put 操作主要分以下几步:
- 第一步,尝试获取对象锁,如果获取到返回 true,否则执行
scanAndLockForPut
方法,这个方法也是尝试获取对象锁; - 第二步,获取到锁之后,类似 hashMap 的 put 方法,通过 key 计算所在 HashEntry 数组的下标;
- 第三步,获取到数组下标之后遍历链表内容,通过 key 和 hash 值判断是否 key 已存在,如果已经存在,通过标识符判断是否覆盖,默认覆盖;
- 第四步,如果不存在,采用头插法插入到 HashEntry 对象中;
- 第五步,最后操作完整之后,释放对象锁;
我们再来看看,上面提到的scanAndLockForPut
这个方法,源码如下:
scanAndLockForPut
这个方法,操作也是分以下几步:
- 当前线程尝试去获得锁,查找 key 是否已经存在,如果不存在,就创建一个 HashEntry 对象;
- 如果重试次数大于最大次数,就调用
lock()
方法获取对象锁,如果依然没有获取到,当前线程就阻塞,直到获取之后退出循环; - 在这个过程中,key 可能被别的线程给插入,所以在第 5 步中,如果 HashEntry 存储内容发生变化,重置重试次数;
通过scanAndLockForPut()
方法,当前线程就可以在即使获取不到segment
锁的情况下,完成需要添加节点的实例化工作,当获取锁后,就可以直接将该节点插入链表即可。
这个方法还实现了类似于自旋锁的功能,循环式的判断对象锁是否能够被成功获取,直到获取到锁才会退出循环,防止执行 put 操作的线程频繁阻塞,这些优化都提升了 put 操作的性能。
3.2、get 操作
get 方法就比较简单了,因为不涉及增、删、改操作,所以不存在并发故障问题,源码如下:
由于 HashEntry 涉及到的共享变量都使用 volatile 修饰,volatile 可以保证内存可见性,所以不会读取到过期数据。
3.3、remove 操作
remove 操作和 put 方法差不多,都需要获取对象锁才能操作,通过 key 找到元素所在的 Segment 对象然后移除,源码如下:
与 get 方法类似,先获取 Segment 数组所在的 Segment 对象,然后通过 Segment 对象去移除元素,源码如下:
先获取对象锁,如果获取到之后执行移除操作,之后的操作类似 hashMap 的移除方法,步骤如下:
- 先获取对象锁;
- 计算 key 的 hash 值在 HashEntry[]中的角标;
- 根据 index 角标获取 HashEntry 对象;
- 循环遍历 HashEntry 对象,HashEntry 为单向链表结构;
- 通过 key 和 hash 判断 key 是否存在,如果存在,就移除元素,并将需要移除的元素节点的下一个,向上移;
- 最后就是释放对象锁,以便其他线程使用;
四、JDK1.8 中的 ConcurrentHashMap
虽然 JDK1.7 中的 ConcurrentHashMap 解决了 HashMap 并发的安全性,但是当冲突的链表过长时,在查询遍历的时候依然很慢!
在 JDK1.8 中,HashMap 引入了红黑二叉树设计,当冲突的链表长度大于 8 时,会将链表转化成红黑二叉树结构,红黑二叉树又被称为平衡二叉树,在查询效率方面,又大大的提高了不少。
因为 HashMap 并不支持在多线程环境下使用, JDK1.8 中的 ConcurrentHashMap 和往期 JDK 中的 ConcurrentHashMa 一样支持并发操作,整体结构和 JDK1.8 中的 HashMap 类似,相比 JDK1.7 中的 ConcurrentHashMap, 它抛弃了原有的 Segment 分段锁实现,采用了 CAS + synchronized
来保证并发的安全性。
JDK1.8 中的 ConcurrentHashMap 对节点Node
类中的共享变量,和 JDK1.7 一样,使用volatile
关键字,保证多线程操作时,变量的可见行!
其他的细节,与 JDK1.8 中的 HashMap 类似,我们来具体看看 put 方法!
4.1、put 操作
打开 JDK1.8 中的 ConcurrentHashMap 中的 put 方法,源码如下:
当进行 put 操作时,流程大概可以分如下几个步骤:
- 首先会判断 key、value 是否为空,如果为空就抛异常!
- 接着会判断容器数组是否为空,如果为空就初始化数组;
- 进一步判断,要插入的元素
f
,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入; - 在接着判断
f.hash == -1
是否成立,如果成立,说明当前f
是ForwardingNode
节点,表示有其它线程正在扩容,则一起进行扩容操作; - 其他的情况,就是把新的
Node
节点按链表或红黑树的方式插入到合适的位置; - 节点插入完成之后,接着判断链表长度是否超过
8
,如果超过8
个,就将链表转化为红黑树结构; - 最后,插入完成之后,进行扩容判断;
put 操作大致的流程,就是这样的,可以看的出,复杂程度比 JDK1.7 上了一个台阶。