Vincent

          Vicent's blog
          隨筆 - 74, 文章 - 0, 評論 - 5, 引用 - 0
          數據加載中……

          輕松使用線程: 同步不是敵人

          與許多其它的編程語言不同,Java語言規范包括對線程和并發的明確支持。語言本身支持并發,這使得指定和管理共享數據的約束以及跨線程操作的計時變得更簡單,但是這沒有使得并發編程的復雜性更易于理解。這個三部分的系列文章的目的在于幫助程序員理解用Java 語言進行多線程編程的一些主要問題,特別是線程安全對 Java程序性能的影響。

          請點擊文章頂部或底部的 討論進入由 Brian Goetz 主持的 “Java線程:技巧、竅門和技術”討論論壇,與本文作者和其他讀者交流您對本文或整個多線程的想法。注意該論壇討論的是使用多線程時遇到的所有問題,而并不限于本文的內容。

          大多數編程語言的語言規范都不會談到線程和并發的問題;因為一直以來,這些問題都是留給平臺或操作系統去詳細說明的。但是,Java 語言規范(JLS)卻明確包括一個線程模型,并提供了一些語言元素供開發人員使用以保證他們程序的線程安全。

          對線程的明確支持有利也有弊。它使得我們在寫程序時更容易利用線程的功能和便利,但同時也意味著我們不得不注意所寫類的線程安全,因為任何類都很有可能被用在一個多線程的環境內。

          許多用戶第一次發現他們不得不去理解線程的概念的時候,并不是因為他們在寫創建和管理線程的程序,而是因為他們正在用一個本身是多線程的工具或框架。任何用過 Swing GUI 框架或寫過小服務程序或 JSP 頁的開發人員(不管有沒有意識到)都曾經被線程的復雜性困擾過。

          Java 設計師是想創建一種語言,使之能夠很好地運行在現代的硬件,包括多處理器系統上。要達到這一目的,管理線程間協調的工作主要推給了軟件開發人員;程序員必須指定線程間共享數據的位置。在 Java 程序中,用來管理線程間協調工作的主要工具是 synchronized 關鍵字。在缺少同步的情況下,JVM 可以很自由地對不同線程內執行的操作進行計時和排序。在大部分情況下,這正是我們想要的,因為這樣可以提高性能,但它也給程序員帶來了額外的負擔,他們不得不自己識別什么時候這種性能的提高會危及程序的正確性。

          synchronized 真正意味著什么?

          大部分 Java 程序員對同步的塊或方法的理解是完全根據使用互斥(互斥信號量)或定義一個臨界段(一個必須原子性地執行的代碼塊)。雖然 synchronized 的語義中確實包括互斥和原子性,但在管程進入之前和在管程退出之后發生的事情要復雜得多。

          synchronized 的語義確實保證了一次只有一個線程可以訪問被保護的區段,但同時還包括同步線程在主存內互相作用的規則。理解 Java 內存模型(JMM)的一個好方法就是把各個線程想像成運行在相互分離的處理器上,所有的處理器存取同一塊主存空間,每個處理器有自己的緩存,但這些緩存可能并不總和主存同步。在缺少同步的情況下,JMM 會允許兩個線程在同一個內存地址上看到不同的值。而當用一個管程(鎖)進行同步的時候,一旦申請加了鎖,JMM 就會馬上要求該緩存失效,然后在它被釋放前對它進行刷新(把修改過的內存位置寫回主存)。不難看出為什么同步會對程序的性能影響這么大;頻繁地刷新緩存代價會很大。





          回頁首


          使用一條好的運行路線

          如果同步不適當,后果是很嚴重的:會造成數據混亂和爭用情況,導致程序崩潰,產生不正確的結果,或者是不可預計的運行。更糟的是,這些情況可能很少發生且具有偶然性(使得問題很難被監測和重現)。如果測試環境和開發環境有很大的不同,無論是配置的不同,還是負荷的不同,都有可能使得這些問題在測試環境中根本不出現,從而得出錯誤的結論:我們的程序是正確的,而事實上這些問題只是還沒出現而已。

          爭用情況定義

          爭用情況是一種特定的情況:兩個或更多的線程或進程讀或寫一些共享數據,而最終結果取決于這些線程是如何被調度計時的。爭用情況可能會導致不可預見的結果和隱蔽的程序錯誤。

          另一方面,不當或過度地使用同步會導致其它問題,比如性能很差和死鎖。當然,性能差雖然不如數據混亂那么嚴重,但也是一個嚴重的問題,因此同樣不可忽視。編寫優秀的多線程程序需要使用好的運行路線,足夠的同步可以使您的數據不發生混亂,但不需要濫用到去承擔死鎖或不必要地削弱程序性能的風險。





          回頁首


          同步的代價有多大?

          由于包括緩存刷新和設置失效的過程,Java 語言中的同步塊通常比許多平臺提供的臨界段設備代價更大,這些臨界段通常是用一個原子性的“test and set bit”機器指令實現的。即使一個程序只包括一個在單一處理器上運行的單線程,一個同步的方法調用仍要比非同步的方法調用慢。如果同步時還發生鎖定爭用,那么性能上付出的代價會大得多,因為會需要幾個線程切換和系統調用。

          幸運的是,隨著每一版的 JVM 的不斷改進,既提高了 Java 程序的總體性能,同時也相對減少了同步的代價,并且將來還可能會有進一步的改進。此外,同步的性能代價經常是被夸大的。一個著名的資料來源就曾經引證說一個同步的方法調用比一個非同步的方法調用慢 50 倍。雖然這句話有可能是真的,但也會產生誤導,而且已經導致了許多開發人員即使在需要的時候也避免使用同步。

          嚴格依照百分比計算同步的性能損失并沒有多大意義,因為一個無爭用的同步給一個塊或方法帶來的是固定的性能損失。而這一固定的延遲帶來的性能損失百分比取決于在該同步塊內做了多少工作。對一個 方法的同步調用可能要比對一個空方法的非同步調用慢 20 倍,但我們多長時間才調用一次空方法呢?當我們用更有代表性的小方法來衡量同步損失時,百分數很快就下降到可以容忍的范圍之內。

          表 1 把一些這種數據放在一起來看。它列舉了一些不同的實例,不同的平臺和不同的 JVM 下一個同步的方法調用相對于一個非同步的方法調用的損失。在每一個實例下,我運行一個簡單的程序,測定循環調用一個方法 10,000,000 次所需的運行時間,我調用了同步和非同步兩個版本,并比較了結果。表格中的數據是同步版本的運行時間相對于非同步版本的運行時間的比率;它顯示了同步的性能損失。每次運行調用的都是清單 1 中的簡單方法之一。

          表格 1 中顯示了同步方法調用相對于非同步方法調用的相對性能;為了用絕對的標準測定性能損失,必須考慮到 JVM 速度提高的因素,這并沒有在數據中體現出來。在大多數測試中,每個 JVM 的更高版本都會使 JVM 的總體性能得到很大提高,很有可能 1.4 版的 Java 虛擬機發行的時候,它的性能還會有進一步的提高。

          表 1. 無爭用同步的性能損失

          JDK staticEmpty empty fetch hashmapGet singleton create
          Linux / JDK 1.1 9.2 2.4 2.5 n/a 2.0 1.42
          Linux / IBM Java SDK 1.1 33.9 18.4 14.1 n/a 6.9 1.2
          Linux / JDK 1.2 2.5 2.2 2.2 1.64 2.2 1.4
          Linux / JDK 1.3 (no JIT) 2.52 2.58 2.02 1.44 1.4 1.1
          Linux / JDK 1.3 -server 28.9 21.0 39.0 1.87 9.0 2.3
          Linux / JDK 1.3 -client 21.2 4.2 4.3 1.7 5.2 2.1
          Linux / IBM Java SDK 1.3 8.2 33.4 33.4 1.7 20.7 35.3
          Linux / gcj 3.0 2.1 3.6 3.3 1.2 2.4 2.1
          Solaris / JDK 1.1 38.6 20.1 12.8 n/a 11.8 2.1
          Solaris / JDK 1.2 39.2 8.6 5.0 1.4 3.1 3.1
          Solaris / JDK 1.3 (no JIT) 2.0 1.8 1.8 1.0 1.2 1.1
          Solaris / JDK 1.3 -client 19.8 1.5 1.1 1.3 2.1 1.7
          Solaris / JDK 1.3 -server 1.8 2.3 53.0 1.3 4.2 3.2

          清單 1. 基準測試中用到的簡單方法
          												
          														 public static void staticEmpty() {  }
          
            public void empty() {  }
          
            public Object fetch() { return field; }
          
            public Object singleton() {
              if (singletonField == null)
                singletonField = new Object();
              return singletonField;
            }
          
            public Object hashmapGet() {
              return hashMap.get("this");
            }
          
            public Object create() { 
              return new Object();
            }
          
          												
          										

          這些小基準測試也闡明了存在動態編譯器的情況下解釋性能結果所面臨的挑戰。對于 1.3 JDK 在有和沒有 JIT 時,數字上的巨大差異需要給出一些解釋。對那些非常簡單的方法( emptyfetch ),基準測試的本質(它只是執行一個幾乎什么也不做的緊湊的循環)使得 JIT 可以動態地編譯整個循環,把運行時間壓縮到幾乎沒有的地步。但在一個實際的程序中,JIT 能否這樣做就要取決于很多因素了,所以,無 JIT 的計時數據可能在做公平對比時更有用一些。在任何情況下,對于更充實的方法( createhashmapGet ),JIT 就不能象對更簡單些的方法那樣使非同步的情況得到巨大的改進。另外,從數據中看不出 JVM 是否能夠對測試的重要部分進行優化。同樣,在可比較的 IBM 和 Sun JDK 之間的差異反映了 IBM Java SDK 可以更大程度地優化非同步的循環,而不是同步版本代價更高。這在純計時數據中可以明顯地看出(這里不提供)。

          從這些數字中我們可以得出以下結論:對非爭用同步而言,雖然存在性能損失,但在運行許多不是特別微小的方法時,損失可以降到一個合理的水平;大多數情況下損失大概在 10% 到 200% 之間(這是一個相對較小的數目)。所以,雖然同步每個方法是不明智的(這也會增加死鎖的可能性),但我們也不需要這么害怕同步。這里使用的簡單測試是說明一個無爭用同步的代價要比創建一個對象或查找一個 HashMap 的代價小。

          由于早期的書籍和文章暗示了無爭用同步要付出巨大的性能代價,許多程序員就竭盡全力避免同步。這種恐懼導致了許多有問題的技術出現,比如說 double-checked locking(DCL)。許多關于 Java 編程的書和文章都推薦 DCL,它看上去真是避免不必要的同步的一種聰明的方法,但實際上它根本沒有用,應該避免使用它。DCL 無效的原因很復雜,已超出了本文討論的范圍(要深入了解,請參閱 參考資料里的鏈接)。





          回頁首


          不要爭用

          假設同步使用正確,若線程真正參與爭用加鎖,您也能感受到同步對實際性能的影響。并且無爭用同步和爭用同步間的性能損失差別很大;一個簡單的測試程序指出爭用同步比無爭用同步慢 50 倍。把這一事實和我們上面抽取的觀察數據結合在一起,可以看出使用一個爭用同步的代價至少相當于創建 50 個對象。

          所以,在調試應用程序中同步的使用時,我們應該努力減少實際爭用的數目,而根本不是簡單地試圖避免使用同步。這個系列的第 2 部分將把重點放在減少爭用的技術上,包括減小鎖的粒度、減小同步塊的大小以及減小線程間共享數據的數量。





          回頁首


          什么時候需要同步?

          要使您的程序線程安全,首先必須確定哪些數據將在線程間共享。如果正在寫的數據以后可能被另一個線程讀到,或者正在讀的數據可能已經被另一個線程寫過了,那么這些數據就是共享數據,必須進行同步存取。有些程序員可能會驚訝地發現,這些規則在簡單地檢查一個共享引用是否非空的時候也用得上。

          許多人會發現這些定義驚人地嚴格。有一種普遍的觀點是,如果只是要讀一個對象的字段,不需要請求加鎖,尤其是在 JLS 保證了 32 位讀操作的原子性的情況下,它更是如此。但不幸的是,這個觀點是錯誤的。除非所指的字段被聲明為 volatile ,否則 JMM 不會要求下面的平臺提供處理器間的緩存一致性和順序連貫性,所以很有可能,在某些平臺上,沒有同步就會讀到陳舊的數據。有關更詳細的信息,請參閱 參考資料

          在確定了要共享的數據之后,還要確定要如何保護那些數據。在簡單情況下,只需把它們聲明為 volatile 即可保護數據字段;在其它情況下,必須在讀或寫共享數據前請求加鎖,一個很好的經驗是明確指出使用什么鎖來保護給定的字段或對象,并在你的代碼里把它記錄下來。

          還有一點值得注意的是,簡單地同步存取器方法(或聲明下層的字段為 volatile )可能并不足以保護一個共享字段。可以考慮下面的示例:

          												
          														 ...
            private int foo;
            public synchronized int getFoo() { return foo; } 
            public synchronized void setFoo(int f) { foo = f; }
          
          												
          										

          如果一個調用者想要增加 foo 屬性值,以下完成該功能的代碼就不是線程安全的:

          												
          														 ...
            setFoo(getFoo() + 1);
          
          												
          										

          如果兩個線程試圖同時增加 foo 屬性值,結果可能是 foo 的值增加了 1 或 2,這由計時決定。調用者將需要同步一個鎖,才能防止這種爭用情況;一個好方法是在 JavaDoc 類中指定同步哪個鎖,這樣類的調用者就不需要自己猜了。

          以上情況是一個很好的示例,說明我們應該注意多層次粒度的數據完整性;同步存取器方法確保調用者能夠存取到一致的和最近版本的屬性值,但如果希望屬性的將來值與當前值一致,或多個屬性間相互一致,我們就必須同步復合操作 ― 可能是在一個粗粒度的鎖上。





          回頁首


          如果情況不確定,考慮使用同步包裝

          有時,在寫一個類的時候,我們并不知道它是否要用在一個共享環境里。我們希望我們的類是線程安全的,但我們又不希望給一個總是在單線程環境內使用的類加上同步的負擔,而且我們可能也不知道使用這個類時合適的鎖粒度是多大。幸運的是,通過提供同步包裝,我們可以同時達到以上兩個目的。Collections 類就是這種技術的一個很好的示例;它們是非同步的,但在框架中定義的每個接口都有一個同步包裝(例如, Collections.synchronizedMap() ),它用一個同步的版本來包裝每個方法。





          回頁首


          結論

          雖然 JLS 給了我們可以使我們的程序線程安全的工具,但線程安全也不是天上掉下來的餡餅。使用同步會蒙受性能損失,而同步使用不當又會使我們承擔數據混亂、結果不一致或死鎖的風險。幸運的是,在過去的幾年內 JVM 有了很大的改進,大大減少了與正確使用同步相關的性能損失。通過仔細分析在線程間如何共享數據,適當地同步對共享數據的操作,可以使得您的程序既是線程安全的,又不會承受過多的性能負擔。

          posted @ 2006-08-24 17:44 Binary 閱讀(284) | 評論 (0)編輯 收藏

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

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

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

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

          AWT 和 Swing 組件(例如 JButtonJTable)使用觀察者模式消除了 GUI 事件生成與它們在指定應用程序中的語義之間的耦合。類似地,Swing 的模型類,例如 TableModelTreeModel,也使用觀察者消除數據模型表示 與視圖生成之間的耦合,從而支持相同數據的多個獨立的視圖。Swing 定義了 EventEventListener 對象層次結構;可以生成事件的組件,例如 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 模型),偵聽器應當確定自己正在正確的線程中執行。

          posted @ 2006-08-24 17:43 Binary 閱讀(233) | 評論 (0)編輯 收藏

          Java 理論與實踐: 用弱引用堵住內存泄漏

          雖然用 Java? 語言編寫的程序在理論上是不會出現“內存泄漏”的,但是有時對象在不再作為程序的邏輯狀態的一部分之后仍然不被垃圾收集。本月,負責保障應用程序健康的工程師 Brian Goetz 探討了無意識的對象保留的常見原因,并展示了如何用弱引用堵住泄漏。

          要讓垃圾收集(GC)回收程序不再使用的對象,對象的邏輯 生命周期(應用程序使用它的時間)和對該對象擁有的引用的實際 生命周期必須是相同的。在大多數時候,好的軟件工程技術保證這是自動實現的,不用我們對對象生命周期問題花費過多心思。但是偶爾我們會創建一個引用,它在內存中包含對象的時間比我們預期的要長得多,這種情況稱為無意識的對象保留(unintentional object retention)

          全局 Map 造成的內存泄漏

          無意識對象保留最常見的原因是使用 Map 將元數據與臨時對象(transient object)相關聯。假定一個對象具有中等生命周期,比分配它的那個方法調用的生命周期長,但是比應用程序的生命周期短,如客戶機的套接字連接。需要將一些元數據與這個套接字關聯,如生成連接的用戶的標識。在創建 Socket 時是不知道這些信息的,并且不能將數據添加到 Socket 對象上,因為不能控制 Socket 類或者它的子類。這時,典型的方法就是在一個全局 Map 中存儲這些信息,如清單 1 中的 SocketManager 類所示:


          清單 1. 使用一個全局 Map 將元數據關聯到一個對象
          												
          														public class SocketManager {
              private Map<Socket,User> m = new HashMap<Socket,User>();
              
              public void setUser(Socket s, User u) {
                  m.put(s, u);
              }
              public User getUser(Socket s) {
                  return m.get(s);
              }
              public void removeUser(Socket s) {
                  m.remove(s);
              }
          }
          
          SocketManager socketManager;
          ...
          socketManager.setUser(socket, user);
          
          												
          										

          這種方法的問題是元數據的生命周期需要與套接字的生命周期掛鉤,但是除非準確地知道什么時候程序不再需要這個套接字,并記住從 Map 中刪除相應的映射,否則,SocketUser 對象將會永遠留在 Map 中,遠遠超過響應了請求和關閉套接字的時間。這會阻止 SocketUser 對象被垃圾收集,即使應用程序不會再使用它們。這些對象留下來不受控制,很容易造成程序在長時間運行后內存爆滿。除了最簡單的情況,在幾乎所有情況下找出什么時候 Socket 不再被程序使用是一件很煩人和容易出錯的任務,需要人工對內存進行管理。





          回頁首


          找出內存泄漏

          程序有內存泄漏的第一個跡象通常是它拋出一個 OutOfMemoryError,或者因為頻繁的垃圾收集而表現出糟糕的性能。幸運的是,垃圾收集可以提供能夠用來診斷內存泄漏的大量信息。如果以 -verbose:gc 或者 -Xloggc 選項調用 JVM,那么每次 GC 運行時在控制臺上或者日志文件中會打印出一個診斷信息,包括它所花費的時間、當前堆使用情況以及恢復了多少內存。記錄 GC 使用情況并不具有干擾性,因此如果需要分析內存問題或者調優垃圾收集器,在生產環境中默認啟用 GC 日志是值得的。

          有工具可以利用 GC 日志輸出并以圖形方式將它顯示出來,JTune 就是這樣的一種工具(請參閱 參考資料)。觀察 GC 之后堆大小的圖,可以看到程序內存使用的趨勢。對于大多數程序來說,可以將內存使用分為兩部分:baseline 使用和 current load 使用。對于服務器應用程序,baseline 使用就是應用程序在沒有任何負荷、但是已經準備好接受請求時的內存使用,current load 使用是在處理請求過程中使用的、但是在請求處理完成后會釋放的內存。只要負荷大體上是恒定的,應用程序通常會很快達到一個穩定的內存使用水平。如果在應用程序已經完成了其初始化并且負荷沒有增加的情況下,內存使用持續增加,那么程序就可能在處理前面的請求時保留了生成的對象。

          清單 2 展示了一個有內存泄漏的程序。MapLeaker 在線程池中處理任務,并在一個 Map 中記錄每一項任務的狀態。不幸的是,在任務完成后它不會刪除那一項,因此狀態項和任務對象(以及它們的內部狀態)會不斷地積累。


          清單 2. 具有基于 Map 的內存泄漏的程序
          												
          														public class MapLeaker {
              public ExecutorService exec = Executors.newFixedThreadPool(5);
              public Map<Task, TaskStatus> taskStatus 
                  = Collections.synchronizedMap(new HashMap<Task, TaskStatus>());
              private Random random = new Random();
          
              private enum TaskStatus { NOT_STARTED, STARTED, FINISHED };
          
              private class Task implements Runnable {
                  private int[] numbers = new int[random.nextInt(200)];
          
                  public void run() {
                      int[] temp = new int[random.nextInt(10000)];
                      taskStatus.put(this, TaskStatus.STARTED);
                      doSomeWork();
                      taskStatus.put(this, TaskStatus.FINISHED);
                  }
              }
          
              public Task newTask() {
                  Task t = new Task();
                  taskStatus.put(t, TaskStatus.NOT_STARTED);
                  exec.execute(t);
                  return t;
              }
          }
          
          												
          										

          圖 1 顯示 MapLeaker GC 之后應用程序堆大小隨著時間的變化圖。上升趨勢是存在內存泄漏的警示信號。(在真實的應用程序中,坡度不會這么大,但是在收集了足夠長時間的 GC 數據后,上升趨勢通常會表現得很明顯。)


          圖 1. 持續上升的內存使用趨勢

          確信有了內存泄漏后,下一步就是找出哪種對象造成了這個問題。所有內存分析器都可以生成按照對象類進行分解的堆快照。有一些很好的商業堆分析工具,但是找出內存泄漏不一定要花錢買這些工具 —— 內置的 hprof 工具也可完成這項工作。要使用 hprof 并讓它跟蹤內存使用,需要以 -Xrunhprof:heap=sites 選項調用 JVM。

          清單 3 顯示分解了應用程序內存使用的 hprof 輸出的相關部分。(hprof 工具在應用程序退出時,或者用 kill -3 或在 Windows 中按 Ctrl+Break 時生成使用分解。)注意兩次快照相比,Map.EntryTaskint[] 對象有了顯著增加。

          請參閱 清單 3

          清單 4 展示了 hprof 輸出的另一部分,給出了 Map.Entry 對象的分配點的調用堆棧信息。這個輸出告訴我們哪些調用鏈生成了 Map.Entry 對象,并帶有一些程序分析,找出內存泄漏來源一般來說是相當容易的。


          清單 4. HPROF 輸出,顯示 Map.Entry 對象的分配點
          												
          														TRACE 300446:
          	java.util.HashMap$Entry.<init>(<Unknown Source>:Unknown line)
          	java.util.HashMap.addEntry(<Unknown Source>:Unknown line)
          	java.util.HashMap.put(<Unknown Source>:Unknown line)
          	java.util.Collections$SynchronizedMap.put(<Unknown Source>:Unknown line)
          	com.quiotix.dummy.MapLeaker.newTask(MapLeaker.java:48)
          	com.quiotix.dummy.MapLeaker.main(MapLeaker.java:64)
          
          												
          										





          回頁首


          弱引用來救援了

          SocketManager 的問題是 Socket-User 映射的生命周期應當與 Socket 的生命周期相匹配,但是語言沒有提供任何容易的方法實施這項規則。這使得程序不得不使用人工內存管理的老技術。幸運的是,從 JDK 1.2 開始,垃圾收集器提供了一種聲明這種對象生命周期依賴性的方法,這樣垃圾收集器就可以幫助我們防止這種內存泄漏 —— 利用弱引用

          弱引用是對一個對象(稱為 referent)的引用的持有者。使用弱引用后,可以維持對 referent 的引用,而不會阻止它被垃圾收集。當垃圾收集器跟蹤堆的時候,如果對一個對象的引用只有弱引用,那么這個 referent 就會成為垃圾收集的候選對象,就像沒有任何剩余的引用一樣,而且所有剩余的弱引用都被清除。(只有弱引用的對象稱為弱可及(weakly reachable)。)

          WeakReference 的 referent 是在構造時設置的,在沒有被清除之前,可以用 get() 獲取它的值。如果弱引用被清除了(不管是 referent 已經被垃圾收集了,還是有人調用了 WeakReference.clear()),get() 會返回 null。相應地,在使用其結果之前,應當總是檢查 get() 是否返回一個非 null 值,因為 referent 最終總是會被垃圾收集的。

          用一個普通的(強)引用拷貝一個對象引用時,限制 referent 的生命周期至少與被拷貝的引用的生命周期一樣長。如果不小心,那么它可能就與程序的生命周期一樣 —— 如果將一個對象放入一個全局集合中的話。另一方面,在創建對一個對象的弱引用時,完全沒有擴展 referent 的生命周期,只是在對象仍然存活的時候,保持另一種到達它的方法。

          弱引用對于構造弱集合最有用,如那些在應用程序的其余部分使用對象期間存儲關于這些對象的元數據的集合 —— 這就是 SocketManager 類所要做的工作。因為這是弱引用最常見的用法,WeakHashMap 也被添加到 JDK 1.2 的類庫中,它對鍵(而不是對值)使用弱引用。如果在一個普通 HashMap 中用一個對象作為鍵,那么這個對象在映射從 Map 中刪除之前不能被回收,WeakHashMap 使您可以用一個對象作為 Map 鍵,同時不會阻止這個對象被垃圾收集。清單 5 給出了 WeakHashMapget() 方法的一種可能實現,它展示了弱引用的使用:


          清單 5. WeakReference.get() 的一種可能實現
          												
          														public class WeakHashMap<K,V> implements Map<K,V> {
          
              private static class Entry<K,V> extends WeakReference<K> 
                implements Map.Entry<K,V> {
                  private V value;
                  private final int hash;
                  private Entry<K,V> next;
                  ...
              }
          
              public V get(Object key) {
                  int hash = getHash(key);
                  Entry<K,V> e = getChain(hash);
                  while (e != null) {
                      K eKey= e.get();
                      if (e.hash == hash && (key == eKey || key.equals(eKey)))
                          return e.value;
                      e = e.next;
                  }
                  return null;
              }
          
          												
          										

          調用 WeakReference.get() 時,它返回一個對 referent 的強引用(如果它仍然存活的話),因此不需要擔心映射在 while 循環體中消失,因為強引用會防止它被垃圾收集。WeakHashMap 的實現展示了弱引用的一種常見用法 —— 一些內部對象擴展 WeakReference。其原因在下面一節討論引用隊列時會得到解釋。

          在向 WeakHashMap 中添加映射時,請記住映射可能會在以后“脫離”,因為鍵被垃圾收集了。在這種情況下,get() 返回 null,這使得測試 get() 的返回值是否為 null 變得比平時更重要了。

          用 WeakHashMap 堵住泄漏

          SocketManager 中防止泄漏很容易,只要用 WeakHashMap 代替 HashMap 就行了,如清單 6 所示。(如果 SocketManager 需要線程安全,那么可以用 Collections.synchronizedMap() 包裝 WeakHashMap)。當映射的生命周期必須與鍵的生命周期聯系在一起時,可以使用這種方法。不過,應當小心不濫用這種技術,大多數時候還是應當使用普通的 HashMap 作為 Map 的實現。


          清單 6. 用 WeakHashMap 修復 SocketManager
          												
          														public class SocketManager {
              private Map<Socket,User> m = new WeakHashMap<Socket,User>();
              
              public void setUser(Socket s, User u) {
                  m.put(s, u);
              }
              public User getUser(Socket s) {
                  return m.get(s);
              }
          }
          
          												
          										

          引用隊列

          WeakHashMap 用弱引用承載映射鍵,這使得應用程序不再使用鍵對象時它們可以被垃圾收集,get() 實現可以根據 WeakReference.get() 是否返回 null 來區分死的映射和活的映射。但是這只是防止 Map 的內存消耗在應用程序的生命周期中不斷增加所需要做的工作的一半,還需要做一些工作以便在鍵對象被收集后從 Map 中刪除死項。否則,Map 會充滿對應于死鍵的項。雖然這對于應用程序是不可見的,但是它仍然會造成應用程序耗盡內存,因為即使鍵被收集了,Map.Entry 和值對象也不會被收集。

          可以通過周期性地掃描 Map,對每一個弱引用調用 get(),并在 get() 返回 null 時刪除那個映射而消除死映射。但是如果 Map 有許多活的項,那么這種方法的效率很低。如果有一種方法可以在弱引用的 referent 被垃圾收集時發出通知就好了,這就是引用隊列 的作用。

          引用隊列是垃圾收集器向應用程序返回關于對象生命周期的信息的主要方法。弱引用有兩個構造函數:一個只取 referent 作為參數,另一個還取引用隊列作為參數。如果用關聯的引用隊列創建弱引用,在 referent 成為 GC 候選對象時,這個引用對象(不是 referent)就在引用清除后加入 到引用隊列中。之后,應用程序從引用隊列提取引用并了解到它的 referent 已被收集,因此可以進行相應的清理活動,如去掉已不在弱集合中的對象的項。(引用隊列提供了與 BlockingQueue 同樣的出列模式 —— polled、timed blocking 和 untimed blocking。)

          WeakHashMap 有一個名為 expungeStaleEntries() 的私有方法,大多數 Map 操作中會調用它,它去掉引用隊列中所有失效的引用,并刪除關聯的映射。清單 7 展示了 expungeStaleEntries() 的一種可能實現。用于存儲鍵-值映射的 Entry 類型擴展了 WeakReference,因此當 expungeStaleEntries() 要求下一個失效的弱引用時,它得到一個 Entry。用引用隊列代替定期掃描內容的方法來清理 Map 更有效,因為清理過程不會觸及活的項,只有在有實際加入隊列的引用時它才工作。


          清單 7. WeakHashMap.expungeStaleEntries() 的可能實現
          												
          														    private void expungeStaleEntries() {
          	Entry<K,V> e;
                  while ( (e = (Entry<K,V>) queue.poll()) != null) {
                      int hash = e.hash;
          
                      Entry<K,V> prev = getChain(hash);
                      Entry<K,V> cur = prev;
                      while (cur != null) {
                          Entry<K,V> next = cur.next;
                          if (cur == e) {
                              if (prev == e)
                                  setChain(hash, next);
                              else
                                  prev.next = next;
                              break;
                          }
                          prev = cur;
                          cur = next;
                      }
                  }
              }
          
          												
          										





          回頁首


          結束語

          弱引用和弱集合是對堆進行管理的強大工具,使得應用程序可以使用更復雜的可及性方案,而不只是由普通(強)引用所提供的“要么全部要么沒有”可及性。下個月,我們將分析與弱引用有關的軟引用,將分析在使用弱引用和軟引用時,垃圾收集器的行為。

          posted @ 2006-08-24 17:42 Binary 閱讀(244) | 評論 (0)編輯 收藏

          Java 理論與實踐: Web 層的狀態復制

          大多數具有一定重要性的 Web 應用程序都要求維護某種會話狀態,如用戶購物車的內容。如何在群集服務器應用程序中管理和復制狀態對應用程序的可伸縮性有顯著影響。許多 J2SE 和 J2EE 應用程序將狀態存儲在由 Servlet API 提供的 HttpSession 中。本月,專欄作家 Brian Goetz 分析了狀態復制的一些選項以及如何最有效地使用 HttpSession 以提供好的伸縮性和性能。在本文論壇中與本文作者和其他讀者分享您的觀點。(可以單擊文章頂部或者底部的 討論訪問論壇。)

          不管正在構建的是 J2EE 還是 J2SE 服務器應用程序,都有可能以某種方式使用 Java Servlet —— 可能是直接地通過像 JSP 技術、Velocity 或者 WebMacro 這樣的表示層,也可能通過一個基于 servlet 的 Web 服務實現,如 Axis 或者 Glue。Servlet API 提供的一個最重要的功能是會話管理 —— 通過 HttpSession 接口進行用戶狀態的認證、失效和維護。

          會話狀態

          幾乎每一個 Web 應用程序都有一些會話狀態,這些狀態有可能像記住您是否已登錄這么簡單,也可能是您的會話的更詳細的歷史,如購物車的內容、以前查詢結果的緩存或者 20 頁動態問卷表的完整響應歷史。因為 HTTP 協議本身是無狀態的,所以需要將會話狀態存儲在某處并與瀏覽會話以某種方式相關聯,使得下次請求同一 Web 應用程序的頁面時可以容易地獲取。幸運的是,J2EE 提供了幾種管理會話狀態的方法 —— 狀態可以存儲在數據層,用 Servlet API 的 HttpSession 接口存儲在 Web 層,用有狀態會話 bean 存儲在 Enterprise JavaBeans(EJB)層,甚至用 cookie 或者隱藏表單字段將狀態存儲在客戶層。不幸的是,會話狀態管理不當會帶來嚴重的性能問題。

          如果應用程序能夠在 HttpSession 中存儲用戶狀態,這種方法通常比其他方法更好。在客戶端用 HTTP cookie 或者隱藏表單字段存儲會話狀態有很大的安全風險 —— 它將應用程序的一部分內部內容暴露給了非受信任的客戶層。(一個早期的電子商務網站將購物車內容(包括價格)存儲在隱藏表單字段中,從而可以很容易被非法利用,讓任何了解 HTML 和 HTTP 的用戶可以以 0.01 美元購買任何商品。噢)此外,使用 cookie 或者隱藏表單字段很混亂,容易出錯,并且脆弱(如果用戶禁止在瀏覽器中使用 cookie,那么基于 cookie 的方法就完全不能工作)。

          在 J2EE 應用程序中存儲服務器端狀態的其他方法是使用有狀態會話 bean,或者在數據庫中存儲會話狀態。雖然有狀態會話 bean 在會話狀態管理方面有更大的靈活性,但是在可能的情況下,將會話狀態存儲在 Web 層仍然有好處。如果業務對象是無狀態的,那么通常可以僅僅添加更多 Web 服務器來擴展應用程序,而不用添加更多 Web 服務器和更多 EJB 容器, 這樣的成本一般要低一些并且容易完成。使用 HttpSession 存儲會話狀態的另一個好處是 Servlet API 提供了一種會話失效時通知的容易方法。在數據庫中存儲會話狀態的成本可能難以承受。

          servlet 規范沒有要求 servlet 容器進行某種類型的會話復制或者持久性,但是它建議將狀態復制作為 servlet 首要 存在理由(raison d'etre) 的重要部分,并且它對作為進行會話復制的容器提出了一些要求。會話復制可以提供大量好處 —— 負載平衡、伸縮性、容錯和高可用性。相應地,大多數 servlet 容器支持某種形式的 HttpSession 復制,但是復制的機制、配置和時間是由實現決定的。





          回頁首


          HttpSession API

          簡單地說, HttpSession 接口支持幾種方法,servlet、JSP 頁或者其他表示層組件可以用這些方法來跨多個 HTTP 請求維護會話信息。會話綁定到特定的用戶,但是在 Web 應用程序的所有 servlet 中共享 —— 不特定于某一個 servlet。一種考慮會話的有用方法是,會話像一個在會話期間存儲對象的 Map —— 可以用 setAttribute 按名字存儲會話屬性,并用 getAttribute 提取它們。 HttpSession 接口還包含會話生存周期方法,如 invalidate() (它通知容器應丟棄會話)。清單 1 顯示 HttpSession 接口最常用的元素:


          清單 1. HttpSession API
          												
          														public interface HttpSession {
              Object getAttribute(String s);
              Enumeration getAttributeNames();
              void setAttribute(String s, Object o);
              void removeAttribute(String s);
          
              boolean isNew();
              void invalidate();
              void setMaxInactiveInterval(int i);
              int getMaxInactiveInterval();
              ...
          }
          
          												
          										

          理論上,可以跨群集一致性地完全復制會話狀態,這樣群集中的所有節點都可以服務任何請求,一個簡單的負載平衡器可以以輪詢方式傳送請求,避開有故障的主機。不過,這種緊密的復制有很高的性能成本,并且難于實現,當群集接近某一規模時,還會有伸縮性的問題。

          一種更常用的方式是將負載平衡與會話相似性(affinity) 結合起來 —— 負載平衡器可以將會話與連接相關聯,并將會話中以后的請求發送給同一服務器。有很多硬件和軟件負載平衡器支持這個功能,并且這意味著只有主連接主機和會話需要故障轉移到另一臺服務器時才訪問復制的會話信息。





          回頁首


          復制方式

          復制提供了一些可能的好處,包括可用性、容錯和伸縮性。此外,有大量會話復制的方法可用:方法的選擇取決于應用程序群集的規模、復制的目標和 servlet 容器支持的復制設施。復制有性能成本,包括 CPU 周期(存儲在會話中的序列化對象)、網絡帶寬(廣播更新),以及基于磁盤的方案中寫入到磁盤或者數據庫的成本。

          幾乎所有 servlet 容器都通過存儲在 HttpSession 中的序列化對象進行 HttpSession 復制,所以如果是創建一個分布式應用程序,應當確保只將可序列化對象放到會話中。(一些容器對像 EJB 引用、事務上下文、還有其他非可序列化的 J2EE 對象類型有特殊的處理。)

          基于 JDBC 的復制

          一種會話復制的方法是序列化會話內容并將它寫入數據庫。這種方法相當直觀,其優點是不僅會話可以故障轉移到其他主機,而且即使整個群集失效,會話數據也可以保存下來。基于數據庫的復制的缺點是性能成本 —— 數據庫事務是昂貴的。雖然它可以在 Web 層很好地伸縮,但是它可能在數據層產生伸縮問題 —— 如果群集增長大到一定程度,擴展數據層以容納會話數據會很困難或者成本無法接受。

          基于文件的復制

          基于文件的復制類似于使用數據庫存儲序列化的會話,只不過是使用共享文件服務器而不是數據庫來存儲會話數據。這種方式的成本一般比使用數據庫的成本(硬件成本、軟件許可證和計算開銷)低,其代價則是可靠性(數據庫可提供比文件系統更強的持久化保證)。

          基于內存的復制

          另一種復制方式是與群集中的一個或者多個其他服務器共享序列化的會話數據副本。復制所有會話到所有主機中提供了最大的可用性,并且負載平衡最容易,但是因為復制消息所消耗的每個節點的內存和網絡帶寬,最終會限制群集的規模。一些應用服務器支持與“伙伴(buddy)”節點的基于內存的復制,其中每一個會話存在于主服務器上和一臺(或更多)備份服務器上。這種方案比將所有會話復制到所有服務器的伸縮性更好,但是當需要將會話故障轉移到另一臺服務器上時會使負載平衡任務復雜化,因為它必須找出另外哪一臺(幾臺)服務器有這個會話。

          時間考慮

          除了決定如何存儲復制會話數據,還有什么時候復制數據的問題。最可靠但也最昂貴的方法是每次數據改變時復制它(如每次 servlet 調用結束)。不那么昂貴、但是在故障時會有丟失一些數據的風險的方法是在每超過 N 秒時復制數據。

          與時間問題有關的問題是,是復制整個會話還是只試嘗復制會話中改變了的屬性(它包含的數據會少得多)。這些都需要在可靠性和性能之間進行取舍。Servlet 開發人員應當認識到在故障轉移時,會話狀態可能變得“過時”(是幾次請求前的復制),并應當準備處理不是最新的會話內容。(例如,如果一個interview 的第 3 步產生一個會話屬性,而用戶在第 4 步時,請求被故障轉移到一個具有兩次請求之前的會話狀態復制的系統上,那么第 4 步的 servlet 代碼應預備在會話中找不到這個屬性,并采取相應的行動 —— 如重定向,而不是認定它會在那里、并在找不到它時拋出一個 NullPointerException 。)





          回頁首


          容器支持

          Servlet 容器的 HttpSession 復制選項以及如何配置這些選項是各不相同的。IBM WebSphere ?提供的復制選項是最多的,它提供了在內存中復制或者基于數據庫的復制、在 servlet 末尾或者基于時間的復制時間、傳播全部會話快照(JBoss 3.2 或以后版本)或者只傳播改變了的屬性等選擇。基于內存的復制基于 JMS 發布-訂閱,它可以復制到所有克隆、一個“伙伴”復制品或者一個專門的復制服務器。

          WebLogic 還提供了一組選擇,包括內存中(使用一個伙伴復制品)、基于文件的或者基于數據庫的。JBoss 與 Tomcat 或者 Jetty servlet 容器一同使用時,進行基于內存的復制,可以選擇 servlet 末尾或者基于時間的復制時間,而快照選項(在 JBoss 3.2 或以后版本)是只復制改變了的屬性。Tomcat 5.0 為所有群集節點提供了基于內存的復制。此外,通過像 WADI 這樣的項目,可以用 servlet 過濾機制將會話復制添加到像 Tomcat 或者 Jetty 這樣的 servlet 容器中。





          回頁首


          改進分布式 Web 應用程序的性能

          不管決定使用什么機制進行會話復制,可以用幾種方式改進 Web 應用程序的性能和伸縮性。首先記住,為了獲得會話復制的好處,需要在部署描述符中將 Web 應用程序標記為 distributable,并保證在會話中的所有內容都是可序列化的。

          保持會話最小

          因為復制會話有隨著會話中的對象圖(object graph) 的變大而增加成本,所以應當盡可能地在會話中少放置數據。這樣做會減少復制的序列化的開銷、網絡帶寬要求和磁盤要求。特別地,將共享對象存儲在會話中一般不是好主意,因為它們需要復制到它們所屬的 每一個會話中。

          不要繞過 setAttribute

          在改變會話的屬性時,要知道即使 servlet 容器只是試圖做最小的更新(只傳播改變了的屬性),如果沒有調用 setAttribute ,容器也可能沒有注意到已經改變的屬性。(想像在會話中有一個 Vector ,表示購物車中的商品 —— 如果調用 getAttribute() 獲取 Vector 、然后向它添加一些內容,并且不再次調用 setAttribute ,容器可能不會意識到 Vector 已經改變了。)

          使用細化的會話屬性

          對于支持最小更新的容器,可以通過將多個細化的對象而不是一個大塊頭放到會話中而降低會話復制的成本。這樣,對快速改變的數據的改變也不會迫使容器去序列化并傳播慢速改變的數據。

          完成后使之失效

          如果知道用戶完成了會話的使用(如,用戶選擇注銷登錄),確保調用 HttpSession.invalidate() 。否則,會話將持久化直到它失效,這會消耗內存,并且可能是長時間的(取決于會話超時時間)。許多 servlet 容器對可以跨所有會話使用的內存的數量有一個限制,達到這個限制時,會序列化最先使用的會話并將它寫到磁盤上。如果知道用戶使用完了會話,可以使容器不再處理它并使它作廢。

          保持會話干凈

          如果在會話中有大的項,并且只在會話的一部分中使用,那么當不再需要時應刪除它們。刪除它們會減少會話復制的成本。(這種做法類似于使用顯式 nulling 以幫助垃圾收集器,老讀者知道我一般不建議這樣做,但是在這種情況下,因為有復制,在會話中保持垃圾的成本要高得多,因此值得以這種方式幫助容器。)





          回頁首


          結束語

          通過 HttpSession 復制,Servlet 容器可以在構建復制的、高可用性的 Web 應用程序方面給您減輕很多負擔。不過,對于復制有一些配置選項,每個容器都不一樣,復制策略的選擇對于應用程序的容錯、性能和伸縮性有影響。復制策略的選擇不應當是事后的 —— 您應當在構建 Web 應用程序時就考慮它。并且,一定不要忘記進行負載測試以確定應用程序的伸縮性 —— 在客戶替您做之前。

          posted @ 2006-08-24 17:41 Binary 閱讀(219) | 評論 (0)編輯 收藏

          Java 理論與實踐: 關于異常的爭論

          關于在 Java 語言中使用異常的大多數建議都認為,在確信異常可以被捕獲的任何情況下,應該優先使用檢查型異常。語言設計(編譯器強制您在方法簽名中列出可能被拋出的所有檢查型異常)以及早期關于樣式和用法的著作都支持該建議。最近,幾位著名的作者已經開始認為非檢查型異常在優秀的 Java 類設計中有著比以前所認為的更為重要的地位。在本文中,Brian Goetz 考察了關于使用非檢查型異常的優缺點。請在附帶的討論論壇中與作者和其他讀者一起分享您有關本文的心得體會(您也可以點擊文章頂部或底部的 討論來訪問該論壇。)

          與 C++ 類似,Java 語言也提供異常的拋出和捕獲。但是,與 C++ 不一樣的是,Java 語言支持檢查型和非檢查型異常。Java 類必須在方法簽名中聲明它們所拋出的任何檢查型異常,并且對于任何方法,如果它調用的方法拋出一個類型為 E 的檢查型異常,那么它必須捕獲 E 或者也聲明為拋出 E(或者 E 的一個父類)。通過這種方式,該語言強制我們文檔化控制可能退出一個方法的所有預期方式。

          對于因為編程錯誤而導致的異常,或者是不能期望程序捕獲的異常(解除引用一個空指針,數組越界,除零,等等),為了使開發人員免于處理這些異常,一些異常被命名為非檢查型異常(即那些繼承自 RuntimeException 的異常)并且不需要進行聲明。

          傳統的觀點

          在下面的來自 Sun 的“The Java Tutorial”的摘錄中,總結了關于將一個異常聲明為檢查型還是非檢查型的傳統觀點(更多的信息請參閱 參考資料):

          因為 Java 語言并不要求方法捕獲或者指定運行時異常,因此編寫只拋出運行時異常的代碼或者使得他們的所有異常子類都繼承自 RuntimeException ,對于程序員來說是有吸引力的。這些編程捷徑都允許程序員編寫 Java 代碼而不會受到來自編譯器的所有挑剔性錯誤的干擾,并且不用去指定或者捕獲任何異常。盡管對于程序員來說這似乎比較方便,但是它回避了 Java 的捕獲或者指定要求的意圖,并且對于那些使用您提供的類的程序員可能會導致問題。

          檢查型異常代表關于一個合法指定的請求的操作的有用信息,調用者可能已經對該操作沒有控制,并且調用者需要得到有關的通知 —— 例如,文件系統已滿,或者遠端已經關閉連接,或者訪問權限不允許該動作。

          如果您僅僅是因為不想指定異常而拋出一個 RuntimeException ,或者創建 RuntimeException 的一個子類,那么您換取到了什么呢?您只是獲得了拋出一個異常而不用您指定這樣做的能力。換句話說,這是一種用于避免文檔化方法所能拋出的異常的方式。在什么時候這是有益的?也就是說,在什么時候避免注明一個方法的行為是有益的?答案是“幾乎從不。”

          換句話說,Sun 告訴我們檢查型異常應該是準則。該教程通過多種方式繼續說明,通常應該拋出異常,而不是 RuntimeException —— 除非您是 JVM。

          Effective Java: Programming Language Guide一書中(請參閱 參考資料),Josh Bloch 提供了下列關于檢查型和非檢查型異常的知識點,這些與 “The Java Tutorial” 中的建議相一致(但是并不完全嚴格一致):

          • 第 39 條:只為異常條件使用異常。也就是說,不要為控制流使用異常,比如,在調用 Iterator.next() 時而不是在第一次檢查 Iterator.hasNext() 時捕獲 NoSuchElementException

          • 第 40 條:為可恢復的條件使用檢查型異常,為編程錯誤使用運行時異常。這里,Bloch 回應傳統的 Sun 觀點 —— 運行時異常應該只是用于指示編程錯誤,例如違反前置條件。

          • 第 41 條:避免不必要的使用檢查型異常。換句話說,對于調用者不可能從其中恢復的情形,或者惟一可以預見的響應將是程序退出,則不要使用檢查型異常。

          • 第 43 條:拋出與抽象相適應的異常。換句話說,一個方法所拋出的異常應該在一個抽象層次上定義,該抽象層次與該方法做什么相一致,而不一定與方法的底層實現細節相一致。例如,一個從文件、數據庫或者 JNDI 裝載資源的方法在不能找到資源時,應該拋出某種 ResourceNotFound 異常(通常使用異常鏈來保存隱含的原因),而不是更底層的 IOExceptionSQLException 或者 NamingException




          回頁首


          重新考察非檢查型異常的正統觀點

          最近,幾位受尊敬的專家,包括 Bruce Eckel 和 Rod Johnson,已經公開聲明盡管他們最初完全同意檢查型異常的正統觀點,但是他們已經認定排他性使用檢查型異常的想法并沒有最初看起來那樣好,并且對于許多大型項目,檢查型異常已經成為一個重要的問題來源。Eckel 提出了一個更為極端的觀點,建議所有的異常應該是非檢查型的;Johnson 的觀點要保守一些,但是仍然暗示傳統的優先選擇檢查型異常是過分的。(值得一提的是,C# 的設計師在語言設計中選擇忽略檢查型異常,使得所有異常都是非檢查型的,因而幾乎可以肯定他們具有豐富的 Java 技術使用經驗。但是,后來他們的確為檢查型異常的實現留出了空間。)





          回頁首


          對于檢查型異常的一些批評

          Eckel 和 Johnson 都指出了一個關于檢查型異常的相似的問題清單;一些是檢查型異常的內在屬性,一些是檢查型異常在 Java 語言中的特定實現的屬性,還有一些只是簡單的觀察,主要是關于檢查型異常的廣泛的錯誤使用是如何變為一個嚴重的問題,從而導致該機制可能需要被重新考慮。

          檢查型異常不適當地暴露實現細節

          您已經有多少次看見(或者編寫)一個拋出 SQLException 或者 IOException 的方法,即使它看起來與數據庫或者文件毫無關系呢?對于開發人員來說,在一個方法的最初實現中總結出可能拋出的所有異常并且將它們增加到方法的 throws 子句(許多 IDE 甚至幫助您執行該任務)是十分常見的。這種直接方法的一個問題是它違反了 Bloch 的 第 43 條 —— 被拋出的異常所位于的抽象層次與拋出它們的方法不一致。

          一個用于裝載用戶概要的方法,在找不到用戶時應該拋出 NoSuchUserException ,而不是 SQLException —— 調用者可以很好地預料到用戶可能找不到,但是不知道如何處理 SQLException 。異常鏈可以用于拋出一個更為合適的異常而不用丟棄關于底層失敗的細節(例如棧跟蹤),允許抽象層將位于它們之上的分層同位于它們之下的分層的細節隔離開來,同時保留對于調試可能有用的信息。

          據說,諸如 JDBC 包的設計采取這樣一種方式,使得它難以避免該問題。在 JDBC 接口中的每個方法都拋出 SQLException ,但是在訪問一個數據庫的過程中可能會經歷多種不同類型的問題,并且不同的方法可能易受不同錯誤模式的影響。一個 SQLException 可能指示一個系統級問題(不能連接到數據庫)、邏輯問題(在結果集中沒有更多的行)或者特定數據的問題(您剛才試圖插入行的主鍵已經存在或者違反實體完整性約束)。如果沒有犯不可原諒的嘗試分析消息正文的過失,調用者是不可能區分這些不同類型的 SQLException 的。( SQLException 的確支持用于獲取數據庫特定錯誤代碼和 SQL 狀態變量的方法,但是在實踐中這些很少用于區分不同的數據庫錯誤條件。)

          不穩定的方法簽名

          不穩定的方法簽名問題是與前面的問題相關的 —— 如果您只是通過一個方法傳遞異常,那么您不得不在每次改變方法的實現時改變它的方法簽名,以及改變調用該方法的所有代碼。一旦類已經被部署到產品中,管理這些脆弱的方法簽名就變成一個昂貴的任務。然而,該問題本質上是沒有遵循 Bloch 提出的第 43 條的另一個癥狀。方法在遇到失敗時應該拋出一個異常,但是該異常應該反映該方法做什么,而不是它如何做。

          有時,當程序員對因為實現的改變而導致從方法簽名中增加或者刪除異常感到厭煩時,他們不是通過使用一個抽象來定義特定層次可能拋出的異常類型,而只是將他們的所有方法都聲明為拋出 Exception 。換句話說,他們已經認定異常只是導致煩惱,并且基本上將它們關閉掉了。毋庸多言,該方法對于絕大多數可任意使用的代碼來說通常不是一個好的錯誤處理策略。

          難以理解的代碼

          因為許多方法都拋出一定數目的不同異常,錯誤處理的代碼相對于實際的功能代碼的比率可能會偏高,使得難以找到一個方法中實際完成功能的代碼。異常是通過集中錯誤處理來設想減小代碼的,但是一個具有三行代碼和六個 catch 塊(其中每個塊只是記錄異常或者包裝并重新拋出異常)的方法看起來比較膨脹并且會使得本來簡單的代碼變得模糊。

          異常淹沒

          我們都看到過這樣的代碼,其中捕獲了一個異常,但是在 catch 塊中沒有代碼。盡管這種編程實踐很明顯是不好的,但是很容易看出它是如何發生的 —— 在原型化期間,某人通過 try...catch 塊包裝代碼,而后來忘記返回并填充 catch 塊。盡管這個錯誤很常見,但是這也是更好的工具可以幫助我們的地方之一 —— 對于異常淹沒的地方,通過編輯器、編譯器或者靜態檢查工具可以容易地檢測并發出警告。

          極度通用的 try...catch 塊是另一種形式的異常淹沒,并且更加難以檢測,因為這是 Java 類庫中的異常類層次的結構而導致的(可疑)。讓我們假定一個方法拋出四個不同類型的異常,并且調用者遇到其中任何一個異常都將捕獲、記錄它們,并且返回。實現該策略的一種方式是使用一個帶有四個 catch 子句的 try...catch 塊,其中每個異常類型一個。為了避免代碼難以理解的問題,一些開發人員將重構該代碼,如清單 1 所示:


          清單 1. 意外地淹沒 RuntimeException
          												
          														try { 
            doSomething();
          }
          catch (Exception e) { 
            log(e);
          }
          
          												
          										

          盡管該代碼與四個 catch 塊相比更為緊湊,但是它具有一個問題 —— 它還捕獲可能由 doSomething 拋出的任何 RuntimeException 并且阻止它們進行擴散。

          過多的異常包裝

          如果異常是在一個底層的設施中生成的,并且通過許多代碼層向上擴散,在最終被處理之前它可能被捕獲、包裝和重新拋出若干次。當異常最終被記錄的時候,棧跟蹤可能有許多頁,因為棧跟蹤可能被復制多次,其中每個包裝層一次。(在 JDK 1.4 以及后來的版本中,異常鏈的實現在某種程度上緩解了該問題。)





          回頁首


          替換的方法

          Bruce Eckel, Thinking in Java(請參閱 參考資料)的作者,聲稱在使用 Java 語言多年后,他已經得出這樣的結論,認為檢查型異常是一個錯誤 —— 一個應該被聲明為失敗的試驗。Eckel 提倡將所有的異常都作為非檢查型的,并且提供清單 2 中的類作為將檢查型異常轉變為非檢查型異常的一個方法,同時保留當異常從棧向上擴散時捕獲特定類型的異常的能力(關于如何使用該方法的解釋,請參閱他在 參考資料小節中的文章):


          清單 2. Eckel 的異常適配器類
          												
          														class ExceptionAdapter extends RuntimeException {
            private final String stackTrace;
            public Exception originalException;
            public ExceptionAdapter(Exception e) {
              super(e.toString());
              originalException = e;
              StringWriter sw = new StringWriter();
              e.printStackTrace(new PrintWriter(sw));
              stackTrace = sw.toString();
            }
            public void printStackTrace() { 
              printStackTrace(System.err);
            }
            public void printStackTrace(java.io.PrintStream s) { 
              synchronized(s) {
                s.print(getClass().getName() + ": ");
                s.print(stackTrace);
              }
            }
            public void printStackTrace(java.io.PrintWriter s) { 
              synchronized(s) {
                s.print(getClass().getName() + ": ");
                s.print(stackTrace);
              }
            }
            public void rethrow() { throw originalException; }
          } 
          
          												
          										

          如果查看 Eckel 的 Web 站點上的討論,您將會發現回應者是嚴重分裂的。一些人認為他的提議是荒謬的;一些人認為這是一個重要的思想。(我的觀點是,盡管恰當地使用異常確實是很難的,并且對異常用不好的例子大量存在,但是大多數贊同他的人是因為錯誤的原因才這樣做的,這與一個政客位于一個可以隨便獲取巧克力的平臺上參選將會獲得十歲孩子的大量選票的情況具有相似之處。)

          Rod Johnson 是 J2EE Design and Development(請參閱 參考資料) 的作者,這是我所讀過的關于 Java 開發,J2EE 等方面的最好的書籍之一。他采取一個不太激進的方法。他列舉了異常的多個類別,并且為每個類別確定一個策略。一些異常本質上是次要的返回代碼(它通常指示違反業務規則),而一些異常則是“發生某種可怕錯誤”(例如數據庫連接失敗)的變種。Johnson 提倡對于第一種類別的異常(可選的返回代碼)使用檢查型異常,而對于后者使用運行時異常。在“發生某種可怕錯誤”的類別中,其動機是簡單地認識到沒有調用者能夠有效地處理該異常,因此它也可能以各種方式沿著棧向上擴散而對于中間代碼的影響保持最小(并且最小化異常淹沒的可能性)。

          Johnson 還列舉了一個中間情形,對此他提出一個問題,“只是少數調用者希望處理問題嗎?”對于這些情形,他也建議使用非檢查型異常。作為該類別的一個例子,他列舉了 JDO 異常 —— 大多數情況下,JDO 異常表示的情況是調用者不希望處理的,但是在某些情況下,捕獲和處理特定類型的異常是有用的。他建議在這里使用非檢查型異常,而不是讓其余的使用 JDO 的類通過捕獲和重新拋出這些異常的形式來彌補這個可能性。

          使用非檢查型異常

          關于是否使用非檢查型異常的決定是復雜的,并且很顯然沒有明顯的答案。Sun 的建議是對于任何情況使用它們,而 C# 方法(也就是 Eckel 和其他人所贊同的)是對于任何情況都不使用它們。其他人說,“還存在一個中間情形。”

          通過在 C++ 中使用異常,其中所有的異常都是非檢查型的,我已經發現非檢查型異常的最大風險之一就是它并沒有按照檢查型異常采用的方式那樣自我文檔化。除非 API 的創建者明確地文檔化將要拋出的異常,否則調用者沒有辦法知道在他們的代碼中將要捕獲的異常是什么。不幸的是,我的經驗是大多數 C++ API 的文檔化非常差,并且即使文檔化很好的 API 也缺乏關于從一個給定方法可能拋出的異常的足夠信息。我看不出有任何理由可以說該問題對于 Java 類庫不是同樣的常見,因為 Jav 類庫嚴重依賴于非檢查型異常。依賴于您自己的或者您的合作伙伴的編程技巧是非常困難的;如果不得不依賴于某個人的文檔化技巧,那么對于他的代碼您可能得使用調用棧中的十六個幀來作為您的主要的錯誤處理機制,這將會是令人恐慌的。

          文檔化問題進一步強調為什么懶惰是導致選擇使用非檢查型異常的一個不好的原因,因為對于文檔化增加給包的負擔,使用非檢查型異常應該比使用檢查型異常甚至更高(當文檔化您所拋出的非檢查型異常比檢查型異常變得更為重要的時候)。





          回頁首


          文檔化,文檔化,文檔化

          如果決定使用非檢查型異常,您需要徹底地文檔化這個選擇,包括在 Javadoc 中文檔化一個方法可能拋出的所有非檢查型異常。Johnson 建議在每個包的基礎上選擇檢查型和非檢查型異常。使用非檢查型異常時還要記住,即使您并不捕獲任何異常,也可能需要使用 try...finally 塊,從而可以執行清除動作例如關閉數據庫連接。對于檢查型異常,我們有 try...catch 用來提示增加一個 finally 子句。對于非檢查型異常,我們則沒有這個支撐可以依靠。

          posted @ 2006-08-24 17:39 Binary 閱讀(198) | 評論 (0)編輯 收藏

          Java 理論和實踐: 理解 JTS ― 平衡安全性和性能

          為 EJB 組件定義事務劃分和隔離屬性(attribute)的職責由應用程序裝配人員來承擔。如果這些屬性設置不當,會對應用程序的性能、可伸縮性或容錯能力造成嚴重的后果。不幸的是,并沒有一種必須遵守的規則用于正確設置這些屬性,但有一些指導可以幫助我們在并發危險和性能危險之間找到一種平衡。

          我們在第 1 部分中討論過,事務主要是一種異常處理機制。事務在程序中的用途與合法合同在日常業務中的用途相似:如果出了什么問題它們可以幫助恢復。但由于大多數時間內都沒實際 發生什么錯誤,我們就希望能夠盡量減少它們的開銷以及對其余時間的占用。我們在應用程序中如何使用事務會對應用程序的性能和可伸縮性產生很大的影響。

          事務劃分

          J2EE 容器提供了兩種機制用來定義事務的起點和終點:bean 管理的事務和容器管理的事務。在 bean 管理的事務中,用 UserTransaction.begin()UserTransaction.commit() 在 bean 方法中顯式開始和結束一個事務。另一方面,容器管理的事務提供了更多的靈活性。通過在裝配描述符中為每個 EJB 方法定義事務性屬性,您可以指定每個方法的事務性需求并讓容器確定何時開始和結束一個事務。無論在哪種情況下,構建事務的基本指導方針都是一樣的。

          進來,出去

          事務劃分的第一條規則是“盡量短小”。事務提供并發控制;這通常意味著資源管理器將代表您獲得您在事務期間訪問的數據項的鎖,并且它必須一直持有這些鎖,直到事務結束。(請回憶一下本系列第 1 部分所討論的 ACID特性,其中“ACID”的“I”代表“隔離”(Isolation)。也就是說,一個事務的結果影響不到與該事務并發執行的其它事務。)當您擁有鎖時,任何需要訪問您鎖定的數據項的其它事務將不得不一直等待,直到您釋放鎖。如果您的事務很長,那些其它的所有事務都將被鎖定,您的應用程序吞吐量將大幅度下降。

          規則 1:使事務盡可能短小。

          通過使事務盡量短小,您可以把阻礙其它事務的時間縮到最短,從而提高應用程序的可伸縮性。保持事務盡可能短小的最好方法當然是不在事務中間做任何不必要耗費時間的事,特別是不要在事務中間等待用戶輸入。

          開始一個事務,從數據庫檢索一些數據,顯示數據,然后在仍處于事務中時請用戶做出一個選擇可能比較誘人。千萬別這么做!即使用戶注意力集中,也要花費數秒來響應 ― 而在數據庫中擁有鎖數秒的時間已經是很長的了。如果用戶決定離開計算機,或許是去吃午餐或者甚至回家一天,會發生什么情況?應用程序將只好無奈停機。在事務期間執行 I/O 是導致災難的秘訣。

          規則 2:在事務期間不要等待用戶輸入。

          將相關的操作歸在一起

          由于每個事務都有不小的開銷,您可能認為最好是在單個事務中執行盡可能多的操作以使每個操作的開銷達到最小。但規則 1 告訴我們長事務對可伸縮性不利。那么如何實現最小化每個操作的開銷和可伸縮性之間的平衡呢?

          我們把規則 1 設置為邏輯上的極端 ― 每個事務一個操作 ― 這樣不僅會導致額外開銷,還會危及應用程序狀態的一致性。假定事務性資源管理器維護應用程序狀態的一致性(請回憶一下第 1 部分,其中“ACID”的“C”代表“一致性”(Consistency)),但它們依賴應用程序來定義一致性的意思。實際上,我們在描述事務時使用的一致性的定義有點圓滑:應用程序說一致性是什么意思它就是什么意思。應用程序把幾組應用程序狀態的變化組織到幾個事務中,結果應用程序的狀態就成了 定義上的(by definition)一致。然后資源管理器確保如果它必須從故障恢復的話,就把應用程序狀態恢復到最近的一致狀態。

          在第 1 部分中,我們給出了一個在銀行應用程序中將資金從一個帳戶轉移到另一個帳戶的示例。清單 1 展示了這個示例可能的 SQL 實現,它包含 5 個 SQL 操作(一個選擇,兩個更新和兩個插入操作):


          清單 1. 資金轉移的樣本 SQL 代碼
          												
          														SELECT accountBalance INTO aBalance 
              FROM Accounts WHERE accountId=aId;
          IF (aBalance >= transferAmount) THEN 
              UPDATE Accounts 
                  SET accountBalance = accountBalance - transferAmount
                  WHERE accountId = aId;
              UPDATE Accounts 
                  SET accountBalance = accountBalance + transferAmount
                  WHERE accountId = bId;
              INSERT INTO AccountJournal (accountId, amount)
                  VALUES (aId, -transferAmount);
              INSERT INTO AccountJournal (accountId, amount)
                  VALUES (bId, transferAmount);
          ELSE
              FAIL "Insufficient funds in account";
          END IF
          
          												
          										

          如果我們把這個操作作為五個單獨的事務來執行會發生什么情況?這樣不僅會使執行速度變慢(由于事務開銷),還會失去一致性。例如,如果一個人從帳戶 A 取了錢,作為執行第一次 SELECT(檢查余額)和隨后的記入借方 UPDATE 之間的一個單獨事務的一部分,會發生什么情況?這樣會違反我們認為這段代碼會強制遵守的業務規則 ― 帳戶余額應該是非負的。如果在第一次 UPDATE 和第二次 UPDATE 之間系統失敗會發生什么情況?現在,當系統恢復時,錢已經離開了帳戶 A 但還沒有記入帳戶 B 的貸方,并且也無記錄說明原因。這樣,哪個帳戶的所有者都不會開心。

          清單 1 中的五個 SQL 操作是單個相關操作 ― 將資金從一個帳戶轉移到另一個帳戶 ― 的一部分。因此,我們希望要么全部執行它們,要么一個也不執行,建議在單個事務中全部執行它們。

          規則 3:將相關操作歸到單個事務中。

          理想化的平衡

          規則 1 說事務應盡可能短小。清單 1 中的示例表明有時候我們必須把一些操作歸到一個事務中來維護一致性。當然,它要依賴應用程序來確定“相關操作”是由什么組成的。我們可以把規則 1 和 3 結合在一起,提供一個描述事務范圍的一般指導,我們規定它為規則 4:

          規則 4:把相關操作歸到單個事務中,但把不相關的操作放到單獨的事務中。





          回頁首


          容器管理的事務

          在使用容器管理的事務時,不是顯式聲明事務的起點和終點,而是為每個 EJB 方法定義事務性需求。bean 的 assembly-descriptorcontainer-transaction 部分的 trans-attribute 元素中定義了事務模式。(清單 2 中顯示了一個 assembly-descriptor 示例。)方法的事務模式以及狀態 ― 調用方法是否早已在事務中被征用 ― 決定了當 EJB 方法被調用時容器應該進行下面幾個操作中的哪一個:

          • 征用現有事務中的方法。
          • 創建一個新事務,并征用該事務中的方法。
          • 不征用任何事務中的方法。
          • 拋出一個異常。

          清單 2. 樣本 EJB 裝配描述符
          												
          														<assembly-descriptor>
            ...
            <container-transaction>
              <method>
                <ejb-name>MyBean</ejb-name>
                <method-name>*</method-name>
              </method>
              <trans-attribute>Required</trans-attribute>
            </container-transaction>
            <container-transaction>
              <method>
                <ejb-name>MyBean</ejb-name>
                <method-name>logError</method-name>
              </method>
              <trans-attribute>RequiresNew</trans-attribute>
            </container-transaction>
            ...
          </assembly-descriptor>
          
          												
          										

          J2EE 規范定義了六種事務模式: RequiredRequiresNewMandatorySupportsNotSupportedNever 。表 1 概述了每種模式的行為 ― 在現有事務中被調用和不在事務內調用時的行為 ― 并描述了每種模式受哪些類型的 EJB 組件支持。(一些容器可能允許您在選擇事務模式時有更多的靈活性,但這種使用要依賴特定于容器的功能,因此不適合跨容器的情況)。

          表 1. 事務模式

          事務模式 Bean 類型 在事務 T 內被調用時的行為 在事務外被調用時的行為
          Required 會話、實體、消息驅動 在 T 中征用 新建事務
          RequiresNew 會話、實體 新建事務 新建事務
          Supports 會話、消息驅動 在 T 中征用 不帶事務運行
          Mandatory 會話、實體 在 T 中征用 出錯
          NotSupported 會話、消息驅動 不帶事務運行 不帶事務運行
          Never 會話、消息驅動 出錯 不帶事務運行

          在只使用容器管理的事務的應用程序中,只有組件調用事務模式為 RequiredRequiresNew 的 EJB 方法時才啟動事務。如果容器創建一個事務作為調用事務性方法的結果,當該方法完成時將關閉該事務。如果方法正常返回,容器將提交事務(除非應用程序已經要求回滾事務)。如果方法通過拋出一個異常退出,容器將回滾事務并傳播該異常。如果在現有事務 T 中調用了一個方法,并且事務模式指定應該不帶事務運行該方法或者在新事務中運行該方法,那么事務 T 將被暫掛,一直到方法完成,然后先前的事務 T 被恢復。

          選擇一種事務模式

          那么我們應該為自己的 bean 方法選擇哪種模式呢?對于會話 bean 和消息驅動 bean,您通常想使用 Required 來確保每個調用都被作為事務的一部分執行,但仍將允許方法作為一個更大的事務的組件。請小心使用 RequiresNew ;只有在確定自己的方法的行為應該與調用您的方法的行為分開提交時,才應該使用這種模式。 RequiresNew 一般情況下只和與系統中其它對象關系很少或沒什么關系的對象(比如日志對象)一起使用。(把 RequiresNew 與日志對象一起使用比較有意義,因為您可能希望在不管外圍事務是否提交的情況下提交日志消息。)

          RequiresNew 使用不當會導致與上面的描述相似的情況,其中,清單 1 中的代碼在五個分開的事務而不是一個事務中執行,這樣會使應用程序處于不一致狀態。

          對于 CMP(容器管理的持久性,container-managed persistence)實體 bean,通常是希望使用 RequiredMandatory 也是一個合理的選項,特別是在最初開發時;這將會警告您實體 bean 方法在事務外被調用這種情況,這時可能會指出一個部署錯誤。您幾乎從不希望把 RequiresNew 和 CMP 實體 bean 一起使用。 NotSupportedNever 旨在用于非事務性資源,比如 Java 事務 API(Java Transaction API,JTA)事務中無法征用的外部非事務性系統或事務性系統的適配器。

          如果 EJB 應用程序設計得當,應用上面的事務模式指導往往會自然地產生規則 4 建議的事務劃分。原因是 J2EE 體系架構鼓勵把應用程序分解為最小的方便處理的塊,并且每個塊都作為一個單獨的請求被處理( 不管是以 HTTP 請求的形式還是作為在 JMS 隊列中排隊的消息的結果)。





          回頁首


          重溫隔離

          在第 1 部分中,我們定義了 隔離(isolation)的意思是:一個事務的影響對與該事務并發執行的其它事務是不可見的;從事務的角度來看,好象事務是連續執行而非并行執行。盡管事務性資源管理器經常可以同時處理許多事務并提供隔離的假象,但有時隔離限制實際上要求把新事務延遲到現有事務完成后才開始。由于完成一個事務至少包括一個同步磁盤 I/O(寫到事務日志),這就會把每秒的事務數限制到接近每秒的寫磁盤次數,這對可伸縮性不利。

          實際上,通常是充分放松隔離需求以允許更多的事務并發執行并使系統響應能夠得到改善,使可伸縮性變得更強。幾乎所有的數據庫都支持標準隔離級別:讀未提交的(Read Uncommitted)、讀已提交的(Read Committed)、可重復的讀(Repeatable Read) 和可串行化的(Serializable)。

          不幸的是,為容器管理的事務管理隔離目前是在 J2EE 規范的范圍之外。但是,許多 J2EE 容器,比如 IBM WebSphere 和 BEA WebLogic,將提供特定于容器的擴展,這些擴展允許您以每方法(per-method)為基礎設置事務隔離級別,設置方法與在裝配描述符中設置事務模式的方法相同。對于 bean 管理的事務,您可以通過 JDBC 或者其它資源管理器連接設置隔離級別。

          為闡明隔離級別之間的差異,我們首先把幾個并發危險分類 ― 這幾種危險是當沒有適當地隔離時一個事務可能會干涉另一個事務的情況。下列的所有這些危險都與這種情況( 第二個事務已經啟動后第一個事務變得對第二個事務 可見)的結果有關:

          • 臟讀(Dirty Read):當一個事務的中間(未提交的)結果對另一個事務可見時就會發生這種情況。
          • 不可重復的讀(Unrepeatable Read):當一個事務讀取一個數據項,然后重新讀取這個數據項并看到不同的值時就是發生了這種情況。
          • 虛讀(Phantom Read):當一個事務執行返回多個行的查詢,稍后再次執行同一個查詢并看到第一次執行該查詢沒出現的額外行時就是發生了這種情況。

          四個標準隔離級別與這三個隔離危險相關,如表 2 所示。最低的隔離級別“讀未提交的”并不能保護事務不被其它事務更改,但它的速度最快,因為它不需要爭奪讀鎖。最高的隔離級別“可串行化的”與上面給出的隔離的定義相當;每個事務好象都與其它事務的影響完全隔離。

          表 2. 事務隔離級別

          隔離級別 臟讀 不可重復的讀 虛讀
          讀未提交的
          讀已提交的
          可重復的讀
          可串行化的

          對于大多數數據庫,缺省的隔離級別為“讀已提交的”,這是個很好的缺省選擇,因為它阻止事務在事務中的任何給定的點看到應用程序數據的不一致視圖。“讀已提交的”是一個很不錯的隔離級別,用于大多數典型的短事務,比如獲取報表數據或獲取要顯示給用戶的數據的時候(多半是作為 Web 請求的結果),也用于將新數據插入到數據庫的情況。

          當您需要所有事務間有較高級別的一致性時,使用較高的隔離級別“可重復的讀”和“可串行化的”比較合適,比如在清單 1 示例中,您希望從檢查余額以確保有足夠的資金到您實際取錢期間賬戶余額一直保持不變;這就要求至少要用“可重復的讀”隔離級別。在數據一致性絕對重要的情況下,比如審核記帳數據庫以確保一個帳戶的所有借方金額和貸方金額的總數等于它目前的余額時,可能還需要防止創建新行。這種情況下就需要使用“可串行化的”隔離級別。

          最低的隔離級別“讀未提交的”很少使用。它適用于您只需要獲得近似值,否則查詢將導致您不希望的性能開銷這種情況。當您想要估計一個變化很快的數量,如定單數或者今天所下定單的總金額(以美元為單位)時一般使用““讀未提交的”。

          因為隔離和可伸縮性之間實際是一種此消彼長的關系,所以您在為事務選擇隔離級別時應該小心行事。選擇太低的級別對數據比較危險。選擇太高的級別可能對性能不利,盡管負載比較輕時可能不會這樣。一般來說,數據一致性問題比性能問題更嚴重。如果拿不準,應該以小心為主,選擇一個較高的隔離級別。這就引出了規則 5:

          規則 5:使用保證數據安全的最低隔離級別,但如果拿不準,請使用“可串行化的”。

          即使您打算剛開始時以小心為主并希望結果性能可以接受 ―(被稱為“拒絕和祈禱(denial and prayer)”的性能管理技術 ― 很可能是最常用的性能策略,盡管大多數開發者都不承認這一點),在開發組件時考慮隔離需求也是有利的。您應該努力編寫能夠容忍級別較低但實用的隔離級別的事務,這樣,當稍后性能成為問題時,自己就不會陷入困境。因為您需要知道方法正在做什么以及這個方法中隱藏了什么一致性假設來正確設置隔離級別,那么在開發期間仔細說明并發需求和假設,以便在裝配應用程序時幫助作出正確的決定也不失為一個好主意。





          回頁首


          結束語

          本文中提供的許多指導可能看起來有點互相矛盾,因為象事務劃分和隔離這種問題本來就是此消彼長的。我們正在努力平衡安全性(如果我們不關心安全性,那就壓根不必用事務了)和我們用來提供安全限度的工具的性能開銷。正確的平衡要依賴許多因素,包括與系統故障或當機時間相關的代價或損害以及組織的風險承受能力。

          posted @ 2006-08-24 17:38 Binary 閱讀(207) | 評論 (0)編輯 收藏

          Java 理論與實踐: 在沒有數據庫的情況下進行數據庫查詢

          手里有錘子的時候,看什么東西都像釘子(就像古諺語所說的那樣)。但是如果沒有錘子時該怎樣辦呢?有時,您可以去借一把錘子。然后,拿著這把借來的錘子敲打虛擬的釘子,最后歸還錘子,沒人知道這些。在本月的 Java 理論與實踐 系列中,Brian Goetz 將演示如何將 SQL 或者 XQuery 這樣的數據操縱之錘應用于非持久存儲的數據。請在本文附帶的 討論論壇 中與作者和其他讀者分享您對本文的看法。(也可以單擊本文頂部或底部的 討論 來訪問該論壇。)

          我最近仔細考察了一個項目,該項目涉及相當多的 Web 快速搜索。當爬蟲程序爬過不同的 Web 站點時,它將建立一個數據庫,該數據庫中包括它所爬過的站點和網頁、每一頁所包含的鏈接、每一頁的分析結果等數據。最終結果是一組報告,詳細說明經過了哪些站點和頁面、哪些是一直鏈接的、哪些鏈接已經斷開、哪些頁面有錯誤、計算出的頁面規格,等等。開始的時候,沒人確切知道需要什么樣的報告,或者應當采用什么樣的格式 —— 只知道有一些內容要報告。這表明報告開發階段會是一個反復的階段,要經過多次反饋、修改,并且可能嘗試使用不同的結構。惟一確定的報告要求是,報告應當以 XML 形式展示,也可能以 HTML 形式展示。因此,開發和修改報告的過程必須是輕量級的,因為報告要求是“動態發現”的,而不是預先指定的。

          不需要數據庫

          對這個問題的“最顯而易見的”解決方法是將所有東西都放入 SQL 數據庫中 —— 頁面、鏈接、度量標準、HTTP 結果代碼、計時結果和其他元數據。這個問題可以借助關系表示來很好地解決,特別是因為這種方法不需要存儲已訪問頁面的內容,只需要存儲它們的結構和元數據。

          到目前為止,這個項目看起來像是一個典型的數據庫應用程序,并且它并不缺少可供選擇的持久性策略。但是,或許可以避免使用數據庫持久存儲數據的復雜性 —— 這個快速搜索工具(crawler)只訪問數萬個頁面。這個數字不是很大,因此可以將整個數據庫放在內存中,當需要持久存儲數據時,可以通過序列化來實現它。(是的,加載和保存操作要花費較長的時間,但是這些操作并不經常執行。)懶惰反而帶來了一個好處 —— 不需要處理持久性極大地縮短了開發應用程序的時間,因而顯著地減少了開發工作量。構建和操縱內存中的數據結構要比每次添加、提取或者分析數據時都使用數據庫容易得多。不管選擇了哪種持久存儲模型,都會限制任何觸及到數據的代碼的構造。

          內存中的數據結構是一種樹型結構,如清單 1 所示,它的根是快速搜索過的各個網站的主頁,因此 Visitor 模式是搜索這些主頁或者從中提取數據的理想模式。(構建一個防止陷入鏈接循環 —— A 鏈接到 B、B 鏈接到 C、C 鏈接到 A —— 的基本 Visitor 類并不是很難。)


          清單 1. Web 爬行器的一個簡化方案
          												
          																		
          public class Site {
              Page homepage;
              Collection<Page> pages;
              Collection<Link> links;
          }
          
          public class Page {
              String url;
              Site site;
              PageMetrics metrics;
          }
          
          public class Link {
              Page linkFrom;
              Page linkTo;
              String anchorText;
          }
          
          												
          										

          這個快速搜索工具的應用程序中有十多個 Visitor,它們所做的事情類似于選擇頁面做進一步分析、選擇不帶鏈接的頁面、列出“被鏈接最多”的頁面,等等。因為所有這些操作都很簡單,所以 Visitor 模式(如清單 2 所示)可以工作得很好,由于數據結構可以放到內存中,因此就算進行徹底搜索,花費也不是很大:


          清單 2. 用于 Web 快速搜索工具數據庫的 Visitor 模式
          												
          																		
          public interface Visitor {
              public void visitSite(Site site);
              public void visitLink(Link link);
          }
          
          												
          										

          噢,忘記報告了

          如果不運行報告的話,Visitor 策略在訪問數據方面會做得非常好。使用數據庫進行持久存儲的一個好處是:在生成報告時,SQL 的能力就會大放光彩 —— 幾乎可以讓數據庫做任何事情。甚至用 SQL 生成報告原型也很容易 —— 運行原型報告,如果結果不是所需要的結果,那么可以修改 SQL 查詢或者編寫新的查詢,然后再試一試。如果改變的只是 SQL 查詢的話,那么這個編輯-編譯-運行周期可能很快。如果 SQL 不是存儲在程序中,那么您甚至可以跳過這個周期的編譯部分,這樣可以快速生成報告的原型。確定所需要的報告后,將它們構建到應用程序中就很容易了。

          因此,雖然對于添加新結果、尋找特定的結果和進行特殊傳輸來說,內存中的數據結構都表現得很不錯,但是對于報告來說,這些變成了不利條件。對于所有其自身結構與數據庫結構不同的報告,Visitor 都必須創建一個全新的數據結構,以包含報告數據。因此,每一種報告類型都需要有自己的、特定于報告的中間數據結構來存放結果,還需要一個用來填充中間數據結構的訪問者,以及用來將中間數據結構轉換成最終報告的后處理(post-processing)代碼。似乎需要做很多工作,尤其在大多數原型報告將被拋棄時。例如,假定您想要列出所有從其他網站鏈接到某個給定網站的頁面的報告、所有外部頁面的列表報告,以及站點上鏈接該頁面的那些頁面的列表,然后,根據鏈接的數量對報告進行歸類,鏈接最多的頁面顯示在最前面。這個計劃基本上將數據結構從里到外翻了個個兒。為了用 Visitor 實現這種數據轉換,需要獲得從某個給定網站可以到達的外部頁面鏈接的列表,并根據被鏈接的頁面對它們進行分類,如清單 3 所示:


          清單 3. Visitor 列出被鏈接最多的頁面,以及鏈接到它們的頁面
          												
          																		
          public class InvertLinksVisitor {
              public Map<Page, Set<Page>> map = ...;
              
              public void visitLink(Link link) {
                  if (link.linkFrom.site.equals(targetSite) 
                      && !link.linkTo.site.equals(targetSite)) {
                      if (!map.containsKey(link.linkTo))
                          map.put(link.linkTo, new HashSet<Page>());
                      map.get(link.linkTo).add(link.linkFrom);
                  }
              }
          }
          
          												
          										

          清單 3 中的 Visitor 生成一個映射,將每一個外部頁面與鏈接它的一組內部頁面相關聯。為了準備該報告,還必須根據關聯頁面的大小對這些條目進行分類,然后創建報告。雖然沒有任何困難步驟,但是每一個報告需要的特定于報告的代碼數量卻很多,因此快速報告原型就成為一個重要的目標(因為沒有提出報告要求),試驗新報告的開銷比理想情況更高。許多報告需要多次傳遞數據,以便對數據進行選擇、匯總和分類。





          回頁首


          我的數據模型王國

          這時,缺少一個正式的數據模型開始成為一項不利因素,該數據模型可以用于描述收集的數據,并且可以用它更容易地表示選擇和聚合查詢。也許懶惰不像開始希望的那樣有效。但是,雖然這個應用程序缺少正式數據模型,但也許我們可以將數據存儲到內存中的數據庫,并憑借該數據庫進行查詢,通過這種方式借用一個數據模型。有兩種可能會立即出現在您的腦海中:開源的內存中的 SQL 數據庫 HSQLDB 和 XQuery。我不需要數據庫提供的持久性,但是我確實需要查詢語言。

          HSQLDB 是一個用 Java 語言編寫的可嵌入的數據庫引擎。它既包含適用于內存中表的表類型,又包含適用于基于磁盤的表的表類型,設計該引擎為了將表完全嵌入到應用程序中,消除與大多數真實數據庫相關的管理開銷。要將數據裝載到 HSQLDB,只需編寫一個 Visitor 即可,該 Visitor 將遍歷內存中的數據結構,并為每一個將要存儲的實體生成相應的 INSERT 語句。然后可以對這個內存中的數據庫表執行 SQL 查詢,以生成報告,并在完成這些操作后拋棄這個“數據庫”。

          噢,忘記了關系數據庫有多煩人

          HSQLDB 方法是一個可行方法,但您很快就發現,我必須為對象關系的不匹配而兩次(而不是一次)受罰 —— 一次是在將樹型結構數據庫轉換為關系數據模型時,一次是在將平面關系查詢結果轉換成結構化的 XML 或者 HTML 結果集時。此外,將 JDBC ResultSet 后處理為 DOM 表示形式的 XML 或者 HTML 文檔也不是一項很容易的任務,需要為每一個報告提供一些定制的編碼。因此雖然內存中的 SQL 數據庫 的確 可以簡化查詢,但是從數據庫中存入和取出數據所需要的額外代碼會抵消所有節省的代碼。





          回頁首


          讓 XQuery 來拯救您

          另一個容易得到的數據查詢方法是 XQuery。XQuery 的優點是,它是為生成 XML 或者 HTML 文檔作為查詢結果而設計的,因此不需要對查詢結果進行后處理。這種想法很有吸引力 —— 每個報告只有一層編碼,而不是兩層或者更多層。因此第一項任務是構建一個表示整個數據集的 XML 文檔。設計一個簡單的 XML 數據模型和編寫遍歷數據結構,并將每一個元素附加到一個 DOM 文檔中的 Visitor 很簡單。(不需要寫出這個文檔。可以將它保持在內存中,用于查詢,然后在完成查詢時丟棄它。當底層數據改變時,可以重新生成它。)之后,所有要做的就是編寫 XQuery 查詢,該查詢將選擇并聚集用于報告的數據,并按最終需要的格式(XML 或 HTML)對它們進行格式化。查詢可以存儲在單獨的文件中,以便進行快速原型制造,因此,可支持多種報告格式。使用 Saxon 評估查詢的代碼如清單 4 中所示:


          清單 4. 執行 XQuery 查詢并將結果序列化為 XML 或 HTML 文檔的代碼
          												
          																		
            String query = readFile(queryFile + ".xq");
            Configuration c = new Configuration();
            StaticQueryContext qp = new StaticQueryContext(c);
            XQueryExpression xe = qp.compileQuery(query);
            DynamicQueryContext dqc = new DynamicQueryContext(c);
            dqc.setContextNode(new DocumentWrapper(document, z.getName(), c));
            List result = xe.evaluate(dqc);
          
            FileOutputStream os = new FileOutputStream(fileName);
            XMLSerializer serializer = new XMLSerializer (os, format);
            serializer.asDOMSerializer();
          
            for(Iterator i = result.iterator(); i.hasNext(); ) {
                Object o = i.next();
                if (o instanceof Element)
                    serializer.serialize((Element) o);
                else if (o instanceof Attr) {
                    Element e = document.createElement("scalar");
                    e.setTextContent(((Attr) o).getNodeValue());
                    serializer.serialize(e);
                }
                else {
                    Element e = document.createElement("scalar");
                    e.setTextContent(o.toString());
                    serializer.serialize(e);
                }
            }
            os.close(); 
          
          												
          										

          表示數據庫的 XML 文檔的結構與內存中的數據結構稍有不同,每一個 <site> 元素都有嵌套的 <page> 元素,每一個 <page> 元素都有嵌套的 <link> 元素,而每一個 <link> 元素都有 <link-to> 和 <link-from> 元素。實踐證明,這種表示方法對于大多數報告都很方便。

          清單 5 顯示了一個示例 XQuery 報告,這個報告處理鏈接的選擇、分類和表示。它有幾個地方優于 Visitor 方法 —— 不僅代碼少(因為查詢語言支持選擇、聚積和分類),而且所有報告的代碼 —— 選擇、聚積、分類和表示 —— 都在一個位置上。


          清單 5.生成鏈接次數最多的頁面的完整報告的 XQuery 代碼
          												
          																		
          <html>
          <head><title>被鏈接最多的頁面</title></head>
          <body>
          <ul>
          {
            let $links := //link[link-to/@siteUrl ne $targetSite
                                 and link-from/@siteUrl eq $targetSite]
            for $page in distinct-values($links/link-to/@url)
            let $linkingPages := $links[link-to/@url eq $page]/link-from/@url
            order by count($linkingPages)
            return 
              <li>Page {$page}, {count($linkingPages)} links 
              <ul> {
                for $p in $linkingPages return <li>Linked from {$p/@url}</li>
              }
              </ul></li>
          }
          </ul> </body> </html>
          
          												
          										





          回頁首


          結束語

          從開發成本角度看,XQuery 方法已證實可以節約大量成本。樹型結構對于構建和搜索數據很理想,但對于報告,就不是很理想了。XML 方法很適合于報告(因為可以利用 XQuery 的能力),但是對于整個應用程序的實現,該方法還有很多不便,并會降低性能。因為數據集的大小是可管理的 —— 只有幾十兆字節,所以可以將數據從一種格式轉換為從開發的角度看最方便的另一種格式。更大的數據集,比如不能完全存儲到內存中的數據集,會要求整個應用程序都圍繞著一個數據庫構建。雖然有許多處理數據持久性的好工具,但是它們需要的工作都比簡單操縱內存中數據結構要多得多。如果數據集的大小合適,那么就可以同時利用這兩種方法的長處。

          posted @ 2006-08-24 17:36 Binary 閱讀(320) | 評論 (0)編輯 收藏

          Java 理論與實踐: 動態編譯與性能測量

          為動態編譯的語言(例如 Java)編寫和解釋性能評測,要比為靜態編譯的語言(例如 C 或 C++)編寫困難得多。在這期的 Java 理論與實踐 中,Brian Goetz 介紹了動態編譯使性能測試復雜的諸多原因中的一些。請在本文附帶的討論組上與作者和其他讀者分享您對本文的看法。 (您也可以選擇本文頂部或底部的 討論 訪問論壇。)

          這個月,我著手撰寫一篇文章,分析一個寫得很糟糕的微評測。畢竟,我們的程序員一直受性能困擾,我們也都想了解我們編寫、使用或批評的代碼的性能特征。當我偶然間寫到性能這個主題時,我經常得到這樣的電子郵件:“我寫的這個程序顯示,動態 frosternation 要比靜態 blestification 快,與您上一篇的觀點相反!”許多隨這類電子郵件而來的所謂“評測“程序,或者它們運行的方式,明顯表現出他們對于 JVM 執行字節碼的實際方式缺乏基本認識。所以,在我著手撰寫這樣一篇文章(將在未來的專欄中發表)之前,我們先來看看 JVM 幕后的東西。理解動態編譯和優化,是理解如何區分微評測好壞的關鍵(不幸的是,好的微評測很少)。

          動態編譯簡史

          Java 應用程序的編譯過程與靜態編譯語言(例如 C 或 C++)不同。靜態編譯器直接把源代碼轉換成可以直接在目標平臺上執行的機器代碼,不同的硬件平臺要求不同的編譯器。 Java 編譯器把 Java 源代碼轉換成可移植的 JVM 字節碼,所謂字節碼指的是 JVM 的“虛擬機器指令”。與靜態編譯器不同,javac 幾乎不做什么優化 —— 在靜態編譯語言中應當由編譯器進行的優化工作,在 Java 中是在程序執行的時候,由運行時執行。

          第一代 JVM 完全是解釋的。JVM 解釋字節碼,而不是把字節碼編譯成機器碼并直接執行機器碼。當然,這種技術不會提供最好的性能,因為系統在執行解釋器上花費的時間,比在需要運行的程序上花費的時間還要多。

          即時編譯

          對于證實概念的實現來說,解釋是合適的,但是早期的 JVM 由于太慢,迅速獲得了一個壞名聲。下一代 JVM 使用即時 (JIT) 編譯器來提高執行速度。按照嚴格的定義,基于 JIT 的虛擬機在執行之前,把所有字節碼轉換成機器碼,但是以惰性方式來做這項工作:JIT 只有在確定某個代碼路徑將要執行的時候,才編譯這個代碼路徑(因此有了名稱“ 即時 編譯”)。這個技術使程序能啟動得更快,因為在開始執行之前,不需要冗長的編譯階段。

          JIT 技術看起來很有前途,但是它有一些不足。JIT 消除了解釋的負擔(以額外的啟動成本為代價),但是由于若干原因,代碼的優化等級仍然是一般般。為了避免 Java 應用程序嚴重的啟動延遲,JIT 編譯器必須非常迅速,這意味著它無法把大量時間花在優化上。所以,早期的 JIT 編譯器在進行內聯假設(inlining assumption)方面比較保守,因為它們不知道后面可能要裝入哪個類。

          雖然從技術上講,基于 JIT 的虛擬機在執行字節碼之前,要先編譯字節碼,但是 JIT 這個術語通常被用來表示任何把字節碼轉換成機器碼的動態編譯過程 —— 即使那些能夠解釋字節碼的過程也算。

          HotSpot 動態編譯

          HotSpot 執行過程組合了編譯、性能分析以及動態編譯。它沒有把所有要執行的字節碼轉換成機器碼,而是先以解釋器的方式運行,只編譯“熱門”代碼 —— 執行得最頻繁的代碼。當 HotSpot 執行時,會搜集性能分析數據,用來決定哪個代碼段執行得足夠頻繁,值得編譯。只編譯執行最頻繁的代碼有幾項性能優勢:沒有把時間浪費在編譯那些不經常執行的代碼上;這樣,編譯器就可以花更多時間來優化熱門代碼路徑,因為它知道在這上面花的時間物有所值。而且,通過延遲編譯,編譯器可以訪問性能分析數據,并用這些數據來改進優化決策,例如是否需要內聯某個方法調用。

          為了讓事情變得更復雜,HotSpot 提供了兩個編譯器:客戶機編譯器和服務器編譯器。默認采用客戶機編譯器;在啟動 JVM 時,您可以指定 -server 開關,選擇服務器編譯器。服務器編譯器針對最大峰值操作速度進行了優化,適用于需要長期運行的服務器應用程序。客戶機編譯器的優化目標,是減少應用程序的啟動時間和內存消耗,優化的復雜程度遠遠低于服務器編譯器,因此需要的編譯時間也更少。

          HotSpot 服務器編譯器能夠執行各種樣的類。它能夠執行許多靜態編譯器中常見的標準優化,例如代碼提升( hoisting)、公共的子表達式清除、循環展開(unrolling)、范圍檢測清除、死代碼清除、數據流分析,還有各種在靜態編譯語言中不實用的優化技術,例如虛方法調用的聚合內聯。

          持續重新編譯

          HotSpot 技術另一個有趣的方面是:編譯不是一個全有或者全無(all-or-nothing)的命題。在解釋代碼路徑一定次數之后,會把它重新編譯成機器碼。但是 JVM 會繼續進行性能分析,而且如果認為代碼路徑特別熱門,或者未來的性能分析數據認為存在額外的優化可能,那么還有可能用更高一級的優化重新編譯代碼。JVM 在一個應用程序的執行過程中,可能會把相同的字節碼重新編譯許多次。為了深入了解編譯器做了什么,請用 -XX:+PrintCompilation 標志調用 JVM,這個標志會使編譯器(客戶機或服務器)每次運行的時候打印一條短消息。

          棧上(On-stack)替換

          HotSpot 開始的版本編譯的時候每次編譯一個方法。如果某個方法的累計執行次數超過指定的循環迭代次數(在 HotSpot 的第一版中,是 10,000 次),那么這個方法就被當作熱門方法,計算的方式是:為每個方法關聯一個計數器,每次執行一個后向分支時,就會遞增計數器一次。但是,在方法編譯之后,方法調用并沒有切換到編譯的版本,需要退出并重新進入方法,后續調用才會使用編譯的版本。結果就是,在某些情況下,可能永遠不會用到編譯的版本,例如對于計算密集型程序,在這類程序中所有的計算都是在方法的一次調用中完成的。重量級方法可能被編譯,但是編譯的代碼永遠用不到。

          HotSpot 最近的版本采用了稱為 棧上(on-stack)替換 (OSR) 的技術,支持在循環過程中間,從解釋執行切換到編譯的代碼(或者從編譯代碼的一個版本切換到另一個版本)。





          回頁首


          那么,這與評測有什么關系?

          我向您許諾了一篇關于評測和性能測量的文章,但是迄今為止,您得到的只是歷史的教訓和 Sun 的 HotSpot 白皮書的老調重談。繞這么大的圈子的原因是,如果不理解動態編譯的過程,就不可能正確地編寫或解釋 Java 類的性能測試。(即使深入理解動態編譯和 JVM 優化,也仍然是非常困難的。)

          為 Java 代碼編寫微評測遠比為 C 代碼編寫難得多

          判斷方法 A 是否比方法 B 更快的傳統方法,是編寫小的評測程序,通常叫做 微評測。這個趨勢非常有意義。科學的方法不能缺少獨立的調查。魔鬼總在細節之中。為動態編譯的語言編寫并解釋評測,遠比為靜態編譯的語言難得多。為了了解某個結構的性能,編寫一個使用該結構的程序一點也沒有錯,但是在許多情況下,用 Java 編寫的微評測告訴您的,往往與您所認為的不一樣。

          使用 C 程序時,您甚至不用運行它,就能了解許多程序可能的性能特征。只要看看編譯出的機器碼就可以了。編譯器生成的指令就是將要執行的機器碼,一般情況下,可以很合理地理解它們的時間特征。(有許多有毛病的例子,因為總是遺漏分支預測或緩存,所以性能差的程度遠遠超過查看機器碼所能夠想像的程度,但是大多數情況下,您都可以通過查看機器碼了解 C 程序的性能的很多方面。)

          如果編譯器認為某段代碼不恰當,準備把它優化掉(通常的情況是,評測到它實際上不做任何事情),那么您在生成的機器碼中可以看到這個優化 —— 代碼不在那兒了。通常,對于 C 代碼,您不必執行很長時間,就可以對它的性能做出合理的推斷。

          而在另一方面,HotSpot JIT 在程序運行時會持續地把 Java 字節碼重新編譯成機器碼,而重新編譯觸發的次數無法預期,觸發重新編譯的依據是性能分析數據積累到一定數量、裝入新類,或者執行到的代碼路徑的類已經裝入,但是還沒有執行過。持續的重新編譯情況下的時間測量會非常混亂、讓人誤解,而且要想獲得有用的性能數據,通常必須讓 Java 代碼運行相當長的時間(我曾經看到過一些怪事,在程序啟動運行之后要加速幾個小時甚至數天),才能獲得有用的性能數據。





          回頁首


          清除死代碼

          編寫好評測的一個挑戰就是,優化編譯器要擅長找出死代碼 —— 對于程序執行的輸出沒有作用的代碼。但是評測程序一般不產生任何輸出,這就意味著有一些,或者全部代碼都有可能被優化掉,而毫無知覺,這時您實際測量的執行要少于您設想的數量。具體來說,許多微評測在用 -server 方式運行時,要比用 -client 方式運行時好得多,這不是因為服務器編譯器更快(雖然服務器編譯器一般更快),而是因為服務器編譯器更擅長優化掉死代碼。不幸的是,能夠讓您的評測工作非常短(可能會把評測完全優化掉)的死代碼優化,在處理實際做些工作的代碼時,做得就不會那么好了。

          奇怪的結果

          清單 1 的評測包含一個什么也不做的代碼塊,它是從一個測試并發線程性能的評測中摘出來的,但是它實際測量的根本不是要評測的東西。(這個示例是從 JavaOne 2003 的演示 “The Black Art of Benchmarking” 中借用的。請參閱 參考資料。)


          清單 1. 被意料之外的死代碼弄亂的評測
          												
          																		
                  
          public class StupidThreadTest {
              public static void doSomeStuff() {
                  double uselessSum = 0;
                  for (int i=0; i<1000; i++) {
                      for (int j=0;j<1000; j++) {
                          uselessSum += (double) i + (double) j;
                      }
                  }
              }
          
              public static void main(String[] args) throws InterruptedException {
                  doSomeStuff();
                  
                  int nThreads = Integer.parseInt(args[0]);
                  Thread[] threads = new Thread[nThreads];
                  for (int i=0; i<nThreads; i++)
                      threads[i] = new Thread(new Runnable() {
                          public void run() { doSomeStuff(); }
                      });
                  long start = System.currentTimeMillis();
                  for (int i = 0; i < threads.length; i++)
                      threads[i].start();
                  for (int i = 0; i < threads.length; i++)
                      threads[i].join();
                  long end = System.currentTimeMillis();
                  System.out.println("Time: " + (end-start) + "ms");
              }
          }
          
                
          												
          										

          表面上看, doSomeStuff() 方法可以給線程分點事做,所以我們能夠從 StupidThreadBenchmark 的運行時間推導出多線程調度開支的一些情況。但是,因為 uselessSum 從沒被用過,所以編譯器能夠判斷出 doSomeStuff 中的全部代碼是死的,然后把它們全部優化掉。一旦循環中的代碼消失,循環也就消失了,只留下一個空空如也的 doSomeStuff。表 1 顯示了使用客戶機和服務器方式執行 StupidThreadBenchmark 的性能。兩個 JVM 運行大量線程的時候,都表現出差不多是線性的運行時間,這個結果很容易被誤解為服務器 JVM 比客戶機 JVM 快 40 倍。而實際上,是服務器編譯器做了更多優化,發現整個 doSomeStuff 是死代碼。雖然確實有許多程序在服務器 JVM 上會提速,但是您在這里看到的提速僅僅代表一個寫得糟糕的評測,而不能成為服務器 JVM 性能的證明。但是如果您沒有細看,就很容易會把兩者混淆。


          表 1. 在客戶機和服務器 JVM 中 StupidThreadBenchmark 的性能
          線程數量 客戶機 JVM 運行時間 服務器 JVM 運行時間
          10 43 2
          100 435 10
          1000 4142 80
          10000 42402 1060

          對于評測靜態編譯語言來說,處理過于積極的死代碼清除也是一個問題。但是,在靜態編譯語言中,能夠更容易地發現編譯器清除了大塊評測。您可以查看生成的機器碼,查看是否漏了某塊程序。而對于動態編譯語言,這些信息不太容易訪問得到。





          回頁首


          預熱

          如果您想測量 X 的性能,一般情況下您是想測量它編譯后的性能,而不是它的解釋性能(您想知道 X 在賽場上能跑多快)。要做到這樣,需要“預熱” JVM —— 即讓目標操作執行足夠的時間,這樣編譯器在為執行計時之前,就有足夠的運行解釋的代碼,并用編譯的代碼替換解釋代碼。

          使用早期 JIT 和沒有棧上替換的動態編譯器,有一個容易的公式可以測量方法編譯后的性能:運行多次調用,啟動計時器,然后執行若干次方法。如果預熱調用超過方法被編譯的閾值,那么實際計時的調用就有可能全部是編譯代碼執行的時間,所有的編譯開支應當在開始計時之前發生。

          而使用今天的動態編譯器,事情更困難。編譯器運行的次數很難預測,JVM 按照自己的想法從解釋代碼切換到編譯代碼,而且在運行期間,相同的代碼路徑可能編譯、重新編譯不止一次。如果您不處理這些事件的計時問題,那么它們會嚴重歪曲您的計時結果。

          圖 1 顯示了由于預計不到的動態編譯而造成的可能的計時歪曲。假設您正在通過循環計時 200,000 次迭代,編譯代碼比解釋代碼快 10 倍。如果編譯只在 200,000 次迭代時才發生,那么您測量的只是解釋代碼的性能(時間線(a))。如果編譯在 100,000 次迭代時發生,那么您總共的運行時間是運行 200,000 次解釋迭代的時間,加上編譯時間(編譯時間非您所愿),加上執行 100,000 次編譯迭代的時間(時間線(b))。如果編譯在 20,000 次迭代時發生,那么總時間會是 20,000 次解釋迭代,加上編譯時間,再加上 180,000 次編譯迭代(時間線(c))。因為您不知道編譯器什么時候執行,也不知道要執行多長時間,所以您可以看到,您的測量可能受到嚴重的歪曲。根據編譯時間和編譯代碼比解釋代碼快的程度,即使對迭代數量只做很小的變化,也可能造成測量的“性能”有極大差異。


          圖 1. 因為動態編譯計時造成的性能測量歪曲
          時間線圖

          那么,到底多少預熱才足夠呢?您不知道。您能做到的最好的,就是用 -XX:+PrintCompilation 開關來運行評測,觀察什么造成編譯器工作,然后改變評測程序的結構,以確保編譯在您啟動計時之前發生,在計時循環過程中不會再發生編譯。

          不要忘記垃圾收集

          那么,您已經看到,如果您想得到正確的計時結果,就必須要讓被測代碼比您想像的多運行幾次,以便讓 JVM 預熱。另一方面,如果測試代碼要進行對象分配工作(差不多所有的代碼都要這樣),那么垃圾收集器也肯定會運行。這是會嚴重歪曲計時結果的另一個因素 —— 即使對迭代數量只做很小的變化,也意味著沒有垃圾收集和有垃圾收集之間的區別,就會偏離“每迭代時間”的測量。

          如果用 -verbose:gc 開關運行評測,您可以看到在垃圾收集上耗費了多少時間,并相應地調整您的計時數據。更好一些的話,您可以長時間運行您的程序,這可以保證觸發許多垃圾收集,從而更精確地分攤垃圾收集的成本。





          回頁首


          動態反優化(deoptimization)

          許多標準的優化只能在“基本塊”內執行,所以內聯方法調用對于達到好的優化通常很重要。通過內聯方法調用,不僅方法調用的開支被清除,而且給優化器提供了更大的優化塊可以優化,會帶來相當大的死代碼優化機會。

          清單 2 顯示了一個通過內聯實現的這類優化的示例。 outer() 方法用參數 null 調用 inner(),結果是 inner() 什么也不做。但是通過把 inner() 的調用內聯,編譯器可以發現 inner()else 分支是死的,因此能夠把測試和 else 分支優化掉,在某種程度上,它甚至能把整個對 inner() 的調用全優化掉。如果 inner() 沒有被內聯,那么這個優化是不可能發生的。


          清單 2. 內聯如何帶來更好的死代碼優化
          												
          																		
                  
          public class Inline {
            public final void inner(String s) {
              if (s == null)
                return;
              else {
                // do something really complicated
              }
            }
          
            public void outer() {
              String s=null; 
              inner(s);
            }
          }
          
                
          												
          										

          但是不方便的是,虛方法對內聯造成了障礙,而虛函數調用在 Java 中要比在 C++ 中普遍。假設編譯器正試圖優化以下代碼中對 doSomething() 的調用:

          												
          														  Foo foo = getFoo();
            foo.doSomething(); 
          
          												
          										

          從這個代碼片斷中,編譯器沒有必要分清要執行哪個版本的 doSomething() —— 是在類 Foo 中實現的版本,還是在 Foo 的子類中實現的版本?只在少數情況下答案才明顯 —— 例如 Foofinal 的,或者 doSomething()Foo 中被定義為 final 方法 —— 但是在多數情況下,編譯器不得不猜測。對于每次只編譯一個類的靜態編譯器,我們很幸運。但是動態編譯器可以使用全局信息進行更好的決策。假設有一個還沒有裝入的類,它擴展了應用程序中的 Foo。現在的情景更像是 doSomething()Foo 中的 final 方法 —— 編譯器可以把虛方法調用轉換成一個直接分配(已經是個改進了),而且,還可以內聯 doSomething()。(把虛方法調用轉換成直接方法調用,叫做 單形(monomorphic)調用變換。)

          請稍等 —— 類可以動態裝入。如果編譯器進行了這樣的優化,然后裝入了一個擴展了 Foo 的類,會發生什么?更糟的是,如果這是在工廠方法 getFoo() 內進行的會怎么樣? getFoo() 會返回新的 Foo 子類的實例?那么,生成的代碼不就無效了么?對,是無效了。但是 JVM 能指出這個錯誤,并根據目前無效的假設,取消生成的代碼,并恢復解釋(或者重新編譯不正確的代碼路徑)。

          結果就是,編譯器要進行主動的內聯決策,才能得到更高的性能,然后當這些決策依據的假設不再有效時,就會收回這些決策。實際上,這個優化如此有效,以致于給那些不被覆蓋的方法添加 final 關鍵字(一種性能技巧,在以前的文章中建議過)對于提高實際性能沒有太大作用。

          奇怪的結果

          清單 3 中包含一個代碼模式,其中組合了不恰當的預熱、單形調用變換以及反優化,因此生成的結果毫無意義,而且容易被誤解:


          清單 3. 測試程序的結果被單形調用變換和后續的反優化歪曲
          												
          																		
                  
          public class StupidMathTest {
              public interface Operator {
                  public double operate(double d);
              }
          
              public static class SimpleAdder implements Operator {
                  public double operate(double d) {
                      return d + 1.0;
                  }
              }
          
              public static class DoubleAdder implements Operator {
                  public double operate(double d) {
                      return d + 0.5 + 0.5;
                  }
              }
          
              public static class RoundaboutAdder implements Operator {
                  public double operate(double d) {
                      return d + 2.0 - 1.0;
                  }
              }
          
              public static void runABunch(Operator op) {
                  long start = System.currentTimeMillis();
                  double d = 0.0;
                  for (int i = 0; i < 5000000; i++)
                      d = op.operate(d);
                  long end = System.currentTimeMillis();
                  System.out.println("Time: " + (end-start) + "   ignore:" + d);
              }
          
              public static void main(String[] args) {
                  Operator ra = new RoundaboutAdder();
                  runABunch(ra); // misguided warmup attempt
                  runABunch(ra);
                  Operator sa = new SimpleAdder();
                  Operator da = new DoubleAdder();
                  runABunch(sa);
                  runABunch(da);
              }
          }
          
                
          												
          										

          StupidMathTest 首先試圖做些預熱(沒有成功),然后測量 SimpleAdderDoubleAdderRoundaboutAdder 的運行時間,結果如表 2 所示。看起來好像先加 1,再加 2 ,然后再減 1 最快。加兩次 0.5 比加 1 還快。這有可能么?(答案是:不可能。)


          表 2. StupidMathTest 毫無意義且令人誤解的結果
          方法 運行時間
          SimpleAdder 88ms
          DoubleAdder 76ms
          RoundaboutAdder 14ms

          這里發生什么呢?在預熱循環之后, RoundaboutAdderrunABunch() 確實已經被編譯了,而且編譯器 OperatorRoundaboutAdder 上進行了單形調用轉換,第一輪運行得非常快。而在第二輪( SimpleAdder)中,編譯器不得不反優化,又退回虛函數分配之中,所以第二輪的執行表現得更慢,因為不能把虛函數調用優化掉,把時間花在了重新編譯上。在第三輪( DoubleAdder)中,重新編譯比第二輪少,所以運行得就更快。(在現實中,編譯器會在 RoundaboutAdderDoubleAdder 上進行常數替換(constant folding),生成與 SimpleAdder 幾乎相同的代碼。所以如果在運行時間上有差異,那么不是因為算術代碼)。哪個代碼首先執行,哪個代碼就會最快。

          那么,從這個“評測”中,我們能得出什么結論呢?實際上,除了評測動態編譯語言要比您可能想到的要微妙得多之外,什么也沒得到。





          回頁首


          結束語

          這個示例中的結果錯得如此明顯,所以很清楚,肯定發生了什么,但是更小的結果能夠很容易地歪曲您的性能測試程序的結果,卻不會觸發您的“這里肯定有什么東西有問題”的警惕。雖然本文列出的這些內容是微評測歪曲的一般來源,但是還有許多其他來源。本文的中心思想是:您正在測量的,通常不是您以為您正在測量的。實際上,您通常所測量的,不是您以為您正在測量的。對于那些沒有包含什么實際的程序負荷,測試時間不夠長的性能測試的結果,一定要非常當心。

          posted @ 2006-08-24 17:36 Binary 閱讀(235) | 評論 (0)編輯 收藏

          Java 理論與實踐: 用動態代理進行修飾

          動態代理工具java.lang.reflect 包的一部分,在 JDK 1.3 版本中添加到 JDK,它允許程序創建 代理對象,代理對象能實現一個或多個已知接口,并用反射代替內置的虛方法分派,編程地分派對接口方法的調用。這個過程允許實現“截取”方法調用,重新路由它們或者動態地添加功能。本期文章中,Brian Goetz 介紹了幾個用于動態代理的應用程序。請在本文伴隨的 討論論壇 上與作者和其他讀者分享您對這篇文章的想法。(也可以單擊文章頂部或底部的 討論 訪問討論論壇。)

          動態代理為實現許多常見設計模式(包括 Facade、Bridge、Interceptor、Decorator、Proxy(包括遠程和虛擬代理)和 Adapter 模式)提供了替代的動態機制。雖然這些模式不使用動態代理,只用普通的類就能夠實現,但是在許多情況下,動態代理方式更方便、更緊湊,可以清除許多手寫或生成的類。

          Proxy 模式

          Proxy 模式中要創建“stub”或“surrogate”對象,它們的目的是接受請求并把請求轉發到實際執行工作的其他對象。遠程方法調用(RMI)利用 Proxy 模式,使得在其他 JVM 中執行的對象就像本地對象一樣;企業 JavaBeans (EJB)利用 Proxy 模式添加遠程調用、安全性和事務分界;而 JAX-RPC Web 服務則用 Proxy 模式讓遠程服務表現得像本地對象一樣。在每一種情況中,潛在的遠程對象的行為是由接口定義的,而接口本質上接受多種實現。調用者(在大多數情況下)不能區分出它們只是持有一個對 stub 而不是實際對象的引用,因為二者實現了相同的接口;stub 的工作是查找實際的對象、封送參數、把參數發送給實際對象、解除封送返回值、把返回值返回給調用者。代理可以用來提供遠程控制(就像在 RMI、EJB 和 JAX-RPC 中那樣),用安全性策略包裝對象(EJB)、為昂貴的對象(EJB 實體 Bean)提供惰性裝入,或者添加檢測工具(例如日志記錄)。

          在 5.0 以前的 JDK 中,RMI stub(以及它對等的 skeleton)是在編譯時由 RMI 編譯器(rmic)生成的類,RMI 編譯器是 JDK 工具集的一部分。對于每個遠程接口,都會生成一個 stub(代理)類,它代表遠程對象,還生成一個 skeleton 對象,它在遠程 JVM 中做與 stub 相反的工作 —— 解除封送參數并調用實際的對象。類似地,用于 Web 服務的 JAX-RPC 工具也為遠程 Web 服務生成代理類,從而使遠程 Web 服務看起來就像本地對象一樣。

          不管 stub 類是以源代碼還是以字節碼生成的,代碼生成仍然會向編譯過程添加一些額外步驟,而且因為命名相似的類的泛濫,會帶來意義模糊的可能性。另一方面,動態代理機制支持在編譯時沒有生成 stub 類的情況下,在運行時創建代理對象。在 JDK 5.0 及以后版本中,RMI 工具使用動態代理代替了生成的 stub,結果 RMI 變得更容易使用。許多 J2EE 容器也使用動態代理來實現 EJB。EJB 技術嚴重地依靠使用攔截(interception)來實現安全性和事務分界;動態代理為接口上調用的所有方法提供了集中的控制流程路徑。





          回頁首


          動態代理機制

          動態代理機制的核心是 InvocationHandler 接口,如清單 1 所示。調用句柄的工作是代表動態代理實際執行所請求的方法調用。傳遞給調用句柄一個 Method 對象(從 java.lang.reflect 包),參數列表則傳遞給方法;在最簡單的情況下,可能僅僅是調用反射性的方法 Method.invoke() 并返回結果。


          清單 1. InvocationHandler 接口
          												
          																		
          public interface InvocationHandler {
              Object invoke(Object proxy, Method method, Object[] args)
                  throws Throwable;
          }
          
          												
          										

          每個代理都有一個與之關聯的調用句柄,只要代理的方法被調用時就會調用該句柄。根據通用的設計原則:接口定義類型、類定義實現,代理對象可以實現一個或多個接口,但是不能實現類。因為代理類沒有可以訪問的名稱,它們不能有構造函數,所以它們必須由工廠創建。清單 2 顯示了動態代理的最簡單的可能實現,它實現 Set 接口并把所有 Set 方法(以及所有 Object 方法)分派給封裝的 Set 實例。


          清單 2. 包裝 Set 的簡單的動態代理
          												
          																		
          public class SetProxyFactory {
          
              public static Set getSetProxy(final Set s) {
                  return (Set) Proxy.newProxyInstance
                    (s.getClass().getClassLoader(),
                          new Class[] { Set.class },
                          new InvocationHandler() {
                              public Object invoke(Object proxy, Method method, 
                                Object[] args) throws Throwable {
                                  return method.invoke(s, args);
                              }
                          });
              }
          }
          
          												
          										

          SetProxyFactory 類包含一個靜態工廠方法 getSetProxy(),它返回一個實現了 Set 的動態代理。代理對象實際實現 Set —— 調用者無法區分(除非通過反射)返回的對象是動態代理。SetProxyFactory 返回的代理只做一件事,把方法分派給傳遞給工廠方法的 Set 實例。雖然反射代碼通常比較難讀,但是這里的內容很少,跟上控制流程并不難 —— 只要某個方法在 Set 代理上被調用,它就被分派給調用句柄,調用句柄只是反射地調用底層包裝的對象上的目標方法。當然,絕對什么都不做的代理可能有點傻,是不是呢?

          什么都不做的適配器

          對于像 SetProxyFactory 這樣什么都不做的包裝器來說,實際有個很好的應用 —— 可以用它安全地把對象引用的范圍縮小到特定接口(或接口集)上,方式是,調用者不能提升引用的類型,使得可以更安全地把對象引用傳遞給不受信任的代碼(例如插件或回調)。清單 3 包含一組類定義,實現了典型的回調場景。從中會看到動態代理可以更方便地替代通常用手工(或用 IDE 提供的代碼生成向導)實現的 Adapter 模式。


          清單 3. 典型的回調場景
          												
          																		
          public interface ServiceCallback {
              public void doCallback();
          }
          
          public interface Service {
              public void serviceMethod(ServiceCallback callback);
          }
          
          public class ServiceConsumer implements ServiceCallback {
              private Service service;
          
              ...
              public void someMethod() {
                  ...
                  service.serviceMethod(this);
              }
          }
          
          												
          										

          ServiceConsumer 類實現了 ServiceCallback(這通常是支持回調的一個方便途徑)并把 this 引用傳遞給 serviceMethod() 作為回調引用。這種方法的問題是沒有機制可以阻止 Service 實現把 ServiceCallback 提升為 ServiceConsumer,并調用 ServiceConsumer 不希望 Service 調用的方法。有時對這個風險并不關心 —— 但有時卻關心。如果關心,那么可以把回調對象作為內部類,或者編寫一個什么都不做的適配器類(請參閱清單 4 中的 ServiceCallbackAdapter)并用 ServiceCallbackAdapter 包裝 ServiceConsumerServiceCallbackAdapter 防止 ServiceServiceCallback 提升為 ServiceConsumer


          清單 4. 用于安全地把對象限制在一個接口上以便不被惡意代碼不能的適配器類
          												
          																		
          public class ServiceCallbackAdapter implements ServiceCallback {
              private final ServiceCallback cb;
          
              public ServiceCallbackAdapter(ServiceCallback cb) {
                  this.cb = cb;
              }
          
              public void doCallback() {
                  cb.doCallback();
              }
          }
          
          												
          										

          編寫 ServiceCallbackAdapter 這樣的適配器類簡單卻乏味。必須為包裝的接口中的每個方法編寫重定向類。在 ServiceCallback 的示例中,只有一個需要實現的方法,但是某些接口,例如 Collections 或 JDBC 接口,則包含許多方法。現代的 IDE 提供了“Delegate Methods”向導,降低了編寫適配器類的工作量,但是仍然必須為每個想要包裝的接口編寫一個適配器類,而且對于只包含生成的代碼的類,也有一些讓人不滿意的地方。看起來應當有一種方式可以更緊湊地表示“什么也不做的限制適配器模式”。

          通用適配器類

          清單 2 中的 SetProxyFactory 類當然比用于 Set 的等價的適配器類更緊湊,但是它仍然只適用于一個接口:Set。但是通過使用泛型,可以容易地創建通用的代理工廠,由它為任何接口做同樣的工作,如清單 5 所示。它幾乎與 SetProxyFactory 相同,但是可以適用于任何接口。現在再也不用編寫限制適配器類了!如果想創建代理對象安全地把對象限制在接口 T,只要調用 getProxy(T.class,object) 就可以了,不需要一堆適配器類的額外累贅。


          清單 5. 通用的限制適配器工廠類
          												
          																		
          public class GenericProxyFactory {
          
              public static<T> T getProxy(Class<T> intf, 
                final T obj) {
                  return (T) 
                    Proxy.newProxyInstance(obj.getClass().getClassLoader(),
                          new Class[] { intf },
                          new InvocationHandler() {
                              public Object invoke(Object proxy, Method method, 
                                Object[] args) throws Throwable {
                                  return method.invoke(obj, args);
                              }
                          });
              }
          }
          
          												
          										





          回頁首


          動態代理作為 Decorator

          當然,動態代理工具能做的,遠不僅僅是把對象類型限制在特定接口上。從 清單 2清單 5 中簡單的限制適配器到 Decorator 模式,是一個小的飛躍,在 Decorator 模式中,代理用額外的功能(例如安全檢測或日志記錄)包裝調用。清單 6 顯示了一個日志 InvocationHandler,它在調用目標對象上的方法之外,還寫入一條日志信息,顯示被調用的方法、傳遞的參數,以及返回值。除了反射性的 invoke() 調用之外,這里的全部代碼只是生成調試信息的一部分 —— 還不是太多。代理工廠方法的代碼幾乎與 GenericProxyFactory 相同,區別在于它使用的是 LoggingInvocationHandler 而不是匿名的調用句柄。


          清單 6. 基于代理的 Decorator,為每個方法調用生成調試日志
          												
          																		
              private static class LoggingInvocationHandler<T> 
                implements InvocationHandler {
                  final T underlying;
          
                  public LoggingHandler(T underlying) {
                      this.underlying = underlying;
                  }
          
                  public Object invoke(Object proxy, Method method, 
                    Object[] args) throws Throwable {
                      StringBuffer sb = new StringBuffer();
                      sb.append(method.getName()); sb.append("(");
                      for (int i=0; args != null && i<args.length; i++) {
                          if (i != 0)
                              sb.append(", ");
                          sb.append(args[i]);
                      }
                      sb.append(")");
                      Object ret = method.invoke(underlying, args);
                      if (ret != null) {
                          sb.append(" -> "); sb.append(ret);
                      }
                      System.out.println(sb);
                      return ret;
                  }
              }
          
          												
          										

          如果用日志代理包裝 HashSet,并執行下面這個簡單的測試程序:

          												
          														    Set s = newLoggingProxy(Set.class, new HashSet());
              s.add("three");
              if (!s.contains("four"))
                  s.add("four");
              System.out.println(s);
          
          												
          										

          會得到以下輸出:

          												
          														  add(three) -> true
            contains(four) -> false
            add(four) -> true
            toString() -> [four, three]
            [four, three]
          
          												
          										

          這種方式是給對象添加調試包裝器的一種好的而且容易的方式。它當然比生成代理類并手工創建大量 println() 語句容易得多(也更通用)。我進一步改進了這一方法;不必無條件地生成調試輸出,相反,代理可以查詢動態配置存儲(從配置文件初始化,可以由 JMX MBean 動態修改),確定是否需要生成調試語句,甚至可能在逐個類或逐個實例的基礎上進行。

          在這一點上,我認為讀者中的 AOP 愛好者們幾乎要跳出來說“這正是 AOP 擅長的啊!”是的,但是解決問題的方法不止一種 —— 僅僅因為某項技術能解決某個問題,并不意味著它就是最好的解決方案。在任何情況下,動態代理方式都有完全在“純 Java”范圍內工作的優勢,不是每個公司都用(或應當用) AOP 的。

          動態代理作為適配器

          代理也可以用作真正的適配器,提供了對象的一個視圖,導出與底層對象實現的接口不同的接口。調用句柄不需要把每個方法調用都分派給相同的底層對象;它可以檢查名稱,并把不同的方法分派給不同的對象。例如,假設有一組表示持久實體(PersonCompanyPurchaseOrder) 的 JavaBean 接口,指定了屬性的 getter 和 setter,而且正在編寫一個持久層,把數據庫記錄映射到實現這些接口的對象上。現在不用為每個接口編寫或生成類,可以只用一個 JavaBean 風格的通用代理類,把屬性保存在 Map 中。

          清單 7 顯示的動態代理檢查被調用方法的名稱,并通過查詢或修改屬性圖直接實現 getter 和 setter 方法。現在,這一個代理類就能實現多個 JavaBean 風格接口的對象。


          清單 7. 用于把 getter 和 setter 分派給 Map 的動態代理類
          												
          																		
          public class JavaBeanProxyFactory {
              private static class JavaBeanProxy implements InvocationHandler {
                  Map<String, Object> properties = new HashMap<String, 
                    Object>();
          
                  public JavaBeanProxy(Map<String, Object> properties) {
                      this.properties.putAll(properties);
                  }
          
                  public Object invoke(Object proxy, Method method, 
                    Object[] args) 
                    throws Throwable {
                      String meth = method.getName();
                      if (meth.startsWith("get")) {
                          String prop = meth.substring(3);
                          Object o = properties.get(prop);
                          if (o != null && !method.getReturnType().isInstance(o))
                              throw new ClassCastException(o.getClass().getName() + 
                                " is not a " + method.getReturnType().getName());
                          return o;
                      }
                      else if (meth.startsWith("set")) {
                          // Dispatch setters similarly
                      }
                      else if (meth.startsWith("is")) {
                          // Alternate version of get for boolean properties
                      }
                      else {
                          // Can dispatch non get/set/is methods as desired
                      }
                  }
              }
          
              public static<T> T getProxy(Class<T> intf,
                Map<String, Object> values) {
                  return (T) Proxy.newProxyInstance
                    (JavaBeanProxyFactory.class.getClassLoader(),
                          new Class[] { intf }, new JavaBeanProxy(values));
              }
          }
          
          												
          										

          雖然因為反射在 Object 上工作會有潛在的類型安全性上的損失,但是,JavaBeanProxyFactory 中的 getter 處理會進行一些必要的額外的類型檢測,就像我在這里用 isInstance() 對 getter 進行的檢測一樣。





          回頁首


          性能成本

          正如已經看到的,動態代理擁有簡化大量代碼的潛力 —— 不僅能替代許多生成的代碼,而且一個代理類還能代替多個手寫的類或生成的代碼。什么是成本呢? 因為反射地分派方法而不是采用內置的虛方法分派,可能有一些性能上的成本。在早期的 JDK 中,反射的性能很差(就像早期 JDK 中幾乎其他每件事的性能一樣),但是在近 10 年,反射已經變得快多了。

          不必進入基準測試構造的主題,我編寫了一個簡單的、不太科學的測試程序,它循環地把數據填充到 Set,隨機地對 Set進行插入、查詢和刪除元素。我用三個 Set 實現運行它:一個未經修飾的 HashSet,一個手寫的、只是把所有方法轉發到底層的 HashSetSet 適配器,還有一個基于代理的、也只是把所有方法轉發到底層 HashSetSet 適配器。每次循環迭代都生成若干隨機數,并執行一個或多個 Set 操作。手寫的適配器比起原始的 HashSet 只產生很少百分比的性能負荷(大概是因為 JVM 級有效的內聯緩沖和硬件級的分支預測);代理適配器則明顯比原始 HashSet 慢,但是開銷要少于兩個量級。

          我從這個試驗得出的結論是:對于大多數情況,代理方式即使對輕量級方法也執行得足夠好,而隨著被代理的操作變得越來越重量級(例如遠程方法調用,或者使用序列化、執行 IO 或者從數據庫檢索數據的方法),代理開銷就會有效地接近于 0。當然也存在一些代理方式的性能開銷無法接受的情況,但是這些通常只是少數情況。

          posted @ 2006-08-24 17:35 Binary 閱讀(214) | 評論 (0)編輯 收藏

          Java 理論與實踐: 偽 typedef 反模式

          將泛型添加到 Java? 語言中增加了類型系統的復雜性,提高了許多變量和方法聲明的冗長程度。因為沒有提供 “typedef” 工具來定義類型的簡短名稱,所以有些開發人員轉而把擴展當作 “窮人的 typedef”,但是收到的決不是好的結果。在這個月的 Java 理論與實踐 中,Java 專家 Brian Goetz 解釋了這個 “反模式” 的限制。

          對于 Java 5.0 中新增的泛型工具,一個常見的抱怨就是,它使代碼變得太冗長。原來用一行就夠的變量聲明不再存在了,與聲明參數化類型有關的重復非常討厭,特別是還沒有良好地支持自動補足的 IDE。例如,如果想聲明一個 Map,它的鍵是 Socket,值是 Future<String>,那么老方法就是:

          												
          														Map socketOwner = new HashMap();
          
          												
          										

          比新方法緊湊得多:
          Map<Socket, Future<String>> socketOwner 
            = new HashMap<Socket, Future<String>>();  
          

          當然,新方法內置了更多類型信息,減少了編程錯誤,提高了程序的可讀性,但是確實帶來了更多聲明變量和方法簽名方面的前期工作。類型參數在聲明和初始化中的重復看起來尤其沒有必要;SocketFuture<String> 需要輸入兩次,這迫使我們違犯了 “DRY” 原則(不要重復自己)。

          合成類似于 typedef 的東西

          添加泛型給類型系統增加了一些復雜性。在 Java 5.0 之前,“type” 和 “class” 幾乎是同義的,而參數化類型,特別是那些綁定的通配類型,使子類型和子類的概念有了顯著區別。類型 ArrayList<?>ArrayList<? extends Number>ArrayList<Integer> 是不同的類型,雖然它們是由同一個類 ArrayList 實現的。這些類型構成了一個層次結構;ArrayList<?>ArrayList<? extends Number> 的超類型,而 ArrayList<? extends Number>ArrayList<Integer> 的超類型。

          對于原來的簡單類型系統,像 C 的 typedef 這樣的特性沒有意義。但是對于更復雜的類型系統,typedef 工具可能會提供一些好處。不知是好還是壞,總之在泛型加入的時候,typedef 沒有加入 Java 語言。

          有些人用作 “窮人的 typedef” 的一個(壞的)做法是一個小小的擴展:創建一個類,擴展泛型類型,但是不添加功能,例如 SocketUserMap 類型,如清單 1 所示:


          清單 1. 偽 typedef 反模式 —— 不要這么做
          public class SocketUserMap extends HashMap<Socket<Future<String>> { }
          SocketUserMap socketOwner = new SocketUserMap();
          

          我將這個技巧稱為偽 typedef 反模式,它實現了將 socketOwner 定義簡化為一行的這一(有問題的)目標,但是有些副作用,最終成為重用和維護的障礙。(對于有明確的構造函數而不是無參構造函數的類來說,派生類也需要聲明每個構造函數,因為構造函數沒有被繼承。)





          回頁首


          偽類型的問題

          在 C 中,用 typedef 定義一個新類型更像是宏,而不是類型聲明。定義等價類型的 typedef,可以與原始類型自由地互換。清單 2 顯示了一個定義回調函數的示例,其中在簽名中使用了一個 typedef,但是調用者提供給回調的是一個等價類型,而編譯器和運行時都可以接受它:


          清單 2. C 語言的 typedef 示例
          // Define a type called "callback" that is a function pointer
          typedef void (*Callback)(int);
          
          void doSomething(Callback callback) { }
          
          // This function conforms to the type defined by Callback
          void callbackFunction(int arg) { }
          
          // So a caller can pass the address of callbackFunction to doSomething
          void useCallback() {
            doSomething(&callbackFunction); 
          }
          

          擴展不是類型定義

          用 Java 語言編寫的試圖使用偽 typedef 的等價程序就會出現麻煩。清單 3 的 StringListUserList 類型都擴展了一個公共超類,但是它們不是等價的類型。這意味著任何想調用 lookupAll 的代碼都必須傳遞一個 StringList,而不能是 List<String>UserList


          清單 3. 偽類型如何把客戶限定在只能使用偽類型
          class StringList extends ArrayList<String> { }
          class UserList extends ArrayList<String> { }
          ...
          class SomeClass {
              public void validateUsers(UserList users) { ... }
              public UserList lookupAll(StringList names) { ... }
          }
          

          這個限制要比初看上去嚴格得多。在小程序中,可能不會有太大差異,但是當程序變大的時候,使用偽類型的需求就會不斷地造成問題。如果變量類型是 StringList,就不能給它分配普通的 List<String>,因為 List<String>StringList 的超類型,所以不是 StringList。就像不能把 Object 分配給類型為 String 的變量一樣,也不能把 List<String> 分配給類型為 StringList 的變量(但是,可以反過來,例如,可以把 StringList 分配給類型為 List<String> 的變量,因為 List<String>StringList 的超類型。)

          同樣的情況也適用于方法的參數;如果一個方法參數是 StringList 類型,那么就不能把普通的 List<String> 傳遞給它。這意味著,如果不要求這個方法的每次使用都使用偽類型,那么根本不能用偽類型作為方法參數,而這在實踐當中就意味著在庫 API 中根本就不能使用偽類型。而且大多數庫 API 都源自本來沒想成為庫代碼的那些代碼,所以 “這個代碼只是給我自己的,沒有其他人會用它” 可不是個好借口(只要您的代碼有一點兒用處,別人就有可能會使用它;如果您的代碼臭得很,那您可能是對的)。

          偽類型會傳染

          這種 “病毒” 性質是讓 C 代碼的重用有困難的因素之一。差不多每個 C 包都有頭文件,定義工具宏和類型,像 int32booleantruefalse,諸如此類。如果想在一個應用程序內使用幾個包,而它們對于這些公共條目沒有使用相同的定義,那么即使要編譯一個只包含所有頭文件的空程序,之前也要在 “頭文件地獄” 問題上花好長時間。如果編寫的 C 應用程序要使用許多來自不同作者的不同的包,那么幾乎肯定要涉及一些這類痛苦。另一方面,對于 Java 應用程序來說,在沒有這類痛苦的情況下使用許多甚至更多的包,是非常常見的事。如果包要在它們的 API 中使用偽類型,那么我們可能就要重新經歷早已留在痛苦回憶中的問題。

          作為示例,假設有兩個不同的包,每個包都用偽類型反模式定義了 StringList,如清單 4 所示,而且每個包都定義了操作 StringList 的工具方法。兩個包都定義了同樣的標識符,這一事實已經是不方便的一個小源頭了;客戶程序必須選擇導入一個定義,而另一個定義則要使用完全限定的名稱。但是更大的問題是現在這些包的客戶無法創建既能傳遞給 sortList 又能傳遞給 reverseList 的對象,因為兩個不同的 StringList 類型是不同的類型,彼此互不兼容。客戶現在必須在使用一個包還是使用另一個包之間進行選擇,否則他們就必須做許多工作,在不同類型的 StringList 之間進行轉換。對包的作者來說以為方便的東西,成為在所有地方使用這個包的突出障礙,除非在最受限的環境中。


          清單 4. 偽類型的使用如何妨礙重用
          package a;
          
          class StringList extends ArrayList<String> { }
          class ListUtilities {
              public static void sortList(StringList list) { }
          }
          
          package b;
          
          class StringList extends ArrayList<String> { }
          class SomeOtherUtilityClass {
              public static void reverseList(StringList list) { }
          }
           
          ...
          
          class Client {
              public void someMethod() {
                  StringList list = ...;
                  // Can't do this
                  ListUtilities.sortList(list);
                  SomeOtherUtilityClass.reverseList(list);
              }
          }
          

          偽類型通常太具體

          偽類型反模式的進一步問題是,它會喪失使用接口定義變量類型和方法參數的好處。雖然可以把 StringList 定義成擴展 List<String> 的接口,再定義一個具體類型 StringArrayList 來擴展 ArrayList<String> 并實現 StringList,但多數偽 typedef 反模式的用戶通常達不到這種水平,因為這項技術的目的主要是為了簡化和縮短類型的名稱。但結果是,API 的用處減少了并變得更脆弱,因為它們使用 ArrayList 這樣的具體類型,而不是 List 這樣的抽象類型。

          更安全的技巧

          一個更安全的減少聲明泛型集合所需打字量的技巧是使用類型推導(type inference)。編譯器可以非常聰明地使用程序中內嵌的類型信息來分配類型參數。如果定義了下面這樣一個工具方法:

          public static <K,V> Map<K,V> newHashMap() {
              return new HashMap<K,V>(); 
          }
          

          那么可以安全地用它來避免錄入兩次參數:
          Map<Socket, Future<String>> socketOwner = Util.newHashMap();
          

          這種方法之所以能夠奏效,在于編譯器可以根據泛型方法 newHashMap() 被調用的位置推導出 KV 的值。



          回頁首


          結束語

          偽 typedef 反模式的動機很簡單 —— 開發人員想要一種方法可以定義更緊湊的類型標識符,特別是在泛型把類型標識符變得更冗長的時候。問題在于這個做法在使用它的代碼和代碼的客戶之間形成了緊密的耦合,從而妨礙了重用。不喜歡泛型類型標識符的冗長是可以理解的,但這不是解決問題的辦法。

          posted @ 2006-08-24 17:34 Binary 閱讀(157) | 評論 (0)編輯 收藏

          僅列出標題
          共8頁: 上一頁 1 2 3 4 5 6 7 8 下一頁 
          主站蜘蛛池模板: 南昌市| 清水河县| 遵义市| 虹口区| 绿春县| 永和县| 宿州市| 磴口县| 英吉沙县| 肥西县| 塘沽区| 天祝| 宁乡县| 宁国市| 东丽区| 光泽县| 屯门区| 句容市| 隆德县| 新乡市| 临夏县| 云浮市| 呼图壁县| 磐安县| 辰溪县| 景洪市| 搜索| 巴林右旗| 含山县| 青浦区| 松江区| 通河县| 宜阳县| 清丰县| 金溪县| 沽源县| SHOW| 邛崃市| 宜阳县| 大港区| 砚山县|