☰
JDK 8 ConcurrentHashMap源码深度解析:从CAS到多线程扩容
2026/10/15 17:49:08 网站建设 项目流程

如果你写过多线程环境下的缓存或者全局Map,大概见过这类让人头皮发麻的现象:某个接口的CPU瞬间打满,jstack抓下来,大量线程卡在java.util.HashMap的resize()或者get()方法里,线程栈反复滚动却不退出。这种事故在并发社区里被讨论过无数次,根子只有一个——把非线程安全的HashMap当成共享缓存用。ConcurrentHashMap就是Java官方给出的并发答案,也是面试中绕不开的高频考点。

这篇文章我打算贴着JDK 8的源码,把这几个核心问题讲透:JDK 8版本的底层数据结构长什么样、put方法是如何做到并发安全的、get凭什么可以不加锁、size()背后的LongAdder式计数器怎么工作,以及扩容时多线程如何协作搬砖。适合正在准备技术面试的Java工程师,也适合想彻底理解并发容器设计思路的开发者。我会尽量用"拆解源码"的方式来讲,不是为了让你背代码,而是让你看懂每一行设计背后的为什么。

1. 先看HashMap在并发环境下的灾难现场:理解ConcurrentHashMap存在的理由

1.1 经典事故一:resize()导致死循环与CPU飙高

HashMap在并发场景下最著名的事故发生在JDK 7。JDK 7的扩容迁移链表时使用头插法:新节点总是插在链表头部,这种反转链表的操作在单线程下没问题,但并发环境下,两个线程同时触发resize并操作同一条链表时,就可能让链表的next引用形成环。

环形链表一旦形成,get方法查询一个不存在的key时,会沿着环无限循环。从jstack里看到的画面就是大量线程卡在HashMap.get()或者HashMap.resize()上,负载直线上升。很多人以为这种情况只有在老版本才出现,其实JDK 8把插入方式改成了尾插法,虽从根本上消除了环形链表的形成条件,但并发安全问题依旧存在——数据覆盖、元素丢失一样不少。

1.2 经典事故二:put时数据覆盖与静默丢失

JDK 8的putVal里有一个经典竞态点:两个线程同时发现某个槽位为null,并且都通过了if (p = tab[i]) == null的判断。接下来两个线程都会创建新的Node对象,后写入的线程直接覆盖前一个线程的结果。整个过程没有任何异常,数据却悄悄丢了。

另一个常见隐患是size字段的统计不准确。JDK 7的HashMap在put成功后对size做++size操作,并不是线程安全的原子自增,多线程并发put时,最终统计值可能比真实值偏差很多。这些问题的本质都是:HashMap内部共享的可变状态缺乏同步保护,且每个方法都不是原子操作。

1.3 Hashtable和同步包装类的局限:锁太大了

HashMap不安全,那用Hashtable或者Collections.synchronizedMap(new HashMap<>())不就行了?这两种方案在功能上确实线程安全,但问题也很直观——所有方法都加了synchronized,锁的是整个Map对象。哪怕两个线程在操作完全不相干的桶,也必须排队竞争同一把锁。

读多写少场景还能忍,一旦进入写密集场景,锁竞争就成了压倒性瓶颈。生产环境里,这类容器并发性能上不去,根本原因不是锁的效率低,而是锁的粒度太粗。所以后来设计并发容器时,核心思路就变成了:把一把大锁拆成多把小锁,让不同线程尽量操作不同的锁。

1.4 JDK 7的分段锁思路与JDK 8的彻底重构

JDK 7的ConcurrentHashMap采用分段锁设计:内部维护一个Segment数组,每个Segment继承自ReentrantLock,持有自己的HashEntry数组。默认有16个Segment,也就是理论并发度16。两个线程操作不同Segment时互不干扰,只有命中同一个Segment才需要竞争锁。

这个设计在当时已经很先进,但问题也有三个:一是Segment粒度还是太大,一个Segment下面挂着一批桶;二是Segment数组初始化后不能调整数量,并发度上限写死;三是ReentrantLock在线程竞争时的用户态/内核态切换成本偏高。

JDK 8把整套方案推翻重来:放弃Segment,改用CAS + synchronized的组合,锁粒度细到"桶"级别;数据结构从"数组+链表"升级为"数组+链表+红黑树";扩容也从单线程迁移变成了多线程协作迁移。这些变化不是局部优化,而是设计思路的整体切换,后面所有源码分析都建立在这个基础上。

2. JDK 8底层数据结构拆解:Node、TreeNode、ForwardingNode与sizeCtl的密码

