美團面試官問:寫一個你認為最好的單例模式?於是我寫了7個

跟著Mic學架構發表於2021-11-03

圖怪獸_7c12286ae7cc540a8fdd027147869cc9_18238

面試題:寫一個你認為最好的單例模式

面試考察點

考察目的: 單例模式可以考察非常多的基礎知識,因此對於這種問題,很多面試官都會問。 小夥伴要注意,在面試過程中,但凡能夠從多個維度考察求職者能力的題目,一定不會被拋棄,特別是比較泛的問題,比如: ”請你說說對xxx的理解“之類。

考察範圍: 工作1到5年經驗,隨著經驗的提升,對於該問題的考察深度越深。

背景知識

單例模式,是一種軟體設計模式,屬於建立型模式的一種。

它的特性是:保證一個類只有唯一的一個例項,並提供一個全域性的訪問點。

基於這個特性可以知道,單例模式的好處是,可以避免物件的頻繁建立對於記憶體的消耗,因為它限制了例項的建立,總的來說,它有以下好處:

  1. 控制資源的使用,通過執行緒同步來控制資源的併發訪問;

  2. 控制例項產生的數量,達到節約資源的目的。

  3. 作為通訊媒介使用,也就是資料共享,它可以在不建立直接關聯的條件下,讓多個不相關的兩個執行緒或者程式之間實現通訊。

在實際應用中,單例模式使用最多的就是在Spring的IOC容器中,對於Bean的管理,預設都是單例。一個bean只會建立一個物件,存在內建map中,之後無論獲取多少次該bean,都返回同一個物件。

下面來了解單例模式的設計。

單例模式設計

既然要保證一個類在執行期間只有一個例項,那必然不能使用new關鍵字來進行例項。

所以,第一步一定是私有化該類的構造方法,這樣就防止了呼叫方自己建立該類的例項。

接著,由於外部無法例項化該物件,因此必須從內部例項化之後,提供一個全域性的訪問入口,來獲取該類的全域性唯一例項,因此我們可以在類的內部定義一個靜態變數來引用唯一的例項,作為對外提供的例項訪問物件。基於這些點,我們可以得到如下設計。

public class Singleton {
    // 靜態欄位引用唯一例項:
    private static final Singleton INSTANCE = new Singleton();

    // private構造方法保證外部無法例項化:
    private Singleton() {
    }
}

接著,還需要給外部一個訪問該物件例項INSTANCE的方法,我們可以提供一個靜態方法

public class Singleton {
    // 靜態欄位引用唯一例項:
    private static final Singleton INSTANCE = new Singleton();

    // 通過靜態方法返回例項:
    public static Singleton getInstance() {
        return INSTANCE;
    }

    // private構造方法保證外部無法例項化:
    private Singleton() {
    }
}

這樣就完成了單例模式的設計,總結來看,單例模式分三步驟。

  1. 使用private私有化構造方法,確保外部無法例項化;
  2. 通過private static變數持有唯一例項,保證全域性唯一性;
  3. 通過public static方法返回此唯一例項,使外部呼叫方能獲取到例項。

單例模式的其他實現

既然單例模式只需要保證程式執行期間只會產生唯一的例項,那意味著單例模式還有更多的實現方法。

  • 懶漢式單例模式
  • 餓漢式單例模式
  • DCL雙重檢查式單例
  • 靜態內部類
  • 列舉單例
  • 基於容器實現單例

懶漢式單例模式

懶漢式,表示不提前建立物件例項,而是在需要的時候再建立,程式碼如下。

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    // synchronized方法,多執行緒情況下保證單例物件唯一
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

其中,對getInstance()方法,增加了synchronized同步關鍵字,目的是為了避免在多執行緒環境下同一時刻呼叫該方法導致出現多例項問題(執行緒的並行執行特性帶來的執行緒安全性問題)。

優點: 只有在使用時才會例項化單例,一定程度上節約了記憶體資源。
缺點: 第一次載入時要立即例項化,反應稍慢。每次呼叫getInstance()方法都會進行同步,這樣會消耗不必要的資源這種模式一般不建議使用。

DCL雙重檢查式單例

DCL雙重檢查式單例模式,是基於餓漢式單例模式的效能優化版本。

