HashMap的原理以及优化历程和ConcurrentHashMap

169 阅读18分钟

HashMap

HashMap作为java中适用频率最高的集合之一,本文结合JDK1.7与1.8来详细介绍一下HashMap的原理以及JDK1.8以后的优化

一.结构

HashMap的本质是基于哈希表的键值对映射的一个容器,key为索引,通过哈希算法转化数组的下标,再通过冲突处理来解决下标重复,帮助我们实现key和value的一个精准绑定。而用一个术语“桶”来描述,我们可以把HashMap的底层数组数组看做一个数组桶,每一个下标对应一个独立的桶,通过计算后将键值对落入相应的桶中存储。

结合JDK1.7的源码可以看出,他的实现依赖于Entry数组+单向链表,数组用来定位链表用来处理哈希冲突

     // JDK1.7 Entry数组作为底层存储(桶数组)
    transient Entry<K,V>[] table;

    // 核心内部类Entry用来封装键值对与链表指针
    static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next; // 仅支持单向链表,指向后续冲突节点
    int hash;

    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n; // 头插法新节点先指向原链表头节点
        key = k;
        hash = h;
        }
     }

而JDK1.8引入了红黑树,从源码来看,当链表长度达到阈值(默认8)且数组长度≥64时,链表自动转为红黑树;当红黑树节点数≤6时,反向转为链表。


    // 切换核心阈值定义
    static final int TREEIFY_THRESHOLD = 8;    // 链表转红黑树阈值:长度≥8触发判断
    static final int UNTREEIFY_THRESHOLD = 6;  // 红黑树转链表阈值:节点数≤6触发转换
    static final int MIN_TREEIFY_CAPACITY = 64;// 树化最小数组容量:避免小容量数组树化浪费资源

    // 链表转红黑树(树化)实现
    // 触发入口:putVal方法中判断链表长度,调用treeifyBin尝试树化
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null) {
            tab[i] = newNode(hash, key, value, null);
        } else {
            Node<K,V> e; K k;
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                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;
                }
            }
            // 这里省略一下值覆盖和扩容判断逻辑
        }
        return null;
    }

    // 树化核心校验数组容量然后完成链表节点转红黑树并平衡
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // 数组长度不足64时优先扩容,而非树化
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                // 普通Node转为红黑树TreeNode
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            // 将红黑树挂载到对应桶,执行平衡调整
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

    // 通过replacementTreeNode将普通节点node转换成树节点TreeNode
    TreeNode<K,V> replacementTreeNode(Node&lt;K,V&gt; p, Node&lt;K,V&gt; next) {
        return new TreeNode<>(p.hash, p.key, p.value, next);
    }

    // 红黑树转链心实现
    final void removeTreeNode(Node<K,V>[] tab, boolean movable) {
        int n;
        if (tab == null || (n = tab.length) == 0)
            return;
        int index = (n - 1) & hash;
        TreeNode<K,V> first = (TreeNode<K,V>)tab[index], root = first, rl;
        TreeNode<K,V> succ = (TreeNode<K,V>)next, pred = prev;
        // 节点数≤6时触发反树化(这里省略一下删除逻辑)
        if (root == null || root.right == null ||
            (rl = root.left) == null || rl.left == null) {
            tab[index] = first.untreeify(this); // 执行反树化
            return;
        }
       
    }

    // TreeNode转为普通Node,构建单向链表
    Node<K,V> untreeify(HashMap<K,V> map) {
        Node<K,V> hd = null, tl = null;
        for (Node<K,V> q = this; q != null; q = q.next) {
            Node<K,V> p = map.newNode(q.hash, q.key, q.value, null);
            if (tl == null)
                hd = p;
            else
                tl.next = p;
            tl = p;
        }
        return hd;
    }

    // 最后红黑树节点定义
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent; // 红黑树父节点
        TreeNode<K,V> left;   // 左子节点
        TreeNode<K,V> right;  // 右子节点
        TreeNode<K,V> prev;   // 链表前驱节点
        boolean red;          // 红黑树颜色标记平衡调整
    }
}