2.1 先认识核心字段:volatile和Unsafe缺一不可

// 主干数组,volatile保证引用可见性 transient volatile Node<K,V>[] table; // 扩容时使用的新表 private transient volatile Node<K,V>[] nextTable; // 计数器的低层基础值 private transient volatile long baseCount; // 多语义字段:初始化/扩容的控制开关 private transient volatile int sizeCtl; // 扩容时的任务分配索引 private transient volatile int transferIndex; // LongAdder式计数单元数组 private transient volatile CounterCell[] counterCells;

这里有个很容易被忽略的细节:table数组本身是volatile的,但数组元素不是。Java原生数组的元素不具备volatile语义,所以コード直接读写tab[i]是存在可见性问题。ConcurrentHashMap的解法是借助Unsafe类,通过tabAt、casTabAt、setTabAt这三个方法对数组元素做volatile读写和CAS操作。理解这一点,再看后续代码就不会对"为什么不直接写tab[i]"产生疑惑。

2.2 Node、TreeNode与TreeBin:三条分支对应数据结构变化

常规的Node是链表节点,字段next和val都声明为volatile。当链表过长,ConcurrentHashMap会把链表转成红黑树,但红黑树的节点不是直接用TreeNode,而是由TreeBin包裹。TreeBin自带一个简化版的读写协调机制:写操作需要给lockState加锁计数,读操作不需要阻塞,而是走红黑树查找。

putVal判断逻辑里有一行f instanceof TreeBin,就是用来区分当前桶是链表还是红黑树。普通节点hash都是大于等于0的,而TreeBin和ForwardingNode的hash都是负数(分别是-2和-1),所以代码里经常用fh >= 0快速判断"这是不是一条普通链表"。

2.3 ForwardingNode:扩容期间的老表占位符

ForwardingNode是扩容过程中的标记节点,hash固定为MOVED = -1,内部保存了nextTable引用。当一个桶迁移完成,旧表对应位置会被替换成ForwardingNode。

它的存在解决了两个问题:一是其他线程put或get时发现槽位hash小于0,就知道这个桶已经搬走了,可以直接去新表查找,或者加入帮忙迁移的队伍;二是保证新数据不会写到已经迁移过的旧桶——因为槽位被ForwardingNode占住后,后来的写操作都会被引导到新表。这是ConcurrentHashMap在扩容期间依然能保证并发正确性的关键设计。

2.4 sizeCtl:一个整型字段承载三种语义

sizeCtl是整个类里"最精打细算"的字段,按值的区间表达不同含义,我整理成了下面这张表:

sizeCtl值含义
0尚未初始化,创建对象时的默认值
正数如果是初始化前,表示初始容量;如果初始化完成,表示下一次扩容阈值(约为当前容量0.75倍)
-1正在初始化,只有一个线程能执行initTable
负数且绝对值大于1正在扩容,高16位存扩容标识戳resizeStamp,低16位表示参与扩容的线程数+1

把多种状态压缩在一个int里,不是为了省内存,而是为了让状态切换只需要一次CAS操作。例如initTable里,线程通过CAS(SIZECTL, sc, -1)一次把"未初始化"切到"初始化中",不需要额外维护独立状态位。读懂sizeCtl,后面addCount和transfer的代码才能真正看懂。

3. put()方法全流程拆解:CAS、自旋与synchronized的三重协奏

3.1 第一步:hash扰动算法spread()

static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }

h ^ (h >>> 16)把高16位与低16位异或,让高位信息也参与低位寻址,降低哈希冲突概率。HASH_BITS是0x7fffffff,作用是清掉符号位,避免hash结果为负数带来边界问题。之后通过(n - 1) & hash定位槽位,因为容量始终是2的幂,这个位运算等价于取模,但速度更快。

3.2 putVal主循环:每个分支都有明确使命

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 || tab.length == 0) tab = initTable(); // 分支1:空表初始化 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // 分支2:空槽位CAS写入 } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 分支3:协助扩容 else { V oldVal = null; synchronized (f) { // 分支4:锁桶头节点 if (tabAt(tab, i) == f) { // 二次校验 if (fh >= 0) { // 链表插入 binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; 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, null); break; } } } else if (f instanceof TreeBin) { // 红黑树插入 ... } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // 分支5:尝试树化 if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); // 分支6:累加计数 return null; }

说几个特别值得注意的隐藏细节。

