ConcurrentHashMap(JDK 7/8)详细介绍

ConcurrentHashMap(JDK 7/8)底层原理

目标:让你在面试里把 ConcurrentHashMap 讲"透",包括:数据结构、并发控制、扩容、put/get 流程、JDK7 vs JDK8 差异、常见坑与高频追问。


1. ConcurrentHashMap 是为啥存在的?和 HashMap / Hashtable / Collections.synchronizedMap 的区别

1.1 HashMap

  • 非线程安全:并发 put 可能导致数据丢失、链表成环(JDK7 扩容时尤其经典)等问题。
  • 性能高但不能并发用。

1.2 Hashtable

  • 线程安全 :方法上 synchronized整张表一把锁
  • 并发度差:读也要锁。

1.3 Collections.synchronizedMap(new HashMap<>())

  • 也是整张表一把锁 ,实现方式是对外层 wrapper 的方法加 synchronized
  • 迭代时仍要手动同步,否则可能 ConcurrentModificationException

1.4 ConcurrentHashMap

  • 目标:高并发下更好的吞吐
  • JDK7:分段锁(Segment)提升并发度。
  • JDK8:更细粒度(CAS + synchronized 在桶级别),读基本无锁,写锁粒度更小。

2. JDK7 ConcurrentHashMap:Segment 分段锁(理解历史,面试会问)

2.1 核心结构

  • ConcurrentHashMap 内部是 Segment<K,V>[] segments
  • 每个 Segment 继承 ReentrantLock,内部类似一个小 HashMap:HashEntry<K,V>[] table
  • 并发度主要由 segments 数量决定(默认 16 级别的并发)。

2.2 put/get 简述

  • get:定位到 segment,再在 segment 的 table 里查链表(读基本无锁,依赖 volatile 可见性)。
  • put :定位 segment,然后 lock(),再按 HashMap 方式插入或替换,必要时扩容 segment 内 table。

2.3 优缺点

  • 优点:相比 Hashtable 的全表锁,并发更强。
  • 缺点:
    • Segment 数量固定/半固定,会影响并发上限。
    • 结构更重,锁粒度不够细,且实现复杂。

记一句话:JDK7 通过"锁分段"提升并发度。


3. JDK8 ConcurrentHashMap:Node + CAS + synchronized(现在主流)

3.1 核心字段与结构(必须会)

  • Node<K,V>[] table:主哈希表(桶数组)
  • Node<K,V>[] nextTable:扩容时的新表
  • volatile int sizeCtl:控制初始化、扩容阈值与扩容状态(非常重要)
  • volatile int transferIndex:多线程扩容时的分工指针
  • CounterCell[] counterCells + baseCount:高并发下的计数(类似 LongAdder 思路)
Node 节点
  • 链表节点:hash, key, val, next
  • valnext 多为 volatile(可见性保证)
  • 红黑树节点:TreeNode(当链表过长会树化)
  • 扩容迁移标记:ForwardingNode(hash 为 MOVED

4. JDK8 的 put 全流程(面试高频,让你讲清楚"先 CAS 再锁桶")

4.1 关键步骤概览

  1. table 未初始化 :触发 initTable()(CAS 控制只有一个线程初始化)
  2. 计算 hash(spread)并定位 index
  3. 桶位为空:CAS 直接放入新 Node(无锁快路径)
  4. 桶位不为空:进入桶级别加锁(synchronized (f)
    • 链表:遍历替换/尾插
    • 红黑树:树操作(putTreeVal)
  5. 插入后 addCount() 更新计数;必要时触发扩容 tryPresize / transfer

4.2 为什么先 CAS?

  • 空桶插入是最常见路径,用 CAS 避免锁竞争,提升吞吐。
  • 只有发生 hash 冲突(同一桶)才会锁桶头节点。

4.3 synchronized 锁的是啥?会不会锁全表?

  • 锁的是桶头节点对象(first node)。
  • 粒度是桶级别,不是全表。
  • 不同桶可并发写(并发度非常高)。

面试一句话:JDK8 用 CAS 抢空桶,用 synchronized 锁桶头解决冲突写。


5. get 为什么快?(读路径基本无锁)

5.1 get 流程

  1. table(volatile)
  2. 定位 index,取桶头 Node f
  3. f.hash
    • 普通链表:遍历 next
    • 树:走红黑树查找
    • MOVED:说明在扩容,走 helpTransfer 或根据 ForwardingNodenextTable

5.2 为什么不加锁也安全?

  • 依赖 volatile 可见性 + final/不可变结构约束(节点的关键引用发布安全)
  • 写入时通过 synchronized/CAS 建立 happens-before,使读线程能看到新值。

6. 扩容(resize/transfer):JDK8 的并发扩容怎么做到的?

6.1 触发条件

  • addCount 发现元素个数超过阈值(sizeCtl 里算出来)
  • 或者 tryPresize(比如 putAll)预扩容

6.2 核心机制:多线程协作迁移(transfer)

  • 扩容不是单线程搬家,而是多个线程一起搬
  • transferIndex 作为"任务指针",线程通过 CAS 领取一段区间去迁移。
  • 迁移完成的桶会放一个 ForwardingNode(MOVED)作为标记:
    • 其他线程看到 MOVED 会去新表查
    • 或者帮忙迁移(helpTransfer)

6.3 迁移时怎么保证并发安全?

  • 迁移某个桶时,会对桶头 synchronized (f),保证链表/树结构不会在迁移过程中被并发破坏。
  • 迁移后把旧桶置为 ForwardingNode,防止重复迁移。

6.4 扩容时 put/get 会不会阻塞?

  • get 基本不阻塞:遇到 MOVED 直接去新表找。
  • put 可能会参与迁移(helpTransfer),但不是全局停顿。

高频回答:CHM 扩容是"边用边扩、多人一起搬"。


7. 链表转红黑树(树化)条件:别背错

JDK8 里桶内冲突严重时会树化,但有两个关键阈值:

  • TREEIFY_THRESHOLD = 8:链表长度达到 8 可能树化
  • UNTREEIFY_THRESHOLD = 6:树节点减少到 6 可能退化回链表
  • MIN_TREEIFY_CAPACITY = 64 :table 长度 < 64 时优先扩容而不是树化

面试常挖坑:链表到 8 不是必树化,table 太小会先扩容。


8. 计数:size() 为啥不"实时精确"?

8.1 baseCount + CounterCells(LongAdder 思路)

  • 高并发更新 size 时,如果所有线程都去 CAS 更新一个计数变量,会严重争用。
  • 所以 CHM 用分桶计数:
    • 低冲突:更新 baseCount
    • 高冲突:分散到 CounterCells[i](不同线程落不同 cell)

8.2 size() 的语义

  • size() 会把 baseCount + cells 求和。
  • 在强并发下可能会有极短窗口的误差(一般面试会说"近似值/最终一致")。
  • JDK8 里 size() 仍努力返回一个合理值,但不要把它当强一致事务计数。

9. 重要语义:null、迭代器、computeIfAbsent

9.1 为啥不允许 key/value 为 null?

  • 并发下 get(key)==null 可能表示:
    1. key 不存在
    2. value 就是 null
  • 无法区分会破坏并发语义与方法实现(比如 putIfAbsent/compute 等)。
  • 所以直接禁止 null,简单粗暴但正确。

9.2 迭代器为啥不会 CME?

  • CHM 的迭代器是 弱一致性(weakly consistent)
    • 允许迭代过程中并发修改
    • 不抛 ConcurrentModificationException
    • 可能看见修改,也可能看不见,但不会乱到破坏结构

9.3 computeIfAbsent 有哪些坑?(高级面试爱问)

  • mappingFunction 可能被调用多次(在竞争下)------不要写有副作用的逻辑(比如扣库存、发MQ)。
  • mappingFunction 里别再对同一个 map 做阻塞性操作/递归调用,否则可能死锁或性能炸裂。
  • 若函数计算很重,考虑外部做缓存击穿保护(比如本地/分布式锁)。

10. 常见面试追问清单(照着练就行)

Q1:JDK7 和 JDK8 最大变化是什么?

  • JDK7:Segment 分段锁,锁粒度是 segment。
  • JDK8:取消 segment,变成 Node 数组 + CAS + synchronized 桶级锁,并发扩容协作迁移。

Q2:put 时什么时候用 CAS,什么时候加锁?

  • 桶为空:CAS 放入新节点。
  • 桶非空:synchronized 锁桶头节点,处理链表/树插入。

Q3:扩容怎么做到"并发且正确"?

  • sizeCtl 控制扩容状态
  • transferIndex 分配迁移任务
  • ForwardingNode 标记已迁移桶
  • 多线程 helpTransfer 协作搬迁

Q4:get 为什么能无锁?

  • volatile + happens-before(写时锁/CAS 发布),读看到的是安全发布的结构。
  • 若遇到 MOVED,跳转到 nextTable 查。

Q5:为什么用 synchronized 而不是 ReentrantLock?

  • synchronized 在 JVM 层做了大量优化(偏向/轻量/自旋等),在桶级别小临界区下很划算。
  • 代码更简单,异常安全(自动释放)。

Q6:为什么树化要 table >= 64?

  • 小表冲突大多数来自容量太小,扩容能更便宜地降冲突。
  • 过早树化会引入维护红黑树的额外开销。

11. 实战建议(面试官喜欢"你真的用过"的感觉)

  • 高并发缓存:CHM + computeIfAbsent 做本地缓存,但要注意副作用/重计算问题。
  • 统计计数:别用 size() 做强一致核心指标;要强一致请用数据库/Redis 原子计数或业务幂等。
  • key 设计:尽量均匀 hash(比如避免大量相同前缀导致 hash 冲突),减少热点桶竞争。
  • 热点 key:哪怕 CHM 也扛不住"同一个 key 被海量并发更新",这种要上分片、合并、队列化或 LongAdder。

12. 一段"面试口播模板"(你可以直接背)

ConcurrentHashMap 在 JDK8 里是 Node[] + CAS + synchronized 的组合。get 基本无锁,靠 volatile 和安全发布保证可见性;put 时如果桶为空走 CAS 快路径,桶不为空就 synchronized 锁桶头做链表/红黑树插入。扩容时用 sizeCtl 控制状态,多线程通过 transferIndex 分段搬迁,搬完的桶放 ForwardingNode 标记,读写遇到 MOVED 会去新表或帮忙迁移,所以扩容是边用边扩、协作完成,不会全表阻塞。链表树化阈值是 8,但表容量小于 64 会优先扩容而不是树化。


13. 你可以再加分的点(真·高级)

  • spread(扰动函数)目的:让 hash 更均匀,减少碰撞。
  • ForwardingNode 的意义:扩容期间的"路标",让读写都能找到正确位置且避免重复迁移。
  • addCount 使用 CounterCells:高并发下减少对单个计数点的 CAS 争用(类似 LongAdder)。

14. 参考阅读(建议你面试前扫一眼源码位置)

  • java.util.concurrent.ConcurrentHashMap(JDK8+)
    • putVal, get, addCount, transfer, helpTransfer, treeifyBin
  • JDK7 旧实现:SegmentHashEntry

相关推荐
大猫和小黄16 小时前
Tomcat vs Undertow 全面对比
java·tomcat
霍田煜熙16 小时前
【无标题】
java
无忧智库16 小时前
深度拆解:某大型医院“十五五”智慧医院建设方案,如何冲刺互联互通五级乙等?(附技术架构与实施路径)
java·数据库·架构
守护砂之国泰裤辣17 小时前
Windows+docker下简单kafka测试联调
java·运维·spring boot·docker·容器
代码方舟17 小时前
Java企业级风控实战:对接天远多头借贷行业风险版API构建信贷评分引擎
java·开发语言
Maiko Star17 小时前
Word工具类——实现导出自定义Word文档(基于FreeMarker模板引擎生成动态内容的Word文档)
java·word·springboot·工具类
优雅的38度17 小时前
maven的多仓库配置理解
java·架构
周末吃鱼17 小时前
研发快速使用JMeter
java·jmeter
EntyIU17 小时前
自己实现mybatisplus的批量插入
java·后端