單例---被廢棄的DCL雙重檢查加鎖


被廢棄的單例的DCL雙重檢查加鎖
/*
  *單例模式   *單例模式,保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。   *加同步鎖的單例模式,適合在多線程中使用。   */   class Singleton{    private static Singleton instance;    private Singleton(){}//構造函數為private,外類不能使用new來創建立此類的實例    public static Singleton getInstance(){//獲得實例的唯一全局訪問點    System.out.println("進入外層");   if (instance==null){   synchronized(Singleton.class){   if(instance==null){   instance=new Singleton();   System.out.println("進入里層");  }//end inner if   }//end synchronized    }//end outter if return instance;  }//end getInstance()   }   class Instance extends Thread{   static int count=1;   public void run(){   System.out.println("第"+ count++ +"次調用同一個實例!");   Singleton s1=Singleton.getInstance();   }//end run   public static void main(String []args){   for( int i=1;i<5;i++){   Instance thread1=new Instance();   thread1.start();   }   }//end main   }   運行結果:   第1次調用同一個實例!   進入外層   進入里層   第2次調用同一個實例!   進入外層   第3次調用同一個實例!   進入外層   第4次調用同一個實例!   進入外層

 

單例對象(Singleton)是一種常用的設計模式。在Java應用中,單例對象能保證在一個JVM中,該對象只有一個實例存在。正是由於這個特點,單例對象通常作為程序中的存放配置信息的載體,因為它能保證其他對象讀到一致的信息。例如在某個服務器程序中,該服務器的配置信息可能存放在數據庫或文件中,這些配置數據由某個單例對象統一讀取,服務進程中的其他對象如果要獲取這些配置信息,只需訪問該單例對象即可。這種方式極大地簡化了在復雜環境下,尤其是多線程環境下的配置管理,但是隨着應用場景的不同,也可能帶來一些同步問題。

本文將探討一下在多線程環境下,使用單例對象作配置信息管理時可能會帶來的幾個同步問題,並針對每個問題給出可選的解決辦法。

問題描述

 

在多線程環境下,單例對象的同步問題主要體現在兩個方面,單例對象的初始化和單例對象的屬性更新。

本文描述的方法有如下假設:

  1. 單例對象的屬性(或成員變量)的獲取,是通過單例對象的初始化實現的。也就是說,在單例對象初始化時,會從文件或數據庫中讀取最新的配置信息。
  2. 其他對象不能直接改變單例對象的屬性,單例對象屬性的變化來源於配置文件或配置數據庫數據的變化。

1.1 單例對象的初始化

 

首先,討論一下單例對象的初始化同步。單例模式的通常處理方式是,在對象中有一個靜態成員變量,其類型就是單例類型本身;如果該變量為null,則創建該單例類型的對象,並將該變量指向這個對象;如果該變量不為null,則直接使用該變量。

其過程如下面代碼所示:

 

Java代碼   收藏代碼
  1. public class GlobalConfig {  
  2.     private static GlobalConfig instance = null;  
  3.     private Vector properties = null;  
  4.     private GlobalConfig() {  
  5.       //Load configuration information from DB or file  
  6.       //Set values for properties  
  7.     }  
  8.     public static GlobalConfig getInstance() {  
  9.       if (instance == null) {  
  10.         instance = new GlobalConfig();  
  11.       }  
  12.       return instance;  
  13.     }  
  14.     public Vector getProperties() {  
  15.       return properties;  
  16.     }  
  17.   }  

 

這種處理方式在單線程的模式下可以很好的運行;但是在多線程模式下,可能產生問題。如果第一個線程發現成員變量為null,准備創建對象;這是第二個線程同時也發現成員變量為null,也會創建新對象。這就會造成在一個JVM中有多個單例類型的實例。如果這個單例類型的成員變量在運行過程中變化,會造成多個單例類型實例的不一致,產生一些很奇怪的現象。例如,某服務進程通過檢查單例對象的某個屬性來停止多個線程服務,如果存在多個單例對象的實例,就會造成部分線程服務停止,部分線程服務不能停止的情況。

1.2 單例對象的屬性更新

 

通常,為了實現配置信息的實時更新,會有一個線程不停檢測配置文件或配置數據庫的內容,一旦發現變化,就更新到單例對象的屬性中。在更新這些信息的時候,很可能還會有其他線程正在讀取這些信息,造成意想不到的后果。還是以通過單例對象屬性停止線程服務為例,如果更新屬性時讀寫不同步,可能訪問該屬性時這個屬性正好為空(null),程序就會拋出異常。

 

 

解決方法

 

2.1 單例對象的初始化同步

 

對於初始化的同步,可以通過如下代碼所采用的方式解決。

Java代碼   收藏代碼
  1. public class GlobalConfig {  
  2.     private static GlobalConfig instance = null;  
  3.     private Vector properties = null;  
  4.     private GlobalConfig() {  
  5.       //Load configuration information from DB or file  
  6.       //Set values for properties  
  7.     }  
  8.     private static synchronized void syncInit() {  
  9.       if (instance == null) {  
  10.         instance = new GlobalConfig();  
  11.       }  
  12.     }  
  13.     public static GlobalConfig getInstance() {  
  14.       if (instance == null) {  
  15.         syncInit();  
  16.       }  
  17.       return instance;  
  18.     }  
  19.     public Vector getProperties() {  
  20.       return properties;  
  21.     }  
  22.   }  

  

這種處理方式雖然引入了同步代碼,但是因為這段同步代碼只會在最開始的時候執行一次或多次,所以對整個系統的性能不會有影響。

2.2 單例對象的屬性更新同步

為了解決第2個問題,有兩種方法:

1,參照讀者/寫者的處理方式

設置一個讀計數器,每次讀取配置信息前,將計數器加1,讀完后將計數器減1。只有在讀計數器為0時,才能更新數據,同時要阻塞所有讀屬性的調用。代碼如下。

Java代碼   收藏代碼
  1. public class GlobalConfig {  
  2.     private static GlobalConfig instance;  
  3.     private Vector properties = null;  
  4.     private boolean isUpdating = false;  
  5.     private int readCount = 0;  
  6.     private GlobalConfig() {  
  7.       //Load configuration information from DB or file  
  8.       //Set values for properties  
  9.     }  
  10.     private static synchronized void syncInit() {  
  11.         if (instance == null) {  
  12.             instance = new GlobalConfig();  
  13.         }  
  14.     }  
  15.     public static GlobalConfig getInstance() {  
  16.         if (instance==null) {  
  17.             syncInit();  
  18.         }  
  19.         return instance;  
  20.     }  
  21.     public synchronized void update(String p_data) {  
  22.         syncUpdateIn();  
  23.         //Update properties  
  24.     }  
  25.     private synchronized void syncUpdateIn() {  
  26.         while (readCount > 0) {  
  27.             try {  
  28.                 wait();  
  29.             } catch (Exception e) {  
  30.             }  
  31.         }  
  32.     }  
  33.     private synchronized void syncReadIn() {  
  34.         readCount++;  
  35.     }  
  36.     private synchronized void syncReadOut() {  
  37.         readCount--;  
  38.         notifyAll();  
  39.     }  
  40.     public Vector getProperties() {  
  41.         syncReadIn();  
  42.         //Process data  
  43.         syncReadOut();  
  44.         return properties;  
  45.     }  
  46.   }  

  

2,采用"影子實例"的辦法

具體說,就是在更新屬性時,直接生成另一個單例對象實例,這個新生成的單例對象實例將從數據庫或文件中讀取最新的配置信息;然后將這些配置信息直接賦值給舊單例對象的屬性。如下面代碼所示。

Java代碼   收藏代碼
  1. public class GlobalConfig {  
  2.     private static GlobalConfig instance = null;  
  3.     private Vector properties = null;  
  4.     private GlobalConfig() {  
  5.       //Load configuration information from DB or file  
  6.       //Set values for properties  
  7.     }  
  8.     private static synchronized void syncInit() {  
  9.       if (instance = null) {  
  10.         instance = new GlobalConfig();  
  11.       }  
  12.     }  
  13.     public static GlobalConfig getInstance() {  
  14.       if (instance = null) {  
  15.         syncInit();  
  16.       }  
  17.       return instance;  
  18.     }  
  19.     public Vector getProperties() {  
  20.       return properties;  
  21.     }  
  22.     public void updateProperties() {  
  23.       //Load updated configuration information by new a GlobalConfig object  
  24.       GlobalConfig shadow = new GlobalConfig();  
  25.       properties = shadow.getProperties();  
  26.     }  
  27.   }  

  

注意:在更新方法中,通過生成新的GlobalConfig的實例,從文件或數據庫中得到最新配置信息,並存放到properties屬性中。

上面兩個方法比較起來,第二個方法更好,首先,編程更簡單;其次,沒有那么多的同步操作,對性能的影響也不大。

 

Singleton模式與雙檢測鎖定(DCL)

看OOP教材時,提到了一個雙檢測鎖定(Double-Checked Lock, DCL)的問題,但是書上沒有多介紹,只是說這是一個和底層內存機制有關的漏洞。查閱了下相關資料,對這個問題大致有了點了解。

從頭開始說吧。

在多線程的情況下Singleton模式會遇到不少問題,一個簡單的例子

class Singleton {      
        private static Singleton instance = null;      
           
        public static Singleton instance() {      
            if (instance == null) {      
                instance = new Singleton();      
            }      
            return instance;     
        }     
}

假設這樣一個場景,有兩個線程調用Singleton.instance(),首先線程一判斷instance是否等於null,判斷完后一瞬間虛擬機把線程二調度為運行線程,線程二再次判斷instance是否為null,然后創建一個Singleton實例,線程二的時間片用完后,線程一被喚醒,接下來它執行的代碼依然是instance = new Singleton(); 
兩次調用返回了不同的對象,出現問題了。

最簡單的方法自然是在類被載入時就初始化這個對象:private static Singleton instance = new Singleton();

JLS(Java Language Specification)中規定了一個類只會被初始化一次,所以這樣做肯定是沒問題的。

但是如果要實現延遲初始化(Lazy initialization),比如這個實例初始化時的參數要在運行期才能確定,應該怎么做呢?

依然有最簡單的方法:使用synchronized關鍵字修飾初始化方法:

    public synchronized static Singleton instance() {        
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
 
    
這里有一個性能問題:多個線程同時訪問這個方法時,會因為同步而導致每次只有一個線程運行,影響程序性能。而事實上初始化完畢后只需要簡單的返回instance的引用就行了。

DCL是一個“看似”有效的解決方法,先把對應代碼放上來吧:

class Singleton {   
          private static Singleton instance = null ;   
         
          public static Singleton instance() {   
              if (instance == null ) {
                  synchronized (this) {   
                      if (instance == null)
                         instance = new Singleton();
                 }
             }
             return instance;
         }
}

 

用JavaWorld上對應文章的標題來評論這種做法就是smart, but broken。來看原因:

Java編譯器為了提高程序性能會進行指令調度,CPU在執行指令時同樣出於性能會亂序執行(至少現在用的大多數通用處理器都是out-of-order的),另外cache的存在也會改變數據回寫內存時的順序[2]。JMM(Java Memory Model, 見[1])指出所有的這些優化都是允許的,只要運行結果和嚴格按順序執行所得的結果一樣即可。

Java假設每個線程都跑在自己的處理器上,享有自己的內存,和共享的主存交互。注意即使在單核上這種模型也是有意義的,考慮到cache和寄存器會保存部分臨時變量。理論上每個線程修改自己的內存后,必須立即更新對應的主存內容。但是Java設計師們認為這種約束會影響程序性能,他們試着創造了一套讓程序跑得更快、但又保證線程之間的交互與預期一致的內存模型。

synchronized關鍵字便是其中一把利器。事實上,synchronized塊的實現和Linux中的信號量(semaphore)還是有區別的,前者過程中鎖的獲得和釋放都會都會引發一次Memory Barrier來強制線程本地內存和主存之間的同步。通過這個機制,Java中的同步機制保證了synchronized塊中指令的原子性(atomic)。

好了,回過頭來看DCL問題。看起來訪問一個未同步的instance字段不會產生什么問題,我們再次來假設一個場景:

線程一進入同步塊,執行instance = new Singleton(); 線程二剛開始執行getInstance();

按照順序的話,接下來應該執行的步驟是 :

1) 分配新的Singleton對象的內存

2) 調用Singleton的構造器,初始化成員字段