外壳是自旋for循环,CAS失败后不会直接返回,而是continue进入下一轮重新判断。CAS失败的原因通常是另一个线程刚把数据写进槽位,本线程下一轮就会进入分支4走锁竞争路径。

synchronized(f)锁的是桶的第一个节点,不是整个链表,更不是整张表。不同桶之间完全无锁竞争,只有命中同一个桶的写线程才需要排队。这个锁粒度比JDK 7的Segment细了一个数量级。

锁内还要再做一次tabAt(tab, i) == f校验,这是双重检查锁定(DCL)思路。原因是当前线程从读取f到真正拿到锁,中间有段时间处于BLOCKED状态,锁等待期间槽位可能已经被其他线程迁移或者替换,必须重新读取确认。

3.3 initTable里的初始化竞争

private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { if ((sc = sizeCtl) < 0) Thread.yield(); // 已有线程在初始化,让出CPU else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { int n = (sc > 0) ? sc : DEFAULT_CAPACITY; Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = tab = nt; sc = n - (n >>> 2); // 0.75n,即扩容阈值 } } finally { sizeCtl = sc; } break; } } return tab; }

如果sizeCtl小于0说明已有线程抢到了初始化权,当前线程就Thread.yield()让出CPU。CAS成功的那一个线程负责真正创建数组,执行完在finally里把sizeCtl改为容量0.75倍的阈值。外层while确保所有线程必须等初始化完成才能继续。

3.4 树化临界判断:为什么是8,为什么又要看64

binCount统计链表长度,每插入一个新节点就加1。当binCount >= TREEIFY_THRESHOLD(8)时,执行treeifyBin。但treeifyBin内部有个前置条件:

if ((n = tab.length) < MIN_TREEIFY_CAPACITY) // 64 tryPresize(n << 1); // 优先扩容 else if (...链表转红黑树...)

也就是说,链表长度到8时,如果整个数组容量还不到64,会退回去做扩容,而不是马上树化。原因不复杂:链表太长大概率是容量不够导致哈希冲突集中,扩容可以把链表拆散,成本比建红黑树低得多。只有容量到位后,树化才用来对付"极端不均匀的哈希分布"。

官方注释里用泊松分布做过估算:在负载因子0.75、随机哈希的条件下,同一个桶链表长度到8的概率已经接近千万分之一。所以树化几乎只为异常冲突兜底,不会频繁触发。

4. get()凭什么无锁读取:volatile、happens-before与弱一致性边界

4.1 get()源码走读

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

get方法全程无锁。它的安全基础有三层:最上面是tabAt用Unsafe的volatile读拿数组元素;中间是Node的val和next都是volatile字段;最底下是JMM的happens-before规则。

当eh < 0时,说明头节点要么是TreeBin要么是ForwardingNode。这两个类都重写了find()方法,所以get不需要区分具体子类,直接调find()就行。这也是多态在并发容器里的一个巧妙应用。

4.2 volatile在这条链路上到底保证了什么

volatile有三个层面的语义:可见性、禁止指令重排、对单个读写操作的原子性。get场景需要的是前两个。

写线程在synchronized块内完成链表节点的新建和链接,退出锁之后,释放锁动作与后续任何加锁操作之间存在happens-before关系。读线程虽然没有拿锁,但通过volatile读数组元素,可以观察到其他线程已经发布出来的链表结构。具体到代码层面,ConcurrentHashMap是先完成新节点内容初始化,再通过setTabAt一次性发布到数组,读线程看到的不会是一个构造到一半的节点。

4.3 弱一致性的典型表现:迭代器与"差不多准"的读

JDK 8里ConcurrentHashMap有两点弱一致性值得注意。

第一是迭代器。迭代器创建后,遍历过程并不反映之后发生的修改,不会抛ConcurrentModificationException。这种设计对"边遍历边删除"比较宽容,但如果你的业务依赖迭代器必须看到全量最新数据,那就会踩坑。

第二是读操作可能看不到"刚刚发生的写入"。我排查过一个缓存刷新问题:线程A遍历某个Map做统计,线程B同时对部分key做replace,统计结果两边不一致。代码逻辑看起来没问题,但ConcurrentHashMap只能保证"读到的一定是某个已发布状态",不保证"读到的一定是最后一次写入"。弱一致性形态下,绝大多数业务都没事,但需要强一致读时,必须在外部加锁或者用其他同步手段。

5. size()与mappingCount():LongAdder式计数器的分而治之

5.1 为什么不直接用一把锁维护size

如果ConcurrentHashMap内部只维护一个volatile long baseCount,每次put都对这个字段做CAS累加,高并发下的写冲突会非常严重:成千上万个线程同时CAS同一个缓存行,大量CPU时间浪费在重试上。JDK 8借鉴了LongAdder的思路,把单一计数变量拆散成一组CounterCell,每个线程尽量累加到自己的Cell,最终查询时再合并。

@sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } }

注意上面的@sun.misc.Contended注解,它的作用是防止伪共享——相邻的CounterCell如果落在同一个CPU缓存行,一个Cell被修改会导致整个缓存行失效。加上这个注解后,每个Cell会占据独立缓存行,避免多个线程互相拖累。这个细节在日常业务代码里不常见,但在并发计数器设计里非常关键。

5.2 addCount流程与ThreadLocalRandom的配合

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; 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; } ... } ... }

流程分三步:先尝试CAS累加baseCount,如果成功,说明当前竞争不激烈,直接结束;如果失败,就按ThreadLocalRandom.getProbe()定位到一个CounterCell,给这个Cell累加;如果Cell数组还没初始化或者目标Cell也CAS失败,代码会进入fullAddCount,在里面做Cell数组初始化、扩容、二次重试等更复杂的操作。

ThreadLocalRandom.getProbe()是线程私有的随机探针值,作用就是让不同线程尽量散列到不同的Cell,减少碰撞。整个设计的目标很简单:让"大多数put"只命中一个低竞争的计数单元,避免全局争抢。

5.3 size()与mappingCount()的差别,以及为什么不建议把size当精确值

public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } public long mappingCount() { long n = sumCount(); return (n < 0L) ? 0L : n; }

两者都调用sumCount(),把baseCount和所有CounterCell的值合并。区别只在返回类型:size()返回int,超过Integer.MAX_VALUE会被截断;mappingCount()返回long,更适合大数据量统计。

但这里的数值是不精确的:统计那一刻并没有全局锁,计数还在并行变化。我在压测里观察过,高并发put下mappingCount和真实值的误差通常在个位数到几十个的量级,对扩容判断和监控指标完全够用。如果你的业务需要"绝对准确且实时"的计数,还是得在外部自己维护一把锁来保证一致性。

6. 多线程扩容transfer():把大搬家拆成"每人搬一块砖"

6.1 扩容触发条件与扩容戳resizeStamp

扩容触发点在addCount末尾:元素总数sumCount()大于等于sizeCtl阈值,且当前容量还没到MAXIMUM_CAPACITY时,会尝试发起扩容。

