Skip to content

Java 并发容器体系与选型全过程

“线程安全集合”不等于“给普通集合套一把锁”。不同容器在一致性、排序、阻塞、读写成本和内存占用上做了完全不同的取舍。本章以 JDK 7/8 为主,讲清 ConcurrentHashMapCopyOnWriteArrayListConcurrentLinkedQueueConcurrentSkipListMapBlockingQueue 为什么这样设计、迭代时能看到什么,以及生产中选错会发生什么。

学习目标

  • 区分线程安全、操作原子性、复合操作原子性和业务一致性;
  • 理解同步包装器、分段/桶级并发、写时复制、CAS 链表、跳表和阻塞队列的取舍;
  • 明白弱一致性迭代不是脏数据,也不等于强一致快照;
  • 根据读写比、是否排序、是否阻塞、容量和遍历语义选择容器;
  • 使用 JDK 7/8 可运行 Demo 验证快照迭代、CAS 队列和有序范围查询;
  • 能排查高 CPU、写入抖动、内存峰值、队列堆积和复合操作丢失更新。

一、普通集合为什么在并发写下失效

ArrayList.add 为例,它至少包含容量检查、可能扩容、写数组元素、更新 size。两个线程可能读到相同 size,把数据写到同一位置,再分别写回 size,产生覆盖;扩容与写入交错还可能读到旧数组结构。

mermaid
flowchart TD
    A["线程A读取size=10"] --> C["写入下标10"]
    B["线程B读取size=10"] --> D["也写入下标10"]
    C --> E["线程A写size=11"]
    D --> F["线程B写size=11"]
    E --> G["两个add只留下一个结果"]
    F --> G

即使没有抛异常,也不代表结果正确。并发错误可能只是低概率覆盖、不可见、遍历遗漏或结构损坏。

二、四个不同层次的“安全”

层次问题例子
单次操作线程安全一个 put 是否破坏容器结构ConcurrentHashMap.put
复合操作原子性多个调用能否作为一个整体getput 并不原子
元素对象线程安全容器中的 value 能否并发修改Map 安全不代表 ArrayList value 安全
业务一致性多个 key/表/服务能否满足业务约束库存扣减不能只靠线程安全 Map

错误写法:

java
Integer old = map.get("success");
map.put("success", old == null ? 1 : old + 1);

两个调用分别安全,但“读—计算—写”之间可被其他线程穿插。JDK 8 可使用 merge/compute,JDK 7 可使用 CAS 重试式 putIfAbsent/replace,高竞争统计可使用 JDK 8 LongAdder

三、并发容器知识地图

mermaid
flowchart TD
    A["并发容器需求"] --> B{"键值映射吗"}
    B -- "普通高并发Map" --> C["ConcurrentHashMap"]
    B -- "需要排序和范围查询" --> D["ConcurrentSkipListMap"]
    B -- "否" --> E{"列表还是队列"}
    E -- "读极多写极少列表" --> F["CopyOnWriteArrayList"]
    E -- "非阻塞FIFO" --> G["ConcurrentLinkedQueue"]
    E -- "空满时需要等待" --> H["BlockingQueue"]
容器读取特点写入特点遍历语义典型场景
ConcurrentHashMap高并发、通常无锁读JDK7 Segment;JDK8 CAS+桶锁弱一致缓存、注册表、分组状态
CopyOnWriteArrayList读和遍历无锁每次写复制数组创建迭代器时的快照监听器、路由规则快照
ConcurrentLinkedQueue非阻塞 FIFOCAS 链接节点弱一致多线程事件传递,不需阻塞
ConcurrentSkipListMap按 key 有序CAS 维护跳表索引弱一致且有序排行、时间范围、价格档位
BlockingQueue空时可阻塞满时可阻塞实现相关生产者消费者、线程池

四、同步包装器为什么不等于并发容器

java
List<String> list = java.util.Collections.synchronizedList(
        new java.util.ArrayList<String>());