/**
 * DCL實現單例模式
 */
public class Singleton {
    private static volatile Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // 兩層判空,第一層是為了避免不必要的同步
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {// 第二層是為了在null的情況下建立例項
                    instance = new Singleton();
                }
            }

        }
        return instance;
    }
}

從程式碼中可以看到,DCL模式做了兩處改進:

  1. getInstance()方法中,把synchronized同步鎖的加鎖範圍縮小了。

    縮小鎖的範圍能夠帶來效能上的提升,不妨思考一下,在原來的懶漢式模式中,把synchronized關鍵字載入方法級別上,意味著不管是多執行緒環境還是單執行緒環境,任何一個呼叫者需要獲得這個物件例項時,都需要獲得鎖。但是加這個鎖其實只有在第一次初始化該例項的時候起到保護作用。後續的訪問,應該直接返回instance例項物件就行。所以把synchroinzed加在方法級別,在多執行緒環境中必然會帶來效能上的開銷。

    而DCL模式的改造,就是縮小了加鎖的範圍,只需要保護該例項物件instance在第一次初始化即可,後續的訪問,都不需要去競爭同步鎖。因此它的設計是:

    • 先判斷instance例項是否為空,如果是,則增加synchronized類級別鎖,保護instance物件的例項化過程,避免在多執行緒環境下出現多例項問題。
    • 接著再synchronized同步關鍵字範圍內,再一次判斷instance例項是否為空,同樣也是為了避免臨界點時,上一個執行緒剛初始化完成,下一個執行緒進入到同步程式碼塊導致多例項問題。
  2. 在成員變數instance上修飾了volatile關鍵字,該關鍵字是為了保證可見性。

    之所以要加這個關鍵字,是為了避免在JVM中指令重排序帶來的可見性問題,這個問題主要體現在instance=new Singleton()這段程式碼中。我們來看這段程式碼的位元組碼

     17: new           #3                  // class org/example/cl04/Singleton
     20: dup
     21: invokespecial #4                  // Method "<init>":()V
     24: putstatic     #2                  // Field instance:Lorg/example/cl04/Singleton;
     27: aload_0
     28: monitorexit
     29: goto          37
     32: astore_1
     33: aload_0
    
    

    關注以下幾個指令

    • new #3 : 這行指令是說在堆上的某個地址處開闢了一塊空間作為Singleton物件

    • invokespecial #4 :這行指令是說將物件裡的成員變數進行賦值操作

    • astore_1 :這行指令是說將棧裡的Singleton instance與堆上的物件建立起引用關聯

    invokespecial #4指令,和astore_1指令,是允許重排序的(關於重排序問題,就不再本篇文章中說明,後續的面試題中會分析到),就是說執行順序有可能astore_1先執行, invokespecial #1後執行。

    重排序對於兩個沒有依賴關係的指令操作,CPU和記憶體以及JVM,為了優化程式執行效能,會對執行指令進行重排序。也就是說兩個指令的執行順序不一定會按照程式編寫順序來執行。

    因為在堆上建立物件開闢地址以後,地址就已經定了,而“將棧裡的Singleton instance與堆上的物件建立起引用關聯” 和 “將物件裡的成員變數進行賦值操作” 是沒什麼邏輯關係的。

    所以cpu可以進行亂序執行,只要程式最終的結果是一致的就可以。

    這種情況,在單執行緒下沒有問題,但是多執行緒下,就會出現錯誤。

    試想一下,DCL下,執行緒A在將物件new出來的時,剛執行完new #4指令,緊接著沒有執行invokespecial #4指令,而是執行了astore_1,也就是說發生了指令重排序。

    此時執行緒B進入getInstance(),發現instance並不為空(因為已經有了引用指向了物件,只不過還沒來得及給物件裡的成員變數賦值),然後執行緒B便直接return了一個“半初始化”物件(物件還沒徹底建立完)。

    所以DCL裡,需要給instance加上volatile關鍵字,因為volatile在JVM層有一個特性叫記憶體屏障,可以防止指令重排序,從而保證了程式的正確性。

關於DCL模式的優缺點:

