關於單例模式的DoubleCheckLock同步的思考

在javaeye上看到很多朋友都提出單例模式的一些變種實現,比如加入了即時加載和DoubleCheckLock機制,來提高併發性能。但事實上這些機制真的必要嗎? 

目前公認影響單例性能的要素有兩個:一是實例構造時間開銷,一是獲取單例實例的同步阻塞開銷。 

我的理解是,併發相對與同步阻塞的優勢,在於當兩條線程中的一條在執行時間開銷較大的操作,而另一條線程無須執行該操作,則併發執行保證了開銷小的線程不需等待開銷大的,能正常執行完畢。然而,如果所有線程都只執行一些基本的操作,例如“返回結果”,“變量賦值”,“判斷跳轉”等,是否併發執行對性能並沒有實質性的提升。差別只在於到底在jdk層面在同步塊上排隊,還是在cpu層面在時間片分配上排隊。 

對於一個基本的單例模式,在每個jvm中肯定只會發生一次單例構造。同步機制雖然保護的是構造過程,但99%時間鎖的是返回結果過程。而對於這種基本操作,有鎖和無鎖差別真的是這麼大(據說有100倍的差別)嗎? 

網上常見的單例實現有以下幾種: 

1. 延遲加載的基本實現 
Java代碼  收藏代碼
  1. public class Singleton {     
  2.   
  3.     private static Singleton instance = null;     
  4.   
  5.     public static synchronized Singleton getInstance() {  
  6.         if (instance == null) {  
  7.             instance = new Singleton();  
  8.         }  
  9.         return instance;  
  10.     }  
  11. }  

2. 即時加載的基本實現 
Java代碼  收藏代碼
  1. public class Singleton {     
  2.   
  3.     private static Singleton instance = new Singleton();     
  4.   
  5.     public static Singleton getInstance() {  
  6.         return instance;  
  7.     }  
  8. }  

3. DoubleCheck同步延遲加載 
Java代碼  收藏代碼
  1. public class Singleton {     
  2.   
  3.     private static Singleton instance = null;     
  4.   
  5.     public static Singleton getInstance() {  
  6.         if (instance == null) {  
  7.             synchronized (Singleton.class) {  
  8.                 if (instance == null) {  
  9.                     instance = new Singleton();  
  10.                 }  
  11.             }  
  12.         }  
  13.         return instance;  
  14.     }  
  15. }  


舉個極端的量化例子,假如有一個單例對象被500條線程使用。單例的構造器中由於使用了一些耗時的資源,執行一次約爲1s。但如果單例實例已經創建好,在getInstance()中直接return該實例,耗時爲0.1ms。這500條線程中的其中200條會每隔10分鐘(以機器內時鐘爲準)同時訪問這個單例對象。 

另外,先假定併發操作對於一些基本的操作,例如方法返回,賦值,判斷跳轉等不會帶來性能上的大幅提升(這與JDK的實現有關)。換句話說,200條線程中“返回實例”動作,無論是否在同步塊中,總耗時都是0.1*200=20ms。 

那麼, 

第一種情況:首次訪問時,第一條線程鎖住getInstance(),並執行1s的實際構造邏輯並返回實例(耗時1.0001秒),其他199條線程依次排隊。在單例構造好後再依次獲取實例,最慢一條線程耗時1.02秒。 二次訪問時,所有線程都依次排隊獲取實例,最慢一條線程耗時0.02秒 

第二種情況:在類裝載時,構造單例實例耗時1s。首次訪問時,200條線程併發獲取實例,由於在cpu上還是要排隊,最慢一條線程耗時0.02秒。(也就是說,從系統啓動到首次訪問完成,耗時1.02秒)。二次訪問時,所有線程都併發獲取實例,最慢一條線程耗時0.02秒。 

第三種情況:首次訪問時,與第一種情況類似(因爲所有線程都通過了instance == null,在同步塊上排隊),最慢線程耗時1.02秒。二次訪問時,所有線程都併發獲取實例,但由於增加了條件判斷(假設判斷跳轉時間爲1ms),最慢一條線程耗時約爲0.04秒。 

可以看出,如果假定併發對基本操作時間無影響時,綜合性能第一種和第二種一致,第三種最低,但差別均在微秒級別,事實上可以忽略。 

需要注意的是,對於一個單例來說,上面例子中的線程總數500是個多餘量,對結果完全沒有影響。換句話說,是否延遲加載對於單個單例的性能沒有影響。 

如果假定jdk的實現使得基本操作在併發環境下性能較高,假設返回結果與條件判斷的平均性能在併發下能提高1倍(雙核?),變成0.05ms。那麼, 
第一種情況(完全沒有利用併發):首次訪問最慢線程:1.02秒,二次訪問最慢線程0.02秒。 
第二種情況:類載入時:1秒,首次訪問最慢線程0.01秒(系統啓動到完成首次訪問總耗時1.01秒),二次訪問最慢線程0.01秒 
第三種情況:首次訪問最慢線程1.01秒,二次訪問最慢線程0.02秒。 

同樣可以看出,三種情況即使有差別,但差別均在微秒級別,事實上可以忽略。而且如果要較真的話,第三種情況還可能會慢,關鍵在於併發環境下返回結果的性能提升能否抵消多出來的兩次判斷跳轉。 

上面討論的是一個單例被多條線程同時使用的情況。延時讀取是考慮的是系統中存在大量不同單例的某些特殊情況。例如即時加載的劣勢,體現在如果系統中有50個不同的單例類,而在某條執行路線上只載入了其中2個,即時加載會導致其他48個單例實例被無謂地創建。問題是在系統中定義了大量的單例類(要注意單例是難以繼承的),而主線邏輯中又只使用了其中少量的現實場景究竟有多少。 

綜上,個人感覺在一般場景中,基本延遲加載方案與基本即時加載方案的效果是差不多的,完全可以根據個人喜好選擇。除非已知系統中會出現“單例類爆炸”,而主流程又只使用其中少量的情況,那麼就需要在團隊中強制推行延遲加載方案。DoubleCheck同步加載方案需要證明“返回結果這種基本操作在併發環境下能帶來實質的性能提升,足以抵消額外的條件判斷的性能損耗”的前提下,才值得推廣。 


轉載:http://kidneyball.iteye.com/blog/850053

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章