3) instance被賦為指向新的對象的引用。

前面說過,編譯器或處理器都為了提高性能都有可能進行指令的亂序執行,線程一的真正執行步驟可能是:

1) 分配內存

2) instance指向新對象

3) 初始化新實例。

如果線程二在2完成后3執行前被喚醒,它看到了一個不為null的instance,跳出方法體走了,帶着一個還沒初始化的Singleton對象。

錯誤發生的一種情形就是這樣,關於更詳細的編譯器指令調度導致的問題,可以參看這個網頁 [4]。

[3] 中提供了一個編譯器指令調度的證據

instance = new Singleton(); 這條命令在Symantec JIT中被編譯成

0206106A   mov         eax,0F97E78h
0206106F   call        01F6B210                  ; 分配空間
02061074   mov         dword ptr [ebp],eax       ; EBP中保存了instance的地址

02061077   mov         ecx,dword ptr [eax]       ; 解引用,獲得新的指針地址

02061079   mov         dword ptr [ecx],100h      ; 接下來四行是inline后的構造器
0206107F   mov         dword ptr [ecx+4],200h    
02061086   mov         dword ptr [ecx+8],400h
0206108D   mov         dword ptr [ecx+0Ch],0F84030h

可以看到,賦值完成在初始化之前,而這是JLS允許的。
 
