你寫的單例模式,能防止反序列化和反射嗎?

前言

說起單例模式,相信大家都不會陌生。因爲相比其他設計模式,實現一個單例模式是比較簡單的。單例模式的意思就是一個類只有一個實例

獲取類的實例,我們往往採用new關鍵字,但是要保證一個類只能有一個實例,所以不能讓使用這個類的開發人員利用new關鍵字來創建實例。也就是不能讓外部調用類的構造方法,所以很容易想到類的構造方法私有,這樣開發人員就不能在類之外通過new的方法創建該類的對象了。

由於外部不能通過new關鍵字來創建單例類的對象了,所以單例類本身必須提供一個靜態方法,使得外部可以通過類名 + 方法名的方法獲取單例類的對象。

這就是單例模式的兩個特點:

  • 構造方法私有
  • 提供一個靜態方法,使得外部通過該方法獲取單例類的實例

幾乎所有的單例模式實現,都圍繞這兩點展開

單例模式

單例模式,也叫單子模式,是一種常用的軟件設計模式,屬於創建型模式的一種。在應用這個模式時,單例對象的類必須保證只有一個實例存在。

單例模式根據實例創建的時機,大致可以分爲兩種:餓漢式單例懶漢式單例

  • 餓漢式單例是指在單例類加載的時候就初始化一個對象,不管之後的程序會不會用到。因爲顯得迫不及待,感覺很餓,所以叫餓漢式單例
  • 懶漢式單例類似懶加載,只有程序第一次用到的時候,纔開始實例化,所以叫懶漢式單例

餓漢式單例

由於餓漢式單例比較簡單,所以直接給出源代碼

/**
 * 惡漢式單例,線程安全
 * @author sicimike
 * @create 2020-02-23 20:15
 */
public class Singleton1 {

    private static final Singleton1 INSTANCE = new Singleton1();

    private Singleton1() {}

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

除此之外,餓漢式單例還有一種變種寫法,就是把實例化的過程放在靜態代碼塊

/**
 * 餓漢式單例,線程安全
 * @author sicimike
 * @create 2020-02-23 20:19
 */
public class Singleton2 {

    private static Singleton2 INSTANCE = null;

    static {
        INSTANCE = new Singleton2();
    }

    private Singleton2() {}

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

總的來說,都是在類加載的時候就被實例化,所以這兩種寫法基本上沒什麼區別,都是餓漢式單例。

懶漢式單例

懶漢式單例相比餓漢式單例就複雜得多。先看一種比較簡單的寫法

/**
 * 懶漢式單例,線程不安全
 * @author sicimike
 * @create 2020-02-23 20:21
 */
public class Singleton3 {

    private static Singleton3 INSTANCE = null;

    private Singleton3() {}

    public static Singleton3 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton3();
        }
        return INSTANCE;
    }
}

當程序第一次調用getInstance()方法時,纔開創建實例。以上代碼在單線程環境沒有什麼問題,但是在多線程環境下,是線程不安全的。原因也很簡單,假設線程A執行到了第14行(尚未執行)INSTANCE = new Singleton3(),此時INSTANCE依然等於null線程B也也會進入if判斷,如果兩個線程繼續執行,那就產生了兩個不同的實例,所以線程不安全。要想實現線程安全也很簡單,因爲我們知道有個關鍵字叫synchronized,稍微改造下代碼

/**
 * 懶漢式單例,線程安全
 * synchronized關鍵字實現
 * @author sicimike
 * @create 2020-02-23 20:26
 */
public class Singleton4 {

    private static Singleton4 INSTANCE = null;

    private Singleton4() {}

    public static synchronized Singleton4 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton4();
        }
        return INSTANCE;
    }
}

毫無疑問,我們實現了線程安全的懶漢式單例

但是由於鎖住了整個getInstance(),所以這種單例模式的效率是非常低下的。之前的博客也寫過,儘量縮小鎖的範圍。所以我們不鎖整個方法,而是鎖住方法中的部分代碼,再次改造代碼

/**
 * 懶漢式單例,線程不安全
 * @author sicimike
 * @create 2020-02-23 20:29
 */
public class Singleton5 {

    private static Singleton5 INSTANCE = null;

