創建型設計模式 之 正確使用單例模式

1定義

單例模式(Singleton Pattern)屬於創建型設計模式之一,它應該算上是我們日常開發裏最常用到的設計模式之一。其使用上就是讓在當前進程下指定的類只被初始化一次,並且一般會一直保存於內存中,保證了全局對象的唯一性。比如線程池、緩存、日誌等等都常常被設計成單例模式來使用。

1.1 那單例和靜態類的區別

常有人拿單例和靜態類作比較和混淆他們的使用場景,因爲它們都是用於全局的訪問。其實它們的區分還是很簡單的:

靜態類:不具備面向對象的特性,不支持延時加載,一般用於工具類,靜態的綁定是在編譯期進行的,所以其效率也高。

單例:具備面向對象的特性,如繼承父類、實現接口、多態等,支持延時加載和隨時釋放,可維護類對象狀態信息。

2 實現方式

一般單例模式一般會將構造方法置爲private,其實現方式會分爲立即加載和延時加載兩種方式,或者有人叫它們爲“餓漢式單例”和“懶漢式單例”。

2.1 立即加載

立即加載的單例,一般是使用靜態變量或靜態代碼塊。

靜態變量的單例

public class Singleton {
    private static Singleton singleton = new Singleton();
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

靜態代碼塊的單例

public class Singleton {
    private static Singleton singleton;
    static {
        singleton = new Singleton();
    }
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

調用

Singleton.getInstance().doSomething();

靜態變量和靜態代碼塊的本質上是一樣的,它們的構造方法都會在類的初始化階段中執行,而且是線程安全的,所以如果你的代碼不需要考慮內存問題且注重安全性,這種單例是最簡單實際的。

是不是隻要不調用getInstance方法,類就不會初始化,也就不會產生內存使用?

非也,如果一直不調用getInstance方法也有可能觸發類的構造方法初始化類,而且類可能早就被執行了加載,類只要加載了就會產生內存使用。因爲Java虛擬機的類加載機制   主要有七個階段:Loading(加載)、verification(驗證)、preparation(準備)、resolution(解析)、initialization(初始化)、using(使用)、unloading(卸載)。

載階段中會進行類二進制字節流獲取、創建運行的數據結構和創建類的實例。JVM規範允許類加載器在預料某個類將要被使用時就預先加載它,而且在調用一個類的.class和某個方法的返回類型是某類的話,那麼該類一定會被加載。比如下方,MyClass1和MyClass2都會被加載。

public class Main {
    public static void main(String[] args){
        System.out.println("hello word: " + MyClass1.class);
    }
    public MyClass2 test() {
        return null;
    }
}

我們可以在運行配置中將虛擬機參數加上:-verbose:class ,便能看到有如下輸出情況:

初始化階段時會執行類中靜態變量的賦值和靜態代碼塊,而初始化的觸發並非一定要調用getInstance方法主動創建類的實例,還有可以在外部訪問該類的其它靜態變量或靜態方法,該類的子類被初始化等。

正因爲上述描述中,我們在正常開發過程中或多或少都會存着這種間接性使類加載或初始化,所以如果你使用立即加載的方式使用單例模式的話,你就不要再糾結此類在什麼時候開始產生內存。如果一定要讓內存使用在刀刃上,那麼請繼續往下看延時加載的單例模式。

2.2 延時加載

2.2.1 線程不安全的單例

延時加載就是在真正使用時纔對類進行初始化,在延時加載的單例模式中最需要考慮的就是線程安全。因爲單例需要保證全局對象的唯一性,如果兩個線程剛好同時進行初始化就會產生不可相象的異常結果。比如:

public class Singleton {
    private static Singleton singleton;
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

上面的例子,它需要保證了到第一次調用getInstance方法時才初始化對象,但沒有保證在多線程中其對象的唯一性。

2.2.2 加同步鎖但影響性能的單例

有朋友可能會覺得,要保證線程同步不是很簡單,只需要給getIntstance加上一個同步鎖synchronized就可以了:

public class Singleton {
    private static Singleton singleton;
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static synchronized Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

沒有錯,加了synchronized關鍵字後,使getInstance方法加了同步鎖,確定能解決多線程產生多個實例的情況,但是鎖是有性能上的代價的,這樣犧牲了運行效率是得不償失的。

2.2.3 雙重檢查鎖解決同步鎖性能問題,但不實際

又有朋友提出,使用雙重檢查就可以避開每次訪問getInstance方法時進行觸發同步鎖,就可以提高執行效率了:

public class Singleton {
    private static Singleton singleton;
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

這種進化版的加同步鎖的單例一般叫做雙檢查鎖(Double-Checked Lock, DCL),在實現思路上很好地解決同步和性能問題,它在創造對象的時刻能鎖定初始化對象保證了多線程安全,在對象創建後期又通過判空避免了鎖性能的消耗,但是這僅僅限於思路上,因爲理論很完美,現實很殘酷。如果你正在使用C++語法開發可能不會有問題,但是如果你使用的是Java語法的話那麼依然會產生多對象的災難。

原因是什麼?這個就要從Java平臺內存模型允許“無序寫入”說起。我們看到的簡單的一句類對象創建:singleton = new Singleton(); 代碼,它底層的進行經歷以下三步:

1. 分配內存空間

2. 初始化對象

3. 給靜態變量singleton指向剛分配的內存地址

然而在虛擬機實際運行時,以上步驟可能會發生重排序,也就是說第2和第3步,可能會存在次序顛倒的情況,因爲就是Java平臺內存模型允許的。這樣就會造成線程A在對象創建工作時,而線程B可能同時對一個尚未初始化的對象判斷它爲非空,從而獲得了一個還未初始化完成的對象。

 

線程A

線程B

1

分配內存空間

 

2

給變量賦值(原第3步)

 

3

 

判斷對象是否爲null

5

 

不爲null,返回錯誤的對象

5

初始化對象(原第2步)

 

所以使用雙重檢查的方式進行避免同步鎖的性能消耗來達到單例的效果只不過是一次學術實踐罷了,此方案行不通。

2.2.4 volatile解決雙重檢查鎖重排序,但對類成員有要求

在JDK1.5後,可以通過volatile關鍵字來禁止對象創建時步驟被重排序。除此外volatile也是Java提供的一種輕量級的同步機制。因爲線程本身會存在着一個本地內存,在一般情況下並非立即同步到主內存中去,當多線程同時操作一個共享變量時就會容易發生讀寫併發時其值更新不同步的情況,而使用volatile修飾的變量JVM會把線程對應的本地內存強制刷新到主內存中,從而保證其它線程立即得知新值更新。

public class Singleton {
    private static volatile Singleton singleton;
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

然而儘管可以使用volatile關鍵字對單例的類對象作修飾,從而可以解決雙重檢查鎖的對象創建重排序的缺陷。但是它仍然允許類裏非volatile聲明變量在讀寫操作的重新排序。這意味着,除非類中所有字段都必須全部使用volatile修飾,否則線程B仍然有可能獲得未完全初始化完成的對象。

2.2.5 使用僅有一個靜態對象的輔助類

當一個類中僅有一個需要初始化的靜態變量,而沒有其它的方法和字段時,JVM會自動有效地執行延遲初始化,直到程序中首次調用該類的靜態變量。

public class MySingleton {
    public static Singleton singleton = new Singleton();
}

上述示例代碼需要先將Singleton類的構造方法置回public,而Singleton對象的加載和初始化會在第一次調用MySingleton. singleton時被執行。這樣就可以解決延時加載且線程安全的情況。

2.2.6 使用靜態內部類的單例

使用僅有一個靜態對象的輔助類,雖然可以很好地解決延時加載且線程安全問題,但是每一個需要做成單例的類還必須附帶一個輔助類這難免顯得有點囉嗦,而且變量被外部直接引用在代碼風格上也不太好看。那麼可否將輔助類變成靜態內部類?如:

public class Singleton {
    private static class InnerSingleton {
        private static Singleton singleton = new Singleton();
    }
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        return InnerSingleton.singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

沒錯,使用靜態內部類可以塑造延時加載且線程安全的單例。靜態內部類不會隨着外部類的加載而加載、初始化而初始化。只有到了第一次調用Singleton. getInstance方法時,InnerSingleton類纔會被加載和初始化,所以其靜態變量singleton的初始化也是在InnerSingleton類的初始化階段進行。

我們還可以通過下面代碼嘗試觸發Singleton類的加載,而從輸出結果看來,並未發現有InnerSingleton類的加載情況。

public class Main {
    public static void main(String[] args){
        System.out.println("hello word: " + Singleton.class);
    }

    public Singleton test() {
        return null;
    }
}

使用靜態內部類的單例看似很完美,但是也有它的短板,那就是傳參問題。因爲通過靜態方法getInstance傳入參數時,無法直接傳遞到內部類中去,除非使用靜態變量中轉,這情況下像Android開如中,如Context這種參數如果使用靜態變量中轉就往往容易發生內存泄露問題以及代碼規範的一些警告,所以在使用上還需要注意。

 

3 總結

一個看似簡單的單例模式在使用上還是很有講究的,既要考慮安全又要兼顧性能和內存。筆者建議,如果你的單例類對象在程序起動後便開始工作的,那麼直接使用靜態變量或靜態代碼塊的立即加載的方式是最簡單實際的。但如果在開始時就需要控制好內存的使用,僅需要在將來某個時刻才觸發類初始化的,那就需要使用延時加載的單例,而在不考慮傳參的情況下靜態內部類形式延時加載單例是最安全又簡潔的方現。具體開發過程中使用哪類單類還需要開發者自行斟酌,根據實際情況實際應用。

 

 

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