Effective Java 第三版—— 87. 考慮使用自定義序列化形式

Tips
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些代碼裏方法是基於Java 9 API中的,因此JDK 最好下載 JDK 9以上的版本。java

Effective Java, Third Edition

87. 考慮使用自定義序列化形式

當在時間緊迫的狀況下編寫類時,一般應該將精力集中在設計最佳API上。有時這意味着發佈一個「一次性使用(throwaway)」實現,將在未來的版本中替換它。一般這不是一個問題,可是若是類實現Serializable並使用默認的序列化形式,將永遠沒法徹底「擺脫一次性使用」的實現了。它永遠決定序列化的形式。這不只僅是一個理論問題。這種狀況發生在Java類庫中的幾個類上,包括BigInteger。git

若是沒有考慮是否合適,請不要接受默認的序列化形式。 接受默認的序列化形式應該有意識地決定,從靈活性,性能和正確性的角度來看這種編碼是合理的。 通常來講,只有在與設計自定義序列化形式時所選擇的編碼大體相同的狀況下,才應接受默認的序列化形式。github

對象的默認序列化形式是對象圖(object graph)的物理表示形式的一種至關有效的編碼,該表示形式以對象爲根。換句話說,它描述了對象中包含的數據以及從該對象能夠訪問的每一個對象中的數據。它還描述了全部這些對象相互關聯的拓撲結構。理想的對象序列化形式只包含對象所表示的邏輯數據。它獨立於物理表示。緩存

若是對象的物理表示與其邏輯內容相同,則默認的序列化形式多是合適的。例如,默認的序列化形式對於下面的類來講是合理的,它簡單地表示一我的的名字:安全

// Good candidate for default serialized form
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;

    /**
     * Middle name, or null if there is none.
     * @serial
     */
    private final String middleName;

    ... // Remainder omitted
}

從邏輯上講,名稱由三個字符串組成,分別表示姓、名和中間名。名稱中的實例屬性精確地反映了這個邏輯內容。網絡

即便你肯定默認的序列化形式是合適的,一般也必須提供readObject方法以確保不變性和安全性。 對於Name類,readObject方法必須確保屬性lastName和firstName爲非null。 條目 88和90詳細討論了這個問題。數據結構

注意,雖然lastName、firstName和middleName屬性是私有的,可是它們都有文檔註釋。這是由於這些私有屬性定義了一個公共API,它是類的序列化形式,而且必須對這個公共API進行文檔化。@serial標籤的存在告訴Javadoc將此文檔放在一個特殊的頁面上,該頁面記錄序列化的形式。dom

與Name類的另外一極端,考慮下面的類,它表示一個字符串列表(暫時忽略使用標準List實現可能更好的建議):性能

// Awful candidate for default serialized form
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;
    }

    ... // Remainder omitted
}

從邏輯上講,這個類表示字符串序列。在物理上,它將序列表示爲雙鏈表。若是接受默認的序列化形式,則序列化形式將煞費苦心地鏡像鏈表中的每一個entry,以及每個entry之間的全部雙向連接。this

當對象的物理表示與其邏輯數據內容有很大差別時,使用默認的序列化形式有四個缺點:

  • 它將導出的API永久綁定到當前類的內部表示。 在上面的示例中,私有StringList.Entry類成爲公共API的一部分。 若是在未來的版本中更改了表示,則StringList類仍須要接受輸入上的鏈表表示,並在輸出時生成它。 該類永遠不會消除處理鏈表entry的全部代碼,即便再也不使用它們。

  • 它會消耗過多的空間。 在上面的示例中,序列化形式沒必要要地表示連接列表中的每一個entry和全部連接。 這些entry和連接僅僅是實現細節,不值得包含在序列化形式中。 因爲序列化形式過大,將其寫入磁盤或經過網絡發送將會很是慢。

  • 它會消耗過多的時間。 序列化邏輯不瞭解對象圖的拓撲結構,所以必須經歷昂貴的圖遍歷。 在上面的例子中,僅僅遵循下一個引用就足夠了。

  • 它會致使堆棧溢出。 默認的序列化過程執行對象圖的遞歸遍歷,即便對於中等大小的對象圖,也可能致使堆棧溢出。 使用1,000-1,800個元素序列化StringList實例,就會在個人機器上生成StackOverflowError異常。 使人驚訝的是,序列化致使堆棧溢出的最小列表大小因運行而異(在個人機器上)。 顯示此問題的最小列表大小可能取決於平臺實現和命令行標記; 某些實現可能根本沒有這個問題。

