單例模式創建方式各自特點

單例模式創建方式各自特點

前言

這是設計模式的第一篇文章,我們從單例模式開始入手,單例模式是 Java 設計模式中最簡單的一種,只需要一個類就能實現單例模式,但是,你可不能小看單例模式,雖然從設計上來說它比較簡單,但是在實現當中你會遇到非常多的坑,所以,繫好安全帶,上車。

單例模式定義

單例模式就是在程序運行中只實例化一次,創建一個全局唯一對象,有點像 Java 的靜態變量,但是單例模式要優於靜態變量,靜態變量在程序啓動的時候JVM就會進行加載,如果不使用,會造成大量的資源浪費,單例模式能夠實現懶加載,在使用實例的時候纔去創建實例。開發工具類庫中的很多工具類都應用了單例模式,比例線程池、緩存、日誌對象等,它們都只需要創建一個對象,如果創建多份實例,可能會帶來不可預知的問題,比如資源的浪費、結果處理不一致等問題。

單例的實現思路

  • 靜態化實例對象
  • 私有化構造方法,禁止通過構造方法創建實例
  • 提供一個公共的靜態方法,用來返回唯一實例

單例的好處

  • 只有一個對象,內存開支少、性能好
  • 避免對資源的多重佔用
  • 在系統設置全局訪問點,優化和共享資源訪問

單例模式的實現方式

單例模式的寫法有餓漢模式懶漢模式雙重檢查鎖模式靜態內部類單例模式、枚舉類實現單例模式五種方式,其中懶漢模式、雙重檢查鎖模式,如果你寫法不當,在多線程情況下會存在不是單例或者單例出異常等問題,具體的原因,在後面的對應處會進行說明。我們從最基本的餓漢模式開始我們的單例編寫之路。

餓漢式

餓漢模式採用一種簡單粗暴的形式,在定義靜態屬性時,直接實例化了對象。代碼如下

public class SingleTon {
    //用靜態變量存儲唯一實例化對象
    private static SingleTon INSTANCE1 = new SingleTon();

    //私有化構造函數
    private SingleTon() {}
    
    //提供公共的靜態方法,用來返回唯一的實例
    public static SingleTon getInstance1() {
        return INSTANCE1;
    }
}

//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.getInstance1();
    }
}
優點
  • 線程安全,用空間換時間。

    因爲餓漢式單例模式,定義的static靜態屬性直接實例化對象,因此在JVM加載初始化類的時候就初始化了對象,保證了線程安全。

缺點
  • 無法延遲實例化造成空間浪費

    如果一個類比較大,我們在初始化的時候就實例化了這類,但是長時間沒有使用到這個類就造成了內存空間浪費。

  • 單例容易被破壞(反射、序列化、反序列化)

懶漢式

懶漢模式是一種偷懶的模式,在程序初始化時不會創建實例,只有在使用實例的時候纔會創建實例,所以懶漢模式解決了餓漢模式帶來的空間浪費問題,同時也引入了其他的問題,我們先來看看下面這個懶漢模式

public class SingleTon {
	//靜態化實例對象
 	private static SingleTon INSTANCE2 = null;

	//私有化構造函數
	private SingleTon() {}
    
    //通過公共的靜態方法返回唯一實例
    public static SingleTon getInstance2(){
        // 使用時,先判斷實例是否爲空,如果實例爲空,則實例化對象
        if(INSTANCE2 == null){
            INSTANCE2 = new SingleTon();
        }
        return INSTANCE2;
    }
    
    
//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.getInstance2();
    }
}

上面是懶漢模式的實現方式,但是上面這段代碼在多線程的情況下是不安全的,因爲它不能保證是單例模式,有可能會出現多份實例的情況,出現多份實例的情況是在創建實例對象時候造成的。所以我單獨把實例化的代碼提出,來分析一下爲什麼會出現多份實例的情況。

1 if(INSTANCE2 == null){
2 	INSTANCE2 = new SingleTon();
  }

假設有兩個線程都進入到 1 這個位置,因爲沒有任何資源保護措施,所以兩個線程可以同時判斷的 instance都爲空,都將去執行 2 的實例化代碼,所以就會出現多份實例的情況。

通過上面的分析我們已經知道出現多份實例的原因,如果我們在創建實例的時候進行資源保護,是不是可以解決多份實例的問題?確實如此,我們給 getInstance()方法加上 synchronized關鍵字,使得 getInstance()方法成爲受保護的資源就能夠解決多份實例的問題。加上 synchronized關鍵字之後代碼如下:

/**
 * 添加class類鎖,影響了性能,加鎖之後將代碼進行了串行化,
 * 我們的代碼塊絕大部分是讀操作,在讀操作的情況下,代碼線程是安全的
 */
public synchronized static SingleTon getInstance2(){
        if(INSTANCE2 == null){
            INSTANCE2 = new SingleTon();
        }
        return INSTANCE2;
    }

