1. 程式人生 > >effectiveJava學習筆記:序列化

effectiveJava學習筆記:序列化

謹慎地實現Serializable

序列化將一個物件編碼成一個位元組流,通過儲存或傳輸這些位元組流資料來達到資料持久化的目的; 
反序列化將位元組流轉換成一個物件;

1.序列化的含義和作用

序列化用來將物件編碼成位元組流,反序列化就使將位元組流編碼重新構建物件。 
序列化實現了物件傳輸和物件持久化,所以它能夠為遠端通訊提供物件表示法,為JavaBean元件提供持久化資料。

2.序列化的危害

1.降低靈活性:為實現Serializable而付出的最大代價是,一旦一個類被髮布,就大大降低了”改變這個類的實現”的靈活性。如果一個類實現了Serializable,它的位元組流編碼(或者說序列化形式,serialized form)就變成了它的匯出的API的一部分,必須永遠支援這種序列化形式。

而且,特殊地,每個可序列化類都有唯一的標誌(serial version id,在類體現為私有靜態final的long域serialVersionUID),如果沒有顯式指示,那麼系統就會自動生成一個serialVersionUID,如果下一個版本改變了這個類,那麼系統就會重新自動生成一個serialVersionUID。因此如果沒有宣告顯式的uid,會破壞版本之間的相容性,執行時產生InvalidClassException。

2.降低封裝性:如果你接受了預設的序列化形式,這個類中私有的和包級私有的例項域將都變成匯出的API的一部分,這不符合”最低限度地訪問域”的實踐準則。

3.降低安全性

:增加了bug和漏洞的可能性,反序列化的過程其實類似於呼叫物件的構造器,但是這個過程又沒有用到構造器,因此如果位元組流被無意修改或被用心不測的人修改,那麼伺服器很可能會產生錯誤或者遭到攻擊。

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