StringList的合理序列化形式,就是列表中的字符串數量,而後緊跟着字符串自己。這構成了由StringList表示的邏輯數據,去掉了其物理表示的細節。下面是修改後的StringList版本,包含實現此序列化形式的writeObject和readObject方法。提醒一下,transient修飾符表示要從類的默認序列化形式中省略一個實例屬性:

// StringList with a reasonable custom serialized form
public final class StringList implements Serializable {
    private transient int size   = 0;
    private transient Entry head = null;

    // No longer Serializable!
    private static class Entry {
        String data;
        Entry  next;
        Entry  previous;
    }

    // Appends the specified string to the list
    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);

        // Write out all elements in the proper order.
        for (Entry e = head; e != null; e = e.next)
            s.writeObject(e.data);
    }

    private void readObject(ObjectInputStream s)
            throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int numElements = s.readInt();

        // Read in all elements and insert them in list
        for (int i = 0; i < numElements; i++)
            add((String) s.readObject());
    }

    ... // Remainder omitted
}

writeObject作的第一件事就是調用defaultWriteObject方法,而readObject作的第一件事就是調用defaultReadObject,即便全部StringList的屬性都是瞬時狀態(transient)的。 你可能會聽到它說若是全部類的實例屬性都是瞬時狀態的,那麼能夠省去調用defaultWriteObject和defaultReadObject,但序列化規範要求不管如何都要調用它們。 這些調用的存在使得能夠在之後的版本中添加非瞬時狀態的實例屬性,同時保持向後和向前兼容性。 若是實例在更高版本中序列化,並在早期版本中反序列化,則添加的屬性將被忽略。 若是早期版本的readObject方法沒法調用defaultReadObject,則反序列化將失敗,拋出StreamCorruptedException異常。

請注意,writeObject方法有一個文檔註釋,即便它是私有的。 這相似於Name類中私有屬性的文檔註釋。 此私有方法定義了一個公共API,它是序列化形式,而且應該記錄公共API。 與屬性的@serial標籤同樣,方法的@serialData標籤告訴Javadoc實用程序將此文檔放在序列化形式的頁面上。

爲了給前面的性能討論提供必定的伸縮性,若是平均字符串長度是10個字符,那麼通過修改的StringList的序列化形式佔用的空間大約是原始字符串序列化形式的一半。在個人機器上,長度爲10的列表,序列化修訂後的StringList的速度是序列化原始版本的兩倍多。最後,在修改後的序列化形式中沒有堆棧溢出問題,所以對於可序列化的StringList的大小沒有實際的上限。

雖然默認的序列化形式對於StringList來講是很差的,可是對於有些類會可能更糟糕。 對於StringList,默認的序列化形式是不靈活的,而且執行得很糟糕,可是在序列化和反序列化StringList實例,它產生了原始對象的忠實副本,其全部不變性都是完整的。 對於其不變性與特定實現的詳細信息相關聯的任何對象,狀況並不是如此。

例如,考慮哈希表(hash table)的狀況。它的物理表示是一系列包含鍵值(key-value)項的哈希桶。每一項所在桶的位置,是其鍵的散列代碼的方法決定的,一般狀況下,不能保證從一個實現到另外一個實現是相同的。事實上,它甚至不能保證每次運行都是相同的。所以,接受哈希表的默認序列化形式會構成嚴重的錯誤。對哈希表進行序列化和反序列化可能會產生一個不變性嚴重損壞的對象。

