孔乙己的疑問:單例模式有幾種寫法

HongJay發表於2019-01-01

引子

單例模式的文章可以說是百家爭鳴,今天我也來說道說道,大家共同提升。

單例模式的作用和使用場景

單例模式(Singleton Pattern)

確保某一個類只有一個例項,而且可以自行例項化並向整個系統提供這個例項,這個類稱為單例類,它提供全域性訪問的方法。 單例模式是一種物件建立型模式。

使用場景

比如一個應用中應該只存在一個ImageLoader例項。

Android中的LayoutInflater類等。

EventBus中getDefault()方法獲取例項。

保證物件唯一
  1. 為了避免其他程式過多建立該類物件。先禁止其他程式建立該類物件
  2. 還為了讓其他程式可以訪問到該類物件,只好在本類中,自定義一個物件。
  3. 為了方便其他程式對自定義物件的訪問,可以對外提供一些訪問方式。

這三步怎麼用程式碼體現呢?

  1. 將建構函式私有化。
  2. 在類中建立一個本類物件。
  3. 提供一個方法可以獲取到該物件。

單例模式的十二種寫法

一、餓漢式(靜態變數)
public class Singleton {
    private static Singleton instance = new Singleton();
    private Singleton() {
    }
    public static Singleton getInstance() {
        return instance;
    }
}
複製程式碼
二、餓漢式(靜態常量)

