你知道有多少種方式實現單例模式?

前言

單例模式是應用最廣的模式之一,也是最簡單的模式,但越是簡單的東西,就越容易忽略它的細節,在應用這個模式時,同一個進程內,單例對象的類必須保證只有一個實例存在,比如在一個應用中,應該只有一個ImagerLoader實例,因爲這個ImagerLoader中含有線程池、緩存系統、網路請求等,創建一次需要消耗很多資源,因此,沒有理由讓它構造多個實例,這種不能自由的構造對象,確保某一個類有且只有一個對象實例的情況,就是單例模式的使用場景,那麼你知道有多少種方式實現單例?具我所瞭解的,有六種,下面分別介紹。

類圖

簡單看一下單例模式的類圖:

角色介紹:

  • Client - 高端客戶層
  • Singleton - 單例類

實現方式

以公司中的CEO來舉例,一個公司中只能有一個CEO,所以CEO就是單例,可以使用單例模式來實現,下面以這個例子來實現單例.

1、餓漢方式

public class CEO{
    //靜態對象
    private static CEO mCeo = new CEO();
    //構造函數私有
    private CEO(){}
    
    //公有的靜態函數,對外暴露獲取單例對象的接口
    public static CEO getInstance(){
        return mCeo;
    }
}

這種方式叫做餓漢方式,它的關鍵點是:

1、構造函數私有;

2、通過一個靜態方法返回一個靜態對象實例.

爲什麼這種方式能夠保證實例的唯一性呢?因爲在同一個類加載器下,類的初始化只會進行一次,並且在多線程環境下,JVM會保證只有一個線程執行類的初始化,所以當我們第一次調用getInstance方法,訪問mCeo靜態變量時,CEO類還沒有沒有進行初始化,就會首先進行CEO類的初始化,類的初始化之前會經歷加載、驗證、準備、解析階段,然後纔到初始化階段,在初始化階段中JVM會執行static語句塊,此時就會爲CEO在java堆上爲分配一塊內存,然後把mCeo指向這塊內存,即實例化了CEO,接着getInstance方法就會返回CEO的實例,當我們第二次調用getInstance()方法時,它會返回上次在類初始化階段創建的CEO實例,而不會再進行一次類的初始化,所以這就保證了每次調用getInstance()方法都是返回同一個實例

餓漢方式的優點是線程安全,類初始化時就完成實例的創建,以後調用getInstance方法獲取對象實例時速度比較快,缺點是會造成類初始化過程變慢,還可能會提前初始化單例類,例如CEO中有另外一個靜態方法hello(),我第一次調用時並不是調用getInstance方法而是調用hello方法,它也會進行CEO類的初始化,導致單例類也完成實例創建,但此時我並沒有使用這個單例類,所以說餓漢方式的優點也是它的缺點。

類的初始化和類的實例化是兩個不同的過程,在類的實例化過程中,如果發現類還未進行初始化,就會先進行類的初始化.

2、靜態內部類形式

public class CEO{
    //構造函數私有
    private CEO(){}
    
    public static CEO getInstanse(){
        //返回靜態內部類中的靜態變量實例
        return SingletonHolder.mCeo;
    }

    /**
     * 靜態內部類
     */
    private static class SingletonHolder{
        private static CEO mCeo = new CEO();
    }
    
}

靜態內部類方式的關鍵點是:

1、構造函數私有;

2、通過一個靜態方法返回靜態內部類中的靜態變量實例.

靜態內部類形式的特點和餓漢方式一樣都是基於類的初始化保證實例的唯一性,同時它在餓漢方式的基礎上增加了延遲初始化,在餓漢方式中說到在CEO類初始化時,就會完成單例類實例的創建,這樣就可能導致了提前初始化了單例類,造成資源浪費,而靜態內部類就解決了這個缺點,當第一次初始化CEO類時並不會創建CEO實例,只有在調用getInstance方法時纔會導致CEO類實例化,因爲第一次調用getInstance方法時會讓JVM初始化SingletonHolder類,在初始化SingletonHolder類的同時完成了CEO單例類實例的創建,然後返回CEO實例,以後調用getInstance方法時都會返回同一實例.

這種方式的優點是不僅能保證線程安全,也能保證單例對象的唯一性,同時也延遲了單例的實例化,所以這是推薦使用的單例模式;它的缺點就是第一次加載時反應稍慢。

