《Java 100 天进阶之路》第49篇:ConcurrentHashMap原理(2026版)

《Java 100 天进阶之路》第49篇:ConcurrentHashMap原理(2026版)

第49篇:ConcurrentHashMap原理(2026版)

📌系列导航:《Java 100 天进阶之路》完整目录 |
⬅️ 上一篇:第48篇:HashMap源码全解(下) |
➡️ 下一篇:第50篇:阻塞队列与并发容器(待发布


文章目录

    • 第49篇:ConcurrentHashMap原理(2026版)
      • 🗺️ 本文阅读地图(3 分钟速览)
      • 一、核心知识点
      • 二、生活类比:从“小杂货铺”到“智能超市”
      • 三、JDK 7 回顾
        • 3.1 核心结构
        • 3.2 JDK 7 的缺陷(为什么废弃?)
      • 四、JDK 8:CAS + synchronized 桶级锁(重点)
        • 4.1 为什么废弃分段锁?
        • 4.2 核心数据结构
        • 4.3 put 流程(面试高频)
        • 4.4 get 流程
        • 4.5 并发扩容机制(JDK 8 最精妙设计)
        • 4.6 计数机制:size() 为什么是“弱一致”?
      • 五、JDK 7 vs JDK 8 完整对比
      • 六、与 HashMap / Hashtable 对比
      • 七、生产级避坑清单
      • 八、面试高频考点
      • 🎤 面试官追问陷阱(加分题)
      • 九、练习题
    • 📊 你的学习进度
    • 👉 下一篇文章预告

🗺️ 本文阅读地图(3 分钟速览)

前两篇彻底拿下了 HashMap 源码,但 HashMap线程不安全。高并发下用什么?ConcurrentHashMap 给出了标准答案:

模块核心问题一句话回答
为什么需要它HashMap 并发有什么问题?JDK 7 扩容死循环、数据覆盖、size 不准
JDK 7 回顾分段锁怎么工作?(简要)Segment 数组,每个 Segment 一把锁,默认 16 段
JDK 8 实现为什么抛弃分段锁?改为Node 数组 + CAS + synchronized,锁粒度细化到桶
并发扩容扩容时其他线程怎么办?多线程协助迁移,旧桶标记ForwardingNode
计数方式size()准确吗?弱一致性,用baseCount+CounterCell近似统计
面试最爱问高频考点有哪些?见文末 🎤 小节

一、核心知识点

  • 设计目标:高并发下的线程安全 Map,兼顾性能与安全
  • JDK 7 架构(简要):Segment 分段锁(继承ReentrantLock)+ HashEntry 数组 + 链表
  • JDK 8 架构(重点):Node 数组 + 链表/红黑树 +CAS +synchronized桶级锁
  • 并发扩容:多线程协助迁移,ForwardingNode占位 +sizeCtl状态控制
  • 计数机制baseCount+CounterCell[](类似LongAdder
  • 弱一致性size()、迭代器不保证强一致性

二、生活类比:从“小杂货铺”到“智能超市”

老王开了家店,一开始是HashMap 小杂货铺:店里就 1 个货架(数组),商品按名字首字母放。问题很大——一旦有人整理货架(扩容),就得挂“暂停营业”的牌子(全局锁),顾客只能排队等。

后来升级成ConcurrentHashMap 智能超市

JDK 7 阶段(分区域管理):超市分成 3 个区域(Segment 数组),每个区域配 1 个保安(ReentrantLock锁)。顾客买“苹果”,先算该去哪个区域,只需要这个区域的保安放行,其他区域完全不受影响。但同一区域的商品如果叠在一起(哈希冲突),拿“苹果”时还是会挡住“牛油果”。

JDK 8 阶段(精细化到货架层):去掉区域保安,直接把货架分成无数层(Node 数组),每一层都有自己的“小锁”(synchronized)。如果这一层没人,直接拿(CAS 无锁);如果有人正在拿,只锁这一层。如果某一层商品堆太多(链表 ≥ 8),自动改成“旋转货架”(红黑树),找商品更快。

一句话总结:锁粒度越来越小——从“全表锁 → 分段锁 → 桶级锁”,兼顾线程安全和效率。


三、JDK 7 回顾

💡说明:JDK 7 在 2026 年已基本上退出生产环境,以下内容仅用于面试对比理解,不必深入实现细节。

3.1 核心结构

  • Segment 数组:核心锁容器,每个 Segment 继承ReentrantLock,默认 16 个
  • HashEntry 数组:每个 Segment 内部维护一个 HashEntry 数组
  • 操作流程put定位 Segment → 获取锁 → 操作链表 → 释放锁;get不加锁,依赖volatile
3.2 JDK 7 的缺陷(为什么废弃?)
缺陷说明
内存开销大16 个 Segment 自带锁、计数、对象头,占用大量内存
并发度受限同一 Segment 内多桶无法并行,最大并发度 = Segment 数量(固定 16)
扩容效率低只能单 Segment 独立扩容,无法多线程协作
结构复杂三层嵌套(Segment → HashEntry → 链表),维护成本高

四、JDK 8:CAS + synchronized 桶级锁(重点)

4.1 为什么废弃分段锁?

JDK 8 彻底重构,核心原因:

  1. 内存开销大:16 个 Segment 自带锁、计数、对象头,占用大量内存
  2. 并发度受限:同一 Segment 内多桶无法并行,最大并发度被 Segment 数量限制
  3. synchronized优化成熟:JDK 6+ 已升级为偏向锁→轻量级锁→重量级锁,由 JVM 托管,代码更简洁,性能不输ReentrantLock
4.2 核心数据结构

核心字段

  • Node<K,V>[] table:主哈希表(桶数组)
  • Node<K,V>[] nextTable:扩容时的新表
  • volatile int sizeCtl核心,控制初始化、扩容阈值与状态
  • volatile int transferIndex:多线程扩容分工指针
  • CounterCell[] counterCells+baseCount:高并发计数(类似LongAdder

三种节点类型

  • Node:链表节点,valnextvolatile
  • TreeNode:红黑树节点(链表过长时树化)
  • ForwardingNode:扩容占位标记,hash值为MOVED
4.3 put 流程(面试高频)
① 计算 hash,定位到桶 ② 检查 table 是否初始化 → 未初始化则 CAS 初始化 ③ 桶位为空 → CAS 直接插入(无锁快路径) ④ 桶位不为空 → 检查头节点 hash 是否为 MOVED ├─ 是 → 协助扩容 └─ 否 → synchronized(头节点) 锁住该桶 ├─ 链表:遍历替换或尾插 └─ 红黑树:树节点插入 ⑤ 链表长度 ≥ 8 且数组容量 ≥ 64 → 树化 ⑥ 元素数量超过阈值 → 触发扩容

核心设计思想

  • 桶为空 →CAS 无锁(最快路径,无竞争)
  • 桶不为空 →synchronized只锁桶头(不同桶完全并行)
4.4 get 流程
  • 计算 hash,定位桶
  • 桶为空 → 返回 null
  • 桶不为空 → 遍历链表/红黑树查找
  • 全程无锁,依赖volatile保证可见性
4.5 并发扩容机制(JDK 8 最精妙设计)

触发条件:元素数量超过容量 × 0.75

核心流程

  1. 单线程创建新表:第一个检测到需要扩容的线程创建nextTable(容量翻倍)
  2. 多线程协助迁移:其他读写线程遇到ForwardingNode主动参与迁移
  3. 迁移标记:已迁移的旧桶放置ForwardingNodehash = MOVED
  4. 数据迁移:每个桶的元素按(hash & oldCap)拆分为低位(原位)和高位(原索引 + oldCap
  5. 完成:所有桶迁移完毕,替换table引用

ForwardingNode 的作用

  • 表示该桶已迁移完成
  • 遇到它的线程会协助扩容(读写不阻塞)
  • 保证扩容期间其他线程仍可正常访问

并发扩容为什么高效

  • 不是单线程苦力,而是多线程协作完成
  • 通过 CAS 控制sizeCtl协调分工
  • transferIndex指针分配迁移任务
4.6 计数机制:size() 为什么是“弱一致”?
publicintsize(){longsum=baseCount;if(counterCells!=null){for(CounterCellcell:counterCells)sum+=cell.value;// 累加所有 CounterCell}return(int)Math.min(sum,Integer.MAX_VALUE);}

设计原理

  • baseCount:无竞争时直接 CAS 更新
  • CounterCell[]:竞争激烈时,每个线程更新自己的 CounterCell
  • 类似LongAdder的“分段计数”思路,避免全局计数器的竞争

弱一致性

  • size()返回近似值,不保证强一致
  • 调用期间可能有并发增删,结果瞬间过时
  • 这是为了性能而主动放弃强一致性

五、JDK 7 vs JDK 8 完整对比

维度JDK 7JDK 8
锁机制ReentrantLock(Segment 锁)CAS +synchronized(桶级锁)
数据结构Segment 数组 → HashEntry 数组 → 链表Node 数组 → 链表/红黑树
锁粒度Segment 级(默认 16 段)桶级(每个桶一把小锁)
扩容方式单 Segment 独立扩容多线程协助全局扩容
计数方式每个 Segment 单独维护 countbaseCount+CounterCell[]
查询复杂度最坏 O(n)(长链表)最坏 O(log n)(红黑树)
初始化时机构造时初始化懒加载(第一次 put 时 CAS 初始化)

六、与 HashMap / Hashtable 对比

对比维度HashMapHashtableConcurrentHashMap
线程安全❌ 不安全synchronized全表锁✅ 桶级锁 + CAS
null 键/值✅ 允许❌ 不允许❌ 不允许
并发度不适用极低(读也锁)极高(读无锁,写细粒度)
迭代器快速失败快速失败弱一致性
性能单线程最高最差接近 HashMap

七、生产级避坑清单

✅ ConcurrentHashMap 生产环境使用规范 1. 不要用 HashMap 做多线程共享 → 用 ConcurrentHashMap 2. 不要用 null 键/值 → 会直接抛 NPE 3. size() 不精确 → 不要用于精确计数(如金额统计) 4. 迭代器不保证强一致 → 遍历期间可能有增删 5. 复合操作(先 get 再 put)非原子 → 用 compute/merge 等方法 6. 批量操作(putAll)非原子 → 需要外部同步或分批次

八、面试高频考点

Q1:ConcurrentHashMap 和 Hashtable 的区别?

Hashtablesynchronized锁整个方法(全表锁),并发度极低。ConcurrentHashMapJDK 7 用分段锁,JDK 8 用 CAS + 桶级synchronized,读操作基本无锁,并发度远高于HashtableHashtable是遗留类,新代码优先用ConcurrentHashMap

Q2:JDK 7 和 JDK 8 的 ConcurrentHashMap 核心区别?

锁机制:JDK 7 用ReentrantLock分段锁,JDK 8 用 CAS +synchronized桶级锁。数据结构:JDK 7 是 Segment + HashEntry + 链表,JDK 8 是 Node + 链表/红黑树。扩容:JDK 7 单 Segment 扩容,JDK 8 多线程协助全局扩容。

Q3:ConcurrentHashMap 为什么不允许 null 键/值?

设计上有歧义——map.get(key)返回 null 时,无法区分是 key 不存在还是 value 本身就是 null。在非并发场景下可用containsKey区分,但在并发环境中,两次调用之间数据可能已变化,这种区分失去意义。

Q4:size()方法准确吗?

不准确,返回的是近似值。因为baseCountCounterCell的累加过程中,仍有并发修改。这是为了性能主动放弃强一致性。

Q5:ConcurrentHashMap 如何实现线程安全?

JDK 8 核心设计:①CAS 无锁:桶为空时直接 CAS 插入;②synchronized桶级锁:桶不为空时只锁桶头节点;③读操作无锁:依赖volatile保证可见性;④并发扩容:多线程协助迁移,ForwardingNode占位。

Q6:扩容时其他线程可以读写吗?

可以。已迁移的旧桶放置ForwardingNodehash = MOVED),其他线程遇到该节点会协助扩容。扩容期间读写均不阻塞,只是可能触发额外的迁移工作。

Q7:ConcurrentHashMap 的迭代器是 fail-fast 还是 fail-safe?

两者都不是,是弱一致性迭代器。遍历期间其他线程的修改不会抛ConcurrentModificationException,但迭代器也不保证能看到最新的修改。


🎤 面试官追问陷阱(加分题)

追问1:“JDK 8 为什么用synchronized而不是ReentrantLock?”

synchronized在 JDK 6+ 已经过锁升级优化(偏向→轻量→重量),由 JVM 自动管理锁的升级与释放,代码更简洁,无需手动lock()/unlock()。在低竞争场景下性能不输ReentrantLock。且ConcurrentHashMap的场景不需要公平锁——ReentrantLock默认也是非公平锁,公平锁反而会降低吞吐量。核心原因是 JVM 对synchronized的深度优化(锁消除、锁粗化)以及代码简洁性。

追问2:“ConcurrentHashMapput流程中,CAS 失败后会怎样?”

CAS 失败说明有竞争,其他线程已抢先完成了该桶的初始化或插入。当前线程会自旋重试——重新定位桶位,再次检查状态,根据新状态走对应的分支(桶为空则再次 CAS、桶不为空则synchronized、遇到ForwardingNode则协助扩容)。整个过程不会阻塞,通过自旋+重试实现轻量级并发控制。

追问3:“ConcurrentHashMapsize()不精确,那如何获得精确大小?”

无法获得精确大小——这是ConcurrentHashMap设计取舍。如果需要精确计数,可以用AtomicLong外部计数器配合put/remove同步更新,或使用Collections.synchronizedMap(但会牺牲并发度)。实际项目中,99% 的场景只需要近似值做监控或统计,精确值需求往往可以通过业务设计规避。


九、练习题

  1. 对比分析:JDK 7 的分段锁和 JDK 8 的桶级锁有什么区别?为什么 JDK 8 废弃了分段锁?

  2. 源码推导ConcurrentHashMap扩容时,ForwardingNode的作用是什么?为什么其他线程看到它要协助扩容?

  3. 场景设计:某系统使用ConcurrentHashMap作为缓存,需要统计缓存中元素个数,但发现size()返回的值经常与实际不符。为什么?如何解决?

  4. 代码分析:下面的代码在并发场景下有什么问题?如何优化?

    ConcurrentHashMap<String,Integer>map=newConcurrentHashMap<>();if(!map.containsKey(key)){map.put(key,1);}else{map.put(key,map.get(key)+1);}

    💡思路containsKey+get+put复合操作,不是原子的。应使用computemergecomputeIfAbsent等原子方法。


📊 你的学习进度

  • 当前:第49篇 / 共108篇 ·进阶篇:集合框架源码解析(第45~50篇)
  • ✅ 已完成:基础篇44篇 + 第45~49篇
  • 📖 正在学:第49篇
  • ⏳ 待学习:第50~108篇

👉 📚 完整目录 & 学习指南 | 🔥 订阅本专栏,不错过每一篇

👉 下一篇文章预告

🚀下一篇:《第50篇:阻塞队列与并发容器》

内容简介

  • ArrayBlockingQueuevsLinkedBlockingQueuevsSynchronousQueue
  • CopyOnWriteArrayList读写分离原理
  • ConcurrentLinkedQueueCAS 无锁队列
  • 线程池中的阻塞队列选型

👉看完第45~50篇,集合框架源码系列正式收官!

📌《Java 100 天进阶之路 | 从入门到上岗就业》每天一篇,建议收藏 + 关注,一起100天拿offer!
👉 点击关注我,更新后第一时间收到推送!