☰
HashMap并发死循环与ConcurrentHashMap源码解析
2026/10/17 12:58:34 网站建设 项目流程

1. 从一场线上事故说起:并发put引发的CPU飙高

如果你在互联网公司待过一段时间,大概率听过这个经典事故:某个模块为了性能优化,把共享的HashMap当成缓存容器用,刚开始一切正常,某天流量一冲上来,CPU直接飙到100%,jstack抓线程栈一看,业务线程全部卡死在HashMap.get()的循环里,没有死锁、没有OOM,就是死循环。

我第一次遇到这个场景时,排查了很久才定位到根因——不是代码逻辑的问题,而是多线程并发put触发了JDK 7版本HashMap的环形链表。那次事故之后,我把HashMap和ConcurrentHashMap的源码认真啃了一遍,才发现以前"数组加链表"的认知有多浅。

这篇内容适合三类读者:准备面试、想读源码但不知道从哪下手的人,以及日常工作里被并发问题坑过、想搞清楚"为什么"的人。我会从数据结构、读写链路、并发设计三个维度拆解,最后聊一聊源码原理落到工程实践时那些容易踩的坑。

2. 底层骨架:数组、链表、红黑树各司其职

2.1 数组是门牌号,hash决定你住哪栋楼

打开JDK 8的HashMap源码,第一眼看到的成员变量就是这个:

transient Node<K,V>[] table;

这就是整个容器的主干。table是一个数组,每个位置叫"桶"(bucket)。你放入键值对时,先对key计算hash值,再通过(n - 1) & hash定位到具体桶位,这个公式非常关键。

为什么用&而不是%?因为HashMap的容量n永远设计成2的幂次方。当n = 2^m时,n - 1的二进制是m个1,此时(n - 1) & hash的结果恰好等于hash % n,但位运算比取模快得多。这就是"容量必须是2的幂"这个设计约束的来源。

说个细节:即使你用new HashMap(1000)指定一个非2的幂的容量,源码里也会通过tableSizeFor()方法把它调整成最接近且不小于该值的2的幂,比如1000会被调整成1024。

2.2 冲突了怎么办:链表罢工与红黑树接管

hash值不同但桶位相同的情况叫hash冲突(碰撞)。HashMap解决冲突的第一招是"拉链法"——同一个桶位上,用链表串起所有冲突的节点。

链表的问题一目了然:如果大量key被分到同一个桶,查询复杂度从O(1)退化成O(n)。为了兜底,JDK 8引入了红黑树。当某个桶的链表长度达到TREEIFY_THRESHOLD = 8,且table容量达到MIN_TREEIFY_CAPACITY = 64时,这条链表会被转成红黑树,查询复杂度变成O(log n)。

有个面试常问的问题:为什么树化阈值是8而不是16?源码注释里给出了数学解释——假设hash函数足够随机,桶中节点数服从泊松分布。在负载因子0.75的前提下,同一个桶出现8个节点的概率大约是千万分之六,属于"几乎不可能发生"的极端退化场景。如果阈值设太大,树化机制形同虚设;设太小,又过早付出树结构的维护成本。8是权衡后的结果,听起来玄学,背后其实有概率论支撑。

链表退化成树的阈值是8,但从树退化成链表的阈值是6,中间留了2的缓冲,防止节点数在7、8之间反复横跳导致频繁树化和反树化,这是很多初学者没注意的细节。

2.3 扩容的副产品:桶位重新分配

当HashMap里的元素数量超过threshold = capacity * loadFactor时,会触发扩容。扩容是原数组的2倍,扩容后每个节点的桶位都会重新计算。很多讲解会说"扩容后重新hash",这个说法不准确。

JDK 8的源码做了个优化:由于新容量newCap = oldCap << 1,节点在新数组的下标只有两种情况:要么保持原位置j,要么变成j + oldCap。判断依据是(e.hash & oldCap) == 0。这个方法我只在源码里见过一次,但理解它能让整个扩容机制瞬间通透——oldCap是2的幂,它的二进制只有一位是1,e.hash & oldCap实际是在判断hash值的这一位是0还是1。这一位决定了节点去原位置还是原位置加旧容量。

3. 读写链路源码走读:put/get/resize的每一道分支

3.1 扰动函数:hash值为什么要高16位异或低16位

JDK 8对key的hash处理是这么一行:

static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }

key.hashCode()返回的是一个32位int,而桶位计算(n - 1) & hash只用了低m位(因为n - 1的高位是0)。这意味着,如果hash值的低位分布不够均匀,冲突就会很严重。

