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

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

rip 有一個 nds size throw ansi all num 兼容性

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

技術分享圖片

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

當在時間緊迫的情況下編寫類時,通常應該將精力集中在設計最佳API上。有時這意味著發布一個“一次性使用(throwaway)”實現,將在將來的版本中替換它。通常這不是一個問題,但是如果類實現Serializable並使用默認的序列化形式,將永遠無法完全“擺脫一次性使用”的實現了。它永遠決定序列化的形式。這不僅僅是一個理論問題。這種情況發生在Java類庫中的幾個類上,包括BigInteger。

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

對象的默認序列化形式是對象圖(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將此文檔放在一個特殊的頁面上,該頁面記錄序列化的形式。

與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之間的所有雙向鏈接。

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

  • 它將導出的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)。 正如無法從將來的版本中刪除導出的方法一樣,也無法從序列化形式中刪除屬性;必須永久保存它們以確保序列化兼容性。 選擇錯誤的序列化形式會對類的復雜性和性能產生永久性的負面影響。

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