}
}
}
}
这里说一下迁移时为什么要分一个ln(低位Node)、hn(高位Node),首先说一个现象:
我们知道,在put值的时候,首先会计算hash值,再散列到指定的Node数组下标中:
//根据key的hashCode再散列
int hash = spread(key.hashCode());
//使用(n - 1) & hash 运算,定位Node数组中下标值
(f = tabAt(tab, i = (n - 1) & hash);
其中n为Node数组长度,这里假设为16。
假设有一个key进来,它的散列之后的hash=9,那么它的下标值是多少呢?
- (16 - 1)和 9 进行与运算 -> 0000 1111 和 0000 1001 结果还是 0000 1001 = 9
假设Node数组需要扩容,我们知道,扩容是将数组长度增加两倍,也就是32,那么下标值会是多少呢?
- (32 - 1)和 9 进行与运算 -> 0001 1111 和 0000 1001 结果还是9
此时,我们把散列之后的hash换成20,那么会有怎样的变化呢?
- (16 - 1)和 20 进行与运算 -> 0000 1111 和 0001 0100 结果是 0000 0100 = 4
- (32 - 1)和 20 进行与运算 -> 0001 1111 和 0001 0100 结果是 0001 0100 = 20
此时细心的读者应该可以发现,如果hash在高X位为1,(X为数组长度的二进制-1的最高位),则扩容时是需要变换在Node数组中的索引值的,不然就hash不到,丢失数据,所以这里在迁移的时候将高X位为1的Node分类为hn,将高X位为0的Node分类为ln。
回到代码中:
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);
}
这个操作将高低位组成了两条链表结构,由下图所示:
然后将其CAS操作放入新的Node数组中:
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
其中,低位链表放入原下标处,而高位链表则需要加上原Node数组长度,其中为什么不多赘述,上面已经举例说明了,这样就可以保证高位Node在迁移到新Node数组中依然可以使用hash算法散列到对应下标的数组中去了。
最后将原Node数组中对应下标Node对象设置为fwd标记Node,表示该节点迁移完成,到这里,一个节点的迁移就完成了,将进行下一个节点的迁移,也就是i-1=14下标的Node节点。
扩容时的get操作:
假设Node下标为16的Node节点正在迁移,突然有一个线程进来调用get方法,正好key又散列到下标为16的节点,此时怎么办?
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;
}
//假如Node节点的hash值小于0
//则有可能是fwd节点
else if (eh < 0)
//调用节点对象的find方法查找值
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操作的源码中,会判断Node中的hash是否小于0,是否还记得我们的占位Node,其hash为MOVED,为常量值-1,所以此时判断线程正在迁移,委托给fwd占位Node去查找值:
//内部类 ForwardingNode中
Node<K,V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
// 这里的查找,是去新Node数组中查找的
// 下面的查找过程与HashMap查找无异,不多赘述
outer: for (Node<K,V>[] tab = nextTable;😉 {
Node<K,V> e; int n;
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (;😉 {
int eh; K ek;
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh < 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;
}
else
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
}
}
到这里应该可以恍然大悟了,之所以占位Node需要保存新Node数组的引用也是因为这个,它可以支持在迁移的过程中照样不阻塞地查找值,可谓是精妙绝伦的设计。
多线程协助扩容
在put操作时,假设正在迁移,正好有一个线程进来,想要put值到迁移的Node上,怎么办?
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
}
//若此时发现了占位Node,证明此时HashMap正在迁移
else if ((fh = f.hash) == MOVED)
//进行协助迁移
tab = helpTransfer(tab, f);
…
}
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;
//sizeCtl加一,标示多一个线程进来协助扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
//扩容
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
此方法涉及大量复杂的位运算,这里不多赘述,只是简单的说几句,此时sizeCtl变量用来标示HashMap正在扩容,当其准备扩容时,会将sizeCtl设置为一个负数,(例如数组长度为16时)其二进制表示为:
1000 0000 0001 1011 0000 0000 0000 0010
无符号位为1,表示负数。其中高16位代表数组长度的一个位算法标示(有点像epoch的作用,表示当前迁移朝代为数组长度X),低16位表示有几个线程正在做迁移,刚开始为2,接下来自增1,线程迁移完会进行减1操作,也就是如果低十六位为2,代表有一个线程正在迁移,如果为3,代表2个线程正在迁移以此类推…
只要数组长度足够长,就可以同时容纳足够多的线程来一起扩容,最大化并行任务,提高性能。
在什么情况下会进行扩容操作?
- 在put值时,发现Node为占位Node(fwd)时,会协助扩容。
- 在新增节点后,检测到链表长度大于8时。
final V putVal(K key, V value, boolean onlyIfAbsent) {
…
if (binCount != 0) {
//TREEIFY_THRESHOLD=8,当链表长度大于8时
if (binCount >= TREEIFY_THRESHOLD)
//调用treeifyBin方法
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
…
}
treeifyBin方法会将链表转换为红黑树,增加查找效率,但在这之前,会检查数组长度,若小于64,则会优先做扩容操作:
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//MIN_TREEIFY_CAPACITY=64
//若数组长度小于64,则先扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
//扩容
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
//…转换为红黑树的操作
}
}
}
}
在每次新增节点之后,都会调用addCount方法,检测Node数组大小是否达到阈值:
final V putVal(K key, V value, boolean onlyIfAbsent) {
…
//在下面一节会讲到,此方法统计容器元素数量
addCount(1L, binCount);
return null;
}
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)) {
//统计元素个数的操作…
}
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);
//发现sizeCtl为负数,证明有线程正在迁移
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();
}
}
}
总结
ConcurrentHashMap运用各类CAS操作,将扩容操作的并发性能实现最大化,在扩容过程中,就算有线程调用get查询方法,也可以安全的查询数据,若有线程进行put操作,还会协助扩容,利用sizeCtl标记位和各种volatile变量进行CAS操作达到多线程之间的通信、协助,在迁移过程中只锁一个Node节点,即保证了线程安全,又提高了并发性能。
6、统计容器大小的线程安全
ConcurrentHashMap在每次put操作之后都会调用addCount方法,此方法用于统计容器大小且检测容器大小是否达到阈值,若达到阈值需要进行扩容操作,这在上面也是有提到的。这一节重点讨论容器大小的统计是如何做到线程安全且并发性能不低的。
大部分的单机数据查询优化方案都会降低并发性能,就像缓存的存储,在多线程环境下将有并发问题,所以会产生并行或者一系列并发冲突锁竞争的问题,降低了并发性能。类似的,热点数据也有这样的问题,在多线程并发的过程中,热点数据(频繁被访问的变量)是在每一个线程中几乎或多或少都会访问到的数据,这将增加程序中的串行部分,回忆一下开头所描述的,程序中的串行部分将影响并发的可伸缩性,使并发性能下降,这通常会成为并发程序性能的瓶颈。
而在ConcurrentHashMap中,如何快速的统计容器大小更是一个很重要的议题,因为容器内部需要依靠容器大小来考虑是否需要扩容,而在客户端而言需要调用此方法来知道容器有多少个元素,如果处理不好这种热点数据,并发性能将因为这个短板整体性能下降。
试想一下,如果是你,你会如何设计这种热点数据?是加锁,还是进行CAS操作?进入ConcurrentHashMap中,看看大师是如何巧妙的运用了并发技巧,提高热点数据的并发性能。
先用图的方式来看看大致的实现思路:
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
这是一个粗略的实现,在设计中,使用了分而治之的思想,将每一个计数都分散到各个countCell对象里面(下面称之为桶),使竞争最小化,又使用了CAS操作,就算有竞争,也可以对失败了的线程进行其他的处理。乐观锁的实现方式与悲观锁不同之处就在于乐观锁可以对竞争失败了的线程进行其他策略的处理,而悲观锁只能等待锁释放,所以这里使用CAS操作对竞争失败的线程做了其他处理,很巧妙的运用了CAS乐观锁。
下面看看具体的代码实现吧:
//计数,并检查长度是否达到阈值
private final void addCount(long x, int check) {
//计数桶
CounterCell[] as; long b, s;
//如果counterCells不为null,则代表已经初始化了,直接进入if语句块
//若竞争不严重,counterCells有可能还未初始化,为null,先尝试CAS操作递增baseCount值
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
//进入此语句块有两种可能
//1.counterCells被初始化完成了,不为null
//2.CAS操作递增baseCount值失败了,说明有竞争
CounterCell a; long v; int m;
//标志是否存在竞争
boolean uncontended = true;
//1.先判断计数桶是否还没初始化,则as=null,进入语句块
//2.判断计数桶长度是否为空或,若是进入语句块
//3.这里做了一个线程变量随机数,与上桶大小-1,若桶的这个位置为空,进入语句块
//4.到这里说明桶已经初始化了,且随机的这个位置不为空,尝试CAS操作使桶加1,失败进入语句块
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();
}
…
}
假设当前线程为第一个put的线程
先假设当前Map还未被put数据,则addCount一定没有被调用过,当前线程第一个调用addCount方法,则此时countCell一定没有被初始化,为null,则进行如下判断:
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x))
这里的if判断一定会走第二个判断,先CAS增加变量baseCount的值:
private transient volatile long baseCount;
这个值有什么用呢?我们看看统计容器大小的方法sumCount:
final long sumCount() {
//获取计数桶
CounterCell[] as = counterCells; CounterCell a;
//获取baseCount,赋值给sum总数
long sum = baseCount;
//若计数桶不为空,统计计数桶内的值
if (as != null) {
for (int i = 0; i < as.length; ++i) {
//遍历计数桶,将value值相加
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
这个方法的大体思路与我们开头那张图差不多,容器的大小其实是分为两部分,开头只说了计数桶的那部分,其实还有一个baseCount,在线程没有竞争的情况下的统计值,换句话说,在增加容量的时候其实是先去做CAS递增baseCount的。
由此可见,统计容器大小其实是用了两种思路:
- CAS方式直接递增:在线程竞争不大的时候,直接使用CAS操作递增baseCount值即可,这里说的竞争不大指的是CAS操作不会失败的情况
- 分而治之桶计数:若出现了CAS操作失败的情况,则证明此时有线程竞争了,计数方式从CAS方式转变为分而治之的桶计数方式
出现了线程竞争导致CAS失败
此时出现了竞争,则不会再用CAS方式来计数了,直接使用桶方式,从上面的addCount方法可以看出来,此时的countCell是为空的,最终一定会进入fullAddCount方法来进行初始化桶:
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
wasUncontended = true;
}
boolean collide = false; // True if last slot nonempty
for (;😉 {
CounterCell[] as; CounterCell a; int n; long v;
…
//如果计数桶!=null,证明已经初始化,此时不走此语句块
if ((as = counterCells) != null && (n = as.length) > 0) {
…
}
//进入此语句块进行计数桶的初始化
//CAS设置cellsBusy=1,表示现在计数桶Busy中…
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
//若有线程同时初始化计数桶,由于CAS操作只有一个线程进入这里
boolean init = false;
try { // Initialize table
//再次确认计数桶为空
if (counterCells == as) {
//初始化一个长度为2的计数桶
CounterCell[] rs = new CounterCell[2];
//h为一个随机数,与上1则代表结果为0、1中随机的一个
//也就是在0、1下标中随便选一个计数桶,x=1,放入1的值代表增加1个容量
rs[h & 1] = new CounterCell(x);
//将初始化好的计数桶赋值给ConcurrentHashMap
counterCells = rs;
init = true;
}
} finally {
//最后将busy标识设置为0,表示不busy了
cellsBusy = 0;
}
if (init)
break;
}
//若有线程同时来初始化计数桶,则没有抢到busy资格的线程就先来CAS递增baseCount
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}
到这里就完成了计数桶的初始化工作,在之后的计数都将会使用计数桶方式来统计总数。
计数桶扩容
从上面的分析中我们知道,计数桶初始化之后长度为2,在竞争大的时候肯定是不够用的,所以一定有计数桶的扩容操作,所以现在就有两个问题了:
- 什么条件下会进行计数桶的扩容?
- 扩容操作是怎么样的?
假设此时是用计数桶方式进行计数:
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;
最后
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数网络安全工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年网络安全全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上网络安全知识点!真正的体系化!
如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
分析中我们知道,计数桶初始化之后长度为2,在竞争大的时候肯定是不够用的,所以一定有计数桶的扩容操作,所以现在就有两个问题了:
- 什么条件下会进行计数桶的扩容?
- 扩容操作是怎么样的?
假设此时是用计数桶方式进行计数:
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;
最后
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数网络安全工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年网络安全全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
[外链图片转存中…(img-XlVDXoYX-1715799786611)]
[外链图片转存中…(img-zPUTleO0-1715799786612)]
[外链图片转存中…(img-rraC0kbh-1715799786612)]
[外链图片转存中…(img-9zyNwMv6-1715799786612)]
[外链图片转存中…(img-M5enpvXA-1715799786612)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上网络安全知识点!真正的体系化!
如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!