爲什麼基於類初始化的單例就是線程安全的呢?這是因爲類的初始化過程其實是在執行clinit方法,clinit方法又叫做類構造器,clinit方法是由編譯器收集所有的靜態變量賦值動作和static語句塊合併形成的,在多線程環境下,JVM執行clinit方法時,會給clinit方法加鎖,多個線程初始化類時,只有一個線程會獲得clinit方法的執行權,其他線程會阻塞等待,等某個線程執行完clinit方法後,就完成了類的初始化,這時就會喚醒其他等待線程,其他等待線程發現類已經執行過clinit方法了,就不再重複執行了,所以這就是單例模式線程安全實現的保證,也是單例模式實例唯一性的保證。

3、懶漢模式(線程安全)

public class CEO {
    private static CEO mCeo;
    //構造函數私有
    private CEO(){}
    
    //方法加上synchronized關鍵字
    public static synchronized CEO getInstance(){
        //判空處理,保證只實例化一次
        if(mCeo == null){
            mCeo = new CEO();
        }
        return mCeo;
    }
}

這種方式叫做線程安全的懶漢模式,它的關鍵點是:

1、構造函數私有;

2、通過一個同步靜態方法返回靜態對象實例;

3、靜態對象實例創建時加入了判空處理,保證只實例化一次.

通過給 getInstance方法加synchronized關鍵字,保證了在多線程環境下只有一個線程對CEO進行初始化,同時又加入了判空處理,避免了重複創建對象實例,保證每次調用getInstance方法都會返回同一個實例.

這種懶漢模式的優點是線程安全,單例只有在使用時纔會被實例化,節約資源,即延遲初始化;缺點是第一次加載時需要及時進行實例化,反應稍慢,還有每次調用getInstance方法時都進行同步,造成不必要的同步開銷,這種模式不建議使用。

還有一種叫非線程安全的懶漢模式,與線程安全的懶漢模式相比,只是少了一個synchronized關鍵字,不適合在多線程環境下使用,因爲沒有正確同步會造成創建多個實例,適合在單線程環境下使用。

4、Double Check Lock(DCL)

public class CEO {
    private static CEO mCeo = null;
     //構造函數私有
    private CEO(){}
    
    public static CEO getInstance(){
        //第一次判空,保證只同步一次
        if(mCeo == null){//1
            //synchronized語句塊,鎖住的是類的Class對象
            synchronized (CEO.class){
                //第二次判空,保證只實例化一次
                if(mCeo == null){
                    mCeo = new CEO();//2
                }
            }
        }
        return mCeo;
    }
}

這種方式的名字叫雙重檢查鎖定,簡稱DCL,它的關鍵點是:

1、構造函數私有;

2、通過一個靜態方法返回靜態對象實例;

3、靜態對象實例創建時加入了雙重判空處理 + synchronized塊,保證只同步一次只實例化一次.

在getInstance方法中對實例進行了倆次判空:第一次判空是爲了避免不必要的同步,解決了懶漢模式每次調用getInstance方法都需要同步的缺點,只有對象爲null的情況下才進入synchronized塊,才需要同步;第二次判空則是爲了進入synchronized塊後只有對象爲null的情況下才創建實例,避免重複創建對象實例,而且synchronized塊鎖住的是類的Class對象,保證了在多線程環境下只有一個線程進入synchronized塊,所以採用DCL方式每次調用 getInstance方法返回的都是同一個實例

DCL優點是資源利用率高,第一次執行getInstance方法時單例對象纔會被實例化,即延遲初始化,第一次實例化時才進行同步,減少了同步開銷,並且能在大多數情況下保證單例唯一性;缺點是第一次加載反應稍慢,因爲又要加鎖,又要初始化對象,導致第一次調用getInstance方法返回較慢。

爲什麼說DCL在大多數情況能下保證單例唯一性?這說明在少數情況下DCL還是會出現問題的,問題就出現在註釋2:mCeo = new CEO(); 在CEO第一次進行實例化過程中,這個實例化過程可以分爲以下3步:

1、在java堆分配中CEO對象的內存空間;

2、進行CEO類的初始化過程;

3、把mCeo指向1步驟中分配的內存空間.

某些JVM會把這3個步驟進行指令重排序,變爲以下順序:

1、在java堆分配中CEO對象的內存空間;

3、把mCeo指向1步驟中分配的內存空間;

2、進行CEO類的初始化過程.

如果在單線程環境下這樣是沒有問題的,因爲就算指令重排,在getInstance方法返回時mCeo指向的對象已經完成了初始化,但是在多線程環境下就出現問題了,假設現在有兩個線程A、B,線程A執行到了getInstance方法的註釋2,即進行CEO的實例化,由於指令重排,線程A先執行1、3步驟,此時mCeo已經指向了分配的內存空間,導致此時的mCeo != null,而恰好線程B此時執行到了getInstance方法的註釋1,進入判斷 if(mCeo == null),因爲此時mCeo != null,所以條件判斷爲false,不進入if語句,直接來到return語句,返回了還沒初始化完畢的mCeo ,這樣就可能導致程序崩潰!因爲你在使用一個還未初始化完成的對象。

