拿捏了!ConcurrentHashMap!,三面蚂蚁金服(交叉面)定级阿里P6

private final void addCount(long x, int check) {
CounterCell[] as; long b, s;

//更新size
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();
}
//resize
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//不断CAS重试
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {//需要resize
//为每个size生成一个独特的stamp 这个stamp的第16为必为1 后15位针对每个n都是一个特定的值 表示n最高位的1前面有几个零
int rs = resizeStamp(n);
//sc会在库容时变成 rs << RESIZE_STAMP_SHIFT + 2;上面说了rs的第16位为1 因此在左移16位后 该位的1会到达符号位 因此在扩容是sc会成为一个负数
//而后16位用来记录参与扩容的线程数
//此时sc < 0 说明正在扩
if (sc < 0) {
/**

  • 分别对五个条件进行说明
  • sc >>> RESIZE_STAMP_SHIFT != rs 取sc的高16位 如果!=rs 则说明HashMap底层数据的n已经发生了变化
  • sc == rs + 1 此处可能有问题 我先按自己的理解 觉得应该是 sc == rs << RESIZE_STAMP_SHIFT + 1; 因为开始transfer时 sc = rs << RESIZE_STAMP_SHIFT + 2(一条线程在扩容,且之后有新线程参与扩容sc均会加1,而一条线程完成后sc - 1)说明是参与transfer的线程已经完成了transfer
  • 同理sc == rs + MAX_RESIZERS 这个应该也改为 sc = rs << RESIZE_STAMP_SHIFT + MAX_RESIZERS 表示参与迁移的线程已经到达最大数量 本线程可以不用参与
  • (nt = nextTable) == null 首先nextTable是在扩容中间状态才使用的数组(这一点和redis的渐进式扩容方式很像) 当nextTable 重新为null时 说明transfer 已经finish
  • transferIndex <= 0 也是同理
  • 遇上以上这些情况 说明此线程都不需要参与transfer的工作
  • PS: 翻了下JDK16的代码 这部分已经改掉了 rs = resizeStamp(n) << RESIZE_STAMP_SHIFT 证明我们的猜想应该是正确的
    */
    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
    transferIndex <= 0)
    break;
    //否则该线程需要一起transfer
    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
    transfer(tab, nt);
    }
    //说明没有其他线程正在扩容 该线程会将sizeCtl设置为负数 表示正在扩容
    else if (U.compareAndSwapInt(this, SIZECTL, sc,
    (rs << RESIZE_STAMP_SHIFT) + 2))
    transfer(tab, null);
    s = sumCount();
    }
    }
    }

如上文所说,这个方法有两个作用,一是更新元素个数,二是判断是否需要resize()。

更新size()

我们可以单独看addCount中更新size的部分

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();
}

首先判断countCells是否已经被初始化,如果没有被初始化,那么将尝试在size的更新操作放在baseCount上。如果此时没有冲突,那么CAS修改baseCount就能成功,size的更新就落在了baseCount上。
如果此时已经有countCells了,那么会根据线程的探针随机落到countCells的某个下标上。对size的更新就是更新对应CountCells的value值。
如果还是不行,将会进入fullAddCount方法中,自旋重试直到更新成功。这里不对fullAddCount展开介绍,具体操作也类似,size的变化要么累加在对应的CountCell上,要么累加在baseCount上。
这里说一下我个人对ConcurrentHashMap采用这么复杂的方式进行计数的理解。因为ConcurrenthHashMap是出于吞吐量最大的目的设计的,因此,如果单纯的用一个size直接记录元素的个数,那么每次增删操作都需要同步size,这会让ConcurrentHashMap的吞吐量大大降低。
因为,将size分散成多个部分,每次修改只需要对其中的一部分进行修改,可以有效的减少竞争,从而增加吞吐量。

resize()

