面試官,你的單例模式能保證百分之百單例嗎?

真快,金三銀四面試季就要過去了,你拿到心儀的offer了嗎?

因爲這次疫情你覺得面試簡單了還是更難了?我覺得既簡單又難,簡單是因爲不需要揹着包到處跑,不需要打印簡歷,都是電話面、視頻面,非常的便利,難是因爲有很多中小公司因此而裁員甚至倒閉。

我的一個小夥伴也趁着這個機會面了幾家試了試水,其中有面試官問到了一個問題:使用過單例模式嗎?單例模式有哪些實現方式?你用過哪些?你的單例模式能保證百分之百單例嗎?

朋友就列舉了幾種實現方式並且比較了幾種方式的優缺點,但對於最後一個問題他當時就想:單例模式不就是單例的嗎?事後我告訴他真相,他才恍然大悟,連連感謝

我猜肯定還有不少小夥伴不知道這個,所以今天就科普一下單例模式,如何打破單例模式以及如何保證百分百的單例。其實我很早前就寫過一篇類似的文章,誰叫你不看呢

 

單例模式的基本概念

什麼是單例

單例模式是Java設計模式中最簡單也是最常用的模式之一。所謂單例就是在系統中只有一個該類的實例,並且提供一個訪問該實例的全局訪問方法。

單例的實現步驟

單例模式的實現分爲三個步驟:

  1. 構造方法私有化。即不能在類外實例化,只能在類內實例化。

  2. 在本類中創建本類的實例。必須自己創建該唯一實例。

  3. 在本類中提供給外部獲取實例的方式。提供訪問該實例的全局訪問方法。

單例模式常見應用場景

  • Windows任務管理器

  • 數據庫連接池

  • Java中的Runtime

  • Spring中Bean的默認生命週期  

單例模式的優點

  • 提供了唯一實例的全局訪問方法,可以優化共享資源的訪問

  • 避免對象的頻繁創建和銷燬,可以提高性能 

單例的具體實現方式

餓漢式-靜態變量

餓漢式的特點就是立即創建,不管現在需不需要,先創建實例。關鍵在於“餓”,餓了就要立即吃。

public class Singleton{
    //靜態變量保存實例變量
    public static Singleton instance = new Singleton();
    //構造器私有化
    private Singleton() {
    }
    //提供訪問該實例的全局訪問方法
    public static Singleton getInstance(){
        return instance;
    }
}

這裏將類的構造器私有化,就不能在外部通過new關鍵字創建該類的實例,然後定義了一個該類的私有靜態變量,接着定義了一個公有getInstance()方法以便外部能夠獲得該類實例。

優點

getInstance()性能好,線程安全,實現簡單。

由於使用了static關鍵字,保證了在引用這個變量時,關於這個變量的所以寫入操作都完成,所以保證了JVM層面的線程安全。

缺點

不能實現懶加載,造成空間浪費。

如果一個類比較大,我們在初始化的時就加載了這個類,但是我們長時間沒有使用這個類,這就導致了內存空間的浪費。

 

餓漢式-靜態代碼塊

這種方式和上面的靜態常量/變量類似,只不過把new放到了靜態代碼塊裏,從簡潔程度上比不過第一種。但是把new放在static代碼塊有別的好處,那就是可以做一些別的操作,如初始化一些變量,從配置文件讀一些數據等。

/**
 * 餓漢模式-靜態代碼塊
 */
public class HungryStaticBlockSingleton{
    //構造器私有化
    private HungryStaticBlockSingleton() {
    }
    //靜態變量保存實例變量
    public static final HungryStaticBlockSingleton INSTANCE;
    static {
        INSTANCE = new HungryStaticBlockSingleton();
    }
}

如下,在static代碼塊裏讀取 info.properties 配置文件動態配置的屬性,賦值給 info 字段。

/**
 * 餓漢模式-靜態代碼塊
 * 這種用於可以在靜態代碼塊進行一些初始化
 */
