Java與單例模式
最近在閱讀《Effective Java 》這本書,第3個條款專門提到了單例屬性,並給出了使用單例的最佳實踐建議。讓我對這個單例模式(原本我以為是設計模式中最簡單的一種)有了更深的認識。
單例模式
單例模式(Singleton Pattern)是 Java 中最簡單的設計模式之一。這種型別的設計模式屬於建立型模式,它提供了一種建立物件的最佳方式。
在應用這個模式時,單例物件的類必須保證只有一個例項存在。許多時候整個系統只需要擁有一個的全域性物件,這樣有利於我們協調系統整體的行為。
單例的特點
- 單例類只能有一個例項。
- 單例類必須自己建立自己的唯一例項。
- 單例類必須給所有其他物件提供這一例項。
單例模式的7種寫法
單例模式的寫法很多,涉及到了執行緒安全和效能問題。在這裡我不重複介紹。這篇《單例模式的七種寫法》寫得很詳細,博主也給出了每一種寫法的優缺點。
但是,單例模式真的能夠實現例項的唯一性嗎?答案是否定的。
如何破壞單例
反射
有兩種常見的方式來實現單例。他們的做法都是將構造方法設為私有,並匯出一個公有的靜態成員來提供對唯一實例的訪問。在第1種方式中,成員是個final欄位:
// Singleton with public final field
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
只調用私有建構函式一次,以初始化公共靜態final欄位elvi.instance。不提供公有的或者受保護的建構函式保證了全域性唯一性:當Elvis類初始化的時候,僅僅只會有一個Elvis例項存在——不多也不少 。無論客戶端怎麼做都無法改變這一點,只不過我還是要警告一下 :授權的客戶端可以通過反射來呼叫私有構造方法,藉助於AccessibleObject.setAccessible方法即可做到 。如果需要防範這種攻擊,請修改建構函式,使其在被要求建立第二個例項時丟擲異常。
測試程式碼:
public class TestSingleton {
/**
* 通過反射破壞單例
*/
@Test
public void testReflection() throws Exception {
/**
* 驗證單例有效性
*/
Elvis elvis1 = Elvis.INSTANCE;
Elvis elvis2 = Elvis.INSTANCE;
System.out.println("elvis1 == elvis2 ? ===>" + (elvis1 == elvis2));
System.err.println("-----------------");
/**
* 反射呼叫構造方法
*/
Class clazz = Elvis.class;
Constructor cons = clazz.getDeclaredConstructor(null);
cons.setAccessible(true);
Elvis elvis3 = (Elvis) cons.newInstance(null);
System.out.println("elvis1 == elvis3 ? ===> "
+ (elvis1 == elvis3));
}
}
執行結果:
Elvis Constructor is invoked!
elvis1 == elvis2 ? ===> true
elvis1 == elvis3 ? ===> false
-----------------
Elvis Constructor is invoked!
結論:
反射是可以破壞單例屬性的。因為我們通過反射把它的建構函式設成可訪問的,然後去生成一個新的物件。
改進版的單例寫法:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
System.err.println("Elvis Constructor is invoked!");
if (INSTANCE != null) {
System.err.println("例項已存在,無法初始化!");
throw new UnsupportedOperationException("例項已存在,無法初始化!");
}
}
}
結果:
Elvis Constructor is invoked!
elvis1 == elvis2 ? ===> true
-----------------
Elvis Constructor is invoked!
例項已存在,無法初始化!
第2種實現單例模式的方法是,提供一個公有的靜態工廠方法:
// Singleton with static factory
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public static Elvis getInstance() { return INSTANCE; }
public void leaveTheBuilding() { ... }
}
所有呼叫Elvis類的getInstance方法,返回相同的物件引用,並且不會有其它的Elvis物件被建立。但同樣有上面第1個方法提到的反射破壞單例屬性的問題存在。
序列化和反序列化
如果對上述2種方式實現的單例類進行序列化,反序列化得到的物件是否是同一個物件呢?答案是否定的。
看下面的測試程式碼:
單例類:
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
System.err.println("Elvis Constructor is invoked!");
}
}
測試程式碼:
/**
* 序列化對單例屬性的影響
* @throws Exception
*/
@Test
public void testSerialization() throws Exception {
Elvis elvis1 = Elvis.INSTANCE;
FileOutputStream fos = new FileOutputStream("a.txt");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(elvis1);
oos.flush();
oos.close();
Elvis elvis2 = null;
FileInputStream fis = new FileInputStream("a.txt");
ObjectInputStream ois = new ObjectInputStream(fis);
elvis2 = (Elvis) ois.readObject();
System.out.println("elvis1 == elvis2 ? ===>" + (elvis1 == elvis2));
}
結果是:
Elvis Constructor is invoked!
elvis1 == elvis2 ? ===>false
說明:
通過對序列化後的Elvis 進行反序列化得到的物件是一個新的物件,這就破壞了Elvis 的單例性。
《Effective Java》已經告訴我們,在單例類中提供一個readResolve方法就可以完成單例特性。這裡大家可以自己去測試。
接下來,我們去看看Java提供的反序列化是如何建立物件的!
ObjectInputStream
物件的序列化過程通過ObjectOutputStream和ObjectInputputStream來實現的,那麼帶著剛剛的問題,分析一下ObjectInputputStream的readObject 方法執行情況到底是怎樣的。
為了節省篇幅,這裡給出ObjectInputStream的readObject的呼叫棧:
大家順著此圖的關係,去看readObject方法的實現。
首先進入readObject0方法裡,關鍵程式碼如下:
switch (tc) {
//省略部分程式碼
case TC_STRING:
case TC_LONGSTRING:
return checkResolve(readString(unshared));
case TC_ARRAY:
return checkResolve(readArray(unshared));
case TC_ENUM:
return checkResolve(readEnum(unshared));
case TC_OBJECT:
return checkResolve(readOrdinaryObject(unshared));
case TC_EXCEPTION:
IOException ex = readFatalException();
throw new WriteAbortedException("writing aborted", ex);
case TC_BLOCKDATA:
case TC_BLOCKDATALONG:
if (oldMode) {
bin.setBlockDataMode(true);
bin.peek(); // force header read
throw new OptionalDataException(
bin.currentBlockRemaining());
} else {
throw new StreamCorruptedException(
"unexpected block data");
}
//省略部分程式碼
這裡就是判斷目標物件的型別,不同型別執行不同的動作。我們的是個普通的Object物件,自然就是進入case TC_OBJECT的程式碼塊中。然後進入readOrdinaryObject方法中。
readOrdinaryObject方法的程式碼片段:
private Object readOrdinaryObject(boolean unshared)
throws IOException {
//此處省略部分程式碼
Object obj;
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;
} catch (Exception ex) {
throw (IOException) new InvalidClassException(
desc.forClass().getName(),
"unable to create instance").initCause(ex);
}
//此處省略部分程式碼
if (obj != null &&
handles.lookupException(passHandle) == null &&
desc.hasReadResolveMethod())
{
Object rep = desc.invokeReadResolve(obj);
if (unshared && rep.getClass().isArray()) {
rep = cloneArray(rep);
}
if (rep != obj) {
handles.setObject(passHandle, obj = rep);
}
}
return obj;
}
重點看程式碼塊:
Object obj;
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;
} catch (Exception ex) {
throw (IOException) new InvalidClassException(
desc.forClass().getName(),
"unable to create instance").initCause(ex);
}
這裡建立的這個obj物件,就是本方法要返回的物件,也可以暫時理解為是ObjectInputStream的readObject返回的物件。
isInstantiable:如果一個serializable/externalizable的類可以在執行時被例項化,那麼該方法就返回true。針對serializable和externalizable我會在其他文章中介紹。
desc.newInstance:該方法通過反射的方式呼叫無參構造方法新建一個物件。
所以。到目前為止,也就可以解釋,為什麼序列化可以破壞單例了?即序列化會通過反射呼叫無引數的構造方法建立一個新的物件。
接下來再看,為什麼在單例類中定義readResolve就可以解決該問題呢?還是在readOrdinaryObjec方法裡繼續往下看。
if (obj != null &&
handles.lookupException(passHandle) == null &&
desc.hasReadResolveMethod())
{
Object rep = desc.invokeReadResolve(obj);
if (unshared && rep.getClass().isArray()) {
rep = cloneArray(rep);
}
if (rep != obj) {
handles.setObject(passHandle, obj = rep);
}
}
這段程式碼也很清楚地給出答案了!
如果目標類有readResolve
方法,那就通過反射的方式呼叫要被反序列化的類的readResolve方法,返回一個物件,然後把這個新的物件複製給之前建立的obj(即最終返回的物件)。那readResolve
方法裡是什麼?就是直接返回我們的單例物件。
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
System.err.println("Elvis Constructor is invoked!");
}
private Object readResolve() {
return INSTANCE;
}
}
所以,原理也就清楚了,主要在Singleton中定義readResolve方法,並在該方法中指定要返回的物件的生成策略,就可以防止單例被破壞。
單元素列舉型別
第三種實現單例的方式是,宣告一個單元素的列舉類:
// Enum singleton - the preferred approach
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() { ... }
}
這個方法跟提供公有的欄位方法很類似,但它更簡潔,提供天然的可序列化機制和能夠強有力地保證不會出現多次例項化的情況 ,甚至面對複雜的序列化和反射的攻擊下。這種方法可能看起來不太自然,但是擁有單元素的列舉型別可能是實現單例模式的最佳實踐。注意,如果單例必須要繼承一個父類而非列舉的情況下是無法使用該方式的(不過可以宣告一個實現了介面的列舉)。
我們分析一下,列舉型別是如何阻止反射來建立例項的?直接原始碼:
看Constructor類的newInstance方法。
public T newInstance(Object ... initargs)
throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, null, modifiers);
}
}
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
ConstructorAccessor ca = constructorAccessor; // read volatile
if (ca == null) {
ca = acquireConstructorAccessor();
}
@SuppressWarnings("unchecked")
T inst = (T) ca.newInstance(initargs);
return inst;
}
這行程式碼(clazz.getModifiers() & Modifier.ENUM) != 0
就是用來判斷目標類是不是列舉型別,如果是丟擲異常IllegalArgumentException("Cannot reflectively create enum objects")
,無法通過反射建立列舉物件!很顯然,反射無效了。
接下來,再看一下反序列化是如何預防的。依然按照上面說的順序去找到列舉型別對應的readEnum方法,如下:
private Enum<?> readEnum(boolean unshared) throws IOException {
if (bin.readByte() != TC_ENUM) {
throw new InternalError();
}
ObjectStreamClass desc = readClassDesc(false);
if (!desc.isEnum()) {
throw new InvalidClassException("non-enum class: " + desc);
}
int enumHandle = handles.assign(unshared ? unsharedMarker : null);
ClassNotFoundException resolveEx = desc.getResolveException();
if (resolveEx != null) {
handles.markException(enumHandle, resolveEx);
}
String name = readString(false);
Enum<?> result = null;
Class<?> cl = desc.forClass();
if (cl != null) {
try {
@SuppressWarnings("unchecked")
Enum<?> en = Enum.valueOf((Class)cl, name);
result = en;
} catch (IllegalArgumentException ex) {
throw (IOException) new InvalidObjectException(
"enum constant " + name + " does not exist in " +
cl).initCause(ex);
}
if (!unshared) {
handles.setObject(enumHandle, result);
}
}
handles.finish(enumHandle);
passHandle = enumHandle;
return result;
}
readString(false)
:首先獲取到列舉物件的名稱name。
Enum<?> en = Enum.valueOf((Class)cl, name)
:再指定名稱的指定列舉型別獲得列舉常量,由於列舉中的name是唯一,切對應一個列舉常量。所以我們獲取到了唯一的常量物件。這樣就沒有建立新的物件,維護了單例屬性。
看看Enum.valueOf
的JavaDoc文件:
- 返回具有指定名稱的指定列舉型別的列舉常量。 該名稱必須與用於宣告此型別中的列舉常量的識別符號完全匹配。 (不允許使用無關的空白字元。)
具體實現:
public static <T extends Enum<T>> T valueOf(Class<T> enumType,
String name) {
T result = enumType.enumConstantDirectory().get(name);
if (result != null)
return result;
if (name == null)
throw new NullPointerException("Name is null");
throw new IllegalArgumentException(
"No enum constant " + enumType.getCanonicalName() + "." + name);
}
enumConstantDirectory()
:返回一個Map,維護著名稱到列舉常量的對映。我們就是從這個Map裡獲取已經宣告的列舉常量,通過這個快取池一樣的元件,讓我們可以重用這個列舉常量!
總結
- 常見的單例寫法有他的弊端,存在安全性問題,如:反射,序列化的影響。
- 《Effective Java》作者Josh Bloch 提倡使用單元素列舉型別的方式來實現單例,首先建立一個列舉很簡單,其次列舉常量是執行緒安全的,最後有天然的可序列化機制和防反射的機制。
參考
分享
我加入了張龍老師的知識星球《effective java 3rd》英文版的學習群。加入我們,一起開啟程式設計師的英文學習之路。