解决思路是让高位也参与低位运算。把hash值右移16位(即取高16位),再与自身异或,相当于把高位的信息"混入"低位。这样即使两个key的hash值在高位不同、低位相同,经过扰动后落到同一桶的概率也会大幅降低。

对比JDK 7,它的扰动函数执行了4次异或,效果类似但成本更高。JDK 8做了一次减法,一是因为计算机性能提升后哈希分布质量没那么差,二是因为树化机制已经兜底了极端情况。

3.2 put方法:一条if-else走到底

put(K key, V value)最终调用putVal,核心流程可以拆成五个分支:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 分支1:table还没初始化,先扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 分支2:桶位为空,直接放入新节点 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 分支3:桶位第一个节点key相同,直接覆盖 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 分支4:桶位已经是红黑树节点,走树的插入逻辑 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 分支5:遍历链表,找key或尾插 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; } } // 找到旧节点则替换值并返回旧值 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; return oldValue; } } ++modCount; if (++size > threshold) resize(); return null; }

这里有个容易忽略的细节:treeifyBin并不是直接转红黑树,它内部还有一个判断——如果table长度小于MIN_TREEIFY_CAPACITY = 64,只会调用resize()扩容而不是树化。这个设计的逻辑是:当整体桶太少时,说明冲突主要是"桶太少"导致的,直接扩大桶的数量就能稀释冲突,没必要动用红黑树。

put流程走完,最后还有一个关键检查:++size > threshold时触发resize()。注意是插入后才检查,所以HashMap的容量永远允许在扩容前的那一刻多存一个元素,然后在下次插入时补一次扩容。

3.3 get方法:一次快速失败的体验

get的代码比put短很多:

final Node<K,V> getNode(int hash, K key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 桶位为空直接返回null if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 先检查第一个节点 if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { // 树节点走树的查找 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); // 链表走循环查找 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }

两个判断值得注意。第一,first节点先检查hash相等再检查key.equals(),hash判断在前是因为它更廉价,能快速过滤掉绝大多数不匹配的节点。第二,没有找到时不抛异常,只是返回null。这个设计有个副作用:HashMap不允许用get结果来区分"key不存在"和"key存在但value为null",如果你需要这种区分,得用containsKey。

3.4 resize扩容:数据迁移的性能优化

resize()不只是数组换大,它包含了初始化、阈值更新、数据迁移三步。数据迁移的亮点在于前面提到的(e.hash & oldCap)判断,我贴一段JDK 8里的核心逻辑:

if (e.next == null) { newTab[e.hash & (newCap - 1)] = e; } else if (e instanceof TreeNode) { ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); } else { Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; 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; } e = next; } while (e != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } }

这里用lo和hi两条链表分别承接"留在原位"和"挪到高位"的节点,而不是逐个节点重新计算桶位。这么做的好处是:在迁移过程中,同一桶内链表的相对顺序保持不变。这个细节和JDK 8为什么要放弃头插法直接相关,下一节展开。

4. 从JDK 7到JDK 8的变迁:环形链表与它背后的教训

4.1 头插法的隐患:并发扩容形成环形链表

JDK 7的HashMap在往链表里插入节点时用的是头插法——新节点插到链表头部。单线程下头插法没问题,还利用了"最近插入的数据最可能被访问"的局部性原理。但在并发扩容场景下,头插法会要命。

我来描述一下环形链表是怎么形成的。假设有个桶里的链表是A -> B -> C,两个线程同时对这个HashMap扩容。线程A先执行迁移,把链表处理到一半:A被移到新数组,next指针还在B上。此时线程B也来迁移,它读取到的是尚未完成的链表状态。因为头插法会把遍历到的每个节点依次插到新桶头部,B在迁移过程中把节点顺序整个反转了——最后A和B的next互相指向对方,闭环形成。之后任何线程对这个桶做get,都会在环形链表里无限循环。

这个问题的根源不是"多线程改了数据结构",而是迁移过程不是原子的。HashMap本来就不是线程安全的容器,官方文档也明确说"如果有多个线程同时访问,且至少有一个线程修改了map,必须外部同步"。但在JDK 7时代,很多人没意识到"读也会死循环",因为读线程一旦撞进环形链表,CPU就完全被吃住了。

4.2 JDK 8的回应:尾插法加树化,但并发问题依然存在

JDK 8把插入方式从头插法改成尾插法,迁移过程中的相对顺序因此得以保持,同一行代码在同一批数据上执行时,不会再因为顺序反转而形成环形链表。这是JDK 8修复"死循环"问题的核心手段。

