Effective Java讀書筆記八:序列化(74-78)

衣舞晨風發表於2017-02-07

第74條:謹慎地實現Serializable介面

物件序列化API,它提供了一個框架,用來將物件編碼成位元組流,並從位元組流編碼中重新構建物件。將一個物件編碼成一個位元組流,稱作將該物件序列化,相反的處理過程稱作反序列化。一旦物件被序列化後,它的編碼就可以從一臺正在執行的虛擬機器傳遞到另一臺虛擬機器上,或者被儲存到磁碟上,供以後反序列化時用。序列化技術為遠端通訊提供了標準和線路級物件表示法,也為JavaBeans元件提供了標準和持久化資料格式。

要想使一個類的例項可被序列化,非常簡單,只要在它的宣告中加入”implements Serializable”字樣即可。正因為太容易了,所以普遍存在這樣一種誤解,認為程式設計師只需要做極少量的工作就可以支援序列化了。實際的情形要複雜得多。雖然使一個類可被序列化的直接開銷低到甚至可以忽略不計,但是為了序列化而付出的長期開銷往往是實實在在的。

為實現Serializable而付出的最大代價是,一旦一個類被髮布,就大大降低了”改變這個類的實現”的靈活性。如果一個類實現了Serializable,它的位元組流編碼(或者說序列化形式,serialized form)就變成了它的匯出的API的一部分。一旦這個類被廣泛使用,往往必須永遠支援這種序列化形式,就好像你必須要支援匯出的API的所有其他部分一樣。如果你不努力設計一個自定義的序列化形式(custom serialized form),而僅僅接受了預設的序列化形式,這種序列化形式將被永遠地束縛在該類最初的內部表示法上。換句話說,如果你接受了預設的序列化形式,這個類中私有的和包級私有的例項域將都變成匯出的API的一部分,這不符合”最低限度地訪問域”的實踐準則,從而它就失去了作為資訊隱藏工具的有效性。

如果你接受了預設的序列化形式,並且以後又要改變這個類的內部表示法,結果可能導致序列化形式的不相容。客戶端程式企圖用這個類的舊版本來序列化一個類,然後用新版本進行反序列化,結果將導致程式失敗。在改變內部表示法的同時仍然維持原來的序列化形式(使用ObjectOutputStream.putFields和ObjectInputStream.readFields),這也是可能的,但是做起來比較困難,並且會在原始碼中留下一些可以明顯的隱患。因此,你應該仔細地設計一種高質量的序列化形式,並且在很長時間內都願意使用這種形式。這樣做將會增加開發的初始成本,但這是值得的。設計良好的序列化形式也許會給類的演變帶來限制;但是設計不好的序列化形式則可能會使類根本無法演變。

序列化會使類的演變受到限制,這種限制的一個例子與流的唯一識別符號(stream unique identifier)有關,通常它也被稱為序列版本UID(serial version UID)。每個可序列化的類都有一個唯一標識號與它相關聯。如果你沒有在一個名為serialVersionUID的私有靜態final的long域中顯式地指定該標識號,系統就會自動地將一個複雜的過程作用在這個類上,從而在執行時產生該標識號。這個自動產生的值會受到類名稱、它所實現的介面的名稱、以及所有公有的和受保護的成員的名稱所影響。如果你通過任何方式改變了這些資訊,比如,增加了一個不是很重要的工具方法,自動產生的序列版本UID也會發生變化。因此,如果你沒有宣告一個顯式的序列版本UID,相容性將會遭到破壞,在執行時導致InvalidClassException異常。

實現Serializable的第二個代價是,它增加了出現Bug和安全漏洞的可能性。通常情況下,物件是利用構造器來建立的;序列化機制是一種語言之外的物件建立機制(extralinguistic mechanism)。無論你是接受了預設的行為,還是覆蓋了預設的行為,反序列化機制(deserialization)都是一個”隱藏的構造器”,具備與其他構造器相同的特點。因為反序列化機制中沒有顯式的構造器,所以你很容易忘記要確保:反序列化過程必須也要保證所有”由構造器建立起來的約束關係”,並且不允許攻擊者訪問正在構造過程中的物件的內部資訊。依靠預設的反序列化機制,可以很容易地使物件的約束關係遭到破壞,以及遭受到非法訪問。

