欢迎光临
我们一直在努力

并发容器选型与陷阱:ConcurrentHashMap、CopyOnWriteArrayList、阻塞队列的适用边界

并发容器选型与陷阱:ConcurrentHashMap、CopyOnWriteArrayList、阻塞队列的适用边界

一、先把问题说清楚

后端服务里几乎每一段共享状态的代码都在回答同一个问题:多个线程同时读写同一份数据时,我该用哪个容器?多数人的第一反应是从 java.util.concurrent 包里挑一个名字里带 Concurrent 的类。这没错,但远远不够。因为“线程安全”这四个字是分层的:有的容器保证单个方法调用不崩,有的容器保证你读到的数据要么全旧要么全新,还有的容器在容量满时会让写线程停下来等。这些差异没有被名字体现出来,选错了不会立刻报错,而是在某个流量高峰以丢数据、卡死或者内存暴涨的形式暴露。

读者要完成的这件事可以表述为:给定一段并发访问共享数据的代码,能够判断该用 ConcurrentHashMap、CopyOnWriteArrayList 还是阻塞队列,并且知道所选容器在什么条件下会失效。

这件事之所以难,是因为三个容器各自解决不同的矛盾:

  • ConcurrentHashMap 解决的是“高并发读写一个键值映射”,它用 CAS 加桶级锁摊薄竞争,代价是部分操作不提供跨键原子性,迭代器只能看到某一刻的近似快照。
  • CopyOnWriteArrayList 解决的是“读极多、写极少”的列表访问,它靠写时复制整个数组来换取读操作完全无锁,代价是每次写都要复制,写多了就是灾难。
  • 阻塞队列解决的是“生产者和消费者之间的流量缓冲”,它把“队列满/空时怎么办”编码进 API,代价是容量语义会直接决定线程是等待、丢弃还是抛异常。

下面按“环境与版本 → 核心机制 → 贯穿示例 → 执行与观察 → 失败反例 → 适用边界”推进。需要先声明证据状态:本文引用的 JDK 行为以 Java 21 的 java.base 源码与官方 Javadoc 为依据,属于官方规范或可从公开源码确认的行为;凡是涉及性能量级的描述,均为“特定条件下的工程观察”,不是本文实测结论,读者应在自己的机器上复核。

二、环境与兼容版本

本文的代码片段针对以下环境编写,属于“预期可运行”而非“本次已运行”:

组件版本说明
JDK Java 21(LTS) java.util.concurrent 容器行为以此为准
构建 Maven 3.9+ 或直接用 javac/java 示例不强依赖框架,方便复现
语言级别 21 使用了 record、文本块、var 等特性
Spring Boot 3.5(可选) 只在讨论与容器选型相关的使用场景时提及

