程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Java理論與實踐:做個好的(事件)偵聽器

Java理論與實踐:做個好的(事件)偵聽器

編輯:關於JAVA

觀察者模式在 Swing 開發中很常見,在 GUI 應用程序以外的場景中,它對 於消除組件的耦合性也非常有用。但是,仍然存在一些偵聽器登記和調用方面的 常見缺陷。在 Java 理論與實踐 的這一期中,Java 專家 Brian Goetz 就如何 做一個好的偵聽器,以及如何對您的偵聽器也友好,提供了一些感覺很好的建議 。請在相應的 討論論壇 上與作者和其他讀者分享您對這篇文章的想法。(您也 可以單擊本文頂部或底部的 討論 訪問論壇。)

Swing 框架以事件偵聽器的形式廣泛利用了觀察者模式(也稱為發布-訂閱模 式)。Swing 組件作為用戶交互的目標,在用戶與它們交互的時候觸發事件;數 據模型類在數據發生變化時觸發事件。用這種方式使用觀察者,可以讓控制器與 模型分離,讓模型與視圖分離,從而簡化 GUI 應用程序的開發。

“四人幫”的 設計模式 一書(參閱 參考資料)把觀察者模式描述為:定義 對象之間的“一對多”關系,這樣一個對象改變狀態時,所有它的依賴項都會被 通知,並自動更新。觀察者模式支持組件之間的松散耦合;組件可以保持它們的 狀態同步,卻不需要直接知道彼此的標識或內部情況,從而促進了組件的重用。

AWT 和 Swing 組件(例如 JButton 或 JTable)使用觀察者模式消除了 GUI 事件生成與它們在指定應用程序中的語義之間的耦合。類似地,Swing 的模型類 ,例如 TableModel 和 TreeModel,也使用觀察者消除數據模型表示 與視圖生 成之間的耦合,從而支持相同數據的多個獨立的視圖。Swing 定義了 Event 和 EventListener 對象層次結構;可以生成事件的組件,例如 JButton(可視組件 ) 或 TableModel(數據模型),提供了 addXxxListener() 和 removeXxxListener() 方法,用於偵聽器的登記和取消登記。這些類負責決定什 麼時候它們需要觸發事件,什麼時候確實觸發事件,以及什麼時候調用所有登記 的偵聽器。

為了支持偵聽器,對象需要維護一個已登記的偵聽器列表,提供偵聽器登記 和取消登記的手段,並在適當的事件發生時調用每個偵聽器。使用和支持偵聽器 很容易(不僅僅在 GUI 應用程序中),但是在登記接口的兩邊(它們是支持偵 聽器的組件和登記偵聽器的組件)都應當避免一些缺陷。

線程安全問題

通常,調用偵聽器的線程與登記偵聽器的線程不同。要支持從不同線程登記 偵聽器,那麼不管用什麼機制存儲和管理活動偵聽器列表,這個機制都必須是線 程安全的。Sun 的文檔中的許多示例使用 Vector 保存偵聽器列表,它解決了部 分問題,但是沒有解決全部問題。在事件觸發時,觸發它的組件會考慮迭代偵聽 器列表,並調用每個偵聽器,這就帶來了並發修改的風險,比如在偵聽器列表迭 代期間,某個線程偶然想添加或刪除一個偵聽器。

管理偵聽器列表

假設您使用 Vector<Listener> 保存偵聽器列表。雖然 Vector 類是 線程安全的(意味著不需要進行額外的同步就可調用它的方法,沒有破壞 Vector 數據結構的風險),但是集合的迭代中包含“檢測然後執行”序列,如 果在迭代期間集合被修改,就有了失敗的風險。假設迭代開始時列表中有三個偵 聽器。在迭代 Vector 時,重復調用 size() 和 get() 方法,直到所有元素都 檢索完,如清單 1 所示:

清單 1. Vector 的不安全迭代Vector<Listener> v;
for (int i=0; i<v.size(); i++)
  v.get(i).eventHappened(event);

但是,如果恰好就在最後一次調用 Vector.size() 之後,有人從列表中刪除 了一個偵聽器,會發生什麼呢?現在,Vector.get() 將返回 null (這是對的 ,因為從上次檢測 vector 的狀態以來,它的狀態已經變了),而在試圖調用 eventHappened() 時,會拋出 NullPointerException。這是“檢測然後執行” 序列的一個示例 —— 檢測是否存在更多元素,如果存在,就取得下一元素 — — 但是在存在並發修改的情況下,檢測之後狀態可能已經變化。圖 1 演示了這 個問題:

圖 1. 並發迭代和修改,造成意料之外的失敗

這個問題的一個解決方案是在迭代期間持有對 Vector 的鎖;另一個方案是 克隆 Vector 或調用它的 toArray() 方法,在每次發生事件時檢索它的內容。 所有這兩個方法都有性能上的問題:第一個的風險是在迭代期間,會把其他想訪 問偵聽器列表的線程鎖在外面;第二個則要創建臨時對象,而且每次事件發生時 都要拷貝列表。

