ConcurrentHashMap:高并发下的键值容器
更新时间:2026-09-20。先有结论:并发环境不要用 HashMap,也别用 Hashtable,用 ConcurrentHashMap。本篇讲清为什么,以及它在 JDK7→JDK8 的实现演进。前置:HashMap 底层原理,锁与 CAS 基础见 JUC 锁与 AQS。
一、HashMap 为什么线程不安全
java
Map<String,Integer> m = new HashMap<>();
// 1000 个线程各 put 不重复的 key,最后 size 经常小于 1000- JDK8 丢数据/覆盖:两个线程同时 put 到同一个空桶,都通过了「该位为 null」的判断后各自写入,后写覆盖先写,键值对丢失。
size++丢更新:size 自增是「读-改-写」非原子操作,并发下计数偏小。- JDK7 扩容头插法:并发 transfer 可能形成环形链表,get 时 CPU 100% 死循环(JDK8 改尾插已消除死链,但仍不线程安全)。
- HashMap 的
modCount还会让并发遍历抛ConcurrentModificationException(fail-fast)。
Hashtable 和 Collections.synchronizedMap 虽然安全,但每个方法都锁住整张表,多线程读写互相串行,吞吐很低。
二、JDK7:分段锁 Segment
JDK7 把整张表拆成多个 Segment(默认 16 段),每段是一把独立的锁、各带一个小数组:不同段的操作可真正并行,并发度约等于段数。缺点是结构复杂、两次哈希,粒度仍偏粗。
三、JDK8:CAS + synchronized 锁桶头节点
bash
table: [ 桶0 ][ 桶1 ][ 桶2 ] ...
│
┌─────┴─────┐
空桶:CAS 无锁写入
非空桶:synchronized(头节点),只锁这一桶
链表过长(>8):树化为红黑树put 流程的关键设计:
- 桶为空:用
CAS直接把新节点写进去,不加锁(无竞争零成本)。 - 桶非空:才用
synchronized锁住该桶的头节点,锁粒度从「整表/整段」缩小到「单个桶」,不同桶完全并行。 - 链表 > 8 且表长 ≥ 64 树化成红黑树,和 HashMap 一样缓解哈希冲突。
- size 计数:用
baseCount+ 一组CounterCell分散计数(类似 LongAdder),减少多线程竞争同一个计数器;size() 是求和的近似值。 - 读(get)全程不加锁:节点的 value/next 用
volatile语义保证可见性。

四、与其他方案对比
| 方案 | 安全 | 锁粒度 | 适用 |
|---|---|---|---|
| HashMap | 否 | — | 单线程 |
| Hashtable / synchronizedMap | 是 | 整表 | 遗留/低并发 |
| ConcurrentHashMap | 是 | 桶头节点(多数操作近乎无锁) | 高并发读写 |
Map.of() 不可变 | 是 | — | 只读常量 |
坑
- 把单个复合操作当原子:
putIfAbsent是原子的,但if(!containsKey(k)) put(k,v)这两步不是;要用putIfAbsent/computeIfAbsent/merge表达 check-then-act。 - 存 null 键或 null 值:ConcurrentHashMap 直接抛 NPE(HashMap 允许),目的是在并发下消除「不存在」与「值为 null」的二义性。
- 依赖 size() 精确值:高并发下它是各计数单元求和的弱一致结果,遍历也是弱一致性(不抛 ConcurrentModificationException,但不反映迭代中的更新)。
- 用它当强一致分布式缓存:它只保证线程安全,不保证多 JVM 一致,跨进程缓存要用 Redis 等。
- JDK7 知识点过时:面试讲 Segment 即可,实战都是 JDK8 的 CAS+桶锁,别再按分段锁描述线上版本。
总结
HashMap 并发 put 会丢数据、JDK7 扩容还可能成环,Hashtable 又用整表锁牺牲吞吐;ConcurrentHashMap 在 JDK7 用 Segment 分段锁,JDK8 进一步演进为「空桶 CAS 无锁写入、非空桶 synchronized 只锁桶头节点、get 靠 volatile 无锁、size 用 CounterCell 分散计数、长链树化」,把锁粒度降到单桶实现高并发;但复合操作要用 putIfAbsent/computeIfAbsent 保证原子、且不接受 null 键值。 底层锁与 CAS 机制见 JUC 锁与 AQS。