前言
Java JDK升级到1.8后有些集合类的实现有了变化,其中ConcurrentHashMap就有进行结构上的大调整。jdk1.6、1.7实现的共同点主要是通过采用分段锁Segment减少热点域来提高并发效率,1.8版本的实现有哪些变化呢?
重要概念
在正式研究前,我们需要先知道几个重要参数,提前说明其值所代表的意义以便更好的讲解源码实现。
table
所有数据都存在table中,table的容量会根据实际情况进行扩容,table[i]存放的数据类型有以下3种:
● TreeBin 用于包装红黑树结构的结点类型
● ForwardingNode 扩容时存放的结点类型,并发扩容的实现关键之一
● Node 普通结点类型,表示链表头结点
nextTable
扩容时用于存放数据的变量,扩容完成后会置为null。
sizeCtl
以volatile修饰的sizeCtl用于数组初始化与扩容控制,它有以下几个值:
其它的分析相应源码时再细说。
put()
注释已说的比较明白,上面的代码中的数字注释再单独细说下:
spread()
jdk1.8的hash策略,与以往版本一样都是为了减少hash冲突:
initTable()
initTable()用于里面table数组的初始化,值得一提的是table初始化是没有加锁的,那么如何处理并发呢?
由下面代码可以看到,当要初始化时会通过CAS操作将sizeCtl置为-1,而sizeCtl由volatile修饰,保证修改对后面线程可见。
这之后如果再有线程执行到此方法时检测到sizeCtl为负数,说明已经有线程在给扩容了,这个线程就会调用Thread.yield()让出一次CPU执行时间。
tabAt()/casTabAt()/setTabAt()
ABASE表示table中首个元素的内存偏移地址,所以(long)i << ASHIFT) + ABASE得到table[i]的内存偏移地址:
对i位置结点的写操作有两个方法,casTabAt()与setTabAt()。源码中有这样一段注释:
所以要原子语义的写操作需要使用casTabAt(),setTabAt()是在锁定桶的状态下才会被调用,之所以实现成这样只是带保守性的一种写法而已。放松一下继续~
TreeBin
注释4、5都是有关TreeBin的操作,为进一步提升性能,ConcurrentHashMap引入了红黑树。引入红黑树是因为链表查询的时间复杂度为O(n),红黑树查询的时间复杂度为O(log(n)),所以在结点比较多的情况下使用红黑树可以大大提升性能。
红黑树是一种自平衡二叉查找树,有如下性质:
● 每个节点要么是红色,要么是黑色。
● 根节点永远是黑色的。
● 所有的叶节点都是空节点(即 null),并且是黑色的。
● 每个红色节点的两个子节点都是黑色。(从每个叶子到- 根的路径上不会有两个连续的红色节点)
● 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
图例:
treeifyBin()
桶内元素超时8个时会调用到此方法。
可以看出按原Node链表顺序转为了TreeNode链表,每个TreeNode的prev、next已完备,传入头结点hd构造红黑树。
TreeBin构造函数
putTreeVal()与此方法遍历方式类似不再介绍。
扩容实现
写这篇文章主要就是想讲讲扩容,Let’s go!
什么时候会扩容?
使用put()添加元素时会调用addCount(),内部检查sizeCtl看是否需要扩容。
tryPresize()被调用,此方法被调用有两个调用点:
● 链表转红黑树(put()时检查)时如果table容量小于64(MIN_TREEIFY_CAPACITY),则会触发扩容。
● 调用putAll()之类一次性加入大量元素,会触发扩容。
addCount()
addCount()与tryPresize()实现很相似,我们先以addCount()分析下扩容逻辑:
resizeStamp()
在上面的代码中首先有调用到这样的一个方法。
Integer.numberOfLeadingZeros(n)用于计算n转换成二进制后前面有几个0。这个有什么作用呢?
首先ConcurrentHashMap的容量必定是2的幂次方,所以不同的容量n前面0的个数必然不同,这样可以保证是在原容量为n的情况下进行扩容。(搜索公众号Java知音,回复“2021”,送你一份Java面试题宝典)
(1 << (RESIZE_STAMP_BITS - 1)即是1<<15,表示为二进制即是高16位为0,第16位为1:
所以resizeStamp()的返回值(简称为rs) 高16位置0,第16位为1,低15位存放当前容量n扩容标识,用于表示是对n的扩容。rs与RESIZE_STAMP_SHIFT配合可以求出新的sizeCtl的值,分情况如下:
● sc >= 0表示没有线程在扩容,使用CAS将sizeCtl的值改为(rs << RESIZE_STAMP_SHIFT) + 2)。
● sc < 0已经有线程在扩容,将sizeCtl+1并调用transfer()让当前线程参与扩容。
rs即resizeStamp(n),如当前容量为8时sc(sizeCtl)的计算过程如下:
那么在扩容时sizeCtl值的意义便如下图所示:
tryPresize()
transfer()
jdk1.8版本的ConcurrentHashMap支持并发扩容,上面已经分析了一小部分,下面这个方法是真正进行并行扩容的地方。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) //每个线程处理桶的最小数目,可以看出核数越高步长越小,最小16个。
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) {
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; //扩容到2倍
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE; //扩容保护
return;
}
nextTable = nextTab;
transferIndex = n; //扩容总进度,>=transferIndex的桶都已分配出去。
}
int nextn = nextTab.length;
//扩容时的特殊节点,标明此节点正在进行迁移,扩容期间的元素查找要调用其find()方法在nextTable中查找元素。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//当前线程是否需要继续寻找下一个可处理的节点
boolean advance = true;
boolean finishing = false; //所有桶是否都已迁移完成。
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//此循环的作用是确定当前线程要迁移的桶的范围或通过更新i的值确定当前范围内下一个要处理的节点。
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing) //每次循环都检查结束条件
advance = false;
//迁移总进度<=0,表示所有桶都已迁移完成。
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) { //transferIndex减去已分配出去的桶。
//确定当前线程每次分配的待迁移桶的范围为[bound, nextIndex)
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//当前线程自己的活已经做完或所有线程的活都已做完,第二与第三个条件应该是下面让"i = n"后,再次进入循环时要做的边界检查。
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) { //所有线程已干完活,最后才走这里。
nextTable = null;
table = nextTab; //替换新table
sizeCtl = (n << 1) - (n >>> 1); //调sizeCtl为新容量0.75倍。
return;
}
//当前线程已结束扩容,sizeCtl-1表示参与扩容线程数-1。
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//还记得addCount()处给sizeCtl赋的初值吗?相等时说明没有线程在参与扩容了,置finishing=advance=true,为保险让i=n再检查一次。
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd); //如果i处是ForwardingNode表示第i个桶已经有线程在负责迁移了。
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) { //桶内元素迁移需要加锁。
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) { //>=0表示是链表结点
//由于n是2的幂次方(所有二进制位中只有一个1),如n=16(0001 0000),第4位为1,那么hash&n后的值第4位只能为0或1。所以可以根据hash&n的结果将所有结点分为两部分。
int runBit = fh & n;
Node<K,V> lastRun = f;
//找出最后一段完整的fh&n不变的链表,这样最后这一段链表就不用重新创建新结点了。
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
//lastRun之前的结点因为fh&n不确定,所以全部需要重新迁移。
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//低位链表放在i处
setTabAt(nextTab, i, ln);
//高位链表放在i+n处
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd); //在原table中设置ForwardingNode节点以提示该桶扩容完成。
advance = true;
}
else if (f instanceof TreeBin) { //红黑树处理。
...
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
- 91.
- 92.
- 93.
- 94.
- 95.
- 96.
- 97.
- 98.
- 99.
- 100.
- 101.
- 102.
- 103.
- 104.
- 105.
- 106.
- 107.
helpTransfer()
添加、删除节点之处都会检测到table的第i个桶是ForwardingNode的话会调用helpTransfer()方法。
并发扩容总结
● 单线程新建nextTable,新容量一般为原table容量的两倍。
● 每个线程想增/删元素时,如果访问的桶是ForwardingNode节点,则表明当前正处于扩容状态,协助一起扩容完成后再完成相应的数据更改操作。
● 扩容时将原table的所有桶倒序分配,每个线程每次最小分配16个桶,防止资源竞争导致的效率下降。单个桶内元素的迁移是加锁的,但桶范围处理分配可以多线程,在没有迁移完成所有桶之前每个线程需要重复获取迁移桶范围,直至所有桶迁移完成。
● 一个旧桶内的数据迁移完成但不是所有桶都迁移完成时,查询数据委托给ForwardingNode结点查询nextTable完成(这个后面看find()分析)。
● 迁移过程中sizeCtl用于记录参与扩容线程的数量,全部迁移完成后sizeCtl更新为新table容量的0.75倍。
扩容节结束!其它常用操作再说下。
get()
remove()
主要改进
CAS无锁算法与synchronized保证并发安全,支持并发扩容,数据结构变更为数组+链表+红黑树,提高性能。
jdk1.8版弃用变量
● Segment只有序列化时会用到。
● loadFactor仅用于构造函数中设定初始容量,已不能影响扩容阈值,jdk1.8版本中阈值计算基本恒定为0.75。
● concurrencyLevel只能影响初始容量,table容量大小与它无关。
文章转载自公众号:程序员新视界