ConcurrentHashMap 是并发场景下首选的线程安全哈希表,用来替代老旧的 HashTable 以及Collections.synchronizedMap。很多开发者只知道它线程安全,锁粒度比 HashTable 更细,但重点考察:JDK7 分段锁设计思想、JDK8 为什么废弃 Segment、CAS+synchronized 锁桶头节点原理、多线程协助扩容机制、Node 节点 volatile 修饰的意义、复合操作不具备原子性这个高频踩坑点。
本文基于 JDK7 与 JDK8 源码对比,从底层结构、核心成员变量、put/get/remove 源码流程、扩容机制、并发安全保障、常见误区、开发踩坑完整讲解,吃透并发哈希表。
前言
HashMap 是非线程安全,并发 put 会丢失数据;HashTable 所有方法直接加synchronized锁住整个哈希表,同一时间只能一个线程读写,并发性能极差,已经基本淘汰。Collections.synchronizedMap本质也是对所有方法加全局锁,和 HashTable 类似,并发性能差。
ConcurrentHashMap(简称 CHM):分段锁 / 桶级细粒度锁,允许多个线程操作不同哈希桶,并发读写性能大幅提升。
版本差异巨大:JDK7 采用 Segment 分段锁;JDK8 重构底层,取消 Segment,改用
CAS + synchronized锁桶头节点,是面试重点。
一、JDK7 ConcurrentHashMap 原理(了解即可)
底层结构
Segment<K,V>[] segments分段数组,每个 Segment 内部维护一张HashEntry<K,V>[] table哈希表。 Segment 继承ReentrantLock,Segment 本身就是一把独立可重入锁。
- 默认 Segment 数量 16,代表最大并发度为 16;
- 操作 KV 时,先定位 key 属于哪一个 Segment,只锁住当前 Segment,其他 Segment 不受影响;
- 不同 Segment 可以并发读写,相比 HashTable 全局锁,并发能力提升。
HashEntry 节点:
static final class HashEntry<K,V> { final int hash; final K key; volatile V value; volatile HashEntry<K,V> next; }value与next用 volatile 修饰,保证多线程可见性。
JDK7 缺陷
- Segment 数量一旦初始化无法扩容,固定 16 段,并发上限固定;
- 结构复杂,内存开销大;
- 不支持多线程协助扩容,扩容时只有当前线程负责迁移数据,扩容速度慢;
- 哈希冲突严重时,单个 Segment 内部链表过长,查询退化为 O (n)。
JDK8 彻底废弃 Segment,重构底层,结构和 HashMap 保持一致:数组 + 链表 + 红黑树。
二、JDK8 ConcurrentHashMap 核心成员变量 & Node 节点
// 初始容量16,2的幂 static final int DEFAULT_CAPACITY = 16; // 最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; // 默认负载因子0.75,和HashMap一致 static final float LOAD_FACTOR = 0.75f; // 链表转红黑树阈值 static final int TREEIFY_THRESHOLD = 8; // 红黑树退链表阈值 static final int UNTREEIFY_THRESHOLD = 6; // 树化最小数组容量 static final int MIN_TREEIFY_CAPACITY = 64; // 协助扩容的线程数量上限 static final int MAX_RESIZERS = (1 << (31 - RESIZE_STAMP_BITS)) - 1; // 哈希表数组 transient volatile Node<K,V>[] table; // 扩容时的新数组,迁移完成后赋值给table private transient volatile Node<K,V>[] nextTable; // 元素总个数,用CAS更新 private transient volatile long baseCount; // 控制扩容、标记桶状态 private transient volatile int sizeCtl;Node 节点源码
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; Node(int hash, K key, V val, Node<K,V> next) { this.hash = hash; this.key = key; this.val = val; this.next = next; } }重点:val、next使用volatile修饰。 保证多线程间可见性,读操作 get 不需要加锁,直接读取最新值。
特殊节点:
- ForwardingNode:hash 为
MOVED(-1),标记当前桶已经迁移到新数组,代表哈希表正在扩容;其他线程碰到这个节点,会协助扩容。- TreeNode:红黑树节点。
三、sizeCtl 变量(JDK8 核心控制标记)
sizeCtl是控制哈希表初始化、扩容的核心变量,不同值含义:
- 负数:代表正在初始化或者扩容;
-1:数组正在初始化;- 小于 - 1:低 16 位代表参与扩容的线程数量;
- 0:table 未初始化;
- 正数:代表扩容阈值(容量 * 负载因子),和 HashMap threshold 作用一样。
四、JDK8 put () 完整流程(重点)
public V put(K key, V value) { return putVal(key, value, false); } final V putVal(K key, V value, boolean onlyIfAbsent) { // 不允许key、value为null! if (key == null || value == null) throw new NullPointerException(); 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修改sizeCtl if (tab == null || (n = tab.length) == 0) tab = initTable(); // 2. 根据hash定位桶,桶为空,CAS尝试插入新节点 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); else { // 4. 桶不为空,对桶头节点加synchronized锁 V oldVal = null; synchronized (f) { // 双重校验:防止加锁期间桶头节点被修改 if (tabAt(tab, i) == f) { if (fh >= 0) { // 单向链表 binCount = 1; 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 TreeNode) { // 红黑树节点新增 binCount = treeNodePutVal((TreeNode<K,V>)f, tab, hash, key, value); } } } // 链表长度>=8,触发树化判断 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); // 更新元素计数,判断是否触发扩容 return null; }put 流程文字总结
- 校验 key/value 不能为 null;
- table 为空则调用
initTable()初始化数组; - 计算 hash 定位桶下标;桶为空,CAS 无锁尝试插入节点;CAS 失败自旋重试;
- 如果桶标记为 MOVED(ForwardingNode),当前线程协助扩容;
- 桶存在节点,锁住桶头节点
synchronized(f);双重校验防止桶头变更; - 如果是链表:遍历找到相同 key 覆盖 value;尾部新增节点;
- 如果是红黑树,走红黑树新增逻辑;
- 链表长度达到阈值触发 treeifyBin 树化判断;
addCount更新元素总数,判断是否触发扩容。
锁粒度:只锁住单个哈希桶的头节点。不同桶可以并发写入,并发度远高于全局锁。
五、get 查询流程(无锁读取)
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }get 全程不加锁。依靠 Node 中volatile val、next保证可见性,线程可以读到最新数据。
弱一致性:get 无法保证实时最新快照,在读的同时其他线程在修改,读到的是修改前的旧值。适合读多场景,不适合强一致性业务。
六、多线程协助扩容 transfer(JDK8 核心亮点)
当元素数量超过阈值,触发扩容:新数组nextTable容量是原数组 2 倍。
- 触发扩容的线程负责迁移桶数据;
- 其他线程执行 put/get 操作,碰到
ForwardingNode(MOVED),主动协助一起迁移数据; - 多个线程分工迁移不同哈希桶,加速扩容;
- 所有桶迁移完成后,将
nextTable赋值给 table,nextTable 置空。
这是 JDK8 巨大优化:扩容不再是单线程任务,多线程分担迁移压力,扩容耗时降低。
七、JDK7 VS JDK8 ConcurrentHashMap 全面对比
| 对比项 | JDK7 ConcurrentHashMap | JDK8 ConcurrentHashMap | |
|---|---|---|---|
| 底层结构 | Segment 数组 + HashEntry 单向链表 | Node 数组 + 链表 + 红黑树 | |
| 锁实现 | Segment 继承 ReentrantLock,分段锁 | CAS + synchronized 锁单个桶头节点 | |
| 锁粒度 | 分段锁,锁一段哈希表 | 桶级锁,只锁当前链表 / 红黑树根节点 | |
| 并发扩容 | 不支持多线程协助扩容 | 支持多线程协助迁移数据 | |
| 红黑树 | 无 | 链表≥8 且数组≥64 转为红黑树,≤6 退链表 | |
| key/value | null | 不允许 key/value 为 null | 不允许 key/value 为 null |
| size 统计 | 分段计数累加 | baseCount + 计数器数组,CAS 更新 |
八、为什么 ConcurrentHashMap 不允许 key、value 为 null?(高频)
HashMap 允许一个 null key,value 可以多个 null。 CHM 不允许,原因: 调用map.get(key)返回 null 时,无法区分两种情况:
- key 不存在;
- key 存在,value 本身就是 null。
在并发场景下,你无法通过containsKey(key)来辅助判断,因为get和containsKey两个操作之间,其他线程可能修改 map,判断失效。 HashMap 是单线程场景使用,开发者可以自己控制;ConcurrentHashMap 面向并发,为了消除歧义,直接禁止 null。
九、最容易踩的大坑:
单个方法线程安全,复合操作不具备原子性
重点!CHM 只能保证单独方法是线程安全,多个方法组合在一起,不是原子操作,并发会出现竞态条件。
示例:先判断 key 不存在,再 put。
// 并发下不安全! if(!chm.containsKey("key")){ chm.put("key","value"); }并发场景:线程 A 执行完 containsKey,还没有 put;线程 B 同时也执行 containsKey,两个线程都认为 key 不存在,先后执行 put,后 put 覆盖前面的值,数据丢失。
解决方案:
1.使用 CHM 自带原子方法putIfAbsent("key","value");(推荐,底层 CAS + 锁保证原子)
chm.putIfAbsent("key","value");2.或者外部手动加锁。
同类复合操作:get + put、get + remove,都不是原子操作。
十、size () 元素计数原理
CHM 的元素总数不是简单一个变量。采用baseCount+CounterCell[]分段计数:
- 竞争低的时候,CAS 更新 baseCount;
- 并发竞争激烈时,线程分散到不同 CounterCell 计数,减少 CAS 竞争; size () = baseCount + 累加所有 CounterCell 的值。
好处:高并发计数,减少 CAS 失败自旋,提升性能。
十一、开发实战踩坑清单
- 误以为 CHM 全场景线程安全:复合操作(containsKey+put)非原子,要用
putIfAbsent、computeIfAbsent等原子 API。 - key/value 不能传入 null,直接抛出空指针。
- get 是弱一致性,读取时可能读到正在迁移 / 修改的旧数据,不适合强一致性业务。
- 扩容时大量桶迁移,CPU 开销上升;预估容量初始化,减少扩容次数。
- 迭代遍历 CHM,遍历过程中集合修改,不会抛出
ConcurrentModificationException,使用弱一致性迭代器。
HashMap 迭代器是快速失败 fail-fast;CHM 迭代器是弱一致 weakly consistent。
十二、常用 API 介绍
| 方法 | 说明 | 是否原子 |
|---|---|---|
| put(k,v) | 新增 / 覆盖键值对 | 原子 |
| get(k) | 获取 value | 原子(弱一致性) |
| remove(k) | 删除 key | 原子 |
| putIfAbsent(k,v) | key 不存在才 put | 原子 |
| computeIfAbsent(k,func) | 不存在则执行函数生成 value 存入 | 原子 |
| containsKey(k) | 判断 key 是否存在 | 原子 |
| size() | 获取元素总数 | 原子,但只是近似值 |
十三、问答
Q1:JDK8 为什么废弃 JDK7 的 Segment 分段锁?
A:Segment 结构笨重,分段数量固定,内存开销大。改用 CAS + synchronized 锁桶头节点,锁粒度更细;支持多线程协助扩容;底层复用 HashMap 的链表红黑树结构,代码更精简,并发性能更好。
Q2:ConcurrentHashMap get 为什么不用加锁?
A:Node 内部 val、next 使用 volatile 修饰,保证多线程可见性。读操作可以直接读取最新值,无需加锁。但是是弱一致性,并发修改时可能读到旧值。
Q3:CHM 为什么不允许 key 和 value 为 null?HashMap 却可以?
A:并发场景下 get 返回 null,无法区分 key 不存在,还是 value 等于 null。HashMap 单线程使用,可以配合 containsKey 判断;CHM 并发下 containsKey 和 get 之间数据会被其他线程篡改,无法区分,直接禁止 null。
Q4:CHM 复合操作为什么不安全?如何解决?
A:containsKey + put 是两次独立方法,中间可以被其他线程打断,不是原子。优先使用putIfAbsent、computeIfAbsent等内置原子方法。
Q5:CHM 迭代器是快速失败吗?
A:不是。CHM 迭代器是弱一致性迭代器,遍历期间其他线程修改集合,不会抛出 ConcurrentModificationException,能遍历到迭代创建时的快照附近数据。HashMap 迭代器是 fail-fast 快速失败。
Q6:JDK8 中 synchronized 锁对象是什么?
A:哈希桶的头节点 Node 对象。只有同一个桶的线程才竞争同一把锁,不同桶可以并发写入。
结语
ConcurrentHashMap 是 Java 并发集合的核心。学习重点要理解锁粒度的进化:HashTable 全局锁 → JDK7 分段锁 → JDK8 桶级锁。 很多人容易踩坑,误以为 CHM 所有场景都线程安全,忽略复合操作非原子这个核心考点。开发并发业务,优先使用它内置的原子方法,避免自己拼接多个方法造成并发 bug。