同步包装器通常用同一把互斥锁保护方法,结构简单,但并发度有限。迭代时必须由调用方手动锁住同一个包装对象:

java
synchronized (list) {
    for (String value : list) {
        System.out.println(value);
    }
}

否则单次 add/get 虽受保护,迭代这段复合过程仍可能与修改交错。Vector/Hashtable 也不能自动保证“先检查再执行”这样的多调用业务原子性。

五、ConcurrentHashMap 的职责边界

本页只建立容器选型心智模型。JDK 7 Segment、JDK 8 CAS 与桶头锁、ForwardingNode、多线程协助扩容、红黑树、计数和源码执行流程见 ConcurrentHashMap 全过程

5.1 JDK 7 与 JDK 8

维度JDK 7JDK 8
主结构Segment[],每段含 HashEntry 数组Node[] + 链表 + 红黑树
写锁粒度Segment冲突桶头节点
空桶插入Segment 锁内CAS
扩容各 Segment 独立扩容写线程可协助迁移
Lambda 原子方法compute/merge增加 compute/merge

5.2 不允许 null 的并发语义

若 value 允许 null,get(key) == null 无法区分“不存在”和“存在但值为 null”。在普通 HashMap 里还可紧接着 containsKey,并发 Map 中两个调用之间可能发生修改。禁止 null 让 null 明确表示“当前没有有效映射”。

5.3 JDK 7 原子初始化 Demo

java
import java.util.concurrent.ConcurrentHashMap;

public class PutIfAbsentDemo {
    public static void main(String[] args) {
        ConcurrentHashMap<String, String> map =
                new ConcurrentHashMap<String, String>();
        String created = "new-config";
        String existing = map.putIfAbsent("tenant-1", created);
        String actual = existing == null ? created : existing;
        System.out.println(actual);
    }
}

注意:created 在调用前已经构造,即使竞争失败也付出了成本;若构造包含副作用,不能简单重复。JDK 8 computeIfAbsent 也要求映射函数短小,不能在桶内做慢 SQL、远程调用或递归更新同一 Map。

5.4 Map 安全但 value 不安全

java
ConcurrentHashMap<String, java.util.ArrayList<String>> map =
        new ConcurrentHashMap<String, java.util.ArrayList<String>>();

ConcurrentHashMap 只保护映射结构,不会让其中的 ArrayList 自动线程安全。可使用不可变 value、CopyOnWriteArrayList、并发队列,或在更高层建立明确同步协议。

六、CopyOnWriteArrayList 全过程

6.1 核心思想

JDK 7/8 的 CopyOnWriteArrayList 维护一个 volatile Object[] array。读操作读取当前数组,不加锁;写操作获取独占锁,复制整个数组,在新数组上修改,最后一次性发布新数组引用。

mermaid
flowchart TD
    A["写线程获取写锁"] --> B["读取旧数组引用"]
    B --> C["复制为新数组"]
    C --> D["在新数组添加删除或替换"]
    D --> E["volatile发布新数组引用"]
    E --> F["释放写锁"]
    G["读线程"] --> H["读取某一版数组"]
    H --> I["不加锁完成读取"]

它不是“写时只复制被修改元素”,而是复制底层数组。数组有 100 万个元素时,添加一个元素也要分配并复制约 100 万个引用。

6.2 为什么读线程不需要锁

写线程只修改尚未发布的新数组,不会原地改变读线程正在使用的旧数组;完成后通过 volatile 写切换引用。读线程要么看到旧数组,要么看到新数组,不会看到复制到一半的数组。

6.3 快照迭代

迭代器创建时保存当前数组引用,之后其他线程修改列表,迭代器仍遍历旧数组:

java
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteSnapshotDemo {
    public static void main(String[] args) {
        CopyOnWriteArrayList<String> list =
                new CopyOnWriteArrayList<String>();
        list.add("email");
        list.add("sms");
        Iterator<String> snapshot = list.iterator();
        list.add("webhook");

        while (snapshot.hasNext()) {
            System.out.println(snapshot.next());
        }
        System.out.println("current=" + list);
    }
}

