ConcurrentHashMap细节分析

ConcurrentHashMap

好长的名字,好难理解的map。

学习这个map,中间也遇到了很多问题,不过现在有了DeepSeek的帮助,我可以随意理解,所有不会的问题,都可以刨根问底,虽然确实不太必要,而且浪费时间。

没关系,整理一下,之后就是背八股了。

简单说一下我对于1.7之前的ConcurrentHashMap的理解,

一个map持有一个Segment数组,每个Segment元素持有一个node数组。进入的时候,先哈希运算,进入相应的Segment的元素,写操作的时候加锁,就这样。

重点说明1.8之后的ConcurrentHashMap,这里不再存在Segment元素,相反,整个map的锁的颗粒度已经达到了每一个node理解。

简单看一下,字段的解释放在后面。

构造函数

先是完整的构造函数:

arduino 复制代码
 Creates a new, empty map with an initial table size based on the given number of elements (initialCapacity), initial table density (loadFactor), and number of concurrently updating threads (concurrencyLevel).
 形参:
 initialCapacity -- the initial capacity. The implementation performs internal sizing to accommodate this many elements, given the specified load factor.
 loadFactor -- the load factor (table density) for establishing the initial table size
 concurrencyLevel -- the estimated number of concurrently updating threads. The implementation may use this value as a sizing hint.
 抛出:
 IllegalArgumentException -- if the initial capacity is negative or the load factor or concurrencyLevel are nonpositive
 ​
 ​
 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;
 }

这里其实就是判断参数的合法性。虽然有这么多参数,其实就是为了控制sizeCtl,它是一个很重要的参数。它是大于等于cap的最小2的n次幂。

arduino 复制代码
tableSizeFor((int)size
/**
 * Returns a power of two table size for the given desired capacity.
 * See Hackers Delight, sec 3.2
 */
private static final int tableSizeFor(int c) {
    int n = -1 >>> Integer.numberOfLeadingZeros(c - 1);
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

这个函数还有另外一种实现方式,但是我感觉没有这种实现方式优雅。实在是太优雅了。

cap * loadFactor也就得到了initialCapacity

之后进行数组操作的时候,会反复调用CAS改变它的值。如果它的值是一个正数,那么它就表示这个map的容量,如果它的值是一个负数,就表示,这个数组正在扩容。而且它的绝对值越大,扩容的线程就越多。后面会重点展示它的计算,它的计算充分展现了位运算的强大。

put方法

我们直接从put方法进入。

typescript 复制代码
public V put(K key, V value) {
    return putVal(key, value, false);
}

它委托给putVal来实现:

ini 复制代码
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    //添加值不能为null,因为null无法进行hash运算。感觉加上null,要处理的麻烦比较多。
    if (key == null || value == null) throw new NullPointerException();
    //参见后面位运算的部分,这里使用spread,把hash值充分压缩到16位
    int hash = spread(key.hashCode());
    //binCount是链表的数量,如果是红黑树,这个值固定为2,普通链表则会在后面动态+1,如果太大,就会转变成红黑树
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh; K fk; V fv;
        //初始化table,走完这里的逻辑,就可以直接回到开头,重新循环了
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        //空槽,直接设值,成功就break,失败,就重新循环。
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;                   // no lock when adding to empty bin
        }
        //扩容,
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        //逻辑可以走到这里,说明这个节点肯定存在了
        //这里先提供看一下是不是onlyIfAbsent,是的话,就存在一种可能,不需要设锁,直接拿到值就跑路。
        else if (onlyIfAbsent // check first node without acquiring lock
                 && fh == hash
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                 && (fv = f.val) != null)
            return fv;
        //上锁,走你
        else {
            V oldVal = null;
            //对象锁,这里因为ConcurrentHashMap的锁的颗粒度非常细,所以这样锁也没有什么问题。
            //因为冲突的可能性不大,所以是轻量锁。
            synchronized (f) {
                //再判断一波,因为可能被锁阻塞了,那么这里f可能就已经被remove了。
                //另外,能够进入这个逻辑,说明,要么是潘通节点,要么是树节点,
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        //能进来,至少是有一个bin的,就为1,另外,因为已经加锁了,所以后面的操作都是线程安全的
                        binCount = 1;
                        //++binCount,也就是对于bin计数
                        for (Node<K,V> e = f;; ++binCount) {
                            //找节点
                            K ek;
                            //先是hash判断,之后引用判断,之后是内容判断,和HashMap一样。
                            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);
                                break;
                            }
                        }
                    }
                    //树节点的fh好像是-2,为了保证和MOVE与普通的node区分。
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        //固定binCount为2,后面就不会触发treeifyBin
                        binCount = 2;
                        //红黑树查找,等我学好数据结构再回来理,如果有这样一天的话。
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                    else if (f instanceof ReservationNode)
                        throw new IllegalStateException("Recursive update");
                }
            }
            //这里就是前面的逻辑,但凡if (fh >= 0) 这里通过了,下面的逻辑就要走一波,不然没有break,就再循环一遍。
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                //oldVal 不等于null,就说明,不需要之后的逻辑了,addCount就不用走了,直接返回值就可以。
                //因为如果存在oldVal,就说明只是一次无关紧要的修改,表的容量没有变大
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    //这里会根据binCount进行判断,逻辑很复杂。
    addCount(1L, binCount);
    return null;
}

上面基本上就是全部了。

addCount

简单进入这个函数的逻辑:

scss 复制代码
/**
 * Adds to count, and if table is too small and not already
 * resizing, initiates transfer. 
 * If already resizing, helps
 * perform transfer if work is available.  Rechecks occupancy
 * after a transfer to see if another resize is already needed
 * because resizings are lagging additions.
 *
 * @param x the count to add
 * @param check if <0, don't check resize, if <= 1 only check if uncontended
 */
