第二章 JDK源码剖析-并发篇
第 18 节
ConcurrentHashMap
经过之前并发组件的学习,相信你对Condition、ReentrantLock(Sync/AQS)、volatile有了更多的认识,通过不同的组合实现不同的并发组件,当然你也可以自己单独是引用Condition或者ReentrantLock这些基础并发工具。之后我也会在第三章逐步让大家积累这些组件的在开源框架的实践经验。
除了并发组件,我们在并发场景下更多的使用的是一些并发集合。所以接下我们就来分析下常见的并发集合。首先第一个分析的就是最常见的ConcurrentHashMap。
ConcurrentHashMap和HashMap到底有什么差异?
你还记得HashMap的知识么,为了方便后面更快的学习ConcuurentHashMap,下面给大家回顾下:
hash值计算的算法是什么?就是key.hashCode()吗?
答:hash = h & h >>> 16 (高16位参与计算,减少hash碰撞概率)
默认情况下,put第一个元素时候容量大小是多少?扩容阈值又是多少?
答:capacity=16 factor=0.75threadsold=12
hash寻址如何进行的?
答:index = h & n -1
hash值如果计算的相同该怎么解决冲突?
答:元素冲突三种情况 key值相等,覆盖;不同,单链表;链表大于8,变成红黑树
HashMap扩容后怎么进行rehash的?
答:元素新位置,三种情况原位置有链表,原位置或者原位置+原来大小,尾插法更改链表,元素位置会颠倒。有红黑树:原位置或者原位置+原来大小
指定大小的HashMap,扩容阈值算法是什么?
答:指定容量大小cap,会进行重新计算,算法:n=cap-1,n连续位或+n右移1/2/4/8/16/,计算出最靠近2的n次幂作为容量大小(为了之后取模和位运算的高效率)
那ConcurrentHashMap和HashMap有什么区别呢?
其实ConcurrentHashMap仍然遵循了HashMap这些特征,只是在一些操作上考虑了多线程并发安全问题。
为了多线程安全肯定可以通过加锁,但是真的要像HashTable那样暴力,在每个方法上加Sychronized么?
那样锁的粒度太大了,所以JDK1.7使用了Segment分段锁机制,降低了加锁粒度。而JDK1.8的实现已经抛弃了Segment分段锁机制,利用CAS+Synchronized来保证并发更新的安全。
所以本节重点就是研究下ConcurrentHashMap如何使用CAS+Synchronized进行细粒度锁,提高并发下性能的,减少锁冲突的。
ConcurrentHashMap的put到底怎么支持线程安全的?
有了之前研究HashMap的经验,之前Hashmap核心的点,主要为初始化、put、get、扩容,hash算法、寻址算法这几个点。
但是由于构造函数,hash算法、寻址算法,HashMap没什么区别,这里就不细看了。接下来我们主要看下put、get、扩容这几个操作。
首先是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;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//省略
}
这个方法的整体脉络和Hashmap很像,我们一段一段的来看,先看这两个步骤:
1、spread方法计算hash值
2、initTable初始化Map大小(CAS操作)
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
spread是计算hash值的方法,本质和hashMap一样,将key的hashCode进行高低16位进行异或。HASH_BITS全是1不影响高低16位的异或结果。
接着执行inittable:
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.compareAndSwapInt(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[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
脉络比较清晰,就是给ConcurrentHashMap赋值了2个属性,初始化了数组:
赋值了sizeCtl=默认容量-默认容量/2
分配了一个Node数组给table属性
但是值得注意的是whlie循环+一个局部变量的CAS操作,保证这两个赋值操作的原子性,同时间只能有一个线程执行else逻辑。
除此之外,和 HashMap的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;
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, null)))
break; // no lock when adding to empty bin
}
代码继续执行,到了tabAt 、casTabAt这一步,这两个操作。
第一步明显是寻址,算法没有变,还是hash&(n-1)取模。
不过值得注意的是volatile读,并发情况如果有人写入了当前线程也可以看到的。
//i = (n - 1) & hash)
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node)U.getObjectVolatile(tab, ((long)i <
}
第二步casTabAt,这个从名字上看就是一个CAS赋值操作,将node设置的寻址对应的位置,前提是这个位置之前是null。
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i <
}
这里的CAS操作,肯定也是为了保证put时候的并发安全性。
整个过程如下图所示:
到这里一次put基本就完成了。
ConcurrentHashMap,基本上每次put都会进行hash值计算->寻址->Valatile读->CAS设置值。
而且整个过程都是线程安全的是不是?那这是不是就意味着,只要多线程不是对同一个位置进行设置值,Map中(数组)每个位置设置的值时,都是独立的。这个就是意味着是不是分段的思想。或者这么说,你会更好理解,CAS操作可以认为是一把自旋锁。数组每个位置都是一把CAS自旋锁,有多长就有多少把锁是不是?
寻址发生冲突怎么办?
在没有冲突的情况,通过CAS+valatile可以保证并发put的线程安全性。但是如果发生冲突呢?
很简单,和HashMap一样,变成链表,链表大于8变成红黑树。put代码如下:
/** 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;
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, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
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;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
简单看下就行,主要是最后一个else逻辑你看到了什么?synchronized(f) {}代码块。
这个f是什么?就是寻址后的对象,由于有冲突f肯定不为空,也就是当发生冲突的时候,对f加一把对象锁,之后就是通过transfer方法,产生冲突可能是挂成单链表,也可能是转换成红黑树。里面具体逻辑就不分析了,和hashMap很类似的。
但是你可以发现这个操作也是针对每一个数组位置的,意思就是说也是分段的。有多少位置就有多少锁。
到这里你就可以得到如下结论:ConcurrentHashMap通过细粒度的锁,实现了put操作的并发安全。如下图所示:
扩容线程安全么?
一般put到了一定程度(超过阈值sizeCtl或者数组某个位置节点超过8总数小于64时)就会进行扩容。
为了关注扩容代码,将put方法简化(其余代码基本分析过了)如下:
final V putVal(K key, V value, boolean onlyIfAbsent) {
//省略其他代码
for (Node<K,V>[] tab = table;;) {
//省略其他代码
if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
}
//省略其他代码
}
addCount(1L, binCount);
return null;
}
核心就是helpTransfer和addCount。这两个方法要仔细分析起来很多细节,这里给大家简单介绍下,相信你已经有能力自己看懂细节,这里提供思路,你可以一步一步分析即可。
1、这个if判断, (fh = f.hash) == MOVED 表示当前正在移动元素,也就是在扩容,扩容的时候如果有线程执行到helpTransfer,表示可以在帮助其他线程挪动元素。
2、addCount方法,非常有意思,主要有三点:
1)支持多个线程同时扩容,扩容本质是申请空间,移动元素。(意味着可以多个线程同时移动不同位置的元素+通过volatile+CAS保证线程安全性),主要通过sizeCtl的大小判断当前扩容是否完成,如果没有会加入扩容,否则会开始扩容。
2)其次通过LongAdder中的CoutnerCell,分段记录了扩容期间,其他线程增加的元素个数(分段CAS统计总数)
3)扩容时,复制出一个新的2倍大小的数组nextTab,不影响之前数组的更新。(有写时复制的思想)
代码如下:
/**
* Helps transfer if a resize is in progress.
*/
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
通过一些条件判断是否可以帮助扩容线程进行transfer。因为元素和多时候,transfer需要移动很多元素,无论是链表、红黑树的调整还是普通元素的调整。
private final void addCount(long x, int check) {
... 省略部分代码
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length)
int rs = resizeStamp(n);
if (sc 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs <2))
transfer(tab, null)
s = sumCount();
}
}
}
参与扩容或者 开始扩容,计算扩容后整数
默认扩容2倍:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
//省略
Node<K,V>[] nt = (Node[])new Node<?,?>[n <1];
//省略
}
上面的代码虽然复杂,但是如果只关心脉络的话,并不难。主要就是考虑了多线程扩容和扩容时增加元素问题。
简单总结张图给大家:
get操作分析
了解了put和扩容原理后,最后你可以看下get方法。
get方法很简单,就是进行了spread方法寻址,之后tabAt方法,volatile读了一下值,保证多线程读。如果在扩容,会从nextTable中find最新的数据(e.find(h,key)))。
代码如下:
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方法也是线程安全的。
小结
ConcurrentHashMap十分复杂,要想分析清除需要很多篇幅,有兴趣的可以一段一段的分析下。这一节重点是它如何线程安全的,你不要过多纠结其他细节,钻牛角尖。
这一节重要的是学习JDK在ConcurrentHashamp应用的思想——CAS+volatile+分段(分段CAS、分段sychronized锁、分段并发扩容等)的思想。分段,降低锁的粒度,是锁优化的的重要策略之一,这样大幅度的提升了ConcurentHashMap的效率。
这些都是JDK很巧妙的优化,值得我们参考和学习。
欢迎各位在评论区留言讨论。