Appearance
第27课:并发容器
🎯 学习目标
- 理解 ConcurrentHashMap 的并发原理(JDK 7 分段锁 vs JDK 8 CAS+synchronized)
- 掌握 CopyOnWriteArrayList 的写时复制原理与适用场景
- 掌握 BlockingQueue 系列(生产者-消费者利器)
- 理解 ConcurrentLinkedQueue、ConcurrentSkipListMap
- 能根据场景选择合适的并发容器
📖 一、概念讲解:为什么需要并发容器
1. 同步包装器的局限
java
Map<String, Integer> m = Collections.synchronizedMap(new HashMap<>());synchronizedMap 用一把锁包裹所有方法——读读、写写、读写都互斥,并发度低。遍历时还需手动 synchronized(否则 ConcurrentModificationException)。
2. 并发容器的思路
- 细粒度锁:ConcurrentHashMap 只锁一个桶(JDK 8 锁桶头节点),不同 key 并发。
- 写时复制:CopyOnWriteArrayList 写时复制整个数组,读完全无锁。
- 非阻塞算法:ConcurrentLinkedQueue 用 CAS,无锁。
不同策略适应不同场景(读多写少 vs 高并发读写 vs 队列)。
📖 二、ConcurrentHashMap
java
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1); // 线程安全,无需 synchronized
Integer v = map.get("a");
map.putIfAbsent("b", 2); // 不存在才放
map.computeIfAbsent("c", k -> k.length()); // 原子计算
map.forEach(2, (k, val) -> {}); // 并行遍历(并发度2)原理演进
- JDK 7:分段锁(Segment[]),每段一把锁,默认 16 段 → 理论并发度 16。
- JDK 8:废弃分段锁,改用 CAS + synchronized 锁桶头节点。
- 空/无锁竞争:CAS 写入。
- 有竞争:synchronized 锁该桶的头节点(粒度更细,只锁一个桶)。
- 链表长度 ≥ 8 转红黑树(同 HashMap)。
并发度从"段数(16)"提升到"桶数",大幅提高。
原子复合操作
java
map.compute(key, (k, old) -> old == null ? 1 : old + 1); // 原子自增
map.merge(key, 1, Integer::sum); // 原子合并这些方法在内部加锁保证原子性,避免"先 get 再 put"的竞态。
与 HashMap 区别
- 线程安全。
- 不允许 null key/value(HashMap 允许)。
- size/isEmpty 弱一致(遍历期间可能变化)。
📖 三、CopyOnWriteArrayList
java
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("a"); // 写:复制整个数组,加新元素,替换引用
list.get(0); // 读:直接读数组,无锁
// 遍历不需 synchronized(基于快照)
for (String s : list) { System.out.println(s); }原理:写时复制
- 读:直接读内部 final 数组,无锁,极快。
- 写:复制整个数组 → 修改 → 把内部引用指向新数组。
- 写操作加锁(保证多线程写不冲突),读完全无锁。
适用与代价
- 适合:读远多于写(监听器列表、配置缓存)。读性能极佳。
- 代价:写复制整个数组,O(n) 且占内存,频繁写不适合。遍历是快照(读时不会看到后续写)。
vs Collections.synchronizedList
- synchronizedList 读写都锁,读慢。
- CopyOnWrite 读无锁,写复制——读多写少选 CopyOnWrite。
📖 四、BlockingQueue 系列
阻塞队列是生产者-消费者的标准实现(封装了 wait/notify):
| 实现 | 特点 | 典型用途 |
|---|---|---|
| ArrayBlockingQueue | 有界,数组 | 通用生产者-消费者 |
| LinkedBlockingQueue | 可选有界,链表 | 线程池默认队列 |
| SynchronousQueue | 无容量,直接交接 | newCachedThreadPool |
| PriorityBlockingQueue | 优先级,无界 | 优先级任务 |
| DelayQueue | 延迟元素 | 定时任务 |
java
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100);
// 生产者
queue.put(task); // 满则阻塞
// 消费者
Task t = queue.take(); // 空则阻塞阻塞 vs 非阻塞方法
- 阻塞:put/take(满/空时阻塞)。
- 返回特殊值:offer/poll(满/空返回 false/null)。
- 超时:offer(e, timeout)/poll(timeout)。
- 抛异常:add/remove。
📖 五、ConcurrentLinkedQueue
无锁(CAS)非阻塞队列,适合高并发但不需要阻塞的场景:
java
ConcurrentLinkedQueue<String> q = new ConcurrentLinkedQueue<>();
q.offer("a"); // 入队,CAS
q.poll(); // 出队,CASvs BlockingQueue:ConcurrentLinkedQueue 不阻塞(空时 poll 返回 null,不等待)。适合"自己控制节奏"的高并发场景,不适合生产者-消费者(消费者要阻塞等数据用 BlockingQueue)。
📖 六、其他并发容器
- ConcurrentSkipListMap/Set:基于跳表,有序并发 Map/Set。替代 TreeMap 的并发版。
- ConcurrentHashMap 的弱一致遍历:迭代器弱一致,不抛 CME,但可能不反映遍历期间的修改。
⚠️ 七、常见陷阱
陷阱1:复合操作非原子
java
if (!map.containsKey(k)) map.put(k, v); // ❌ 检查与放置之间可能被其他线程插入修复:用 putIfAbsent / computeIfAbsent 等原子方法。
陷阱2:CopyOnWriteArrayList 频繁写
写复制整个数组,频繁写性能差、占内存。读多写少才用。
陷阱3:遍历同步容器不锁定
java
synchronizedMap.forEach(...) // ❌ 可能 CME(遍历需手动加锁)用并发容器(ConcurrentHashMap/CopyOnWrite)避免。
陷阱4:ConcurrentHashMap 允许 null
不允许!null key/value 会抛 NPE(与 HashMap 不同)。因为并发下 null 有歧义(get 返回 null 不知是不存在还是值为 null)。
陷阱5:LinkedBlockingQueue 默认无界
new LinkedBlockingQueue<>() 容量 Integer.MAX_VALUE,可能 OOM。生产指定容量。
🆚 八、Java vs C / 选择指南
| 场景 | 选择 |
|---|---|
| 高并发 Map | ConcurrentHashMap |
| 读多写少 List | CopyOnWriteArrayList |
| 生产者-消费者队列 | ArrayBlockingQueue / LinkedBlockingQueue |
| 高并发无阻塞队列 | ConcurrentLinkedQueue |
| 有序并发 Map | ConcurrentSkipListMap |
对 C 程序员:Java 并发容器把 C 里手写的"加锁哈希表""信号量队列"封装成开箱即用的类。关键是理解每种容器的并发策略(细粒度锁/CAS/写时复制),按场景选。
💡 九、最佳实践
- 高并发 Map 用 ConcurrentHashMap,不用 synchronizedMap。
- 复合操作用原子方法:putIfAbsent/computeIfAbsent/merge。
- 读多写少用 CopyOnWriteArrayList(监听器、配置)。
- 生产者-消费者用 BlockingQueue,少手写 wait/notify。
- 无界队列慎用,生产指定容量防 OOM。
- 遍历并发容器无需加锁(弱一致或快照)。
📝 练习预告
完成 练习/Ex27_ConcurrentCollections.java 中的 6 道题:
- ConcurrentHashMap 基本操作
- 原子复合操作(compute/merge)
- CopyOnWriteArrayList 读写
- BlockingQueue 生产者-消费者
- ConcurrentLinkedQueue 无锁队列
- 综合:并发计数器(ConcurrentHashMap vs HashMap)
完成后对比 答案/Sol27.java,查看逐行讲解与多解法。
📖 十、弱一致遍历
ConcurrentHashMap 的迭代器是弱一致的。
含义:
text
遍历时不会抛 ConcurrentModificationException。
遍历期间的修改可能看到,也可能看不到。
遍历结果不是某个绝对瞬间的快照。这与 fail-fast 迭代器不同:
text
ArrayList/HashMap 普通迭代器检测到结构修改,可能抛 CME。
ConcurrentHashMap 允许边遍历边修改,但不保证强一致视图。
CopyOnWriteArrayList 遍历的是创建迭代器时的快照。如果需要强一致快照,可以复制一份:
java
Map<String, Integer> snapshot = new HashMap<>(concurrentMap);📖 十一、compute 方法注意事项
ConcurrentHashMap 的 compute、merge、computeIfAbsent 是原子复合操作。
示例计数:
java
map.merge(word, 1, Integer::sum);注意事项:
text
计算函数会在内部同步区域执行。
函数应短小,不能做长时间阻塞 IO。
不要在计算函数中递归更新同一个 Map。
函数可能在竞争下被重新尝试,要避免副作用。缓存初始化示例:
java
User user = cache.computeIfAbsent(id, key -> loadUser(key));如果 loadUser 很慢,会阻塞同一个 key 相关的竞争线程。高延迟加载可以考虑 Future 缓存或异步加载。
🧪 十二、CopyOnWrite 监听器列表
CopyOnWriteArrayList 很适合监听器列表:
java
class EventBus {
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
public void addListener(Listener listener) {
listeners.addIfAbsent(listener);
}
public void publish(Event event) {
for (Listener listener : listeners) {
listener.onEvent(event);
}
}
}原因:
text
监听器注册少,事件发布多。
遍历期间允许新增或删除监听器。
读不加锁,发布路径快。
快照遍历避免 ConcurrentModificationException。不适合:
text
高频写入列表。
大列表频繁修改。
写入延迟敏感场景。📊 十三、并发容器选型补充
| 需求 | 推荐 | 不推荐 |
|---|---|---|
| 高并发 key-value | ConcurrentHashMap | synchronizedMap |
| 读多写少列表 | CopyOnWriteArrayList | Vector |
| 有界任务队列 | ArrayBlockingQueue | 无界 LinkedBlockingQueue |
| 非阻塞队列 | ConcurrentLinkedQueue | 手写 CAS 队列 |
| 有序并发 Map | ConcurrentSkipListMap | TreeMap + 手动锁 |
| 并发 Set | ConcurrentHashMap.newKeySet | HashSet + 手动锁 |
并发容器不是越高级越好。关键看:
text
读写比例。
是否需要阻塞。
是否需要排序。
是否需要强一致遍历。
是否需要容量上限。🛠 十四、并发容器排查清单
常见问题:
text
HashMap 在多线程下被当作并发 Map 使用。
ConcurrentHashMap 中执行 get 后再 put,复合操作不原子。
CopyOnWriteArrayList 写太频繁导致内存和 CPU 飙升。
LinkedBlockingQueue 默认无界导致堆积。
PriorityBlockingQueue 无界且优先级比较不稳定。
ConcurrentHashMap 不允许 null。
遍历结果被误认为强一致快照。监控建议:
text
Map 大小。
队列长度。
入队出队速率。
写入频率。
拒绝或丢弃数量。
对象分配速率。✅ 十五、掌握标准
学完本课后,应能做到:
text
能解释 synchronizedMap 和 ConcurrentHashMap 的差异。
能使用 putIfAbsent、computeIfAbsent、merge。
能说明 ConcurrentHashMap 为什么不允许 null。
能解释 CopyOnWriteArrayList 的快照遍历。
能根据读写比例选择并发 List。
能区分 BlockingQueue 和 ConcurrentLinkedQueue。
能识别无界队列 OOM 风险。
能根据排序、阻塞、容量选择并发容器。并发容器解决的是特定并发访问模式,不是“把所有线程安全问题自动消灭”。复合操作、容量边界和业务一致性仍要自己设计。
🧪 十六、案例:并发词频统计
需求:多个线程同时统计单词出现次数。
错误写法:
java
Map<String, Integer> map = new HashMap<>();
map.put(word, map.getOrDefault(word, 0) + 1);问题:
text
HashMap 非线程安全。
getOrDefault + put 不是原子复合操作。
并发更新会丢失计数。改进写法:
java
ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();
public void add(String word) {
counter.computeIfAbsent(word, k -> new LongAdder()).increment();
}
public long count(String word) {
LongAdder adder = counter.get(word);
return adder == null ? 0 : adder.sum();
}为什么这样写:
text
ConcurrentHashMap 保证并发 Map 结构安全。
computeIfAbsent 保证初始化过程原子。
LongAdder 降低高并发计数竞争。
读取 sum 用于统计展示,允许瞬时弱一致。如果计数必须绝对精确并参与交易决策,需要使用数据库事务、锁或更严格的一致性方案,而不是简单依赖 LongAdder。
🎓 下一步
- 第28课:原子类 — AtomicInteger、CAS、LongAdder