《Effective Java》學習筆記十——序列化
本文會提到readObject、writeObject、readResolve方法,都和序列化相關,這裡集中解釋一下。此外,這一章還提到了readObjectNoData方法,也放到這裡一起說明。
readObject、writeObject方法:這兩個方法用於自定義序列化、反序列化的方式,如果一個類中有些成員的序列化形式希望自定義,需要重寫這兩個方法。注意這兩個方法都是private的。在重寫的這兩個方法的內部,需要首先呼叫ObejectInputStream/ObjectOutputStream的defaultReadObject/defaultWriteObject方法對還需要預設序列化/反序列化的成員操作。 readResolve方法:因為反序列化操作也可以看作一個構造器,在程式中單例模式只需要一個類的例項,如果通過反序列化又得到了一個例項就違反了初衷,這時可以通過編寫readResolve方法,返回當前ofollow,noindex">系統 中存在的這個單例的例項,而不是反序列化,來保持單例的正常工作。 readObjectNoData方法:在一些情況下(比如舊的型別反序列化),我們需要反序列化的類一開始就有一些約束條件,但是序列化出來的外部檔案中並沒有建立這個約束,這個時候可以編寫這個方法,將需要建立約束的成員在這裡賦值,避免這些成員初始化成預設值破壞類的狀態。注意,這個方法也是private的。
謹慎地實現Serializable介面
序列化的含義和作用
序列化用來將物件編碼成位元組流,反序列化就使將位元組流編碼重新構建物件。
序列化實現了物件傳輸和物件持久化,所以它能夠為遠端通訊提供物件表示法,為JavaBean元件提供持久化資料。
序列化的危害
-
降低靈活性:為實現Serializable而付出的最大代價是,一旦一個類被髮布,就大大降低了”改變這個類的實現”的靈活性。如果一個類實現了Serializable,它的位元組流編碼(或者說序列化形式,serialized form)就變成了它的匯出的API的一部分,必須永遠支援這種序列化形式。
而且,特殊地,每個可序列化類都有唯一的標誌(serial version id,在類體現為私有靜態final的long域serialVersionUID),如果沒有顯式指示,那麼系統就會自動生成一個serialVersionUID,如果下一個版本改變了這個類,那麼系統就會重新自動生成一個serialVersionUID。因此如果沒有宣告顯式的uid,會破壞版本之間的相容性,執行時產生InvalidClassException。
-
降低封裝性:如果你接受了預設的序列化形式,這個類中私有的和包級私有的例項域將都變成匯出的API的一部分,這不符合”最低限度地訪問域”的實踐準則。
-
降低安全性:增加了bug和漏洞的可能性,反序列化的過程其實類似於呼叫物件的構造器,但是這個過程又沒有用到構造器,因此如果位元組流被無意修改或被用心不測的人修改,那麼伺服器很可能會產生錯誤或者遭到攻擊。16年出現的大名鼎鼎的Java反序列化漏洞本質上就是不恰當的序列化造成的。
-
降低可測試性:隨著類版本的不斷更替,必須滿足版本相容問題,所以發行的版本越多,測試的難度就越大。
-
降低效能:序列化物件時,不僅會序列化當前物件本身,還會對該物件引用的其他物件也進行序列化。如果一個物件包含的成員變數是容器類等並深層引用時(物件是連結串列形式),此時序列化開銷會很大,這時必須要採用其他一些手段處理。
序列化的使用場景
- 需要實現一個類的物件傳輸或者持久化。
- A是B的元件,當B需要序列化時,A也實現序列化會更容易讓B使用。
序列化不適合場景
為了繼承而設計的類應該儘可能少地去實現Serializable介面,使用者介面也應該儘可能不繼承Serializable介面,原因是子類或實現類也要承擔序列化的風險。
序列化需要注意的地方
- 如果父類實現了Serializable,子類自動序列化了,不需要實現Serializable;
- 若父類未實現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();//再反序列化父類的域 }
-
序列化時,只對物件狀態進行了儲存,物件方法和類變數等並沒有儲存,因此序列化並不儲存靜態變數值。
-
當一個物件的例項變數引用其他物件,序列化該物件時也把引用物件序列化了。所以元件也應該序列化。
-
不是所有物件都可以序列化,基於安全和資源方面考慮,如Socket/thread若可序列化,進行傳輸或儲存,無法對他們重新分配資源。
考慮使用自定義的序列化方式
如果沒有先認真考慮預設的序列化形式是否合適,則不要貿然接受。
一個物件為根的物件圖,相對於它的物理表示法而言,該物件的預設序列化形式是一種比較有效的編碼形式。換句話說,預設的序列化形式描述了該物件內部所包含的資料,以及每一個可以從這個物件到達的其他物件的內部資料。它也描述了所有這些物件被連結起來後的拓撲結構。對於一個物件來說,理想的序列化形式應該只包含該物件所表示的邏輯資料,而邏輯資料與物理表示法應該是各自獨立的。
如果一個物件的物理表示法等同於它的邏輯內容,可能就適合於使用預設的序列化形式。
即使你確定了預設的序列化形式是合適的,通常還必須提供一個readObject方法以保證約束關係和安全性。
當一個物件的物理表示法與它的邏輯資料內容有實質性的區別時,使用預設序列化形式會有以下四個缺點:
- 它使這個類的匯出API永遠地束縛在該類的內部表示法上。
- 它會消耗過多的空間。
- 它會消耗過多的時間。
- 它會引起棧溢位。
無論你是否使用預設的序列化形式,如果在讀取整個物件狀態的任何其他方法上強制任何同步,則也必須在物件序列化上強制這種同步。
不管你選擇了哪種序列化形式,都要為自己編寫的每個可序列化的類生命一個顯式的序列版本UID(serial Version UID)。
保護性地編寫readObject方法
對於序列化形成的位元組流,並不都是安全的,裡面可能有偽造的有害資料,對它們不加分辨地反序列化,可能會導致程式收到損害。偽造的有害資料一方面可以使不正確的位元組流 ;另一方面還可能是在正確的位元組流中夾帶的“私貨”,通過“私貨”可以惡意修改反序列化的物件。
本條建議:
在readObject反序列化之後,檢查物件成員的有效性。 進行保護性拷貝(關聯第39條——必要時進行保護性拷貝),這裡的注意點和第39條一樣:保護性拷貝先於引數有效性檢測和避免使用clone方法(但是保護性拷貝會導致這個類需要保護性拷貝的成員不能為final)。 儘管Java1.4中為了阻止惡意攻擊並且節省保護性拷貝的開銷,在ObjectOutputStream/ObjectInputStream中引入了writeObjectUnshared/readObjectUnshared方法,並且比保護性拷貝更快,但是這些方法可能會受到複雜的攻擊,不建議使用。 readObject方法和構造器行為類似,所以對構造器的注意事項同樣適用於readObject方法:不要呼叫可被覆蓋的方法。
編寫readObject方法的建議:
對於物件引用域必須保持為私有的類,要保護性地拷貝這些域中的每一個物件。不可變類的可變元件就屬於這一類 對於任何約束條件,如果檢查失敗,則丟擲InvalidObjectException異常。這些檢查動作應該跟在所有的保護性拷貝之後 如果整個物件圖在被反序列化之後必須進行檢驗,就應該使用ObjectInputValidation介面(查了一下,這個介面有方法validateObject,就是用來檢驗一個有“圖”的物件是否符合約束的,驗證不成功就丟擲2中提到的異常) 無論是直接方式還是間接方式,都不要呼叫類中任何可能被覆蓋的方法。
對於例項控制,列舉型別優先於readResolve
readObject方法實現的困難:
前文中的背景知識提到了readResolve方法,這裡再做深化:readResolve的呼叫是在readObject之後,readResolve方法會返回一個物件,取代readObject反序列化的物件。也就是說存在一種可能,在readResolve呼叫之前,readObject呼叫之後,有人惡意地得到反序列化的新的物件,取得它的引用,進而破壞單例。因此需要單例的類的所有例項域都是transient的。 對於readObject,它的可訪問性值得考慮,私有意味著這個類失去了被子類化的能力;如果它是受保護或者公有的,而這個類的子類沒有覆蓋readObject方法,反序列化會產生一個這個類(超類)的例項,可能導致ClassCastException異常
本條建議:鑑於前面提到的諸多困難,建議使用列舉實現單例(例項控制),簡單而且不會有差錯。但是,如果一個單例的例項在編譯時還不能確定(未例項化),那麼是無法使用列舉型別的。
考慮用序列化代理代替序列化例項
序列化代理:
在需要序列化的類的內部建立一個私有的靜態內部類,這個靜態內部類同樣實現Serializable介面。靜態內部類通過建構函式傳入外圍類的引用,保留外圍類的邏輯狀態(比如保留所有資料、約束條件),並且有readObject方法(實現不同,稍後講到) 同樣實現了Serializable介面的外部類需要編寫方法writeReplace,返回一個new出來的靜態內部類(傳入了自己的引用)。wrieReplace會在序列化的時候對寫入的物件進行替換,替換為這個靜態內部類。 當反序列化的時候,不是呼叫外圍類,而是呼叫靜態內部類的反序列化的方法readResolve,返回一個使用當初保留的外部類的全部資訊構造的外部類。
使用序列化代理的好處:
可以像保護性拷貝方法一樣阻止偽造字元流的攻擊以及內部域的盜用 可以不必像保護性拷貝那樣不能把需要把需要拷貝的值設為final 允許反序列化例項與原始序列化例項得到不同的類 無需花費很多形式
使用序列化代理的侷限性:
不能與可被客戶擴充套件的類相容:(我理解是靜態內部類沒有寫入文件,而且不能擴充套件,如果客戶程式碼新加入了域,這個靜態內部類不能儲存新加入域的任何資訊,進而影響序列化/反序列化的能力) 不能與物件圖中包含迴圈的某些類相容:因為不能從物件的序列化代理的readResolve方法中呼叫這個物件的方法,因為這個物件還不存在,可能增加效能開銷。