劍指Offer對答如流系列 - 實現Singleton模式

面試題2:實現Singleton模式

題目:設計一個類,咱們只能生成該類的一個實例。面試

因爲設計模式在面向對象程序設計中起着舉足輕重的做用,在面試過程當中不少公司都喜歡問一些與設計模式相關的問題。在經常使用的模式中,Singleton是惟一一個可以用短短几十行代碼完整實現的模式。所以,寫一個Singleton的類型是一個很常見的面試題。編程

在這裏插入圖片描述

若是你看過我以前寫的設計模式專欄,那麼這道題思路你會很開闊。設計模式

單例模式的要點有三個:一是某個類只能有一個實例;二是它必須自行建立這個實例;三是它必須自行向整個系統提供這個實例安全

咱們下面來看一下它的實現多線程

1、懶漢式寫法

public class LazySingleton {

    private static LazySingleton lazySingleton = null;

    private LazySingleton() {

    }

    public static LazySingleton getInstance() {
        if(lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

關鍵就是將構造器私有,限制只能經過內部靜態方法來獲取一個實例。併發

可是這種寫法,很明顯不是線程安全的。若是多個線程在該類初始化以前,有大於一個線程調用了getinstance方法且lazySingleton == null 判斷條件都是正確的時候,這個時候就會致使new出多個LazySingleton實例。能夠這麼改一下:ide

這種寫法叫作DoubleCheck。針對類初始化以前多個線程進入 if(lazySingleton == null) 代碼塊中狀況函數

這個時候加鎖控制,再次判斷 if(lazySingleton == null) ,若是條件成立則new出來一個實例,輪到其餘的線程判斷的時候天然就就爲假了,問題大體解決。測試

public class LazyDoubleCheckSingleton {

    private static LazyDoubleCheckSingleton lazySingleton = null;

    private LazyDoubleCheckSingleton() {

    }

    public static LazyDoubleCheckSingleton getInstance() {
        if(lazySingleton == null) {
            synchronized (LazyDoubleCheckSingleton.class){
                if(lazySingleton == null) {
                    lazySingleton = new LazyDoubleCheckSingleton();
                }
            }
        }
        return lazySingleton;
    }
}

可是即便是這樣,上面代碼的改進有些問題仍是沒法解決的。

由於會有重排序問題。重排序是一種編譯優化技術,屬於《編譯原理》的內容了,這裏不詳細探討,可是要告訴你怎麼回事。

正常來講,下面的這段代碼

lazySingleton = new LazyDoubleCheckSingleton();

執行的時候是這樣的:
1.分配內存給這個對象
2.初始化對象
3.設置LazyDoubleCheckSingleton指向剛分配的內存地址。

可是編譯優化後,多是這種樣子
1.分配內存給這個對象
3.設置LazyDoubleCheckSingleton指向剛分配的內存地址。
2.初始化對象

2 步驟 和 3 步驟一反,就出問題了。(前提條件,編譯器進行了編譯優化)
好比說有兩個線程,名字分別是線程1和線程2,線程1進入了 if(lazySingleton == null) 代碼塊,拿到了鎖,進行了new LazyDoubleCheckSingleton()的執行,在加載構造類的實例的時候,設置LazyDoubleCheckSingleton指向剛分配的內存地址,可是尚未初始化對象。線程2判斷 if(lazySingleton == null) 爲假,直接返回了lazySingleton,又進行了使用,使用的時候就會出問題了。

畫兩張圖吧:

重排序的狀況以下:

在這裏插入圖片描述
再看出問題的地方

在這裏插入圖片描述
固然這個很好改進,從禁用重排序方面下手,添加一個volatile。不熟悉線程安全能夠參考這篇文章【Java併發編程】線程安全性詳解

private volatile static LazyDoubleCheckSingleton lazySingleton = null;

方法不止一種嘛,也能夠利用對象初始化的「可見性」來解決,具體來講是利用靜態內部類基於類初始化的延遲加載,名字很長,可是理解起來並不困難。(使用這種方法,沒必要擔憂上面編譯優化帶來的問題)

類初始化的延遲加載與JVM息息相關,咱們演示的例子的只是被加載了而已,而沒有連接和初始化。

咱們看一下實現方案:
定義一個靜態內部類,其靜態字段實例化了一個單例。獲取單例須要調用getInstance方法間接獲取。

public class StaticInnerClassSingleton {

    private static class InnerClass{
        private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
    }

    public static StaticInnerClassSingleton getInstance() {
        return InnerClass.staticInnerClassSingleton;
    }
}

若是對內部類不熟悉,能夠參考這篇文章【Java核心技術卷】深刻理解Java的內部類

在這裏插入圖片描述
懶漢式的介紹就到這裏吧,下面再看看另一種單例模式的實現


2、餓漢式寫法

演示一下基本的寫法

public class HungrySingleton {

    // 類加載的時候初始化
    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    /*
    也能夠在靜態塊裏進行初始化
      private static HungrySingleton hungrySingleton;

     static {
        hungrySingleton = new HungrySingleton();
     }
     */
    private HungrySingleton() {

    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }

}

餓漢式在類加載的時候就完成單例的實例化,若是用不到這個類會形成內存資源的浪費,由於單例實例引用不可變,因此是線程安全的

一樣,上面的餓漢式寫法也是存在問題的

咱們依次看一下:

首先是序列化破壞單例模式

先保證餓漢式可以序列化,須要繼承Serializable 接口。

import java.io.Serializable;

public class HungrySingleton implements Serializable {

    // 類加載的時候初始化
    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    /*
    也能夠在靜態塊裏進行初始化
      private static HungrySingleton hungrySingleton;

     static {
        hungrySingleton = new HungrySingleton();
     }
     */
    private HungrySingleton() {

    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }

}

咱們測試一下:

import lombok.extern.slf4j.Slf4j;

import java.io.*;

@Slf4j
public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
       HungrySingleton hungrySingleton = HungrySingleton.getInstance();
       ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton"));
       oos.writeObject(hungrySingleton);

       File file = new File("singleton");
       ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));