而红黑树是一种自平衡二叉查找树,通过严格的颜色规则保证树的高度始终维持在O(log n)级别,所以它的查找、插入、删除操作时间复杂度均为O(log n),且性能稳定不随数据量波动,而链表遇到大量key哈希冲突时,某一桶内的链表会持续变长,一批key经哈希计算后落入同一桶,链表长度达到几十甚至上百,此时查询该桶内元素需遍历整个链表,时间复杂度从平均O(1)退化至O(n),导致接口响应超时和服务性能瓶颈的问题。但是创建红黑树的开销成本大于链表,在低冲突场景(既链表长度小于8反之则为高),用链表来处理明显是优于红黑树的。所以JDK1.8中HashMap不但引入了红黑树还保留了链表进行动态切换,不但解决了JDK1.7在面临大量数据的极端情况下的性能和稳定性短板,同时还能进行相应的节省开销。

二.扩容机制

扩容(resize)是HashMap维持哈希分布均匀性、避免冲突加剧的核心机制。其本质是想通过将桶数组容量翻倍(始终为2的幂),从而减少单个桶的元素数量提升操作效率。

在JDK1.7时扩容逻辑拆分为两个独立方法,职责分离但存在冗余开销:

  • resize():仅负责初始化或扩容桶数组分配新容量和新阈值不处理节点迁移
  • transfer():单独负责将旧数组中的节点迁移到新数组

    // JDK1.7 仅扩容数组,不迁移节点
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        // 省略容量校验、阈值计算逻辑
        Entry[] newTable = new Entry[newCapacity];
        table = newTable;
        if (oldTable != null) {
            transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 调用transfer迁移节点
        }
    }

    // JDK1.7 单独的节点迁移方法
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) { // 遍历旧桶数组
            while (null != e) {
                Entry<K,V> next = e.next;
                if (rehash) { // 按需重算哈希
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity); // 计算新下标
                e.next = newTable[i]; // 头插法迁移
                newTable[i] = e;
                e = next;
            }
        }
    }

而JDK1.8取消transfer()方法,将数组扩容、阈值计算、节点迁移整合进resize(),形成一个单一入口来闭环执行的逻辑,从而减少方法调用开销。


     // JDK1.8 resize包含“扩容+阈值计算+节点迁移”
    final Node<K,V>[] resize() {
      Node<K,V>[] oldTab = table;
     // 旧数组容量若未初始化则为0
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
      // 旧阈值扩容触发条件
         int oldThr = threshold;
         int newCap, newThr = 0;

    // 计算新容量和新阈值(针对已初始化的数组)
    if (oldCap > 0) {
        // 若旧容量已达最大值,阈值设为Integer.MAX_VALUE,不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 容量翻倍,阈值同步翻倍,且不超过最大容量
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) {
            newThr = oldThr << 1;
        }
    }
    // 对初始化时指定了阈值的场景(new HashMap(int initialCapacity))
    else if (oldThr > 0) {
        newCap = oldThr;
    }
    // 无参构造初始化(默认容量16,默认阈值16*0.75=12)
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    // 校正新阈值(对初始化指定了阀值场景,计算阈值=新容量×负载因子)
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY) ?
                (int)ft : Integer.MAX_VALUE;
    }
    // 更新全局阈值
    threshold = newThr;

    // 创建新桶数组,执行节点迁移
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    // 迁移旧数组节点到新数组
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 1.桶内仅单个节点,直接计算新下标挂载
                if (e.next == null) {
                    newTab[e.hash & (newCap - 1)] = e;
                }
                // 2.桶内是红黑树,拆分后迁移
                else if (e instanceof TreeNode) {
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                }
                // 3.桶内是链表,尾插法迁移+位运算定位新位置
                else {
                    Node<K,V> loHead = null, loTail = null; // 原下标链表
                    Node<K,V> hiHead = null, hiTail = null; // 新下标(j+oldCap)链表
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 通过位运算判断新位置,无需重算哈希
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null) loHead = e;
                            else loTail.next = e; // 尾插法保持顺序
                            loTail = e;
                        } else {
                            if (hiTail == null) hiHead = e;
                            else hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 挂载链表到新桶
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
    }