private final void addCount(long x, int check) {
    CounterCell[] cs; long b, s;
    //counterCells用来分段计数,如果还没有这个东西,
    if ((cs = counterCells) != null ||
        //就看能不能直接CAS给baseCount赋值,结束这段逻辑
        //能够进入这段逻辑,要么存在cs。存在cs,就说明已经有着比较激烈的竞争了,所以就不需要CAS直接赋值baseCount了
        //要么就是赋值baseCount失败
        !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell c; long v; int m;
        //uncontended表示没有竞争
        boolean uncontended = true;
        //为什么需要在这里加上一个判断cs == null呢?为了避免后面出现空指针异常,因为很可能还没有cs,
        if (cs == null || (m = cs.length - 1) < 0 ||
            //如果c槽为空。c是一个线程相关的随机数,&m表示映射到m这段区间中。
            (c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              //这里表示直接赋值失败,也就是存在竞争。
              U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        //一个简单的问题是,为什么这里check <= 1就直接return了,
        //下面check >= 0还要继续走。
        //因为,如果直接进入下面的逻辑,说明上面来得太轻松了,竞争不激烈,估计整个map也不大,那么对于扩容的判断就要多一些。
        //反过来,如果进入上面的逻辑,说明已经存在竞争了,但是既然check<=1,说明map还可以用,多一事不如少一事。
        if (check <= 1)
            return;
        //直接通过cs进行求和,走的是LongAddr的逻辑,为了下面的循环。
        s = sumCount();
    }
    //check>=0,就说明存在需要扩表的可能。这里存在check=0的的情况,但是必要是前面的if没有进入,说明表很小,无冲突。
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        //while循环,因为这里存在并发问题,有可能你走到这里的时候,其他线程添加了节点导致需要扩表了。
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            //这一步的结束后面有。
            int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
            if (sc < 0) {
                //rs是一个标记值,它非常优雅,但是不好理解。它是一个最高位为1的数,换言之,一个负数。
                //但是,这里我认为,我们应该超越正负的观念,只是认为这个数最高位标记为1,表示它和寻常的状态不一样罢了。
                //transfer结束之后,sc--,所以一开始就直接rs+2,说明rs+1就是结束transfer的时刻。
                if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
                    (nt = nextTable) == null || transferIndex <= 0)
                    break;
                if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

以上只是简单走完了构造一个ConcurrentHashMap和往里面put一个值的过程,但是,我们还有很多问题没有解决。

transfer方式究竟是如何运作的?

为什么会出现一个helptransfer的函数。

binCount是什么?又为什么存在?

check是什么,为什么存在多个判断条件?

uncontended是什么?

rs是什么?为什么选择这样一个数作为标记?

ConcurrentHashMap里面使用的位运算有多么巧妙?

ConcurrentHashMap.addCount() 方法详细解析

一开始询问了DeepSeek,它的回答如下。我稍微删减一下。

这是 ConcurrentHashMap 中最核心的方法之一,负责元素计数和扩容触发。让我完整解释这个方法的每个部分:

📊 方法签名和变量声明

arduino 复制代码
private final void addCount(long x, int check) {
    CounterCell[] cs; long b, s;
  • x:要增加的计数值(通常为 1 或 -1)也就表示添加或者删减的节点的数量,在putVal中,为1,表示新增一个节点。
  • check:扩容检查阈值,在putVal中,也就是binCount。
  • cs:计数单元数组引用,这里就是一个LongAddr,以空间换时间,避免多线程冲突。
  • b, s:临时存储 baseCount 和总和

🔢 第一部分:计数更新机制

1.1 快速路径尝试

ini 复制代码
if ((cs = counterCells) != null ||
    !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {

逻辑

  • 如果已经存在 CounterCell 数组,或者
  • CAS 更新 baseCount 失败(说明有竞争)

1.2 慢速路径 - 分段计数

ini 复制代码
CounterCell c; long v; int m;
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 ||
    (c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
    !(uncontended = U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
    fullAddCount(x, uncontended);
    return;
}

分段计数失败条件(任一满足即进入 fullAddCount):

  1. cs == null:CounterCell 数组未初始化
  2. (m = cs.length - 1) < 0:数组长度为0
  3. (c = cs[ThreadLocalRandom.getProbe() & m]) == null:当前线程对应的槽位为空
  4. !U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x):CAS 更新对应槽位失败

反过来说,以上任何一个成功了,后面就不会进行判断,有了cs,就不会尝试CAS,这就是短路或的优雅。,

1.3 计数完成后的检查

ini 复制代码
if (check <= 1)
    return;
s = sumCount();  // 计算当前总元素数

check 参数的含义

  • check <= 1:不需要检查扩容(如替换现有值)
  • check >= 0:需要检查扩容(如新增元素)

🚀 第二部分:扩容检查与触发

2.1 扩容条件判断

ini 复制代码
if (check >= 0) {
    Node<K,V>[] tab, nt; int n, sc;
    while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
           (n = tab.length) < MAXIMUM_CAPACITY) {

扩容触发条件

  1. s >= (long)(sc = sizeCtl):元素总数 ≥ 扩容阈值
  2. (tab = table) != null:哈希表已初始化
  3. (n = tab.length) < MAXIMUM_CAPACITY:未达到最大容量

2.2 扩容状态标识计算

ini 复制代码
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
  • resizeStamp(n):生成与当前容量相关的唯一标识
  • 左移 RESIZE_STAMP_SHIFT(16位)得到基础状态值

这里是位运算,提高效率,只需要理解resizeStamp(n)是一个唯一标识就足够了。左移16位,为的就是空余来低16位用于添加线程。

2.3 扩容中状态处理(已有扩容在进行)

ini 复制代码
if (sc < 0) {
    if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
        (nt = nextTable) == null || transferIndex <= 0)
        break;
    if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
        transfer(tab, nt);
}

不能协助扩容的条件

  • sc == rs + MAX_RESIZERS:协助线程数已达上限
  • sc == rs + 1:扩容即将完成
  • (nt = nextTable) == null:新表未初始化
  • transferIndex <= 0:所有迁移任务已完成

协助扩容 :通过 CAS 将 sizeCtl + 1,然后调用 transfer 参与迁移

2.4 发起新扩容

kotlin 复制代码
else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
    transfer(tab, null);

第一个发起扩容的线程

  • 通过 CAS 将 sizeCtl 设置为 rs + 2
  • 调用 transfer(tab, null) 开始迁移,第二个参数为 null 表示需要初始化新表

2.5 更新计数并重新检查

ini 复制代码
s = sumCount();

在循环末尾重新计算总元素数,因为迁移过程中可能有其他线程在操作


🎯 关键设计思想详解

1. 分段计数(CounterCell)机制

问题:高并发下频繁更新 baseCount 会成为性能瓶颈

解决方案

scss 复制代码
// 类似于 LongAdder 的分段计数
+----------+    +------------+------------+------------+
| baseCount|    | CounterCell| CounterCell| CounterCell|
|  (基础值) |    |   (线程1)  |   (线程2)  |   (线程3)  |
+----------+    +------------+------------+------------+

最终计数 = baseCount + ∑CounterCell[i].value

2. sizeCtl 状态机

sizeCtl 的不同值表示不同状态:

值范围 含义
> 0 扩容阈值(容量 × 负载因子)
-1 表正在初始化
<-1 高16位:扩容标识,低16位:参与扩容线程数+1

举例

  • 容量=16,阈值=12:sizeCtl = 12
  • 扩容中(容量16→32,3个线程):sizeCtl = rs + 3

3. 扩容戳(resizeStamp)机制

arduino 复制代码
static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

作用:为每次扩容生成唯一标识,防止不同容量的扩容相互干扰


🔄 完整工作流程示例

场景:插入新元素触发扩容

  1. 线程A 插入元素,调用 addCount(1, binCount)
  2. 尝试快速计数:CAS 更新 baseCount 失败(有竞争)
  3. 分段计数:找到对应 CounterCell,CAS 更新成功
  4. 检查扩容:计算总元素数 s=13,阈值=12
  5. 发起扩容 :线程A成功 CAS 设置 sizeCtl = rs + 2
  6. 开始迁移 :调用 transfer(tab, null) 开始数据迁移
  7. 线程B 插入时发现扩容中,协助迁移
  8. 完成扩容:所有线程协作完成,更新 table 引用和 sizeCtl

💡 性能优化亮点

1. 无锁计数

  • 通过 CAS 和分段计数避免锁竞争
  • 读多写少时性能接近 volatile 变量

2. 协作式扩容

  • 多个线程并行迁移,加速扩容过程
  • 动态调整参与线程数

3. 精确状态控制

  • 通过 sizeCtl 精确控制扩容状态
  • 防止过度扩容或协助线程过多

4. 渐进式检查

  • 只在真正需要时检查扩容
  • 减少不必要的计算开销

这个方法是 ConcurrentHashMap 高并发设计的精华体现,通过巧妙的分段计数和协作式扩容,在保证线程安全的同时实现了极高的并发性能!

Uncontende与check解析

🔍 uncontended 的作用

基本含义

uncontended 表示 "无竞争" ,用于标记在 CounterCell 更新过程中是否遇到了竞争。

在代码中的使用

ini 复制代码
boolean uncontended = true;  // 初始假设无竞争

if (cs == null || (m = cs.length - 1) < 0 ||
    (c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
    !(uncontended = U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
    // 如果进入这里,说明有竞争或初始化问题
    fullAddCount(x, uncontended);
    return;
}

uncontended 的三种状态

  1. true:真正无竞争(理想情况)

    • CounterCell 数组存在且长度正常
    • 当前线程对应的槽位存在
    • CAS 更新 CounterCell 成功
  2. false:有竞争

    • CounterCell 数组和槽位都正常
    • 但 CAS 更新 CounterCell 失败(其他线程在同时更新)
  3. 初始化问题

    • CounterCell 数组为 null 或长度为0
    • 当前线程对应的槽位为 null

uncontendedfullAddCount 中的作用

scss 复制代码
fullAddCount(x, uncontended);

uncontended 参数告诉 fullAddCount

  • true:可能是初始化问题,需要初始化 CounterCell 数组或槽位
  • false:明确有竞争,需要重试或扩容 CounterCell 数组

🎯 check 参数的巧妙设计

check 参数的含义

check 参数控制扩容检查的粒度,在不同操作中传递不同的值:

操作类型 check 值 含义
空桶插入 0 最低优先级的扩容检查
链表插入 1-7 中等优先级的扩容检查
树节点插入 2 标记为树节点
替换现有值 < 0 不检查扩容

看似"矛盾"的逻辑解析

kotlin 复制代码
// 第一处:CounterCell 更新成功后
if (check <= 1)
    return;

// 第二处:后续逻辑
if (check >= 0) {
    // 扩容检查逻辑
}

这实际上是一个精心设计的"过滤网"

过滤流程分析

sql 复制代码
        check 值分布
     ←--- 负值 --- 0 --- 1 --- 2+ ---→
     不检查    ↓    ↓    ↓    检查
             │    │    │
第一处过滤:   │    │    × (check<=1 时返回)
             │    │
第二处过滤:   × (check<0 跳过)  ✓ (进入扩容检查)

实际场景分析

场景1:check < 0(如 replace 操作)
arduino 复制代码
// 第一处:check <= 1 ? 是,但不会执行到这里
// 因为 replace 操作通常直接返回,不调用 addCount

// 第二处:check >= 0 ? 否,跳过扩容检查

结果:不检查扩容

场景2:check = 0(空桶插入)
kotlin 复制代码
// 第一处:check <= 1 ? 是,但注意逻辑!
if (check <= 1)
    return;  // 这里会返回吗?不一定!

关键 :这个 return 只在 CounterCell 路径 中生效!

kotlin 复制代码
if ((cs = counterCells) != null || !U.compareAndSetLong(...)) {
    // CounterCell 路径
    if (check <= 1)
        return;  // 在这里,check=0 会返回
}
// 如果是 baseCount CAS 成功,会继续执行
场景3:check = 1(短链表插入)
kotlin 复制代码
// 第一处:check <= 1 ? 是
if (check <= 1)
    return;  // 在 CounterCell 路径中返回

设计思想:短链表的插入竞争较小,不急于扩容检查

场景4:check >= 2(长链表或树节点)
arduino 复制代码
// 第一处:check <= 1 ? 否,继续执行
// 第二处:check >= 0 ? 是,进行扩容检查

设计思想:冲突较多的桶需要及时检查扩容


🎪 现实世界比喻

医院急诊分诊系统

把 ConcurrentHashMap 比作医院check 参数就像病人病情的紧急程度

分诊规则
  • check < 0:健康体检(不需要急诊处理)
  • check = 0:轻微擦伤(低优先级)
  • check = 1:普通感冒(中等优先级)
  • check ≥ 2:重伤急诊(高优先级)
处理流程
scss 复制代码
病人到达 → 分诊台评估紧急程度(check)
    ↓
[第一关:CounterCell 通道]
    ↓
轻微病人(check≤1) → 简单处理后就回家(return)
    ↓  
重伤病人(check≥2) → 进入急诊室(扩容检查)
    ↓
[第二关:baseCount 通道]
    ↓  
所有病人(check≥0) → 都进急诊室检查

uncontended 就像医疗资源竞争

  • true:医院空闲,直接就诊
  • false:医院繁忙,需要排队或转院

💡 设计哲学深度解析

性能与及时性的平衡

为什么 check <= 1 在 CounterCell 路径中返回?
kotlin 复制代码
// 高并发场景下的优化
if (cs = counterCells) != null || !U.compareAndSetLong(...)) {
    // 进入这里说明:要么已有 CounterCell,要么 baseCount CAS 失败
    // 这两种情况都表明系统处于高并发状态
    
    if (check <= 1)  // 对于低冲突的桶
        return;      // 不进行扩容检查,避免在高并发时增加负担
}
为什么 check >= 0 在 baseCount 路径中检查?
scss 复制代码
// 低并发场景下的保证
// 如果能直接 CAS 更新 baseCount 成功,说明并发度低
if (check >= 0) {
    // 即使是最低优先级的检查也执行,因为系统有充足资源
}

智能的扩容触发策略

这种设计实现了 "自适应扩容检查"

  1. 高并发时:只对高冲突的桶进行扩容检查
  2. 低并发时:对所有插入都进行扩容检查
  3. 无竞争时:最全面的扩容保障

实际效果

  • 避免过度扩容:在高并发时减少不必要的检查
  • 保证及时扩容:在真正需要时及时触发
  • 资源优化:根据系统负载动态调整策略

🔧 代码执行路径总结

路径1:baseCount CAS 成功(低并发)

scss 复制代码
addCount(1L, binCount)
    ↓
baseCount CAS 成功
    ↓
if (check >= 0) → 总是检查扩容
    ↓
可能触发扩容

路径2:CounterCell 更新成功(高并发)

scss 复制代码
addCount(1L, binCount)  
    ↓
baseCount CAS 失败 → 进入 CounterCell 路径
    ↓
CounterCell CAS 成功
    ↓
if (check <= 1) → 低冲突桶直接返回
    ↓  
if (check >= 2) → 高冲突桶检查扩容

路径3:CounterCell 初始化/竞争(高并发激烈)

scss 复制代码
addCount(1L, binCount)
    ↓
baseCount CAS 失败 → 进入 CounterCell 路径  
    ↓
CounterCell CAS 失败或需要初始化
    ↓
fullAddCount(x, uncontended) → 复杂处理
    ↓
直接返回(不检查扩容,先解决计数问题)

这种精妙的设计让 ConcurrentHashMap 能够在各种并发场景下都保持优异的性能!

rs是什么?

rs(resize stamp)是扩容戳,用于唯一标识一次扩容操作:

ini 复制代码
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;

resizeStamp 计算方法

arduino 复制代码
static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

rs 的组成

  • 高16位:扩容标识(基于容量n的前导零个数)
  • 低16位:用于记录扩容线程数

举例(假设 n=16):

  • Integer.numberOfLeadingZeros(16) = 27(32-5,因为16=2⁴)
  • 1 << (16 - 1) = 0x8000
  • resizeStamp(16) = 27 | 0x8000 = 0x8000 | 0x001B = 0x801B
  • 左移16位:rs = 0x801B0000

🎯 为什么需要这些判断条件

sc < 0 时,表示扩容正在进行中,当前线程想要协助扩容。但这些判断确保我们只在"合适的时候"协助:

判断条件逐行解析:

ini 复制代码
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
    (nt = nextTable) == null || transferIndex <= 0)
    break;

🔒 四个退出条件的深层原因

1. sc == rs + MAX_RESIZERS - 线程数已达上限

ini 复制代码
if (sc == rs + MAX_RESIZERS)

含义:扩容协助线程数已经达到最大值

数学原理

  • rs = 扩容基础戳(如 0x801B0000)
  • MAX_RESIZERS = 最大协助线程数(通常是 2¹⁶-1)
  • sc == rs + MAX_RESIZERS 表示:(低16位) = MAX_RESIZERS

为什么需要

  • 防止过多线程参与扩容,造成资源浪费
  • 避免线程间过度竞争,反而降低性能

举例

ini 复制代码
rs = 0x801B0000
MAX_RESIZERS = 0x0000FFFF
rs + MAX_RESIZERS = 0x801BFFFF
如果 sc == 0x801BFFFF,说明已经有65535个线程在协助了

2. sc == rs + 1 - 扩容即将完成

ini 复制代码
if (sc == rs + 1)

含义:扩容工作马上结束,不需要更多协助

数学原理

  • 初始扩容状态:sc = rs + 2(1个主导线程 + 1)
  • 每个协助线程:sc = sc + 1
  • sc == rs + 1 时,表示所有线程都快退出了

为什么需要

  • 避免在扩容收尾阶段加入,造成混乱
  • 防止新线程刚开始工作,扩容就结束了

3. (nt = nextTable) == null - 新表未准备好

ini 复制代码
if ((nt = nextTable) == null)

含义:新的哈希表还没有创建完成

为什么需要

  • 如果 nextTable 为 null,说明扩容还没真正开始
  • 或者扩容出现了异常情况
  • 此时协助没有意义,应该退出等待

4. transferIndex <= 0 - 任务已分配完

scss 复制代码
if (transferIndex <= 0)

含义:所有迁移任务都已经被分配完毕

背景知识

  • transferIndex:从后往前分配迁移任务的范围指针
  • 初始值 = 旧表长度 n
  • 每个线程领取一段任务:[transferIndex-stride, transferIndex)

为什么需要

  • 如果 transferIndex <= 0,说明所有桶都已经分配了迁移任务
  • 没有剩余工作需要协助,应该退出

✅ 通过检查后的协助逻辑

scss 复制代码
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
    transfer(tab, nt);

逻辑

  1. CAS 增加协助线程计数sc → sc + 1
  2. 参与实际迁移工作 :调用 transfer(tab, nt)

CAS 的重要性:确保线程安全地增加计数器,防止并发问题


🎪 形象比喻:建筑工地协作

把扩容比作建筑工地搬迁

场景设定

  • 旧工地 = 当前哈希表
  • 新工地 = nextTable
  • 工头 = 主导扩容的线程
  • 工人 = 协助扩容的线程
  • 任务板 = transferIndex
  • 工号牌 = sizeCtl 的低16位

四个退出条件的现实对应

  1. sc == rs + MAX_RESIZERS 👥

    • "工地已经招了65535个工人,人满为患,别再来了!"
  2. sc == rs + 1 🏁

    • "搬迁工作已经完成99%,现在来帮忙反而添乱"
  3. (nt = nextTable) == null 🏗️

    • "新工地还没建好呢,来了也没活干"
  4. transferIndex <= 0 📋

    • "任务板上已经没有待分配的工作了"

合格的协助流程

yaml 复制代码
工人A来到工地 → 查看情况:
✅ 工地还在搬迁中(sc < 0)
✅ 工人数量没满(不是 rs + MAX_RESIZERS)
✅ 不是收尾阶段(不是 rs + 1)  
✅ 新工地已建好(nextTable != null)
✅ 还有未分配的任务(transferIndex > 0)

→ 去管理处登记领工号(CAS: sc → sc+1)
→ 开始实际搬迁工作(transfer)

💡 设计哲学总结

"恰到好处"的协作

  • 不早不晚:在需要的时候协助,不需要的时候退出
  • 不多不少:控制协助线程数量,避免过度竞争
  • 不慌不忙:有序参与,避免状态混乱

状态机的精确控制

通过 sizeCtl 这一个变量,同时维护:

  • 扩容标识(高16位):确保是同一轮扩容
  • 线程计数(低16位):精确控制参与线程数
  • 整体状态:正数=阈值,负数=扩容中

自我保护机制

这些判断条件形成了完整的自我保护:

  • 资源保护:防止线程爆炸
  • 状态保护:防止错误参与
  • 进度保护:防止干扰收尾工作

这种精妙的设计让 ConcurrentHashMap 在高并发扩容时既能充分利用多核优势,又能避免过度竞争和资源浪费!

sc = rs + 2的原因

这确实不是巧合,而是 精心设计的数学关系!让我深入解释这个设计的巧妙之处。

🔢 数字关系的数学原理

rs 的结构分析

bash 复制代码
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;  // RESIZE_STAMP_SHIFT = 16

rs 的特点

  • 高16位包含扩容标识信息
  • 低16位全为0(因为左移了16位)

举例(假设 n=16):

scss 复制代码
resizeStamp(16) = 0x801B     // 高16位: 0x801B, 低16位: 0x0000
rs = 0x801B << 16 = 0x801B0000  // 高16位: 0x801B, 低16位: 0x0000

🎯 rs + 2 的深层含义

状态编码设计

在 ConcurrentHashMap 中,sizeCtl 在扩容期间被设计为:

ini 复制代码
sizeCtl = rs + (1 + 协助线程数)

具体分解

  • rs + 1 = 扩容完成或即将完成的状态
  • rs + 2 = 初始状态(1个主导线程 + 1)
  • rs + 3 = 有2个线程在扩容
  • rs + 4 = 有3个线程在扩容
  • ...
  • rs + MAX_RESIZERS = 达到最大线程数

为什么是 +2 而不是 +1

kotlin 复制代码
// 第一个线程发起扩容
else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
    transfer(tab, null);

关键洞察+2 表示 "1个主导线程 + 1个计数基准"

数学关系

  • 实际扩容线程数 = (sizeCtl - rs) - 1
  • sizeCtl = rs + 2 时:实际线程数 = (rs + 2 - rs) - 1 = 1

🔍 状态转换的完整生命周期

扩容状态演进

ini 复制代码
正常状态: sizeCtl = 12 (阈值)
    ↓
开始扩容: sizeCtl = rs + 2 (1个线程)
    ↓
线程A协助: sizeCtl = rs + 3 (2个线程)  
    ↓
线程B协助: sizeCtl = rs + 4 (3个线程)
    ↓
...
    ↓
完成前: sizeCtl = rs + 1 (0个线程,即将完成)
    ↓
完成: sizeCtl = 24 (新阈值)

代码中的状态检查

ini 复制代码
if (sc == rs + MAX_RESIZERS)    // 线程数达上限
if (sc == rs + 1)               // 扩容即将完成

💡 设计精妙之处

1. 统一的数学框架

scss 复制代码
// 所有状态检查都基于同一个数学关系
线程数 = (sc - rs) - 1

// 因此可以统一判断:
if (线程数 >= MAX_RESIZERS)  → if (sc == rs + MAX_RESIZERS)
if (线程数 == 0)             → if (sc == rs + 1)

2. 避免特殊值冲突

如果使用 rs + 1 作为初始值:

  • 完成状态就无法用 rs + 1 表示
  • 需要另外定义特殊值,增加复杂度

3. 自然的计数增长

kotlin 复制代码
// 线程协助时的操作非常自然
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
  • 不需要特殊处理初始状态
  • 每个线程简单地 +1,数学关系自动保持

4. 清晰的状态边界

  • rs + 1:明确的完成边界
  • rs + 2:明确的开始边界
  • 中间状态:线性的线程计数

🎪 现实世界比喻

团队项目的版本号管理

把扩容比作一个软件开发项目

ini 复制代码
版本号 = 项目代号 + 参与人数

rs = "CHM-2024" (项目基础代号)
rs + 1 = "CHM-2024-FINAL"  // 项目完成,准备发布
rs + 2 = "CHM-2024-TEAM1"  // 项目启动,1个核心开发者
rs + 3 = "CHM-2024-TEAM2"  // 第2个开发者加入
...
rs + MAX = "CHM-2024-TEAMMAX" // 团队满员

工作流程

  1. 项目经理 (主导线程)创建项目:rs + 2
  2. 开发者A 加入:检查是否 当前版本 == FINAL?不是 → 版本+1
  3. 开发者B 加入:检查是否 当前版本 == TEAMMAX?不是 → 版本+1
  4. 项目完成 :版本回到 FINAL 状态

🔧 实际代码验证

让我们验证这个设计的一致性:

kotlin 复制代码
// 初始状态:1个线程在扩容
sizeCtl = rs + 2

// 线程协助成功
U.compareAndSetInt(this, SIZECTL, sc, sc + 1)  // sc = rs + 2 → rs + 3
// 现在有2个线程:(rs + 3 - rs) - 1 = 2

// 线程退出时
U.compareAndSetInt(this, SIZECTL, sc, sc - 1)  // sc = rs + 3 → rs + 2  
// 回到1个线程

// 最后一个线程退出
U.compareAndSetInt(this, SIZECTL, sc, sc - 1)  // sc = rs + 2 → rs + 1
// 现在有0个线程,扩容完成

🏆 总结:这不是巧合,而是精妙设计

sc = rs + 2 的选择体现了 "简单即美" 的设计哲学:

  1. 数学优雅:用简单的算术关系表达复杂的状态机
  2. 操作一致 :所有状态转换都通过简单的 +1/-1 完成
  3. 边界清晰:开始和结束状态有明确的数学定义
  4. 并发安全:CAS 操作天然适合这种线性增长

这种设计让复杂的多线程协作变得可预测、可推理、可维护,正是 ConcurrentHashMap 高并发能力的基石之一!

所以,这不是"巧合",而是计算机科学和软件工程的精妙结合

binCount 的作用详解

putVal 方法中,binCount 是一个非常重要的变量,它有多重作用,让我详细解释:

📋 binCount 的基本含义

binCount 表示当前桶的节点数量(在插入新节点之前),具体含义因桶类型而异:

  • 链表:链表的长度(从0开始计数)
  • 红黑树:固定值 2(作为标记值)
  • 空桶:值为 0

🎯 binCount 的三大核心作用

1. 树化判断依据(最主要作用)

scss 复制代码
// 在链表遍历插入时
for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        // 树化判断:链表长度达到阈值
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
        break;
    }
    // ... 其他逻辑
}

树化条件

  • binCount >= TREEIFY_THRESHOLD - 1(即链表长度 ≥ 8)
  • 为什么要 -1?因为 binCount 从0开始计数

举例

css 复制代码
链表:A → B → C → D → E → F → G → H
binCount 变化:0→1→2→3→4→5→6→7
当 binCount=7 时,插入第9个节点,触发树化

2. 扩容检查的触发条件

scss 复制代码
// 在 putVal 方法最后
addCount(1L, binCount);

binCount 作为第二个参数传递给 addCount,控制扩容检查的粒度:

binCount 值 含义 是否检查扩容
0 空桶插入
1-7 短链表插入
≥ 8 长链表或树节点

设计思想:无论桶的长度如何,每次插入都会检查是否需要扩容,但检查的紧迫性不同。

3. 区分桶类型的标记

java 复制代码
else if (f instanceof TreeBin) {
    // 红黑树处理
    binCount = 2;  // 固定标记值
}

对于红黑树,binCount 被固定设为 2,这有几个目的:

  • 标记作用:表示这是树节点,不是链表
  • 避免误树化:值2 < 8,不会重复触发树化
  • 兼容性:保持方法调用的一致性

🔍 实际代码流程分析

场景1:空桶插入

scss 复制代码
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
else {
    // 非空桶处理
}
// 调用 addCount
addCount(1L, 0);  // binCount=0 表示空桶插入

场景2:链表插入

ini 复制代码
for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        if (binCount >= TREEIFY_THRESHOLD - 1)  // 树化检查
            treeifyBin(tab, hash);
        break;
    }
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;  // 键已存在
    p = e;
}
// binCount 是最终的链表长度
addCount(1L, binCount);

场景3:树节点插入

ini 复制代码
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;
    }
}
addCount(1L, binCount);  // binCount=2

