大话系列 - ConcurrentHashMap源码分析
因为上文中已经详细的说明了HashMap,这里在聊ConcurrentHashMap的时候,相差不大的部分就不再重复了,因为实现的原理都相差不多。这里就只说一下put操作吧
一、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(后面用CHM来代替)中是不允许key和value为null的,在方法的第一行就进行了检查。
然后是hash函数,这个跟HashMap的稍微有点不同
static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }
这里的 HASH_BITS 是 0x7fffffff (不明白这个的可以看我之前的文章),也就是说在经过扰动函数处理之后,还要把最高位置为0 (这个问题跟为什么CHM不允许key和value不能为null一样,都是没有明确的解释,大家自己Google理解吧)。binCount是用来记录某一个数组链中元素的个数(其实也表示当前key是否存在hash冲突)
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 }
接下来是对table对象进行判断处理,与HashMap相同这里对table也是懒加载的方式,在第一次添加元素的时候才会初始化。else分支是如果通过hash值定位到数组的具体下标位置后,如果没有元素就直接把当前元素添加为数组中的第一个元素,这里比较好理解,下面看一下initTable方法
/** * Initializes table, using the size recorded in sizeCtl. */ 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; }
这里是再次判断table是否为空,因为在并发环境下有可能已经被其他线程初始化了,这里通过while循环来确保一定有一个线程来完成数组的初始化。接下来首先是判断sizeCtl如果小于0那么表示数组正在进行扩容等操作,通过yield来放弃CPU等待。然后是CAS方法,这里是用的Unsafe类中的方式,就是当前对象在SIZECTL偏移量的位置的值如果与sc相等,则把-1赋值给SIZECTL,这也就是上面为什么判断如果sizeCtl小于0的原因。
接下来还是判断如果table此时还没有被初始化,那就用sizeCtl的大小创建一个Node数组,其实这里面的sizeCtl与HashMap中的threshold是相同的作用,是作为一个阈值标量存在的,当对象中的元素数量超过这个阈值的时候,就会触发扩容操作。
在初始化table对象之后,执行了一句: sc = n - (n >>> 2) ,这里也是一个无符号右移操作,右移两位相当于n/4 ,这里为什么这么操作要说明一下,在HashMap中我们知道,threshold的值是数组的容量 * loadFactor,但是这里为什么跟HashMap中的不一样。这里我们需要看一下构造方法。
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); if (initialCapacity < concurrencyLevel) // Use at least as many bins initialCapacity = concurrencyLevel; // as estimated threads long size = (long)(1.0 + (long)initialCapacity / loadFactor); int cap = (size >= (long)MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)size); this.sizeCtl = cap; }
这里我们看到虽然构造方法中可以传入loadFacotr,但是是上并没有将这个入参赋值给成员变量。所以咱们带着问题看一下CHM的局部变量有什么不同:
/** * The load factor for this table. Overrides of this value in * constructors affect only the initial table capacity. The * actual floating point value isn't normally used -- it is * simpler to use expressions such as {@code n - (n >>> 2)} for * the associated resizing threshold. */ private static final float LOAD_FACTOR = 0.75f;
通过上面的注释我们知道,构造方法中的参数只会影响初始容量,在初始化之后的负载因子不会被改变,所以这里也就能明白了 之前的操作,就是通过无符号位移的方式来减去1/4,这样剩余的sizeCtl的大小就是数组容量的75%,满足负载因子的要求。
else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f);
还是回到put操作的流程中来,Node实例f当前指向的是数组中的第一个元素,fh用于记录当前Node元素的hash值,这里的判断如果hash值为MOVE(定义的状态常亮),那就说明当前对象在进行扩容迁移,然后通过方法名字也比较好了解,当前进程参与到迁移的过程中帮忙一起(这个方法后面再细说,先把流程走完)
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<K,V>)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; } }
这一段是比较长的,但是相对来说里面的逻辑也比较好理解。其实从相关科普文章中我们已经知道在Java8中对CHM进行了优化,已经不再是1.7中通过ReentrentLock采用分段锁来实现的了,因为Synchorized经过轻量级锁,偏向锁等一系列优化之后,性能已经不是很差了,所以这里简化代码的逻辑,直接采用synchorized的方式来实现。
这里对数组中的第一个元素添加对象锁,然后判断fh是否大于0。解释一下为什么这么判断
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; Node(int hash, K key, V val, Node<K,V> next) { this.hash = hash; this.key = key; this.val = val; this.next = next; }
这里是在数据中链表情况下保存元素的对象,可以看到其中保存了hash值。但是我们知道与HashMap一样,在满足一定条件之后,单链表结构会转换成红黑树的方式。那怎么判断当前元素中是单链表还是红黑树呢?
* This map usually acts as a binned (bucketed) hash table. Each * key-value mapping is held in a Node. Most nodes are instances * of the basic Node class with hash, key, value, and next * fields. However, various subclasses exist: TreeNodes are * arranged in balanced trees, not lists. TreeBins hold the roots * of sets of TreeNodes. ForwardingNodes are placed at the heads * of bins during resizing. ReservationNodes are used as * placeholders while establishing values in computeIfAbsent and * related methods. The types TreeBin, ForwardingNode, and * ReservationNode do not hold normal user keys, values, or * hashes, and are readily distinguishable during search etc * because they have negative hash fields and null key and value * fields. (These special nodes are either uncommon or transient, * so the impact of carrying around some unused fields is * insignificant.)
这部分内容摘自CHM的类注释中的第二段,英文好的同学还是建议直接看英文。
简单的翻译一下就是在CHM中每一个被添加的元素其实都是Node的包装对象,Node对象也是在单链表情况的元素。但是有一些子类,TreeNode是一颗平衡树中用来保存元素的包装对象(也就是我们说的红黑树),然后平衡树的root是用TreeBin对象来封装的, ForwardingNode子类是在扩容迁移的时候数组中的第一个元素。
这里除了TreeNode之外的其他子类都有一个特点,就是他们不持有用户添加的元素,也就是没有key、value和hash属性,这也就解释了为什么那里需要判断fh大于等于0,因为任何一个添加的元素的hash肯定是大于0的,只有在他们是特定节点的时候hash值是作为默认值而存在的。
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; } } }
所以这里如果fh大于等于0就表示当前数组下表中保存的是一个单链表的元素集合,然后通过next指针去遍历寻找,如果能找到hash和key相同的对象,就根据参数替换oldValue,如果找不到就在链表的最后添加当前元素。
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; } }
那如果不是单链表结构,这里是通过TreeBin类型来判断的,就直接将当前对象添加到红黑树中即可
if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; }
其实上面两个分支的逻辑其实都还比较好理解,不过这里出现一个判断binCount的方法。上面我们知道binCount是用来统计数组下标中元素个数的,那么这里如果binCount不等于0就证明当前待添加的key存在hash碰撞了。先是判断如果binCount大于等于8那么尝试转化成红黑树,这个与HashMap中的逻辑相同。
addCount(1L, binCount);
但是在方法结束的最后有一个addCount的方式,其实从方法的入参中我们可以猜到,这个应该是用于统计当前CHM中元素个数的,带着猜想看一下里面的代码
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(); }
这出现了一个新的对象CountCell数组,这里就不卖官司了,还是看了一下类注释了解到,这里其实是参照了LongAddr里面的设计,采用一个数组来统计元素的个数。但是我们知道CHM只一个使用于并发场景中的集合对象,而AutomicLong在高并发的情况下,因为CAS自旋会大量占用CPU,导致更新的性能下降的比较厉害,所以在CHM中就采用LongAddr的方式来计算size数组,这里就不讨论LongAddr的实现了。
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(); } }
这部分的逻辑其实也是争议比较大的一部分了,Google中可以看到很多争吵,其实这里想说一下个人的看法,写代码这个东西真的是每个人一个思路。其他人有的时候还真的是不好理解,那在我们学习源码的过程中遇到十分不理解代码的时候,其实是可以放弃,没必要每一行都去理解。只要领会大概的意思就好了。
书归正传继续说这段代码的逻辑,首先就是判断s是否大于sizeCtl,还有一些边界值的判断,其实就是为了判断是否满足扩容的条件。然后通过当前的数组大小n来获取一个Stamp ,这个方法需要好好说一下
static final int resizeStamp(int n) { return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); }
首先n是目前数组的容量, Integer.numberOfLeadingZeros(n) 方法返回这个数的二进制串中从最左边算起连续的“0”的总数量。因为int类型的数据长度为32所以高位不足的地方会以“0”填充。而我们知道CHM的数组容量一个是2的N次幂,对应的二进制一定是1后面跟随N个0的形式,所以比如数组容量是16 那对应的二进制低16位就是:
0000 0000 0001 0000 , 那么numberOfLeadingZeros 方法返回的值就应该是27 。
RESIZE_STAMP_BITS 是成员变量16,减去1就是15,所以后面部分就是1带符号左移15位 , 那对应的二进制低16位就是:
1000 0000 0000 0000 , 然后是用这两个值进行位或操作 ,上面说的返回值是27 ,那27的二进制低16位就是:
0000 0000 0001 1101 ,经过位或之后(其实就是相加) : 1000 0000 0001 1101 。这个就是一个int值得低16位,此时的高16位全部都是0 。
说了这么多,那计算这个东西是用来做什么的呢?看一下下面这部分逻辑:
else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null);
这里通过CAS的方式设置sc的值,将刚才计算出的rs带符号左移 16位 ,到这里就是这个resizeStamp的精髓所在了,我们把刚才计算出来的rs的二进制拿过来:
0000 0000 0000 0000 1000 0000 0001 1101
左移动16位
1000 0000 0001 1101 0000 0000 0000 0000
这时可以看到原来的低16位已经被移动到高16位了,低16位现在都是0 ,再看上面的代码,加2,那最终sizeCtl的值就应该是:
1000 0000 0001 1101 0000 0000 0000 0010
这里总结一下这个函数的意义 : 这里是将32位的int值分成两部分,高16位用来记录当前对应被扩容的信息,比如原数组的大小等。低16位用来记录现在有多少个线程在进行扩容迁移操作,扩容的逻辑与HashMap中的相同,这里就不再重复了。
那么数组容量仍然是扩大为原来的两倍,这次有多个线程来帮忙迁移,那效率就比较快。
所以还是回来看刚才的if分支:
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); }
这里的就是通过rs的值来来判断当前对象是都正在扩容迁移,已经是都可以帮助进行迁移。如果可以就领取一个数组下表进行迁移工作。这里可以看到CAS方式是加1的操作。
说到这里 addCount 方法中的逻辑基本都理清楚了,说两个题外话:
- if (sc < 0) 里面的if判断条件
- 初始化时为什么要+2
这两个问题在网上也是有很多的讨论,但是仍然没有一个合理的解释。大家看自己喜欢去理解就好了,这里我们不较真。
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<K,V>)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; }
最后一起看一下helpTransfer 方法, ForwardingNode 子类我们上面也说过,如果一个数组元素正在被迁移,那么就会用 ForwardingNode 添加到头结点中。所以这里也是通过结点的类型来判断状态的一种方式,然后下面的处理逻辑与addCount中大同小异。

浙公网安备 33010602011771号