通过实现观察者模式来提供Java事件通知(Javaeventnotification)看似不难,但是在这个过程中很容易陷入一些陷阱。本文描述了我在各种情况下无意中犯的一些常见错误。Java事件通知让我们从最简单的JavaBean开始,它叫做StateHolder,它封装了一个privateint属性state和常用的访问方法:;}}现在假设我们决定让Javabean向注册的观察者广播一个状态改变的事件。一块蛋糕!!!定义最简单的事件和侦听器只是卷起袖子......}}//observerinterfacepublicinterfaceStateListener{voidstateChanged(StateEventevent);}接下来,我们需要在StateHolder实例中注册StatListeners。publicclassStateHolder{privatefinalSetlisteners=newHashSet<>();[...]publicvoidaddStateListener(StateListenerlistener){listeners.add(listener);}publicvoidremoveStateListener(StateListenerlistener){listeners.remove(listener);}}***关键点需要调整的看一下StateHolder#setState方法,确保每次状态改变时发送的通知意味着状态相对于上一次真的改变了:publicvoidsetState(intstate){intoldState=this.state;this.state=状态;if(oldState!=state){broadcast(newStateEvent(oldState,state));}}privatevoidbroadcast(StateEventstateEvent){for(StateListenerlistener:listeners){listener.stateChanged(stateEvent);}}完成!这就是你所需要的。为了看起来专业(bi),我们甚至可以为其实施试驾,并吹嘘紧密的代码覆盖率和表明测试通过的绿色小条。而且不管怎么说,这不是我从网上的教程中学到的方法吗?那么问题来了:这个方案是有缺陷的...#p#并发修改像上面这样写StateHolder很容易遇到并发修改异常(ConcurrentModificationException),即使只限于单线程。但究竟是谁造成了这个异常,为什么会发生呢?java.util.HashMap$HashIterator.nextNode(HashMap.java:1429)处的java.util.ConcurrentModificationException.java:60)atcom.codeaffine.events.StateProvider.setState(StateProvider.java:55)atcom.codeaffine.events.StateProvider.main(StateProvider.java:122)乍一看这个错误栈包含的信息,异常由我们使用的HashMap迭代器抛出,但我们在代码中没有使用任何迭代器,对吗?好吧,我们有。要知道broadcast方法中写的foreach结构,实际上在编译时会转化为一个迭代循环。因为在事件广播过程中,如果监听器试图将自己从StateHolder实例中移除,可能会引发ConcurrentModificationException。因此,我们可以在这组侦听器的快照上迭代,而不是对原始数据结构进行操作。这样“removelistener”操作就不会再干扰事件广播机制了(但是注意通知还是会有轻微的语义变化,因为在执行广播方法的时候,这样的removelistener操作不会反映到snapshot):privatevoidbroadcast(StateEventstateEvent){Setsnapshot=newHashSet<>(listeners);for(StateListenerlistener:snapshot){listener.stateChanged(stateEvent);}}但是,如果在多线程环境下使用StateHolder呢?#p#同步要在多线程环境下使用StateHolder,它必须是线程安全的。不过这样也很容易实现,只要在我们类的每个方法中加上synchronized就可以了不是吗?publicclassStateHolder{publicsynchronizedvoidaddStateListener(StateListenerlistener){[...]publicsynchronizedvoidremoveStateListener(StateListenerlistener){[...]publicsynchronizedintgetState(){[...]publicsynchronizedvoidsetState(intstate){[...]现在当我们读写一个StateHolder实例,我们有内置的锁(IntrinsicLock)作为保证,它使公共方法成为原子的,并确保正确的状态对不同的线程可见。任务完成!难怪……虽然这样的实现是线程安全的,但是一旦程序试图调用它,就需要承担死锁的风险。想象一下下面的情况:线程A改变了StateHolder的状态S,当把这个状态S广播给各个监听者(listener)时,线程B访问状态S被阻塞了。如果B对处于状态S的对象持有同步锁,并且打算将其广播给多个侦听器之一,那么就会出现死锁。这就是为什么我们需要减少状态访问的同步并在“受保护的通道”中广播此事件:;}}publicvoidremoveStateListener(StateListenerlistener){synchronized(listeners){listeners.remove(listener);}}publicintgetState(){synchronized(listeners){returnstate;}}publicvoidsetState(intstate){intoldState=this.state;synchronized(listeners){this.state=state;}if(oldState!=state){broadcast(newStateEvent(oldState,state));}}privatevoidbroadcast(StateEventstateEvent){Setsnapshot;synchronized(listeners){snapshot=newHashSet<>(listeners);}for(StateListenerlistener:snapshot){listener.stateChanged(stateEvent);}}}上面的代码是在之前的基础上稍作改进实现的,通过使用Set实例作为内部锁来提供合适的(但是有一些过时的)同步,监听器的通知事件发生在保护块之外,从而避免了死等待的可能性。注意:由于系统的并发特性,此解决方案不保证更改通知按生成顺序到达侦听器。如果观察者端对实际状态的准确性要求比较高,可以考虑使用StateHolder作为你的事件对象的来源。如果事件的顺序在您的程序中很重要,一种方法是考虑使用线程安全的先进先出(FIFO)结构以及侦听器的快照来缓冲您的对象。只要FIFO结构不为空,一个单独的线程就可以从未受保护的区域块(生产者-消费者模式)触发实际事件,这在理论上确保一切都得到保证,而不会冒死锁的风险。按时间顺序进行。我说理论上是因为到目前为止我自己还没有尝试过。.鉴于之前已经实现的,我们可以使用诸如CopyOnWriteArraySet和AtomicInteger来编写我们的线程安全类,这样这个方案就不会那么复杂了:;publicvoidaddStateListener(StateListenerlistener){listeners.add(listener);}publicvoidremoveStateListener(StateListenerlistener){listeners.remove(listener);}publicintgetState(){returnstate.get();}publicvoidsetState(intstate){intoldState=this.state.getAndSet(state);if(oldState!=state){broadcast(newStateEvent(oldState,state));}}privatevoidbroadcast(StateEventstateEvent){for(StateListenerlistener:listeners){listener.stateChanged(stateEvent);}}}自CopyOnWriteArraySet和AtomicInteger已经是线程安全的,我们不再需要上面提到的“保护块”。可是等等!我们不是刚刚了解到我们应该使用快照来广播事件,而不是用一个不可见的迭代器循环遍历原始集合(Set)吗?这可能有点令人困惑,但CopyOnWriteArraySet提供的Iterator(迭代器))已经有了“快照”。CopyOnWriteXXX等集合就是专门为这种情况发挥作用的——它在小长度的场景下会非常高效,同时也针对迭代频繁、内容修改量很小的场景进行了优化。这意味着我们的代码是安全的。随着Java8的发布,由于结合了Iterable#forEach和lambdas表达式,广播方法可以变得更加简洁,代码当然同样安全,因为迭代仍然在“快照”中进行:privatevoidbroadcast(StateEventstateEvent){listeners.forEach(listener->listener.stateChanged(stateEvent));}#p#ExceptionHandling本文的底线描述了如何处理抛出RuntimeExceptions的损坏的侦听器。尽管我对快速失败错误机制一直很严格,但在这种情况下不处理此异常是不合适的。尤其是考虑到这种实现经常被用在一些多线程的环境中。损坏的监听器可以通过两种方式破坏系统:首先,它会阻止通知传递给观察者;其次,它会伤害不准备处理此类问题的调用线程。总而言之,它会导致各种莫名其妙的故障,其中有一些是很难追查原因的。因此,用try-catch块来保护每个通知区域会更有用。privatevoidbroadcast(StateEventstateEvent){listeners.forEach(listener->notifySafely(stateEvent,listener));}privatevoidnotifySafely(StateEventstateEvent,StateListenerlistener){andtry{listener.stateChanged(stateEvent);}catch(RuntimeExceptionunexpectedhere){go/appro...}总结总结来说,Java的事件通知有一些基本点还是要记住的。在事件通知过程中,确保遍历监听器集合的快照,确保事件通知在同步块之外,然后在适当的时候安全地通知监听器。希望我写的让你觉得容易理解,至少尤其是并发那一节,不要再一头雾水了。如果您在文章中发现错误或有其他想法要分享,请随时在文章下方的评论中告诉我。
