ConcurrentHashMap 超详细详解
ConcurrentHashMap 超详细详解
一、基础定位
ConcurrentHashMap是 Java 线程安全的哈希表,替代老旧线程安全集合Hashtable,同时弥补HashMap并发下死循环、数据丢失问题:
- HashMap:并发读写会链表循环、数据覆盖,线程不安全;
- Hashtable:全表加
synchronized锁,粒度极大,并发性能极差; - ConcurrentHashMap:分段锁 / 分段 CAS+synchronized,锁粒度极小,高并发读写性能优秀。
版本区分核心:
- JDK7:分段锁 Segment,底层数组 + 链表,多段独立锁;
- JDK8/JDK9/JDK17:取消 Segment,CAS + 节点 synchronized,底层数组 + 链表 / 红黑树,锁粒度降到哈希桶单个节点。
二、JDK 7 实现原理(过时,仅做对比理解)
1. 底层结构
plaintext
[Segment[HashEntry链表], Segment[], ...]- 外层:
Segment[]分段数组,默认 16 段,每段独立锁; - 内层:每个 Segment 内部是
HashEntry<K,V>[]哈希桶,桶下挂单向链表; Segment继承ReentrantLock,自身就是锁对象。
2. 并发机制
- 写操作(put/remove):先定位 key 对应 Segment,调用
lock()独占锁,操作完成释放;只会锁住一个分段,其他分段并发读写不受影响; - 读操作(get):无锁,HashEntry 的
value、next用volatile修饰,保证内存可见性,读到最新值;仅 value 为 null 时加锁重读; - 扩容:单段独立扩容,互不干扰。
缺陷
- 默认分段 16,并发上限固定;分段越多内存占用越高;
- 链表查询效率低,hash 冲突严重时 O (n);
- size () 统计总数需要锁住全部 Segment,并发统计开销大。
三、JDK 8 核心实现(主流版本,重点)
3.1 底层数据结构
和 HashMap 结构统一:数组 (Node []) + 单向链表 + 红黑树
- 哈希桶数组:
transient volatile Node<K,V>[] table,volatile 保证数组可见; - Node:普通链表节点,
val、next均为volatile; - TreeNode:红黑树节点,冲突链表长度≥8 转红黑树,≤6 退回链表;
- ForwardingNode:扩容标记节点,table 扩容时旧桶挂载该节点,线程碰到协助扩容;
- ReservationNode:computeIfAbsent 计算占位节点。
3.2 并发核心机制:CAS + synchronized(取消 Segment)
锁粒度从分段缩小到单个哈希桶头节点,并发能力大幅提升:
- 无锁 CAS:初始化 table、空桶插入新节点、扩容转移节点,全部用 CAS 乐观锁,无阻塞;
- 桶头 synchronized 锁:哈希桶已有数据时,锁住当前桶的首节点,仅阻塞同一个 hash 桶的并发操作,不同桶完全并发;
- volatile 关键字:table、节点 val/next、size 计数器保证多线程内存可见。
CAS 关键方法
java
运行
// 比较并替换数组指定下标节点,成功返回true static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) // 获取指定下标节点 static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) // 设置指定下标节点 static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v)3.3 核心属性
java
运行
// 哈希表数组,初始化懒加载(第一次put才创建) transient volatile Node<K,V>[] table; // 扩容时临时转移数组,非扩容时为null private transient volatile Node<K,V>[] nextTable; // 计数器控制:高16位记录size,低16位存储扩容阈值/标记 private transient volatile long baseCount; // 扩容阈值,容量*负载因子(默认0.75) private transient int threshold; // 扩容控制标记:负数=扩容中,正数=扩容并行线程数 private transient volatile int sizeCtl;sizeCtl 状态说明:
- 0:table 未初始化;
0:初始化 / 扩容阈值;
- -1:正在初始化;
- 负数(n<=-2):
-(1+扩容线程数),代表多线程协同扩容。
3.4 put () 完整流程(核心源码逻辑)
java
运行
public V put(K key, V value) { return putVal(key, value, false); } final V putVal(K key, V value, boolean onlyIfAbsent) { // 不允许key/value为null(HashMap允许key一个null、多个null value) if (key == null || value == null) throw new NullPointerException(); // 1. 计算hash,扰动函数减少冲突 int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { // 自旋循环,直到插入成功 Node<K,V> f; int n, i, fh; // 分支1:table未初始化,CAS初始化数组 if (tab == null || (n = tab.length) == 0) tab = initTable(); // 分支2:当前桶为空,CAS直接新建Node放入桶,成功跳出循环 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; } // 分支3:当前桶是ForwardingNode,说明正在扩容,当前线程协助扩容 else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 分支4:桶已有数据,锁住桶头节点f,单桶独占锁 else { V oldVal = null; synchronized (f) { // 双重校验:防止锁获取前桶头节点被替换 if (tabAt(tab, i) == f) { // 链表节点 if (fh >= 0) { binCount = 1; // 遍历链表,key存在则覆盖,不存在尾插 for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); break; } } } // 红黑树节点 else if (f instanceof TreeBin) { Node<K,V> p; binCount = 2; // 红黑树插入 if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } // 链表长度≥8,转为红黑树 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } // 累加元素数量,触发扩容判断 addCount(1L, binCount); return null; }关键特性:
- 禁止 null key /null value:HashMap 允许,ConcurrentHashMap 直接抛 NPE,避免并发下空值歧义;
- 懒加载:第一次 put 才初始化 table,节省内存;
- 空桶无锁 CAS 插入,性能极高;
- 遇到扩容节点主动协助转移,多线程并行扩容;
- 仅锁住单个桶头,其他桶并发读写完全不受阻塞。
3.5 get () 无锁读取流程
读操作全程不加锁,依靠volatile可见性保证数据实时:
- 计算 hash 定位桶下标;
- 获取桶头节点,volatile 读取最新引用;
- 链表 / 红黑树遍历匹配 key,直接返回 val;
- 若读到 ForwardingNode(扩容中),调用 find 协助读取转移中的数据。
无锁设计让读操作几乎无性能损耗,高并发读场景优势巨大。
3.6 扩容机制(transfer 多线程协同扩容)
- 触发条件:
addCount计数后元素数量 ≥ threshold(容量 * 0.75); - 扩容容量:原容量 * 2;
- 多线程协同:
- 单线程扩容效率低,其他线程 put/get 时发现
ForwardingNode,主动进入helpTransfer分担桶迁移任务; - 每个线程分配一段哈希桶迁移,迁移完成标记 ForwardingNode;
- 单线程扩容效率低,其他线程 put/get 时发现
- 迁移逻辑:每个桶内链表拆分为低位链表(原下标)、高位链表(原下标 + 旧容量),分别挂载到新 table;
- 扩容期间读写正常可用,不会阻塞全局。
3.7 size () 元素总数统计
JDK7 需要锁住全部 Segment,性能差; JDK8 采用分段计数器 baseCount + CounterCell 数组(类似 LongAdder):
- 低并发:直接 CAS 修改
baseCount; - 高并发竞争:线程分散写入不同
CounterCell; - size () = baseCount + 所有 CounterCell 累加和; 无需加全局锁,统计性能大幅提升。
四、核心方法与并发特性对比
1. 读写分离优势
- 读:无锁,volatile 可见;
- 写:仅锁单个哈希桶,不同桶并行写入; 远优于 Hashtable 全局锁。
2. 常用方法并发语义
get(key):无阻塞,返回最新可见值;put(k,v):覆盖旧值,单桶互斥;putIfAbsent(k,v):key 不存在才插入,原子操作;remove(k)/remove(k,v):原子删除,锁桶头;computeIfAbsent(k, func):不存在则执行函数计算 value 插入,全程原子;compute(k, func):存在则更新,原子操作;merge(k, val, func):合并更新,原子; 以上复合操作都是原子操作,并发下不会出现数据竞争问题,HashMap 无原子复合方法。
五、和 HashMap、Hashtable 完整对比
表格
| 特性 | HashMap | Hashtable | ConcurrentHashMap(JDK8) |
|---|---|---|---|
| 线程安全 | 不安全 | 安全 | 安全 |
| 锁机制 | 无锁 | 方法全量 synchronized | CAS + 桶节点 synchronized |
| null 键值 | 允许 1 个 null key,任意 null value | 不允许 null | 不允许 null key/value |
| 底层结构 | 数组 + 链表 + 红黑树 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 并发性能 | 高(单线程)极低(并发) | 极低,全局锁 | 极高,细粒度锁 |
| 扩容 | 单线程 | 单线程 | 多线程协同扩容 |
| 复合原子操作 | 无 | 有但全局锁 | 丰富原子 API |
| 初始容量 | 16 | 11 | 16 |
| 负载因子 | 0.75 | 0.75 | 0.75 |
六、常见面试高频考点
为什么废弃 Segment 分段锁?Segment 本质是 ReentrantLock,内存开销大;并发上限固定 16;JDK8 锁粒度细化到单个桶,并发度更高、内存占用更小。
为什么不允许 null key/value?并发场景下,get 返回 null 无法区分:key 不存在 /value 本身是 null;无锁读无法同步判断,容易产生业务逻辑歧义。
扩容时其他线程读写是否阻塞?不会全局阻塞:读可正常访问旧 table,写线程会协助扩容,扩容完成切换新表,全程可用。
synchronized 锁桶头为什么安全?同一 hash 桶的元素只会竞争同一个头节点锁;不同桶互不干扰,锁粒度极小;插入前双重校验桶头引用,防止 CAS 替换后锁失效。
并发下 HashMap 死循环,ConcurrentHashMap 为什么不会?HashMap 扩容单线程头插法,并发转移链表形成循环; ConcurrentHashMap 扩容多线程尾拆分链表,且迁移时加桶锁,不会出现环形链表。
size () 为什么不用加全局锁?使用 LongAdder 思想,baseCount+CounterCell 分散计数,牺牲微小精度换取超高并发统计性能;最终求和是近似值(统计过程有新增删除),无法做到精准瞬时总数。
七、使用场景与注意事项
使用场景
- 多线程同时读写哈希表,替代 Hashtable、
Collections.synchronizedMap(new HashMap()); - 高并发缓存、本地消息映射、多线程计数映射。
注意事项
- 复合操作优先使用内置原子方法(putIfAbsent/computeIfAbsent),不要
if(get()==null) put(),非原子会并发覆盖; 错误示例:java
运行
// 线程不安全 if (map.get("key") == null) { map.put("key", 1); } // 正确原子写法 map.computeIfAbsent("key", k -> 1); - 迭代器是弱一致性,不是快速失败(fail-fast):遍历期间其他线程新增 / 删除,迭代器不会抛
ConcurrentModificationException,可能读到部分新数据;HashMap 迭代器 fail-fast,并发修改直接报错; - 大批量数据读写可合理设置初始容量,减少频繁扩容开销;
- 不要存储 null,否则直接 NPE。