effectiveJava學習筆記:序列化
謹慎地實現Serializable
序列化:將一個物件編碼成一個位元組流,通過儲存或傳輸這些位元組流資料來達到資料持久化的目的;
反序列化:將位元組流轉換成一個物件;
1.序列化的含義和作用。
序列化用來將物件編碼成位元組流,反序列化就使將位元組流編碼重新構建物件。
序列化實現了物件傳輸和物件持久化,所以它能夠為遠端通訊提供物件表示法,為JavaBean元件提供持久化資料。
2.序列化的危害
1.降低靈活性:為實現Serializable而付出的最大代價是,一旦一個類被髮布,就大大降低了”改變這個類的實現”的靈活性。如果一個類實現了Serializable,它的位元組流編碼(或者說序列化形式,serialized form)就變成了它的匯出的API的一部分,必須永遠支援這種序列化形式。
而且,特殊地,每個可序列化類都有唯一的標誌(serial version id,在類體現為私有靜態final的long域serialVersionUID),如果沒有顯式指示,那麼系統就會自動生成一個serialVersionUID,如果下一個版本改變了這個類,那麼系統就會重新自動生成一個serialVersionUID。因此如果沒有宣告顯式的uid,會破壞版本之間的相容性,執行時產生InvalidClassException。
2.降低封裝性:如果你接受了預設的序列化形式,這個類中私有的和包級私有的例項域將都變成匯出的API的一部分,這不符合”最低限度地訪問域”的實踐準則。
3.降低安全性
4.降低可測試性:隨著類版本的不斷更替,必須滿足版本相容問題,所以發行的版本越多,測試的難度就越大。
5.降低效能:序列化物件時,不僅會序列化當前物件本身,還會對該物件引用的其他物件也進行序列化。如果一個物件包含的成員變數是容器類等並深層引用時(物件是連結串列形式),此時序列化開銷會很大,這時必須要採用其他一些手段處理。
3.序列化的使用場景
1.需要實現一個類的物件傳輸或者持久化。
2.A是B的元件,當B需要序列化時,A也實現序列化會更容易讓B使用。
4.序列化不適合場景
為了繼承而設計的類應該儘可能少地去實現Serializable介面,使用者介面也應該儘可能不繼承Serializable介面,原因是子類或實現類也要承擔序列化的風險。
5.序列化需要注意的地方
1)如果父類實現了Serializable,子類自動序列化了,不需要實現Serializable;
2)若父類未實現Serializable,而子類序列化了,父類屬性值不會被儲存,反序列化後父類屬性值丟失,需要父類有一個無參的構造器,子類要負責序列化(反序列化)父類的域,子類要先序列化自身,再序列化父類的域。
至於為什麼需要父類有一個無參的構造器,是因為子類先序列化自身的時候先呼叫父類的無參的構造器。
例項:
private void writeObject(java.io.ObjectOutputStream out)
throws IOException{
out.defaultWriteObject();//先序列化物件
out.writeInt(parentvalue);//再序列化父類的域
}
private void readObject(java.io.ObjectInputStream in)
throws IOException, ClassNotFoundException{
in.defaultReadObject();//先反序列化物件
parentvalue=in.readInt();//再反序列化父類的域
}
3)序列化時,只對物件狀態進行了儲存,物件方法和類變數等並沒有儲存,因此序列化並不儲存靜態變數值。
4)當一個物件的例項變數引用其他物件,序列化該物件時也把引用物件序列化了。所以元件也應該序列化。
5)不是所有物件都可以序列化,基於安全和資源方面考慮,如Socket/thread若可序列化,進行傳輸或儲存,無法對他們重新分配資源。
考慮使用自定義的序列化形式
若一個物件的物理表示法等同於它的邏輯內容,則可以使用預設的序列化形式
預設序列化形式描述物件內部所包含的資料,及每一個可以從這個物件到達其他物件的內部資料。
若一個物件的物理表示法與邏輯資料內容有實質性區別時,如下面的類:
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;
}
}
我們知道,序列一個類,會同時序列化它的元件。
也就是說,如果我序列化了A物件, B是雙向連結串列,它要序列化它的內部成員B和C物件,但是序列化B和C物件的時候,A同時也是它們的元件,也要序列化A
於是就進入了無窮的死迴圈中。會有下面的問題
a) 該類匯出API被束縛在該類的內部表示法上,連結串列類也變成了公有API的一部分,若將來內部表示法發生變化,仍需要接受連結串列形式的輸入,併產生鏈式形式的輸出。
b) 消耗過多空間:像上面的例子,序列化既表示了連結串列中的每個項,也表示了所有連結串列關係,而這是不必要的。這樣使序列化過於龐大,把它寫到磁碟中或網路上傳送都很慢;
c) 消耗過多時間:序列化邏輯並不瞭解物件圖的拓撲關係,所以它必須要經過一個圖遍歷過程。
d) 引起棧溢位:預設的序列化過程要對物件圖執行一遍遞迴遍歷,這樣的操作可能會引起棧溢位。
這時候,我們的需求很簡單,對於每個物件的Entry,我只序列化一次就行了,不需要迭代序列化。
於是就有了transient關鍵字,不會被序列化。
public final class StringList implements Serializable {
private transient int size = 0;
private transient Entry head = null;
//此類不再實現Serializable介面
private static class Entry {
String data;
Entry next;
Entry previous;
}
private final void add(String s) {
size++;
Entry entry = new Entry();
entry.data = s;
head.next = entry;
}
/**
* 自定義序列化
* @param s
* @throws IOException
*/
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);
}
}
/**
* 自定義反序列化
* @param s
* @throws IOException
*/
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException{
s.defaultReadObject();
size = s.readInt();
for (Entry e = head; e != null; e = e.next) {
add((String) s.readObject());
}
}
}
標記為transient的不會自動序列化,這就防止預設序列化做出錯誤的事情,然後呼叫writeObject手動序列化transient欄位,做自己認為正確的事。readObject同理。
總結,自定義序列化目的就是做自己認為正確的事情,經典的例子有ArrayList和HashMap。
保護性地編寫readObject方法
這個和之前公有的構造器一樣,將引數進行保護性拷貝。
readObject相當於一個公有構造器,而構造器需要檢查引數有效性及必要時對引數進行保護性拷貝。而如果序列化的類包含了私有的可變元件,就需要在readObject方法中進行保護性拷貝。
我們看兩者:
public final class Period implements Serializable {
private Date start;
private 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 bigger end");
}
}
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
start = new Date(start.getTime());//保護性拷貝
end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0) {
throw new IllegalArgumentException("start bigger end");
}
}
}
如果實體域被宣告為final,為了實現可序列化可能需要去掉final
①對於物件引用必須保持為私有的類,要保護性拷貝這些域中的每個物件
②對於任何約束條件,如果檢查失敗,則丟擲一個InvalidObjectException異常
③如果整個物件圖在被反序列化之後必須進行驗證,就應該使用ObjectInputValidation介面
④無論是直接還是間接方式,都不要呼叫類中任何可被覆蓋的方法
單例模式序列化,列舉型別優先於readObsolve
對單例:
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { }
public static Elvis getINSTANCE() {
return INSTANCE;
}
}
通過序列化工具,可以將一個類的單例的例項物件寫到磁碟再讀回來,從而有效獲得一個例項。
如果想要單例實現Serializable,任何readObject方法,它會返回一個新建的例項,這個新建例項不同於該類初始化時建立的例項。從而導致單例獲取失敗。但序列化工具可以讓開發人員通過readResolve來替換readObject中建立的例項,即使構造方法是私有的。在反序列化時,新建物件上的readResolve方法會被呼叫,返回的物件將會取代readObject中新建的物件。
//該方法忽略了被反序列化的物件,只返回該類初始化時建立的那個Elvis例項
private Object readResolve() {
return INSTANCE;
}
採用readResolve的一些缺點:
1) readResolve的可訪問性需要控制好,否則很容易出問題。如果readResolve方法是受保護或是公有的,且子類沒有覆蓋它,序列化的子類例項進行反序列化時,就會產生一個超類例項,這時可能導致ClassCastException異常。
2) readResolve需要類的所有例項域都用transient來修飾,否則可能被攻擊。
而將一個可序列化的例項受控類用列舉實現,可以保證除了宣告的常量外,不會有別的例項。
所以如果一個單例需要序列化,最好用列舉來實現:
public enum Elvis implements Serializable {
INSTANCE;
private String[] favriteSongs = {"test", "abc"};//如果不是列舉,需要將該變數用transient修飾
}
考慮用序列化代理代替序列化例項
序列化代理類:為可序列化的類設計一個私有靜態巢狀類,精確地表示外部類的例項的邏輯狀態。
(1) 使用場景
1) 必須在一個不能被客戶端擴充套件的類上編寫readObject或writeObject方法時,可以考慮使用序列化代理模式;
2) 想穩定地將帶有重要約束條件的物件序列化時
(2) 序列化代理類的使用方法
序列代理類應該有一個單獨的構造器,引數就是外部類,此構造器只能引數中拷貝資料,不需要一致性檢查或是保護性拷貝。外部類及其序列化代理類都必須宣告實現Serializable介面。
writeReplace: 如果實現了writeReplace方法後,在序列化時會先呼叫writeReplace方法將當前物件替換成另一個物件(該方法會返回替換後的物件)並將其寫入資料流。
具體實現如下:
public class Period implements Serializable{
private static final long serialVersionUID = 1L;
private final Date start;
private final Date end;
public Period(Date start, Date end) {
if(null == start || null == end || start.after(end)){
throw new IllegalArgumentException("Time Uncurrent");
}
this.start = start;
this.end = end;
}
public Date start(){
return new Date(start.getTime());
}
public Date end(){
return new Date(end.getTime());
}
@Override
public String toString(){
return "startTime:" + start + " , endTime:" + end;
}
/**
* 序列化外圍類時,虛擬機器會轉掉這個方法,最後其實是序列化了一個內部的代理類物件!
* @return
*/
private Object writeReplace(){
System.out.println("進入writeReplace()方法!");
return new SerializabtionProxy(this);
}
/**
* 如果攻擊者偽造了一個位元組碼檔案,然後來反序列化也無法成功,因為外圍類的readObject方法直接拋異常!
* @param ois
* @throws InvalidObjectException
*/
private void readObject(ObjectInputStream ois) throws InvalidObjectException{
throw new InvalidObjectException("Proxy required!");
}
/**
* 序列化代理類,他精確表示了其當前外圍類物件的狀態!最後序列化時會將這個私有內部內進行序列化!
*/
private static class SerializabtionProxy implements Serializable{
private static final long serialVersionUID = 1L;
private final Date start;
private final Date end;
SerializabtionProxy(Period p){
this.start = p.start;
this.end = p.end;
}
/**
* 反序列化這個類時,虛擬機器會呼叫這個方法,最後返回的物件是一個Period物件!這裡同樣呼叫了Period的建構函式,
* 會進行建構函式的一些校驗!
*/
private Object readResolve(){
System.out.println("進入readResolve()方法,將返回Period物件!");
// 這裡進行保護性拷貝!
return new Period(new Date(start.getTime()), new Date(end.getTime()));
}
}
(3) 代理類的侷限性
不能與可以被客戶端擴充套件的類相容;
不能與物件圖中包仿迴圈的某些類相容;
(4) 使用writeReplace的幾點注意事項
1) 實現了writeReplace就不要實現writeObject方法,因為writeReplace返回值會被自動寫入輸出流中,相當於自動呼叫了writeObject(writeReplace())
2) writeReplace的返回值必須是可序列化的;
3) 若返回的是自定義型別的物件,該型別必須是實現了序列化。
4) 使用writeReplace替換寫入後的物件不能通過實現readObject方法實現自動恢復,因為物件預設被徹底替換了,就不存在自定義序列化問題,直接自動反序列化了。
5) writeObject和readObject配合使用,實現了writeReplace就不再需要writeObject和readObject