
引言
在现代多核CPU架构下,并发编程已成为Java开发者的必备技能。构建高性能、线程安全的应用,关键在于正确选择和使用并发集合。本文旨在深入探讨Java并发包中的两个核心组件 ConcurrentHashMap
和 CopyOnWriteArrayList
,并通过一个真实的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
类似,为“数组 + 链表 / 红黑树”。 - 工作方式:
- 无锁操作: 在向哈希桶(数组的某个槽位)放入第一个节点时,使用无锁的CAS(Compare-And-Swap)操作,避免了无竞争情况下的加锁开销。
- 哈希桶锁: 当多个线程需要修改同一个哈希桶(即发生哈希冲突)时,使用
synchronized
关键字锁定该哈希桶的头节点。锁的粒度从“段”缩小到了“桶”,极大地提升了并发度。 - 无锁读取:
get
操作完全不加锁,利用volatile
保证内存可见性,性能极高。 - 红黑树优化: 当哈希桶内的链表过长时,会转换为红黑树,将严重冲突时的查询效率从 O(n) 优化到 O(logn)。
第二部分:构建一个线程安全的SSE推送服务
理论结合实践,我们来看一个具体的并发问题。
2.1 业务场景
我们需要实现一个SSE(Server-Sent Events)服务,用于向特定设备(deviceId
)的多个客户端(如多个浏览器标签页)实时推送流式数据。
2.2 代码实现
1 |
|
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 为什么它是最佳选择
- 迭代绝对安全: 由于读写分离,迭代器永远不会感知到后续的修改,因此**绝对不会抛出
ConcurrentModificationException
**。 - 高并发读取: 读操作无锁,性能极高。多个线程可以同时、安全地遍历列表。
- 适用场景匹配: SSE场景是典型的“读多写少”。推送消息(读)非常频繁,而客户端连接/断开(写)相对较少。这使得
CopyOnWriteArrayList
高昂的写成本可以被接受。
第四部分:为什么其他List不行?
若不使用 CopyOnWriteArrayList
,而使用其他List
,将会导致严重问题。
4.1 ArrayList
:非线程安全
直接使用 ArrayList
会在并发修改时立即抛出 ConcurrentModificationException
,或造成数据不一致,是完全不可行的。
4.2 synchronizedList
:方法级同步的局限性
Collections.synchronizedList()
提供了方法级别的同步,但这并不能解决“迭代时修改”的问题。
4.2.1 foreach
循环的陷阱
foreach
循环在底层依赖 Iterator
。它的 hasNext()
和 next()
方法调用之间存在执行间隙。
- 遍历线程调用
iterator.hasNext()
(加锁->检查->解锁)。 - 修改线程乘虚而入,调用
list.remove()
(加锁->修改->解锁)。 - 遍历线程再调用
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应用的基础。
- 默认使用
ConcurrentHashMap
: 在需要线程安全的Map时,它几乎永远是你的最佳选择。 - 理解
CopyOnWriteArrayList
的场景: 当你需要一个线程安全的List,且面临“高频读取/遍历”和“低频写入/修改”的场景时,CopyOnWriteArrayList
是完美的解决方案。 - 慎用
synchronizedList
: 它仅适用于保护简单的、非复合的单步操作。绝不能想当然地认为它能保证遍历等复杂操作的线程安全。 - 拥抱“快速失败”:
ConcurrentModificationException
不是敌人,而是朋友。它在帮助你第一时间发现并发设计中的缺陷。