單例模式詳細解析

單例模式最要關心的則是對象創建的次數以及何時被創建。 

Singleton模式可以是很簡單的,它的全部只需要一個類就可以完成(看看這章可憐的UML圖)。但是如果在“對象創建的次數以及何時被創建”這兩點上較真起來,Singleton模式可以相當的複雜,比頭五種模式加起來還複雜,譬如涉及到DCL雙鎖檢測(double checked locking)的討論、涉及到多個類加載器(ClassLoader)協同時、涉及到跨JVM(集羣、遠程EJB等)時、涉及到單例對象被銷燬後重建等。對於複雜的情況,本章中會涉及到其中一些[1] 

目的: 
希望對象只創建一個實例,並且提供一個全局的訪問點。 

場景: 
Kerrigan對於Zerg來說是個至關重要的靈魂人物,無數的Drone、Zergling、Hydralisk……可以被創造、被犧牲,但是Kerrigan得存在關係到Zerg在這局遊戲中的生存,而且Kerrigan是不允許被多次創造的,必須有且只有一個蟲族刀鋒女王的實例存在,這不是遊戲規則,但這是個政治問題。 

分析: 
結構是簡單的,只是我們還有一些小小的要求如下: 

1.最基本要求:每次從getInstance()都能返回一個且唯一的一個Kerrigan對象。 

2.稍微高一點的要求:Kerrigan很忙,很多人找,所以希望這個方法能適應多線程併發訪問。 

3.再提高一點的要求:Zerg是講究公務員效率的社會,希望找Kerrigan的方法性能儘可能高。 

4.最後一點要求是Kerrigan自己提出的:體諒到Kerrigan太累,希望多些睡覺時間,因此Kerrigan希望實現懶加載(Lazy Load),在需要的時候才被構造。 