優點:資源利用率高,既能夠在需要的時候才初始化例項,又能保證執行緒安全,同時呼叫getInstance()方法不進行同步鎖,效率高。
缺點:第一次載入時稍慢,由於Java記憶體模型的原因偶爾會失敗。在高併發環境下也有一定的缺陷,雖然發生概率很小。

DCL模式是使用最多的單例模式實現方式,除非程式碼在併發場景比較複雜,否則,這種方式基本都能滿足需求。

餓漢式單例模式

在類載入的時候不建立單例例項。只有在第一次請求例項的時候的時候建立,並且只在第一次建立後,以後不再建立該類的例項。

/**
 * 餓漢式實現單例模式
 */
public class Singleton {
    private static Singleton instance = new Singleton();

    private Singleton() {
    }

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

由於static關鍵字修飾的屬性,表示這個成員屬於類本身,不屬於例項,執行時,Java 虛擬機器只為靜態變數分配一次記憶體,在類載入的過程中完成靜態變數的記憶體分配。

所以在類載入的時候就建立好物件例項,後續在訪問時直接獲取該例項即可。

而該模式的優缺點也非常明顯。

優點:執行緒安全,不需要考慮併發安全性。

缺點:浪費記憶體空間,不管該物件是否被使用到,都會在啟動是提前分配記憶體空間。

靜態內部類

靜態內部類,是基於餓漢式模式下的優化。

第一次載入Singleton類時不會初始化instance,只有在第一次呼叫getInstance()方法時,虛擬機器會載入SingletonHolder類,初始化instanceinstance 的唯一性、建立過程的執行緒安全性,都由 JVM 來保證。

/**
 * 靜態內部類實現單例模式
 */
public class Singleton {
  private Singleton() {
  }

  public static Singleton getInstance() {
    return SingletonHolder.instance;
  }

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

這種方式既保證執行緒安全,單例物件的唯一,也延遲了單例的初始化,推薦使用這種方式來實現單例模式。

靜態內部類不會因為外部內的載入而載入,同時靜態內部類的載入不需要依附外部類,在使用時才載入,不過在載入靜態內部類的過程中也會載入外部類

知識點:如果用static來修飾一個內部類,那麼就是靜態內部類。這個內部類屬於外部類本身,但是不屬於外部類的任何物件。因此使用static修飾的內部類稱為靜態內部類。靜態內部類有如下規則:

  • 靜態內部類不能訪問外部類的例項成員,只能訪問外部類的類成員。
  • 外部類可以使用靜態內部類的類名作為呼叫者來訪問靜態內部類的類成員,也可以使用靜態內部類物件訪問其例項成員。

靜態內部類單例優點

  • 物件的建立是執行緒安全的。
  • 支援延時載入。
  • 獲取物件時不需要加鎖。

這是一種比較常用的模式之一。

基於列舉實現單例

用列舉來實現單例,是最簡單的方式。這種實現方式通過Java列舉型別本身的特性,保證了例項建立的執行緒安全性和例項的唯一性。

public enum SingletonEnum {

    INSTANCE;

    public void execute(){
        System.out.println("begin execute");
    }

    public static void main(String[] args) {
        SingletonEnum.INSTANCE.execute();
    }
}

基於列舉實現單例會發現它並不需要前面描述的幾個操作

  1. 構造方法私有化
  2. 例項化的變數引用私有化
  3. 獲取例項的方法共有

這類的方式實現列舉其實並不保險,因為私有化構造並不能抵禦反射攻擊.

這種方式是Effective Java作者Josh Bloch提倡的方式,它不僅能避免多執行緒同步問題,而且還能防止反序列化重新建立新的物件,可謂是很堅強的壁壘啊。

基於容器實現單例

下面的程式碼演示了基於容器的方式來管理單例。

import java.util.HashMap;
import java.util.Map;
/**
 * 容器類實現單例模式
 */
public class SingletonManager {
    private static Map<String, Object> objMap = new HashMap<String, Object>();

    public static void regsiterService(String key, Object instance) {
        if (!objMap.containsKey(key)) {
            objMap.put(key, instance);
        }
    }