經過修改後,我們解決了多份實例的問題,但是因爲加入了 synchronized關鍵字,對代碼加了鎖,就引入了新的問題,加鎖之後會使得程序變成串行化,只有搶到鎖的線程才能去執行這段代碼塊,這會使得系統的性能大大下降。

優點
  • 實現了延遲實例化,節省內存空間
缺點
  • 在不加鎖情況下,線程不安全,可能存在多份實例。
  • 在加鎖情況下,程序串行化,系統存在嚴重性能問題。
  • 單例容易被破壞(反射、序列化、反序列化)

懶漢式-雙重檢驗鎖

再來討論一下懶漢模式中加鎖的問題,對於 getInstance()方法來說,絕大部分的操作都是讀操作,讀操作是線程安全的,所以我們沒必讓每個線程必須持有鎖才能調用該方法,我們需要調整加鎖的問題。由此也產生了一種新的實現模式:雙重檢查鎖模式,下面是雙重檢查鎖模式的單例實現代碼塊:

 public class SingleTon {
    //靜態化實例對象
    public static SingleTon INSTANCE3 = null;

    //私有化構造函數
    private SingleTon() {}
    
    //提供公共的靜態方法,返回唯一的實例
    public static SingleTon getInstance3() {
        //第一次判斷,如果這裏爲空,不進入搶鎖階段,直接返回實例
        if (INSTANCE3 == null) {
            synchronized (SingleTon.class) {
                //搶到鎖之後再次判斷是否爲空
                if (INSTANCE3 == null) {
                    INSTANCE3 = new SingleTon();
                }
            }
        }
        return INSTANCE3;
    }
}

雙重檢查鎖模式是一種非常好的單例實現模式,解決了單例、性能、線程安全問題,上面的雙重檢測鎖模式看上去完美無缺,其實是存在問題,在多線程的情況下,可能會出現空指針問題,出現問題的原因是JVM在實例化對象的時候會進行優化和指令重排序操作(亂序執行)。什麼是指令重排?,看下面這個例子,簡單瞭解一下指令從排序

private SingletonObject4(){
        1 int x = 10;
        2 int y = 30;
        3 Object o = new Object();
    }

上面的構造函數 SingletonObject4(),我們編寫的順序是1、2、3,JVM 會對它進行指令重排序,所以執行順序可能是3、1、2,也可能是2、3、1,不管是那種執行順序,JVM 最後都會保證所有實例都完成實例化。如果構造函數中操作比較多時,爲了提升效率,JVM 會在構造函數裏面的屬性未全部完成實例化時,就返回對象。雙重檢測鎖出現空指針問題的原因就是出現在這裏,當某個線程獲取鎖進行實例化時,其他線程就直接獲取實例使用,由於JVM指令重排序的原因,其他線程獲取的對象也許不是一個完整的對象,所以在使用實例的時候就會出現空指針異常問題。

回到雙重檢測鎖創建單例代碼中:

INSTANCE3 = new SingleTon();

這個步驟,其實在jvm裏面執行分爲三步

  1. 在堆內存開闢內存空間。
  2. 在堆內存中實例化SingleTon裏面的各個參數。
  3. 把對象指向堆內存空間。

由於jvm存在指令重排亂序執行功能,所以可能在2還沒執行時就先執行了3,如果此時在被切換到線程B,由於執行了3,INSTANCE3已經非空了,會被直接拿出來用,這樣就會拋出異常,這就是著名的DCL失效問題。

要解決雙重檢查鎖模式帶來空指針異常的問題,在JDK1.5之後官方發現這個問題後故而具體化了volatile關鍵字,volatile確保INSTANCE每次均在主內存中讀取(而不是使用一個緩存值), volatile關鍵字嚴格遵循 happens-before原則,即在讀操作前,寫操作必須全部完成。添加 volatile關鍵字之後的單例模式代碼:

public class SingleTon {
    //靜態化實例對象(使用 volatile關鍵字修飾)
    public volatile static SingleTon INSTANCE3 = null;

    //私有化構造函數
    private SingleTon() {}
    
    //提供公共的靜態方法,返回唯一的實例
    public static SingleTon getInstance3() {
        //第一次判斷,如果這裏爲空,不進入搶鎖階段,直接返回實例
        if (INSTANCE3 == null) {
            synchronized (SingleTon.class) {
                //搶到鎖之後再次判斷是否爲空
                if (INSTANCE3 == null) {
                    INSTANCE3 = new SingleTon();
                }
            }
        }
        return INSTANCE3;
    }
}

//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.getInstance3();
    }
}

添加 volatile關鍵字之後的雙重檢查鎖模式是一種比較好的單例實現模式,能夠保證在多線程的情況下線程安全也不會有性能問題。

優點
  • 線程安全
  • 延遲實例化,避免內存空間浪費
