Java序列化總結

昨天讀到了Hollis的一篇關於序列化的文章,以及文章裏所關聯的之前幾篇文章,讓我對序列化有了一個比較深入的瞭解,所以在這裏做個總結,加深理解。

原文地址:https://mp.weixin.qq.com/s/5xcDDtsVYdgzUebF3_Mg4g

以下是總結,基本都是自己的理解,大白話。如有不對的地方,請指正。

1、什麼是java序列化,爲什麼要序列化。

因爲java對象是存活在jvm裏的,一旦jvm停止,那麼java對象也就滅亡了。java序列化就是將java對象按照一個規則以二進制的方式到存儲硬盤中,保證對象的持久化。在網絡傳輸中,以及RPC調用中,需要通過二進制方式傳輸,所以也需要將對象進行序列化。在需要使用對象,或調用方接收到返回的二進制信息後,可以通過既定規則將二進制信息轉成java對象。由於在反序列化過程中,採用的是反射方式構造對象,所以會破壞單例模式,下面會談到。

另外,還搜到一些文章說,由於序列化存在比較大的安全風險,oracle正計劃摒棄序列化,這個就不細寫了。

2、如果實現序列化。

只要一個對象實現了java.io.Serializable或者java.io.Externalizable這兩個接口,就表示這個對象可以被序列化。其中Externalizable接口繼承自Serializable接口。

3、Serializable爲什麼是一個空接口。

Serializable是一個標記接口,標記一個對象爲可序列化的,然後在序列化過程中,會判斷一個對象是否可序列化,如果不是,則會拋出異常:NotSerializableException。

如果一個父類實現了Serializable接口,那麼它的所有子類都可以被序列化。

4、如何進行序列化。

建一個User類,實現Serializable,裏面包含兩個參數name和age,並生成get/set方法,這個就不寫了。下面是序列化與反序列化過程

User u = new User();
u.setAge(12);
u.setName("張三");
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("SerializableTest"));
objectOutputStream.writeObject(u);
IOUtils.closeQuietly(objectOutputStream);
File file = new File("SerializableTest");
ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
User user = (User) objectInputStream.readObject();
IOUtils.closeQuietly(objectInputStream);
System.out.println(user.getName());//打印張三
System.out.println(user.getAge());//打印12

可以看到,新的User對象被創建了,並且屬性值也可以還原。順便說一下,這也是面試經常被問到的不使用new創建對象的方式之一。

5、Serializable和Externalizable的區別。

同樣那個User類,這次改爲實現Externalizable接口。在更改後,會發現編輯器提示必須實現兩個方法:writeExternal和readExternal,先不用管。其他代碼不變,再跑一次,會發現這次打印的name是null,age是0。

所以可以得出第一個區別:

區別1:Serializable在反序列化後,默認將所有成員變量的值還原,而Externalizable會默認將所有的成員變量置爲初始值,String爲null,int爲0。如果想要讓Externalizable能將成員變量值進行序列化,就需要自己實現writeExternal和readExternal這兩個方法。

這樣的好處是,我們可以自定義在序列化過程中,允許暴露哪些變量值,也可以對被序列化的值做加密操作。如一個用戶的密碼,當我們重寫writeExternal方法時,可以將密碼進行加密再進行序列化,取出時再進行解密,保證信息安全。

然後來看第二個區別:我們給這個User類中新增一個構造方法:

public User(int age) {
     this.age=age;
}

然後分別調用一次,會發現Serializable可以正常運行,而Externalizable會報錯: java.io.InvalidClassException: no valid constructor。

區別2:Serializable在反序列化中,採用了反射方式直接創建對象,而Externalizable採用了調用無參構造方法創建對象。

而Java在沒有寫構造方法時,會默認生成無參構造方法,但是如果自定義了有參構造方法後,就不會再生成了。

6、Serializable怎麼實現某些字段不被序列化,transient的作用。

transient就是標識在序列化過程中,某一個變量不參與序列化。所以當某個變量被transient修飾,那麼在反序列化後,該變量會顯示初始值,就和Externalizable效果一樣了。所以transient在Externalizable無實際意義。