public class HungryStaticBlockSingleton{
    private String info;
    private HungryStaticBlockSingleton(String info) {
        this.info = info;
    }
    //構造器私有化
    private HungryStaticBlockSingleton() {
    }
    //靜態變量保存實例變量
    public static HungryStaticBlockSingleton instance;
    static {
        Properties properties = new Properties();
        try {
            properties.load(HungryStaticBlockSingleton.class.getClassLoader().getResourceAsStream("info.properties"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        instance = new HungryStaticBlockSingleton(properties.getProperty("info"));
    }
    //getter and setter...
}

Test

public class HungrySingletonTest{
    public static void main(String[] args) {
        HungryStaticBlockSingleton hun = HungryStaticBlockSingleton.INSTANCE;
        System.out.println(hun.getInfo());
    }
}

 

輸出

懶漢式

需要時再創建,關鍵在於“懶”,類似懶加載。

public class Singleton1 {
    //定義靜態實例對象,但不初始化
    private static Singleton1 instance = null;
    //構造方法私有化
    private Singleton1() {
    }
    //提供全局訪問方法
    public static Singleton1 getInstance() {
        if (instance == null) {
            instance = new Singleton1();
        }
        return instance;
    }
}

同樣是構造方法私有化,提供給外部獲得實例的方法,getInstance()方法被調用時創建實例。

優點

getInstance()性能好,延遲初始化

缺點

適用於單線程環境,多線程下可能發生線程安全問題,導致創建不同實例的情況發生。

可以看下面的演示。非線程安全演示:

public class LazyUnsafeSingletionTest{
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService es = Executors.newFixedThreadPool(2);
        Callable<Singleton1> c1 = new Callable<Singleton1>(){
            @Override
            public Singleton1 call() throws Exception {
                return Singleton1.getInstance();
            }
        };
        Callable<Singleton1> c2 = new Callable<Singleton1>(){
            @Override
            public Singleton1 call() throws Exception {
                return Singleton1.getInstance();
            }
        };
        Future<Singleton1> submit = es.submit(c1);
        Future<Singleton1> submit1 = es.submit(c2);
        Singleton1 lazyUnsafeSingleton = submit.get();
        Singleton1 lazyUnsafeSingleton1 = submit1.get();
        es.shutdown();
        System.out.println(lazyUnsafeSingleton);
        System.out.println(lazyUnsafeSingleton);
        System.out.println(lazyUnsafeSingleton1==lazyUnsafeSingleton);
    }
}

輸出 

大概運行三次就會出現一次,我們可以在Singleton1中增加一個判斷,在 if(instance==null) 之後增加一行線程休眠的代碼以獲得更好的效果。

 

懶漢式 + synchronized

通過使用synchronized修飾getInstance()方法保證同步訪問該方法,但是訪問性能不高。

public class Singleton1 {
    //定義靜態實例對象,但不初始化
    private static Singleton1 instance = null;
    //構造方法私有化
    private Singleton1() {
    }
    //提供全局訪問方法 synchronized同步訪問getInstance
    public static synchronized Singleton1 getInstance() {
        if (instance == null) {
            instance = new Singleton1();
        }
        return instance;
    }
}

優點

線程安全,延遲初始化

缺點

getInstance()性能不好(使用了synchronized修飾訪問需要同步,併發訪問性能不高)

 

懶漢式 + Double check

解決懶漢式 + synchronized 訪問性能不高的問題

public class Singleton1 {
    //定義靜態實例對象,但不初始化
    private static Singleton1 instance = null;
    //構造方法私有化
    private Singleton1() {
    }
    //提供全局訪問方法 synchronized同步控制創建實例
    public static Singleton1 getInstance() {
        if (instance == null) {
            synchronized (Singleton1.class) {
                if (instance == null) {
                    instance = new Singleton1();
                }
            }
        }
        return instance;
    }
}

優點

getInstance()訪問性能高,延遲初始化

缺點

非線程安全?

該方式通過縮小同步範圍提高訪問性能,同步代碼塊控制併發創建實例。並且採用雙重檢驗,當兩個線程同時執行第一個判空時,都滿足的情況下,都會進來,然後去爭鎖,假設線程1拿到了鎖,執行同步代碼塊的內容,創建了實例並返回,釋放鎖,然後線程2獲得鎖,執行同步代碼塊內的代碼,因爲此時線程1已經創建了,所以線程2雖然拿到鎖了,如果內部不加判空的話,線程2會再new一次,導致兩個線程獲得的不是同一個實例。線程安全的控制其實是內部判空在起作用,至於爲什麼要加外面的判空下面會說。

當不加內層判空時,會出現不是單例的情況,只不過出現的概率更低了點。

可不可以只加內層判空呢?

答案是可以。

那爲什麼還要加外層判空的呢?

內層判空已經可以滿足線程安全了,加外層判空的目的是爲了提高效率。

因爲可能存在這樣的情況:如果不加外層判空,線程1拿到鎖後執行同步代碼塊,在new之後,還沒有釋放鎖的時候,線程2過來了,它在等待鎖(此時線程1已經創建了實例,只不過還沒釋放鎖,線程2就來了),然後線程1釋放鎖後,線程2拿到鎖,進入同步代碼塊中,判空不成立,直接返回實例。

這種情況線程2是不是不用去等待鎖了?因爲線程1已經創建了實例,只不過還沒釋放鎖。

所以在外層又加了一個判空就是爲了防止這種情況,線程2過來後先判空,不爲空就不用去等待鎖了,這樣提高了效率。

 

懶漢式 + Double check + volatile

雙重檢查鎖模式是一種非常好的單例實現模式,解決了單例、性能問題,上面的雙重檢測鎖模式看上去完美無缺,其實是存在問題,那就是上面缺點中,線程安全後面打問號的原因。

在多線程的情況下,雙重檢查鎖模式可能會出現空指針問題,出現問題的原因是JVM在實例化對象的時候會進行優化和指令重排序操作。什麼是指令重排?上面的instance = new Singleton1();這行代碼並不是一個原子指令,會被分割成多個指令:

memory = allocate(); //1:分配對象的內存空間
ctorInstance(memory); //2:初始化對象
instance = memory; //3:設置instance指向剛分配的內存地址

經過指令重排後的代碼順序:

memory = allocate(); //1:分配對象的內存空間
instance = memory; //3:設置instance指向剛分配的內存地址,此時對象還沒被初始化
ctorInstance(memory); //2:初始化對象

 

實例化對象實際上可以分解成以下4個步驟:

  1. 爲對象分配內存空間

  2. 初始化默認值(區別於構造器方法的初始化),

  3. 執行構造器方法

將對象指向剛分配的內存空間

編譯器或處理器爲了性能的原因,可能會將第3步和第4步進行重排序:

  1. 爲對象分配內存空間

  2. 初始化默認值  

  3. 將對象指向剛分配的內存空間

  4. 執行構造器方法 

線程可能獲得一個初始化未完成的對象......

若有線程1進行完重排後的第二步,且未執行初始化對象。此時線程2來取instance時,發現instance不爲空,於是便返回該值,但由於沒有初始化完該對象,此時返回的對象是有問題的。這也就是爲什麼說看似穩的一逼的代碼,實則不堪一擊。 上述代碼的改進方法:將instance聲明爲volatile類型即可(volatile有內存屏障的功能)。

private static volatile Singleton1 instance = null;

內部類

該方式天然線程安全,適用於多線程,利用了內部類的特性:加載外部類時不會加載內部類,在內部類被加載和初始化時,才創建實例。靜態內部類不會自動隨着外部類的加載和初始化而初始化,它是要單獨加載和初始化的。因爲我們的單例對象是在內部類加載和初始化時才創建的,因此它是線程安全的,且實現了延遲初始化。

public class LazyInnerSingleton{
    private LazyInnerSingleton() {
    }
    private static class Inner{
        private static LazyInnerSingleton instance = new LazyInnerSingleton();
    }
    public static LazyInnerSingleton getInstance(){
        return Inner.instance;
    }
}

優點

getInstance()訪問性能高,延遲初始化,線程安全

 

前面實現方式可能存在的問題:

  • 需要額外的工作來實現序列化,否則每次反序列化一個序列化的對象時都會創建一個新的實例,如果沒有自定義序列化方式則單例有被破壞的風險。

  • 可以使用反射強行調用私有構造器,單例有被破壞的風險。

《Effective Java》中推薦使用Enum來創建單例對象

  • 枚舉類很好的解決了這兩個問題,使用枚舉除了線程安全和防止反射調用構造器之外,還提供了自動序列化機制,防止反序列化的時候創建新的對象。

枚舉

這種方式是最簡潔的,不需要考慮構造方法私有化。值得注意的是枚舉類不允許被繼承,因爲枚舉類編譯後默認爲final class,可防止被子類修改。常量類可被繼承修改、增加字段等,容易導致父類的不兼容。枚舉類型是線程安全的,並且只會裝載一次,設計者充分的利用了枚舉的這個特性來實現單例模式,枚舉的寫法非常簡單,而且枚舉類型是所用單例實現中唯一一種不會被破壞的單例實現模式

public enum SingletonEnum {
    INSTANCE;
    public void otherMethod(){
        System.out.println("枚舉類裏的方法");
    }
}

Test,打印實例直接輸出了【INSTANCE】,是因爲枚舉幫我們實現了toString,默認打印名稱。

public class SingletonEnumTest {
    public static void main(String[] args) {
        SingletonEnum instance = SingletonEnum.INSTANCE;
        System.out.println(instance);
        instance.otherMethod();
    }
}

輸出結果

優點

getInstance()訪問性能高,線程安全

缺點

非延遲初始化

 

破壞單例模式的方法及預防措施

上面介紹枚舉實現單例模式前已經介紹了除枚舉外的其他單例模式實現方式存在的兩個問題,也正是這兩個問題,導致了單例模式若不採取措施,會有被破壞的可能。

1、除枚舉方式外,其他方法都會通過反射的方式破壞單例。

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

private Singleton(){
    if (instance != null){
        throw new RuntimeException("實例已經存在,請通過 getInstance()方法獲取");
    }
}

2、如果單例類實現了序列化接口Serializable, 就可以通過反序列化破壞單例。

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

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

總結

單例模式,從加載時機方面來說分爲餓漢模式和懶漢模式,從程序安全性方面來說分爲線程安全和非線程安全的。最後總結一下單例模式各種實現方式的優缺點。

方式

優點

缺點

餓漢式 - 靜態變量

線程安全,訪問性能高

不能延遲初始化

餓漢式 - 靜態代碼塊

線程安全,訪問性能高,支持額外操作

不能延遲初始化

懶漢式

訪問性能高,延遲初始化

非線程安全

懶漢式 + synchronized

線程安全,延遲初始化

訪性能不高

懶漢式 + Double check

線程安全,延遲初始化

非線程安全

懶漢式 + Double check + volatile

線程安全,延遲初始化,訪問性能高

-

內部類

線程安全,延遲初始化,訪問性能高

-

枚舉

線程安全,訪問性能高,安全

不能延遲初始化

後三種用的較多,根據自己的實際場景選擇不同的單例模式。

更多技術乾貨歡迎關注公衆號“編程大道

 

 

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