🎪 形象比喻:超市收银台

把 ConcurrentHashMap 比作超市 ,桶比作收银台

binCount 就是收银台排队人数

  1. 空收银台(binCount=0)

    • 新顾客直接结账
    • 通知经理:"现在有1个顾客了"
  2. 短队伍(binCount=1-7)

    • 顾客排队等候
    • 通知经理:"现在有X个顾客在排队"
  3. 长队伍(binCount=8)

    • 经理看到:"这个收银台人太多了!"
    • 决定:"升级为快速收银通道!"(链表→红黑树)
  4. 快速通道(binCount=2)

    • 已经是快速通道了,标记为"特殊通道"
    • 不再重复升级,但继续统计顾客数量

💡 设计哲学思考

为什么用 binCount 而不用实际长度?

  1. 性能优化

    • 遍历时自然计数,无需额外计算长度
    • 避免对长链表的重复遍历
  2. 状态融合

    • 一个变量同时承载长度信息和类型标记
    • 简化方法签名和逻辑判断
  3. 渐进式决策

    • 在插入过程中实时判断,避免事后分析
    • 及时触发树化或扩容,防止性能恶化

binCount 的智能之处

  • 动态感知:实时反映桶的负载情况
  • 多重职责:既是计数器,也是决策依据
  • 阈值触发:在关键时刻(长度=8)自动升级数据结构

