前言
日常开发中,HashMap 是我们最常用的键值对存储容器,上手简单、性能优秀。但一旦放到多线程并发场景,HashMap 就会暴露出致命问题:数据覆盖、链表成环、CPU 飙高、程序死循环。
很多人会退而求其次用 HashTable,可它锁粒度太大、并发性能拉胯,根本撑不起高并发业务。
而 ConcurrentHashMap 就是为解决这个痛点而生:既保证线程安全,又拥有接近 HashMap 的高性能,成为并发编程里的标配容器。今天从使用痛点、对比差异、源码底层、核心优势、适用场景一次性讲透。
一:HashMap 为什么不能用于多线程?
- HashMap底层是数组+链表+红黑树,它的任何方法都没有加任何同步锁
- 这样就导致多线程同时执行put,扩容操作时
-
多线程同时插入元素,会发生数据覆盖,丢失数据;
-
JDK7 扩容时容易形成循环链表,调用
get()时无限遍历,导致 CPU 100%; -
无法保证复合操作(先判断再赋值)的原子性,业务逻辑错乱。
-
所以HashMap适合在单线程下使用,并发场景下会有风险
二:HashTable为什么被淘汰
HashTable 是早期线程安全 Map,原理极其粗暴:所有方法 put、get、remove 都加了 synchronized 锁,锁住整个哈希表对象。但这样做的弊端也很明显:
- 并发下读写互斥、写写互斥、读读也互斥;
- 同一时刻只能有一个线程操作,并发量大时大量线程阻塞;
- 性能极差,高并发场景完全扛不住。 相当于只给卫生间一把钥匙,所有人都要排队使用,效率极差
三:ConcurrentHashMap 凭什么又安全又快?
先看整体架构
由数组,单项链表,红黑树组成
核心思路: 缩小锁的粒度,不锁整张表,只锁局部节点,配合 CAS 无锁操作,做到读几乎无锁、写只锁当前桶。
在 JDK 1.7 中,ConcurrentHashMap 采用 分段锁(Segment) 设计,将整个哈希表分成多个 Segment,每个 Segment 内部持有一个 ReentrantLock,并管理一个 HashEntry<K,V>[] 数组。
// JDK 1.7 简化结构
static final class Segment<K,V> extends ReentrantLock {
transient volatile HashEntry<K,V>[] table;
// ...
}
final Segment<K,V>[] segments;
- 并发度:默认 16,即最多允许 16 个线程同时写入不同的分段。
- put 流程:对 key 的 hash 值取模定位到 Segment,加锁后执行链表插入。
- get 流程:无锁读取,因为
HashEntry的 value 和 next 都是volatile修饰。 - size 计算:先尝试不加锁遍历所有 Segment 计算两次,若结果一致则返回;否则对所有 Segment 加锁重算。
缺点:当并发线程数超过分段数时,锁竞争依然存在;内存占用高(每个 Segment 单独 lock);扩容只影响单个 Segment,整体容量受限。
在JDK8中解决了这个问题
JDK 8 彻底废弃分段锁,采用 Node 数组 + CAS + synchronized 的组合策略,将锁粒度细化到每个桶(bin)的头节点。同时引入红黑树(当链表长度 > 6 且数组长度 ≥ 64 时)优化极端冲突下的查找性能。
通过- 通过 volatile 修饰数组节点,保证可见性,get 全程无锁,效率极高。
往空桶放数据时,先用 CAS 无锁自旋 尝试写入,成功就不用加锁。
CAS 竞争失败后,只对当前哈希桶的头节点加 synchronized 锁,只锁一个桶,不锁整个表。
扩容、树化、迁移都采用分段迁移、并发协助扩容多个线程可以一起帮忙扩容,大幅减少扩容阻塞时间。
// 存储桶的数组,首次使用时初始化,容量总是 2 的幂
transient volatile Node<K,V>[] table;
// 扩容时使用的新数组(仅在扩容期间有效)
private transient volatile Node<K,V>[] nextTable;
// 控制标识符,含义复杂:
// -1:表示正在初始化
// -N:表示有 N-1 个线程正在进行扩容
// 正数:表示下次触发扩容的阈值(容量 * 负载因子)
private transient volatile int sizeCtl;
// 基础计数(不加锁时使用)
private transient volatile long baseCount;
// 辅助计数数组,用于高并发下减少对 baseCount 的竞争
private transient volatile CounterCell[] counterCells;
构造方法:延迟初始化
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
// sizeCtl 暂存初始容量,真正的 table 在首次 put 时创建
this.sizeCtl = cap;
}
tableSizeFor 保证容量为 2 的幂,与 HashMap 类似
put操作
final V putVal(K key, V value, boolean onlyIfAbsent) {
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;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 1. 初始化数组
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 2. 桶为空,CAS 插入新节点
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
else if ((fh = f.hash) == MOVED)
// 3. 当前桶正在扩容,协助迁移
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 4. 对桶头节点加锁(synchronized 细粒度锁)
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 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;
}
}
}
}
if (binCount != 0) {
// 链表长度超过 TREEIFY_THRESHOLD(8),尝试转红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount); // 5. 计数,并触发扩容检查
return null;
}
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;
}
- 完全无锁:
table和Node.val都被volatile修饰,保证读取的可见性。 - 弱一致性:遍历过程中若有其他线程修改了链表,不会抛出
ConcurrentModificationException,可能读取到旧值。
扩容机制 transfer() 与多线程协助
当 put 后元素个数超过阈值 sizeCtl 时,触发扩容。扩容流程核心由 transfer 方法执行,支持多线程并发迁移。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 每个线程负责迁移的桶数(最少 16)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
if (nextTab == null) { // 初始化新数组(2倍容量)
// ... 创建 nextTab
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<>(nextTab); // 转发节点标记已迁移
boolean advance = true;
boolean finishing = false;
for (int i = 0, bound = 0;;) {
// 分配任务区间 [bound, i]
while (advance) {
// ...
}
// 迁移当前桶
Node<K,V> f;
if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd); // 空桶直接标记转发
else if ((fh = f.hash) == MOVED)
advance = true;
else {
synchronized (f) { // 锁住头节点,迁移链表/树
// 将原桶中的节点拆分为低位链和高位链(类似 HashMap)
// 放入 nextTab 的 i 和 i+n 位置
}
}
}
if (finishing) {
table = nextTab; // 完成,更新 table 引用
sizeCtl = (n << 1) - (n >>> 1); // 重新计算阈值
}
}
sizeCtl高位记录参与扩容的线程数(例如sizeCtl = - (参与线程数 + 1))。- 每个线程通过
transferIndex字段分配一段连续的桶区间,独立迁移。 - 迁移完一个桶后设置
ForwardingNode(hash 值MOVED=-1),其他线程遇到它时会跳过或协助。 - 最后完成时,最后一个退出的线程负责重置
table和sizeCtl。
5. 计数:size() 与 mappingCount()
高并发下全局计数器的维护很有技巧:
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
sumCount() 将 baseCount 与 counterCells 数组内的所有值累加。addCount() 逻辑:
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// 优先使用 counterCells 累加(分散热点)
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
// CAS 失败则使用 counterCells
fullAddCount(x, unbranded);
}
// 检查是否需要扩容
if (check >= 0) {
// ...
}
}
- 每个
CounterCell对象独占一个缓存行(@sun.misc.Contended避免伪共享),减少竞争。 size()并不是实时精确值,但能保证最终一致性。
6. 树化与反树化
treeifyBin():当链表长度 ≥TREEIFY_THRESHOLD(8) 时,尝试将链表转为红黑树。但如果数组长度 <MIN_TREEIFY_CAPACITY(64),会先扩容。- 红黑树查找时间复杂度 O(log n),显著提升冲突链表的性能。
- 当红黑树节点数 ≤
UNTREEIFY_THRESHOLD(6) 时,在resize过程中会退化为链表。
四、并发设计核心技巧
- Unsafe + volatile
大量使用U.getObjectVolatile、U.compareAndSwapObject等底层操作,保证无锁读写的可见性和原子性。 - 锁粒度最小化
只在修改链表头节点或红黑树根节点时使用synchronized,不同桶之间无竞争。 - 伪共享优化
CounterCell使用@Contended注解填充缓存行,避免多线程频繁修改相邻内存地址导致的缓存失效。 - 弱一致性迭代器
KeySet、Values等视图迭代器不抛出ConcurrentModificationException,遍历过程中修改集合不会影响已遍历元素,且不保证一定看到最新修改。 - 扩容友好
多线程共同迁移数据,大幅缩短扩容停顿时间;ForwardingNode实现读写过程中扩缩容的无缝衔接。