如果用迭代器(Iterator)去遍歷偵聽器列表,也會有同樣的問題,只是表 現略有不同; iterator() 實現不拋出 NullPointerException,它在探測到迭 代開始之後集合發生修改時,會拋出 ConcurrentModificationException。同樣 ,也可以通過在迭代期間鎖定集合防止這個問題。

java.util.concurrent 中的 CopyOnWriteArrayList 類,能夠幫助防止這個 問題。它實現了 List,而且是線程安全的,但是它的迭代器不會拋出 ConcurrentModificationException,遍歷期間也不要求額外的鎖定。這種特性 組合是通過在每次列表修改時,在內部重新分配並拷貝列表內容而實現的,這樣 ,遍歷內容的線程不需要處理變化 —— 從它們的角度來說,列表的內容在遍歷 期間保持不變。雖然這聽起來可能沒效率,但是請記住,在多數觀察者情況下, 每個組件只有少量偵聽器,遍歷的數量遠遠超過插入和刪除的數量。所以更快的 迭代可以補償較慢的變化過程,並提供更好的並發性,因為多個線程可以同時迭 代列表。

初始化的安全風險

從偵聽器的構造函數中登記它很誘惑人,但是這是一個應當避免的誘惑。它 僅會造成“失效偵聽器(lapsed listener)的問題(我稍後討論它),而且還 會造成多個線程安全問題。清單 2 顯示了一個看起來沒什麼害處的同時構造和 登記偵聽器的企圖。問題是:它造成到對象的“this”引用在對象完全構造完成 之前轉義。雖然看起來沒什麼害處,因為登記是構造函數做的最後一件事,但是 看到的東西是有欺騙性的:

清單 2. 事件偵聽器允許“this”引用轉義,造成問題public class EventListener {
  public EventListener(EventSource eventSource) {
   // do our initialization
   ...
   // register ourselves with the event source
   eventSource.registerListener(this);
  }
  public onEvent(Event e) {
   // handle the event
  }
}

在繼承事件偵聽器的時候,會出現這種方法的一個風險:這時,子類構造函 數做的任何工作都是在 EventListener 構造函數運行之後進行的,也就是在 EventListener 發布之後,所以會造成爭用情況。在某些不幸的時候,清單 3 中的 onEvent 方法會在列表字段還沒初始化之前就被調用,從而在取消 final 字段的引用時,會生成非常讓人困惑的 NullPointerException 異常:

清單 3. 繼承清單 2 的 EventListener 類造成的問題public class RecordingEventListener extends EventListener {
  private final ArrayList<Event> list;
  public RecordingEventListener(EventSource eventSource) {
   super(eventSource);
   list = Collections.synchronizedList(new ArrayList<Event> ());
  }
  public onEvent(Event e) {
   list.add(e);
   super.onEvent(e);
  }
}

即使偵聽器類是 final 的,不能派生子類,也不應當允許“this”引用在構 造函數中轉義 —— 這樣做會危害 Java 內存模型的某些安全保證。如果“this ”這個詞不會出現在程序中,就可讓“this”引用轉義;發布一個非靜態內部類 實例可以達到相同的效果,因為內部類持有對它包圍的對象的“this”引用的引 用。偶然地允許“this”引用轉義的最常見原因,就是登記偵聽器,如清單 4 所示。事件偵聽器不應當在構造函數中登記!

清單 4. 通過發布內部類實例,顯式地允許“this”引用轉義public class EventListener2 {
  public EventListener2(EventSource eventSource) {
   eventSource.registerListener(
    new EventListener() {
     public void onEvent(Event e) {
      eventReceived(e);
     }
    });
  }
  public void eventReceived(Event e) {
  }
}

偵聽器線程安全

使用偵聽器造成的第三個線程安全問題來自這個事實:偵聽器可能想訪問應 用程序數據,而調用偵聽器的線程通常不直接在應用程序的控制之下。如果在 JButton 或其他 Swing 組件上登記偵聽器,那麼會從 EDT 調用該偵聽器。偵聽 器的代碼可以從 EDT 安全地調用 Swing 組件上的方法,但是如果對象本身不是 線程安全的,那麼從偵聽器訪問應用程序對象會給應用程序增加新的線程安全需 求。

Swing 組件生成的事件是用戶交互的結果,但是 Swing 模型類是在 fireXxxEvent() 方法被調用的時候生成事件。這些方法又會在調用它們的線程 中調用偵聽器。因為 Swing 模型類不是線程安全的,而且假設被限制在 EDT 內 ,所以對 fireXxxEvent() 的任何調用也都應當從 EDT 執行。如果想從另外的 線程觸發事件,那麼應當用 Swing 的 invokeLater() 功能讓方法轉而在 EDT 內調用。一般來說,要注意調用事件偵聽器的線程,還要保證它們涉及的任何對 象或者是線程安全的,或者在訪問它們的地方,受到適當的同步(或者是 Swing 模型類的線程約束)的保護。

失效偵聽器

