2871 字
约 9 分钟
17
S05-A12 · 并发容器的选型判据:读多写少与写多读少
2024-12-31
2026-11-09

一段本该很快的代码,把 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. 自测

  1. 为什么"线程安全"只是最低门槛?真正的判据是什么?
  2. 并发度参数为什么不是主要旋钮?哪三个因素影响更大?
  3. 单键热点为什么无法用并发容器解决?分片的代价是什么?
  4. 弱一致迭代器在哪类场景会造成麻烦?两种修法各自的代价是什么?
  5. 无界队列为什么比有界队列更难排查?它把压力转移到了哪里?
  6. 什么情况下必须用一把锁而不是无锁结构?举出两个具体形状。
S05-A12 · 并发容器的选型判据:读多写少与写多读少
https://www.clxhxhhr.top/posts/6390/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。