从ConcurrentHashMap到CopyOnWriteArrayList的深度解析
CYY

引言

在现代多核CPU架构下,并发编程已成为Java开发者的必备技能。构建高性能、线程安全的应用,关键在于正确选择和使用并发集合。本文旨在深入探讨Java并发包中的两个核心组件 ConcurrentHashMapCopyOnWriteArrayList,并通过一个真实的SSE(Server-Sent Events)消息推送案例,详细剖析在“迭代时修改”这一典型并发场景下,不同集合的选择会导致何种结果,从而揭示synchronizedList等传统同步容器的常见陷阱。

第一部分:高性能的并发基石 - ConcurrentHashMap

ConcurrentHashMap 是Java并发包提供的线程安全且高性能的哈希表实现,是构建并发系统的首选键值存储。

1.1 为什么需要它?

  • HashMap: 非线程安全,多线程下可能导致数据错乱甚至死循环。
  • Hashtable: 线程安全,但通过对几乎所有方法使用synchronized全局锁实现,性能极低,是典型的并发瓶颈。 ConcurrentHashMap 的设计目标就是在保证线程安全的同时,实现最大限度的并发读写。

1.2 核心思想:锁粒度的演进

ConcurrentHashMap 的高性能秘诀在于其不断优化的“锁分离”思想,即减小锁的粒度,允许多个线程在不同数据段上并行操作。

1.3 Java 1.7: 分段锁 (Segmentation)

在Java 1.7及更早版本中,ConcurrentHashMap 由一个 Segment 数组和多个 HashEntry 数组构成。

  • 结构: ConcurrentHashMap -> Segment[] -> HashEntry[]
  • Segment: 每个 Segment 本质上是一个自带锁(ReentrantLock)的小型哈希表。
  • 工作方式: 对数据操作时,首先根据key的哈希值定位到某个Segment,然后只锁定该Segment进行操作。
  • 优势: 只要线程操作的key不属于同一个Segment,它们就可以并行执行,并发度默认为16。
  • 劣势: size() 计算复杂且开销大;锁的粒度仍然相对较粗。

1.4 Java 1.8+: CAS + 哈希桶锁

从Java 1.8开始,Segment 的设计被废弃,转而采用更细粒度的锁机制。

  • 结构: 内部结构与 HashMap 类似,为“数组 + 链表 / 红黑树”。
  • 工作方式:
    1. 无锁操作: 在向哈希桶(数组的某个槽位)放入第一个节点时,使用无锁的CAS(Compare-And-Swap)操作,避免了无竞争情况下的加锁开销。
    2. 哈希桶锁: 当多个线程需要修改同一个哈希桶(即发生哈希冲突)时,使用 synchronized 关键字锁定该哈希桶的头节点。锁的粒度从“段”缩小到了“桶”,极大地提升了并发度。
    3. 无锁读取: get 操作完全不加锁,利用 volatile 保证内存可见性,性能极高。
    4. 红黑树优化: 当哈希桶内的链表过长时,会转换为红黑树,将严重冲突时的查询效率从 O(n) 优化到 O(logn)。

第二部分:构建一个线程安全的SSE推送服务

理论结合实践,我们来看一个具体的并发问题。

2.1 业务场景

我们需要实现一个SSE(Server-Sent Events)服务,用于向特定设备(deviceId)的多个客户端(如多个浏览器标签页)实时推送流式数据。

2.2 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Component
public class SsePushService {

// 使用ConcurrentHashMap存储每个设备对应的Emitter列表
private final Map<Long, List<SseEmitter>> deviceEmitterMap = new ConcurrentHashMap<>();

public SseEmitter register(Long deviceId) {
if (deviceId == null) {
throw new IllegalArgumentException("deviceId cannot be null");
}
SseEmitter emitter = new SseEmitter(0L); // 永不超时

// 关键点1: 使用computeIfAbsent保证原子性地创建List
// 关键点2: List的实现选择了CopyOnWriteArrayList
deviceEmitterMap.computeIfAbsent(deviceId, k -> new CopyOnWriteArrayList<>()).add(emitter);

// 关键点3: 在连接终止时,从List中移除Emitter
Runnable removeEmitter = () -> {
List<SseEmitter> emitters = deviceEmitterMap.get(deviceId);
if (emitters != null) {
emitters.remove(emitter);
}
};
emitter.onCompletion(removeEmitter);
emitter.onTimeout(removeEmitter);
emitter.onError(e -> removeEmitter.run());

return emitter;
}

public void pushToDevice(Long deviceId, StreamDataShowDto data) {
List<SseEmitter> emitters = deviceEmitterMap.getOrDefault(deviceId, Collections.emptyList());
// 关键点4: 遍历List进行推送
for (SseEmitter emitter : emitters) {
try {
emitter.send(data, MediaType.APPLICATION_JSON);
} catch (IOException e) {
emitter.completeWithError(e);
}
}
}
}