对于resize()过程,我其实在代码的注释中说明的比较详细了。
首先,是一个while()循环,其中的条件是元素的size(由上一步计算而来)已经大于等于sizeCtl(说明到达了扩容条件,需要进行resize),这是用来配合CAS操作的。
接着,是根据当前数组的容量计算了resizeStamp(该函数会根据不同的容量得到一个确定的数)。得到的这个数会在之后的扩容过程中被使用。
然后是比较sizeCtl,如果sizeCtl小于0,说明此时已经有线程正在扩容,排除了几种不需要参与扩容的情况(例如,扩容已经完成,或是参与的扩容线程数已经到最大值,具体情况代码上的注解已经给出了分析),剩下的情况当前线程会帮助其他线程一起扩容,扩容前需要修改CAS修改sizeCtl(因为在扩容时,sizeCtl的后16位表示参与扩容的线程数,每当有一个线程参与扩容,需要对sizeCtl加1,当该线程完成时,对sizeCtl减1,这样比对sizeCtl就可以知道是否所有线程都完成了扩容)。
另外如果sizeCtl大于0,说明还没有线程参与扩容,此时需要CAS修改sizeCtl为rs << RESIZE_STAMP_SHIFT + 2(其中rs是有resizeStamp(n)得到的),这是一个负数,上文也说了这个数的后16位表示参与扩容的线程,当所有线程都完成了扩容时,sizeCtl应该为rs << RESIZE_STAMP_SHIFT + 1。这是我们结束扩容的条件,会在后文看到。

transfer()