實現Serializable的第三個代價是,隨著類發行新的版本,相關的測試負擔也增加了。當一個可序列化的類被修訂的時候,很重要的一點是,要檢查是否可以”在新版本中序列化一個例項,然後在舊版本中反序列化”,反之亦然。因此,測試所需要的工作量與”可序列化的類的數量和發行版本號”的乘積成正比,這個乘積可能會非常大。這些測試不可能自動構建,因為除了二進位制相容性(binary compatibility)以外,你還必須測試語義相容性(semantic compatibility)。換句話說,你必須既要確保”序列化-反序列化”過程成功,也要確保結果產生的物件真正是原始物件的複製品。可序列化類的變化越大,它就越需要測試。如果在最初編寫一個類的時候,就精心設計了自定義的序列化形式,測試的要求就可以有所降低,但是也不能完全沒有測試。

實現Serializable介面並不是一個很輕鬆就可以做出的決定。它提供了一些實在的益處:如果一個類將要加入到某個框架中,並且該框架依賴於序列化來實現物件傳輸或者持久化,對於這個類來說,實現Serializable介面就非常有必要。更進一步來看,如果這個類要成為另一個類的一個元件,並且後者必須實現Serializable介面,若前者也實現了Serializable介面,它就會更易於被後者使用。然而,有許多實際的開銷都與實現Serializable介面有關。每當你實現一個類的時候,都需要權衡一下所付出的代價和帶來的好處。根據經驗,比如Date和BigInteger這樣的值類應該實現Serializable,大多數的集合類也應該如此。代表活動實體的類,比如執行緒池(thread pool),一般不應該實現Serializable。

為了繼承而設計的類應該很少實現Serializable,介面也應該很少會擴充套件它。如果違反了這條規則,擴充套件這個類或者實現這個介面的程式設計師就會背上沉重的負擔。然而在有些情況下違反這條規則卻是合適的。例如,如果一個類或者介面存在的目的主要是為了參與到某個框架中,該框架要求所有的參與者都必須實現Serializable,那麼,對於這個類或者介面來說,實現或者擴充套件Serializable就是非常有意義的。

為了繼承而設計的類中真正實現了Serializable的有Throwable、Component和HttpServlet。因為Throwable實現了Serializable,所以RMI的異常可以從伺服器端傳到客戶端。Component實現了Serializable,因此GUI可以被髮送、儲存和恢復。HttpServlet實現了Serializable,因此會話狀態可以被快取。

如果一個專門為了繼承而設計的類不是可序列化的,就不可能編寫出可序列化的子類。特別是,如果超類沒有提供可訪問的無參構造器,子類也不可能做到可序列化。因此,對於為繼承而設計的不可序列化的類,你應該考慮提供一個無參構造器。

內部類不應該實現Serializable。它們使用編譯器產生的合成域來儲存指向外圍例項的引用,以及儲存來自外圍作用域的區域性變數的值。因此,內部類的預設序列化形式是定義不清楚的。然而,靜態成員類卻是可以實現Serializable介面。

簡而言之,千萬不要認為實現Serializable介面會很容易。除非一個類在用了一段時間之後就會被拋棄,否則,實現Serializable介面就是個很嚴肅的承諾,必須認真對待。如果一個類是為了繼承而設計的,則吏加需要加倍小心。對於這樣的類而言,在“允許子類實現Serializable介面”或“禁止子類實現Serializable介面”兩者之間的一個折衷設計方案是,提供一個可訪問的無參構造器,這種設計方案允許(但不要求)子類實現Serializable介面。

第75條:考慮使用自定義的序列化形式

設計一個類的序列化形式和設計該類的API 同樣重要,因此在沒有認真考慮好預設的序列化形式是否合適之前,不要貿然使用預設的序列化行為。在作出決定之前,你需要從靈活性、效能和正確性多個角度對這種編碼形式進行考察。一般來講,只有當你自行設計的自定義序列化形式與預設的形式基本相同時,才能接受預設的序列化形式。

比如,當一個物件的物理表示法等同於它的邏輯內容,可能就適合於使用預設的序列化形式。

見如下程式碼示例:

public class Name implements Serializable { 
            private final String lastName; 
            private final String firstName; 
            private final String middleName; 
            ... ... 
        } 

從邏輯角度而言,該類的三個域欄位精確的反應出它的邏輯內容。然而有的時候,即便預設的序列化形式是合適的,通常還必須提供一個readObject 方法以保證約束關係和安全性,如上例程式碼中,firstName 和lastName 不能為null 等。

使用預設序列化形式會有以下幾個缺點:
(1)它使這個類的匯出API 永遠的束縛在該類的內部表示法上,即使今後找到更好的的實現方式,也無法擺脫原有的實現方式。
(2)它會消耗過多的空間。
(3)它會消耗過多的時間。
(4)它會引起棧溢位。

transient是Java語言的關鍵字,用來表示一個域不是該物件序列化的一部分。當一個物件被序列化的時候,transient型變數的值不包括在序列化的表示中,然而非transient型的變數是被包括進去的。

