并发容器选型:从 ConcurrentHashMap 说起
并发容器的演进主线是「不断缩小锁的粒度」:从 Hashtable 的全表锁,到 ConcurrentHashMap 的分段锁,再到桶级锁。这篇讲清它的两代实现、CopyOnWriteArrayList 的适用边界,以及一套选型对照表。
Hashtable 和 Collections.synchronizedMap() 都能让 Map 变"线程安全",但它们的做法是给整张表加一把锁——16 个线程读 16 个不同的 key,照样排队。而 ConcurrentHashMap 在 16 个线程操作 16 个不同桶时,可以真正并行。
这中间的差别,就是并发容器二十年演进的全部内容:不断缩小锁的粒度,直到锁不再成为瓶颈。
一、为什么不用 Hashtable
先看三代 HashMap 的线程安全方案对比:
| 实现 | 加锁范围 | 读操作 | 并发度 | 问题 |
|---|---|---|---|---|
HashMap |
不加锁 | 无锁 | — | 并发写会丢数据、JDK 7 下成环 |
Hashtable |
整张表 | 加锁 | 1 | 读写全部串行,并发度极低 |
Collections.synchronizedMap |
整张表(包装器) | 加锁 | 1 | 同上,且迭代仍需手动同步 |
ConcurrentHashMap |
单个桶(JDK 8+) | 无锁 | 桶数量级 | — |
Hashtable 的问题不只是慢,还在于它的"安全"是靠牺牲全部并发换来的。一个统计接口每秒读几万次、每秒写几次,在 Hashtable 上写操作会把所有读操作一起堵住。
而 Collections.synchronizedMap 更麻烦:它只在单个方法调用上加锁,复合操作依然不安全:
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
// ❌ 依然有竞态:两个线程可能都读到 null,都执行 put
if (!map.containsKey(key)) {
map.put(key, 1);
}
即使换成 ConcurrentHashMap,下面这段依然是错的——因为竞态不在 Map 内部,而在你的两次调用之间:
// ❌ 仍然是 check-then-act 的复合操作
if (!map.containsKey(key)) {
map.put(key, 1);
}
正确写法是用原子复合方法:
map.putIfAbsent(key, 1); // 原子
map.computeIfAbsent(key, k -> new ArrayList<>()); // 原子
map.merge(key, 1, Integer::sum); // 原子
这是并发容器最重要的使用原则:能用复合方法就用复合方法,别把原子操作拆成两步。
二、ConcurrentHashMap 的两代实现
JDK 7:分段锁(Segment)
JDK 7 的思路是把一张大表切成 N 个小表,每个小表一把锁:
ConcurrentHashMap
├── Segment[0] (继承 ReentrantLock) → HashEntry[] 桶数组
├── Segment[1]
├── ...
└── Segment[15] 默认并发级别 16
定位一个 key 需要两次哈希:先算出落在哪个 Segment,再在 Segment 内部算出落在哪个桶。加锁只加在目标的那个 Segment 上。
| 维度 | JDK 7 Segment 方案 |
|---|---|
| 锁对象 | Segment(继承 ReentrantLock) |
| 锁粒度 | 一段(默认整表的 1/16) |
| 并发度 | 等于 Segment 数量,构造时固定,之后不能改 |
| 定位开销 | 两次哈希,先定位段再定位桶 |
| 内存开销 | 每个 Segment 都是一个锁对象,额外占用 |
分段锁解决了"全表锁"的问题,但引入了新问题:并发度被写死在构造参数里。初始化时定 16,业务涨到需要 64 并发也改不了,只能重建。
另一个隐忧是内存浪费:哪怕只有 3 个元素,16 个 Segment 也得全部创建出来。
JDK 8:CAS + synchronized 的桶级锁
JDK 8 做了三处结构性改动:
- 抛弃
Segment,回到与HashMap一致的Node[] table结构,只一次哈希; - 锁对象从
Segment变成桶的头节点,粒度从"一段"细化到"一个桶"; - 空桶插入用 CAS,非空桶用
synchronized,读操作完全无锁。
写入流程大致是这样:
// 简化后的 putVal 核心逻辑,展示 JDK 8 的并发策略
final V putVal(K key, V value, boolean onlyIfAbsent) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int i;
if (tab == null) {
tab = initTable(); // ① 懒初始化,用 sizeCtl 做 CAS 保证只初始化一次
} else if ((f = tabAt(tab, i)) == null) {
// ② 桶为空:直接 CAS 放进去,不加锁
if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
break;
} else if (f.hash == MOVED) {
tab = helpTransfer(tab, f); // ③ 正在扩容:当前线程也去帮忙搬数据
} else {
// ④ 桶非空:只锁住这个桶的头节点
synchronized (f) {
// ...链表遍历或红黑树插入
}
break;
}
}
addCount(1L, binCount); // ⑤ 计数走 CounterCell 分散热点
return null;
}
这段代码里有四个设计值得单独说:
① 懒初始化 + sizeCtl CAS。 表在第一次写入时才创建,用 sizeCtl 这个 volatile 字段配合 CAS 保证只有一个线程真正初始化。
② 空桶 CAS。 大部分写入落在空桶上(HashMap 默认负载因子 0.75,大量桶是空的),这类写入完全不加锁,一条 CAS 指令解决。
③ 协助扩容。 扩容时把桶标记为 ForwardingNode(MOVED),其他线程遇到这个标记不会阻塞等待,而是加入搬运行列。这把原本"一个线程搬、其他线程等"的过程变成了并行任务。
④ 桶级 synchronized。 只有发生哈希冲突时才加锁,而且只锁那一个桶的头节点。为什么这里用 synchronized 而不是 ReentrantLock?因为 JDK 6 之后 synchronized 有了偏向锁、轻量级锁的优化,在低冲突场景下开销已经很小;而每个桶配一把 ReentrantLock 会带来可观的内存开销。
计数为什么要用 CounterCell
ConcurrentHashMap.size() 的准确性一直是难点。如果用一个 AtomicInteger 计数,每次写入都 CAS 同一个变量,高并发下会形成严重的热点竞争——所有线程抢同一个缓存行。
JDK 8 的方案借鉴了 LongAdder 的分散热点思想:
// 简化示意
final long sumCount() {
CounterCell[] as = counterCells;
long sum = baseCount; // 基础计数
if (as != null) {
for (CounterCell a : as) {
if (a != null) sum += a.value; // 分散在各个 Cell 里
}
}
return sum;
}
写入时优先尝试 baseCount 的 CAS;失败了就往 counterCells 数组里的某个槽位累加,槽位按线程哈希分散。这样不同线程落在不同槽位上,几乎不产生竞争。
代价是 size() 返回的是估算值——它要遍历所有 CounterCell 求和,求和过程中可能还有写入。
所以
ConcurrentHashMap的size()是弱一致的近似值,不要用它做业务判断依据。 需要精确计数时,应该由业务维护独立的计数器(比如 Redis 的原子计数)。
两代实现对比
| 维度 | JDK 7 | JDK 8+ |
|---|---|---|
| 数据结构 | Segment[] + HashEntry[] |
Node[],与 HashMap 一致 |
| 锁粒度 | Segment(一段) | 桶头节点(一个桶) |
| 锁类型 | ReentrantLock |
空桶 CAS + 桶内 synchronized |
| 哈希次数 | 2 次 | 1 次 |
| 并发度 | 固定(等于 Segment 数) | 随表容量动态增长 |
| 读操作 | 部分需要加锁 | 完全无锁(volatile 读) |
| 扩容 | 单线程搬 | 多线程协助搬运 |
| 计数 | Segment 内独立计数 |
baseCount + CounterCell 分散 |
| 链表过长 | 一直链表 | 转红黑树(阈值 8) |
读操作为什么可以完全无锁? 因为 table 数组的元素读写用的是 volatile 语义(Unsafe.getObjectVolatile / putObjectVolatile),保证可见性;而节点的 val 和 next 也是 volatile 的。读线程要么看到旧值要么看到新值,但不会看到"写了一半"的中间态。
三、CopyOnWriteArrayList:读多写极少的特化容器
CopyOnWriteArrayList 的思路极其直白:写时复制整个数组。
public boolean add(E e) {
synchronized (lock) { // ① 写操作加锁,保证只有一个写线程
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1); // ② 复制一份新数组
es[len] = e;
setArray(es); // ③ volatile 写,切换引用
return true;
}
}
public E get(int index) {
return elementAt(getArray(), index); // ④ 读操作完全无锁,读的是快照
}
关键在于读线程拿到的永远是一个不可变的数组快照:
| 特性 | 说明 |
|---|---|
| 读操作 | 完全无锁,不需要任何同步,性能与 ArrayList 相当 |
| 写操作 | 加锁 + 全量复制,时间复杂度 O(n) |
| 内存 | 每次写入都产生一个新数组,写频繁时内存压力大 |
| 迭代器 | 快照迭代器,迭代期间不会抛 ConcurrentModificationException |
| 数据一致性 | 弱一致,迭代过程中看不到新写入的元素 |
迭代器不抛异常,是优点也是陷阱
ArrayList 在迭代中被修改会立刻抛 ConcurrentModificationException,而 CopyOnWriteArrayList 的迭代器基于快照,永远不会抛:
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("A");
for (String s : list) {
list.add("B"); // 不抛异常,但这次迭代看不到 "B"
}
这看起来很美好,但要注意:"不抛异常"不等于"你看到了最新数据"。如果业务逻辑依赖"迭代到的数据就是当前全部数据",这个假设在 CopyOnWriteArrayList 上不成立。
适用边界
| 场景 | 适合吗 | 原因 |
|---|---|---|
| 监听器列表、白名单、配置项 | ✅ | 写极少,读极多 |
| 本地缓存的路由表、字典表 | ✅ | 变更频率低,读取高频 |
| 作为消息队列使用 | ❌ | 写频繁,每次都全量复制 |
| 频繁增删的集合 | ❌ | O(n) 复制会成为瓶颈 |
| 必须读到最新数据 | ❌ | 弱一致性,可能读到旧快照 |
判断标准很简单:读操作比写操作高出几个数量级时用它,读写相当或写多读少时别碰。
四、其他常用并发容器
队列族
BlockingQueue 家族是生产者-消费者模型的基础设施,选型主要看两点:有界还是无界、锁的粒度。
| 实现 | 底层结构 | 锁粒度 | 有界 | 适用 |
|---|---|---|---|---|
ArrayBlockingQueue |
数组 | 一把锁(读写共用) | 必须指定容量 | 通用,容量可预估 |
LinkedBlockingQueue |
链表 | 两把锁(put/take 分离) | 可选(不传即无界) | 吞吐更高,但慎用无界 |
SynchronousQueue |
无容量 | — | 容量为 0 | 直接交接,CachedThreadPool 用它 |
PriorityBlockingQueue |
堆 | 一把锁 | 无界 | 带优先级的任务 |
DelayQueue |
堆 + 延迟 | 一把锁 | 无界 | 延迟任务、超时取消 |
LinkedTransferQueue |
链表 | 无锁算法 | 无界 | 高吞吐,transfer() 可直接交付 |
LinkedBlockingQueue 用两把锁(putLock / takeLock)把入队和出队分离,并发度比 ArrayBlockingQueue 的单锁更高。但它最大的坑是默认无界——不传容量参数时容量是 Integer.MAX_VALUE,下游消费不过来时会无限积压,最终把堆撑爆。
结论:队列一律显式指定容量。
原子类族
| 类 | 适用场景 | 原理 |
|---|---|---|
AtomicInteger / AtomicLong |
单变量计数,冲突不激烈 | CAS |
LongAdder / DoubleAdder |
高并发统计计数 | 分散到多个 Cell,求和时汇总 |
AtomicReference |
单个对象引用的原子更新 | CAS |
AtomicStampedReference |
需要感知修改过程的场景 | 值 + 版本号一起 CAS |
AtomicIntegerArray |
数组元素独立更新 | 元素级 CAS |
AtomicFieldUpdater |
已有类的字段原子化 | 反射 + CAS,省内存 |
LongAdder 和 AtomicLong 的取舍值得单独说:
| 维度 | AtomicLong |
LongAdder |
|---|---|---|
| 实现 | 单个 volatile long + CAS |
base + Cell[] 分散 |
| 高并发写 | 所有线程抢同一个变量,竞争激烈 | 分散到不同 Cell,几乎无竞争 |
| 高并发写吞吐 | 低 | 高 |
读(sum()) |
精确、O(1) | 需要遍历 Cell,近似值 |
| 适用 | 需要精确值、写不频繁 | 统计埋点、QPS 计数等写多读少场景 |
选型原则和 ConcurrentHashMap 的计数一致:写多读少要吞吐就 LongAdder,要精确值就 AtomicLong。
五、选型总表
| 需求 | 首选 | 备选 / 注意 |
|---|---|---|
| 高并发 KV 存储 | ConcurrentHashMap |
复合操作用 computeIfAbsent / merge |
| 需要排序的并发 Map | ConcurrentSkipListMap |
基于跳表,平均 O(log n),无锁读 |
| 读多写极少的 List | CopyOnWriteArrayList |
写频繁时别用 |
| 需要并发 Set | ConcurrentHashMap.newKeySet() |
比 CopyOnWriteArraySet 更通用 |
| 生产者-消费者队列 | ArrayBlockingQueue(有界) |
高吞吐可换 LinkedBlockingQueue,但必须指定容量 |
| 高并发计数 | LongAdder |
需要精确值用 AtomicLong |
| 延迟任务 | DelayQueue |
或专门的调度框架 |
| 线程安全的累加器 | LongAccumulator |
可自定义聚合函数 |
几条贯穿始终的原则:
- 能用现成的并发容器,就别自己拿锁拼一个——
ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue已经覆盖了绝大多数场景,而且都是经过充分验证的; - 复合操作用原子方法——
putIfAbsent、computeIfAbsent、merge,别用containsKey+put两步; - 队列必须有界——无界队列是内存泄漏的常见入口;
- 分清精确值和近似值——
ConcurrentHashMap.size()和LongAdder.sum()都是估算,别拿来做业务判断; - 弱一致要评估——
CopyOnWriteArrayList的迭代器和ConcurrentHashMap的遍历都是弱一致的,业务能否接受取决于场景。
小结
- 并发容器的主线是不断缩小锁粒度:全表锁(
Hashtable)→ 分段锁(JDK 7Segment)→ 桶级锁 + CAS(JDK 8) - JDK 8 的
ConcurrentHashMap四板斧:空桶 CAS 不加锁、非空桶synchronized只锁头节点、读完全无锁、扩容多线程协助搬运 - 锁对象从
Segment变成桶头节点后,并发度不再被构造参数写死,而是随容量动态增长 size()走baseCount+CounterCell分散热点,返回的是近似值,不能作为业务判断依据CopyOnWriteArrayList靠写时全量复制实现无锁读,只适合读多写极少的场景;它的快照迭代器不抛异常,但也不保证看到最新数据- 队列选型看两点:有界与否、锁粒度;
LinkedBlockingQueue的读写双锁并发度更高,但默认无界必须显式指定容量 - 计数场景:写多读少用
LongAdder(近似),要精确值用AtomicLong - 最重要的使用原则:复合操作要用原子方法,
containsKey+put这种两步写法在任何并发容器上都不安全
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。