这个精巧的设计让 ConcurrentHashMap 能够自适应地调整内部结构,在保持高性能的同时优雅地处理哈希冲突!

Transfer逻辑

ini 复制代码
 private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
     int n = tab.length, stride;
     //stride,每个线程处理的桶的数量,根据NCPU计算
     //但是即使CPU很多,每个线程也应该有一个保底的数量,不会太少,不至于少于16个。
     if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
         stride = MIN_TRANSFER_STRIDE; // subdivide range
     if (nextTab == null) {            // initiating
         try {
             @SuppressWarnings("unchecked")
             //扩容,表的大小直接翻倍
             Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
             nextTab = nt;
             //Out of memery,把sizeCtl设置成最大数,所以就不会再触发transfer的逻辑了,即使触发了,也没有办法了。
         } catch (Throwable ex) {      // try to cope with OOME
             sizeCtl = Integer.MAX_VALUE;
             return;
         }
         nextTable = nextTab;
         transferIndex = n;
     }
     int nextn = nextTab.length;
     ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
     boolean advance = true;
     boolean finishing = false; // to ensure sweep before committing nextTab
     //看似初始值i=0,但其实i是倒着走的,它的值表示当前正在工作的槽,  i = nextIndex - 1;
     for (int i = 0, bound = 0;;) {
         Node<K,V> f; int fh;
         //任务分配逻辑,不管怎样,不管哪一个if,反正至少要进入到其中一个if的逻辑中,advance才能变成false。
         while (advance) {
            
             int nextIndex, nextBound;
             //任务还能继续干,淦!
             //这里为什么多出来一个finishing呢?因为后来可以回出现循环检查的情况,
             //进入这个逻辑,直接advance=false,就不需要后面的任务判断,然后每一个都直接走出while逻辑,--i
            //会直接走到 already processed的部分。
             if (--i >= bound || finishing)
                 advance = false;
             //干完了,应该就是0
             //transferIndex是一个成员变量
             //初始化的线程会把它设置成n,
             else if ((nextIndex = transferIndex) <= 0) {
                 i = -1;
                 advance = false;
             }
              //nextIndex,开始处理的index+1
             //nextBound,结束处理的位置。
             //思考一下,为什么CAS的时候使用了nextIndex,而不是直接使用transferIndex?
             else if (U.compareAndSetInt
                      (this, TRANSFERINDEX, nextIndex,
                       nextBound = (nextIndex > stride ?
                                    nextIndex - stride : 0))) {
                 bound = nextBound;
                 i = nextIndex - 1;
                 advance = false;
             }
         }
         //虽然我不能理解为什么需要这样写?
         //i >= n, 其实就是在完成所有任务的时候, i = n; // recheck before commit,再重新检查一下
         //这里或许会出现并发问题,检查一下,看有没有遗漏的槽。
         if (i < 0 || i >= n || i + n >= nextn) {
             int sc;
             if (finishing) {
                 nextTable = null;
                 table = nextTab;
                 sizeCtl = (n << 1) - (n >>> 1);
                 return;
             }
             if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                 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);
         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) {
                         int runBit = fh & n;
                         Node<K,V> lastRun = f;
                         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;
                         }
                         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);
                         }
                         setTabAt(nextTab, i, ln);
                         setTabAt(nextTab, i + n, hn);
                         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;
                     }
                     else if (f instanceof ReservationNode)
                         throw new IllegalStateException("Recursive update");
                 }
             }
         }
     }
 }