缺點
  • 容易導致DCL失效(需要使用volatile關鍵字解決)

  • 單例容易被破壞(反射、序列化、反序列化)

靜態內部類方式

靜態內部類單例模式也稱單例持有者模式,實例由內部類創建,由於 JVM 在加載外部類的過程中, 是不會加載靜態內部類的, 只有內部類的屬性/方法被調用時纔會被加載, 並初始化其靜態屬性。靜態屬性由 static修飾,保證只被實例化一次,並且嚴格保證實例化順序。靜態內部類單例模式代碼如下:

public class SingleTon {
    //私有化構造函數
    private SingleTon() {}
    
    //單例持有者(靜態內部類創建單例)
    private static class Build {
        private static SingleTon INSTANCE4 = new SingleTon();
    }
    
    //提供公共靜態方法,返回唯一的實例對象
    public static SingleTon getInstance4() { 
		//調用內部類屬性
        return Build.INSTANCE4;
    }
}

//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.getInstance4();
    }
}

靜態內部類單例模式是一種優秀的單例模式,是開源項目中比較常用的一種單例模式。在沒有加任何鎖的情況下,保證了多線程下的安全,並且沒有任何性能影響和空間的浪費。

優點
  • 線程安全
  • 延遲實例化,避免內存空間浪費
缺點
  • 無法傳參數

    由於是靜態內部類的形式去創建單例的,故外部無法傳遞參數進去,例如Context這種參數,所以,我們創建單例時,可以在靜態內部類與DCL模式裏自己斟酌。

枚舉方式

枚舉類實現單例模式是 effective java 作者極力推薦的單例實現模式,因爲枚舉類型是線程安全的,並且只會裝載一次,設計者充分的利用了枚舉的這個特性來實現單例模式,枚舉的寫法非常簡單,而且枚舉類型是所用單例實現中唯一一種不會被破壞的單例實現模式。

public enum SingleTon{
    INSTANCE;
}

//使用
//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.INSTANCE
    }
}

通過將定義好的枚舉反編譯,我們就能發現,其實枚舉在經過javac的編譯之後,會被轉換成形如public final class T extends Enum的定義。而且,枚舉中的各個枚舉項同事通過static來定義的。如:

public enum SingleTon {
    INSTANCE;
}

反編譯後代碼爲:

public final class SingleTon extends Enum
{
    //省略部分內容
    public static final SingleTon INSTANCE;
    private static final SingleTon ENUM$VALUES[];
    static
    {
        INSTANCE = new SingleTon("INSTANCE", 0);
        ENUM$VALUES = (new T[] {INSTANCE});
    }
}

瞭解JVM的類加載機制的朋友應該對這部分比較清楚。static類型的屬性會在類被加載之後被初始化,我們在深度分析Java的ClassLoader機制(源碼級別)中介紹過,當一個Java類第一次被真正使用到的時候靜態資源被初始化、Java類的加載和初始化過程都是線程安全的(因爲虛擬機在加載枚舉的類的時候,會使用ClassLoader的loadClass方法,而這個方法使用同步代碼塊保證了線程安全)。所以,創建一個enum類型是線程安全的。

也就是說,我們定義的一個枚舉,在第一次被真正用到的時候,會被虛擬機加載並初始化,而這個初始化過程是線程安全的。而我們知道,解決單例的併發問題,主要解決的就是初始化過程中的線程安全問題。

所以,由於枚舉的以上特性,枚舉實現的單例是天生線程安全的。

優點
  • 實現簡單

  • 線程安全,不會出現DCL失效問題

  • 單例不會被破壞(反射、序列化、反序列化)

缺點
  • 不能延遲實例化

破壞單例模式的方法及解決辦法

除枚舉方式外, 其他方法都會通過反射或序列化和反序列化的方式破壞單例。

解決反射破壞單例

反射是通過調用構造方法生成新的對象,所以如果我們想要阻止單例破壞,可以在構造方法中進行判斷,若已有實例, 則阻止生成新的實例,解決辦法如下:

//私有化構造函數
private SingleTon() {
   if(INSTANCE1 != null){
      throw new RuntimeException("此類被設計爲單例模式,不允許重複創建對象,"+
       "請使用getInstance()獲取單例對象");
    }
}

解決序列化、反序列化破壞單例

如果單例類實現了序列化接口Serializable, 就可以通過反序列化破壞單例,所以我們可以不實現序列化接口,如果非得實現序列化接口,可以重寫反序列化方法readResolve(), 反序列化時直接返回相關單例對象。

public Object readResolve () throws ObjectStreamException {
       return instance;  
}

總結

單例創建的方式多種,如果明確要求要懶加載(lazy initialization)會傾向於使用靜態內部類,如果涉及到反序列化創建對象時會試着使用枚舉方式來實現單例。

參考:

爲什麼我牆裂建議大家使用枚舉來實現單例

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