7、如何打破transient的限制,以及如何重寫Externalizable的writeExternal和readExternal方法。

先說如何打破transient的限制。需要在User類中,增加兩個方法:

private void readObject(java.io.ObjectInputStream s)
和
private void writeObject(java.io.ObjectOutputStream s) 

是的,很奇怪的兩個方法,沒有繼承,沒有實現,Object中也不存在。這是因爲在Serializable的序列化過程中,全部通過反射實現的,反射過程中,會檢查對象是否具有這兩個方法,如果有的話,會主動調用;如果沒有,就採用Java默認的方式進行處理。

並且writeObject和writeExternal寫法基本類似,只是writeObject中要增加一句話:s.defaultWriteObject();這是因爲Serializable是有默認的序列化方法的,如果你只想重定義某個變量的序列化,其他變量依然按照默認的方式進行,就需要靠這句話進行聲明。否則,只會序列化自定義的那個變量了。read同理。

說明:因爲Serializable和Externalizable自定義實現的都是以read和write開頭的,所以後面就統稱read和write了。

private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
    s.defaultReadObject();//Serializable接口需要,Externalizable接口不需要
    setAge(s.readInt());//或者直接age=s.readInt()
    setName(s.readUTF());
}

private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
    s.defaultWriteObject();//Serializable接口需要,Externalizable接口不需要
    s.writeInt(age);
    s.writeUTF(name);
}

我們看到在自定義序列化中,直接使用了readInt和writeInt,而沒有指定這個int到底是那個參數。這是因爲在序列化過程中,產生的二進制文件會按照定義順序依次寫入變量及其對應的值,所以在讀取時,必須需要按照寫入順序來進行讀取了。

舉例說明:如果只有一個String name和int age,因爲這兩個變量是兩個類型,所以以下兩種讀取順序,並不影響反序列化

//簡寫代碼
s.writeInt(age);//先序列化int,如:12
s.writeUTF(name);//後序列化String,如:張三
//第一種
setAge(s.readInt());//先讀取int,結果爲12
setName(s.readUTF());//後讀取String,結果爲張三
//第二種
setName(s.readUTF());//先讀取String,結果爲張三
setAge(s.readInt());//後讀取int,結果爲720902

反過來,先序列化String,後序列化int,然後先讀int,後讀String則會報錯。

以上出現錯誤int數據和報錯的原因是因爲,反序列化的過程,姑且用“翻譯”二字來描述,它是一個線性過程。以代碼中的錯誤例子說,因爲首先要讀取的是String,但是翻譯過程中首先遇到了int數據,會被拋棄,接着翻譯第二個,是String數據,然後將其賦值給變量name,接着需要讀int了,但是下一段要翻譯的,已經不是變量了,所以會讀取這一塊數據所對應的數字,來進行賦值。String報錯也同理,因爲那塊數據無法被轉爲UTF格式。

所以就要求,在自定義序列化與反序列化的過程中,讀寫操作的順序必須一致!

還有一點要注意:write和read方法必須成對實現,不能單獨實現其中一個方法。

如果只有write,沒有read,Externalizable類型的對象的變量值依然爲初始值。Serializable類型的對象,會出現反序列化的結果和實際不符。如下:

private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
	s.defaultWriteObject();
	s.writeUTF(name);
	s.writeInt(age+1);//或者是s.write(encrypt(passwd));這樣的加密
}

我們序列化的時候假設age=10,這時使age+1,但是沒有重寫read方法,這時會調用java默認反序列化方式讀取這個age,如果它不是transient的,那麼這個age依然會是10。而如果它是transient的,這個age會是0。想要讓它等於11,就必須在read方法中主動獲取。

而只有read,沒有write,程序會直接拋錯。具體原因後面說明。

8、爲什麼要打破transient的限制,或者說,爲什麼既定義一個變量爲transient,卻還要自定義實現它的序列化。

這一點,Hollis在文中舉了ArrayList的例子來說明,大家可以去看,我直接把結論拿過來就好了。