public class Singleton {
    private final static Singleton INSTANCE = new Singleton();
    private Singleton() {
    }
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

複製程式碼
三、餓漢式(靜態程式碼塊)

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

複製程式碼

上面三種寫法本質上其實是一樣的,也是各類文章在介紹餓漢式時常用的方式。但使用靜態final的例項物件或者使用靜態程式碼塊依舊不能解決在反序列化、反射、克隆時重新生成例項物件的問題。

序列化:一是可以將一個單例的例項物件寫到磁碟,實現資料的持久化;二是實現物件資料的遠端傳輸。 當單例物件有必要實現 Serializable 介面時,即使將其建構函式設為私有,在它反序列化時依然會通過特殊的途徑再建立類的一個新的例項,相當於呼叫了該類的建構函式有效地獲得了一個新例項!

反射:可以通過setAccessible(true)來繞過 private 限制,從而呼叫到類的私有建構函式建立物件。

克隆:clone()是 Object 的方法,每一個物件都是 Object 的子類,都有clone()方法。clone()方法並不是呼叫建構函式來建立物件,而是直接拷貝記憶體區域。因此當我們的單例物件實現了 Cloneable 介面時,儘管其建構函式是私有的,仍可以通過克隆來建立一個新物件,單例模式也相應失效了。

優點:寫法比較簡單,在類裝載的時候就完成例項化。避免了執行緒同步問題。

缺點:在類裝載的時候就完成例項化,沒有達到Lazy Loading的效果。如果從始至終從未使用過這個例項,則會造成記憶體的浪費。


那麼我們就要考慮懶載入的問題了。

四、懶漢式(執行緒不安全)

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

複製程式碼

優點:懶載入,只有使用的時候才會載入。

缺點:但是隻能在單執行緒下使用。如果在多執行緒下,instacnce物件還是空,這時候兩個執行緒同時訪問getInstance()方法,因為物件還是空,所以兩個執行緒同時通過了判斷,開始執行new的操作。所以在多執行緒環境下不可使用這種方式。

五、懶漢式(執行緒安全,存在同步開銷)

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

複製程式碼

優點:懶載入,只有使用的時候才會載入,獲取單例方法加了同步鎖,保正了執行緒安全。

缺點:效率太低了,每個執行緒在想獲得類的例項時候,執行getInstance()方法都要進行同步。

六、懶漢式(執行緒假裝安全,同步程式碼塊)

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

複製程式碼

優點:改進了第五種效率低的問題。

缺點:但實際上這個寫法還不能保證執行緒安全,和第四種寫法類似,只要兩個執行緒同時進入了 if (singleton == null) { 這句判斷,照樣會進行兩次new操作


接下來就是聽起來很牛逼的雙重檢測加鎖的單例模式。

七、DCL「雙重檢測鎖:Double Checked Lock」 單例(假)

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

複製程式碼

本例的亮點都在getInstance()方法上,可以看到在該方法中對instance進行了兩次判空:第一層判斷為了避免不必要的同步,第二層判斷則是為了在null的情況下建立例項。對第六種單例的漏洞進行了彌補,但是還是有丶小問題的,問題就在instance = new Singleton();語句上。

這語句在這裡看起來是一句程式碼啊,但實際上它並不是一個原子操作,這句程式碼最終會被編譯成多條彙編指令,它大致做了3件事情:

  1. 給Singleton的例項分配記憶體
  2. 呼叫Singleton()的 建構函式,初始化成員欄位
  3. 將instance物件指向分配的記憶體空間(此時instance就不是null了)

但是,由於Java編譯器執行處理器亂序執行,以及jdk1.5之前Java記憶體模型中Cache、暫存器到主記憶體會寫順序的規定,上面的第二和第三的順序是無法保證的。也就是說,執行順序可能是1-2-3也可能是1-3-2.如果是後者,並且在3執行完畢、2未執行之前,被切換到執行緒B上,這時候instance因為已經線上程A內執行3了,instance已經是非null,所有執行緒B直接取走instance,再使用時就會出錯,這就是DCL失效問題,而且這種難以跟蹤難以重現的問題很可能會隱藏很久。

優點:執行緒安全;延遲載入;效率較高。

缺點:JVM編譯器的指令重排導致單例出現漏洞。

八、DCL「雙重檢測鎖:Double Checked Lock」 單例(真,推薦使用)

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

複製程式碼

在jdk1.5之後,官方已經注意到這種問題,調整了JVM、具體化了volatile關鍵字,因此,如果是1.5或之後的版本,只需要將instance的定義改成private static volatile Singleton instance = null;

就可以保證instance物件每次都是從主記憶體中讀取,就可以使用DCL的寫法來完成單例模式。當然,volatile多少會影響到效能,但考慮到程式的正確性,犧牲這點效能還是值得的。

優點:執行緒安全;延遲載入;效率較高。

缺點:由於volatile關鍵字會遮蔽Java虛擬機器所做的一些程式碼優化,略微的效能降低,但除非你的程式碼在併發場景比較複雜或者低於JDK6版本下使用,否則,這種方式一般是能夠滿足需求的。

九、靜態內部類(推薦使用)

public class Singleton {
    private Singleton() {
    }
    private static class SingletonInstance {
        private static final Singleton INSTANCE = new Singleton();
    }
    public static Singleton getInstance() {
        return SingletonInstance.INSTANCE;
    }
}

複製程式碼

這種方式跟餓漢式方式採用的機制類似,但又有不同。 兩者都是採用了類裝載的機制來保證初始化例項時只有一個執行緒。不同的地方在餓漢式方式是隻要Singleton類被裝載就會例項化,沒有Lazy-Loading的作用,而靜態內部類方式在Singleton類被裝載時並不會立即例項化,而是在需要例項化時,呼叫getInstance方法,才會裝載SingletonInstance類,從而完成Singleton的例項化。

所以在這裡,利用 JVM的 classloder 的機制來保證初始化 instance 時只有一個執行緒。JVM 在類初始化階段會獲取一個鎖,這個鎖可以同步多個執行緒對同一個類的初始化

優點:避免了執行緒不安全,延遲載入,效率高。

缺點:依舊不能解決在反序列化、反射、克隆時重新生成例項物件的問題。

十、列舉

public enum Singleton {
    INSTANCE
}

複製程式碼

列舉類單例模式是《Effective Java》作者 Josh Bloch 極力推薦的單例方法

藉助JDK 1.5中新增的列舉來實現單例模式。P.S. Enum是沒有clone()方法的

  1. 列舉類型別是 final 的「不可以被繼承」
  2. 構造方法是私有的「也只能私有,不允許被外部例項化,符合單例」
  3. 類變數是靜態的
  4. 沒有延時初始化,隨著類的初始化就初始化了「從上面靜態程式碼塊中可以看出」
  5. 由 4 可以知道列舉也是執行緒安全的

優點:寫法簡單,不僅能避免多執行緒同步問題,而且還能防止反序列化、反射,克隆重新建立新的物件。

缺點:JDK 1.5之後才能使用。

十一、登記式單例--使用Map容器來管理單例模式

public class SingletonManger {
    private static Map<String, Object> objectMap = new HashMap<String, Object>();
    private SingletonManger() {
    }
    public static void registerService(String key, Object instance) {
        if (!objectMap.containsKey(key)) {
            objectMap.put(key, instance);
        }
    }
    public static Object getService(String key) {
        return objectMap.get(key);
    }
}

複製程式碼

查閱Android原始碼中的 LayoutInflater 物件就能發現使用了這種寫法

優點:在程式的初始,將多種單例型別注入到一個統一的管理類中,在使用時根據key獲取物件對應型別的物件。這種方式使得我們可以管理多種型別的單例,並且在使用時可以通過統一的介面進行獲取操作, 降低了使用者的使用成本,也對使用者隱藏了具體實現,降低了耦合度。

缺點:不常用,有些麻煩

十二、內部列舉類

在微信公眾號看到有大佬說使用列舉配合內部類實現內部列舉類,可以達成執行緒安全,懶載入,責任單一原則,等等是現在最完美的寫法。


孔乙己的疑問:單例模式有幾種寫法
四種需求的滿足情況圖

總結

如果你和我一樣是Android開發,那麼由於在客戶端通常沒有高併發的情況,選擇哪種實現方式並不會有太大的影響。但即便如此,出於效率考慮我們也應該使用後面幾種單例方法。

單例模式的優點

單例模式的優點其實已經在定義中提現了:可以減少系統記憶體開支,減少系統效能開銷,避免對資源的多重佔用、同時操作。

單例模式的缺點
  1. 違反了單一責任鏈原則,測試困難 單例類的職責過重,在一定程度上違背了“單一職責原則”。因為單例類既充當了工廠角色,提供了工廠方法,同時又充當了產品角色,包含一些業務方法,將產品的建立和產品的本身的功能融合到一起。
  2. 擴充套件困難 由於單例模式中沒有抽象層,因此單例類的擴充套件有很大的困難。修改功能必須修改原始碼。
  3. 共享資源有可能不一致。 現在很多物件導向語言(如Java、C#)的執行環境都提供了自動垃圾回收的技術,因此,如果例項化的共享物件長時間不被利用,系統會認為它是垃圾,會自動銷燬並回收資源,下次利用時又將重新例項化,這將導致共享的單例物件狀態的丟失。
注意在Application中存取資料

在Android 應用啟動後、任意元件被建立前,系統會自動為應用建立一個 Application類(或其子類)的物件,且只建立一個。從此它就一直在那裡,直到應用的程式被殺掉。

所以雖然 Application並沒有採用單例模式來實現,但是由於它的生命週期由框架來控制,和整個應用的保持一致,且確保了只有一個,所以可以被看作是一個單例。 但是如果你直接用它來存取資料,那你將得到無窮無盡的NullPointerException。

因為Application 不會永遠駐留在記憶體裡,隨著程式被殺掉,Application 也被銷燬了,再次使用時,它會被重新建立,它之前儲存下來的所有狀態都會被重置。

要預防這個問題,我們不能用 Application 物件來傳遞資料,而是要:

  1. 通過傳統的 intent 來顯式傳遞資料(將 Parcelable 或 Serializable 物件放入Intent / Bundle。Parcelable 效能比 Serializable 快一個量級,但是程式碼實現要複雜一些)。

  2. 重寫onSaveInstanceState()以及onRestoreInstanceState()方法,確保程式被殺掉時儲存了必須的應用狀態,從而在重新開啟時可以正確恢復現場。

  3. 使用合適的方式將資料儲存到資料庫或硬碟。

  4. 總是做判空保護和處理。


參考文章

《Android 原始碼設計模式解析與實戰》

www.cnblogs.com/zhaoyan001/…

www.jianshu.com/p/4f4f2fa7e…

www.jianshu.com/p/9b3587e8b…

相關文章