在序列化過程中,虛擬機器會試圖呼叫物件類裡的writeObject() 和readObject(),進行使用者自定義的序列化和反序列化,如果沒有則呼叫ObjectOutputStream.defaultWriteObject() 和ObjectInputStream.defaultReadObject()。

同樣,在ObjectOutputStream和ObjectInputStream中最重要的方法也是writeObject() 和 readObject(),遞迴地寫出/讀入byte。

所以使用者可以通過writeObject()和 readObject()自定義序列化和反序列化邏輯。對一些敏感資訊加密的邏輯也可以放在此。

對於預設序列化還需要進一步說明的是,當一個或多個域欄位被標記為transient 時,如果要進行反序列化,這些域欄位都將被初始化為其型別預設值,如物件引用域被置為null,數值基本域的預設值為0,boolean域的預設值為false。如果這些值不能被任何transient 域所接受,你就必須提供一個readObject方法。它首先呼叫defaultReadObject,然後再把這些transient 域恢復為可接受的值。

最後需要說明的是,無論你是否使用預設的序列化形式,如果在讀取整個物件狀態的任何其他方法上強制任何同步,則也必須在物件序列化上強制這種同步。

無論你選擇了哪種序列化形式,都要為自己編寫的每個可序列化的類宣告一個顯式的序列版本UID。這樣可以避免序列版本UID成為潛在的不相容根源,同時也會帶來小小的效能好處,因為不需要去算序列版本UID。

第76條:保護性地編寫readObject方法

對於非final 的可序列化類,在readObject 方法和構造器之間還有其他類似的地方,readObject方法不可以呼叫可被覆蓋的方法,無論是直接呼叫還是間接調都不可以。如果違反了該規則,並且覆蓋了該方法,被覆蓋的方法將在子類的狀態被反序列化之前先執行。程式很可能會失敗。

總而言之,每當你編寫readObject方法的時候,都要這樣想:你正在編寫一個公有的構造器,無論給它傳遞什麼樣的位元組流,它都必須產生一個有效的例項。不要假設這個位元組流一定代表著一個真正被序列化過的例項。雖然在本條目的例子中,類使用了預設的序列化形式,但是,所有討論到的有可能發生的問題也同樣適用於使用自定義序列化形式的類。下面以摘要的形式給出一些指導方針,有助於編寫出更加健壯的readObject方法:

  • 對於物件引用域必須保持為私有的類,要保護性地拷貝這些域中的每個物件。不可變類的可變元件就屬於這一類別。
  • 對於任何約束條件,如果檢查失敗,則丟擲一個InvalidObjectException異常。這些檢查動作應該跟在所有的保護性拷貝之後。
  • 如果整個物件圖在被反序列化之後必須進行驗證,就應該使用ObjectInputValidation介面[JavaSE6,Serialization]。
  • 無論是直接方式還是間接方式,都不要呼叫類中任何可被覆蓋的方法。

第77條:對於例項控制,列舉型別優先於readResolve

public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}

如果這個類的宣告中加上了”implements Serializable”的字樣,它就不再是一個Singleton。無論該類使用了預設的序列化形式,還是自定義的序列化形式,都沒有關係;也跟它是否提供了顯式的readObject方法無關。任何一個readObject方法,不管是顯式的還是預設的,它都會返回一個新建的例項,這個新建的例項不同於該類初始化時建立的例項。

readResolve特性允許你用readObject建立的例項代替另一個例項[Serialization, 3.7]。對於一個正在被反序列化的物件,如果它的類定義了一個readResolve方法,並且具備正確的宣告,那麼在反序列化之後,新建物件上的readResolve方法就會被呼叫。然後,該方法返回的物件引用將被返回,取代新建的物件。在這個特性的絕大多數用法中,指向新建物件的引用不需要再被保留,因此立即成為垃圾回收的物件。

如果Elvis類要實現Serializable介面,下面的readResolve方法就足以保證它的Singleton屬性:


// readResolve for instance control - you can do better!
private Object readResolve() {
// Return the one true Elvis and let the garbage collector
// take care of the Elvis impersonator.
return INSTANCE;
}

該方法忽略了被反序列化的物件,只返回該類初始化時建立的那個特殊的Elvis例項。因此,Elvis例項的序列化形式並不需要包含任何實際的資料;所有的例項域都應該被宣告為transient的。事實上,如果依賴readResolve進行例項控制,帶有物件引用型別的所有例項域則都必須宣告為transient的。

如果反過來,你將一個可序列化的例項受控的類編寫成列舉,就可以絕對保證除了所宣告的常量之外,不會有別的例項。JVM對此提供了保障,這一點你可以確信無疑。