5.原本打算說還提要處理多ClassLoader、多JVM等情況,不過還是不要把情況考慮的太複雜了,暫且先放過作者吧(-_-#)。 

我們第一次寫的單例模式是下面這個樣子的: 
/**  
 * 實現單例訪問Kerrigan的第一次嘗試  
 */  
public class SingletonKerriganA {   
    
    /**  
     * 單例對象實例  
     */  
    private static SingletonKerriganA instance = null;   
    
    public static SingletonKerriganA getInstance() {   
        if (instance == null) {                              //line A   
            instance = new SingletonKerriganA();          //line B   
        }   
        return instance;   
    }   
}   




這個寫法我們把四點需求從上往下檢測,發現第二點的時候就出了問題,假設這樣的場景:兩個線程併發調用SingletonKerriganA.getInstance(),假設線程一先判斷完instance是否爲null,既代碼中的line A進入到line B的位置。剛剛判斷完畢後,JVM將CPU資源切換給線程二,由於線程一還沒執行line B,所以instance仍然是空的,因此線程二執行了new SignletonKerriganA()操作。片刻之後,線程一被重新喚醒,它執行的仍然是new SignletonKerriganA()操作,好了,問題來了,兩個Kerrigan誰是李逵誰是李鬼? 



緊接着,我們做單例模式的第二次嘗試: 

/**  
 * 實現單例訪問Kerrigan的第二次嘗試  
 */  
public class SingletonKerriganB {   
    
    /**  
     * 單例對象實例  
     */  
    private static SingletonKerriganB instance = null;   
    
    public synchronized static SingletonKerriganB getInstance() {   
        if (instance == null) {   
            instance = new SingletonKerriganB();   
        }   
        return instance;   
    }   
}  


比起第一段代碼僅僅在方法中多了一個synchronized修飾符,現在可以保證不會出線程問題了。但是這裏有個很大(至少耗時比例上很大)的性能問題。除了第一次調用時是執行了SingletonKerriganB的構造函數之外,以後的每一次調用都是直接返回instance對象。返回對象這個操作耗時是很小的,絕大部分的耗時都用在synchronized修飾符的同步準備上,因此從性能上說很不划算。 

那繼續把代碼改成下面的樣子: 
/**  
 * 實現單例訪問Kerrigan的第三次嘗試  
 */  
public class SingletonKerriganC {   
    
    /**  
     * 單例對象實例  
     */  
    private static SingletonKerriganC instance = null;   
    
    public static SingletonKerriganC getInstance() {   
        synchronized (SingletonKerriganC.class) {   
            if (instance == null) {   
                instance = new SingletonKerriganC();   
            }   
        }   
        return instance;   
    }   
}  


基本上,把synchronized移動到代碼內部是沒有什麼意義的,每次調用getInstance()還是要進行同步。同步本身沒有問題,但是我們只希望在第一次創建Kerrigan實例的時候進行同步,因此我們有了下面的寫法——雙重鎖定檢查(DCL)。 

/**  
 * 實現單例訪問Kerrigan的第四次嘗試  
 */  
public class SingletonKerriganD {   
    
    /**  
     * 單例對象實例  
     */  
    private static SingletonKerriganD instance = null;   
    
    public static SingletonKerriganD getInstance() {   
        if (instance == null) {   
            synchronized (SingletonKerriganD.class) {   
                if (instance == null) {   
                    instance = new SingletonKerriganD();   
                }   
            }   
        }   
        return instance;   
    }   
}  


看起來這樣已經達到了我們的要求,除了第一次創建對象之外,其他的訪問在第一個if中就返回了,因此不會走到同步塊中。已經完美了嗎? 

我們來看看這個場景:假設線程一執行到instance = new SingletonKerriganD()這句,這裏看起來是一句話,但實際上它並不是一個原子操作(原子操作的意思就是這條語句要麼就被執行完,要麼就沒有被執行過,不能出現執行了一半這種情形)。事實上高級語言裏面非原子操作有很多,我們只要看看這句話被編譯後在JVM執行的對應彙編代碼就發現,這句話被編譯成8條彙編指令,大致做了3件事情: 

1.給Kerrigan的實例分配內存。 

2.初始化Kerrigan的構造器 

3.將instance對象指向分配的內存空間(注意到這步instance就非null了)。 

但是,由於Java編譯器允許處理器亂序執行(out-of-order),以及JDK1.5之前JMM(Java Memory Medel)中Cache、寄存器到主內存回寫順序的規定,上面的第二點和第三點的順序是無法保證的,也就是說,執行順序可能是1-2-3也可能是1-3-2,如果是後者,並且在3執行完畢、2未執行之前,被切換到線程二上,這時候instance因爲已經在線程一內執行過了第三點,instance已經是非空了,所以線程二直接拿走instance,然後使用,然後順理成章地報錯,而且這種難以跟蹤難以重現的錯誤估計調試上一星期都未必能找得出來,真是一茶几的杯具啊。 

DCL的寫法來實現單例是很多技術書、教科書(包括基於JDK1.4以前版本的書籍)上推薦的寫法,實際上是不完全正確的。的確在一些語言(譬如C語言)上DCL是可行的,取決於是否能保證2、3步的順序。在JDK1.5之後,官方已經注意到這種問題,因此調整了JMM、具體化了volatile關鍵字,因此如果JDK是1.5或之後的版本,只需要將instance的定義改成“private volatile static SingletonKerriganD instance = null;”就可以保證每次都去instance都從主內存讀取,就可以使用DCL的寫法來完成單例模式。當然volatile或多或少也會影響到性能,最重要的是我們還要考慮JDK1.42以及之前的版本,所以本文中單例模式寫法的改進還在繼續。 

代碼倒越來越複雜了,現在先來個返璞歸真,根據JLS(Java Language Specification)中的規定,一個類在一個ClassLoader中只會被初始化一次,這點是JVM本身保證的,那就把初始化實例的事情扔給JVM好了,代碼被改成這樣: 

/**  
 * 實現單例訪問Kerrigan的第五次嘗試  
 */  
public class SingletonKerriganE {   
    
    /**  
     * 單例對象實例  
     */  
    private static SingletonKerriganE instance = new SingletonKerriganE();   
    
    public static SingletonKerriganE getInstance() {   
        return instance;   
    }   
}  


好吧,如果這種寫法是完美的話,那前面那麼幾大段話就是作者在消遣各位讀者。這種寫法不會出現併發問題,但是它是餓漢式的,在ClassLoader加載類後Kerrigan的實例就會第一時間被創建,餓漢式的創建方式在一些場景中將無法使用:譬如Kerrigan實例的創建是依賴參數或者配置文件的,在getInstance()之前必須調用某個方法設置參數給它,那樣這種單例寫法就無法使用了。 

再來看看下面這種我覺得能應對較多場景的單例寫法: 

/**  
 * 實現單例訪問Kerrigan的第六次嘗試  
 */  
public class SingletonKerriganF {   
    
    private static class SingletonHolder {   
        /**  
         * 單例對象實例  
         */  
        static final SingletonKerriganF INSTANCE = new SingletonKerriganF();   
    }   
    
    public static SingletonKerriganF getInstance() {   
        return SingletonHolder.INSTANCE;   
    }   
}  


這種寫法仍然使用JVM本身機制保證了線程安全問題;由於SingletonHolder是私有的,除了getInstance()之外沒有辦法訪問它,因此它是懶漢式的;同時讀取實例的時候不會進行同步,沒有性能缺陷;也不依賴JDK版本。 

其他單例模式的寫法還有很多,如使用本地線程(ThreadLocal)來處理併發以及保證一個線程內一個單例的實現、GoF原始例子中使用註冊方式應對單例類需要需要繼承時的實現、使用指定類加載器去應對多ClassLoader環境下的實現等等。我們做開發設計工作的時,應當既要考慮到需求可能出現的擴展與變化,也應當避免“幻影需求”導致無謂的提升設計、實現複雜度,最終反而帶來工期、性能和穩定性的損失。設計不足與設計過度都是危害,所以說沒有最好的單例模式,只有最合適的單例模式。 

到這裏爲止,單例模式本身就先告一段落了,最後在介紹從其他途徑屏蔽構造單例對象的方法: 
1.直接new單例對象 

2.通過反射構造單例對象 

3.通過序列化構造單例對象。 

對於第一種情況,一般我們會加入一個private或者protected的構造函數,這樣系統就不會自動添加那個public的構造函數了,因此只能調用裏面的static方法,無法通過new創建對象。 

對於第二種情況,反射時可以使用setAccessible方法來突破private的限制,我們需要做到第一點工作的同時,還需要在在 ReflectPermission("suppressAccessChecks") 權限下使用安全管理器(SecurityManager)的checkPermission方法來限制這種突破。一般來說,不會真的去做這些事情,都是通過應用服務器進行後臺配置實現。 

對於第三種情況,如果單例對象有必要實現Serializable接口(很少出現),則應當同時實現readResolve()方法來保證反序列化的時候得到原來的對象。 

基於上述情況,將單例模式增加兩個方法: 

/**  
 * 能應對大多數情況的單例實現  
 */  
public class SingletonKerrigan implements Serializable {   
    
    private static class SingletonHolder {   
        /**  
         * 單例對象實例  
         */  
        static final SingletonKerrigan INSTANCE = new SingletonKerrigan();   
    }   
    
    public static SingletonKerrigan getInstance() {   
        return SingletonHolder.INSTANCE;   
    }   
    
    /**  
     * private的構造函數用於避免外界直接使用new來實例化對象  
     */  
    private SingletonKerrigan() {   
    }   
    
    /**  
     * readResolve方法應對單例對象被序列化時候  
     */  
    private Object readResolve() {   
        return getInstance();   
    }   
}  



總結: 

本章通過一次次的的嘗試,去了解單例模式各種實現方案的優缺點。對雙鎖鎖定檢測進行了簡單的討論,相信大家能從各種嘗試的演化過程中,明白爲何單例模式是最簡單而又最複雜的一種構造模式。 

---------------volatile介紹----------------- 
java的volatile是什麼意思 
我們知道,在Java中設置變量值的操作,除了long和double類型的變量外都是原子操作,也就是說,對於變量值的簡單讀寫操作沒有必要進行同步。 

這在JVM 1.2之前,Java的內存模型實現總是從主存讀取變量,是不需要進行特別的注意的。而隨着JVM的成熟和優化,現在在多線程環境下volatile關鍵字的使用變得非常重要。 

在當前的Java內存模型下,線程可以把變量保存在本地內存(比如機器的寄存器)中,而不是直接在主存中進行讀寫。這就可能造成一個線程在主存中修改了一個變量的值,而另外一個線程還繼續使用它在寄存器中的變量值的拷貝,造成數據的不一致。 

要解決這個問題,只需要像在本程序中的這樣,把該變量聲明爲volatile(不穩定的)即可,這就指示JVM,這個變量是不穩定的,每次使用它都到主存中進行讀取。一般說來,多任務環境下各任務間共享的標誌都應該加volatile修飾。 

Volatile修飾的成員變量在每次被線程訪問時,都強迫從共享內存中重讀該成員變量的值。而且,當成員變量發生變化時,強迫線程將變化值回寫到共享內存。這樣在任何時刻,兩個不同的線程總是看到某個成員變量的同一個值。 

Java語言規範中指出:爲了獲得最佳速度,允許線程保存共享成員變量的私有拷貝,而且只當線程進入或者離開同步代碼塊時才與共享成員變量的原始值對比。 

這樣當多個線程同時與某個對象交互時,就必須要注意到要讓線程及時的得到共享成員變量的變化。 

而volatile關鍵字就是提示VM:對於這個成員變量不能保存它的私有拷貝,而應直接與共享成員變量交互。 

使用建議:在兩個或者更多的線程訪問的成員變量上使用volatile。當要訪問的變量已在synchronized代碼塊中,或者爲常量時,不必使用。 

由於使用volatile屏蔽掉了VM中必要的代碼優化,所以在效率上比較低,因此一定在必要時才使用此關鍵字。 

注:我在單例模式中用到此關鍵字. 

$$ 單例的一個例子: 

public class Singleton(){
     private volatile static Singleton singleton;
     private Sington(){};
     public static Singleton getInstance(){
     if(singleton == null){
         synchronized (Singleton.class);
              if(singleton == null){
                 singleton = new Singleton();
                                   }
                          }
              }
return singleton;
        }
}
發佈了1 篇原創文章 · 獲贊 1 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章