但同时要说清楚:JDK 8的HashMap依然不是线程安全的。并发put仍然可能丢失数据(两个线程同时写同一个桶,后写覆盖先写)、size值不准确、甚至在某些极端情况下依然可能形成问题结构。只不过死循环这个特定场景得到了缓解。我在实际项目中见过一个案例:JDK 8环境,多个线程同时往同一个HashMap写数据,最终size比实际键值对少了几个——这就是典型的并发丢失更新。

所以原则很明确:并发场景拒绝HashMap,这不是源码版本能解决的问题,而是容器设计目标不同。需要线程安全就用ConcurrentHashMap。

5. ConcurrentHashMap:并发场景的正确打开方式

5.1 JDK 7的分段锁设计:把整把锁拆成16把

JDK 7的ConcurrentHashMap采用分段锁(Segment)设计。核心结构是:

final Segment<K,V>[] segments;

Segment继承自ReentrantLock,默认数组长度16,也就是说并发度上限是16。put一个键值对时,先通过hash定位到具体的Segment,然后对这个Segment加锁,在Segment内部再执行类似HashMap的插入逻辑。不同的Segment之间互不干扰,所以理论上16个线程可以同时写不同的Segment。

分段锁的优点是读操作完全无锁(因为Segment里的table用volatile修饰,且get不依赖修改),写操作只锁一段,并发效率远高于全局锁的Hashtable。

但分段锁也不是没代价。第一,定位需要的hash次数变多:一次定位Segment,再一次定位桶。第二,扩容的粒度过大:Segment内部数组扩容时,整个Segment都要锁住,如果某个Segment数据特别多,扩容期间这个Segment的写操作全部阻塞。第三,Segment个数初始化后不可变,想提高并发度只能在构造时手工调参,灵活性差。

5.2 JDK 8的CAS加synchronized:锁粒度细到单个桶

JDK 8的ConcurrentHashMap彻底抛弃了Segment,回归到和HashMap类似的结构——Node[] table加链表加红黑树,但每个桶的写入用synchronized锁住桶的头节点。

为什么不沿用分段锁?一个核心原因是CPU成本。JDK 7的put在竞争不激烈时也要获取ReentrantLock,即使锁没被占用,Lock.lock()的CAS操作和可能发生的线程阻塞唤醒都有开销。JDK 8的做法是"能不加锁就不加锁,加了锁就只锁最小范围"。

具体到putVal,逻辑是这样的:

// 桶位为空,直接CAS放入,不加锁 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) break; } // 桶位不为空,锁住桶头节点再操作 else { synchronized (f) { // 内部再判断一次当前桶头节点是否还是f // 然后走链表插入或红黑树插入 } }

两个细节值得细品。第一,tabAt方法用Unsafe.getObjectVolatile来读取数组元素——虽然table数组本身是volatile的,但数组里的每个元素不是volatile,要保证读到的桶头节点是最新的,必须用getObjectVolatile。这是ConcurrentHashMap线程安全的基础设施之一。第二,synchronized块内部还要if (tabAt(tab, i) == f)再校验一次桶头节点有没有变,防止我们在等待锁的过程中数组已经扩容或节点已被其他线程改动。

size()方法的实现也很有意思。JDK 8用了一个baseCount加一个CounterCell[]数组来拆分计数,写操作时优先CAS更新baseCount,竞争激烈时把一个线程的计数分摊到某个CounterCell上,最终统计时把所有CounterCell的值和baseCount相加。这样避免了在每次put时都去抢一个全局计数器的锁。

5.3 多线程协作扩容:MOVED标记与ForwardingNode

JDK 8的ConcurrentHashMap扩容是一个亮点设计。当某个线程触发扩容后,它不会像HashMap那样闷头迁移完所有数据,而是把任务拆分成多个stride(步长)区间,其他线程在put或get时如果发现桶位上有ForwardingNode(hash值为MOVED),就会主动加入扩容,大家一起搬。

迁移的标记物是ForwardingNode,结构上它是一个带特殊hash值(MOVED = -1)的节点。迁移完一个桶,就把这个桶的引用替换成ForwardingNode,同时它内部持有nextTable引用——即新数组的地址。任何线程看到MOVED,就知道"这个桶已经搬走了,请到新数组去找",然后调用helpTransfer()协助扩容。

这个设计解决了两个问题。第一,扩容期间读写不阻塞:读线程遇到ForwardingNode会直接去新数组查;写线程遇到ForwardingNode会协助扩容,而不是干等。第二,扩容速度随并发量提升:参与扩容的线程越多,迁移完成越快,队头阻塞被大幅缓解。

源码里transfer()方法大体流程是:先计算每个线程负责的桶区间,然后循环领取任务,迁移完一个桶就CAS更新transferIndex,直到所有桶都迁移完毕。由于每个桶的迁移都是在synchronized(f)里完成的,不会出现两个线程搬同一个桶的问题。

6. 源码背后的实战经验:容量、负载因子与日常选型

6.1 初始容量怎么设:别让扩容成为常客

很多人在new HashMap()时不传容量,默认16和0.75的负载因子意味着存到12个元素就要扩容。如果你的map预期要存几千个键值对,不设置初始容量会导致扩容次数过多——每次扩容都是全量数组复制加重新分配,虽然JDK 8优化了迁移过程,但成本依然不可忽视。

正确姿势是预估元素个数再除以负载因子:

// 预期存储1000个元素 Map<String, String> map = new HashMap<>((int) (1000 / 0.75f) + 1);

为什么加1?因为tableSizeFor会向上取最近的2的幂,直接传1000 / 0.75f = 1333,结果被调整成2048,阈值变成2048 * 0.75 = 1536,足够容纳1000个元素且不至于浪费太多内存。这里的经验法则是:容量 = 预估元素数 / 负载因子 + 1,再交给HashMap自动规整。

6.2 负载因子能随便改吗

loadFactor = 0.75是时间和空间的一个折衷。调小到0.5,内存占用翻倍,但冲突更少、查询更快;调大到1,阈值变大,内存省了但链表更长、树化更频繁。绝大多数业务场景直接沿用0.75,需要微调的场景通常是"内存极贵但允许查询慢一点",比如某些离线批量任务里的中间数据。

还有个冷知识:HashMap的容量在首次put时才真正分配数组,构造器里只做参数校验和阈值计算。所以字面上"new HashMap(1024)"并不会立刻分配1024个槽位的数组,数组是懒加载的。

6.3 HashMap、ConcurrentHashMap、Hashtable怎么选

我见过不少老项目还在用Hashtable,它的所有方法都加了synchronized,读和写争同一把锁,在高并发下性能非常差。选型的结论其实很简单:

场景选择理由
单线程、无共享HashMap性能最高,无同步开销
多线程读、几乎不写ConcurrentHashMap读操作无锁
多线程读写都有ConcurrentHashMap锁粒度细,并发度高
遗留系统、无并发要求HashMap保持简单

有一个原则不能忘:即使是ConcurrentHashMap,也不能保证复合操作的原子性。比如"先get再put"这种check-then-act逻辑,依然需要外部加锁或使用compute、putIfAbsent等原子方法。ConcurrentHashMap保证的是单次操作的线程安全,不是任意序列的线程安全。

6.4 实际开发中那些反复出现的坑

第一条是关于key的。equals和hashCode必须一起重写,否则两个业务上相等的key会落到不同桶;更隐蔽的是用可变对象当key——放进map后再修改key的属性,它的hashCode变了,但桶位已经算好了,之后get就永远找不到,造成"数据消失"。

第二条是用HashMap当缓存却不控制size。常见做法是cacheMap.put(key, value)无限写入,最终内存膨胀。即使加了ConcurrentHashMap也一样,它只保证并发安全,不提供淘汰策略。业务缓存最好用专门的Caffeine或Guava Cache,它们有容量上限和过期机制。

第三条是遍历时修改。HashMap的迭代器是fail-fast的,modCount变了就抛ConcurrentModificationException,但这不是bug,是设计——它提示你"迭代中别改结构"。如果你确实需要边遍历边删除,应该用Iterator.remove()或直接用ConcurrentHashMap。

我在实际项目里还踩过一个坑:用HashMap做大批量数据去重时,以为内存不够是因为key太多,jmap一看才发现是桶里的链表极端退化——某个自定义对象的hashCode()实现有bug,返回了常量,所有key全挤在一个桶里。这种问题从原理上讲就是"hash分布差导致树化失效",排查时回头去看扰动函数和hashCode的实现,往往能快速定位。

源码阅读这件事,我的个人建议是不要从头到尾硬读,而是以put和get为入口,跟着一条调用链走到底,遇到看不懂的再翻数据结构定义。HashMap和ConcurrentHashMap两版源码对比着看尤其有意思——它们解决的问题一模一样,设计理念却截然不同:前者赌hash分布足够好,后者赌原子操作足够快。理解了这两套设计哲学,很多看似神秘的阈值、位运算和锁策略,其实都是同一道"成本与收益"的数学题。

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

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

立即咨询