注:源码系列文章主要是对某付费专栏的总结记录。如有侵权,请联系删除。
1 结构
ConcurrentHashMap 的底层数据结构和方法的实现细节和 HashMap 大体一致,但两者在类结构上却没有任何关联,如下类图:
看 ConcurrentHashMap 的源码,我们会发现很多方法和代码和 HashMap 很相似,那可能会产生疑问,为什么不继承 HashMap 呢?继承的确是个好办法,但是 ConcurrentHashMap 都是在方法中间进行一些加锁操作,也就是说加锁把方法切割了,继承就很难解决这个问题。
ConcurrentHashMap 和 HashMap 两者的相似之处:
- 数组、链表结构几乎相同,所以底层对数据结构的操作思路是相同的(只是思路相同,底层实现不同,因为 ConcurrentHashMap 需要在方法实现中间加锁);
- 都实现了 Map 接口,继承了AbstractMap 抽象类,所以大多数的方法也都是相同的,HashMap 有的方法,ConcurrentHashMap 几乎都有,所以我们需要从 HashMap 切换到 ConcurrentHashMap 时,无需关心两者之间的兼容问题。
不同之处:
- 红黑树结构略有不同,HashMap 的红黑树中的节点叫做 TreeNode,TreeNode 不仅仅有属性,还维护着红黑树的结构,比如查找(
TreeNode.getTreeNode
),新增(TreeNode.putTreeVal
)等;ConcurrentHashMap 中红黑树被拆分成两块,TreeNode 仅仅维护的属性和查找功能,新增了 TreeBin,来维护红黑树结构,并负责根节点的加锁和解锁(TreeBin.putTreeVal
负责新增,并有lockRoot
和unlockRoot
)。 - 新增 ForwardingNode(转移)节点,扩容的时候会使用到,通过使用该节点,来保证扩容时的线程安全。
2 put
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 计算 hash
int hash = spread(key.hashCode());
int binCount = 0;
// 通过自旋死循环保证一定可以新增成功
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// table 为空进行初始化操作
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 如果当前桶位置没有值,直接创建
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// CAS 在 i 位置创建新的元素,当 i 位置是 null 时才能创建成功,结束 for 自循环,否则继续自旋
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 转移节点的 hash 值是固定的,都是 MOVED
// 如果当前桶节点是转移节点,表示该槽点正在扩容,就会一直等待扩容完成
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 桶位置有值
else {
V oldVal = null;
// 锁定当前桶,其余线程不能操作,保证了安全
synchronized (f) {
// 这里再次判断索引 i 位置的数据没有被修改
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;
}
}
}
// 红黑树
// 这里没有使用 TreeNode,使用的是 TreeBin,TreeNode 只是红黑树的一个节点
// TreeBin 持有红黑树的引用,并且会对其加锁,保证其操作的线程安全
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// 在 putTreeVal 方法里面,在给红黑树重新着色旋转的时候,会锁住红黑树的根节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// binCount 不为空表示当前索引 i 位置有值
if (binCount != 0) {
// 链表是否转换为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// oldVal 存在则直接返回,也就是不需要走后面的检查扩容步骤
if (oldVal != null)
return oldVal;
break;
}
}
}
// 检查容器是否需要扩容,如果需要扩容,调用 transfer 方法去扩容
// 如果已经在扩容中了,则检查扩容是否完成
addCount(1L, binCount);
return null;
}
下面终点说下 ConcurrentHashMap 在 put 过程中,采用了哪些手段来保证线程安全。
2.1 数组初始化时的线程安全 initTable
数组初始化时,首先通过自旋来保证一定可以初始化成功,然后通过
// 初始化数组,通过对 sizeCtl 的变量赋值来保证数组只被初始化一次
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 通过自旋保证初始化成功
while ((tab = table) == null || tab.length == 0) {
// 如果 sizeCtl 小于0(也就是等于 -1) 时表示有线程正在初始化,释放当前 CPU 的调度权,重新发起锁的竞争
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// CAS 赋值保证了当前只有一个线程初始化 SIZECTL 为 -1,保证了数组的初始化的安全性
// -1: 待更新的值
// sc: 期望值
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 有可能执行到这里的时候,table 已经不为空了,类似于单例模式的双重 check
if ((tab = table) == null || tab.length == 0) {
// 初始化数组大小为 16 (DEFAULT_CAPACITY)
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
2.2 新增节点值时的线程安全
此时为了保证线程安全,做了四处优化:
- 通过自旋死循环保证一定可以新增成功。
在新增之前,通过 for (Node<K,V>[] tab = table;;)
这样的死循环来保证新增一定可以成功,一旦新增成功,就可以退出当前死循环,新增失败的话,会重复新增的步骤,直到新增成功为止。
- 当前节点为空时,通过 CAS 新增
Java 这里的写法非常严谨,没有在判断节点为空的情况下直接赋值,因为在判断节点为空和赋值的瞬间,很有可能节点已经被其它线程赋值了,所以我们采用 CAS 算法,能够保证节点为空的情况下赋值成功,如果恰好节点已经被其它线程赋值,当前 CAS 操作失败,会再次执行 for 自旋,再走节点有值的 put 流程,这里就是自旋 + CAS 的组合。
- 当前节点有值,锁住当前节点。
新增时,如果当前节点有值,就是 key 的 hash 冲突的情况,此时节点上可能是链表或红黑树,我们通过锁住节点,来保证同一时刻只会有一个线程对节点进行修改:synchronized (f)
- 红黑树旋转时,锁住红黑树的根节点,保证同一时刻,当前红黑树只能被一个线程旋转。
Snipaste_2020-04-20_11-02-43.png
在旋转之前,锁住红黑树的根节点。如果锁定失败,将一直竞争,直到得到为止。
通过以上四点,保证了各种情况下的新增(不考虑扩容的情况下),都是些线程安全的,通过自旋 + CAS + 锁,实现的很巧妙,值得我们借鉴。
2.3 扩容时的线程安全
略(暂未理解)
3 get
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
// 数组不为空并且当前槽点的数据不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 槽点的第一个值相等则直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果是红黑树或者转移节点,使用其对应的 find 方法
// 扩容时会将槽点设置为转移节点,转移节点的 hash 为 MOVED(-1)
// 红黑树: ???
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 如果是链表则遍历查找
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
------------------------------------- END -------------------------------------