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 val和next多为 volatile(可见性保证)- 红黑树节点:TreeNode(当链表过长会树化)
- 扩容迁移标记:ForwardingNode(hash 为
MOVED)
4. JDK8 的 put 全流程(面试高频,让你讲清楚"先 CAS 再锁桶")
4.1 关键步骤概览
- table 未初始化 :触发
initTable()(CAS 控制只有一个线程初始化) - 计算 hash(spread)并定位 index
- 桶位为空:
CAS直接放入新 Node(无锁快路径) - 桶位不为空:进入桶级别加锁(
synchronized (f))- 链表:遍历替换/尾插
- 红黑树:树操作(putTreeVal)
- 插入后
addCount()更新计数;必要时触发扩容tryPresize / transfer
4.2 为什么先 CAS?
- 空桶插入是最常见路径,用 CAS 避免锁竞争,提升吞吐。
- 只有发生 hash 冲突(同一桶)才会锁桶头节点。
4.3 synchronized 锁的是啥?会不会锁全表?
- 锁的是桶头节点对象(first node)。
- 粒度是桶级别,不是全表。
- 不同桶可并发写(并发度非常高)。
面试一句话:JDK8 用 CAS 抢空桶,用 synchronized 锁桶头解决冲突写。
5. get 为什么快?(读路径基本无锁)
5.1 get 流程
- 读
table(volatile) - 定位 index,取桶头
Node f f.hash:- 普通链表:遍历 next
- 树:走红黑树查找
MOVED:说明在扩容,走helpTransfer或根据ForwardingNode去nextTable查
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可能表示:- key 不存在
- 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 旧实现:
Segment、HashEntry