ArrayList實際上是動態數組,每次在放滿以後自動增長設定的長度值,如果數組自動增長長度設爲100,而實際只放了一個元素,那就會序列化99個null元素。爲了保證在序列化的時候不會將這麼多null同時進行序列化,ArrayList把元素數組設置爲transient,然後通過自定義序列化的方式,每次只把數組中具體的值進行序列化。

道理是懂了,但是研究到這裏,我反而產生一個疑問,加了transient的Serializable,在序列化的思想上和Externalizable豈不沒區別了?不知道這麼想到底對不對。

9、序列化的調用過程,爲什麼實現了Serializable接口就可以被序列化。

依然取自原文,序列化調用流程如下:

writeObject ---> writeObject0 --->writeOrdinaryObject--->writeSerialData--->invokeWriteObject

在writeObject0方法中,我們會看到如下一段代碼:

 if (obj instanceof String) {
    writeString((String) obj, unshared);
} else if (cl.isArray()) {
    writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
    writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
    writeOrdinaryObject(obj, desc, unshared);
} else {
    if (extendedDebugInfo) {
        throw new NotSerializableException(
            cl.getName() + "\n" + debugInfoStack.toString());
    } else {
        throw new NotSerializableException(cl.getName());
    }
}

也就是通過instanceof判斷一個對象是否實現了Serializable,沒有就會拋錯。

至於爲什麼會有4個if語句,是因爲其他三個,String,Array和Enum的底層代碼,其實也是實現了Serializable的,不過他們的序列化過程和對象不一樣,需要走不同的處理邏輯。

下面就不貼源碼了,只說明一下。

在writeOrdinaryObject中,會看到裏面有判斷這個類是否實現了Externalizable,然後有一個分支writeExternalData去處理。因爲大部分都是實現了Serializable,所以就會走writeSerialData方法。

在writeSerialData中,會看到一句判斷:slotDesc.hasWriteObjectMethod(),這就是在判斷這個對象是否有重寫的WriteObject方法,有的話,就通過反射的方式調用自定義的方法,沒有才會走默認的方法去處理。

同樣讀取也是這樣的流程。

10、serialVersionUID的作用。

serialVersionUID主要是標識一個序列化後的對象,當它進行反序列化時,判斷兩者的這個ID是否一致,一致則認爲是同一個對象,允許被序列化,不一致則不被序列化,同時拋出一個錯誤:java.io.InvalidClassException。

這也就是爲什麼所有的serialVersionUID都可以被編輯器生成爲1L,不是爲了區分每一個對象,而是標識一個對象序列化與反序列化前後是否一致。

當我們沒有給出一個serialVersionUID時,jvm會根據類名、接口名、成員方法及屬性等生成一個默認的。所以只要一個類的這些內容沒有變更過,那麼在反序列化時,不會報錯。

11、當變量是一個對象時,如何序列化。

對於這一點,沒有做深入研究。只說一個結論吧:

如果一個類A中的成員變量爲對象B時,如果要對A進行序列化,則B必須也是可以被序列化的,除非它被transient修飾。

12、序列化後的二進制文件分析

我把這一點放在最後說,是因爲我其實不太會分析這個,所以就推薦一篇文章,讓大家去看吧:https://www.toutiao.com/i6579019730017845763/

我只把個人對這篇文章的總結列一下就好,也能回答一下上面遺留的問題。

a、默認實現的序列化方式,產生的二進制文件中,每個變量的值會跟隨在變量後面。而自定義實現的序列化方式,是在將整個類都進行序列化完畢後,在後面補充每個變量的值。上面也說過,他是一個線性“翻譯”的過程,所以在翻譯完整個類後,讀取到變量值再進行復制。所以上面第7點最後說到的只有read,沒有write,程序會直接拋錯。當處理read方法時,就是在讀取這個類最後的數據,但是沒有write去寫。則讀到的要麼是空,要麼是亂碼,所以會報錯

b、如果一次性序列化的多個類中,同時有對另一個類的引用。比如User1和User2這兩個類中,都引用了Company這個類,那麼在序列化時,Company只會被序列化一次,然後生成一個序號,讓兩個User指向它。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章