发起扩容的线程通过CAS把sizeCtl设置成(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2,其中resizeStamp(n)是旧容量n对应的扩容标识戳。这样sizeCtl高16位表示本次扩容的"批次号",低16位表示当前参与扩容的线程数+1。后续线程想加入扩容,只需要判断批次号是否一致,一致就把低16位加1。

这个设计天然形成一个"协作申请"机制:所有人都可以参与,但只有同一次扩容的线程能互相协作。不同批次的扩容互认,避免新老线程混乱。

6.2 单桶迁移:链表如何拆成lo链表和hi链表

迁移单桶时,旧容量是n,新容量是2n。对任意节点,hash & n的结果只能有两种:0或n。结果为0的节点在新表里依然落在下标i的位置,结果为n的节点落在i + n位置。迁移代码利用这个性质,把一条链表拆成两条新链表:

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

这里的lastRun优化很实用:从某个节点开始,后续所有节点的hash & n结果都一样,那么从这个节点开始的整条子链可以原样复用,不需要一个个创建新节点。第一个for循环就是找出这个分界点,第二个for只复制分界点之前的节点。这样能明显减少新节点创建数量,我在自己写数据分片逻辑时也借鉴了这个"找分界点、批量搬移"的思想。

6.3 协助扩容:helpTransfer与ForwardingNode的配合

当一个put线程走到某个槽位,发现头节点hash正好是MOVED,说明该桶已经迁移完成,它会调用helpTransfer尝试加入扩容队伍。

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) { // 判断批次号一致后,CAS把sizeCtl低16位+1,加入扩容 } return nextTab; } return table; }

多个线程各自从transferIndex领取任务段,每迁完一个桶就把旧表对应位置替换成ForwardingNode。扩容过程中,读写请求遇到ForwardingNode会被引导到新表,数据不会出现"写入已经搬走的旧桶"的情况。新表全部迁移完成后,table指向nextTable,sizeCtl恢复成新容量对应的阈值,整个扩容结束。

这部分是ConcurrentHashMap源码里最考验耐心的代码,我第一次读transfer时绕了很久。建议大家先理解"hash & n拆链表"和"ForwardingNode占位"这两个核心,再回头看循环细节。

7. 从源码反推使用建议与高频面试追问

7.1 构造参数不是并发度参数

很多人以为new ConcurrentHashMap(100)是设置并发度,其实JDK 8里构造参数就是初始容量,内部会用tableSizeFor调整成2的幂。JDK 7遗留下来的concurrencyLevel参数,到了JDK 8已经退化成了初始容量的一个计算输入,不再代表并发度。

真正决定并发表现的是桶的数量——桶越多,锁竞争越分散。所以如果预估数据量比较大,构造时就应该给足容量,否则只能靠频繁扩容去补。

7.2 单一操作原子,复合操作依然需要外部加锁

putIfAbsent、replace这类方法是原子的,但"先查再写"这类复合逻辑不保证原子。典型的例子:

if (!map.containsKey(key)) { map.put(key, value); }

两个线程可能同时判断!containsKey为true,然后先后put,后写覆盖前写。正确姿势是用putIfAbsent或者computeIfAbsent。但注意,computeIfAbsent在Java 8里有过递归触发map操作导致行为异常的案例,回调函数内部应当避免直接操作同一个ConcurrentHashMap,尤其是不能递归触发同键的computeIfAbsent。

7.3 JDK 7与JDK 8的关键差异汇总

维度JDK 7JDK 8
同步机制Segment继承ReentrantLockCAS + synchronized桶锁
锁粒度一个Segment锁一批桶锁单个桶的头节点
可调整并发度固定16(Segment数量)随桶数量自然提升
数据结构数组 + 链表数组 + 链表 + 红黑树
扩容方式单线程迁移多线程协助迁移
扩容期间读写需要判断Segment状态通过ForwardingNode引导到新表

面试被问"为什么JDK 8抛弃分段锁"时,可以这样回答:分段锁粒度还是太粗,Segment数量初始化后无法调整,ReentrantLock在阻塞和唤醒上的线程切换成本也比JDK 8优化后的synchronized更高。JDK 8把锁粒度细到桶,同时借助JVM的锁升级机制(偏向锁、轻量级锁、重量级锁),在大多数冲突不激烈的场景下开销更低。

7.4 实操里的四个"别"

  1. 别把size()当精确业务判断依据——它是弱一致性的估算值,用mappingCount()可以缓解int溢出,但两者都不是强一致精确值。
  2. 别在热点key上集体扎堆——所有实现都是"锁桶",如果业务把大量写操作压到同几个key上,该桶依然会退化串行。必要时做热点拆分。
  3. 别忽略构造时的容量预估——容量不足会频繁触发扩容,扩容期间虽然可以协作,但整体成本远超一次普通put。
  4. 别在computeIfAbsent回调里操作同一个map——复杂回调逻辑容易引入递归或死循环,业务代码里建议把计算逻辑放到回调外面。

7.5 面试追问中最容易忽略的底层细节

问得最多的一个问题是:"两个线程同时想把同一个链表转成红黑树怎么办?"答案在treeifyBin里:方法对头节点加了synchronized,并做了二次校验,所以真正执行转树操作的只有一个线程,其他线程进入后看到桶已经不是原节点,会放弃或走扩容。

另一个常被追问的点是"扩容时get为什么还能工作":因为get遇到ForwardingNode时,会通过它的find()方法到新表继续查找。老表节点还在,新表数据也已迁移,两个方向都能找到正确结果。

还有人会问"为什么扩容时不用重新计算每个节点的hash":因为扩容是乘以2,节点在新表的槽位要么是原下标i,要么是i+oldCap,判断依据就是hash & oldCap,一次位运算就能完成分桶,根本不需要重新计算hash值。

最后分享一点个人经验

读ConcurrentHashMap源码最忌一上来就啃transfer。我建议的顺序是:先画一个"数组-桶-链表/树-节点"的结构图,理清每个对象存在的意义,然后沿着put这一条链路走一遍,再把get、size、扩容逐个串起来。最难理解的部分往往不是某一行语法,而是"为什么多个线程都愿意帮忙扩容却不乱套"——答案就在sizeCtl和ForwardingNode这两个设计里。

把这两块彻底搞懂之后,再回头看线程池里的ctl变量、限流器里的并发状态机,你会发现它们用了极其相似的思路:用一个整型字段压缩多个状态,用CAS做状态切换,用协作代替阻塞。理解了一套,后面的路会越走越顺。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询