    public static Object getService(String key) {
        return objMap.get(key);
    }
}

SingletonManager可以管理多個單例型別,在程式的初始化時,將多個單例型別注入到一個統一管理的類中,使用時根據key獲取物件對應型別的物件。這種方式可以通過統一的介面獲取操作,隱藏了具體實現,降低了耦合度。

關於單例模式的破壞

前面在分析列舉類實現單例模式時,有提到一個問題,就是私有化構造,會被反射破壞,導致出現多例項問題。

public class Singleton {

    private static volatile Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // 兩層判空,第一層是為了避免不必要的同步
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {// 第二層是為了在null的情況下建立例項
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) throws Exception{
        Singleton instance=Singleton.getInstance();
        Constructor<Singleton> constructor=Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton refInstance=constructor.newInstance();
        System.out.println(instance);
        System.out.println(refInstance);
        System.out.println(instance==refInstance);

    }
}

執行結果如下

org.example.cl04.Singleton@29453f44
org.example.cl04.Singleton@5cad8086
false

由於反射可以破壞private特性,所以凡是通過private私有化構造實現的單例模式,都能夠被反射破壞從而出現多例項問題。

可能有人會問,我們沒事幹嘛要去破壞單例呢? 直接基於這個入口訪問就不會有問題啊?

理論上來說是這樣,但是,假設遇到下面這種情況呢?

下面的程式碼演示的是通過物件流實現Singleton的序列化和反序列化。

public class Singleton implements Serializable {

    private static volatile Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // 兩層判空,第一層是為了避免不必要的同步
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {// 第二層是為了在null的情況下建立例項
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) throws Exception {
        Singleton instance=Singleton.getInstance();
        ByteArrayOutputStream baos=new ByteArrayOutputStream();
        ObjectOutputStream oos=new ObjectOutputStream(baos);
        oos.writeObject(instance);
        ByteArrayInputStream bais=new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois=new ObjectInputStream(bais);
        Singleton ri=(Singleton) ois.readObject();
        System.out.println(instance);
        System.out.println(ri);
        System.out.println(instance==ri);
    }
}

執行結果如下

org.example.cl04.Singleton@36baf30c
org.example.cl04.Singleton@66a29884
false

可以看到,序列化的方式,也會破壞單例模式。

列舉類單例的破壞測試

可能有人會問,列舉難道就不能破壞嗎?

我們可以試試看,程式碼如下。

public enum SingletonEnum {

    INSTANCE;

    public void execute(){
        System.out.println("begin execute");
    }

    public static void main(String[] args) throws Exception{
        SingletonEnum instance=SingletonEnum.INSTANCE;
        Constructor<SingletonEnum> constructor=SingletonEnum.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        SingletonEnum refInstance=constructor.newInstance();
        System.out.println(instance);
        System.out.println(refInstance);
        System.out.println(instance==refInstance);
    }
}

執行結果如下

Exception in thread "main" java.lang.NoSuchMethodException: org.example.cl04.SingletonEnum.<init>()
	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)
	at org.example.cl04.SingletonEnum.main(SingletonEnum.java:15)

從錯誤來看,似乎是沒有一個空的建構函式?這裡並沒有證明 反射無法破壞單例。

下面是Enum這類的原始碼,所有列舉類都繼承了Enum這個抽象類。

public abstract class Enum<E extends Enum<E>>
        implements Comparable<E>, Serializable {
    /**
     * The name of this enum constant, as declared in the enum declaration.
     * Most programmers should use the {@link #toString} method rather than
     * accessing this field.
     */
    private final String name;

    /**
     * Returns the name of this enum constant, exactly as declared in its
     * enum declaration.
     *
     * <b>Most programmers should use the {@link #toString} method in
     * preference to this one, as the toString method may return
     * a more user-friendly name.</b>  This method is designed primarily for
     * use in specialized situations where correctness depends on getting the
     * exact name, which will not vary from release to release.
     *
     * @return the name of this enum constant
     */
    public final String name() {
        return name;
    }

    /**
     * The ordinal of this enumeration constant (its position
     * in the enum declaration, where the initial constant is assigned
     * an ordinal of zero).
     *
     * Most programmers will have no use for this field.  It is designed
     * for use by sophisticated enum-based data structures, such as
     * {@link java.util.EnumSet} and {@link java.util.EnumMap}.
     */
    private final int ordinal;

    /**
     * Returns the ordinal of this enumeration constant (its position
     * in its enum declaration, where the initial constant is assigned
     * an ordinal of zero).
     *
     * Most programmers will have no use for this method.  It is
     * designed for use by sophisticated enum-based data structures, such
     * as {@link java.util.EnumSet} and {@link java.util.EnumMap}.
     *
     * @return the ordinal of this enumeration constant
     */
    public final int ordinal() {
        return ordinal;
    }

    /**
     * Sole constructor.  Programmers cannot invoke this constructor.
     * It is for use by code emitted by the compiler in response to
     * enum type declarations.
     *
     * @param name - The name of this enum constant, which is the identifier
     *               used to declare it.
     * @param ordinal - The ordinal of this enumeration constant (its position
     *         in the enum declaration, where the initial constant is assigned
     *         an ordinal of zero).
     */
    protected Enum(String name, int ordinal) {
        this.name = name;
        this.ordinal = ordinal;
    }
}

該類有一個唯一的構造方法,接受兩個引數分別是:nameordinal

那我們嘗試通過這個構造方法來建立一下例項,演示程式碼如下。

public enum SingletonEnum {

