1. 为什么事件监听器管理需要CopyOnWriteArrayList?
如果你写过GUI程序,或者用过观察者模式,那你肯定遇到过这个经典难题:我有一堆监听器(比如按钮点击监听器、数据变化监听器),当事件发生时,我需要安全、高效地通知所有监听器。但麻烦在于,监听器列表本身是动态的——用户可能在运行时注册新的监听器,也可能移除旧的监听器。
用最朴素的 ArrayList 试试看?你马上就会遇到 ConcurrentModificationException。想象一下这个场景:你正在遍历监听器列表挨个通知,突然另一个线程往列表里添加了一个新的监听器。ArrayList 的迭代器是“快速失败”的,它一检测到结构被修改,就会立刻抛出异常,程序就崩了。为了解决这个问题,你可能会想到加锁,用 synchronized 关键字把整个遍历和修改过程锁起来。这确实能保证安全,但代价是性能。在高并发事件触发的场景下,锁竞争会成为巨大的瓶颈,所有线程都得排队等着通知监听器,系统吞吐量直线下降。
这时候,CopyOnWriteArrayList 就像个“救火队员”一样出现了。我第一次在Swing的事件分发机制里看到它的应用时,有种豁然开朗的感觉。它的设计哲学非常巧妙:为了极致的读性能,牺牲一点写性能。具体到监听器管理这个场景,这简直是量身定做。因为监听器的注册和注销(写操作)通常是低频的(比如用户点击某个按钮才注册一个监听器),而事件的触发和通知(读操作,即遍历列表)则是高频的(比如鼠标移动事件每秒触发几十次)。CopyOnWriteArrayList 的核心“写时复制”机制,完美地化解了遍历与修改的冲突,让读操作可以完全无锁地进行,从而获得了极高的并发读取性能。
2. 写时复制机制:如何做到无锁读取?
CopyOnWriteArrayList 这个名字就揭示了它的核心:“写时复制”。我们来拆解一下这个听起来有点玄乎的机制到底是怎么工作的,以及它为什么能实现无锁读取。
你可以把它想象成一个公告板。这个公告板上贴着一张当前所有监听器的名单(一个数组)。当有人想读取这份名单时(比如事件触发需要通知所有人),他可以直接走过去看,不需要任何登记、不需要排队,因为看这个动作不会改变名单本身。这就是 无锁读取,性能极高。
那么,当需要修改名单时(比如新增或移除一个监听器)呢?管理员不会直接在原来的名单上涂改。他会做三件事:
- 加锁:先锁上公告板前的操作台,确保同一时间只有一个管理员能进行修改操作。
- 复制并修改:把原来的名单完整地抄写一份到一张新纸上,然后在这张新纸上进行添加或删除。
- 替换:把公告板上旧的那份名单撕下来,把新的名单贴上去。这个“贴上去”的动作是原子的,并且由于公告板的指针(数组引用)是
volatile的,所有后来的人都能立刻看到新名单。
这个过程在代码层面,以 add 方法为例,简化后是这样的:
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 1. 写操作加锁
try {
Object[] elements = getArray(); // 获取当前数组快照
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1); // 2. 复制新数组
newElements[len] = e; // 在新数组上修改
setArray(newElements); // 3. 替换引用(volatile写,保证可见性)
return true;
} finally {
lock.unlock();
}
}
而读操作,比如 get 或迭代,则简单到令人发指:
public E get(int index) {
return (E) getArray()[index]; // 直接访问数组,没有任何锁!
}
这里的关键点在于“快照”。当事件触发,开始遍历监听器列表时,CopyOnWriteArrayList 的迭代器拿到的是当前数组的一个“快照”。即便遍历过程中,其他线程注册了新的监听器(发生了写操作),也只是创建并替换了新的数组,对正在进行的遍历毫无影响。遍历器会稳稳当当地把快照里的所有监听器通知完。这就彻底杜绝了 ConcurrentModificationException,实现了遍历的安全性。
3. 实战:构建一个线程安全的事件管理器
光说不练假把式,我们动手写一个真正可用的事件管理器。这个管理器要能应对多线程环境下的监听器注册、注销和事件触发。
首先,定义我们的事件和监听器接口,为了简单起见,我们的事件就携带一个字符串消息:
// 事件对象
public class MyEvent {
private final String message;
public MyEvent(String message) { this.message = message; }
public String getMessage() { return message; }
}
// 监听器接口
@FunctionalInterface
public interface MyEventListener {
void onEvent(MyEvent event);
}
接下来是核心的事件管理器。我们将使用 CopyOnWriteArrayList 来存储监听器:
import java.util.concurrent.CopyOnWriteArrayList;
public class EventManager {
// 使用 final 修饰,确保监听器列表引用不变
private final CopyOnWriteArrayList<MyEventListener> listeners = new CopyOnWriteArrayList<>();
// 注册监听器(低频写操作)
public void registerListener(MyEventListener listener) {
if (listener != null && !listeners.contains(listener)) {
listeners.add(listener);
System.out.println(Thread.currentThread().getName() + " 注册了一个监听器。");
}
}
// 注销监听器(低频写操作)
public void unregisterListener(MyEventListener listener) {
if (listener != null) {
listeners.remove(listener);
System.out.println(Thread.currentThread().getName() + " 移除了一个监听器。");
}
}
// 触发事件(高频读/遍历操作)
public void fireEvent(MyEvent event) {
if (event == null || listeners.isEmpty()) {
return;
}
// 注意:这里遍历的是迭代器创建时的数组快照
for (MyEventListener listener : listeners) {
try {
// 在实际项目中,这里通常会考虑异常处理,避免一个监听器出错影响后续通知
listener.onEvent(event);
} catch (Exception e) {
// 记录日志,但不要抛出异常中断遍历
System.err.println("监听器处理事件异常: " + e.getMessage());
}
}
}
// 获取当前监听器数量(诊断用)
public int getListenerCount() {
return listeners.size();
}
}
现在,让我们模拟一个高并发场景来测试它。我们创建多个线程,有的不断注册/注销监听器,有的疯狂触发事件:
public class EventManagerDemo {
public static void main(String[] args) throws InterruptedException {
EventManager manager = new EventManager();
// 模拟几个不同的监听器
MyEventListener listener1 = event -> System.out.println("监听器1收到: " + event.getMessage());
MyEventListener listener2 = event -> System.out.println("监听器2收到: " + event.getMessage());
MyEventListener listener3 = event -> System.out.println("监听器3收到: " + event.getMessage());
// 线程A:不断触发事件(高频读)
Thread producer = new Thread(() -> {
for (int i = 0; i < 100; i++) {
manager.fireEvent(new MyEvent("事件-" + i));
try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
}
}, "事件触发线程");
// 线程B:随机注册/注销监听器(低频写)
Thread mutator = new Thread(() -> {
MyEventListener[] listeners = {listener1, listener2, listener3};
for (int i = 0; i < 20; i++) {
MyEventListener l = listeners[i % listeners.length];
if (i % 3 == 0) {
manager.unregisterListener(l);
} else {
manager.registerListener(l);
}
try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); }
}
}, "监听器管理线程");
producer.start();
mutator.start();
producer.join();
mutator.join();
System.out.println("最终监听器数量: " + manager.getListenerCount());
}
}
运行这段代码,你会发现尽管注册和触发在同时进行,但程序从未崩溃,也没有抛出并发修改异常。事件触发线程总是能安全地遍历当时的监听器快照并完成通知。这就是 CopyOnWriteArrayList 在事件监听器管理中带来的最直观的好处:简单而坚固的线程安全。
4. 性能权衡:何时用?何时不用?
CopyOnWriteArrayList 不是银弹,它的优势背后是明确的代价。理解这些权衡,你才能做出正确的技术选型。我们可以从读、写、内存三个维度来对比。
1. 读性能:王者
这是它最大的亮点。由于读操作完全无锁,其性能在并发环境下几乎与单线程读取 ArrayList 无异。在事件通知这种需要遍历整个列表的场景下,优势巨大。线程越多,读操作越频繁,这个优势就越明显。
2. 写性能:代价高昂
每次 add、remove、set 操作,都伴随着一次全量数组复制。这意味着写操作的时间复杂度是 O(n),其中 n 是列表当前的大小。如果你有一个包含一万个监听器的列表(虽然不常见),每次注册一个新监听器,都需要拷贝这一万个引用。这带来的不仅是CPU开销,还有内存分配的压力。
3. 内存开销:瞬时翻倍 在写操作执行的那个瞬间,内存中会同时存在旧数组和新数组两份完整数据,直到旧数组不再被引用并被垃圾回收。对于大列表,这会导致瞬间的内存使用量激增,可能加剧GC(垃圾回收)的压力。
基于以上特点,我画了一个简单的决策表,帮你判断:
| 场景特征 | 推荐使用? | 原因与说明 |
|---|---|---|
| 读极多,写极少 (如:事件监听器、配置项缓存) | 强烈推荐 | 完美契合设计目标,无锁读的优势得以最大化发挥。 |
| 写操作频繁 (如:实时计数器、高频更新的队列) | 绝对避免 | O(n)的写性能会成为灾难,内存和CPU开销无法承受。 |
| 列表规模巨大 (如:超过数万个元素) | 谨慎使用 | 即使写操作少,单次复制的成本也可能过高,需严格评估。 |
| 要求强数据一致性 (如:金融交易状态) | 不适用 | 它的迭代器是弱一致性的,读操作可能看不到最新的写入。 |
| 内存资源极度紧张 | 避免使用 | 写时复制的内存翻倍特性在受限环境下可能是致命的。 |
一个常见的误区:开发者看到“线程安全”就盲目使用。我见过有人用 CopyOnWriteArrayList 来记录实时日志,每秒写入上千条,很快程序就因GC频繁而卡顿。对于这种写多读少的场景,ConcurrentLinkedQueue 是更优的选择。
那么,事件监听器场景通常符合“读多写少”吗? 是的,在绝大多数GUI框架或观察者模式实现中,监听器的注册和注销往往发生在用户交互或初始化阶段,频率很低。而事件触发(如鼠标移动、定时器、消息到达)则可能非常频繁。这种不对称性,正是 CopyOnWriteArrayList 大显身手的地方。
5. 避坑指南:使用CopyOnWriteArrayList的注意事项
用了这么多年,我也踩过不少坑。把这些经验总结下来,希望能帮你绕开这些陷阱。
第一坑:在迭代中调用修改方法
CopyOnWriteArrayList 的迭代器是基于创建时的快照,它自己是安全的。但是,迭代器不支持通过 remove() 方法修改集合。如果你调用 iterator.remove(),会直接抛出 UnsupportedOperationException。这是设计使然,因为迭代器不知道当前底层数组是哪一个。正确的做法是使用事件管理器本身的 unregisterListener 方法,它会作用于真正的列表上。
// 错误做法!
for (MyEventListener listener : listeners) {
if (someCondition) {
// 这行会抛异常!
// listeners.remove(listener);
// 应该使用:
// eventManager.unregisterListener(listener);
}
}
第二坑:忽略弱一致性
这是最重要的一个认知点。由于遍历的是快照,正在进行的遍历无法感知到遍历开始后新注册的监听器。举个例子,如果一个监听器在事件触发循环的中途被注册,那么这次触发事件它收不到通知,必须等到下一次事件触发。对于绝大多数事件系统,这种短暂的延迟是可接受的(最终一致性)。但如果你需要确保新监听器立刻生效,就不能单纯依赖 CopyOnWriteArrayList,可能需要结合其他同步机制。
第三坑:频繁的单个写操作
这是性能杀手。避免在循环中逐个调用 add()。
// 性能差的写法
for (String ip : ipListFromDB) {
blacklist.add(ip); // 每次add都复制一次数组!
}
// 性能好的写法:使用 addAll 批量操作
blacklist.addAll(ipListFromDB); // 只复制一次数组
如果无法批量,但写操作又确实来自多个线程且较频繁,可以考虑引入一个缓冲队列,定期批量写入 CopyOnWriteArrayList。
第四坑:误用于超大对象集合
CopyOnWriteArrayList 存储的是对象的引用。如果集合里存放的是非常大的对象(比如巨大的缓存数据块),虽然复制的是引用数组,开销相对可控,但仍需注意。更危险的是集合本身规模巨大。一个包含10万个字符串的列表,每次复制带来的开销是实实在在的。对于配置、监听器这类场景,集合规模通常很小(几十到几百个),所以没问题。但如果你的“监听器”列表膨胀到上万,就需要重新审视架构了。
第五坑:忘记内存开销
在监控系统性能时,除了CPU,也要关注GC日志。如果发现 CopyOnWriteArrayList 使用后,Young GC或Full GC的频率明显增高,很可能就是写操作导致的大量临时数组在“作怪”。这时候就需要评估写频率是否真的如预期那么低,或者集合是否过大了。
6. 进阶:对比其他并发容器,为什么偏偏是它?
Java并发包提供了不少线程安全的容器,为什么在事件监听器这个场景下,大家更偏爱 CopyOnWriteArrayList 呢?我们来和几个常见的“竞争对手”做个对比。
1. vs Collections.synchronizedList(new ArrayList<>())
这是最直接的替代方案。它通过在方法内部使用 synchronized 块来保证线程安全。
- 读性能:
synchronizedList的读操作也需要获取锁,在高并发读取时,锁竞争会成为瓶颈。而CopyOnWriteArrayList读操作无锁,优势明显。 - 写性能:
synchronizedList直接修改原数组,写操作是O(1)或O(n)(扩容时),通常比CopyOnWriteArrayList的O(n)复制要快。 - 一致性:
synchronizedList是强一致性的,能立刻看到修改。 - 迭代器:
synchronizedList的迭代器必须在外部手动同步,否则会抛异常,非常麻烦。
结论:在读远多于写且需要安全、方便的遍历的场景下,CopyOnWriteArrayList 的编程模型更简单,并发读性能更高。
2. vs ConcurrentLinkedQueue
这是一个基于链接节点的无界线程安全队列。
- 数据结构:
ConcurrentLinkedQueue是队列(FIFO),不支持随机访问(get(index))。而事件监听器列表通常需要遍历所有元素,队列的语义不太匹配。 - 遍历:
ConcurrentLinkedQueue的迭代器也是弱一致性的,但它的遍历效率可能不如数组快照高,且同样不支持迭代器remove。 - 写性能:它的入队出队操作采用CAS,性能很高,适合写多读少的队列场景。
结论:如果你的事件监听器有严格的顺序要求(比如先进先出处理),且不需要随机访问,可以考虑。但通常监听器通知是广播式的,没有顺序要求,CopyOnWriteArrayList 的列表模型更直观。
3. vs CopyOnWriteArraySet
这是 CopyOnWriteArrayList 的兄弟,一个基于 CopyOnWriteArrayList 实现的Set。
- 去重:
CopyOnWriteArraySet保证了元素的唯一性。这在监听器场景下非常有用!可以防止同一个监听器实例被意外注册多次。 - 性能:因为底层用List实现,
add操作前需要遍历检查是否存在,所以写性能比CopyOnWriteArrayList还差一点。 - 选择:如果你的业务逻辑要求监听器不能重复注册,那么直接使用
CopyOnWriteArraySet是更省心的选择,它内部已经处理好了去重逻辑。
综合来看,CopyOnWriteArrayList 在事件监听器管理这个细分领域胜出,是因为它精准地抓住了“遍历安全”和“无锁读”这两个最痛的点,并用“写时复制”这个稍微有点“笨”但极其有效的方法解决了。它用写操作的性能损耗,换来了读操作的极致速度和编程的极度简化。这种交换,在监听器这种特定场景下,是一笔非常划算的买卖。
7. 真实案例:在Spring应用事件机制中的应用
理论说了这么多,我们看看它在成熟框架里是怎么用的。Spring Framework 内部就使用了 CopyOnWriteArrayList 来管理应用事件监听器,这是一个非常经典且值得学习的案例。
在Spring中,当你发布一个 ApplicationEvent 时,框架需要通知所有注册的 ApplicationListener。这个过程本质就是我们讨论的事件监听器管理。Spring的 AbstractApplicationEventMulticaster 类中,维护了一个监听器集合。在较新版本的Spring中,你可以看到类似这样的结构(经过简化):
public abstract class AbstractApplicationEventMulticaster implements ApplicationEventMulticaster, BeanClassLoaderAware, BeanFactoryAware {
private final ListenerRetriever defaultRetriever = new ListenerRetriever();
private class ListenerRetriever {
// 关键在这里:使用 CopyOnWriteArrayList 存储最终的监听器对象
public final Set<ApplicationListener<?>> applicationListeners = new LinkedHashSet<>();
public final Set<String> applicationListenerBeans = new LinkedHashSet<>();
private volatile List<ApplicationListener<?>> cachedListeners;
public List<ApplicationListener<?>> getApplicationListeners() {
List<ApplicationListener<?>> listeners = this.cachedListeners;
if (listeners == null) {
// 从Set构建List,并使用CopyOnWriteArrayList包装
listeners = new ArrayList<>(this.applicationListeners);
// ... 可能还有一些排序逻辑
this.cachedListeners = new CopyOnWriteArrayList<>(listeners);
}
return this.cachedListeners;
}
}
@Override
public void multicastEvent(final ApplicationEvent event, @Nullable ResolvableType eventType) {
ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event));
// 获取当前所有监听器(这里返回的是CopyOnWriteArrayList)
for (final ApplicationListener<?> listener : getApplicationListeners(event, type)) {
// 异步或同步执行监听器
invokeListener(listener, event);
}
}
}
Spring在这里用了一个小技巧:它先用一个 LinkedHashSet 来存储监听器Bean的引用(保证唯一性),然后将其转换为 ArrayList 进行排序等操作,最后包装成一个 CopyOnWriteArrayList 作为缓存。当事件广播时,就直接遍历这个 CopyOnWriteArrayList。
为什么Spring要这么做?
- 线程安全:Spring容器是多线程的,Bean的注册、销毁可能发生在运行时,监听器列表需要动态变化。
- 遍历安全:事件发布(
multicastEvent)需要遍历所有监听器,必须保证在遍历过程中,即使有监听器被注册或移除,也不会导致异常。 - 读多写少:在Spring应用运行期,事件发布(读)的频率通常远高于监听器Bean的动态注册与销毁(写)。
- 性能:无锁遍历保证了事件发布的性能。
这个案例告诉我们,像Spring这样的大型框架,在面临并发遍历与修改的问题时,也选择了 CopyOnWriteArrayList 这种简单而可靠的方案。它可能不是所有场景下性能最优的,但一定是稳健性和可维护性上非常好的选择。我们在设计自己的事件系统或观察者模式时,直接借鉴这个模式,往往能事半功倍。
770




被折叠的 条评论
为什么被折叠?