迭代输出不包含 webhook,当前列表包含它。这是有意的快照一致性,不是可见性 bug。

6.4 为什么迭代器 remove 不支持

快照数组可能已经不是当前版本。如果迭代器按旧下标删除当前数组元素,无法可靠判断要删除哪个新版本元素,因此 Iterator.remove/add/setUnsupportedOperationException

6.5 适用和不适用

适合:监听器列表、少量路由规则、白名单快照、读极多写极少且允许读取短暂旧版本。
不适合:高频订单写入、大集合频繁增删、必须立刻看见最新修改、元素巨大导致内存峰值不可接受。

写入时旧数组和新数组会短暂同时存活;若旧快照迭代器长期持有,旧数组还不能回收,可能造成显著内存保留。批量变更优先一次 addAll/removeIf,不要循环做数千次单元素复制。

6.6 CopyOnWriteArraySet

它通常基于 CopyOnWriteArrayList 保存不重复元素,继承读多写少和快照迭代特点。判重需要线性查找,集合大或写多时不合适;高并发无序 Set 可考虑 ConcurrentHashMap.newKeySet(),但该方法是 JDK 8 能力,JDK 7 常用 Collections.newSetFromMap

七、ConcurrentLinkedQueue 全过程

ConcurrentLinkedQueue 是无界、非阻塞、线程安全 FIFO 队列。JDK 实现源自 Michael-Scott 队列思想,并针对 GC 和遍历做了工程调整。

7.1 节点和两个指针

逻辑上每个节点包含 itemnext,队列维护 headtail。入队核心是找到真正尾节点,通过 CAS 把其 next 从 null 改为新节点;tail 允许暂时落后,后续线程帮助推进。

mermaid
flowchart TD
    A["offer新节点"] --> B["从tail寻找真正尾节点"]
    B --> C{"尾节点next为空吗"}
    C -- "否" --> D["沿next前进并帮助更新tail"]
    D --> B
    C -- "是" --> E{"CAS把next设为新节点"}
    E -- "失败" --> B
    E -- "成功" --> F["尝试推进tail"]
    F --> G["offer成功"]

tail 为什么允许滞后:若每次链接节点和更新 tail 必须作为一个大锁事务,会增加竞争。真正决定新节点是否入队的是前尾节点 next CAS;tail 只是加速定位的提示,其他线程可以帮助修正。

7.2 出队不是简单删除头节点

poll() 从 head 向后寻找仍有 item 的节点,CAS 把 item 置 null 代表逻辑删除,并适时推进 head。节点链接可能暂时存在,以便并发线程安全遍历和推进;GC 可回收不再可达部分。

mermaid
flowchart TD
    A["poll从head开始"] --> B["寻找item非空节点"]
    B --> C{"找到有效item吗"}
    C -- "否" --> D["返回null"]
    C -- "是" --> E{"CAS把item置null"}
    E -- "失败" --> B
    E -- "成功" --> F["推进head并返回原item"]

7.3 非阻塞不等于绝不等待

“非阻塞算法”通常指没有因互斥锁持有者暂停而让所有线程停住,系统整体能持续进展;单个线程在高竞争下可能多次 CAS 失败甚至饥饿,所以不能把 lock-free 误说成每个操作都有固定完成时间的 wait-free。

7.4 为什么 size 是 O(n)

它没有在每次并发入队/出队时维护强一致全局计数,size() 需要遍历节点,成本 O(n),并发修改期间结果只是一个观察值。不要用 size() 在热路径限流,也不要写 if (queue.size() < limit) queue.offer(x),检查和插入既昂贵又不原子。

7.5 为什么是无界队列

offer 不会因为“容量满”而拒绝,生产速度长期高于消费速度时会持续占用堆内存,最终 OOM。需要背压和容量边界时使用有界 BlockingQueue,而不是 ConcurrentLinkedQueue。