針對DCL的錯誤,有兩種解決辦法,第一種辦法是使用Volatile關鍵字,因爲Volatile會禁止指令重排序,保證對象實例化過程按1、2、3步驟進行;第二種辦法是再加一個局部變量做一層緩衝,下面分別使用來完善DCL:

解決方法1:使用Volatile關鍵字

public class CEO {
    //加上Volatile關鍵字修飾
    private volatile static CEO mCeo = null;
    private CEO(){}
    
    public static CEO getInstance(){
        if(mCeo == null){
            synchronized (CEO.class){
                if(mCeo == null){
                    mCeo = new CEO();
                }
            }
        }
        return mCeo;
    }
}

熟悉Volatile特性的朋友都知道,在多線程環境下,Volatile會禁止指令重排序保證內存可見性,所以線程執行到mCeo = new CEO()時,保證CEO類初始化完畢後才把mCeo引用指向java堆的內存空間,避免另外一個線程訪問到未初始化完畢的mCeo。

解決方法2:增加一個局部變量

public class CEO {
    private static CEO mCeo = null;
    private CEO(){}
    
    public static CEO getInstance(){
        //1、增加一個局部變量,同爲CEO類型
        CEO ceo = null;
        if(mCeo == null){
            synchronized (CEO.class){
                if(mCeo == null){
                    //2、執行實例時,先實例化這個局部變量
                    ceo = new CEO();
                    //3、待局部變量實例化完畢後,才把這個實例賦值給要返回的靜態變量mCeo
                    mCeo = ceo;
                }
            }
        }
        return mCeo;
    }
}

這種方法也能夠保證DCL的正確性,因爲它是先把同爲CEO類型的局部變量ceo實例化後,才賦值給mCeo,這就不管ceo實例化過程中怎麼樣重排序,在ceo還未初始化完畢之前,mCeo一直爲null,當ceo實例化完畢後,mCeo才指向它,這樣就避免了mCeo指向一個未初始化完畢的對象。

使用DCL時,建議使用解決方法1、2中的DCL方式。

5、枚舉模式

public enum CEO{
    INSTANCE;
    
    //枚舉中還可以定義一些方法和字段
    String name = "ceo";
    public void doSomething(){
        System.out.println("do Something");
    }
}

枚舉模式的關鍵點是:在枚舉類中定義一個枚舉,叫什麼名字都可以,這裏叫INSTANCE,而且只能定義一個枚舉,不能定義第二個枚舉如INSTANCE2。

寫法簡單是枚舉單例最大的優點,枚舉實例的創建天生就是線程安全的,並且任何情況下它都是一個單例,我們直接通過CEO.INSTANCE就可以訪問到這個單例,枚舉中還可以爲這個單例定義一些方法,例如這裏我定義了一個doSomething方法,我通過**CEO.INSTANCE.doSomething()**就可以調用這個方法,字段的訪問同理。

那麼枚舉的實現原理是什麼?接下來把CEO.java文件編譯成CEO.class,然後通過jad工具反編譯,如下:

//CEO枚舉類反編譯後的java代碼
public final class CEO extends Enum{
    //...
    
    private CEO(String s, int i){
        super(s, i);
        name = "ceo";
    }
    
   public static final CEO INSTANCE;

    String name;
    public void doSomething(){
        System.out.println("do Something");
    }

    static {
        INSTANCE = new CEO("INSTANCE", 0);
        //...
    }
}

我省略了一些無關代碼,可以看到CEO繼承自一個Enum類,所以枚舉類本質還是一個類,並且它是final的,所以它不可被繼承,它裏面的構造方法私有,並且INSTANCE字段是一個靜態變量,在static語句塊中實例化,所以枚舉模式保持單例唯一性的本質還是基於類的初始化,它的原理和前面講過的餓漢方式、靜態內部類形式一樣。

在effective java中,枚舉模式被推薦爲實現的單例是最好的方式

6、使用容器實現

前面所講的方式都是針對單個類的單例,如果一個程序中存在着多種類型的單例,就可以通過一個容器把它們集中管理:

public class SingletonManager extends Staff {
    private static Map<String, Object> mServices = new HashMap<>();
    
    private SingletonManager(){}
    
     public static void registerService(String key, Object instance){
         //加入了一個判斷處理,避免重複添加
        if(!mServices.containsKey(key)){
            mServices.put(key, instance);
        }
    }
    