不管是否接受默認的序列化形式,當調用defaultWriteObject方法時,沒有標記爲transient的每一個實例屬性都會被序列化。所以,能夠聲明爲transient的每一個實例屬性都應該是。這包括派生(derived)屬性,其值能夠從主要數據屬性(primary data fields)(如緩存的哈希值)計算。它還包括一些屬性,這些屬性的值與JVM的一個特定運行相關聯,好比表示指向本地數據結構指針的long型屬性。在決定使非瞬時狀態的屬性以前,請確信它的值是對象邏輯狀態的一部分。若是使用自定義序列化形式,則大多數或全部實例屬性都應該標記爲transient,如上面的StringList示例所示。

若是使用默認的序列化形式,而且標記了一個或多個屬性爲transient,請記住,當反序列化實例時,這些屬性將初始化爲默認值:對象引用屬性爲null,基本數字類型的屬性爲0,布爾屬性爲false [JLS, 4.12.5]。若是這些值對於任何瞬時狀態的屬性都不可接受,則必須提供一個readObject方法,該方法調用defaultReadObject方法,而後將瞬時狀態的屬性恢復爲可接受的值(條目 88)。或者,這些屬性能夠在第一次使用時進行延遲初始化(條目 83)。

不管是否使用默認的序列化形式,必須對對象序列化加以同步,也要對讀取對象的整個狀態的任何方法施加同步。。 所以,例如若是有一個線程安全的對象(條目 82)經過同步每一個方法來實現其線程安全,而且選擇使用默認的序列化形式,請使用如下write-Object方法:

// writeObject for synchronized class with default serialized form
private synchronized void writeObject(ObjectOutputStream s)
        throws IOException {
    s.defaultWriteObject();
}

若是將同步放在writeObject方法中,則必須確保它遵照與其餘活動相同的鎖排序( lock-ordering)約束,不然將面臨資源排(resource-ordering)序死鎖的風險[Goetz06, 10.1.5]。

不管選擇哪一種序列化形式,都要在編寫的每一個可序列化類中聲明顯式的序列版本UID。這消除了序列版本UID做爲不兼容性的潛在來源(條目 86)。還有一個小的性能優點。若是沒有提供序列版本UID,則須要執行昂貴的計算來在運行時生成一個UID。

聲明序列版本UID很簡單。只須要在類中添加這一行:

private static final long serialVersionUID = randomLongValue;

如編寫一個新類,爲randomLongValue選擇什麼值並不重要。能夠經過在類上運行serialver實用程序來生成該值,可是也能夠憑空選擇一個數字。序列版本UID不須要是唯一的。若是修改缺乏序列版本UID的現有類,而且但願新版本接受現有的序列化實例,則必須使用爲舊版本自動生成的值。能夠經過在類的舊版本上運行serialver實用程序(序列化實例存在於舊版本上)來得到這個數字。

若是想要建立與現有版本不兼容的類的新版本,只需更改序列版本UID聲明中的值便可。 這將致使嘗試反序列化先前版本的序列化實例拋出InvalidClassException異常。 不要更改序列版本UID,除非想破壞與類的全部現有序列化實例的兼容性

總而言之,若是你已肯定某個類應該可序列化(條目 86),請仔細考慮序列化形式應該是什麼。 僅當它是對象邏輯狀態的合理描述時,才使用默認的序列化形式;不然設計一個適當描述對象的自定義序列化形式。 在分配設計導出方法時,應該分配儘量多的時間來設計類的序列化形式(條目 51)。 正如沒法從未來的版本中刪除導出的方法同樣,也沒法從序列化形式中刪除屬性;必須永久保存它們以確保序列化兼容性。 選擇錯誤的序列化形式會對類的複雜性和性能產生永久性的負面影響。

相關文章
相關標籤/搜索