ConcurrentHashMap的并发原理
我们都知道jdk1.8之前ConcurrentHashMap是采用分段锁的机制允许多线程并发操作一个ConcurrentHashMap的方式来提高并发的,它的主要思想是优化HashTable中全局锁,让锁更加细粒度来达到更高的并发度的。
但是JDK1.8里面的ConcurrentHashMap则完全不一样,它用到了CAS思想,使用的是乐观锁思想,乐观锁认为对于同一个数据的并发操作是不会发生修改,在更新数据时会采用尝试更新不断重试的方式更新数据。synchronized锁则只锁一个节点,这样大大提高了并发性能,而且ConcurrentHashMap的数据结构和HashMap是一样的,不了解HashMap的结构可以参考下面这篇文章:
https://blog.csdn.net/justuseit/article/details/102487264
JDK1.8中的ConcurrentHashMap中比HashMap多了一个重要属性sizeCtl,它是一个控制标识符,为0时,表示table还未初始化,为正数时表示初始化或下一次扩容的大小;而如果sizeCtl为-1,表示正在进行初始化操作;而为-N时,则表示有N-1个线程正在进行扩容。
我们只需要理解ConcurrentHashMap的put和get两个方法就能理解它的并发原理。
1、put方法
先看put方法代码:
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
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; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
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);
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;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
核心逻辑在putVal方法里面,这个方法大致逻辑如下:
1、检查key和value都不能为空,算出key的hash值
2、如果table还没有初始化,则初始化table。
3、根据hash值取出table里的首节点,如果为空,就把当前值构造成节点通过cas插入当前桶中。如果插入成功就退出循环,返回值;如果插入失败则进行下一轮循环。
4、hash值对应的桶节点不为空,并且节点的hash值为-1,表示正在扩容,那么当前线程就加入扩容队伍帮助扩容
5、如果hash值匹配到的节点的hash值和value都和当前要put进去的值相等,并且设置了onlyIfAbsent为真,就直接返回当前存在的value值
6、以上情况都不满足,则对当前节点加锁,执行put链表和二叉树的逻辑,这个和hashMap的逻辑就是差不多的。
我们再来看看初始化table的代码:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
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;
}
1、如果发现有其他线程在初始化,就让当前线程让出cpu,继续下一轮循环。
2、如果没有其他线程在初始化,就通过cas修改sizeCtl的值为-1,开始进行初始化操作。
这里通过线程让出cpu继续循环,不挂起当前线程,减少线程间切换,又通过CAS操作来修改控制变量sizeCtl,设置为-1,如果设置成功,就进入初始化逻辑块,如果设置失败线程又进入下一轮循环,下一轮循环进来发现sizeCtl小于零,主动让出CPU。
可以看出来这个方法里面大量使用了CAS操作,多个地方去掉了锁的使用,不让线程挂起,减少了线程上下文切换,因为一个线程上下文切换要消耗几万个cpu时钟周期,而一个CAS重试只需要几十个CPU时钟周期,即使CAS重试几十次都还是性能要好于线程切换的。
2、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;
}
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;
}
可以看到整个get分为三种情况:
1、获取到节点不为空,并且节点的hash值等于key的hash值,直接返回节点的val
2、获取到的节点不为空,但是头节点的hash值不等于key的hash值,并且节点是一个链表,就遍历链表,找到对应的数据
3、如果节点的hash值小于零,则节点是一个二叉树(也可能正在扩容),就从树中找到对应的值。
通看所有代码,整个过程都没有用到锁。
那么有些人可能会觉得奇怪这个get方法如何保证线程安全?
比如下面两种情况:
这个疑虑就不用当心了,因为val这个字段在node类里面是volatile的,也就是线程直接是可见的。
还有一种情况就是,如果读线程找到了hash值相同的节点,但是写线程直接remove了呢?
我们去看看remove方法:
public V remove(Object key) {
return replaceNode(key, null, null);
}
/**
* Implementation for the four public remove/replace methods:
* Replaces node value with v, conditional upon match of cv if
* non-null. If resulting value is null, delete.
*/
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
boolean validated = false;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
validated = true;
for (Node<K,V> e = f, pred = null;;) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
e.val = value;
else if (pred != null)
pred.next = e.next;
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
if ((e = e.next) == null)
break;
}
}
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (validated) {
if (oldVal != null) {
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
return null;
}
这个方法有点儿长,核心代码情况下面:
其实remove方法是把原来节点的值给替换成null,然后再把这个节点的引用从链表中移除的。如果删除发生在return e.val这行代码之前,那么返回的val肯定是null。
从ConcurrentHashMap的源码中我们可以学到很多并发控制的优秀思想,我个人就总结两点:
(1)当其他线程需要修改,当前线程也需要修改时,可以循环CAS修改直到成功。
(2)当其他线程在修改,当前线程可以修改也可以不修改时,可以帮组其他线程修改,也可以让出cpu,循环等待。
这样可以减少很多线程上下文切换。