        HungrySingleton newHungrySingleton = (HungrySingleton) ois.readObject();

        log.info("結果 {}",hungrySingleton);
        log.info("結果 {}",newHungrySingleton);
        log.info("對比結果 {}",hungrySingleton == newHungrySingleton);
    }
}

結果:
在這裏插入圖片描述
結果發現對象不同,緣由就涉及到序列化的底層緣由了,咱們先看解決方式:

餓漢式代碼中添加下面這段代碼

private Object readResolve() {
        return hungrySingleton;
    }

從新運行,這個時候的結果:

在這裏插入圖片描述
緣由出在readResolve方法上,下面去ObjectInputStream源碼部分找找緣由。(裏面都涉及到底層實現,不要期望看懂)

在一個讀取底層數據的方法上有一段描述
就是序列化的Object類中可能定義有一個readResolve方法。咱們在二進制數據讀取的方法中看到了是否判斷

在這裏插入圖片描述
private Object readOrdinaryObject()方法中有這段代碼,若是存在ReadResolve方法,就去調用。不存在,不調用。聯想到咱們在餓漢式添加的代碼,大體能猜到怎麼回事了吧。

在這裏插入圖片描述
***

另一種狀況就是反射攻擊破壞單例

演示一下

import lombok.extern.slf4j.Slf4j;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

@Slf4j
public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class objectClass = HungrySingleton.class;

        Constructor constructor = objectClass.getDeclaredConstructor();
        constructor.setAccessible(true); // 強行打開構造器權限
        HungrySingleton instance = HungrySingleton.getInstance();
        HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

        log.info("結果{}",instance);
        log.info("結果{}",newInstance);
        log.info("比較結果{}",newInstance == instance);
    }
}

在這裏插入圖片描述
這裏強行破開了private的構造方法的權限,使得能new出來一個單例實例,這不是咱們想看到的。

解決方法是在構造方法中拋出異常

private HungrySingleton() {
        if( hungrySingleton != null) {
            throw new RuntimeException("單例構造器禁止反射調用");
        }
    }

這個時候再運行一下

在這裏插入圖片描述
其實對於懶漢式也是有反射破壞單例的問題的,也能夠採用相似拋出異常的方法來解決。

餓漢式單例與懶漢式單例類比較

  • 餓漢式單例類在本身被加載時就將本身實例化。單從資源利用效率角度來說,這個比懶漢式單例類稍差些。從速度和反應時間角度來說,則比懶漢式單例類稍好些。
  • 懶漢式單例類在實例化時,必須處理好在多個線程同時首次引用此類時的訪問限制問題,特別是當單例類做爲資源控制器在實例化時必然涉及資源初始化,而資源初始化頗有可能耗費大量時間,這意味着出現多線程同時首次引用此類的機率變得較大,須要經過同步化機制進行控制。

3、枚舉

