什麼是序列化、反序列化
- 序列化:把Java物件轉換為位元組序列的過程。
- 反序列化:把位元組序列恢復為Java物件的過程。
序列化的作用
- 1、可以把物件的位元組序列永久地儲存到硬碟上,通常存放在一個檔案中;(持久化物件)
- 2、也可以在網路上傳輸物件的位元組序列;(網路傳輸物件)
序列化在Java中的用法
在Java中序列化的實現:將需要被序列化的類實現Serializable介面,該介面沒有需要實現的方法,實現該介面只是為了標註該物件是可被序列化的,然後使用一個輸出流(如:FileOutputStream)來構造一個ObjectOutputStream(物件輸出流)物件,接著,使用ObjectOutputStream物件的writeObject(Object obj)方法就可以將引數為obj的物件寫出(即儲存其狀態),要恢復的話則用ObjectInputStream(物件輸入流)。
如下為序列化、反序列化簡單案例Test01
:
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; public class Test01 { public static void main(String[] args) { //序列化操作 serializable(); //反序列化操作 deserialization(); } private static void serializable() { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"))) { Person person = new Person(); person.setName("張三"); person.setAge(20); oos.writeObject(person); } catch (IOException e) { e.printStackTrace(); } } private static void deserialization() { try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) { Person person = (Person) ois.readObject(); System.out.println(person); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } } //目標類實現Serializable介面 class Person implements Serializable { private static final long serialVersionUID = -2052381772192998351L; private String name; private int age; public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override public String toString() { return "Person{" + "name='" + name + '\'' + ", age=" + age + '}'; } }
上面案例中只是簡單的進行了物件序列化和反序列化,但是序列化和反序列化過程中有很多值得思考的細節問題,例如:
1、序列化版本號(serialVersionUID)問題
2、靜態變數序列化
3、父類的序列化與transient
關鍵字
4、自定義序列化規則
5、序列化儲存規則
1、序列化版本號(serialVersionUID)問題
在寫Java程式中有時我們經常會看到類中會有一個序列化版本號:serialVersionUID。這個值有的類是1L或者是自動生成的。
private static final long serialVersionUID = 1L;
或者
private static final long serialVersionUID = -2052381772192998351L;
當在反序列化時JVM需要判斷需要轉化的兩個類是不是同一個類,於是就需要一個序列化版本號。如果在反序列化的時候兩個類的serialVersionUID不一樣則JVM會丟擲java.io.InvalidClassException的異常;如果serialVersionUID一致則表明可以轉換。
如果可序列化類未顯式宣告 serialVersionUID,則序列化執行時將基於該類的各個方面計算該類的預設 serialVersionUID 值。不過,強烈建議 所有可序列化類都顯式宣告 serialVersionUID 值,原因是計算預設的 serialVersionUID 對類的詳細資訊具有較高的敏感性,根據編譯器實現的不同可能千差萬別,這樣在反序列化過程中可能會導致意外的 InvalidClassException,所以這種方式不支援反序列化重構。所謂重構就是可以對類增加或者減少屬性欄位,也就是說即使兩個類並不完全一致,他們也是可以轉換的,只不過如果找不到對應的欄位,它的值會被設為預設值。
因此,為保證 serialVersionUID 值跨不同 java 編譯器實現的一致性或程式碼重構時,序列化類必須宣告一個明確的 serialVersionUID 值。還強烈建議使用 private 修飾符顯示宣告 serialVersionUID(如果可能),原因是這種宣告僅應用於直接宣告類 — serialVersionUID 欄位作為繼承成員沒有用處。陣列類不能宣告一個明確的 serialVersionUID,因此它們總是具有預設的計算值,但是陣列類沒有匹配 serialVersionUID 值的要求。
還有一個常見的值是1L(或者其他固定值),如果所有類都這麼寫那還怎麼區分它們,這個欄位還有什麼意義嗎?有的!首先如果兩個類有了相同的反序列化版本號,比如1L,那麼表明這兩個類是支援在反序列化時重構的。但是會有一個明顯的問題:如果兩個類是完全不同的,但是他們的序列化版本號都是1L,那麼對於JVM來說他們也是可以進行反序列化重構的!這這顯然是不對的,但是回過頭來說這種明顯的,愚蠢的錯誤在實際開發中是不太可能會犯的,如果不是那麼嚴謹的話用1L是個不錯的選擇。
一般的情況下這個值是顯式地指定為一個64位的雜湊欄位,比如你寫了一個類實現了java.io.Serializable介面,在idea裡會提示你加上這個序列化id。這樣做可以區分不同的類,也支援反序列化重構。
總結如下:
serialVersionUID | 區分不同類 | 支援相同類的重構 |
---|---|---|
不指定 | YES | NO |
1L | NO | YES |
64位雜湊值 | YES | YES |
簡單而言,從嚴謹性的角度來說,指定64位雜湊值>預設值1L>不指定serialVersionUID值,具體怎麼使用就看你的需求啦。
2、靜態變數序列化
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; public class Test02 { public static void main(String[] args) { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt")); ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) { //初始時avgAge為77 Person person = new Person(); person.setName("張三"); person.setAge(20); oos.writeObject(person); //序列化後修改avgAge為80 Person.avgAge = 80; Person person1 = (Person) ois.readObject(); //再讀取,通過person1.avgAge輸出新的值,通過例項物件訪問靜態變數本來就很反常 System.out.println(person1.avgAge); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } } //目標物件實現Serializable介面 class Person implements Serializable { private static final long serialVersionUID = -2052381772192998351L; private String name; private int age; public static int avgAge = 77; public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override public String toString() { return "Person{" + "name='" + name + '\'' + ", age=" + age + '}'; } }
執行結果顯示如下:
我們看到Test02.java
將物件序列化後,修改靜態變數的數值再將序列化物件讀取出來,然後通過讀取出來的物件獲得靜態變數的數值並列印出來,最後的輸出是 10,之所以列印 10 的原因在於序列化時,並不儲存靜態變數,這其實比較容易理解,序列化儲存的是物件的狀態,靜態變數屬於類的狀態,因此 序列化並不儲存靜態變數 。
3、父類的序列化與transient
關鍵字
情境 :一個子類實現了 Serializable 介面,它的父類都沒有實現 Serializable 介面,序列化該子類物件,然後反序列化後輸出父類定義的某變數的數值,該變數數值與序列化時的數值不同。
解決 : 要想將父類物件也序列化,就需要讓父類也實現 Serializable 介面 。如果父類不實現的話的,就需要有預設的無參的建構函式 。在父類沒有實現 Serializable 介面時,虛擬機器是不會序列化父物件的,而一個 Java 物件的構造必須先有父物件,才有子物件,反序列化也不例外。所以反序列化時,為了構造父物件,只能呼叫父類的無參建構函式作為預設的父物件。因此當我們取父物件的變數值時,它的值是呼叫父類無參建構函式後的值。如果你考慮到這種序列化的情況,在父類無參建構函式中對變數進行初始化,否則的話,父類變數值都是預設宣告的值,如 int 型的預設是 0,string 型的預設是 null。
transient 關鍵字的作用是控制變數的序列化,在變數宣告前加上該關鍵字,可以阻止該變數被序列化到檔案中,在被反序列化後,transient 變數的值被設為初始值,如 int 型的是 0,物件型的是 null。
3-1、特性使用案例:
我們熟悉使用 transient 關鍵字可以使得欄位不被序列化,那麼還有別的方法嗎?根據父類物件序列化的規則,我們可以將不需要被序列化的欄位抽取出來放到父類中,子類實現 Serializable 介面,父類不實現,根據父類序列化規則,父類的欄位資料將不被序列化,形成類圖如下圖所示。
上圖中可以看出,attr1、attr2、attr3、attr5 都不會被序列化,放在父類中的好處在於當有另外一個 Child 類時,attr1、attr2、attr3 依然不會被序列化,不用重複書寫 transient 關鍵字,程式碼簡潔。
4、自定義序列化規則
在序列化和反序列化過程中需要特殊處理的類必須使用下列準確簽名來實現特殊方法:
private void writeObject(java.io.ObjectOutputStream oos) throws IOException; private void readObject(java.io.ObjectInputStream oin) throws IOException, ClassNotFoundException; private void readObjectNoData() throws ObjectStreamException;
writeObject 方法負責寫入特定類的物件的狀態,以便相應的 readObject 方法可以恢復它。通過呼叫 oos.defaultWriteObject 可以呼叫儲存 Object 的欄位的預設機制。該方法本身不需要涉及屬於其超類或子類的狀態。通過使用 writeObject 方法或使用 DataOutput 支援的用於基本資料型別的方法將各個欄位寫入 ObjectOutputStream,狀態可以被儲存。
readObject 方法負責從流中讀取並恢復類欄位。它可以呼叫 oin.defaultReadObject 來呼叫預設機制,以恢復物件的非靜態和非瞬態(非 transient 修飾)欄位。defaultReadObject方法使用流來分配儲存在流中的物件的欄位當前物件中相應命名的欄位。這用於處理類演化後需要新增新欄位的情形。該方法本身不需要涉及屬於其超類或子類的狀態。通過使用 writeObject 方法或使用 DataOutput 支援的用於基本資料型別的方法將各個欄位寫入 ObjectOutputStream,狀態可以被儲存。
在序列化流不列出給定類作為將被反序列化物件的超類的情況下,readObjectNoData 方法負責初始化特定類的物件狀態。這在接收方使用的反序列化例項類的版本不同於傳送方,並且接收者版本擴充套件的類不是傳送者版本擴充套件的類時發生。在序列化流已經被篡改時也將發生;因此,不管源流是“敵意的”還是不完整的,readObjectNoData 方法都可以用來正確地初始化反序列化的物件。
readObjectNoData()應用示例:
import java.io.FileOutputStream; import java.io.ObjectOutputStream; import java.io.Serializable; //先對舊的類物件進行序列化 public class Test03Old { public static void main(String[] args) { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"))) { Person person = new Person(); person.setAge(20); oos.writeObject(person); } catch (Exception e) { e.printStackTrace(); } } } class Person implements Serializable { private int age; public void setAge(int age) { this.age = age; } public int getAge() { return this.age; } }
import java.io.FileInputStream; import java.io.ObjectInputStream; import java.io.Serializable; //用新的類規範來反序列化 public class Test03New { public static void main(String[] args) { try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) { Person person = (Person) ois.readObject(); System.out.println(person.getName()); } catch (Exception e) { e.printStackTrace(); } } } //新的類繼承了Animal,這是已經序列化的舊物件裡面所沒有的內容, //所以實現readObjectNoData,可以彌補這種因臨時擴充套件而無法相容反序列化的缺陷 class Person extends Animal implements Serializable { private int age; public void setAge(int age) { this.age = age; } public int getAge() { return this.age; } } class Animal implements Serializable { private String name; public void setName(String name) { this.name = name; } public String getName() { return this.name; } private void readObjectNoData() { this.name = "張三"; } }
將物件寫入流時需要指定要使用的替代物件的可序列化類,應使用準確的簽名來實現此特殊方法:
ANY-ACCESS-MODIFIER Object writeReplace() throws ObjectStreamException;
此 writeReplace 方法將由序列化呼叫,前提是如果此方法存在,而且它可以通過被序列化物件的類中定義的一個方法訪問。因此,該方法可以擁有私有 (private)、受保護的 (protected) 和包私有 (package-private) 訪問。子類對此方法的訪問遵循 java 訪問規則。
在從流中讀取類的一個例項時需要指定替代的類應使用的準確簽名來實現此特殊方法。
ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;
此 readResolve 方法遵循與 writeReplace 相同的呼叫規則和訪問規則。
TIP: readResolve常用來反序列單例類,保證單例類的唯一性
例如:
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; public class Test04Old { public static void main(String[] args) throws IOException, ClassNotFoundException { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"))) { oos.writeObject(Brand.NIKE); } try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) { Brand b = (Brand) ois.readObject(); // 答案顯然是false System.out.println(b == Brand.NIKE); } } } class Brand implements Serializable { private int val; private Brand(int val) { this.val = val; } // 兩個列舉值 public static final Brand NIKE = new Brand(0); public static final Brand ADDIDAS = new Brand(1); }
答案很顯然是false,因為Brand.NIKE是程式中建立的物件,而b是從磁碟中讀取並恢復過來的物件,兩者明顯來源不同,因此必然記憶體空間是不同的,引用(地址)顯然也是不同的;
但這不是我們想看到的,因為我們把Brand設計成列舉型別,不管是程式中建立的還是從哪裡讀取的,其必須應該和列舉常量完全相等,這才是列舉的意義啊!
而此時readResolve就派上用場了,我們可以這樣實現readResolve:
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.ObjectStreamException; import java.io.Serializable; public class Test04New { public static void main(String[] args) throws IOException, ClassNotFoundException { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"))) { oos.writeObject(Brand.NIKE); } try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) { Brand b = (Brand) ois.readObject(); // 答案顯然是true System.out.println(b == Brand.NIKE); } } } class Brand implements Serializable { private int val; private Brand(int val) { this.val = val; } // 兩個列舉值 public static final Brand NIKE = new Brand(0); public static final Brand ADDIDAS = new Brand(1); private Object readResolve() throws ObjectStreamException { if (val == 0) { return NIKE; } if (val == 1) { return ADDIDAS; } return null; } }
改造以後,不管來源如何,最終得到的都將是程式中Brand的列舉值了!因為readResolve的程式碼在執行時已經進入了程式記憶體環境,因此其返回的NIKE和ADDIDAS都將是Brand的靜態成員物件;
因此保護性恢復的含義就在此:首先恢復的時候沒有改變其值(val的值沒有改變)同時恢復的時候又能正常實現列舉值的對比(地址也完全相同);
4-1、對敏感欄位加密
情境:伺服器端給客戶端傳送序列化物件資料,物件中有一些資料是敏感的,比如密碼字串等,希望對該密碼欄位在序列化時,進行加密,而客戶端如果擁有解密的金鑰,只有在客戶端進行反序列化時,才可以對密碼進行讀取,這樣可以一定程度保證序列化物件的資料安全。
解決:在序列化過程中,虛擬機器會試圖呼叫物件類裡的 writeObject 和 readObject 方法,進行使用者自定義的序列化和反序列化,該方法必須要被宣告為private,如果沒有這樣的方法,則預設呼叫是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。使用者自定義的 writeObject 和 readObject 方法可以允許使用者控制序列化的過程,比如可以在序列化的過程中動態改變序列化的數值。基於這個原理,可以在實際應用中得到使用,用於敏感欄位的加密工作,如下程式碼展示了這個過程。
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; public class Test05 { public static void main(String[] args) { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt")); ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) { oos.writeObject(new Account()); Account account = (Account) ois.readObject(); System.out.println("解密後的字串:" + account.getPassword()); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } } class Account implements Serializable { private String password = "123456"; public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } private void writeObject(ObjectOutputStream out) { try { ObjectOutputStream.PutField putFields = out.putFields(); System.out.println("原密碼:" + password); //模擬加密 password = "encryption"; putFields.put("password", password); System.out.println("加密後的密碼" + password); out.writeFields(); } catch (IOException e) { e.printStackTrace(); } } private void readObject(ObjectInputStream in) { try { ObjectInputStream.GetField readFields = in.readFields(); Object object = readFields.get("password", ""); System.out.println("要解密的字串:" + object.toString()); //模擬解密,需要獲得本地的金鑰 password = "123456"; } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } } }
上述程式碼中的 writeObject 方法中,對密碼進行了加密,在 readObject 中則對 password 進行解密,只有擁有金鑰的客戶端,才可以正確的解析出密碼,確保了資料的安全。執行上述程式碼後控制檯輸出如下圖所示。
4-2、序列化SDK中不可序列化的型別
4-1、對敏感欄位加密
案例使用 writeObject 和 readObject 進行了物件屬性值加解密操作,有時我們想將物件中的某一欄位序列化,但它在SDK中的定義卻是不可序列化的型別,這樣的話我們也必須把他標註為 transient 才能保證正常序列化,可是不能序列化又怎麼恢復呢?這就用到了上面提到的 writeObject 和 readObject 方法,進行自定義序列化操作了。
示例:java.awt.geom包中的Point2D.Double類就是不可序列化的,因為該類沒有實現Serializable介面
import java.awt.geom.Point2D; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; public class Test06 { public static void main(String[] args) { LabeledPoint label = new LabeledPoint("Book", 5.00, 5.00); try { // 寫入前 System.out.println(label); ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("object.txt")); //通過物件輸出流,將label寫入流中 out.writeObject(label); out.close(); // 寫入後 System.out.println(label); ObjectInputStream in = new ObjectInputStream(new FileInputStream("object.txt")); LabeledPoint label1 = (LabeledPoint) in.readObject(); in.close(); // 讀出並加1.0後 System.out.println(label1); } catch (Exception e) { e.printStackTrace(); } } } class LabeledPoint implements Serializable { private String label; //因為不可被序列化,所以需要加transient關鍵字 transient private Point2D.Double point; public LabeledPoint(String str, double x, double y) { label = str; //此類Point2D.Double不可被序列化 point = new Point2D.Double(x, y); } //因為Point2D.Double不可被序列化,所以需要實現下面兩個方法 private void writeObject(ObjectOutputStream oos) throws IOException { oos.defaultWriteObject(); oos.writeDouble(point.getX()); oos.writeDouble(point.getY()); } private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { ois.defaultReadObject(); double x = ois.readDouble() + 1.0; double y = ois.readDouble() + 1.0; point = new Point2D.Double(x, y); } @Override public String toString() { return "LabeledPoint{" + "label='" + label + '\'' + ", point=" + point + '}'; } }
執行結果如圖所示:
在4-1、序列化SDK中不可序列化的型別
案例中,你會發現呼叫了defaultWriteObject()和defaultReadObject()。它們做的是預設的序列化程式,就像寫/讀所有的non-transient和 non-static欄位(但他們不會去做serialVersionUID的檢查)。通常說來,所有我們想要自己處理的欄位都應該宣告為transient。這樣的話 defaultWriteObject/defaultReadObject 便可以專注於其餘欄位,而我們則可為這些特定的欄位(指transient)定製序列化。使用那兩個預設的方法並不是強制的,而是給予了處理複雜應用時更多的靈活性。
5、序列化儲存規則
5-1、儲存兩次相同物件
import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; public class Test07 { public static void main(String[] args) { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt")); ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) { //試圖將物件兩次寫入檔案 Account account = new Account(); account.setPassword("123456"); oos.writeObject(account); oos.flush(); System.out.println(new File("object.txt").length()); oos.writeObject(account); System.out.println(new File("object.txt").length()); //從檔案依次讀出兩個物件 Account account1 = (Account) ois.readObject(); Account account2 = (Account) ois.readObject(); //判斷兩個引用是否指向同一個物件 System.out.println(account1 == account2); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } } class Account implements Serializable { private String password; public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } }
上述程式碼中對同一物件兩次寫入檔案,列印出寫入一次物件後的儲存大小和寫入兩次後的儲存大小,然後從檔案中反序列化出兩個物件,比較這兩個物件是否為同一物件。一般的思維是,兩次寫入物件,檔案大小會變為兩倍的大小,反序列化時,由於從檔案讀取,生成了兩個物件,判斷相等時應該是輸入 false 才對,但是最後結果輸出如圖下圖所示。
我們看到,第二次寫入物件時檔案只增加了 5 位元組,並且兩個物件是相等的,因為Java 序列化機制為了節省磁碟空間,具有特定的儲存規則,當寫入檔案的為同一物件時,並不會再將物件的內容進行儲存,而只是再次儲存一份引用,上面增加的 5 位元組的儲存空間就是新增引用和一些控制資訊的空間。反序列化時,恢復引用關係,使得上述程式碼中的 account1 和 account2 指向唯一的物件,二者相等,輸出 true。該儲存規則極大的節省了儲存空間
5-2、儲存兩次相同物件,更改屬性值
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; public class Test08 { public static void main(String[] args) { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt")); ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"))) { Account account = new Account(); account.setPassword("123456"); oos.writeObject(account); oos.flush(); account.setPassword("456789"); oos.writeObject(account); //從檔案依次讀出兩個物件 Account account1 = (Account) ois.readObject(); Account account2 = (Account) ois.readObject(); System.out.println(account1.getPassword()); System.out.println(account2.getPassword()); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } } class Account implements Serializable { private String password; public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } }
執行結果如下圖:
上述程式碼的目的是希望將 account 物件兩次儲存到 object.txt 檔案中,寫入一次以後修改物件屬性值再次儲存第二次,然後從 object.txt 中再依次讀出兩個物件,輸出這兩個物件的 password 屬性值。上述程式碼的目的原本是希望一次性傳輸物件修改前後的狀態。
結果兩個輸出的都是 123456, 原因就是第一次寫入物件以後,第二次再試圖寫的時候,虛擬機器根據引用關係知道已經有一個相同物件已經寫入檔案,因此只儲存第二次寫的引用,所以讀取時,都是第一次儲存的物件。這也驗證了5-1、儲存兩次相同物件
案例的現象,相同物件存在只會儲存引用,不再進行物件儲存,所以第二次修改的屬性未變化。讀者在使用一個檔案多次 writeObject 需要特別注意這個問題。