再看Java中的單例

此前面試遇到了單例問題,本覺得已經背的倒背如流,沒想到被問單例如何避免被反射和序列化破壞,雖而後來仍是等到了通知,但仍是複習一下單例的實現方式,並學習防止反射和序列化破壞的手段。java

基本實現方式

其餘相關資料中,最多的能數出八種單例實現方式,而實際上其中有些實現並不具有實際意義,在文中出現也僅是爲了指出存在的問題便於引出下文。本文僅介紹有實際意義的單例實現模式。爲了縮減篇幅,先給出一個後續出現代碼的模板的類圖:git

classDiagram class Singleton{ -Logger log$ -Singleton instance$ +getInstance()$ Singleton +loadClass()$ void +function() void -Singleton() }

單例類 Singleton 模板:後文中介紹具體實現方式僅給出 Singleton#instance 引用和 Singleton#getInstance 方法的內容,其餘內容無變化。github

public class Singleton {

    private static final Logger log = LogManager.getLogger(Singleton.class);
	//單例引用,不一樣實現方式有所不一樣
    private static Singleton instance;

    /**
     * 獲取單例的函數,不一樣實現方式有所不一樣
     *
     * @return 單例
     */
    public static Singleton getInstance() {
        //some code
    }

    /**
     * 靜態方法,用於觸發虛擬機類加載,僅有一行日誌用於觀察類加載時間
     */
    public static void load() {
        log.debug("{} loaded", Singleton.class);
    }

    /**
     * 單例類的功能函數,僅有一行日誌
     */
    public void function() {
        log.debug("Singleton's instance using");
    }

    /**
     * 私有的構造函數,僅有一行日誌用於觀察構造時間
     */
    private Singleton() {
        log.debug("Singleton's instance instantiated");
    }
}

調用單例類的 Main 類:面試

public class Main {

    public static void main(String[] args) throws Exception{
        //先觸發類加載
        Singleton.load();
        //等待必定時間
        TimeUnit.SECONDS.sleep(3);
        //執行單例的功能函數
        Singleton.getInstance().function();
    }

}

餓漢式

餓漢式具備線程安全和非 Lazy 初始化的特色,實現難度最簡單。因爲 JVM 的類加載是單線程的,且已加載過的類不會重複加載,因此餓漢式天生具備線程安全的特色。設計模式

  • 因爲是類加載即初始化,單例引用可添加 final 修飾。安全

    public static final Singleton instance = new Singleton();
    
    //下面寫法效果相同
    /*
    public static final Singleton instance;
    
    static {
    	instance = new Singleton();
    }
    */
  • 獲取單例函數:函數

    public static Singleton getInstance() {
        return instance;
    }

執行結果:學習

13:19:32.565 - Singleton's instance instantiated
13:19:32.569 - class cncsl.github.io.Singleton loaded
13:19:35.572 - Singleton's instance using

從日誌能夠看出,單例類剛加載時就調用構造函數完成了單實例的初始化。線程

雙鎖檢查式

雙鎖檢查是常常出現於面試題中的實現方式,具備線程安全和 Lazy 初始化的特色。須要自行實現線程安全的單例初始化,且要避免指令重排序致使的安全問題,實現難度較高。debug

  • 爲了不指令重排序致使的線程安全問題,須要給單例引用添加 volatile 修飾:

    private volatile static Singleton instance;
  • 雙鎖檢查式最難的部分就是在獲取單例的函數中進行兩次非 null 判斷和加鎖後再初始化的過程:

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

執行結果:

13:30:07.524 - class cncsl.github.io.Singleton loaded
13:30:10.541 - Singleton's instance instantiated
13:30:10.541 - Singleton's instance using

從日誌能夠看出,類加載以後並無當即初始化,實際須要調用到單例的功能函數前才進行了初始化。

靜態內部類式

靜態內部類式也用到了 JVM 類加載器的特性,既保證線程安全的狀況下實現了 Lazy 加載。

  • 添加一個靜態內部類持有單例引用:

    private static class InstanceHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
  • 獲取單例的函數調用時纔會加載靜態內部類,進而觸發單實例的初始化:

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

執行效果與雙鎖檢查式相同,不在贅述。

枚舉式

枚舉式的實現是將單例類寫成一個枚舉,枚舉值僅包含一個單例引用,再加上與業務邏輯相關的功能函數便可。因爲枚舉的特色,這種實現方式具備線程安全、非 Lazy 加載和防止反射、序列化破壞單例等特色。

因爲改動較大附上所有代碼:

public enum Singleton {

    /**
     * 單例枚舉值
     */
    INSTANCE;

    /**
     * 獲取單例的函數
     */
    public static Singleton getInstance() {
        return INSTANCE;
    }

    /**
     * 靜態方法,用於觸發虛擬機類加載,僅有一行日誌用於觀察類加載時間
     */
    public static void load() {
        System.out.printf("%s - %s loaded%n", LocalTime.now().toString(), Singleton.class);
    }

    /**
     * 單例類的功能函數,僅有一行日誌
     */
    public void function() {
        System.out.printf("%s - Singleton's instance using%n", LocalTime.now().toString());
    }

    /**
     * 私有的構造函數,僅有一行日誌用於觀察構造時間
     */
    Singleton() {
        System.out.printf("%s - Singleton's instance instantiated%n", LocalTime.now().toString());
    }
}

執行結果:

13:57:27.142 - Singleton's instance instantiated
13:57:27.154 - class cncsl.github.io.Singleton loaded
13:57:30.156 - Singleton's instance using

能夠看出,枚舉類加載以後當即初始化了單例對象,而三秒後執行了單例類的功能函數。

防反射和序列化破壞單例

在 Java 中,經過序列化也能建立新的對象實例,而反射能突破構造函數 private 的限制,下面介紹一下如何避免這些狀況的發生。枚舉式單例天生避免了這些問題,下方內容都是針對其餘三種實現方式而言的。

另外,請明白一個前提,設計模式是一種設計的方式,既不是某種語言的語法約束,除了枚舉方式之外、其餘實現方式在有人惡意破壞的狀況都沒法徹底確保單例。在這種狀況下,須要考慮的不是如何改進現有的設計,而是找出企圖經過這些手段破壞單例的人。因此下面的知識通常用於面試:當遇到如何確保單例的問題時,首先說枚舉式設計方式、而後纔是下面的內容。

反射手段

下方是經過反射方式破壞單例的過程:

public static void main(String[] args) {
    try {
        //經過getInstance()獲取
        Singleton one = Singleton.getInstance();
        log.debug(one.hashCode());
        //反射調用構造函數
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton two = constructor.newInstance();
        log.debug(two.hashCode());
        log.debug(one == two);
    } catch (Exception e) {
        log.error("Exception: ", e);
    }
}

執行結果:

15:10:48.237 - Singleton's instance instantiated
15:10:48.240 - 1159114532
15:10:48.240 - Singleton's instance instantiated
15:10:48.240 - 1832580921
15:10:48.240 - false

能夠看出目前程序中以存在兩個 Singleton 類的實例,單例已經被破壞。

解決方案爲在單例類的構造函數中進行檢查,若是單例引用不爲 null 就拋出異常:

private Singleton() {
    if (instance != null) {
        throw new UnsupportedOperationException("不容許重複建立實例");
    }
    log.debug("Singleton's instance instantiated");
}

再次執行結果:

15:17:36.834 - Singleton's instance instantiated
15:17:36.836 - 1159114532
15:17:36.836 - Exception: 
java.lang.reflect.InvocationTargetException: null
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[?:1.8.0_261]
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62) ~[?:1.8.0_261]
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[?:1.8.0_261]
	at java.lang.reflect.Constructor.newInstance(Constructor.java:423) ~[?:1.8.0_261]
	at cncsl.github.io.Main.main(Main.java:18) [classes/:?]
Caused by: java.lang.UnsupportedOperationException: 不容許重複建立實例
	at cncsl.github.io.Singleton.<init>(Singleton.java:32) ~[classes/:?]
	... 5 more

固然,攻擊者能夠在外部先記錄一份 instance 引用,經過反射修改 instance 引用後再建立對象,這樣程序中會存在兩個 Singleton 實例。

序列化手段

下方是經過序列化手段破壞單例的過程:

public static void main(String[] args) {
    try (ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("Singleton.temp"));
         ObjectInputStream input = new ObjectInputStream(new FileInputStream("Singleton.temp"))) {

        Singleton one = Singleton.getInstance();
        log.debug(one.hashCode());

        output.writeObject(one);
        Singleton two = (Singleton) input.readObject();
        log.debug(two.hashCode());
      
        log.debug(one == two);
    } catch (Exception e) {
        log.error("Exception: ", e);
    }
}

執行結果以下:

21:15:36.605 - Singleton's instance instantiated
21:15:36.610 - 22756955
21:15:36.619 - 1582785598
21:15:36.619 - false

能夠看出,序列化讀取到的對象已是一個新的對象,單例已被破壞。

解決方案是爲單例類添加以下函數:

private Object readResolve() {
  return instance;
}

再次執行後能夠發現已經反序列化時獲得的仍然是原單例對象:

21:28:34.954 - Singleton's instance instantiated
21:28:34.956 - 22756955
21:28:34.964 - 22756955
21:28:34.964 - true

當前序列化有個前提是實現 Serializable 接口,私覺得這種狀況是一個錯誤的設計:單例類通常和業務邏輯相關、而序列化通常和封裝數據用的實體對象有關,兩者不該該出如今同一個類裏。

相關文章
相關標籤/搜索