当满足扩容触发的条件(1.当头HashMap未初始化首次put时;2.已初始化HashMap且插入不覆盖旧值的新节点,会进入if判断里,满足++size > threshold且桶位不为空时);对旧数组和它容量阈值暂存到全局变量中,同时声明新数组和新阈值,接着判断一下数组是否初始化(容量大于0),然后进入判断若旧容量≥最大值(oldCap >= MAXIMUM_CAPACITY),则无法再进行扩容,就将全局阈值设为整型最大值(避免再次触发扩容检查),而旧容量<最大值时,会进行左移一位的位运算(可以看做×2),对旧容量翻倍,且确保翻倍后的新容量不超过HashMap允许的最大值(最大值定义为1 << 30,约10亿)和当旧容量 ≥ 16 时,才同步将阈值翻倍;接着处理未初始化数组的两种场景(指定初始容量和无参构造);针对指定初始容量的场景,计算它的阈值(新容量 × 负载因子),判断阈值是否超过最大值若超过则取整反之则设为整型最大值,再把新阈值更新到全局变量;接着创建新桶数组将全局的哈希表数组替换为新数组;遍历旧数组的所有桶位,取出节点并释放旧数组引用;再根据场景判断,若桶位只有一个节点,则计算新索引,再将节点放入新数组对应位置(e为头节点指向下一个节点为空则看作单节点)。第二个场景判断出红黑数的节点,再通过split将树拆分为原索引和原索引 + 旧容量两个链表,若拆分后链表长度 > 6 则重新树化,≤6 则退化为普通链表,接着再挂载到新数组。最后一个场景定义两个链表的头尾指针,尾插法迁移再根据位运算定位新位置

三.节点迁移

从上面的源码中我们知道JDK1.7采用的是头插法(迁移时新节点始终插入新桶链表头部)来迁移节点,而这种方法我们也可以知道它迁移时可能需重算所有节点哈希值,这样会让CPU的消耗增加等问题。而JDK1.8通过loTail/hiTail记录链表尾部,按原链表顺序插入新桶,能避免链表反转和并发成环。同时无需重算哈希值,通过e.hash & oldCap就可以快速判断节点新位置让HashMap变的更加高效安全。

四.总结HashMapJDK 1.7 到 JDK 1.8 的变化和补充

在HashMap中数组的长度始终为2的幂次(默认16),这样设计有几点,首先在HashMap中一个键值对最终要放到数组的哪个位置主要是根据 哈希值 % 数组长度来计算出索引,当数组长度 length 是 2 的幂次时,hash % length = hash & (length - 1)既可以用位运算来代替取模运算,而位运算的效率是高于取模运算的;其次HashMap 扩容时,新容量是旧容量的 2 倍(也是 2 的幂次)此时可通过e.hash & oldCap 判断新索引,无需重新计算 hash & (newCap-1)就提升了我们扩容时的节点迁移效率。

而上述说负载因子(loadFactor),其核心是想平衡HashMap的空间利用率和时间效率,而默认为0.75, 在HashMap的源码中提到,它是基于的统计分析泊松分布当加载因子为 0.75 时哈希桶中节点数量的数学期望是 0.5(绝大多数桶位只有 0 或 1 个节点),这样能使发生哈希冲突的概率极低,同时来看当加载因子为其他的情况,所以该默认值为0.75不但符合数学统计还满足HashMap高效操作和合理空间利用的思想

加载因子取值空间利用率哈希冲突概率扩容频率操作效率(get/put)
偏大(如 1.0)高(数组装满才扩容)极高(链表 / 红黑树变长)
偏小(如 0.5)低(数组装一半就扩容)极低高(保持 O (1))
0.75(默认)适中极低适中适中

1.19.png

五.优化历史

在上述了解到了JDK1.7以及HashMap最重要的一个版本JDK1.8,在JDK1.8后HashMap到如今也没有对结构进行大的动更,可以理解成都是在优化JDK1.8,

<1>在JDK1.8u中,对1.8 正式版 treeifyBin() 方法中,仅判断链表长度≥8 就尝试树化,未严格校验数组容量是否≥MIN_TREEIFY_CAPACITY(64)在某些极端场景下可能会发生链表长度达标但数组容量不足,树化失败却未触发扩容的异常,增加了tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)的校验。

<2>在JDK9中,新增了Map构造(Map.of()/map.ofEntries()),他的本质是基于哈希逻辑衍生出的轻量不可变实现(这些Map创建后不可修改,所以不需要支持动态扩容和操作数据等)。