ConcurrentHashMap.transfer() 方法深度解析与可视化

这是 ConcurrentHashMap 中最核心、最复杂的扩容迁移方法。让我通过文字 + 可视化图表的方式详细解析。

📊 方法整体架构

方法三个阶段:

  1. 初始化阶段:计算步长、创建新数组
  2. 任务分配阶段:多线程协作分配迁移任务
  3. 数据迁移阶段:实际移动节点到新数组

🔧 第一阶段:初始化准备

1.1 步长计算(Stride Calculation)

ini 复制代码
 int n = tab.length, stride;
 if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
     stride = MIN_TRANSFER_STRIDE;

可视化计算过程

ini 复制代码
 假设:n=64, NCPU=4, MIN_TRANSFER_STRIDE=16
 ​
 计算步骤:
 n >>> 3 = 64 / 8 = 8
 8 / NCPU = 8 / 4 = 2
 2 < 16 → stride = 16
 ​
 最终:每个线程处理16个桶

1.2 新表初始化

ini 复制代码
if (nextTab == null) {            // 第一个发起扩容的线程
    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];  // 容量翻倍
    nextTab = nt;
    nextTable = nextTab;
    transferIndex = n;  // 从后往前迁移,起始位置=旧表长度
}

内存状态变化

scss 复制代码
扩容前:
table    → [0][1][2]...[63] (长度64)
nextTable → null

扩容后:
table    → [0][1][2]...[63] (长度64)  
nextTable → [0][1][2]...[127] (长度128)
transferIndex = 64

🎯 第二阶段:任务分配机制

2.1 核心循环结构

ini 复制代码
boolean advance = true;
boolean finishing = false;
for (int i = 0, bound = 0;;) {
    Node<K,V> f; int fh;
    while (advance) {
        // 任务分配逻辑
    }
    // 迁移逻辑
}

2.2 任务分配状态机

ini 复制代码
while (advance) {
    int nextIndex, nextBound;
    if (--i >= bound || finishing)        // 情况1:当前段内继续处理
        advance = false;
    else if ((nextIndex = transferIndex) <= 0) { // 情况2:所有任务已完成
        i = -1;
        advance = false;
    }
    else if (U.compareAndSetInt          // 情况3:获取新任务段
             (this, TRANSFERINDEX, nextIndex,
              nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
        bound = nextBound;
        i = nextIndex - 1;
        advance = false;
    }
}

任务分配可视化

css 复制代码
初始状态:n=64, stride=16, transferIndex=64

时间线:
┌─────────┬────────────┬──────────┬─────────┬─────────┐
│  线程   │  操作顺序   │ 获取区间  │ bound   │   i     │
├─────────┼────────────┼──────────┼─────────┼─────────┤
│ 线程A   │    第1步    │ [48,64)  │ 48      │ 63      ││ 线程B   │    第2步    │ [32,48)  │ 32      │ 47      │  │ 线程C   │    第3步    │ [16,32)  │ 16      │ 31      ││ 线程D   │    第4步    │ [0,16)   │ 0       │ 15      │└─────────┴────────────┴──────────┴─────────┴─────────┘

图形化表示任务分配

less 复制代码
旧数组索引: 0   1   2 ... 15  16  17 ... 31  32  33 ... 47  48  49 ... 63
            ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
任务分配:    │线程D │      线程C      │      线程B      │      线程A      │
            └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

🚀 第三阶段:数据迁移逻辑

3.1 迁移完成检查

ini 复制代码
if (i < 0 || i >= n || i + n >= nextn) {
    // 扩容完成处理
    if (finishing) {
        nextTable = null;
        table = nextTab;          // 切换主表引用
        sizeCtl = (n << 1) - (n >>> 1);  // 新阈值 = 1.5n
        return;
    }
    // 当前线程退出扩容
    if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
        if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
            return;  // 不是最后一个线程
        finishing = advance = true;  // 最后一个线程,重新扫描
        i = n; // 从最后开始检查
    }
}

线程退出可视化

ini 复制代码
初始:sizeCtl = rs + 4 (4个线程参与)
线程A完成:sizeCtl = rs + 3
线程B完成:sizeCtl = rs + 2  
线程C完成:sizeCtl = rs + 1
线程D(最后一个)完成:切换table引用,清理状态

3.2 空桶处理

ini 复制代码
else if ((f = tabAt(tab, i)) == null)
    advance = casTabAt(tab, i, null, fwd);  // 直接标记为ForwardingNode

空桶迁移过程

css 复制代码
迁移前:
tab[i] = null

迁移后:  
tab[i] = ForwardingNode → nextTable

3.3 链表迁移(核心算法)

ini 复制代码
synchronized (f) {
    if (tabAt(tab, i) == f) {
        if (fh >= 0) {
            // 1. 寻找最后一段连续相同位的节点
            int runBit = fh & n;
            Node<K,V> lastRun = f;
            for (Node<K,V> p = f.next; p != null; p = p.next) {
                int b = p.hash & n;
                if (b != runBit) {
                    runBit = b;
                    lastRun = p;
                }
            }
            
            // 2. 构建两个链表
            // ... 详细逻辑
        }
    }
}

链表迁移优化算法图解

步骤1:寻找 lastRun
less 复制代码
原始链表:A → B → C → D → E → F → G → H
hash & n 计算:
A:0, B:0, C:0, D:1, E:1, F:1, G:1, H:1
        ↑
     lastRun (从这里开始都是连续的1)

结果:lastRun = D
步骤2:链表拆分优化
css 复制代码
第一次遍历(lastRun之前):
低位链表:C → B → A  (头插法)
高位链表:不需要创建新节点

第二次遍历(lastRun及之后):
直接复用:D → E → F → G → H

最终:
低位链表:C → B → A
高位链表:D → E → F → G → H

为什么这样优化?

  • 减少对象创建:lastRun 之后的节点直接复用
  • 保持顺序:虽然前半部分头插法,但并发场景下顺序本身就不保证
步骤3:放置到新表
scss 复制代码
setTabAt(nextTab, i, ln);      // 原位置:i
setTabAt(nextTab, i + n, hn);  // 新位置:i + 旧容量
setTabAt(tab, i, fwd);         // 标记为已迁移

位置计算原理

bash 复制代码
因为新容量是2的幂,hash & (2n-1) 的结果:
- 等于 hash & (n-1)        → 留在位置 i
- 等于 hash & (n-1) + n    → 移动到位置 i + n

3.4 红黑树迁移

ini 复制代码
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) {
        // 根据 (h & n) 分为低位和高位链表
    }
    
    // 判断是否需要树化或链化
    ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : new TreeBin<K,V>(lo);
    hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : new TreeBin<K,V>(hi);
}

树迁移决策逻辑

perl 复制代码
节点数判断:
lc ≤ 6 → 链化低位链表
hc ≤ 6 → 链化高位链表  
否则 → 树化

特殊情况:如果一边为0,复用原树

🎪 完整迁移过程可视化示例

场景:容量16→32的扩容

初始状态

ini 复制代码
旧表 (n=16):
索引:  0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15
      [A] [ ] [C] [D] [E] [F] [ ] [G] [H] [I] [J] [K] [L] [M] [N] [O]
              ↓
            B→C

新表 (nextn=32): [空表]
transferIndex = 16

迁移过程

第1步:线程A获取任务 [12,16)
makefile 复制代码
处理桶15: O → 新表位置15或31
处理桶14: N → 新表位置14或30  
处理桶13: M → 新表位置13或29
处理桶12: L → 新表位置12或28
第2步:线程B获取任务 [8,12)
makefile 复制代码
处理桶11: K → 新表位置11或27
处理桶10: J → 新表位置10或26
处理桶9:  I → 新表位置9或25
处理桶8:  H → 新表位置8或24
第3步:处理链表桶2
ini 复制代码
桶2: B → C
计算:
B.hash & 16 = 0 → 低位
C.hash & 16 = 0 → 低位

结果:
新表[2] = B → C
标记旧表[2] = ForwardingNode
最终状态:
makefile 复制代码
旧表: 所有桶都是ForwardingNode
新表: 数据均匀分布在32个桶中
table引用指向新表

💡 设计亮点总结

1. 并行迁移

  • 多线程同时处理不同区段
  • 无锁任务分配,避免竞争

2. 优化算法

  • lastRun优化减少对象创建
  • 链表保持部分顺序

3. 状态安全

  • 精确的线程计数控制
  • 安全的表切换机制

4. 渐进式

  • 迁移过程中可继续读写
  • ForwardingNode引导访问新表

5. 资源控制

  • 根据CPU数量动态调整步长
  • 防止过多线程参与造成竞争

这个精妙的迁移算法是 ConcurrentHashMap 高并发性能的基石,通过巧妙的并行设计和状态管理,实现了真正无阻塞的扩容!

辅助机制

同步扩容

putVal()方法中有这样一行:

ini 复制代码
else if ((fh = f.hash) == MOVED)
    tab = helpTransfer(tab, f);

这是 ConcurrentHashMap 中的 协助扩容方法(helpTransfer),当线程在操作时发现当前桶正在迁移,会调用此方法来协助完成扩容工作。

