本章节基于源码进行逐行分析,请大家结合源码一点一点看;
构造方法
如果看过JDK1.7中ConcurrentHashMap源码的同学可以知道,JDK1.7的无参构造方法中进行了很多数据的运算进行初始化,而在JDK1.8中,无参方法就是无参方法,没有其他的操作;
那么我们直接点进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;
我们可以很明显的看到,ConcurrentHashMap是不允许key或者value为空的
然后基于当前key计算了一个hash值,然后定义了一个bitCount;
然后往下走,进入了一个for循环:
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
在这个for循环中,首先拿到当前操作的数组对象,然后判断是否为null,如果为null则调用initTable方法进行初始化,那我们就来看看ConcurrentHashMap的初始化机制:
初始化机制
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<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
1.它使用了一个自旋的方式(保证在这个过程中数组没有被其他线程初始化)
2.第一个判断sc(默认为0)是否小于0,这个值就是一个记录参数,如果小于0则调用Thread类的yield方法,该方法的意思是让当前这个线程暂时放弃当前CPU的资源,让它重新去竞争资源;
3.默认情况下走下面的分支,通过CAS操作,让sc减一并且存入内存中,因此如果有多个线程进行CAS的话,只有一个线程能够减成功,其他线程重新进入循环的时候再去进行判断sc就会拿到内存中已经减一的值,那么就会放弃当前CPU的资源,保证了线程安全;
4.减成功之后再去进行一次数组为null的判断
5.然后去获取数组的容量:如果我们使用了有参构造器指定sc的话就使用sc,如果我们没有指定初始化容量那么就使用默认的初始化容量(默认为16)。
6.创建Node数组,并赋予容量
7.再去记录一下sc与sizeCtl,这个时候sc就等于初始容量n减去初始容量n左移两位(四分之一)也就是0.75乘以n。
8.相当于在初始化数组的时候我们就记录了后面扩容的阈值(sizeCtl)。
至此初始化结束;
那么继续往下看,如果数组已经初始化过了,进入第二次循环,进入真正的put流程:
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方法,我们来看看这两个方法是什么:
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
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 << ASHIFT) + ABASE, c, v);
}
tabAt:基于Unsafe的方法在内存中去拿值
casTabAt:基于CAS的设置值的操作
1.因此这里首先基于当前索引值在数组中去获取链表的头节点,如果头节点为null的话,说明当前数组位置是为null的,那么则生成一个Node对象,基于CAS设置值,如果CAS操作成功则返回true则跳出循环;
如果当前链表头节点不为空,则进入下一个分支进行判断:
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
判断当前头节点的hash值是否等于默认值MOVED(-1),若等于的话说明有其他线程正在对当前数组进行扩容操作,则调用helpTransfer方法帮助扩容;
如果该条件也不满足,说明可以正常执行当前链表中的put操作:
//(f = tabAt(tab, i = (n - 1) & hash)
synchronized (f) {
xxx
}
1.首先对整个操作加锁,我们可以知道,这个f是当前数组位置的链表头节点,因此可以发现加锁的对象是这个头节点;
if (tabAt(tab, i) == f) {
if (fh >= 0) { //fh=f.hash
2.再次判断当前位置头节点是没有发生改变的(避免加锁期间有其他线程操作了当前链表),然后再判断当前fh是否大于0(确保当前节点是链表的节点而非红黑树的节点)
for (Node<K,V> e = f;; ++binCount) {
3.拿到头节点并遍历当前链表
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
4.判断当前key是否与链表中对应位置的key相等,如果相等的话就用新的value值来覆盖旧的value值并退出循环;
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
5.上面这个代码就可以看出我们是对当前链表进行的循环,如果循环到了尾节点(next节点为null),都没有找到相同的key,那么就直接使用尾插法,生成一个新的Node节点,然后插入到链表末尾并退出循环;
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;
}
}
6.如果当前头节点是红黑树的节点,则调用红黑树的插入方法;
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
在put结束之后,我们就可以看到最开始定义的binCount的作用了,我们在循环遍历链表的时候每次都记录了次数,因此最后需要判断添加完成之后链表长度是否大于阈值8,如果大于则调用treeifyBin方法进行树化,最后返回oldVal跳出最外层的for循环;
addCount(1L, binCount);
我们发现它在整个循环外层,还调用了这个方法,这个方法的目的也就是让整个长度Size+1,加一之后再判断是否需要进行扩容,若需要则进行扩容操作;
扩容机制
这个方法实现特别复杂,我们点进去一步一步来看;
在ConcurrentHashMap中,统计元素总和使用的属性是baseCount;
为什么说这个addCount很重要,因为我们ConcurrentHashMap的使用大部分都是在并发场景下,因此我们在解读这个方法的时候要带着并发的思维去进行解读,去观察在这个方法里面它是如何保证线程安全去进行baseCount加一,并且安全进行扩容的;
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
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 << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
首先上来就定义了一个CounterCell类型的数组,我们查看一下sumCount这个方法就可以知道了:
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
这个方法就是用来统计所有元素数量的,我们可以发现最后返回的sum总数等于CounterCell数组中所有CounterCell对象中的value值相加再加上baseCount值;
我们现在再来看CounterCell对象里面有什么:
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
它内部就只有一个value属性,然后我们现在肯定有很多疑问,那么带着这些疑问再回到addCount方法的代码中;
1.该方法有很多if else分支,第一个分支先判断Cell数组不为空,并且利用CAS对baseCount值加一操作的结果返回取反,也就是说如果Cell数组不为空或者baseCount加一失败了,则进入第一个分支
再去判断Cell数组是否为空(保证线程安全),如果为空则去计算在Cell数组中的下标值a,a=ThreadLocalRandom.getProbe&m,这个m等于cell数组长度-1,而前面的方法意思是:针对当前线程生成一个随机数,并且将这个随机数保存在内存中,当前线程之后无论调用多少次该方法,得到的值都是和第一次相同的;
如果这个数组不为空,或者计算出来的数组对应下标志a也不为空,那么最后一个判断就是通过CAS对当前数组位置的Cell对象中的value属性执行加一操作的结果进行非运算,如果操作成功了也就是(!uncontended)结果为false才不会走下面的逻辑;若以上三个条件有一个成立了,都说明当前Cell数组是为null的,说明是第一次操作Cell数组,那么就进入到内部调用fullAdddCount方法,进行Cell数组的初始化,并生成Cell对象添加到对应位置;
若没有进入到该方法,说明当前数组不为空,并且CAS添加value值成功了,那么就判断check属性(我们传过来的binCount参数)是否小于等于一,若是则直接返回(说明第一次添加成功,直接返回),如果不是大于等于一,则调用sumCount记录总数赋值给s;
3.第三个分支,也就是当Check大于等于0的时候,则在这里面去判断并进行扩容操作;
第二三个分支都比较简单,我们这里主要来看第一个分支,对CounterCell数组是如何操作的:
我们点开fullCount方法;
首先我们先看传入这个方法的两个参数x和uncontended:
第一个参数x:是我们调用addCount方法传入的第一个参数,这里也就是1L,
第二个参数uncontended:要想进入到这个方法,uncontended就必须为false了,因此这里传入的也就是false了;
fullCount方法
int h;
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
wasUncontended = true;
}
进入到这个方法后,再去获取当前线程的这个随机数,如果为0的话,那么就重新获取一次并赋值给h,再将wasUncontended参数设置为true;
然后紧接着进入到了一个非常长的for循环中
整个for循环的三大分支由下面三个判断组成:
if ((as = counterCells) != null && (n = as.length) > 0)
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1))
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
我们先看后面两个判断逻辑,把最难的留在后面
第二个分支
首先判断条件中出现了一个cellsBusy标记,该标记的意思是表示当前数组是否“暂忙”,也就是当前数组是否有其他线程正在使用,等于0的话表示没有;
如果当前数组没有其他线程使用,并且在抵达这个判断的时候,当前数组没有发生改变(没有被其他线程操作过),那么这个时候这个分支就要操作当前数组了,因此通过CAS将cellsBusy标记改为1,并且如果修改成功了,才进入到这个分支中
boolean init = false;
try { // Initialize table
if (counterCells == as) {
CounterCell[] rs = new CounterCell[2];
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
1.确认当前数组依旧没有发生改变,则创建出一个新的Cell数组,并且赋予默认容量2;
2.然后生成一个Cell对象,将我们传入的x参数(1L)赋值进去,并且放到数组的对应索引下方(因为我们初始化容量已经为2了,因此不需要再计算Length-1直接使用1就可以了)
3.然后再将外面定义的标记init修改为true即可,最后将cellsBusy标记修改为0表示当前线程操作该数组完毕,最后判断init为true(其他线程没有进入过该方法),则完成,并退出循环
第三个分支
如果前两个对Cell数组的分支操作都不满足,也就是说Cell数组为空,并且有其他线程正在初始化Cell数组往里面添加元素,那么才走到最后这个分支,通过CAS操作来使baseCount值加一;若加一成功了则退出循环;
第一个分支
如果当前Cell数组不为空则进入到第一个分支中来;
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
CounterCell r = new CounterCell(x); // Optimistic create
if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean created = false;
try { // Recheck under lock
CounterCell[] rs; int m, j;
if ((rs = counterCells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
else if (counterCells != as || n >= NCPU)
collide = false; // At max size or stale
else if (!collide)
collide = true;
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {// Expand table unless stale
CounterCell[] rs = new CounterCell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
counterCells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
h = ThreadLocalRandom.advanceProbe(h);
1.计算出当前数组位置下的元素是否为空,如果为空的话,则再判断cellsBusy标记是否为0,如果为0就生成一个Cell对象,将参数x放进去;
2.再次判断cellsBusy是否0,如果为0则通过CAS操作将其修改为1;
3.然后计算出索引下标值,将生成的Cell对象放进去;在finally中将cellsBusy标记修改为1;
4.如果当前索引位置不为空,那么就进入到下面的判断中,判断wasUncontended的非运算结果;
5.如果wasUncontended为false,那么就将其修改为true,并调用循环外层的ThreadLocalRandom.advanceProbe方法重新生成一个随机数(与之前的getProbe方法获得的值就不同了),进行第二次循环,回到最开始重新获取一个新的索引位置再操作一次;;
6.那么第二次循环如果还是走到了这里,这个时候wasUncontended为true,那么就会走到下一个判断中去,也就是通过CAS对当前位置的Cell对象中的value值进行加一操作;如果操作成功了就退出循环
7.如果操作失败了,就又会判断另外一个标记collide,若该标记为false,那么将其修改为true,进行下一次的循环,然后再次生成一个新的随机数,回到最开始再重新获取索引位置,再操作一次;
8.如果这一次循环还是走到了这里,这个时候collide为true,那么走到最后一个判断,如果cellsBusy为0,通过CAS将cellsBusy修改为1,执行Cell数组的扩容操作;
9.该扩容比较简单,它就是将原来的数组变成了两倍,然后将老数组元素复制过去;
但是我们可以发现,只要collide为ture就会触发扩容,因此在判断前面加了下面这个判断来控制它不能无限制地进行扩容:
也就是满足了上面的判断就会将collide修改为false,然后再次重新获取hash值来循环一次;
这个判断的意思就是,如果当前数组发生了变化(也就是其他线程把我们本来要扩容的这个数组修改了),或者当前数组的大小大于等于CPU的核心数了,那么就不再进行后面的扩容,即把collide修改回false;
通俗易懂的讲就是:第一次进到循环,基于索引判断数组当前位置不为空,那么就会修改wasUncounted标识为true,并且重新计算hash值(调用循环外层的ThreadLocalRandom.advanceProbe方法),进入第二次循环:重新计算索引,如果算出来的位置还是不为空,那么就会去基于CAS进行当前位置value+1的操作,如果添加失败了,那么就会修改collide这个标识为true,再次去重新计算hash值,进入第三次循环:再次重新计算索引,如果算出来的位置还是不为空,然后再次基于CAS进行当前位置的value+1的操作,如果还是失败了,这个时候collide已经为true了,那么经过这几次循环重新计算了两次索引位置并且执行了两次value+1操作,都还没有添加成功,,那么ConcurrentHashMap就会认为当前时间利用率非常低了,那么就会进行扩容,用空间利用率去换取时间利用率;