在jdk8中,concurrentHashMap是我们认为的线程安全的hashmap,这篇博客主要记录concurrentHashMap的原理
在jdk8中,hashMap的结构是:数组 + 链表 + 红黑树,concurrentHashMap也一样,也是这个结构,只是concurrentHashMap多了线程安全
源码入口
我们以put的过程为例,来介绍concurrentHashMap线程安全的原因
和hashMap一样,在put的时候,其实内部调用的是putVal()方法
下面是put的核心源码,我们一一拆解
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//1.在put元素的时候,首先判断当前tab是否为空,为空,就初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//2.如果tab不为空,就根据key计算出一个下标值,判断数组中这个位置是否为null,为null,就new一个新的node节点,通过cas设置到该数组对应的元素中
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
/**
* 3.这里的f就是根据put的key计算得到的元素下标位置,如果hash值为-1,表示当前有其他线程正在进行扩容
* 如果有其他线程在扩容,那helpTransfer的意思是帮助另外一个线程去扩容
*
* 这里要帮助扩容是这样的:
* 如果A线程正在对数组进行扩容,会把A线程自己正在扩容的tab[8]位置的元素的hash设置为moved
* 1、如果B线程在put元素的时候,正好是要放到tab[8]这个位置的,那此时就没办法插入了,因为A线程正在迁移这个位置的元素,所以:
* 干脆B线程就帮助A线程一起去迁移整个数组
* 等数组迁移完成了,B线程就会再来一遍循环,此时获取到的table,就是新的table,就可以进行加锁、put数据等
* 2、如果B线程在put元素的时候,是要放到tab[7]位置,那此时是不受影响的(我这里只是举例子,只说思想),就可以继续对tab[7]加锁,然后进行put
* 此时就是A线程一遍迁移tab[8]位置的元素,B线程一遍向tab[7]位置插入元素
*/
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
//4.进入到这里,表示是正常的插入
V oldVal = null;
synchronized (f) {
/**
* 4.1 先通过synchronized加锁
* 4.2 然后再判断当前i这个位置是否是f节点,因为有可能当前线程在执行的时候,其他线程修改了数组中i位置对应的结点信息
* 4.3 如果hashCode大于等于0,表示这是一个链表
* 4.3.1 从头结点开始循环,如果找到key相同的node结点,就把值放到oldVal,然后value覆盖原来的value
* 4.3.2 如果到最后一个结点,还没有找到key相等的,就插入到队尾
* 4.4 如果当前node结点是TreeBin类型的,那就是树
*
* 4.5 如果bigCount大于0,且大于链表转红黑树的阈值,就进行树的转换
* 如果oldValue不为null,表示是值覆盖,就返回oldValue
*/
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
//这里的if判断,是进行覆盖操作
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;
//如果遍历到尾结点还是没有查到相同的key,就插入到尾结点的后面
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 如果当前元素存储的是红黑树
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
/**
* 5.判断是否需要进行树化
* 如果oldVal不为null,表示是进行了覆盖写入,直接return 即可
*/
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
/**
* 6.将concurrentHashMap维护的元素个数 + 1,在这个方法中,可能会触发扩容的逻辑
*/
addCount(1L, binCount);
return null;
}
一、如果tab为空
这里对应代码中的注释1,如果tab为null,表示当前数组需要初始化,会通过initTable()来完成初始化
这里就不做过多解释了,注释写到应该还算比较清楚
二、如果tab[i] == null
这里对应注释的第二点,如果tab不为null,会根据当前key的hash值和当前数组长度进行 &运算,得到当前元素要插入的位置,如果这个位置为null,表示还没有元素插入,当前元素可以直接写入,在写的时候,会发现,这里采用的是cas的方式,这也是concurrentHashMap线程安全的原因之一
三、辅助扩容
辅助扩展这部分,先跳过吧,这里的细节太多了,后面单独起一篇博客,写辅助扩容的逻辑,这里大致讲下辅助扩容的逻辑:
如果tab[i]位置已经存在元素,那就会继续走这里的判断,这里的 f 是在上面获取到的tab[i]位置的node节点,这里判断,如果这个node节点的hash值为MOVED(-1),就表示当前tab[i]位置正在进行扩容,此时会通过helpTransfer()方法,进入到辅助扩容的逻辑,这里辅助扩容,用白话来讲:
- 当线程1插入元素时,如果发现要插入的tab[i]位置的hash值变为了MOVED,表示当前concurrentHashMap正在对tab[i]位置进行扩容,或者是已经完成了扩容,此时就不允许继续往tab[i]位置插入元素
- 有可能是线程2正在对tab[i]进行扩容
- 那此时线程1,就会暂停写入的动作,去去进行扩容,有可能线程2正在对 tab[16] - tab[31]位置的元素进行扩容,那此时线程1就会对tab[1] - tab[15]位置的元素进行扩容,可以理解为多线程并行操作,每个线程只负责一部分元素节点的扩容
- 这里所谓的扩容,简单来讲,就是把老的数组中的元素,迁移到新的数组中,所以在concurrentHashMap中,每个线程在进行数据迁移的时候,只会锁定一部分,处理一部分,接着锁定下一部分,继续迁移
四、对链路、红黑树进行遍历判断
如果进入到第四点的注释这里,表示,当前元素要插入的位置,已经有元素了,并且没有在进行扩容操作,所以,此时会看到,进来之后,先通过synchronized对f这个元素节点进行加锁,这个f节点,上面说过了,就是根据key的hash值和当前数组长度&运算之后,计算出来的要插入位置的root节点
在这里,实际上,需要区分链表和红黑树,虽然处理思想是相似的,但是因为两者数据结构不同,所以处理逻辑代码实现上会有所区别
这部分代码,是链表的场景下,进行元素插入的逻辑,我们看这段代码,大致的意思是:
会从链表的头结点,开始遍历,如果待插入的元素key和key的hash值和链表中的节点完全一致,表示需要进行覆盖操作,否则,继续获取next节点进行判断
如果遍历到节点尾部,依旧没有相同的节点,那就执行插入的动作,所谓的插入,就是根据key和value生成node节点,和最后一个节点的next指针关联上
这里是红黑树的处理逻辑,对于红黑树的插入逻辑,putTreeVal()方法里面的细节没有完全搞懂,先跳过,总之这个putTreeVal()方法,如果没有插入新的节点,而是进行了覆盖操作,会把被覆盖的old节点返回
五、判断是否需要转换为红黑树
这里主要和插入链表有关系,会看到,上面在遍历链表的时候,会依次累加bitCount的值,在这里,会根据bitCount的值,判断是否需要树化,如果超过了指定阈值,就会把红黑树转换为链表
treeifyBin的逻辑大致是这个样子的
- 在树化前的链表,是单向链表,只有next指针
- 在树化的时候,会将单向链表转换为双向链表,分别有next和prev指针
- 在转换的过程中,会依次向红黑数中插入元素,在插入的过程中,红黑树的root节点,可能会发生变化,当root节点发生变化时,会把最新的root节点,移动到双向链表的头部
- 直到最后一个节点插入到树中完毕,就会形成下图右边的两个数据结构
- 转换之后,tab[i]位置存放的就是root节点这一个元素,根据这个元素,可以很快的从树中找到要使用的元素
六、判断是否需要进行扩容
在addCount()方法中,会把当前hashmap中统计总元素个数的值 + 1,这里需要知道,如果在put的时候,做的是覆盖操作,在第五步判断是否需要进行树化的时候,就会return,
如果oldVal不为null,表示是覆盖操作,直接return即可
如果没有覆盖,而是插入了一个新的元素,那就需要执行addCount()的逻辑,这个逻辑,也不贴代码了,这里后面单起博客说明
这里addCount()的方法,也是线程安全的表现,在对volatile修饰的baseCount进行+1时,使用的是cas方法来完成的
这里的逻辑,其实和LongAdder的思想是类似的
- 通过cas,对baseCount进行+1,如果cas成功,return,如果cas失败,表示有其他线程对baseCount也在进行操作
- 此时会通过额外的一个数组来完成+1,这个数组中的元素,也是volatile修饰的,会根据key,与这个额外的数据,进行&运算,计算一个值,假设为n
- 会取这个额外数组中n位置的元素,然后通过cas进行+1
- 所以我们会发现,concurrentHashMap,我们通过方法获取元素个数的时候,并不是直接return了baseCount,而是需要把数组中的元素值也加上
这里的counterCells就是我们上面说的额外的数组
总结
所以,总结来看,concurrentHashMap线程安全的原因有以下几点
- 会通过cas,对tab[i]位置进行赋值
- 在插入元素的时候,会通过synchronized来加锁
- 在统计hashmap中元素个数的时候,通过volatile修饰的变量,以及cas操作来完成+1