    public static Object getService(String key){
        return mServices.get(key);
  }

這種方式的關鍵點是:將多種類型的單例注入到一個統一的管理類中,且只能注入一次(注入時判斷),在使用時根據key可以獲取對象對應的單例。

這種方式可以讓我們管理多種類型的單例,並且使用時通過統一的接口進行獲取,例如在Android中,各種類型的服務在應用啓動時都註冊在Context內的一個容器中,我們需要使用各種服務時,可以通過Context的getService方法獲得服務的單例,服務不能重複創建,會很消耗資源。

考慮反序列化

上面所介紹的6種實現單例的方法中,除了使用容器實現單例模式的方法,其他5種方法都有以下共同的關鍵點:

1、構造函數私有;

2、通過一個靜態方法或枚舉返回單例對象;

3、在多線程環境下,確保單例類的實例只有一個.

一般要實現單例模式,做到這3個點就行了,這樣就能確保在同一個進程內單例類只有一個實例存在,但是,如果你的單例類是可以保存到磁盤或通過網絡傳輸,換句話說你的單例類是支持序列化的,那麼你就要保證單例類在反序列化時不會重新創建新的對象實例,因爲反序列化時會創建一個和單例一模一樣的實例的,java中通過ObjectInputStream的readObject方法來實現反序列化,它裏面會通過反射來創建一個新的實例,所以就算你的構造方法私有,它還是可以通過setAccessible(true)來獲得單例構造器的訪問權,從而創建一個新的對象實例,反序列化的本質就是反射,換句話說,反射會破壞單例模式的實例唯一性

那麼如何確保單例類反序化時不會重新創建新實例呢?只要在單例類中加入以下方法:

public class CEO{
    private static CEO mCeo = null;
    
    //...
    
    private Object readResolve() throws ObjectStreamException {
        //返回單例對象
        return mCeo;
    }
}

加入readResolve方法後就可以改變反序列化的規則,在readObject方法中,它發現該類定義了readResolve方法,它就會通過反射調用readResolve方法返回對象實例,而不是默認的通過反射重新創建一個新的對象,所以只要我們在readResolve方法中返回單例對象,readObject方法就會返回單例對象,這樣就防止了單例模式被反序列化的破壞。

注意:對於枚舉模式不存在反序列化重新生成實例的情況,所以對於枚舉模式實現的單例不用考慮反序列化情況,因爲枚舉的反序列化不是通過反射實現的,而是通過其他方式實現,枚舉有自己的一套處理反序列化的機制,類似於使用容器的方式,有興趣可以自己查找資料,而對於餓漢模式、靜態內部類形式、懶漢模式、DCL、容器中的單例就需要考慮反序列情況。

總結

本文講解了6種方式實現單例模式,分別是餓漢方式、靜態內部類形式、懶漢模式、DCL、枚舉模式和容器方式,不管使用哪種形式實現單例,其核心思想都是以下4點:

1、構造函數私有化;

2、通過靜態方法獲取單例;

3、保證線程安全;

4、避免反序列化重新生成實例.

這6種方式的優缺點如下:

餓漢方式 靜態內部類方式 懶漢模式(線程安全) DCL 枚舉模式 容器方式
優點 安全,獲取單例速度快 安全,延遲初始化 安全,延遲初始化 安全,延遲初始化 寫法簡潔,延遲初始化,安全,反射也無法破壞單例 實現簡單,獲取單例速度快
缺點 提前初始化單例類,浪費空間 第一次使用反應慢 第一次使用反應慢,效率低,同步開銷大 寫法複雜,第一次使用反應慢 暫時沒發現缺點,枚舉是java5之後才加入,使用的人少,很多人不熟悉枚舉 需要保證線程安全

這麼多種方式實現單例模式,我們如何選擇呢?

首先如果你確保程序是在單線程環境下工作,那麼推薦你使用不加synchronized關鍵字的懶漢模式;但是如果程序是在多線程環境下工作,這時就要考慮線程安全問題,基於類的初始化的單例模式天生線程安全,可以使用餓漢方式、靜態內部類方式、枚舉模式;如果你要明確的延遲初始化要求,推薦使用靜態內部類方式、DCL、枚舉模式;如果你有選擇困難症,那不用考慮那麼多了,推薦你使用DCL和靜態內部類形式;不管在什麼場合,都不要考慮使用加synchronized關鍵字的懶漢模式,它的缺點最多。

以上就是本文的全部內容,希望大家有所收穫。

本文源碼相關位置

參考資料:

多線程問題與double-check小結

枚舉的線程安全性及序列化問題

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