<3>JDK11,JDK11为LTS版本,优化了rotateLeft()/rotateRight()方法,合并重复的节点状态判断,减少选择次数,减少JDK1.8对于红黑树插入和删除时旋转逻辑的冗余判断,同时在迭代器遍历优化上,JDK11通过提前缓存桶长度位置和简化节点判断逻辑,减少迭代器的无效检验,提升效率

<4>在JDK17中,对内存占用做了深度的优化,且同时适配JDK9紧凑字符串的新特性,在JDK9中引入紧凑字符串后String 底层存储从 char [] 改为 byte []+ 编码标识,使得内存的占用大幅降低,而JDK17的HashMap因为hash()方法依赖String的hashCode() 所以无需修改核心逻辑就能通过适配String的内存布局来减少String作为Key时的内存开销;且在JDK1.8中每次创建空HashMap都会新建空数组,而JDK17新增了静态空HashMap实例,通过EMPTY_TABLE 复用,当创建空 HashMap 且无初始容量指定时,直接返回复用实例避免我们频繁的创建重复的空数组;最后JDK17还针对超大容量的场景下,JDK1.8中threshold = (int)(newCap * loadFactor)会因数值溢出导致阈值为负数的问题补充了溢出校验newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE;

<5>JDK21(最新),JDK21简化退化判断的条件,同时优化节点遍历链路,让红黑树在退化的时候可以更加快速;还优化了HashMap节点的内存访问顺序,减少缓存行的争用。还新增了一个批量插入的优化,在JDK1.8中putAll()是逐一插入键值对,而每次插入又会检查一下,是否size > threshold,而JDK21通过预先计算插入所需的总容量,调用ensureCapacityInternal()方法提前完成一次扩容(按需),如下,减少插入时的扩容次数和检查

if (m.size() > 0) { 
ensureCapacityInternal(size + m.size()); // 预计算总容量 
for (Map.Entry<K,V> e : m.entrySet()) { 
putVal(e.getKey(), e.getValue(), false); 
    }
}

扩展ConcurrentHashMap

我们可以看到HashMap不管是1.7还是1.8其都不支持并发操作,因为HashMap的设计在于单线程高性能的特点,即使在JDK1.8中由于HashMap的put,resize等都包含多步非原子操作,而又没对其进行加锁,所以在多个线程同时操作的时会导致数据不一致的问题。而如果需要并发安全的哈希表,JAVA也为我们提供了方法ConcurrentHashMap。它的核心设计思路,是在保留哈希表高性能特性的前提下,通过精细化对锁控制加原子操作和可见性保障,针对性解决 HashMap 的并发缺陷。而在此主要介绍1.8版本

  • 1.我们看底层核心结构的源码定义及配套工具方法

// volatile作为核心存储数组修饰确保线程间可见性,transient 避免序列化
transient volatile Node<K,V>[] table;

// 仅在扩容时非空,volatile 修饰保证迁移过程中线程可见
private transient volatile Node<K,V>[] nextTable;

// 兼具初始化标记、扩容阈值、扩容状态多重作用
// 负数表示正在初始化/扩容(-1 为初始化中,-N 为 N 个线程正在扩容),正数为下次扩容阈值
private transient volatile int sizeCtl;

// 控制多线程迁移的桶范围,volatile 修饰确保分工同步
private transient volatile int transferIndex;

// 工具类
private static final sun.misc.Unsafe U;
private static final long SIZECTL;
private static final long TRANSFERINDEX;
private static final long TABLE;
private static final long NEXTTABLE;

// 静态代码块初始化 Unsafe 及各变量的内存偏移量
static {
    try {
        U = sun.misc.Unsafe.getUnsafe();
        Class<?> k = ConcurrentHashMap.class;
        SIZECTL = U.objectFieldOffset(k.getDeclaredField("sizeCtl"));
        TRANSFERINDEX = U.objectFieldOffset(k.getDeclaredField("transferIndex"));
        TABLE = U.objectFieldOffset(k.getDeclaredField("table"));
        NEXTTABLE = U.objectFieldOffset(k.getDeclaredField("nextTable"));
    } catch (Exception e) {
        throw new Error(e);
    }
}

// 原子读取 table 数组指定位置的节点,避免指令重排与可见性问题
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

// 原子设置 table 数组指定位置的节点,确保操作不可中断
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