前因是什么呢?为什么会出现这样一种情况呢?

当然是,已经有了线程正在执行迁移的逻辑,迁移的时候会对node上锁,迁移结束之后,会把node设置成一个特殊的fwd

scss 复制代码
           setTabAt(tab, i, fwd);

之后其他节点过来的时候,就会进入这个if的逻辑。

方法作用

swift 复制代码
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
  • 目的:让当前线程协助完成哈希表的扩容迁移工作
  • 触发条件 :当操作过程中遇到 ForwardingNode(转发节点)

代码逻辑分析

1. 条件检查

yaml 复制代码
if (tab != null && (f instanceof ForwardingNode) &&
    (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
  • 检查表不为空
  • 当前节点是 ForwardingNode(表示该桶正在迁移)
  • 获取新表 nextTable 不为空

一般来说,都会进入这个逻辑,想不到什么时候不为true。

2. 计算扩容标识

ini 复制代码
int rs = resizeStamp(tab.length) << RESIZE_STAMP_SHIFT;
  • resizeStamp(tab.length):生成与当前容量相关的扩容戳
  • 左移 RESIZE_STAMP_SHIFT(16位)得到扩容状态的基础值
arduino 复制代码
/**
 * Returns the stamp bits for resizing a table of size n.
 * Must be negative when shifted left by RESIZE_STAMP_SHIFT.
 */
static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

逻辑也挺简单的,最低位是前导零,接着在第十六位上一个1,如果再右移16位,也就是32位上一个1,又是优雅的位运算。

3. 循环协助迁移

ini 复制代码
while (nextTab == nextTable && table == tab &&
       (sc = sizeCtl) < 0) {

循环条件:

  • 新表未改变
  • 旧表未改变
  • sizeCtl < 0(表示扩容仍在进行中)

4. 退出条件检查

ini 复制代码
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
    transferIndex <= 0)
    break;

退出条件

  • sc == rs + MAX_RESIZERS:协助线程数已达上限
  • sc == rs + 1:扩容即将完成
  • transferIndex <= 0:所有迁移任务已完成

5. CAS 尝试参与扩容

kotlin 复制代码
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) {
    transfer(tab, nextTab);
    break;
}
  • 通过 CAS 将 sizeCtl 加1(增加一个协助线程)
  • 成功后调用 transfer 执行实际的迁移工作

关键设计思想

1. 协作式扩容

  • 多线程并行迁移:多个线程可以同时协助完成数据迁移
  • 工作窃取:线程完成自己的迁移任务后可以协助其他线程

2. 状态控制机制

sizeCtl 的不同值表示不同状态:

  • 正数:扩容阈值
  • -1:表正在初始化
  • 其他负数:高16位是扩容标识,低16位是参与扩容的线程数

3. 转发节点(ForwardingNode)

scala 复制代码
static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    // ...
}
  • 特殊标记节点,表示该桶已迁移完成
  • 包含指向新表的引用

工作流程示例

  1. 线程A 在 put 操作时发现桶节点是 ForwardingNode
  2. 线程A 调用 helpTransfer 检查是否可以协助
  3. 如果条件满足,线程A通过 CAS 注册为协助线程
  4. 线程A 调用 transfer 参与数据迁移
  5. 迁移完成后,操作在新表上继续执行

性能优势

  1. 避免操作阻塞 :读操作遇到 ForwardingNode 可以直接转到新表
  2. 加速扩容:多线程并行迁移大幅缩短扩容时间
  3. 负载均衡:自动根据系统负载调整迁移速度

这种设计体现了 ConcurrentHashMap 的"人人为我,我为人人"思想:每个线程在需要时都能得到其他线程的协助,同时自己也会在可能时协助他人,共同完成繁重的扩容任务。

transferIndex 的 CAS 操作深度解析

ini 复制代码
        //transferIndex是一个成员变量
        //初始化的线程会把它设置成n,
        else if ((nextIndex = transferIndex) <= 0) {
            i = -1;
            advance = false;
        }
         //nextIndex,开始处理的index+1
        //nextBound,结束处理的位置。
        //思考一下,为什么CAS的时候使用了nextIndex,而不是直接使用transferIndex?
        else if (U.compareAndSetInt
                 (this, TRANSFERINDEX, nextIndex,
                  nextBound = (nextIndex > stride ?
                               nextIndex - stride : 0))) {
            bound = nextBound;
            i = nextIndex - 1;
            advance = false;
        }

你问到了一个非常关键的设计点!让我详细解释为什么需要 nextIndex 局部变量,以及 transferIndex 的 volatile 特性如何影响这个设计。

🔍 transferIndex 的 volatile 特性

是的,transferIndex 是 volatile 变量!

java 复制代码
// 在 ConcurrentHashMap 中的定义
private transient volatile int transferIndex;

volatile 的作用

  • 内存可见性 :所有线程都能立即看到 transferIndex 的最新值
  • 禁止指令重排序:保证操作的顺序性

🎯 为什么需要 nextIndex 局部变量?

1. 竞态条件保护

问题场景

kotlin 复制代码
// 错误写法:直接在 CAS 中使用 transferIndex
else if (U.compareAndSetInt(this, TRANSFERINDEX, transferIndex, 
                           transferIndex - stride)) {
    // 潜在问题:transferIndex 可能已被其他线程修改!
}

竞态风险

ini 复制代码
时间线:
1. 线程A读取 transferIndex = 64
2. 线程B修改 transferIndex = 48
3. 线程A执行 CAS(TRANSFERINDEX, 64, 48) → 失败!
因为实际值已经是48,不是64了

2. CAS 操作的原子性要求

CAS 操作需要 "比较-交换" 的原子性:

kotlin 复制代码
U.compareAndSetInt(this, TRANSFERINDEX, expectedValue, newValue)
  • expectedValue:期望的旧值(必须与内存中当前值匹配)
  • newValue:要设置的新值

使用局部变量的正确模式

ini 复制代码
int nextIndex = transferIndex;  // 快照当前值
// 基于快照值计算
int nextBound = (nextIndex > stride) ? nextIndex - stride : 0;
// CAS 使用快照值作为期望值
if (U.compareAndSetInt(this, TRANSFERINDEX, nextIndex, nextBound)) {
    // 成功:基于快照值设置局部变量
    bound = nextBound;
    i = nextIndex - 1;
    advance = false;
}

🔄 完整的线程安全逻辑

代码解析:

ini 复制代码
else if (U.compareAndSetInt
         (this, TRANSFERINDEX, nextIndex,          // 期望值:快照的nextIndex
          nextBound = (nextIndex > stride ?        // 新值:计算出的下界
                       nextIndex - stride : 0))) {
    bound = nextBound;    // 任务段的下界
    i = nextIndex - 1;    // 从后往前处理,起始索引
    advance = false;      // 退出任务分配循环,开始处理
}

执行流程:

  1. 读取快照nextIndex = transferIndex(volatile 读)

  2. 计算新值nextBound = nextIndex - stride

  3. 原子更新 :CAS 尝试将 transferIndexnextIndex 更新为 nextBound

  4. 结果处理

    • 成功 :当前线程获得任务段 [nextBound, nextIndex)
    • 失败:循环重试(其他线程已修改)

🎪 现实世界比喻

超市排队取号系统

把迁移任务分配比作银行/医院的取号系统

transferIndex = 总服务台
arduino 复制代码
volatile int transferIndex = 100;  // 当前可服务的最大号码
nextIndex = 你看到的号码牌
ini 复制代码
// 你走到服务台前
int nextIndex = transferIndex;  // 看到"当前服务到100号"

// 你决定取10个号码(stride=10)
int nextBound = nextIndex - 10;  // 你要90-100号

// 向服务员申请
if (CAS(当前号码=100, 期望改为=90)) {
    // 成功!你获得了90-100号的处理权
    bound = 90;  // 从90号开始
    i = 99;      // 先处理99号(从后往前)
}
如果不用局部变量的灾难场景
arduino 复制代码
你: "我要100-90号"
服务员: "好的...(正在处理)"
同时,另一个人: "我要100-90号" 
服务员: "混乱!到底给谁?"
使用局部变量的正确场景
arduino 复制代码
你: "我看到现在是100号,我要100-90号"
服务员: "检查现在确实是100号 → 改为90号 → 成功!"

另一个人: "我看到现在是90号,我要90-80号"  
服务员: "检查现在确实是90号 → 改为80号 → 成功!"

💡 设计精妙之处

1. 乐观锁思想

objectivec 复制代码
// 不加锁,而是"先尝试,失败了重试"
do {
    int nextIndex = transferIndex;  // 读取最新值
    // 计算、尝试CAS...
} while (CAS失败);

2. 无锁并行

  • 多个线程可以同时尝试获取任务段
  • 只有成功的线程继续工作,失败的重新尝试
  • 避免线程阻塞,提高CPU利用率

3. 任务分配的原子性

ini 复制代码
// 这三个操作在CAS成功后是原子的:
bound = nextBound;    // 1. 设置下界
i = nextIndex - 1;    // 2. 设置起始索引  
advance = false;      // 3. 退出分配循环

4. 从后往前的处理顺序

ini 复制代码
i = nextIndex - 1;  // 从区间的最大值开始处理

好处:减少与正常put操作的冲突(新元素通常插入到前面)


🔧 并发场景分析

场景:4个线程竞争任务

初始状态transferIndex = 64, stride = 16

ini 复制代码
时间线:
t0: 线程A读取 nextIndex=64, 计算 nextBound=48
t1: 线程B读取 nextIndex=64, 计算 nextBound=48  
t2: 线程A CAS成功: transferIndex=64→48
t3: 线程B CAS失败: 期望64, 实际48 → 重新循环
t4: 线程B读取 nextIndex=48, 计算 nextBound=32
t5: 线程B CAS成功: transferIndex=48→32