    INSTANCE;

    public void execute(){
        System.out.println("begin execute");
    }

    public static void main(String[] args) throws Exception{
        SingletonEnum instance=SingletonEnum.INSTANCE;
        Constructor<SingletonEnum> constructor=SingletonEnum.class.getDeclaredConstructor(String.class,int.class);
        constructor.setAccessible(true);
        SingletonEnum refInstance=constructor.newInstance("refinstance",2);
        System.out.println(instance);
        System.out.println(refInstance);
        System.out.println(instance==refInstance);
    }
}

執行上述程式碼,執行結果如下

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
	at org.example.cl04.SingletonEnum.main(SingletonEnum.java:17)

從錯誤資訊來看,我們成功獲取到了Constructor這個構造器,但是在newInstance時報錯。

定位到出錯的原始碼位置。

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        ConstructorAccessor ca = constructorAccessor;   // read volatile

從這段程式碼:(clazz.getModifiers() & Modifier.ENUM) != 0說明:反射在通過newInstance建立物件時,會檢查該類是否ENUM修飾,如果是則丟擲異常,反射失敗,因此列舉型別對反射是絕對安全的。

既然反射無法破壞?那序列化呢?我們再來試試

public enum SingletonEnum {

    INSTANCE;

    public void execute(){
        System.out.println("begin execute");
    }
    public static void main(String[] args) throws Exception{
        SingletonEnum instance=SingletonEnum.INSTANCE;
        ByteArrayOutputStream baos=new ByteArrayOutputStream();
        ObjectOutputStream oos=new ObjectOutputStream(baos);
        oos.writeObject(instance);
        ByteArrayInputStream bais=new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois=new ObjectInputStream(bais);
        SingletonEnum ri=(SingletonEnum) ois.readObject();
        System.out.println(instance);
        System.out.println(ri);
        System.out.println(instance==ri);
    }
}

執行結果如下.

INSTANCE
INSTANCE
true

因此,我們可以得出一個結論,列舉型別是所有單例模式中唯一能夠避免反射破壞導致多例項問題的設計模式。

綜上,可以得出結論:列舉是實現單例模式的最佳實踐。畢竟使用它全都是優點:

  1. 反射安全

  2. 序列化/反序列化安全

  3. 寫法簡單

問題解答

面試題:寫一個你認為最好的單例模式

對於這個問題,相比大家都有答案了,列舉方式實現單例才是最好的。

當然,回答的時候要從全方面角度去講解。

  1. 單例模式的概念
  2. 有哪些方式實現單例
  3. 每種單例模式的優缺點
  4. 最好的單例模式,以及為什麼你覺得它是最好的?

問題總結

單例模式看起來簡單,但是學到極致,也還是有很多知識點的。

比如涉及到執行緒安全問題、靜態方法和靜態從成員變數的特徵、列舉、反射等。

多想再回到從前,大家都只用jsp/servlet,沒有這麼多亂七八糟的知識,我們只想做個簡單的程式設計師。
關注[跟著Mic學架構]公眾號,獲取更多精品原創

相關文章