Java 并发容器体系与选型全过程
“线程安全集合”不等于“给普通集合套一把锁”。不同容器在一致性、排序、阻塞、读写成本和内存占用上做了完全不同的取舍。本章以 JDK 7/8 为主,讲清 ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、ConcurrentSkipListMap 和 BlockingQueue 为什么这样设计、迭代时能看到什么,以及生产中选错会发生什么。
学习目标
- 区分线程安全、操作原子性、复合操作原子性和业务一致性;
- 理解同步包装器、分段/桶级并发、写时复制、CAS 链表、跳表和阻塞队列的取舍;
- 明白弱一致性迭代不是脏数据,也不等于强一致快照;
- 根据读写比、是否排序、是否阻塞、容量和遍历语义选择容器;
- 使用 JDK 7/8 可运行 Demo 验证快照迭代、CAS 队列和有序范围查询;
- 能排查高 CPU、写入抖动、内存峰值、队列堆积和复合操作丢失更新。
一、普通集合为什么在并发写下失效
以 ArrayList.add 为例,它至少包含容量检查、可能扩容、写数组元素、更新 size。两个线程可能读到相同 size,把数据写到同一位置,再分别写回 size,产生覆盖;扩容与写入交错还可能读到旧数组结构。
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 |
| 复合操作原子性 | 多个调用能否作为一个整体 | get 后 put 并不原子 |
| 元素对象线程安全 | 容器中的 value 能否并发修改 | Map 安全不代表 ArrayList value 安全 |
| 业务一致性 | 多个 key/表/服务能否满足业务约束 | 库存扣减不能只靠线程安全 Map |
错误写法:
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。
三、并发容器知识地图
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 | 非阻塞 FIFO | CAS 链接节点 | 弱一致 | 多线程事件传递,不需阻塞 |
ConcurrentSkipListMap | 按 key 有序 | CAS 维护跳表索引 | 弱一致且有序 | 排行、时间范围、价格档位 |
BlockingQueue | 空时可阻塞 | 满时可阻塞 | 实现相关 | 生产者消费者、线程池 |
四、同步包装器为什么不等于并发容器
List<String> list = java.util.Collections.synchronizedList(
new java.util.ArrayList<String>());同步包装器通常用同一把互斥锁保护方法,结构简单,但并发度有限。迭代时必须由调用方手动锁住同一个包装对象:
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 7 | JDK 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
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 不安全
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。读操作读取当前数组,不加锁;写操作获取独占锁,复制整个数组,在新数组上修改,最后一次性发布新数组引用。
flowchart TD
A["写线程获取写锁"] --> B["读取旧数组引用"]
B --> C["复制为新数组"]
C --> D["在新数组添加删除或替换"]
D --> E["volatile发布新数组引用"]
E --> F["释放写锁"]
G["读线程"] --> H["读取某一版数组"]
H --> I["不加锁完成读取"]它不是“写时只复制被修改元素”,而是复制底层数组。数组有 100 万个元素时,添加一个元素也要分配并复制约 100 万个引用。
6.2 为什么读线程不需要锁
写线程只修改尚未发布的新数组,不会原地改变读线程正在使用的旧数组;完成后通过 volatile 写切换引用。读线程要么看到旧数组,要么看到新数组,不会看到复制到一半的数组。
6.3 快照迭代
迭代器创建时保存当前数组引用,之后其他线程修改列表,迭代器仍遍历旧数组:
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/set 抛 UnsupportedOperationException。
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 节点和两个指针
逻辑上每个节点包含 item 和 next,队列维护 head、tail。入队核心是找到真正尾节点,通过 CAS 把其 next 从 null 改为新节点;tail 允许暂时落后,后续线程帮助推进。
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 可回收不再可达部分。
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
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,支持 firstKey、ceilingEntry、subMap 等范围操作,预期查找、插入、删除复杂度为 O(log n)。
8.1 跳表为什么快
普通有序链表只能逐个向后走。跳表在基础有序链表之上建立多层稀疏索引:高层跨大步,发现下一节点超过目标后下沉一层继续。
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:待执行时间窗口
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/take、offer/poll、Array/Linked/Synchronous/Priority/Delay 队列的锁、Condition、容量、线程池选型和堆积排查已在 BlockingQueue 全过程 详细说明。
关键区别:
| 需求 | ConcurrentLinkedQueue | 有界 BlockingQueue |
|---|---|---|
| 入队满时 | 没有容量满概念 | 拒绝、超时或阻塞 |
| 出队空时 | 立即返回 null | 可等待元素 |
| 背压 | 无 | 可通过容量表达 |
| 算法 | CAS 非阻塞链表 | 常见实现用锁和 Condition |
| 风险 | 无界堆积 OOM | 队列满导致等待/拒绝,需设计策略 |
十一、商业选型决策
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 | 是否允许无界、消费者轮询策略 |
| 价格/时间范围索引 | ConcurrentSkipListMap | Comparator、持久化和多实例边界 |
| 异步任务缓冲 | 有界 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-修改-put、containsKey-put、跨 key 更新。线程安全容器只能保证其定义的单次/原子 API,不能自动把业务多步骤变成事务。改用 putIfAbsent/replace/compute/merge、不可变对象 CAS、锁或数据库事务。
12.5 范围查询结果数量波动
ConcurrentSkipListMap 的弱一致迭代允许并发变化。先确认业务是否真的需要时点快照;若需要,固定版本、复制视图或转移到支持事务快照的数据库。不能通过重复 size 和遍历“碰运气”获得一致结果。
十三、JDK 7、8 与现代版本
| 能力 | JDK 7 | JDK 8 | 后续版本 |
|---|---|---|---|
| CHM 主结构 | Segment | Node + CAS + 桶锁 | 持续演进 |
compute/merge | 无 | 引入 | 支持 |
LongAdder | 无 | 引入 | 支持 |
| CopyOnWrite/CLQ/SkipList | 可用 | 可用 | 语义基本兼容 |
| Lambda Demo | 匿名内部类 | 可用 Lambda | 可用 |
ConcurrentHashMap.newKeySet | 无 | 有 | 有 |
学习 JDK 8 ConcurrentHashMap 时不能把 Segment 和桶头锁混在同一个版本回答;写兼容 JDK 7 的 Demo 时不能使用 Lambda、merge、computeIfAbsent 或 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 安全就并发改普通 value | value 内部竞态 | 不可变或并发 value |
| CopyOnWrite 高频单条写大列表 | CPU、分配率和 GC 激增 | 批量快照或换容器 |
| 用快照迭代器期待最新值 | 业务读取旧规则 | 明确版本和刷新语义 |
| CLQ 作为无限任务缓冲 | 堆积到 OOM | 有界队列和背压 |
CLQ size() 做并发限流 | O(n) 且检查插入不原子 | Semaphore/有界队列 |
| 修改 SkipList key 排序字段 | 排序和查找语义破坏 | key 不可变 |
| 把弱一致遍历当报表快照 | 数量与明细不一致 | 版本快照或数据库事务 |
十六、关联知识与验收
- ConcurrentHashMap 全过程
- BlockingQueue 全过程
- JUC 并发协作工具全过程
- CAS 与 ABA
- JMM 与 happens-before
- 线程池生命周期与排查
- JavaSE 面试知识点
掌握验收:
- 能解释“线程安全 Map”为什么仍会
get-put丢更新; - 能画出 CopyOnWrite 复制、修改、volatile 发布和快照迭代过程;
- 能解释 CLQ tail 为什么允许落后、poll 为什么逻辑删除、size 为什么 O(n);
- 能用跳表分层搜索解释 ConcurrentSkipListMap 的 O(log n) 预期复杂度;
- 能为配置缓存、监听器、事件队列、范围索引和异步任务分别选择容器并说明不选其他容器的原因;
- 面对生产高 CPU、内存峰值或队列增长,能提出可验证证据,而不是只说“换成线程安全集合”。