结果

  • 线程A:处理区间 [48, 64)
  • 线程B:处理区间 [32, 48)
  • 没有重叠,完美并行!

🚨 如果不这样设计的后果

错误实现1:直接使用 transferIndex

kotlin 复制代码
// 错误!transferIndex可能被修改
else if (U.compareAndSetInt(this, TRANSFERINDEX, 
                           transferIndex, transferIndex - stride))

问题:CAS 的期望值可能已经过时,导致永远失败或错误成功

错误实现2:没有 volatile

java 复制代码
 private transient int transferIndex;  // 非volatile

问题

  • 线程间看不到最新值
  • 可能出现多个线程处理相同区间
  • 数据一致性被破坏

✅ 总结

nextIndex 局部变量的使用体现了 "读取-计算-比较交换" 的标准无锁编程模式:

  1. volatile 读取:获取共享状态的最新快照
  2. 本地计算:基于快照计算新值(无副作用)
  3. 原子更新:CAS 确保状态变更的原子性
  4. 失败重试:乐观锁的典型模式

这种设计确保了:

  • 🏃 高并发:多线程无锁协作
  • 🔒 线程安全:不会出现任务重叠或遗漏
  • 高性能:避免线程阻塞
  • 🎯 精确控制:任务段分配准确无误

这正是 ConcurrentHashMap 能够在高并发环境下保持高性能的关键技术之一!

等一下,volatile不是重点,volatile只是一个安全性的补充罢了,重点在于,这里需要一个快照进行CAS,才能实现乐观锁。所以直接上transferIndex,乐观锁就失效了,因为CAS永远通过......

位运算

在ConcurrentHashMap中,多处使用到位运算。

我不是科班出生,大学上的计算机基础又确实太基础了,当时也太傻。不过,仔细学习,感觉位运算实在很优雅。一直都很有趣,如果我时间多一点就好了,就可以再仔细钻研了。

首先回答一个问题:为什么补码是反码+1?就如同,为什么时针倒退两小时和时针正走十小时的效果一样。减去一个数,等于加上这个数距离那个数的距离。那个数,这里具体取决于有多少位,或者是232,或者是264,因为最高位是符号位。

二进制的原码、反码、补码 - 狂风吹我心的文章 - 知乎

我现在理解为什么补码是反码+1

问题1:补码为什么等于反码+1

这涉及到计算机中负数的表示方法,让我从数学原理和设计逻辑来解释:

数学原理

对于一个 n 位二进制系统:

  • 模数 = 2ⁿ
  • 补码的设计目标是:x + (-x) = 2ⁿ(在模运算下等于0)

推导过程

以 8 位系统为例(n=8,模数=256):

对于负数 -x(x>0):

  1. 反码 = (2ⁿ - 1) - x

    • 因为 2ⁿ - 1 是全 1(11111111)
    • 减去 x 就是按位取反
  2. 补码 = 2ⁿ - x

    • 根据补码定义:x + 补码 = 2ⁿ
  3. 关系推导

    ini 复制代码
     补码 = 2ⁿ - x
          = [(2ⁿ - 1) - x] + 1
          = 反码 + 1

实际例子

求 -5 的补码(8位):

  • +5 原码:00000101
  • 反码:11111010(按位取反)
  • 补码:11111011(反码+1)

验证:5 + (-5) = 00000101 + 11111011 = 100000000 = 256 ≡ 0 (mod 256)


问题2:tableSizeFor 函数解析

这个函数用于找到大于等于给定容量的最小2的幂

代码分析

arduino 复制代码
 private static final int tableSizeFor(int c) {
     int n = -1 >>> Integer.numberOfLeadingZeros(c - 1);
     return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
 }

算法步骤

  1. c - 1:处理当 c 本身就是2的幂的情况
  2. Integer.numberOfLeadingZeros(c - 1):计算前导零的个数
  3. -1 >>> ...:将全1的 -1 右移,得到掩码
  4. n + 1:得到最终的2的幂

举例说明

假设 c = 10:

ini 复制代码
 c = 10
 c - 1 = 9 (二进制: 00001001)
 前导零个数 = 28 (32位系统中)
 -1 >>> 28 = 00000000000000000000000000001111 (十进制15)
 n + 1 = 16

结果:16 是大于等于 10 的最小2的幂

更多例子

  • c=1 → 1
  • c=5 → 8
  • c=16 → 16
  • c=33 → 64

在 ConcurrentHashMap 中的作用

  1. 保证容量是2的幂 :便于使用位运算 hash & (n-1) 代替取模运算
  2. 提高性能:位运算比除法运算快得多
  3. 优化哈希分布:2的幂容量能更好地分散哈希冲突

这个巧妙的位运算技巧来自《Hacker's Delight》这本书,是经典的位操作算法。

源码

看源码还是有很多好处的,就比如说这里。

当然,回过头来,我们可以看一下,好了,现在就不优雅了。

ini 复制代码
 @IntrinsicCandidate
 public static int numberOfLeadingZeros(int i) {
     // HD, Count leading 0's
     if (i <= 0)
         return i == 0 ? 32 : 0;
     int n = 31;
     if (i >= 1 << 16) { n -= 16; i >>>= 16; }
     if (i >= 1 <<  8) { n -=  8; i >>>=  8; }
     if (i >= 1 <<  4) { n -=  4; i >>>=  4; }
     if (i >= 1 <<  2) { n -=  2; i >>>=  2; }
     return n - (i >>> 1);
 }

这个算法实在是太朴素了......

也太强大了!

先看前面有没有16个零,就先右移,右移之后n也-16

之后8个零

4个零

2个零

最后就判断,还有没有1,这里直接减去右移的数就可以了。

但是说起来,还有另外一种更粗暴(当然,也就是优雅)的实现,找到大于等于给定容量的最小2的幂

arduino 复制代码
 /**
  * Returns a power of two table size for the given desired capacity.
  * See Hackers Delight, sec 3.2
  */
 private static final int tableSizeFor(int c) {
     int n = c - 1;
     n |= n >>> 1;
     n |= n >>> 2;
     n |= n >>> 4;
     n |= n >>> 8;
     n |= n >>> 16;
     return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
 }

我也不懂哪一个更好。前导零的算法应该更通用一些,因为其他地方也用到了这个。

内存操作

java 复制代码
 @SuppressWarnings("unchecked")
 static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
     return (Node<K,V>)U.getReferenceAcquire(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.compareAndSetReference(tab, ((long)i << ASHIFT) + ABASE, c, v);
 }
 ​
 static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
     U.putReferenceRelease(tab, ((long)i << ASHIFT) + ABASE, v);
 }

这些方法是 ConcurrentHashMap 中用于直接内存操作的核心方法,通过 Unsafe 类实现高效的并发访问。让我详细解释 ASHIFT 和 ABASE 的计算原理:

方法作用

  • tabAt():原子性地读取数组元素
  • casTabAt():CAS 原子更新数组元素
  • setTabAt():原子性地设置数组元素

ASHIFT 和 ABASE 的计算

在 ConcurrentHashMap 的静态初始化块中:

ini 复制代码
 // 获取 Unsafe 实例
 U = sun.misc.Unsafe.getUnsafe();
 Class<?> ak = Node[].class;
 ​
 // 计算 ABASE:数组第一个元素的基准地址偏移量
 ABASE = U.arrayBaseOffset(ak);
 ​
 // 计算 ASHIFT:元素大小对数的移位量
 int scale = U.arrayIndexScale(ak); // 获取数组中每个元素的大小
 if ((scale & (scale - 1)) != 0)    // 检查是否是2的幂
     throw new Error("data type scale not a power of two");
 ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);

详细计算过程

1. ABASE (Array Base Offset)
ini 复制代码
 ABASE = U.arrayBaseOffset(Node[].class);
  • 作用 :获取 Node[] 数组对象中第一个元素的内存偏移地址
  • :通常是数组对象头的大小(在64位JVM中约16-24字节)
2. ASHIFT (Array Shift)
ini 复制代码
 int scale = U.arrayIndexScale(Node[].class);  // 返回每个Node引用的大小
 // 在64位JVM中,开启压缩指针时scale=4,关闭时scale=8
 ​
 ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);

计算原理

  • Integer.numberOfLeadingZeros(scale) 计算前导零个数
  • 31 - ... 得到以2为底的对数

内存地址计算

arduino 复制代码
 ((long)i << ASHIFT) + ABASE

计算过程

  1. (long)i << ASHIFT = i * scale(通过移位实现乘法)
  2. + ABASE = 加上数组基准偏移量

举例(scale=4, ABASE=16):

  • i=0: (0 << 2) + 16 = 16(第0个元素地址)
  • i=1: (1 << 2) + 16 = 20(第1个元素地址)
  • i=2: (2 << 2) + 16 = 24(第2个元素地址)

为什么这样设计?

性能优势

  1. 避免边界检查:直接内存操作跳过数组边界检查
  2. 原子性保证:通过CPU原语实现真正的原子操作
  3. 内存屏障控制:精确控制内存可见性

内存屏障语义

  • getReferenceAcquire:获取操作,具有acquire语义
  • putReferenceRelease:设置操作,具有release语义
  • compareAndSetReference:CAS操作,具有完整的原子性

与普通数组访问对比

ini 复制代码
 // 普通访问(有边界检查)
 Node<K,V> node = tab[i];
 ​
 // Unsafe直接访问(无边界检查,有内存屏障)
 Node<K,V> node = (Node<K,V>)U.getReferenceAcquire(tab, ((long)i << ASHIFT) + ABASE);

这种设计是 ConcurrentHashMap 实现高并发性能的关键技术之一,通过直接内存操作和精确的内存屏障控制,在保证线程安全的同时达到接近原生数组的性能。