7.6 JDK 7 多生产者 Demo

java
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;

public class ConcurrentQueueDemo {
    public static void main(String[] args) throws Exception {
        final Queue<String> queue = new ConcurrentLinkedQueue<String>();
        final CountDownLatch done = new CountDownLatch(2);
        for (int i = 0; i < 2; i++) {
            final int producer = i;
            new Thread(new Runnable() {
                public void run() {
                    try {
                        for (int n = 0; n < 1000; n++) {
                            queue.offer(producer + "-" + n);
                        }
                    } finally {
                        done.countDown();
                    }
                }
            }).start();
        }
        done.await();
        int consumed = 0;
        while (queue.poll() != null) consumed++;
        System.out.println("consumed=" + consumed);
    }
}

ConcurrentLinkedDeque 是双端版本,可从头尾并发插入/删除,但双向结构和帮助修正更复杂;只有明确需要双端语义时才使用。

八、ConcurrentSkipListMap 全过程

HashMap 擅长按 key 精确查找,但不维护顺序。ConcurrentSkipListMap 是并发有序 Map,支持 firstKeyceilingEntrysubMap 等范围操作,预期查找、插入、删除复杂度为 O(log n)。

8.1 跳表为什么快

普通有序链表只能逐个向后走。跳表在基础有序链表之上建立多层稀疏索引:高层跨大步,发现下一节点超过目标后下沉一层继续。

mermaid
flowchart TD
    A["从最高层索引开始"] --> B{"右侧key仍小于目标吗"}
    B -- "是" --> C["向右跨越多个节点"]
    C --> B
    B -- "否" --> D{"已经到底层吗"}
    D -- "否" --> E["下降一层"]
    E --> B
    D -- "是" --> F["确认目标存在或插入位置"]

索引层级通过随机化形成,不要求严格平衡。它避免红黑树在并发旋转和重平衡中的复杂协调,适合并发有序结构。

8.2 写入的逻辑与物理步骤

插入先在底层有序链表找到位置,CAS 链接基础节点,再按随机层高建立索引;删除通常先逻辑标记,再协助解除基础节点和索引。并发读可能短暂看到正在变化的索引路径,但最终仍可沿底层链找到正确有序位置。

8.3 比较器必须稳定

key 的自然顺序或 Comparator 决定“是否是同一个位置”。比较器若与业务相等语义严重不一致,两个 equals 不同的 key 可能比较为 0,被 Map 当成同一个排序键。key 和排序字段应不可变,不能插入后修改影响排序的内容。

8.4 范围视图不是复制

subMap/headMap/tailMap 返回受边界约束的动态视图,不是独立快照。对视图写入会影响原 Map,越界 key 会抛异常;并发遍历仍是弱一致。需要报表时点快照,应显式复制并定义一致性边界。

8.5 商业 Demo:待执行时间窗口

java
import java.util.NavigableMap;
import java.util.concurrent.ConcurrentSkipListMap;

public class TimeWindowDemo {
    public static void main(String[] args) {
        ConcurrentSkipListMap<Long, String> tasks =
                new ConcurrentSkipListMap<Long, String>();
        long now = System.currentTimeMillis();
        tasks.put(Long.valueOf(now - 1000), "expired-1");
        tasks.put(Long.valueOf(now + 60000), "future-1");

        NavigableMap<Long, String> due = tasks.headMap(Long.valueOf(now), true);
        for (java.util.Map.Entry<Long, String> entry : due.entrySet()) {
            if (tasks.remove(entry.getKey(), entry.getValue())) {
                System.out.println("execute=" + entry.getValue());
            }
        }
    }
}

范围遍历后使用条件 remove(key,value),避免删除已经被其他线程替换的新值。这个本地结构不能代替持久化调度:进程重启任务会丢失,多实例也不共享,应结合数据库、XXL-JOB 或延迟消息。

ConcurrentSkipListSet 通常由并发跳表 Map 支撑,适合并发有序去重集合。

九、弱一致性迭代到底是什么意思

并发容器通常不采用普通集合的快速失败语义,也不为了遍历停止所有写入。弱一致性迭代器:

  • 不会因为并发修改就必然抛 ConcurrentModificationException
  • 能看到迭代器创建前已完成的一部分或全部元素,具体保证看容器文档;
  • 迭代期间新增、删除是否被看到取决于并发时序;
  • 不会凭空返回一个从未存在过的元素;
  • 不能当作全局同一时刻的强一致快照。

CopyOnWrite 迭代器与此不同:它固定遍历创建时的数组快照,因此明确看不到后续写入。

如果业务要求“这一批 key 必须来自同一时点”,需要外部版本号、不可变快照、锁或数据库事务,不能仅因为容器线程安全就假定遍历强一致。

十、BlockingQueue 的边界

BlockingQueue 解决生产者消费者的空/满等待和背压。put/takeoffer/poll、Array/Linked/Synchronous/Priority/Delay 队列的锁、Condition、容量、线程池选型和堆积排查已在 BlockingQueue 全过程 详细说明。

关键区别:

需求ConcurrentLinkedQueue有界 BlockingQueue
入队满时没有容量满概念拒绝、超时或阻塞
出队空时立即返回 null可等待元素
背压可通过容量表达
算法CAS 非阻塞链表常见实现用锁和 Condition
风险无界堆积 OOM队列满导致等待/拒绝,需设计策略

十一、商业选型决策

mermaid
flowchart TD
    A["确定数据访问模式"] --> B{"需要阻塞和背压吗"}
    B -- "是" --> C["有界BlockingQueue"]
    B -- "否" --> D{"需要按key查询吗"}
    D -- "是" --> E{"需要排序范围吗"}
    E -- "否" --> F["ConcurrentHashMap"]
    E -- "是" --> G["ConcurrentSkipListMap"]
    D -- "否" --> H{"读远多于写吗"}
    H -- "是且规模小" --> I["CopyOnWriteArrayList"]
    H -- "否且FIFO" --> J["ConcurrentLinkedQueue"]
商业场景推荐必须确认
租户配置缓存ConcurrentHashMap + 不可变 value刷新原子性、过期和容量
事件监听器CopyOnWriteArrayList写极少、允许快照稍旧
本地事件转交ConcurrentLinkedQueue是否允许无界、消费者轮询策略
价格/时间范围索引ConcurrentSkipListMapComparator、持久化和多实例边界
异步任务缓冲有界 BlockingQueue容量、拒绝、消费能力、停机

十二、生产故障 Runbook

12.1 CPU 高且热点在 CAS

用 JFR/async-profiler/火焰图确认热点是否集中在 ConcurrentHashMap 更新、ConcurrentLinkedQueue CAS 或 LongAdder。再看 key 分布、单热点 key、写入比例和线程数。CAS 高冲突时加线程可能更慢,应分片热点、批量聚合、减少共享写或改为单消费者串行化。

12.2 CopyOnWrite 写入延迟和内存峰值

检查列表元素数、每秒写次数、add/remove 调用热点、GC 分配速率和旧数组保留。若循环单条更新,改为批量构造新快照并一次发布;若写频繁,改用其他容器。用堆分析检查长生命周期迭代器是否持有旧数组。

12.3 队列持续增长

ConcurrentLinkedQueue 无容量指标保护,必须维护独立近似计数和拒绝策略,或直接换有界 BlockingQueue。比较生产 TPS、消费 TPS、最老元素年龄和消费者状态;生产长期大于消费,队列必然增长,扩堆只延迟 OOM。

12.4 Map “丢更新”但容器没有损坏

审查是否使用 get-修改-putcontainsKey-put、跨 key 更新。线程安全容器只能保证其定义的单次/原子 API,不能自动把业务多步骤变成事务。改用 putIfAbsent/replace/compute/merge、不可变对象 CAS、锁或数据库事务。

12.5 范围查询结果数量波动

ConcurrentSkipListMap 的弱一致迭代允许并发变化。先确认业务是否真的需要时点快照;若需要,固定版本、复制视图或转移到支持事务快照的数据库。不能通过重复 size 和遍历“碰运气”获得一致结果。

十三、JDK 7、8 与现代版本

能力JDK 7JDK 8后续版本
CHM 主结构SegmentNode + CAS + 桶锁持续演进
compute/merge引入支持
LongAdder引入支持
CopyOnWrite/CLQ/SkipList可用可用语义基本兼容
Lambda Demo匿名内部类可用 Lambda可用
ConcurrentHashMap.newKeySet

学习 JDK 8 ConcurrentHashMap 时不能把 Segment 和桶头锁混在同一个版本回答;写兼容 JDK 7 的 Demo 时不能使用 Lambda、mergecomputeIfAbsent 或 LongAdder。

十四、面试标准回答

CopyOnWriteArrayList 为什么读不加锁

写线程在独占锁内复制旧数组、修改新数组,最后通过 volatile 发布新数组引用,不原地修改读线程持有的旧数组。读线程读取某一版稳定数组,所以无需和写线程互斥;代价是写入 O(n) 复制、短期双数组内存和读取旧快照。

ConcurrentLinkedQueue 为什么是非阻塞

入队通过 CAS 把尾节点 next 链接到新节点,出队通过 CAS 把 item 逻辑删除,竞争失败重试;没有一个持锁线程暂停就阻止所有线程前进。但单线程可能反复失败,所以 lock-free 不等于 wait-free。

ConcurrentLinkedQueue 为什么不要频繁调用 size

它不维护强一致全局计数,size 需要遍历链表,是 O(n),并发修改时也只是观察值。热路径限流应使用有界队列或独立计数协议,不能用 size < limit 后再 offer。

ConcurrentSkipListMap 为什么适合范围查询

它按 key 保持有序,并用多层随机索引把预期查找、插入和删除降到 O(log n),支持 ceiling、first、subMap 等导航与范围 API;代价是索引内存和比 HashMap 更复杂的写入。

并发容器的迭代是强一致快照吗

通常不是。CHM、CLQ、SkipList 迭代多为弱一致,可与并发修改共同进行,不保证全局同一时点;CopyOnWrite 迭代器则固定在创建时数组快照,明确看不到后续写入。

十五、错误用法与后果

错误后果正确方向
CHM get 后普通 put 计数丢失更新原子 API、CAS 或 LongAdder
Map 安全就并发改普通 valuevalue 内部竞态不可变或并发 value
CopyOnWrite 高频单条写大列表CPU、分配率和 GC 激增批量快照或换容器
用快照迭代器期待最新值业务读取旧规则明确版本和刷新语义
CLQ 作为无限任务缓冲堆积到 OOM有界队列和背压
CLQ size() 做并发限流O(n) 且检查插入不原子Semaphore/有界队列
修改 SkipList key 排序字段排序和查找语义破坏key 不可变
把弱一致遍历当报表快照数量与明细不一致版本快照或数据库事务

十六、关联知识与验收

掌握验收:

  • 能解释“线程安全 Map”为什么仍会 get-put 丢更新;
  • 能画出 CopyOnWrite 复制、修改、volatile 发布和快照迭代过程;
  • 能解释 CLQ tail 为什么允许落后、poll 为什么逻辑删除、size 为什么 O(n);
  • 能用跳表分层搜索解释 ConcurrentSkipListMap 的 O(log n) 预期复杂度;
  • 能为配置缓存、监听器、事件队列、范围索引和异步任务分别选择容器并说明不选其他容器的原因;
  • 面对生产高 CPU、内存峰值或队列增长,能提出可验证证据,而不是只说“换成线程安全集合”。