2.3 核心并发挑战:迭代时修改

此代码存在一个典型的并发冲突:

  • 迭代线程: pushToDevice 方法中的 for 循环会遍历某个 deviceId 对应的 List。
  • 修改线程: 当客户端连接断开、超时或出错时,Web服务器的I/O线程会执行 onCompletion/onTimeout/onError 回调,进而调用 list.remove() 修改同一个 List。

一个线程正在遍历集合,而另一个线程同时在修改它,这是并发编程中的经典难题。

第三部分:迭代安全的解决方案 - CopyOnWriteArrayList

在上述代码中,CopyOnWriteArrayList 是解决问题的“银弹”。

3.1 工作原理:写时复制 (Copy-on-Write)

  • 读操作(迭代): 读取是无锁的。获取迭代器时,它会引用一个指向底层数组的快照。后续任何对列表的修改都不会影响这个快照,迭代器将安全地遍历完创建它那一刻的数据。
  • 写操作(add/remove): 写入是加锁的。它会完整地复制一份底层数组,在新数组上进行修改,然后原子性地将内部指针指向新数组。

3.2 为什么它是最佳选择

  1. 迭代绝对安全: 由于读写分离,迭代器永远不会感知到后续的修改,因此**绝对不会抛出 ConcurrentModificationException**。
  2. 高并发读取: 读操作无锁,性能极高。多个线程可以同时、安全地遍历列表。
  3. 适用场景匹配: SSE场景是典型的“读多写少”。推送消息(读)非常频繁,而客户端连接/断开(写)相对较少。这使得CopyOnWriteArrayList高昂的写成本可以被接受。

第四部分:为什么其他List不行?

若不使用 CopyOnWriteArrayList,而使用其他List,将会导致严重问题。

4.1 ArrayList:非线程安全

直接使用 ArrayList 会在并发修改时立即抛出 ConcurrentModificationException,或造成数据不一致,是完全不可行的。

4.2 synchronizedList:方法级同步的局限性

Collections.synchronizedList() 提供了方法级别的同步,但这并不能解决“迭代时修改”的问题。

4.2.1 foreach 循环的陷阱

foreach 循环在底层依赖 Iterator。它的 hasNext()next() 方法调用之间存在执行间隙

  1. 遍历线程调用 iterator.hasNext() (加锁->检查->解锁)。
  2. 修改线程乘虚而入,调用 list.remove() (加锁->修改->解锁)。
  3. 遍历线程再调用 iterator.next() (加锁->检查发现modCount不一致->抛出ConcurrentModificationException!)。 synchronizedList 保证了单步操作的安全,但无法保证复合操作(整个遍历过程)的安全。
4.2.2 for-i 循环:更危险的深渊

有人可能会尝试用 for(int i = 0; i < list.size(); i++) 规避 Iterator。这种做法更危险,因为它用数据错乱代替了明确的异常。

  • 跳过元素: 如果在遍历时,一个靠前的元素被移除,后面的元素索引会前移,而循环变量 i 正常递增,导致一个元素被永久跳过。
  • IndexOutOfBoundsException: 如果在循环即将结束时,末尾的元素被移除,list.get(i) 可能会访问一个不再存在的索引,导致异常。

ConcurrentModificationException 是一种“快速失败”机制,它是一个有益的警报。而 for-i 循环的“悄悄失败”则会将Bug隐藏起来,导致难以追踪的数据一致性问题。

总结与建议

选择正确的并发集合是构建健壮Java应用的基础。

  1. 默认使用 ConcurrentHashMap: 在需要线程安全的Map时,它几乎永远是你的最佳选择。
  2. 理解CopyOnWriteArrayList的场景: 当你需要一个线程安全的List,且面临“高频读取/遍历”和“低频写入/修改”的场景时,CopyOnWriteArrayList 是完美的解决方案。
  3. 慎用 synchronizedList: 它仅适用于保护简单的、非复合的单步操作。绝不能想当然地认为它能保证遍历等复杂操作的线程安全。
  4. 拥抱“快速失败”: ConcurrentModificationException 不是敌人,而是朋友。它在帮助你第一时间发现并发设计中的缺陷。
 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep