將一個對象編碼成字節流稱作將該對象「序列化」。相反,從字節流編碼中重新構建對象被稱作「反序列化」。一旦對象被「序列化」后,它的編碼就可以從一臺虛擬機傳遞到另一臺虛擬機,或被存儲到磁盤上,供以后「反序列化」使用。序列化技術為JavaBean組件結構提供了標準的持久化數據格式。
74、謹慎的實現Serializable接口
一個類實現Serializable接口需要付出的代價:
- 一旦一個類被發布,就大大降低了「改變這個類的實現」的靈活性。若一個類實現了Serializable接口,它就成了這個類導出API的一部分。
- 增加了出現Bug和安全漏洞的可能性。序列化機制是一種語言之外的對象創建機制,反序列化是一個「隱藏的構造器」,具備與其他構造器相同的特點。因此,反序列化過程必須要保證所有的約束關系。
- 隨著發行新的版本,相關的測試負擔也增加了。
每個可序列化的類都有一個唯一的名為serialVersionUID的標識號與它相關聯。若類在私有的靜態final的long域中沒有顯式的指定這個標識號,系統就會自動的為該類產生一個標識號,這時類的兼容性將會遭到破壞,在運行時導致InvalidClassException異常。
為了繼承而設計的類應該盡可能少的去實現Serializable接口,用戶自定義的接口也應該盡可能少的繼承Serializable接口。例外,Throwable、Component和HttpServlet抽象類。
內部類不應該實現Serializable即可。靜態成員類可以實現Serializable接口。
75、考慮使用自定義的序列化形式
對于一個對象來說,理想的序列化形式應該只包含該對象所表示的邏輯數據,而邏輯數據與物理表示法(存儲結構)應該是獨立的。如果一個對象的物理表示法等同于它的邏輯內容,就適用于使用默認的序列化形式。如:
public class Name implements Serializable {
/**
* Last name. Must be non-null.
* @serial
*/
private final String lastName;
/**
* first name. Must be non-null.
* @serial
*/
private final String firstName;
private final String middleName;
....
}
在這段代碼中,Name類的實例域精確的反應了它的邏輯內容,可以使用默認的序列化形式。注意:雖然lastName、firstName和middleName域是私有的,但它們仍然需要有注釋文檔。因為,這些私有域定義了一個公有的API,即這個類的序列化形式。@serial標簽用來告知Javadoc工具,把這些文檔信息放在有關序列化形式的特殊文檔頁中。
當一個對象的物理表示法與它的邏輯內容之間有實質性的不同時,使用默認序列化形式有如下缺點:
- 它將這個類的導出API永遠束縛在了該類的內部表示法上。如,私有內部類變成公有API的一部分。
- 會消耗過多的空間和時間
- 會引起棧溢出
- 其約束關系可能遭到嚴重破壞,如散列表
如:
//默認序列化形式
public final class StringList implements Serializable {
private int size = 0;
private Entry head = null;
private static class Entry implements Serializable {
String data;
Entry next;
Entry previous;
}
....
}
自定義序列化:
public final class StringList implements Serializable {
private static final long serialVersionUID = ...;
private transient int size = 0; //不會被序列化
private transient Entry head = null;
private static class Entry {
String data;
Entry next;
Entry previous;
}
public final void add(String s) { ... }
/**
* Serialize this {@code StringList} instance
*
* @serialData The size of the list (the number of strings it contains)
* is emitted ({@code int}), followed by all of its elements (each a
* {@code String}), in the proper sequence.
*/
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
s.writeInt(size);
for(Entry e = head; e != null; e = e.next ) {
s.writeObject(e.data);
}
}
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
int num = s.readInt();
for(int i=0; i < num; i++) {
add((String)s.readObject());
}
}
.....
}
注意:盡管StringList的所有域都是transient,但writeObject和readObject的首要任務仍是調用defaultXxxObject方法,這樣可以極大的增強靈活性。另外盡管writeObject是私有的,仍然需要文檔注釋。
無論自定義序列化還是默認序列化,對于一個線程安全的對象,必須在序列化方法上強制同步。如:
private synchronized void writeObject(ObjectOutputStream s)
throws IOException {
s.defaultWriteObject();
}
總之,當要將一個類序列化時,應該仔細考慮采用默認序列化還是自定義序列化。選擇錯誤的序列化形式對于一個類的復雜性和性能都會有永久的負面影響。
76、保護性編寫readObject方法
readObject方法實際上相當于一個公有的構造器,如同其它構造器一樣,readObject方法必須檢查其參數的有效性,并且在必要的時候進行保護性拷貝。readObject是一個「用字節流作為唯一參數」的構造器,當面對一個人工仿造的字節流時,readObject產生的對象可能會違反它所屬類的約束條件,所以必須在readObject中增加約束性檢查,若有效性檢查失敗,拋出InvalidObjectException異常。如:
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if(this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date getStart() {
return new Date(start.getTime());
}
public Date getEnd() {
return new Date(end.getTime());
}
//反序列化時增加約束條件
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
if(start.compareTo(end) > 0) {
throw new InvalidObjectException(start + " after " + end);
}
}
}
在這段代碼中,盡管readObject中增加了有效性檢查,但通過偽造字節流創建可變的Period實例仍是可能的。做法是:字節流以Period實例開頭,然后附加上兩個額外的引用執行Period實例中兩個私有的Date域。攻擊者從ObjectInputStream中讀取Period實例,然后讀取其后的「惡意引用」,通過這個引用攻擊者就可以修改Period中私有的Date域。如:
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
Period period = new Period(new Date(), new Date());
out.writeObject(period);
byte[] ref = {0x71, 0, 0x7e, 0, 5}; //指向period中私有域start的字節
bos.write(ref);
ref[4] = 4; //指向period中私有域end的字節
bos.write(ref);
//反序列化
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
Period period1 = (Period)in.readObject();
//ref1指向period1中私有域start指向的對象,可通過這個引用修改不可變對象
Date ref1 = (Date)in.readObject();
Date ref2 = (Date)in.readObject();
因此,對于每個可序列化的不可變類,若它包含了私有的可變組件(對象的引用),那么在它的readObject方法中,必須對這些組件進行保護性拷貝。否則,它內部的約束條件可能遭受破壞。如:
private void readObject(ObjectInputStream s) {
s.defaultReadObject();
start = new Date(start.getTime());
end = new Date(end.getTime());
if(start.compareTo(end) > 0) {
throw new InvalidObjectException(start + " after " + end);
}
}
注意:final域必須在對象構造時初始化,為了使用readObject方法,必須將start和end域做成非final的。
編寫readObject方法的指導原則:
- 對于對象引用域必須保持為私有的類,要保護性的拷貝這些域中的每個對象。
- 對于任何約束條件,若檢查失敗,則拋出一個InvalidObjectException異常。檢查應在保護性拷貝之后。
- 無論直接方式還是間接方式,都不要調用類中任何可被覆蓋的方法,否則反序列時可能會失敗。
77、對于實例控制,枚舉類型優先于readResolve
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { }
....
}
如上所示,若這個Singleton類的聲明上加上「implements Serializable」,它就不再是一個Singleton。無論該類使用的是默認的序列化形式還是自定義的序列化形式。因為任何一個readObject方法,它都會返回一個新建的實例。
對于一個正在被反序列化的對象,若它的類定義了一個readResolve方法,那么在反序列化后,新建對象上的readResolve方法就會被調用。然后該方法返回的對象引用將被返回,取代新建的對象,而新建的對象將被垃圾回收。
public class Elvis implements Serializable {
public static final transient Elvis INSTANCE = new Elvis();
private Elvis() { }
....
private Object readResolve() {
//Return the one true Elvis
return INSTANCE;
}
}
該方法忽略了被反序列化的對象,只返回該類初始化時創建的Elvis實例。若依賴readResolve進行實例控制,帶有對象引用類型的所有實例域則都必須聲明為transient的。,否則能被人工仿造的字節流攻擊。靜態成員不屬于對象,不參與序列化。
通過將一個可序列化的實例受控的類編寫成枚舉,可以絕對保證除了所聲明的常量外,不會有別的實例。如:
public enum Elvis {
INSTANCE;
....
}
另外,readResolve的可訪問性很重要。若把readResolve方法放在一個final類上,它就應該是私有的。若readResolve方法是受保護的或共有的,并且子類沒有覆蓋它,對序列化過的子類實例進行反序列化,就會產生一個超類實例,這可能導致ClassCastException異常。
總之,應該盡可能的使用枚舉類型來實施實例控制的約束條件,若做不到,就必須提供一個readResolve方法,并將引用類型的域聲明為transient的。
78、考慮用序列化代理代替序列化實例
序列化代理模式能夠極大的減少實現Serializable接口所帶來的風險。
實現序列化代理模式的步驟:
- 首先為可序列化的類設計一個私有的靜態嵌套類,精確的表示外圍類實例的邏輯狀態。它有一個單獨的構造器,其參數類型為外圍類。外圍類及其序列化代理都必須實現Serializable接口。
- 將writeReplace方法添加到外圍類中。
- 在SerializableProxy類中提供readResolve方法,它返回邏輯上相等的外圍類的實例。
如:
//外圍類不需要serialVersionUID
public final class Period implements Serializable {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if(this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date getStart() {
return new Date(start.getTime());
}
public Date getEnd() {
return new Date(end.getTime());
}
//在序列化之前,將外圍類的實例轉變成它的序列化代理
private Object writeReplace(){
return new SerializationProxy(this);
}
//防止被攻擊者使用
private void readObject(ObjectInputStream stream)
throws InvalidObjectException{
throw new InvalidObjectException("Proxy required");
}
private static class SerializationProxy implements Serializable {
private static final long serialVersionUID = ...;
private final Date start;
private final Date end;
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private Object readResolve() {
return new Period(start, end);
}
}
}
正如保護性拷貝一樣,序列化代理可以阻止偽字節流的攻擊及內部域的盜用攻擊。與使用保護性拷貝不同,使用序列化代理允許Period的域為final的,這可以保證Period類真正不可變。序列化代理模式更容易實現,它不必考慮哪些域會被序列化攻擊,也不必顯示的執行有效性檢查。
序列化代理的局限性:不能與可以被客戶端擴展的類兼容,也不能與對象圖中包含循環的類兼容,比保護性拷貝性能低。