transfer()方法负责对数组进行扩容,并将数据rehash到新的节点上。这一过程中会启用nextTable变量,并在扩容完成后,替换成table变量。

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
//stride是步长,transfer会依据stride,把table分为若干部分,依次处理,好让多线程能协助transfer
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating //nextTab等于null表示第一个进来扩容的线程
try {
@SuppressWarnings(“unchecked”)
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; //第一个线程需要对扩容的数组翻倍
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
//用nextTable和transferIndex表示扩容的中间状态
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true; // advance 表示是否可以继续执行下一个stride
boolean finishing = false; // to ensure sweep before committing nextTab finish表示transfer是否已经完成 nextTable已经替换了table

//开始转移各个槽
for (int i = 0, bound = 0;😉 {
Node<K,V> f; int fh;
//STEP1 判断是否可以进入下一个stride 确认i和bound
//通过stride领取一部分的transfer任务,while循环就是确认边界
while (advance) {
int nextIndex, nextBound;
if (–i >= bound || finishing) //认领的部分已经被执行完(一个stride执行完)
advance = false;
else if ((nextIndex = transferIndex) <= 0) { //transfer任务被认领完
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) { //认领一个stride的任务
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}

/**

  • i < 0 说明要转移的桶 都已经处理过了
  • 以上条件已经说明 transfer已经完成了
    */
    if (i < 0 || i >= n || i + n >= nextn) { //transfer 结束
    int sc;
    if (finishing) {//如果完成整个 transfer的过程 清空nextTable 让table等于扩容后的数组
    nextTable = null;
    table = nextTab;
    sizeCtl = (n << 1) - (n >>> 1); //0.75f * n 重新计算下次扩容的阈值
    return;
    }
    if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {//一个线程完成了transfer
    //如果还有其他线程在transfer 先返回
    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
    return;
    //说明这是最后一个在transfer的线程 因此finish标志被置为 true
    finishing = advance = true;
    i = n; // recheck before commit
    }
    }
    else if ((f = tabAt(tab, i)) == null) //如果该节点为null,则对该节点的迁移立马完成,设置成forwardNode
    advance = casTabAt(tab, i, null, fwd);
    else if ((fh = f.hash) == MOVED)
    advance = true; // already processed
    else { //开始迁移该节点
    synchronized (f) {//同步,保证线程安全
    if (tabAt(tab, i) == f) { //double-check
    Node<K,V> ln, hn; //ln是扩容后依旧保留在原index上的node链表;hn是移到index + n 上的node链表
    if (fh >= 0) { //普通链表
    int runBit = fh & n;
    Node<K,V> lastRun = f;
    //这一次遍历的目的是找到最后一个一个节点,其后的节点hash & N 都不发生改变
    //例如 有A->B->C->D,其hash & n 为 0,1,1,1 那就是找到B点
    //这样做的目的是之后对链表进行拆分时 C和D不需要单独处理 维持和B的关系 B移动到新的tab[i]或tab[i+cap]上即可
    //还有不理解的可以参考我的测试代码:https://github.com/insaneXs/all-mess/blob/master/src/main/java/com/insanexs/mess/collection/TestConHashMapSeq.java
    for (Node<K,V> p = f.next; p != null; p = p.next) {
    int b = p.hash & n;
    if (b != runBit) {
    runBit = b;
    lastRun = p;
    }
    }
    //如果runBit == 0 说明之前找到的节点应该在tab[i]
    if (runBit == 0) {
    ln = lastRun;
    hn = null;
    }
    //否则说明之前的节点在tab[i+cap]
    else {
    hn = lastRun;
    ln = null;
    }
    //上面分析了链表的拆分只用遍历到lastRun的前一节点 因为lastRun及之后的节点已经移动好了
    for (Node<K,V> p = f; p != lastRun; p = p.next) {
    int ph = p.hash; K pk = p.key; V pv = p.val;
    //这里不再继续使用尾插法而是改用了头插法 因此链表的顺序可能会发生颠倒(lastRun及之后的节点不受影响)
    if ((ph & n) == 0)
    ln = new Node<K,V>(ph, pk, pv, ln);
    else
    hn = new Node<K,V>(ph, pk, pv, hn);
    }
    //将新的链表移动到nextTab的对应坐标中
    setTabAt(nextTab, i, ln);
    setTabAt(nextTab, i + n, hn);
    //tab上对应坐标的节点变为ForwardingNode
    setTabAt(tab, i, fwd);
    advance = true;
    }
    else if (f instanceof TreeBin) { //红黑树节点
    TreeBin<K,V> t = (TreeBin<K,V>)f;
    //同样拆成两棵树
    TreeNode<K,V> lo = null, loTail = null;
    TreeNode<K,V> hi = null, hiTail = null;
    int lc = 0, hc = 0;
    for (Node<K,V> e = t.first; e != null; e = e.next) {
    int h = e.hash;
    TreeNode<K,V> p = new TreeNode<K,V>
    (h, e.key, e.val, null, null);
    if ((h & n) == 0) {
    if ((p.prev = loTail) == null)
    lo = p;
    else
    loTail.next = p;
    loTail = p;
    ++lc;
    }
    else {
    if ((p.prev = hiTail) == null)
    hi = p;
    else
    hiTail.next = p;
    hiTail = p;
    ++hc;
    }
    }
    //是否需要退化成链表
    ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
    (hc != 0) ? new TreeBin<K,V>(lo) : t;
    hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
    (lc != 0) ? new TreeBin<K,V>(hi) : t;
    setTabAt(nextTab, i, ln);
    setTabAt(nextTab, i + n, hn);
    setTabAt(tab, i, fwd);
    advance = true;
    }
    }
    }
    }
    }
    }

transfer的代码比较长,我们也一部分一部分的分析各段代码的作用。
首先,最先发起扩容的线程需要对数组进行翻倍,然后将翻倍后得到的新数组通过nextTable变量保存。并且启用了transferIndex变量,初始值为旧数组的容量n,这个变量会被用来标记已经被认领的桶的下标。
扩容过程是从后往前的,因此transferIndex的初始值才是n。并且整个扩容过程依据步长stride,被拆分成个部分,线程从后往前依次领取一个部分,所以每次有线程领取任务,transferIndex总是要被减去一个stride。
当线程认领的一个步长的任务完成后,继续去认领下一个步长,直到transferIndex < 0,说明所有数据都被认领完。
当参与扩容的线程发现没有其他任务能被认领,那么就会更新sizeCtl为 sizeCtl-1 (说明有一条线程退出扩容)。最后一条线程完成了任务,发现sizeCtl == (resizeStamp(n) << RESIZE_STAMP_SHIFT + 2) ,那么说明所有的线程都完成了扩容任务,此时需要将nextTable替换为table,重置transferIndex,并计算新的sizeCtl表示下一次扩容的阈值。

上面介绍了线程每次认领一个步长的桶数负责rehash,这里介绍下针对每个桶的rehash过程。
首先,如果桶上没有元素或是桶上的元素是ForwardingNode,说明不用处理该桶,继续处理上一个桶。
对于桶上存放正常的节点而言,为了线程安全,需要对桶的头节点进行上锁,然后以链表为例,需要将链表拆为两个部分,这两部分存放的位置是很有规律的,如果旧数组容量为oldCap,且节点之前在旧数组的下标为i,那么rehash链表中的所有节点将放在nextTable[i]或者nextTable[i+oldCap]的桶上(这一点可以从之前哈希值中比n最高位还靠前的一位来考虑,当前一位为0时,就落在nextTable[i]上,而前一位为1时,就落在nextTable[i+oldCap])。
同理红黑树也会被rehash()成两部分,如果新的红黑树不满足成树条件,将会被退化成链表。
当一个桶的元素被transfer完成后,旧数组相关位置上会被放上ForwardingNode的特殊节点表示该桶已经被迁移过。且ForwardingNode会指向nextTable。

由于不满足树化条件而引起的扩容

当一个桶上的链表节点数大于8,但是数组容量又小于64时,ConcurrentHashMap会优先选择扩容而非树化,具体的方法在tryPresize()中。整体流程和addCount()方法类似,这里不再赘述。

后话

如果读者够仔细的话,会发现在扩容这一段Doug Lea老爷子其实也留了些BUG下来。
一个是在addCount中判断rs和sc关系的时候,一部分条件老爷子忘记了加位移操作。这部分代码如下:

sc == rs + 1 || sc == rs + MAX_RESIZERS

这一部分的等式均差了一个位移的运算。

另一个是在tryPresize()方法中,while里的最后一个else if中 sc < 0的条件应该是永远不成立的,因为while的条件就是sc >=0。

if (sc < 0) {
Node<K,V>[] nt;
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);
}

上面两部分代码,我在OPENJDK 16版本中确认过,确实已经修改过了。

size()过程

size()过程其实相对简单,上文在addCount()已经介绍过了,为了保证ConcurrentHashMap的吞吐量,元素个数被拆成了多个部分保存在countCells和baseCount中。那么求size()其实就是将这几部分数据累积。

final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
//counterCells 不为空,说明此时有其他线程在更新数组
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}

