前言
ConcurrentHashMap是个老生常谈的集合类了,我们都知道多线程环境下不能直接使用HashMap,而需要使用ConcurrentHashMap,但有没有了解过ConcurrentHashMap到底是如何实现线程安全的呢?他到底跟传统的Hashtable和SynchronizeMap(没听过SynchronizeMap?他就是Collections.synchronizeMap方法返回的对象)到底好在哪?
ConcurrentHashMap建立在HashMap的基础上实现了线程安全,关于HashMap读者可以参考这篇文章:深入剖析HashMap,从散列表的三大要素:哈希函数、哈希冲突、扩容方案、以及线程安全展开详解HashMap的设计。关于HashMap的内容本文不再赘述,读者若对HashMap的底层设计不了解,一定要先去阅读前面的文章。ConcurrentHashMap中蕴含的并发编程智慧是非常值得我们学习的,正如文章开头的两个问题,你会如何解决呢?可能会直接上锁或用更高性能的CAS,但ConcurrentHashMap给了我们更不一样的解决方案。
本文的主要内容是讲解ConcurrentHashMap中的并发设计,重点分析ConcurrentHashMap的四个方法源码:putVal
、initTable
、addCount
、transfer
。分析每个方法前会使用图解介绍ConcurrentHashMap的核心思路。源码中我加了非常详细的注释,有时间仍建议读者阅读完源码,ConcurrentHashMap的并发智慧,都蕴含在源码中。
CAS与自旋锁
CAS是ConcurrentHashMap中的一个重点,也是ConcurrentHashMap提升性能的根基所在。在阅读源码中,可以发现CAS无处不在。在介绍ConcurrentHashMap前,必须先介绍一下这两个重点。
Java中的运算并不是原子操作,如count++
可分为:
- 获取count副本count_
- 对count_进行自增
- 把count_赋值给count
如果在第一步之后,count被其他的线程修改了,第三步的赋值会直接覆盖掉其他线程的修改。synchronize可以解决这个问题,但上锁为重量级操作,严重影响性能,CAS是更好的解决方案。
CAS的思路并不复杂。还是上面的例子:当我们需要对变量count进行自增时,我们可以认为没有发生并发冲突,先存储一个count副本,再对count进行自增,然后把副本和count本身进行比较,如果两者相同,则证明没有发生并发冲突,修改count的值;如果不同,则说明count在我们自增的过程中被修改了,把上述整个过程重新来一遍,直到修改成功为止,如下图:
那,如果我们在判断count==count_之后,count被修改了怎么办?比较赋值的操作操作系统会保证的原子性,保证不会出现这种情况。在java中常见的CAS方法有:
// 比较并替换
U.compareAndSwapInt();
U.compareAndSwapLong();
U.compareAndSwapObject();
在后续的源码中,我们会经常看到他们。通过这种思路,我们不需要给count变量上锁。但如果并发度过高,处理时间过长,则会导致某些线程一直在循环自旋,浪费cpu资源。
自旋锁是利用CAS而设计的一种应用层面的锁。如下代码:
// 0代表锁释放,1代表锁被某个线程拿走了
int lock = 0;
while(true){
if(lock==0){
int lock_ ;
if(U.compareAndSwapInt(this,lock_,0,1)){
... // 获取锁后的逻辑处理
// 最后释放锁
lock = 0;
break;
}
}
}
上面就是很经典自旋锁设计。判断锁是否被其他线程拥有,若没有则尝试使用CAS获得锁;前两步失败都会重新循环再次尝试直到获得锁。最后逻辑处理完成要令lock=0
来释放锁。冲突时间短的并发情景下这种方法可以大大提升效率。
CAS和自旋锁在ConcurrentHashMap应用地非常广泛,在源码中我们会经常看到他们的身影。同时这也是ConcurrentHashMap的设计核心所在。
认识ConcurrentHashMap的并发策略
Hashtable与SynchronizeMap采取的并发策略是对整个数组对象加锁,导致性能及其低下。jdk1.7之前,ConcurrentHashMap采用的是锁分段策略来优化性能,如下图:
相当于把整个数组,拆分成多个小数组。每次操作只需要锁住操作的小数组即可,不同的segment之间不互相影响,提高了性能。jdk1.8之后,对整个策略进行了重构:锁的不是segment,而是节点,如下图:
锁的粒度进一步被降低,并发的效率也提高了。jdk1.8做得优化不只是细化锁粒度,还带来了CAS+synchronize的设计。那么下面,我们针对ConcurrentHashMap的常见方法:添加、删除、扩容、初始化等进行详解他的设计思路。
添加数据:putVal()
ConcurrentHashMap添加数据时,采取了CAS+synchronize结合策略。首先会判断该节点是否为null,如果为null,尝试使用CAS添加节点;如果添加失败,说明发生了并发冲突,再对节点进行上锁并插入数据。在并发较低的情景下无需加锁,可以显著提高性能。同时只会CAS尝试一次,也不会造成线程长时间等待浪费cpu时间的情况。
ConcurrentHashMap的put方法整体流程如下(并不是全部流程):
- 首先会判断数组是否已经初始化,如若未初始化,会先去初始化数组;
- 如果当前要插入的节点为null,尝试使用CAS插入数据;
- 如果不为null,则判断节点hash值是否为-1;-1表示数组正在扩容,会先去协助扩容,再回来继续插入数据。(协助扩容后面会讲)
- 最后会执行上锁,并插入数据,最后判断是否需要返回旧值;如果不是覆盖旧值,需要更新map中的节点数,也就是图中的addCount方法。
ConcurrentHashMap是基于HashMap改造的,其中的插入数据、hash算法和HashMap都大同小异,这里不再赘述。思路清晰之后,下面我们看源码分析:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 不允许插入空值或空键
// 允许value空值会导致get方法返回null时有两种情况:
// 1. 找不到对应的key2. 找到了但是value为null;
// 当get方法返回null时无法判断是哪种情况,在并发环境下containsKey方法已不再可靠,
// 需要返回null来表示查询不到数据。允许key空值需要额外的逻辑处理,占用了数组空间,且并没有多大的实用价值。
// HashMap支持键和值为null,但基于以上原因,ConcurrentHashMap是不支持空键值。
if (key == null || value == null) throw new NullPointerException();
// 高低位异或扰动hashcode,和HashMap类似
// 但有一点点不同,后面会讲,这里可以简单认为一样的就可以
int hash = spread(key.hashCode());
// bincount表示链表的节点数
int binCount = 0;
// 尝试多种方法循环处理,后续会有很多这种设计
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 情况一:如果数组为空则进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 情况二:目标下标对象为null
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 重点:采用CAS进行插入
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
break;
}
// 情况三:数组正在扩容,帮忙迁移数据到新的数组
// 同时会新数组,下次循环就是插入到新的数组
// 关于扩容的内容后面再讲,这里理解为正在扩容即可
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 情况四:直接对节点进行加锁,插入数据
// 下面代码很多,但逻辑和HashMap插入数据大同小异
// 因为已经上锁,不涉及并发安全设计
else {
V oldVal = null;
// 同步加锁
synchronized (f) {
// 重复检查一下刚刚获取的对象有没有发生变化
if (tabAt(tab, i) == f) {
// 链表处理情况
if (fh >= 0) {
binCount = 1;
// 循环链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 找到相同的则记录旧值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
// 判断是否需要更新数值
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
// 若未找到则插在链表尾
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 红黑树处理情况