    private Singleton5() {}

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

相比於前一種,這種實現明顯效率更高,因爲synchronized關鍵字外部有一層判斷,只要實例被創建了,就不會再進入同步代碼塊,而前一種是每次調用方法都會進入同步代碼。

也許有的同學會在這裏有疑問,爲什麼synchronized外部加了一次判斷,內部還要加一次判斷?
這其實是併發編程中常見的一種手段,叫double-check,也就是常說的雙重校驗(博主的併發編程分類,寫過一些JDK併發容器、線程池的源碼,很多地方用到了雙重校驗,有興趣的同學可以去翻一翻)。假設線程A執行到了第15行(尚未執行)if (INSTANCE == null),此時INSTANCE依然等於null,而線程B可能已經進入了外層判斷,而被阻塞在synchronized這裏。線程A繼續執行完成對象的創建後釋放鎖,線程B獲取鎖進入同步代碼塊,如果沒有第二次判斷,線程B會直接創建對象。所以synchronized內也必須加一次判斷。

這種實現方式看起來似乎已經天衣無縫了,但是它依然是線程不安全的。
線程不安全的根本原因就是INSTANCE = new Singleton5()不是原子操作。而是分爲三步完成
1、分配內存給這個對象
2、初始化這個對象
3、把INSTANCE變量指向初始化的對象
正常情況下按照1 -> 2 -> 3的順序執行,但是2和3可能會發生重排序,執行順序變成1 -> 3 -> 2。如果是1 -> 3 -> 2的順序執行。線程A執行完3,此時對象尚未初始化,但是INSTANCE變量已經不爲null,線程B執行到synchronized關鍵字外部的if判斷時,就直接返回了。此時線程B拿到的是一個尚未初始化完成的對象,可能會造成安全隱患。所以這種實現方式是線程不安全的。要向解決這個問題,也就是解決重排序的問題,聰明的你應該想到了另一個關鍵字volatile,關於volatile關鍵字,不熟悉的同學請翻閱博主之前的文章:volatile關鍵字詳解,再次改造下代碼

/**
 * 懶漢式單例,線程安全
 * 雙重校驗鎖
 * @author sicimike
 * @create 2020-02-23 20:34
 */
public class Singleton6 {

    private static volatile Singleton6 INSTANCE = null;

    private Singleton6() {}

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

INSTANCE加上volatile關鍵字就解決了這個問題,這種實現方式就是雙重校驗鎖的方式。volatile關鍵字的在這裏的作用有兩個:

  • 解決了重排序的問題
  • 保證了INSTANCE的修改,能夠及時的被其他線程所知

雙重校驗鎖的實現方式涉及到的知識較多,所以相對來說,還有更加簡便的方式,那就是利用靜態內部類

/**
 * 懶漢式單例,線程安全
 * 靜態內部類
 * @author sicimike
 * @create 2020-02-23 20:36
 */
public class Singleton7 {

    private Singleton7() {}

    public static Singleton7 getInstance() {
        return InnerClass.INSTANCE;
    }

    private static class InnerClass {
        private static Singleton7 INSTANCE = new Singleton7();
    }

}

這種實現方式既滿足懶加載,又滿足線程安全,代碼量還少,相對來說是一種比較優雅的實現方式。

至此已經給出了單例模式的7種寫法,線程安全的有5種。雖然有點類似“茴香豆的茴字有幾種寫法”,但是仔細瞭解各種寫法之間的區別,以及線程安全的問題,收穫肯定不小。

單例與序列化

序列化(serialization)在計算機科學的數據處理中,是指將數據結構或對象狀態轉換成可取用格式(例如存成文件,存於緩衝,或經由網絡中發送),以留待後續在相同或另一臺計算機環境中,能恢復原先狀態的過程。依照序列化格式重新獲取字節的結果時,可以利用它來產生與原始對象相同語義的副本。

簡單來說,將數據結構或者對象的狀態轉化成可存儲或可傳輸的過程稱爲序列化,相反的過程稱爲反序列化。咋一看,序列化和單例視乎沒有什麼關係。所以先看一個例子,首先利用第一種餓漢單例的方式寫一個單例,實現Serializable接口

/**
 * 單例模式與序列化
 * @author sicimike
 * @create 2020-02-23 22:26
 */
public class SingletonWithSerialize implements Serializable {
    
    private static final long serialVersionUID = 6133201454552796162L;

    private static final SingletonWithSerialize INSTANCE = new SingletonWithSerialize();

    private SingletonWithSerialize() {}

    public static SingletonWithSerialize getInstance() {
        return INSTANCE;
    }

}

接下來對這個對象進行序列化反序列化

/**
 * @author sicimike
 * @create 2020-02-23 20:48
 */
public class SingletonDemo {

    private static final String FILE_PATH = "singleton.data";

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        SingletonWithSerialize instance = SingletonWithSerialize.getInstance();
        
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File(FILE_PATH)));
        oos.writeObject(instance);
        
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File(FILE_PATH)));
        SingletonWithSerialize readInstance = (SingletonWithSerialize) ois.readObject();
        
        System.out.println(instance);
        System.out.println(readInstance);
        
        // 關閉IO流
    }

}

執行結果

com.sicimike.creation.singleton.SingletonWithSerialize@7f31245a
com.sicimike.creation.singleton.SingletonWithSerialize@7cca494b

可以看到,序列化反序列化破壞了單例模式。

對於這個問題,JDK早已提供瞭解決方案,那就是在單例類中提供一個readResolve方法

The readResolve method is called when ObjectInputStream has read an object from the stream and is preparing to return it to the caller. ObjectInputStream checks whether the class of the object defines the readResolve method. If the method is defined, the readResolve method is called to allow the object in the stream to designate the object to be returned.
——https://docs.oracle.com/javase/8/docs/platform/serialization/spec/input.html#a5903

ObjectInputStream從流中讀取一個對象並準備將其返回給調用方時,將調用readResolve方法。 ObjectInputStream檢查對象的類是否定義了readResolve方法。如果定義了該方法,則將調用readResolve方法,以允許流中的對象指定要返回的對象。

也就是說反序列化的時候,JDK提供了一個鉤子函數來讓開發者指定要返回的對象,用法如下

/**
 * 單例模式與序列化
 * @author sicimike
 * @create 2020-02-23 22:26
 */
public class SingletonWithSerialize implements Serializable {

    private static final long serialVersionUID = 6133201454552796162L;

    private static final SingletonWithSerialize INSTANCE = new SingletonWithSerialize();

    private SingletonWithSerialize() {}

    public static SingletonWithSerialize getInstance() {
        return INSTANCE;
    }

	// 解決序列化與反序列化破壞單例模式的問題
    private Object readResolve() {
        return this.INSTANCE;
    }

}

執行結果

com.sicimike.creation.singleton.SingletonWithSerialize@6d6f6e28
com.sicimike.creation.singleton.SingletonWithSerialize@6d6f6e28

可以看到添加readResolve方法後,再一次完成了單例模式

單例與反射

咋一看單例模式和反射也沒有什麼關係。但是仔細一想,前文所述創建對象的方式,都是通過new關鍵字來實現的。其實還可以通過反射的方式來創建對象。以第一種餓漢式單例爲例

/**
 * @author sicimike
 * @create 2020-02-23 20:48
 */
public class SingletonDemo {

    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        
        Class<Singleton1> classObject = Singleton1.class;
        Constructor<Singleton1> constructor = classObject.getDeclaredConstructor();
        constructor.setAccessible(true);

        // 單例模式獲取
        Singleton1 instance = Singleton1.getInstance();
        // 反射獲取
        Singleton1 reflectInstance = constructor.newInstance();
        System.out.println(instance);
        System.out.println(reflectInstance);
    }

}

執行結果

com.sicimike.creation.singleton.Singleton1@4554617c
com.sicimike.creation.singleton.Singleton1@74a14482

可以看到通過單例類創建的對象,和通過反射創建的對象不是同一個,並且反射可以隨意創建實例,這樣就破壞了單例模式。

第八種單例

前文提到的7種單例都會被序列化/反序列化、反射不同程度的破壞。解決序列化/反序列化靠JDK提供的鉤子函數readResolve,想要解決反射也有一些辦法,那就是在私有構造方法里加一下判斷,如果INSTANCE不爲null時,就拋出異常…

想要更優雅的解決序列化/反序列化反射的問題,還有一種更優雅的寫法,那就是利用枚舉

