1. 程式人生 > >劍指Offer對答如流系列 - 實現Singleton模式

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

目錄

  • 面試題2:實現Singleton模式
    • 懶漢式寫法
    • 餓漢式寫法
    • 列舉

面試題2:實現Singleton模式

題目:設計一個類,我們只能生成該類的一個例項。

由於設計模式在面向物件程式設計中起著舉足輕重的作用,在面試過程中很多公司都喜歡問一些與設計模式相關的問題。在常用的模式中,Singleton是唯一一個能夠用短短几十行程式碼完整實現的模式。因此,寫一個Singleton的型別是一個很常見的面試題。

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

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

我們下面來看一下它的實現

懶漢式寫法

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例項。可以這麼改一下:

這種寫法叫做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. 分配記憶體給這個物件
  2. 設定LazyDoubleCheckSingleton指向剛分配的記憶體地址。
  3. 初始化物件

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的內部類


懶漢式的介紹就到這裡吧,下面再看看另外一種單例模式的實現


餓漢式寫法

演示一下基本的寫法

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("單例構造器禁止反射呼叫");
        }
    }

這個時候再執行一下

其實對於懶漢式也是有反射破壞單例的問題的,也可以採用類似丟擲異常的方法來解決。

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

  • 餓漢式單例類在自己被載入時就將自己例項化。單從資源利用效率角度來講,這個比懶漢式單例類稍差些。從速度和反應時間角度來講,則比懶漢式單例類稍好些。
  • 懶漢式單例類在例項化時,必須處理好在多個執行緒同時首次引用此類時的訪問限制問題,特別是當單例類作為資源控制器在例項化時必然涉及資源初始化,而資源初始化很有可能耗費大量時間,這意味著出現多執行緒同時首次引用此類的機率變得較大,需要通過同步化機制進行控制。

列舉

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

使用列舉的方式實現單例模式是《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;
    }
}

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