get过程

相对于put过程,get()可以说十分简单了。

public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//?在hash值得基础上再做一次散列,具体目的不明
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//根据散列的值 得到tab中的元素,因为tabAt保证了可见性,因此可以认为多线程下数据没有问题
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//哈希值小于0 说明节点正在迁移或是为树节点 为ForwardNode或是TreeBin 可以以多态的方式由不同实现根据不同的情况去查找
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;
}

和HashMap的get过程基本一直(除了对hash值的扰动方式不一样)。
整体流程就是计算键的哈希值属于哪个桶,然后查找该桶的所有元素,获取key相等的节点(链表直接遍历,红黑树用树的方式查找),并返回。

与JDK7实现的简单对比

文章的最后,我们看一下JDK8中的 ConcurentHashMap 与JDK7版本中的不同,也算是一个总结。
其实,最大的差异就是JDK 8中不在使用Segment。因为其他所有的差异都是为了适应新的方式而做出的调整。
譬如resize()时的不同(JDK7中只用对对应的Segment上锁,就可以用HashMap的方式进行resize())。
又譬如二者在size()方法上的不同(JDK7中会先累加三次各个段的size(),如果其中数据发生了变化,说明此时有其他线程在操作,为了数据强一致性会上全锁(所有segment上锁)统计size)。
虽然,JDK8中的ConcurrentHashMap实现上更为复杂, 但这样的好处也是显而易见的。那就是让ConcurrentHashMap的并发等级或者说吞吐量达到了最大话。

小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Java工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Java开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频

如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Java)
img

结语

小编也是很有感触,如果一直都是在中小公司,没有接触过大型的互联网架构设计的话,只靠自己看书去提升可能一辈子都很难达到高级架构师的技术和认知高度。向厉害的人去学习是最有效减少时间摸索、精力浪费的方式。

我们选择的这个行业就一直要持续的学习,又很吃青春饭。

虽然大家可能经常见到说程序员年薪几十万,但这样的人毕竟不是大部份,要么是有名校光环,要么是在阿里华为这样的大企业。年龄一大,更有可能被裁。

送给每一位想学习Java小伙伴,用来提升自己。

在这里插入图片描述

本文到这里就结束了,喜欢的朋友可以帮忙点赞和评论一下,感谢支持!

得这些内容对你有帮助,可以添加下面V无偿领取!(备注Java)**
[外链图片转存中…(img-RoznxiMB-1710423521204)]

结语

小编也是很有感触,如果一直都是在中小公司,没有接触过大型的互联网架构设计的话,只靠自己看书去提升可能一辈子都很难达到高级架构师的技术和认知高度。向厉害的人去学习是最有效减少时间摸索、精力浪费的方式。

我们选择的这个行业就一直要持续的学习,又很吃青春饭。

虽然大家可能经常见到说程序员年薪几十万,但这样的人毕竟不是大部份,要么是有名校光环,要么是在阿里华为这样的大企业。年龄一大,更有可能被裁。

送给每一位想学习Java小伙伴,用来提升自己。

[外链图片转存中…(img-4UBpe64Q-1710423521205)]

本文到这里就结束了,喜欢的朋友可以帮忙点赞和评论一下,感谢支持!

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

  • 23
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值