除此以外還有一種單例模式的實現就是枚舉

使用枚舉的方式實現單例模式是《Effective Java》做者力推的方式,在不少優秀的開源代碼中常常能夠看到使用枚舉方式實現單例模式的地方,枚舉類型不容許被繼承,一樣是線程安全的且只能被實例化一次,可是枚舉類型不可以懶加載,對Singleton主動使用,好比調用其中的靜態方法則INSTANCE會當即獲得實例化。

//枚舉類型自己是final的,不容許被繼承
public enum Singleton
{
    INSTANCE;
    //實例變量
    private byte[] data = new byte[1024];

    Singleton()
    {
        System.out.println("I want to follow Jeffery.");
    }

    public static void method()
    {
        //調用該方法則會主動使用Singleton,INSTANCE將會被實例化
    }

    public static Singleton getInstance()
    {
        return INSTANCE;
    }
}

在實際面試中,咱們爲了展示枚舉單例模式,能夠寫成這樣:

public enum Singleton
{
    INSTANCE;
  
    public static Singleton getInstance()
    {
        return INSTANCE;
    }
}

Java中的枚舉實際上是一種語法糖,換句話說就是編譯器幫助咱們作了一些的事情,咱們將字節碼反編譯成Java代碼,看看編譯器幫咱們作了什麼,以及探討爲何使用枚舉的方式實現單例模式是《Effective Java》做者力推的方式?

原始代碼以下:

public enum EnumClass {
    SPRING,SUMMER,FALL,WINTER;
}

反編譯後的代碼

public final class EnumClass extends Enum
{

    public static EnumClass[] values()
    {
        return (EnumClass[])$VALUES.clone();
    }

    public static EnumClass valueOf(String name)
    {
        return (EnumClass)Enum.valueOf(suger/EnumClass, name);
    }

    private EnumClass(String s, int i)
    {
        super(s, i);
    }

    public static final EnumClass SPRING;
    public static final EnumClass SUMMER;
    public static final EnumClass FALL;
    public static final EnumClass WINTER;
    private static final EnumClass $VALUES[];

    static 
    {
        SPRING = new EnumClass("SPRING", 0);
        SUMMER = new EnumClass("SUMMER", 1);
        FALL = new EnumClass("FALL", 2);
        WINTER = new EnumClass("WINTER", 3);
        $VALUES = (new EnumClass[] {
            SPRING, SUMMER, FALL, WINTER
        });
    }
}

對於靜態代碼塊不瞭解的參考 : Java中靜態代碼塊、構造代碼塊、構造函數、普通代碼塊

結合前面的內容,是否是很容易理解了? 除此以外,咱們還能夠看出,枚舉是繼承了Enum類的,同時它也是final,即不可繼承的。

枚舉類型的單例模式的玩法有不少,網上傳的比較多的有如下幾種:

內部枚舉類形式

1.構造方法中實例化對象(上面提到了 注意了嗎)

public class EnumSingleton {
    private EnumSingleton(){}
    
    public static EnumSingleton getInstance(){
        return Singleton.INSTANCE.getInstance();
    }

    private enum Singleton{
        INSTANCE;
        
        private EnumSingleton singleton;
        
        //JVM會保證此方法絕對只調用一次
        Singleton(){
            singleton = new EnumSingleton();
        }
        public EnumSingleton getInstance(){
            return singleton;
        }
    }
}

2.枚舉常量的值即爲對象實例

public class EnumSingleton {
    private EnumSingleton(){}
    
    public static EnumSingleton getInstance(){
        return Singleton.INSTANCE.getInstance();
    }

    private enum Singleton{
        INSTANCE(new EnumSingleton());
        private EnumSingleton singleton;
        
        //JVM會保證此方法絕對只調用一次
        Singleton(EnumSingleton singleton){
            this.singleton = singleton;
        }
        public EnumSingleton getInstance(){
            return singleton;
        }
    }
}

接口實現形式
對於一個標準的enum單例模式,最優秀的寫法仍是實現接口的形式:

// 定義單例模式中須要完成的代碼邏輯
public interface MySingleton {
    void doSomething();
}

public enum Singleton implements MySingleton {
    INSTANCE {
        @Override
        public void doSomething() {
            System.out.println("I want to follow Jeffery. What about you ?");
        }
    };

    public static MySingleton getInstance() {
        return Singleton.INSTANCE;
    }
}

我就問!單例模式的面試,你還怕不怕?

相關文章
相關標籤/搜索