一段本该很快的代码,把 HashMap 换成了 ConcurrentHashMap,压测时吞吐没变好,反而在某个场景下更慢了——因为那个场景是单线程高频写入,而无锁实现的 CAS 循环在有竞争时并不占优,反而比一把普通锁更贵。
并发容器的选型常被简化成"线程安全就用 CHM"。线程安全只是最低门槛,真正的判据是访问模式:谁在读、谁在写、比例多少、要不要顺序、能不能容忍弱一致。
1. 先分清四类访问模式
同样是"线程安全",四种模式的正确工具完全不同。
| 访问模式 | 特征 | 首选结构 | 为什么 |
|---|---|---|---|
| 读多写极少 | 写几乎只发生在初始化 | 不可变结构 / 快照替换 | 读不需要同步 |
| 读写均衡 | 读写都在热点路径 | 分段锁 / CHM | 并发度与开销平衡 |
| 写多读少 | 高频追加或更新 | 单写者 + 队列 | 避免所有线程争同一把锁 |
| 需要顺序 | 消费必须按序 | 单消费者 + 队列 | 顺序与并发天然冲突 |
| 需要全量一致快照 | 遍历时不能看到中间态 | 写时复制 | 代价在写侧 |
四类模式可以进一步压成两个问题:这段数据被几个线程写、读的时候能不能容忍看到中间态。两个问题组合出的四种答案,正好对应上表的四行。
第一类的常见误用是"用 CHM 存一个基本不变的配置表"。这个结构确实线程安全,但每次读都要走一次哈希查找与内存屏障;如果这张表在启动后不再变化,用不可变结构(构造后不再修改)读的性能与语义都更好。
2. 并发度不是越高越好
CHM 的并发度参数常被当成性能旋钮,实际影响很有限,而其他因素的量级更大。
| 因素 | 影响量级 | 说明 |
|---|---|---|
| 键的散列质量 | 大 | 冲突多则桶内链表变长,并发退化为串行 |
| 表大小与扩容 | 大 | 扩容期间的迁移成本 |
| 单键热点 | 大 | 所有线程争同一个桶的锁 |
| 并发度参数 | 小 | 现代实现对它的敏感度已降低 |
| 初始容量 | 中 | 容量不足会触发多次扩容 |
可以先用一条命令看清键的分布,再决定要不要分片:
// 从火焰图看竞争:真正的时间花在 Lock/Atomic 上吗
async-profiler -d 30 -e cpu -o flame.html -p PID
// 若栈顶出现 Unsafe.park / AtomicReference.cas / CHM.transfer,才是容器层问题
// 若栈顶是业务方法与序列化,容器不是瓶颈,换结构没有收益
第三项最容易被忽略:一张有一百万个键的表,如果 90% 的访问集中在其中十个键上,那么这张表实际上是十个热点加一堆冷数据,并发度再高也没用。热点键的问题不能用并发容器解决,只能靠分片、本地缓存或预聚合。
// 单键热点:所有线程争同一个桶,并发度无意义
ConcurrentHashMap<String, Long> counters = new ConcurrentHashMap<>();
counters.merge("global", 1L, Long::sum); // 这一行是串行的
// 分片后:把热点打散到 N 个键,读时再合并
int shard = ThreadLocalRandom.current().nextInt(16);
counters.merge("global#" + shard, 1L, Long::sum);
long total = IntStream.range(0, 16).mapToLong(i -> counters.getOrDefault("global#" + i, 0L)).sum();
// 代价:读要合并 16 个键,且总量不是精确的实时值
3. 遍历与弱一致
这是并发容器最容易出问题的地方:迭代器不保证看到某一时刻的完整状态。
| 结构 | 遍历语义 | 适用 |
|---|---|---|
ConcurrentHashMap |
弱一致,可能看到部分更新 | 统计、缓存查找 |
CopyOnWriteArrayList |
遍历的是某个快照 | 监听器列表、读极多写极少 |
Collections.synchronizedMap |
迭代需外部同步 | 不推荐用于遍历 |
| 不可变结构 | 完全一致的快照 | 配置、规则集 |
弱一致在多数场景可以接受,但在"遍历求和作为对外数字"的场景会造成困惑:同一时刻两次遍历得到不同的总数,没人能解释。这类场景要么用不可变快照,要么明确接受"这个数是近似值"并写进文档。
// 危险:遍历求和对外的报表数字,弱一致导致同一时刻两个结果
long sum = map.values().stream().mapToLong(Long::longValue).sum();
// 安全:用不可变快照,代价是每次更新复制整表(仅适合小表、低频写)
volatile Map<String, Long> snapshot = Map.of();
synchronized void update(String k, long v) {
var next = new HashMap<>(snapshot);
next.put(k, v);
snapshot = Map.copyOf(next); // 发布新快照,读者永远看到完整状态
}
4. 队列的选型:容量与阻塞语义
并发容器里最容易选错的是队列,因为名字相近而语义差别很大。
| 队列 | 容量 | 满时行为 | 适用 |
|---|---|---|---|
ArrayBlockingQueue |
固定 | 阻塞或拒绝 | 有界、需要背压 |
LinkedBlockingQueue |
可选 | 默认无界,满不了 | 不推荐默认构造(见下) |
SynchronousQueue |
0 | 必须有人接 | 直接交接,线程池场景 |
ConcurrentLinkedQueue |
无界 | 永远成功 | 只适合可丢弃/可容忍堆积 |
LinkedTransferQueue |
无界 | 可等待消费者 | 生产者比消费者快的场景 |
队列还有一个容易被忽略的参数:消费者线程数。它与队列容量是耦合的——队列越长、消费者越多,同时驻留的任务就越多,内存与下游压力都会放大。这两个数要一起定,不能分别调。
第二行的陷阱值得单独说:无界队列意味着内存是唯一的边界。用无界队列做缓冲,高峰期不会阻塞生产者,而是把压力转移到堆上——最终表现为 GC 压力上升,而不是可观测的队列积压。这比"队列满了拒绝"更难排查。
有界队列的价值就在这里:它把"处理不过来"变成一个立刻可见的事件(拒绝或阻塞),而不是一个延迟出现的 OOM。
// 有界队列 + 明确的满时策略 = 可观测的背压
BlockingQueue<Task> q = new ArrayBlockingQueue<>(2000);
// 生产者:满时拒绝并计数,不要静默丢弃
if (!q.offer(task)) {
metrics.counter("queue.rejected").increment();
throw new RejectedExecutionException("queue full");
}
// 消费者:固定数量,避免线程数与队列长度互相放大
for (int i = 0; i < 4; i++) {
workers.submit(() -> {
while (running) {
Task t = q.poll(200, TimeUnit.MILLISECONDS);
if (t != null) handle(t);
}
});
}
5. 锁与无锁的判断
不是所有并发问题都该用无锁结构,判据是临界区的形状。
| 临界区形状 | 更适合 | 理由 |
|---|---|---|
| 单变量更新 | 原子类 | 无锁开销低于锁 |
| 少量键的独立更新 | CHM / 分段 | 冲突概率低 |
| 多变量必须一起更新 | 一把锁 | 无锁难以保证组合原子性 |
| 临界区含 IO 或慢操作 | 锁 + 缩小范围 | 无锁实现会长时间自旋 |
| 需要公平性 | 显式锁(可设公平) | 无锁无公平概念 |
第三项是关键:多变量的组合原子性是无锁结构的短板。余额与流水必须一起更新,用两个原子变量分别更新,中间状态一定被读到过。这时一把锁比任何 CAS 循环都正确。
一个可用的判断顺序:
// 临界区选型四问,从下往上问,命中即停
4. 需要公平性或可中断等待? → 显式锁
3. 临界区含 IO / 远程调用 / 慢操作? → 一把锁 + 缩小范围
2. 多个变量必须一起更新? → 一把锁
1. 只更新单个变量? → 原子类
以上都不成立 → 分段/CHM,并先确认无热点键
第四项同样常见:临界区里有数据库调用或远程调用时,CAS 失败重试的代价会成倍放大——每次重试都要重新执行一遍慢操作。
6. 排错:五种"换了并发容器没变快"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 换 CHM 后吞吐没变 | 瓶颈不在容器,或在热点键 | 先做火焰图定位,再看键分布 |
| 单线程写比原来慢 | CAS 自旋在无竞争时也有开销 | 单写者场景用普通结构加外部同步 |
| 内存持续上涨 | 用了无界队列或无限增长的表 | 改有界,加容量上限与淘汰 |
| 遍历得到的总数不稳定 | 弱一致迭代器 | 改快照,或明确标注近似 |
| 偶发死锁 | 多个容器之间交叉加锁 | 固定加锁顺序,或合并为一把锁 |
排错时还有一条容易被忽略的经验:并发问题与容量问题是两种病。容器选错的表现通常是"扩机器没用、加线程也没用";容量问题的表现是"高峰才出问题、平时正常"。前者要改结构,后者要加资源,用错了手段就会来回折腾。
第一条是最常见的浪费:容器不是瓶颈时,替换它只会改变常数因子。先确认瓶颈在容器上,再谈选型。
7. 边界:什么情况不需要并发容器
| 场景 | 为什么 | 做法 |
|---|---|---|
| 结构只在启动时构建 | 之后只读,无竞争 | 不可变结构 |
| 每线程独立实例 | 无共享 | 普通集合,线程本地 |
| 全流程单线程 | 无线程竞争 | 普通集合 |
| 已用消息队列解耦 | 跨线程靠队列而非共享内存 | 无需并发集合 |
判断口径一句话:这段数据有没有被两个线程同时碰。没有,并发容器就是纯粹的开销;有,再按访问模式选具体结构。
8. 自测
- 为什么"线程安全"只是最低门槛?真正的判据是什么?
- 并发度参数为什么不是主要旋钮?哪三个因素影响更大?
- 单键热点为什么无法用并发容器解决?分片的代价是什么?
- 弱一致迭代器在哪类场景会造成麻烦?两种修法各自的代价是什么?
- 无界队列为什么比有界队列更难排查?它把压力转移到了哪里?
- 什么情况下必须用一把锁而不是无锁结构?举出两个具体形状。