用readResolve進行例項控制並不過時。如果必須編寫可序列化的例項受控的類,它的例項在編譯時還不知道,你就無法將類表示成一個列舉型別。

readResolve的可訪問性(accessibility)很重要。如果把readResolve方法放在一個final類上,它就應該是私有的。如果把readResolver方法放在一個非final的類上,就必須認真考慮它的可訪問性。如果它是私有的,就不適用於任何子類。如果它是包級私有的,就只適用於同一個包中的子類。如果它是受保護的或者公有的,就適用於所有沒有覆蓋它的子類。如果readResolve方法是受保護的或者公有的,並且子類沒有覆蓋它,對序列化過的子類例項進行反序列化,就會產生一個超類例項,這樣有可能導致ClassCastException異常。

總而言之,你應該儘可能地使用列舉型別來實施例項控制的約束條件。如果做不到,同時又需要一個既可序列化又是例項受控的類,就必須提供一個readResolver方法,並確保該類的所有例項域都為基本型別,或者是瞬時的。

第78條:考慮用序列化代理代替序列化例項

序列化代理模式相當簡單。首先,為可序列化的類設計一個私有的靜態巢狀類,精確地表示外圍類的例項的邏輯狀態。這個巢狀類被稱作序列化代理(serialization proxy),它應該有一個單獨的構造器,其引數型別就是那個外圍類。這個構造器只從它的引數中複製資料:它不需要進行任何一致性檢查或者保護性拷貝。按設計,序列代理的預設序列化形式是外圍類最好的序列化形式。外圍類及其序列代理都必須宣告實現Serializable介面。

具體demo可以參考jdk中EnumSet程式碼,以下是該類程式碼的簡化版:

public abstract class EnumSet<E extends Enum<E>> extends AbstractSet<E>
    implements Cloneable, java.io.Serializable
{
    /**
     * The class of all the elements of this set.
     */
    final Class<E> elementType;

    /**
     * All of the values comprising T.  (Cached for performance.)
     */
    final Enum[] universe;

    private static Enum[] ZERO_LENGTH_ENUM_ARRAY = new Enum[0];

    EnumSet(Class<E>elementType, Enum[] universe) {
        this.elementType = elementType;
        this.universe    = universe;
    }

   //N多方法程式碼已省略

    /**
     * This class is used to serialize all EnumSet instances, regardless of
     * implementation type.  It captures their "logical contents" and they
     * are reconstructed using public static factories.  This is necessary
     * to ensure that the existence of a particular implementation type is
     * an implementation detail.
     *
     * @serial include
     */
    private static class SerializationProxy <E extends Enum<E>>
        implements java.io.Serializable
    {
        /**
         * The element type of this enum set.
         *
         * @serial
         */
        private final Class<E> elementType;

        /**
         * The elements contained in this enum set.
         *
         * @serial
         */
        private final Enum[] elements;

        SerializationProxy(EnumSet<E> set) {
            elementType = set.elementType;
            elements = set.toArray(ZERO_LENGTH_ENUM_ARRAY);
        }

        private Object readResolve() {
            EnumSet<E> result = EnumSet.noneOf(elementType);
            for (Enum e : elements)
                result.add((E)e);
            return result;
        }

        private static final long serialVersionUID = 362491234563181265L;
    }

    Object writeReplace() {
        return new SerializationProxy<>(this);
    }

    // readObject method for the serialization proxy pattern
    // See Effective Java, Second Ed., Item 78.
    private void readObject(java.io.ObjectInputStream stream)
        throws java.io.InvalidObjectException {
        throw new java.io.InvalidObjectException("Proxy required");
    }
}

序列化代理模式有兩個侷限性:

它不能與可以被客戶端擴充套件的類相容。它也不能與物件圖中包含迴圈的某些類相容:如果你企圖從一個物件的序列化代理的readResolve方法內部呼叫這個物件中的方法,就會得到一個ClassCastException異常,因為你還沒有這個物件,只有它的序列化代理。

最後,序列化代理模式所增強的功能和安全性並不是沒有代價的。在我的機器上,通過序列化代理來序列化和反序列化Period例項的開銷,比用保護性拷貝進行的開銷增加了14%。

總而言之,每當你發現自己必須在一個不能被客戶端擴充套件的類上編寫readObject或者writeObject方法的時候,就應該考慮使用序列化代理模式。要想穩健地將帶有重要約束條件的物件序列化時,這種模式可能是最容易的方法。

《Effective Java中文版 第2版》PDF版下載:
http://download.csdn.net/detail/xunzaosiyecao/9745699

作者:jiankunking 出處:http://blog.csdn.net/jiankunking

相關文章