不管什麼時候使用觀察者模式,都耦合著兩個獨立組件 —— 觀察者和被觀 察者,它們通常有不同的生命周期。登記偵聽器的後果之一就是:它在被觀察對 象和偵聽器之間建立起很強的引用關系,這種關系防止偵聽器(以及它引用的對 象)被垃圾收集,直到偵聽器取消登記為止。在許多情況下,偵聽器的生命周期 至少要和被觀察的組件一樣長 —— 許多偵聽器會在整個應用程序期間都存在。 但是在某些情況下,應當短期存在的偵聽器最後變成了永久的,它們這種無意識 的拖延的證據就是應用程序性能變慢、高於必需的內存使用。

“失效偵聽器”的問題可以由設計級別上的不小心造成:沒有恰當地考慮包 含的對象的壽命,或者由於松懈的編碼。偵聽器登記和取消登記應當結對進行。 但是即使這麼做,也必須保證是在正確的時間執行取消登記。清單 5 顯示了會 造成失效偵聽器的編碼習慣的示例。它在組件上登記偵聽器,執行某些動作,然 後取消登記偵聽器:

清單 5. 有造成失效偵聽器風險的代碼 public void processFile (String filename) throws IOException {
   cancelButton.registerListener(this);
   // open file, read it, process it
   // might throw IOException
   cancelButton.unregisterListener(this);
  }

清單 5 的問題是:如果文件處理代碼拋出了 IOException —— 這是很有可 能的 —— 那麼偵聽器就永遠不會取消登記,這就意味著它永遠不會被垃圾收集 。取消登記的操作應當在 finally 塊中進行,這樣,processFile() 方法的所 有出口都會執行它。

有時推薦的一個處理失效偵聽器的方法是使用弱引用。雖然這種方法可行, 但是實現起來很麻煩。要讓它工作,需要找到另外一個對象,它的生命周期恰好 是偵聽器的生命周期,並安排它持有對偵聽器的強引用,這可不是件容易的事。

另外一項可以用來找到隱藏失效偵聽器的技術是:防止指定偵聽器對象在指 定事件源上登記兩次。這種情況通常是 bug 的跡象 —— 偵聽器登記了,但是 沒有取消登記,然後再次登記。不用檢測問題,就能緩解這個問題的影響的一種 方式是:使用 Set 代替 List 來存儲偵聽器;或者也可以檢測 List,在登記偵 聽器之前檢查是否已經登記了,如果已經登記,就拋出異常(或記錄錯誤),這 樣就可以搜集編碼錯誤的證據,並采取行動。

其他偵聽器問題

在編寫偵聽器時,應當一直注意它們將要執行的環境。不僅要注意線程安全 問題,還需要記住:偵聽器也可以用其他方式為它的調用者把事情搞糟。偵聽器 不該 做的一件事是:阻塞相當長一段時間(長得可以感覺得到);調用它的執 行上下文很可能希望迅速返回控制。如果偵聽器要執行一個可能比較費時的操作 ,例如處理大型文本,或者要做的工作可能阻塞,例如執行 socket IO,那麼偵 聽器應當把這些操作安排在另一個線程中進行,這樣它就可以迅速返回它的調用 者。

對於不小心的事件源,偵聽器會造成麻煩的另一個方式是:拋出未檢測的異 常。雖然大多數時候,我們不會故意拋出未檢測異常,但是確實有些時候會發生 這種情況。如果使用清單 1 的方式調用偵聽器,列表中的第二個偵聽器就會拋 出未檢測異常,那麼不僅後續的偵聽器得不到調用(可能造成應用程序處在不一 致的狀態),而且有可能把執行它的線程破壞掉,從而造成局部應用程序失敗。

在調用未知代碼(偵聽器就是這樣的代碼)時,謹慎的方式是在 try-catch 塊中執行它,這樣,行為有誤的偵聽器不會造成更多不必要的破壞。對於拋出未 檢測異常的偵聽器,您可能想自動對它取消登記,畢竟,拋出未檢測異常就證明 偵聽器壞掉了。(您可能還想記錄這個錯誤或者提醒用戶注意,好讓用戶能夠知 道為什麼程序停止像期望的那樣繼續工作。)清單 6 顯示了這種方式的一個示 例,它在迭代循環內部嵌套了 try-catch 塊:

清單 6. 健壯的偵聽器調用List<Listener> list;
for (Iterator<Listener> i=list.iterator; i.hasNext(); ) {
   Listener l = i.next();
   try {
     l.eventHappened(event);
   }
   catch (RuntimeException e) {
     log("Unexpected exception in listener", e);
     i.remove();
   }
}

結束語

觀察者模式對於創建松散耦合的組件、鼓勵組件重用非常有用,但是它有一 些風險,偵聽器的編寫者和組件的編寫者都應當注意。在登記偵聽器時,應當一 直注意偵聽器的生命周期。如果偵聽器的壽命應當比應用程序的短,那麼請確保 取消它的登記,這樣它就可以被垃圾收集。在編寫偵聽器和組件時,請注意它包 含的線程安全性問題。偵聽器涉及的任何對象,都應當是線程安全的,或者是受 線程約束的對象(例如 Swing 模型),偵聽器應當確定自己正在正確的線程中 執行。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved