Java單例模式與反射及序列化

dreamGong發表於2018-09-14

單例模式的注意點

單例模式相信大家都不陌生,我們不討論單例模式的幾種寫法及其優劣。今天我們單獨拎出單例的幾種實現來看看如何有效的抵禦反射及序列化的攻擊。如果不瞭解反射和序列化的可以看這兩篇文章。
反射
序列化

單例模式與反射

單例模式最根本的在於類只能有一個例項,如果通過反射來構建這個類的例項,單例模式就會被破壞,下面我們通過例子來看下:

/**
 * 靜態內部類式單例模式
 */
class Singleton implements Serializable{
	
	private static class SingletonClassInstance {
	    private static final Singleton instance = new Singleton();
	}
	
	//方法沒有同步,呼叫效率高
	public static Singleton getInstance() {
	    return SingletonClassInstance.instance;
	}
	
	private Singleton() {}
}
複製程式碼

相信大家對於這個單例的這種實現方式肯定不陌生,下面我們來看看通過反射來建立類例項會不會破壞單例模式。main函式程式碼如下:

Singleton sc1 = Singleton.getInstance();
Singleton sc2 = Singleton.getInstance();
System.out.println(sc1); // sc1,sc2是同一個物件
System.out.println(sc2);

/*通過反射的方式直接呼叫私有構造器(通過在構造器裡丟擲異常可以解決此漏洞)*/
Class<Singleton> clazz = (Class<Singleton>) Class.forName("com.learn.example.Singleton");
Constructor<Singleton> c = clazz.getDeclaredConstructor(null);
c.setAccessible(true); // 跳過許可權檢查
Singleton sc3 = c.newInstance();
Singleton sc4 = c.newInstance();
System.out.println("通過反射的方式獲取的物件sc3:" + sc3);  // sc3,sc4不是同一個物件
System.out.println("通過反射的方式獲取的物件sc4:" + sc4);
複製程式碼

下面我們來看輸出:

com.learn.example.Singleton@52e922
com.learn.example.Singleton@52e922
通過反射的方式獲取的物件sc3:com.learn.example.Singleton@25154f
通過反射的方式獲取的物件sc4:com.learn.example.Singleton@10dea4e
複製程式碼

我們看到正常的呼叫getInstance是符合我們預期的,如果通過反射(繞過檢查,通過反射可以呼叫私有的),那麼單例模式其實是失效了,我們建立了兩個完全不同的物件sc3和sc4。我們如何來修復這個問題呢?反射需要呼叫建構函式,那我們可以在建構函式裡面進行判斷。修復程式碼如下:

class Singleton implements Serializable{
	
    private static class SingletonClassInstance {
    	private static final Singleton instance = new Singleton();
    }
    
    //方法沒有同步,呼叫效率高
    public static Singleton getInstance() {
    	return SingletonClassInstance.instance;
    }
    
    //防止反射獲取多個物件的漏洞
    private Singleton() {
    	if (null != SingletonClassInstance.instance)
    	    throw new RuntimeException();
    }
}
複製程式碼

我們看到唯一的改進在於,建構函式裡面新增了判斷,如果當前已有例項,通過丟擲異常來阻止反射建立物件。我們來看下輸出:

com.learn.example.Singleton@52e922
com.learn.example.Singleton@52e922
Exception in thread "main" java.lang.reflect.InvocationTargetException
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(Unknown Source)
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(Unknown Source)
	at java.lang.reflect.Constructor.newInstance(Unknown Source)
	at com.learn.example.RunMain.main(RunMain.java:45)
Caused by: java.lang.RuntimeException
	at com.learn.example.Singleton.<init>(RunMain.java:28)
	... 5 more
複製程式碼

我們看到,我們通過反射建立物件的時候會丟擲異常了。

單例模式與序列化

除了反射以外,反序列化過程也會破壞單例模式,我們來看下現階段反序列化輸出的結果:

com.learn.example.Singleton@52e922
com.learn.example.Singleton@52e922
物件定義了readResolve()方法,通過反序列化得到的物件:com.learn.example.Singleton@16ec8df
複製程式碼

我們看到反序列化後的物件和原物件sc1已經不是同一個物件了。我們需要對反序列化過程進行處理,處理程式碼如下:

//防止反序列化獲取多個物件的漏洞。
//無論是實現Serializable介面,或是Externalizable介面,當從I/O流中讀取物件時,readResolve()方法都會被呼叫到。
//實際上就是用readResolve()中返回的物件直接替換在反序列化過程中建立的物件
private Object readResolve() throws ObjectStreamException {  
    return SingletonClassInstance.instance;
}
複製程式碼

我們從註釋裡面也可以看出來,readResolve方法會將原來反序列化出來的物件進行覆蓋。我們丟棄原來反序列化出來的物件,使用已經建立的好的單例物件進行覆蓋。我們來看現在的輸出:

com.learn.example.Singleton@52e922
com.learn.example.Singleton@52e922
物件定義了readResolve()方法,通過反序列化得到的物件:com.learn.example.Singleton@52e922
複製程式碼

關於readResolve這個方法的詳細解釋可以看這篇文章:
序列化的相關方法介紹

使用列舉實現單例

Effective Java中推薦使用列舉來實現單例,因為列舉實現單例可以阻止反射及序列化的漏洞,下面我們通過例子來看下:

class Resource{}

/**
 * 使用列舉實現單例
 */
enum SingletonEnum{
    INSTANCE;
    
    private Resource instance;
    SingletonEnum() {
        instance = new Resource();
    }
    public Resource getInstance() {
        return instance;
    }
}
複製程式碼

我們在main方法中呼叫程式碼:

Resource resource1 = SingletonEnum.INSTANCE.getInstance();
Resource resource2 = SingletonEnum.INSTANCE.getInstance();
System.out.println(resource1);
System.out.println(resource2);
複製程式碼

輸出如下:

com.learn.example.Resource@52e922
com.learn.example.Resource@52e922
複製程式碼

我們看到,通過列舉我們實現了單例,那麼列舉是如何保證單例的(如何滿足多執行緒及序列化的標準的)?其實列舉是一個普通的類,它繼承自java.lang.Enum類。我們將上面的class檔案反編譯後,會得到如下程式碼:

public final class SingletonEnum extends Enum<SingletonEnum> {
    public static final SingletonEnum INSTANCE;
    public static SingletonEnum[] values();
    public static SingletonEnum valueOf(String s);
    static {};
}
複製程式碼

由反編譯後的程式碼可知,INSTANCE 被宣告為static 的,在類載入過程,可以知道虛擬機器會保證一個類的() 方法在多執行緒環境中被正確的加鎖、同步。所以,列舉實現是在例項化時是執行緒安全。

列舉實現與序列化

Java規範中規定,每一個列舉型別極其定義的列舉變數在JVM中都是唯一的,因此在列舉型別的序列化和反序列化上,Java做了特殊的規定。
在序列化的時候Java僅僅是將列舉物件的name屬性輸出到結果中,反序列化的時候則是通過 java.lang.Enum 的 valueOf() 方法來根據名字查詢列舉物件。
也就是說,以下面列舉為例,序列化的時候只將 INSTANCE 這個名稱輸出,反序列化的時候再通過這個名稱,查詢對於的列舉型別,因此反序列化後的例項也會和之前被序列化的物件例項相同。
Effective Java中單元素的列舉型別被作者認為是實現Singleton的最佳方法。

相關文章