// 强制写入 table 数组指定位置的节点,保证可见性
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

table 数组作为主存储容器volatile 修饰让它修改能被其他线程立即感知,这与 HashMap 中无 volatile 修饰的 table 数组形成本质区别,在HashMap中线程修改 table 后,其他线程可能因缓存一致性问题无法读取最新值而ConcurrentHashMap通过 volatile 与 Unsafe 结合解决这个问题

  • 2.CAS 无锁插入与桶级别的同步锁

    ConcurrentHashMap的put 方法是其并发安全的核心体现它的整个流程既避免了全局锁的性能损耗,又保证了操作的原子性。

    
    private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    // 循环判断 table 是否为空,确保初始化完成
    while ((tab = table) == null || tab.length == 0) {
      // SIZECTL 小于 0 表示已有线程在初始化,当前线程自旋等待
      if ((sc = sizeCtl) < 0)
          Thread.yield(); // 让出 CPU 资源,避免忙等
      // CAS 操作将 SIZECTL 设为 -1,抢占初始化权限
      else if (U.compareAndSetInt(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;
                  // 计算下次扩容阈值,默认为容量的 0.75 倍
                  sc = n - (n >>> 2);
              }
          } finally {
              // 初始化完成,恢复 SIZECTL 为阈值
              sizeCtl = sc;
          }
          break;
      }
    }
    return tab;
    }
    

    通过其源码可看出核心是 CAS 抢占机制:当多个线程同时进入 initTable 方法时,只有一个线程能通过 U.compareAndSetInt 将 SIZECTL 设为 -1,其他线程检测到 SIZECTL 小于 0 后会自旋等待,就避免了多线程初始化导致的数组异常。其中 U 是 Unsafe 工具类实例,负责底层的 CAS 操作,这也是 ConcurrentHashMap 原子操作的核心依赖。其实现为当桶位置为空时,ConcurrentHashMap 并未直接赋值,而是通过 CAS 操作尝试插入节点。casTabAt 方法底层调用 UNSAFE 的 compareAndSwapObject 方法,仅当桶位置为空时才插入节点,确保这一步操作的原子性,来避免HashMap 中“空桶插入覆盖”的问题。

  • 3并发安全的分步迁移

ConcurrentHashMap 的扩容(resize)过程同样支持并发,分段迁移与锁保护迁移。其核心为transferIndex的迁移范围控制变量,线程通过CAS操作抢占一段连续的桶,再从后往前逐步迁移,同时对迁移的桶加锁,迁移完成后将原桶的节点换成迁移标记节点(ForwardingNode),其他线程看到该节点后,直接操作新数值或协助迁移,让多个线程协同扩容,避免混乱的同时还提升我们的效率。


private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // 计算每个线程负责的桶数量,最小为 16
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // 64
    // 新数组未初始化则创建
    if (nextTab == null) {
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) { 
            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; // 标记是否所有桶都迁移完成
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        // 循环分配迁移桶范围
        while (advance) {
            int nextIndex, nextBound;
            if (--i < bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            // CAS 操作抢占迁移范围,从后往前分配
            else if (U.compareAndSetInt(this, TRANSFERINDEX, nextIndex,
                                         nextBound = Math.max(nextIndex - stride, 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        // 省略迁移完成的判断逻辑...
        if (tabAt(tab, i) == f) {
            if (fh < 0) {
                // 遇到迁移标记节点,推进迁移进度
                advance = (fwd == f) && finishing;
            }
            else {
                // 对当前桶加锁,开始迁移
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        Node<K,V> ln, hn;
                        // 链表节点迁移,拆分到新数组的两个桶中
                        if (fh >= 0) {
                            // 将拆分后的链表放入新数组
                            setTabAt(nextTab, i, ln);
                            setTabAt(nextTab, i + n, hn);
                            // 标记当前桶迁移完成,替换为迁移节点
                            setTabAt(tab, i, fwd);
                            advance = true;
                        }
                    }
                }
            }
        }
        // 省略其他迁移逻辑...
    }
}
最后

HashMap追求单线程下的极致性能,而 ConcurrentHashMap 则在性能与并发安全之间寻求平衡,从源码上看ConcurrentHashMap在HashMap的基础上增加精准的并发控制,补足了HashMap在多线程场景下的短板。