/**
 * 枚舉實現單例,線程安全
 * @author sicimike
 * @create 2020-02-23 20:46
 */
public enum Singleton8 {
    INSTANCE;

    public static Singleton8 getInstance() {
        return INSTANCE;
    }

}

再來測試下反射

/**
 * @author sicimike
 * @create 2020-02-23 20:48
 */
public class SingletonDemo {

    private static final String FILE_PATH = "singleton.data";

    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

        Class<Singleton8> classObject = Singleton8.class;
        Constructor<Singleton8> constructor = classObject.getDeclaredConstructor();
        constructor.setAccessible(true);

        // 單例模式獲取
        Singleton8 instance = Singleton8.getInstance();
        // 反射獲取
        Singleton8 reflectInstance = constructor.newInstance();
        System.out.println(instance);
        System.out.println(reflectInstance);

    }
}

執行會得到如下結果

Exception in thread "main" java.lang.NoSuchMethodException: com.sicimike.creation.singleton.Singleton8.<init>()
	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)
	at com.sicimike.creation.singleton.SingletonDemo.main(SingletonDemo.java:31)

在解釋這個結果之前,先來看下Singleton8的反編譯之後的代碼(編譯工具:jad下載頁面

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3) 
// Source File Name:   Singleton8.java

package com.sicimike.creation.singleton;


public final class Singleton8 extends Enum
{

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

    public static Singleton8 valueOf(String name)
    {
        return (Singleton8)Enum.valueOf(com/sicimike/creation/singleton/Singleton8, name);
    }

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

    public static Singleton8 getInstance()
    {
        return INSTANCE;
    }

    public static final Singleton8 INSTANCE;
    private static final Singleton8 $VALUES[];

    static 
    {
        INSTANCE = new Singleton8("INSTANCE", 0);
        $VALUES = (new Singleton8[] {
            INSTANCE
        });
    }
}

對於反編譯之後的代碼,只需要關注3點:

  • 默認繼承了java.lang.Enum
  • 生成了一個私有的構造方法Singleton8(String s, int i),並不是無參的
  • INSTANCE實例在靜態代碼塊中被創建

現在也就知道爲什麼上面的實例會報NoSuchMethodException異常了,既然沒有無參構造方法,而是有2個參數的構造方法。那我們就再次修改代碼,調用有2個參數的構造方法

/**
 * @author sicimike
 * @create 2020-02-23 20:48
 */
public class SingletonDemo {

    private static final String FILE_PATH = "singleton.data";

    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

        Class<Singleton8> classObject = Singleton8.class;
        Constructor<Singleton8> constructor = classObject.getDeclaredConstructor(String.class, int.class);
        constructor.setAccessible(true);

        // 單例模式獲取
        Singleton8 instance = Singleton8.getInstance();
        // 反射獲取
        Singleton8 reflectInstance = constructor.newInstance("Sicimike", 18);
        System.out.println(instance);
        System.out.println(reflectInstance);
    }
}

執行結果如下

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
	at com.sicimike.creation.singleton.SingletonDemo.main(SingletonDemo.java:59)

也就是說枚舉類型根本就不支持通過反射創建實例。

至此,通過枚舉類型實現的單例模式完美的解決了序列化/反序列化和反射的問題。這種方式也是Joshua Bloch推薦的方式。

但是通過反編譯的結果我們也可以看出,枚舉類創建對象也是在靜態代碼塊中完成的,也就是類加載階段。所以說枚舉類型實現的單例模式應該屬於餓漢式單例

源代碼

Singleton

總結

本篇中提到了前七種方式,有部分可以通過在構造方法中添加判斷邏輯,來避免被反射破壞。有興趣的同學可以嘗試一下。

單例模式在面試中很常見,因爲手寫一個單例模式比較快,幾分鐘就能搞定。單例模式最簡單的一種設計模式,同時也是最複雜的一種設計模式,涉及到的知識點比較多。我感覺面試官要你手寫單例模式時,最希望看到的應該是雙重校驗鎖的那種,也就是Singleton6。因爲這裏涉及到的知識點最多:JVM、多線程、鎖、volatile、序列化、反射等等。

參考

  • https://docs.oracle.com/javase/8/docs/platform/serialization/spec/input.html#a5903
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章