另一種情形是,假設線程一安穩地完成Singleton對象的初始化,退出了同步塊,並同步了和本地內存和主存。線程二來了,看到一個非空的引用,拿走。注意線程二沒有執行一個Read Barrier,因為它根本就沒進后面的同步塊。所以很有可能此時它看到的數據是陳舊的。

還有很多人根據已知的幾種提出了一個又一個fix的方法,但最終還是出現了更多的問題。可以參閱[3]中的介紹。

[5]中還說明了即使把instance字段聲明為volatile還是無法避免錯誤的原因。

由此可見,安全的Singleton的構造一般只有兩種方法,一是在類載入時就創建該實例,二是使用性能較差的synchronized方法

[1] Java Language Specification, Second Edition, 第17章介紹了Java中線程和內存交互關系的具體細節。
[2] out-of-order與cache的介紹可以參閱Computer System, A Programmer's Perspective的第四、五章。
[3] The "Double-Checked Locking is Broken" Declaration, http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[4] Synchronization and the Java Memory Model, http://gee.cs.oswego.edu/dl/cpj/jmm.html
[5] Double-checked locking: Clever, but broken, http://www.javaworld.com/javaworld/jw-02-2001/jw-0209-double.html?page=1
[6] Holub on Patterns, Learning Design Patterns by Looking at Code


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM