Skip to content

第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();        // 出队,CAS

vs 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&lt;&gt;() 容量 Integer.MAX_VALUE,可能 OOM。生产指定容量。


🆚 八、Java vs C / 选择指南

场景选择
高并发 MapConcurrentHashMap
读多写少 ListCopyOnWriteArrayList
生产者-消费者队列ArrayBlockingQueue / LinkedBlockingQueue
高并发无阻塞队列ConcurrentLinkedQueue
有序并发 MapConcurrentSkipListMap

对 C 程序员:Java 并发容器把 C 里手写的"加锁哈希表""信号量队列"封装成开箱即用的类。关键是理解每种容器的并发策略(细粒度锁/CAS/写时复制),按场景选。


💡 九、最佳实践

  1. 高并发 Map 用 ConcurrentHashMap,不用 synchronizedMap。
  2. 复合操作用原子方法:putIfAbsent/computeIfAbsent/merge。
  3. 读多写少用 CopyOnWriteArrayList(监听器、配置)。
  4. 生产者-消费者用 BlockingQueue,少手写 wait/notify。
  5. 无界队列慎用,生产指定容量防 OOM。
  6. 遍历并发容器无需加锁(弱一致或快照)。

📝 练习预告

完成 练习/Ex27_ConcurrentCollections.java 中的 6 道题:

  1. ConcurrentHashMap 基本操作
  2. 原子复合操作(compute/merge)
  3. CopyOnWriteArrayList 读写
  4. BlockingQueue 生产者-消费者
  5. ConcurrentLinkedQueue 无锁队列
  6. 综合:并发计数器(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 的 computemergecomputeIfAbsent 是原子复合操作。

示例计数:

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-valueConcurrentHashMapsynchronizedMap
读多写少列表CopyOnWriteArrayListVector
有界任务队列ArrayBlockingQueue无界 LinkedBlockingQueue
非阻塞队列ConcurrentLinkedQueue手写 CAS 队列
有序并发 MapConcurrentSkipListMapTreeMap + 手动锁
并发 SetConcurrentHashMap.newKeySetHashSet + 手动锁

并发容器不是越高级越好。关键看:

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