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

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

面試考察點

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

    關注以下幾個指令

    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層有一個特性叫內存屏障,可以防止指令重排序,從而保證了程序的正確性。

    • new #3 :這行指令是說在堆上的某個地址處開闢了一塊空間作為Singleton對象
    • invokespecial #4 :這行指令是說將對象里的成員變量進行賦值操作
    • astore_1 :這行指令是說將棧里的Singleton instance與堆上的對象建立起引用關聯

關於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,沒有這麼多亂七八糟的知識,我們只想做個簡單的程序員。