版本敏感点必须先讲清,否则后面结论会失准:

  • ConcurrentHashMap 的锁粒度在 JDK 8 发生过一次大改。 JDK 7 使用 Segment 分段锁,JDK 8 起改为“数组槽位 + CAS + synchronized 桶头”的形态,扩容也从“整表迁移”变成“协助迁移”。本文只讨论 JDK 8 及以后(含 21)的形态。如果你维护的是 JDK 7 系统,扩容期间的并发行为和内存占用模型都不同,不能直接套用。
  • CopyOnWriteArrayList 的实现从早期版本起基本稳定:内部持有一个 volatile Object[] array,写操作持有锁并复制新数组。JDK 9 起内部锁字段形态有过调整,但“写时复制、读无锁、迭代器是快照”这一组语义在 Java 8 到 21 之间保持一致。
  • JDK 21 引入虚拟线程后,阻塞队列的语义没有变,但“阻塞一个线程”的成本变了。 虚拟线程被阻塞时会卸载载体线程,因此“用阻塞队列做背压”在虚拟线程下依然是有效手段;但如果你把阻塞队列和固定大小的平台线程池配合,那么队列满导致的阻塞会真实占用宝贵的平台线程。这一点在容量估算时经常被忽略。
  • 说明:本节的版本事实来自 OpenJDK 公开源码与官方 API 文档。本文没有进行独立压测,任何“比 synchronized 快多少倍”的说法都不在本文结论范围内。

    三、核心机制:三个容器各自在解决什么矛盾

    3.1 先建立一张心智地图

    把三个容器放在同一张图里,能看清它们的定位差异:

    写入频率低 写入频率高
    ┌──────────────┬──────────────────────┐
    读多写少 │ CopyOnWrite │ 读多写多 │
    │ ArrayList │ 大多数场景不合适 │
    ├──────────────┼──────────────────────┤
    键值映射 │ —— │ ConcurrentHashMap │
    ├──────────────┼──────────────────────┤
    生产消费 │ │ 阻塞队列 │
    │ │ (ArrayBlockingQueue │
    │ │ / LinkedBlockingQ) │
    └──────────────┴──────────────────────┘

    这张图是选型的第一道筛子,但它只是起点。真正的陷阱藏在每种容器“为了快而放弃了什么”里。

    3.2 ConcurrentHashMap:用局部锁换吞吐,用弱一致换并发

    它解决什么问题。 普通 HashMap 在多线程同时写入时可能丢数据,甚至因为链表成环导致死循环(这在 JDK 7 及更早的 HashMap 中确实出现过)。最粗暴的修法是外面包一层 synchronized,但那等于把所有读写都串行化。ConcurrentHashMap 的思路是:把锁尽量缩小,让大部分操作无锁完成。

    正式机制。 内部是一个 Node[] table 数组,每个槽位挂着链表或红黑树(当单个桶内节点数超过阈值 8 且表容量不低于 64 时,链表会树化)。写入时:

  • 如果目标槽位为空,用 CAS 直接把新节点挂上去,不加锁。
  • 如果槽位非空,对桶头节点 synchronized,在此基础上追加或更新。
  • 更新时用 volatile 读写保证可见性。
  • 这套机制的直接后果是:单个键的读写在绝大多数情况下是原子的,但跨多个键的操作不是原子的。 这是本文最想让人记住的结论之一。

    扩容(resize)机制。 当元素数量超过 sizeCtl 阈值(容量 × 负载因子 0.75),多个线程会一起参与迁移。每个参与扩容的线程领取一段桶(通过 transferIndex 递减分配),把旧表里的节点搬到新表。迁移过程中,为了不让并发的 get 读到不完整状态,被迁移的桶头会被替换为一个 ForwardingNode,里面记录了新表引用。get 遇到 ForwardingNode 会转发到新表查询;写入遇到它会先帮忙迁移一部分再执行。

    这里要强调三个容易被误传的点:

    • 扩容不是“暂停一切”的全表冻结。迁移是分桶进行的,读操作在迁移期间仍能进行,只是可能在新旧两个表之间跳一次。
    • 扩容期间的 size() 是估算值。size() 的实现是把 baseCount 与各个 CounterCell 的值累加,这些计数槽在并发更新时可能产生偏差,它不保证在并发写入时返回精确值。如果你需要精确计数,不要依赖它。
    • mappingCount() 返回 long,是比 size() 更适合大容量场景的接口,但同样只是估算。

    复合操作的原子性陷阱。 这是实际代码里最常见的错误。看这段:

    // 反例:不是原子的复合操作
    Map<String, Integer> countMap = new ConcurrentHashMap<>();
    public void increment(String key) {
    Integer old = countMap.get(key);
    if (old == null) {
    countMap.put(key, 1);
    } else {
    countMap.put(key, old + 1);
    }
    }

    get 和 put 各自线程安全,但两者之间没有任何保护。两个线程都读到 old = 5,都写回 6,最终计数少了一次。这就是“复合操作丢失更新”。正确写法是使用容器提供的原子复合方法:

    countMap.merge(key, 1, Integer::sum); // 推荐:一次原子调用
    countMap.compute(key, (k, v) -> v == null ? 1 : v + 1);
    countMap.computeIfAbsent(key, k -> 0);

    merge、compute、computeIfAbsent 的执行在目标桶上加了锁,因此对同一个键是原子的(官方 Javadoc 明确说明 computeIfAbsent 对整个方法调用是原子的)。但注意:不同键之间仍然并行,所以它们不能用来实现“多个键的联合原子更新”。

    一个更隐蔽的坑:computeIfAbsent 的映射函数在桶锁内执行。如果函数体里做了耗时操作、访问了其他 Map、或者再次进入同一个 Map 的写操作,就可能长时间持锁甚至死锁。官方文档对这一点有提醒:映射函数应当简短且不能修改这个 Map。

    3.3 CopyOnWriteArrayList:用复制换无锁读,用内存换简单

    它解决什么问题。 有些列表的访问模式极端偏斜:读的次数是写的上千倍,比如“服务注册表”“路由规则列表”“监听器集合”。这类场景里,如果读也要加锁,那就是白白浪费。CopyOnWriteArrayList 的想法是:让读操作直接读一个不可变数组快照,永远不加锁;写操作则复制出一份新数组,改完之后把引用一次性换掉。

    正式机制。

    • 内部字段 private transient volatile Object[] array。volatile 保证换引用后其他线程立即可见。
    • 读操作(get、size、迭代)直接读 array,无锁。
    • 写操作(add、set、remove)先获取内部锁(JDK 9 起用一个专门的 final transient Object lock 配合 synchronized 实现写互斥,语义等价于早期版本的 ReentrantLock),复制整个数组 Arrays.copyOf,在新数组上修改,再赋回 array。

    这种设计的代价是写放大:一次 add 要复制 N 个元素,时间复杂度 O(N),内存瞬时占用 2N。如果列表有一万个元素,一次写就要复制一万个引用。写频率稍高,CPU 和 GC 都会明显吃紧。

    写放大的具体表现,可以用一个估算来说明(以下数字是示意,不是实测):假设列表有 10,000 个元素,每个元素引用占 8 字节,那么数组本体约 80 KB。每次写都要分配一个新的 80 KB 数组并复制,如果每秒写 1,000 次,就是每秒产生约 80 MB 的短命对象。这在年轻代里会直接推高 GC 频率。这不是“CopyOnWrite 不行”,而是“它只适合写很少”。

    迭代器是快照。 CopyOnWriteArrayList 的 iterator() 返回的是创建时刻 array 的一份引用,后续的写不会影响已经拿到的迭代器。这带来两个后果:迭代期间不会抛 ConcurrentModificationException;但如果迭代过程中有写入,你看到的是旧数据。对于“读配置”这类场景,这恰好是想要的;对于“读最新状态”这类场景,这就是 bug。

    3.4 阻塞队列:把“队列满/空”的策略写进 API

    它解决什么问题。 生产者和消费者速度不一致时,需要一块缓冲区。缓冲区太小会频繁阻塞,太大则内存不可控、延迟变长。阻塞队列把“队列空时取怎么办、满时放怎么办”这四类策略明确编码成不同的方法,逼调用方显式选择。

    正式机制(以 ArrayBlockingQueue 与 LinkedBlockingQueue 为例)。 四种操作语义:

    操作抛异常返回特殊值阻塞超时
    插入 add(e) offer(e) 返回 false put(e) offer(e, t, unit)
    移除 remove() poll() 返回 null take() poll(t, unit)
    检查 element() peek() 不适用 不适用

    容量语义是这里最关键的差异:

    • ArrayBlockingQueue 必须指定容量,内部一个定长数组 + 一把锁(JDK 中为 ReentrantLock,读写共用同一把锁,通过两个 Condition 区分非空/非满)。它的内存是预先分配的。
    • LinkedBlockingQueue 可以不指定容量,此时默认 Integer.MAX_VALUE。这是一个经典陷阱:看起来“无界”,实际上是把内存风险从“队列满阻塞”转移成了“生产快于消费时堆内存持续增长直到 OOM”。此外,它的入队与出队各用一把锁,并在队空/队满时各用一个条件队列;而 ArrayBlockingQueue 只有一把锁,入队出队会互相争用。这是两者在并发吞吐上的结构性差异,与容量语义无关,但选型时常被混淆。
    • 两者都提供“公平”模式构造函数,但公平模式会降低吞吐,只在有明确公平性需求时才用。

    容量语义直接决定了背压是否成立。一个有界队列 + put 阻塞,能让上游生产者在系统过载时自然减速,这是最朴素的限流降级手段。一个无界队列 + offer 不阻塞,就等于把压力全部转成内存占用。

    3.5 弱一致性迭代器:三个容器的共同底色

    前面反复出现“近似快照”“旧数据”“估算值”,它们背后是同一个概念:弱一致性(weakly consistent)迭代器。普通集合的迭代器是“快速失败”的,一旦发现集合被外部修改就抛 ConcurrentModificationException;而并发容器的迭代器承诺不抛这个异常,但不承诺看到最新数据。

    具体到三个容器:

    • ConcurrentHashMap 的迭代器遍历的是桶数组,创建时确定起始位置,遍历过程中如果遇到已经迁移的桶会处理,但不保证反映创建之后的所有修改,也不保证同一个值只出现一次。size() 同样是估算。
    • CopyOnWriteArrayList 的迭代器是真正的不可变快照,创建那一刻的数据定格。
    • 阻塞队列的迭代器(如 ArrayBlockingQueue.iterator())同样是弱一致的,遍历时可能看到也可能看不到并发插入的元素。LinkedBlockingQueue 的 size() 使用 AtomicInteger 维护,是有更多一致性保证的,但在并发修改下仍不能当作精确同步点使用。

    这条性质的意义在于:如果你需要“遍历时看到一个精确一致的视图”,并发容器不是为此设计的。 那种需求要么靠不可变快照(复制一份普通集合),要么靠显式加锁。

    四、贯穿示例:一个可复现的计数与快照实验

    为了把上面的机制串起来,设计一个最小实验。它不依赖任何框架,一个源文件即可运行。

    4.1 实验目标

  • 演示 ConcurrentHashMap 复合操作丢失更新,以及用 merge 修复后的差异。
  • 演示 CopyOnWriteArrayList 迭代器拿到的是快照,并发写入不影响已创建的迭代器。
  • 演示有界阻塞队列在容量满时 offer 返回 false 而 put 阻塞,从而体现容量语义。
  • 4.2 前置环境与依赖

    • 只需要 JDK 21,无第三方依赖。
    • 文件:ConcurrentContainerLab.java,放在任意目录。

    4.3 代码(可直接编译运行的完整程序)

    import java.util.Map;
    import java.util.concurrent.ArrayBlockingQueue;
    import java.util.concurrent.BlockingQueue;
    import java.util.concurrent.ConcurrentHashMap;
    import java.util.concurrent.CopyOnWriteArrayList;
    import java.util.concurrent.CountDownLatch;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.TimeUnit;
    import java.util.concurrent.atomic.AtomicInteger;

    public class ConcurrentContainerLab {

    // 实验一:复合操作丢失更新 vs merge
    static void lostUpdate() throws InterruptedException {
    int threads = 8;
    int loops = 10_000;
    Map<String, Integer> unsafe = new ConcurrentHashMap<>();
    Map<String, Integer> safe = new ConcurrentHashMap<>();
    ExecutorService pool = Executors.newFixedThreadPool(threads);
    CountDownLatch start = new CountDownLatch(1);
    CountDownLatch done = new CountDownLatch(threads * 2);

    for (int i = 0; i < threads; i++) {
    pool.submit(() -> {
    try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    for (int j = 0; j < loops; j++) {
    Integer old = unsafe.get("k");
    unsafe.put("k", old == null ? 1 : old + 1);
    }
    done.countDown();
    });
    pool.submit(() -> {
    try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    for (int j = 0; j < loops; j++) {
    safe.merge("k", 1, Integer::sum);
    }
    done.countDown();
    });
    }
    start.countDown();
    done.await();
    pool.shutdown();
    System.out.println("[实验一] 期望=" + (threads * loops));
    System.out.println("[实验一] unsafe.get = " + unsafe.get("k"));
    System.out.println("[实验一] safe.merge = " + safe.get("k"));
    }

    // 实验二:CopyOnWriteArrayList 快照迭代
    static void cowSnapshot() {
    CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
    list.add("a");
    list.add("b");
    var it = list.iterator();
    list.add("c"); // 写入发生在迭代器创建之后
    System.out.println("[实验二] 迭代器看到的元素数 = " + count(it));
    System.out.println("[实验二] 列表当前 size = " + list.size());
    }

    static int count(java.util.Iterator<?> it) {
    int n = 0;
    while (it.hasNext()) { it.next(); n++; }
    return n;
    }

    // 实验三:有界阻塞队列容量语义
    static void queueCapacity() throws InterruptedException {
    BlockingQueue<Integer> q = new ArrayBlockingQueue<>(2);
    System.out.println("[实验三] offer 1 = " + q.offer(1));
    System.out.println("[实验三] offer 2 = " + q.offer(2));
    System.out.println("[实验三] offer 3 = " + q.offer(3) + "(容量满,非阻塞返回 false)");

    AtomicInteger taken = new AtomicInteger();
    Thread consumer = new Thread(() -> {
    try {
    TimeUnit.MILLISECONDS.sleep(300);
    taken.set(q.take()); // 先消费掉 1,腾出一个位置
    taken.set(q.take()); // 再消费掉 2
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    });
    consumer.start();

    long t0 = System.nanoTime();
    q.put(3); // 阻塞直到有空间;消费线程睡 300 ms 后才取走一个元素
    long elapsedMs = (System.nanoTime() – t0) / 1_000_000;
    consumer.join();
    System.out.println("[实验三] put 阻塞了约 " + elapsedMs + " ms,消费到 " + taken.get());
    }

    public static void main(String[] args) throws Exception {
    lostUpdate();
    cowSnapshot();
    queueCapacity();
    }
    }

    4.4 执行命令

    javac ConcurrentContainerLab.java
    java ConcurrentContainerLab

    4.5 观察与解释

    实验一。 输入是 8 个线程各做 10,000 次递增,期望总和 80,000。输出中 unsafe.get 的预期结果是小于 80,000,因为 get 与 put 之间存在竞态窗口,多个线程读到相同旧值再覆盖。这个数每次运行都可能不同,取决于调度;它不会稳定等于 80,000,这正是“丢失更新”。而 safe.merge 的预期结果是恰好 80,000,因为 merge 在目标桶的锁内完成“读取—计算—写回”整体,对同一个键是原子的。

    需要说清因果:merge 的原子性只覆盖同一个键。如果实验改成每个线程写不同的键,然后统计总和,那么读取总和的那一步仍然不是原子的,仍需要额外同步。这就是“跨键不原子”的边界。另外,操作数达到 long 量级之前,Integer 溢出不是这个实验的干扰项;但如果沿用同样的写法去累计几十亿次,Integer 会在 21 亿附近回绕成负数,那时“计数变少”的原因就不再是丢失更新,而是溢出,两者不要混淆。

    实验二。 迭代器创建时 list 有两个元素,之后 add("c") 换掉了内部数组引用。迭代器持有的是旧引用。因此预期输出是“迭代器看到的元素数 = 2”,而“列表当前 size = 3”。这直接演示了快照语义。若把这段替换成普通的 ArrayList 并在迭代中途修改,通常会抛 ConcurrentModificationException(这是“通常”,因为快速失败检测基于 modCount,不是每次都必现,属于特定条件下的行为)。

    实验三。 前两次 offer 成功,第三次返回 false,因为容量为 2 且无消费者。随后启动消费者,睡 300 ms 后连续取走两个元素;主线程的 put(3) 在第二个元素被取走、腾出空间的那一刻返回,预期阻塞时间大约 300 ms 量级(具体数值依赖调度,只能看量级)。这里刻意让消费者取两次,是为了让 put 一定能完成,避免主线程一直阻塞导致程序挂住;如果不取走元素,put 会无限等待。这个实验展示的是容量语义:offer 是非阻塞的判满返回,put 是把“满”变成阻塞。

    4.6 资源清理

    • ExecutorService 必须 shutdown(),否则 JVM 不会退出。示例中已调用。
    • 消费者线程用了 join() 等待结束。
    • 如果实验失败导致线程卡死,可以用 jstack <pid> 查看线程栈,确认是否卡在 ArrayBlockingQueue.put 的 Condition.await 上。

    五、失败与反例:这些写法为什么会出事

    5.1 反例一:用 ConcurrentHashMap 做“先检查后执行”

    // 反例:本意是“若不存在则初始化并返回单例”,但外部逻辑不是原子的
    if (!map.containsKey(id)) {
    map.put(id, loadFromDb(id)); // loadFromDb 可能被多个线程重复执行
    }
    return map.get(id);

    containsKey 和 put 之间没有保护,多个线程可能同时执行 loadFromDb。即使改用 computeIfAbsent,也要注意映射函数在锁内执行,loadFromDb 是数据库 IO,会长时间持有桶锁,拖慢同一桶上的其他键操作。更好的做法是:先用 computeIfAbsent 放入一个占位对象(如 CompletableFuture),让初始化在锁外完成;或者接受重复加载,用幂等保证正确性。这是“工程建议”,不是 JDK 强制行为。

    5.2 反例二:把 CopyOnWriteArrayList 当普通列表用

    // 反例:在每次请求里向 COW 列表追加一条审计记录
    public void record(String event) {
    auditList.add(event); // 每次 O(N) 复制
    }

    如果 auditList 有几万条历史记录,每次请求都要复制整个数组,QPS 一高就会出现明显的 CPU 与 GC 压力。这类“高频追加”场景应当换成有界队列 + 异步消费落盘,或者用 ConcurrentLinkedQueue。

    另一个反例是遍历 COW 列表时指望看到实时更新:

    // 反例:期望遍历期间新加入的元素也被处理
    for (String rule : ruleList) {
    apply(rule); // 遍历期间另一个线程 add 的规则不会出现在本次循环
    }

    这就是弱一致性的典型误用。如果业务要求“新规则必须被本轮处理”,只能在下一次循环中再读一次,或者改用带版本号的机制。

    5.3 反例三:无界队列导致的“假背压”

    // 反例:看起来有队列,实际上没有背压
    BlockingQueue<Task> q = new LinkedBlockingQueue<>(); // 默认容量接近 Integer.MAX_VALUE
    for (Task t : batch) {
    q.offer(t); // 永远返回 true,直到 OOM
    }

    LinkedBlockingQueue 无参构造的容量是 Integer.MAX_VALUE,offer 在未满时立即返回。生产速度长期高于消费速度时,队列会持续增长,最终触发 OutOfMemoryError。这不是队列的 bug,而是容量语义选择错误。修复方式是给一个与下游处理能力匹配的有界容量,并在满时选择 put 阻塞(形成背压)或 offer 失败后走降级路径。

    5.4 反例四:把 size() 当作精确计数

    // 反例:用 size() 判断是否为空来控制流程
    if (concurrentMap.size() == 0) {
    initialize();
    }

    ConcurrentHashMap.size() 是估算值,在并发写入时可能不准确。用它在并发路径上做控制流判断会引入不可预测行为。需要精确计数时应使用 LongAdder 之类的专用计数器,而不是依赖容器的 size()。

    5.5 反例五:阻塞队列与线程池的隐性耦合

    在固定大小线程池中,如果任务内部又向同一个线程池提交任务并等待结果,就可能出现线程饥饿死锁。类似地,如果使用有界队列 + CallerRunsPolicy,队列满时任务会在提交线程上执行,这会改变调用方的延迟特征。这些行为属于 ThreadPoolExecutor 的既定语义,但与队列容量选择直接相关,需要一起评估。

    六、机制串讲:把因果链走一遍

    用一张流程图把“一次并发写”在各容器中的路径串起来:

    [线程发起写操作]
    |
    +–> [ConcurrentHashMap]
    | |
    | +–> [目标桶为空?]
    | | |– 是 –> [CAS 挂载新节点]
    | | |– 否 –> [synchronized 桶头后修改]
    | |
    | +–> [达到扩容阈值?]
    | |– 是 –> [多线程协助迁移, 桶头置 ForwardingNode] –> [返回]
    | |– 否 –> [返回]
    |
    +–> [CopyOnWriteArrayList]
    | |
    | +–> [持写锁复制数组] –> [在新数组上修改] –> [volatile 换引用] –> [返回]
    |
    +–> [阻塞队列]
    |
    +–> [容量已满?]
    |– 未满 –> [入队并 signal 非空条件] –> [返回]
    |– 已满 –> [调用的是 put 还是 offer?]
    |– put –> [在非满条件上 await]
    |– offer –> [立即返回 false]

    这条链的关键分叉点在于“容器为了高并发放弃了什么”:ConcurrentHashMap 放弃的是跨键原子性与精确 size;CopyOnWriteArrayList 放弃的是写性能和实时可见性;阻塞队列放弃的是“无界缓冲”的幻想,把容量约束前置成调用方必须做的决策。

    七、适用条件与结论

    7.1 选型判定表

    场景特征推荐容器必须注意
    高并发键值读写,热点键分散 ConcurrentHashMap 复合操作用 merge/compute;不要依赖 size();映射函数要短
    缓存本地映射,允许估算大小 ConcurrentHashMap + LongAdder 精确计数单独维护
    读远多于写,需要无锁遍历 CopyOnWriteArrayList 写频率必须很低;迭代是快照
    监听器列表、路由规则 CopyOnWriteArrayList 规则变更应整体替换而非逐条增删
    生产消费解耦,需要背压 有界阻塞队列 容量与下游能力匹配;用 put 形成背压
    任务提交缓冲 ArrayBlockingQueue / LinkedBlockingQueue(显式容量) 避免无界;评估拒绝策略与调用方延迟

    7.2 结论

    第一,并发容器的“安全”是分层的。ConcurrentHashMap 保证单键操作原子,不保证跨键复合操作原子;CopyOnWriteArrayList 保证读不加锁、迭代不抛异常,不保证读到最新;阻塞队列保证入队出队线程安全,不保证容量之外的内存安全。判断选型时,先问“我的正确性依赖哪一层保证”。

    第二,容量与频率是最容易被忽视的选型维度。CopyOnWrite 的写放大随元素数线性增长;无界队列把内存风险后移;ConcurrentHashMap 的扩容在元素数增长时才会发生,但一旦发生就是多线程迁移。这些都不是“用错 API”,而是“用错场景”。

    第三,本文的代码是预期可运行的实验,不是已实测结论。实验一的具体丢失数量、实验三的具体阻塞毫秒数都依赖运行环境,读者应自行观察量级而非固定数值。凡是涉及性能倍数的判断,都需要在自己的硬件与负载下用 JMH 或压测复核,本文不提供也未声称提供这类数据。

    第四,弱一致性迭代器不是缺陷,而是设计取舍。它让并发容器在无锁或低锁的情况下仍能安全遍历,代价是视图可能滞后。理解这一点,就不会在遍历中期待实时性,也不会因为没抛异常就误以为看到了全部数据。

    7.3 需要复核的问题

    • 若目标环境是 JDK 8 或 11,扩容与锁实现细节与本文以 21 为准的描述可能有细微差异,应查阅对应版本文档。
    • 若使用虚拟线程,阻塞队列的阻塞成本模型改变,容量估算需要重新评估。
    • 若业务对“跨键事务性更新”有硬需求,并发容器本身不是答案,需要数据库事务或显式锁协调。

    参考资料

  • Oracle. java.util.concurrent.ConcurrentHashMap — Java SE 21 API 文档。 https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html
  • Oracle. java.util.concurrent.CopyOnWriteArrayList — Java SE 21 API 文档。 https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/CopyOnWriteArrayList.html
  • Oracle. java.util.concurrent.BlockingQueue — Java SE 21 API 文档。 https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/BlockingQueue.html
  • Oracle. java.util.concurrent.ArrayBlockingQueue — Java SE 21 API 文档。 https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ArrayBlockingQueue.html
  • Oracle. java.util.concurrent.LinkedBlockingQueue — Java SE 21 API 文档。 https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/LinkedBlockingQueue.html
  • OpenJDK. jdk/src/java.base/share/classes/java/util/concurrent/ConcurrentHashMap.java — OpenJDK 源码仓库。 https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/concurrent/ConcurrentHashMap.java
  • Oracle. Package java.util.concurrent — Java SE 21 包级说明,含弱一致性迭代器描述。 https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/package-summary.html
  • Oracle. java.util.concurrent.ThreadPoolExecutor — Java SE 21 API 文档(队列与拒绝策略语义)。 https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html
  • 赞(0)
    未经允许不得转载:171主机测评 » 并发容器选型与陷阱:ConcurrentHashMap、CopyOnWriteArrayList、阻塞队列的适用边界
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址