spread和哈希

ConcurrentHashMapspread方法中,使用了无符号右移操作符>>>而不是有符号右移操作符>>。这是因为>>>>>在处理负数时的行为不同,而spread方法需要确保生成的哈希值始终是非负的,以避免在哈希表中产生负索引。

有符号右移>>和无符号右移>>>的区别

• 有符号右移>>

• 有符号右移操作符>>会保留符号位(最高位),即在右移时,左边补符号位。

• 对于正数,>>>>>的行为相同。

• 对于负数,>>会在左边补 1,结果仍然是负数。

• 无符号右移>>>

• 无符号右移操作符>>>会忽略符号位,即在右移时,左边补 0。

• 对于正数,>>>的行为与>>相同。

• 对于负数,>>>会在左边补 0,结果变为正数。

spread方法的实现

arduino 复制代码
 static final int spread(int h) {
     return (h ^ (h >>> 16)) & HASH_BITS;
 }

作用

h ^ (h >>> 16)

• 将哈希值h与其右移 16 位后的值进行异或操作。右移 16 位可以将高 16 位的值移到低 16 位,这样可以更好地混合哈希值的高位和低位,减少哈希冲突。

• 使用>>>确保右移后的值是非负的,即使h是负数,右移后也会变成正数。

& HASH_BITS

• 将混合后的哈希值与HASH_BITS进行按位与操作,确保结果在哈希表的有效索引范围内。

HASH_BITS通常是2^n - 1,用于将哈希值映射到哈希表的索引范围内。

为什么使用>>>而不是>> 假设h是一个负数,例如h = -1(其二进制表示为0xFFFFFFFF):

• 使用>>

ini 复制代码
    int result = (h ^ (h >> 16)) & HASH_BITS;

h >> 16的结果是-1(二进制表示为0xFFFFFFFF)。

h ^ (h >> 16)的结果是0(二进制表示为0x00000000)。

0 & HASH_BITS的结果是0

• 使用>>>

ini 复制代码
    int result = (h ^ (h >>> 16)) & HASH_BITS;

h >>> 16的结果是65535(二进制表示为0x0000FFFF)。

h ^ (h >>> 16)的结果是4294901760(二进制表示为0xFFFF0000)。

4294901760 & HASH_BITS的结果是一个非负的哈希值。

总结 使用>>>而不是>>的原因是:

• 确保结果始终是非负的:>>>会将负数右移后变成正数,避免生成负的哈希值。

• 更好的哈希混合:通过将高 16 位的值移到低 16 位,更好地混合哈希值的高位和低位,减少哈希冲突。

因此,使用>>>可以确保spread方法生成的哈希值始终是非负的,并且具有更好的分布特性。

if ((eh = e.hash) == h)

ConcurrentHashMap的实现中,if ((eh = e.hash) == h)是一个关键的检查步骤,用于快速判断两个节点是否可能相等。这个检查的目的是减少不必要的对象比较操作,提高性能。具体来说,if ((eh = e.hash) == h)检查的是两个节点的哈希值是否相等。如果哈希值不相等,那么这两个节点一定不相等,可以直接跳过后续的比较。

接下来的if ((ek = e.key) == key || (ek != null && key.equals(ek)))是一个更严格的检查,用于确定两个节点的键是否真正相等。这里使用了||(逻辑或)运算符,原因如下:

1.直接引用比较

ini 复制代码
 (ek = e.key) == key

• 作用:首先检查两个键的引用是否相同。

• 原因:如果两个键的引用相同,那么它们一定相等。这种比较非常快速,因为它直接比较了两个对象的内存地址。

• 效率:引用比较非常高效,因为它避免了调用equals方法,减少了不必要的对象比较操作。

2.内容比较

csharp 复制代码
 (ek != null && key.equals(ek))

• 作用:如果两个键的引用不同,但键的值可能相等,需要进一步检查它们的内容是否相等。

• 原因:即使两个键的引用不同,它们的内容也可能相同。例如,两个不同的String对象可能包含相同的字符序列。

• 效率:内容比较相对更耗时,因为它需要调用equals方法,这可能涉及复杂的逻辑,如字符串比较。

为什么使用||运算符

• 短路逻辑:||是一个短路逻辑运算符,即如果第一个条件为true,则不会执行第二个条件。这可以提高效率,因为如果引用比较已经确定两个键相等,就没有必要再进行内容比较。

• 确保准确性:通过结合引用比较和内容比较,可以确保两个键是否真正相等。这在处理哈希表时非常重要,因为哈希表依赖于键的唯一性来存储和检索值。

代码解析

ini 复制代码
 if ((eh = e.hash) == h) {
     if ((ek = e.key) == key || (ek != null && key.equals(ek)))
         return e.val;
 }

• 哈希值比较:

if ((eh = e.hash) == h):检查当前节点的哈希值e.hash是否与目标哈希值h相等。

• 如果哈希值不相等,直接跳过后续的键比较,因为哈希值不同,键一定不同。

• 键比较:

if ((ek = e.key) == key):首先检查两个键的引用是否相同。

• 如果引用相同,直接返回当前节点的值e.val,因为键一定相等。

• 如果引用不同,进一步检查内容是否相等:

ek != null && key.equals(ek):确保ek不为null,然后调用equals方法比较两个键的内容。

• 如果内容相等,返回当前节点的值e.val

总结 使用||运算符的原因是:

• 提高效率:通过短路逻辑,避免不必要的内容比较。

• 确保准确性:结合引用比较和内容比较,确保两个键是否真正相等。

这种设计在ConcurrentHashMap中非常常见,因为它在保证线程安全的同时,也优化了性能。

eh<0

ConcurrentHashMap的实现中,else if (eh < 0)这一行代码处理的是特殊情况,即当前节点的哈希值eh是负数。这种情况通常发生在使用了特殊的节点类型,如树化节点(TreeNodes)或转发节点(ForwardingNodes)时。这些节点的哈希值被设置为负数,以区分普通节点。

1.背景知识 在ConcurrentHashMap中,节点的哈希值通常是非负的。但是,为了支持一些特殊功能,如树化(将链表转换为红黑树)和并发控制,某些节点的哈希值被设置为负数。具体来说:

• 树化节点(TreeNodes):当链表长度超过一定阈值时,链表会被转换为红黑树,以提高查找效率。树化节点的哈希值通常设置为负数。

• 转发节点(ForwardingNodes):在并发操作中,某些节点可能被用于转发操作,这些节点的哈希值也设置为负数。

2.代码解析

kotlin 复制代码
 else if (eh < 0)
     return (p = e.find(h, key)) != null ? p.val : null;

2.1 条件检查

arduino 复制代码
 else if (eh < 0)

eh < 0:检查当前节点的哈希值是否为负数。如果是负数,说明当前节点是一个特殊节点,如树化节点或转发节点。

2.2 调用find方法

yaml 复制代码
 (p = e.find(h, key)) != null ? p.val : null;

e.find(h, key):调用当前节点的find方法,传入目标哈希值h和键key,尝试找到匹配的节点。

find方法的具体实现取决于节点的类型。对于树化节点,find方法会在红黑树中查找匹配的节点;对于转发节点,find方法会处理转发逻辑。

p != null:检查find方法返回的节点是否为null

• 如果返回的节点不为null,说明找到了匹配的节点,返回该节点的值p.val

• 如果返回的节点为null,说明没有找到匹配的节点,返回null

3.具体场景

3.1 树化节点 当链表长度超过一定阈值时,链表会被转换为红黑树。树化节点的哈希值通常设置为负数。在这种情况下,find方法会在红黑树中查找匹配的节点。

3.2 转发节点 在并发操作中,某些节点可能被用于转发操作,这些节点的哈希值也设置为负数。转发节点通常用于处理并发更新操作,如扩容或迁移。

4.为什么需要特殊处理

• 树化节点:红黑树的查找效率比链表高,尤其是在链表长度较长时。通过将链表转换为红黑树,可以显著提高查找效率。

• 转发节点:转发节点用于处理并发操作中的复杂情况,如扩容或迁移。通过使用转发节点,可以确保在并发操作中保持线程安全和高效。

5.总结 这段代码的作用是处理哈希值为负数的特殊节点。具体步骤如下:

• 检查当前节点的哈希值是否为负数。

• 如果是负数,调用find方法在特殊节点中查找匹配的节点。

• 如果找到匹配的节点,返回该节点的值;否则返回null

这种设计确保了ConcurrentHashMap在处理特殊节点时的高效性和线程安全性。

相关推荐
我是华为OD~HR~栗栗呀3 小时前
华为OD-23届考研-Java面经
java·c++·后端·python·华为od·华为·面试
shepherd1113 小时前
⏰ 一招鲜吃遍天!详解Java延时队列DelayQueue,从此延时任务不再难!
java·后端·消息队列
brzhang3 小时前
我用 Flutter 做了个小游戏,结果发现这玩意有点意思
前端·后端·架构
brzhang3 小时前
用 Vue 写原生 iOS/Android 应用,我发现这个库比 uni 要轻量且靠谱些
前端·后端·架构
这里有鱼汤3 小时前
3步用Python识别MACD背驰,避免80%追涨杀跌陷阱,建议收藏
后端·python
武子康3 小时前
大数据-125 - Flink 实时流计算中的动态逻辑更新:广播状态(Broadcast State)全解析
大数据·后端·flink
Mintopia3 小时前
🧠 Next.js 文件上传(头像 / 图片)终极指南
前端·后端·全栈
程序员爱钓鱼3 小时前
Python编程实战 · 基础入门篇 | Python能做什么
后端·python·github
xyy1233 小时前
.NET SQLite
后端