細看Java序列化機制

超人汪小建發表於2018-01-31

概況

在程式中為了能直接以 Java 物件的形式進行儲存,然後再重新得到該 Java 物件,這就需要序列化能力。序列化其實可以看成是一種機制,按照一定的格式將 Java 物件的某狀態轉成介質可接受的形式,以方便儲存或傳輸。其實想想就大致清楚基本流程,序列化時將 Java 物件相關的類資訊、屬性及屬性值等等儲存起來,反序列化時再根據這些資訊構建出 Java 物件。而過程可能涉及到其他物件的引用,所以這裡引用的物件的相關資訊也要參與序列化。

Java 中進行序列化操作需要實現 Serializable 或 Externalizable 介面。

序列化的作用

  • 提供一種簡單又可擴充套件的物件儲存恢復機制。
  • 對於遠端呼叫,能方便對物件進行編碼和解碼,就像實現物件直接傳輸。
  • 可以將物件持久化到介質中,就像實現物件直接儲存。
  • 允許物件自定義外部儲存的格式。

序列化例子

FileOutputStream f = new FileOutputStream("tmp.o");
ObjectOutput s = new ObjectOutputStream(f);
s.writeObject("test");
s.writeObject(new ArrayList());
s.flush();
複製程式碼

常見的使用方式是直接將物件寫入流中,比如上述例子中,建立了 FileOutputStream 物件,其對應輸出到 tmp.o 檔案中,然後建立 ObjectOutputStream 物件巢狀前面的輸出流。當我們呼叫 writeObject 方法時即能進行序列化操作。

writeObject 方法這裡需要說明下,在對某個物件進行寫入時,它其實不僅僅序列化自己,還會去遍歷尋找相關引用的其他物件,由自己和其他引用物件組成的一個完整的物件圖關係都會被序列化。

對於陣列、enum、Class類物件、ObjectStreamClass 和 String 等都會做特殊處理,而其他物件序列化則需要實現 Serializable 或 Externalizable 介面。

反序列化例子

FileInputStream in = new FileInputStream("tmp.o");
ObjectInputStream s = new ObjectInputStream(in);
String test = (String)s.readObject();
List list = (ArrayList)s.readObject();
複製程式碼

針對序列化則存在反序列化操作,通過流直接讀取物件,先建立 FileInputStream 物件,其對應輸入檔案為 tmp.o,然後建立 ObjectInputStream 物件巢狀前面的輸入流,接著則可以呼叫 readObject 方法讀取物件。

其中呼叫 readObject 方法反序列操作的過程,除了會恢復物件自己之外還會遍歷整個完整的物件圖,建立整個物件圖包含的所有物件。

serialVersionUID 有什麼用

在序列化操作時,經常會看到實現了 Serializable 介面的類會存在一個 serialVersionUID 屬性,並且它是一個固定數值的靜態變數。比如如下,這個屬性有什麼作用?其實它主要用於驗證版本一致性,每個類都擁有這麼一個 ID,在序列化的時候會一起被寫入流中,那麼在反序列化的時候就被拿出來跟當前類的 serialVersionUID 值進行比較,兩者相同則說明版本一致,可以序列化成功,而如果不同則序列化失敗。

private static final long serialVersionUID = -6849794470754667710L;
複製程式碼

一般情況下我們可以自己定義 serialVersionUID 的值或者 IDE 幫我們自動生成,而如果我們不顯示定義 serialVersionUID 的話,這不代表不存在 serialVersionUID,而是由 JDK 幫我們生成,生成規則是會利用類名、類修飾符、介面名、欄位、靜態初始化資訊、建構函式資訊、方法名、方法修飾符、方法簽名等組成的資訊,經過 SHA 演算法生成摘要即是最終的 serialVersionUID 值。

父類序列化什麼情況

如果一個子類實現了 Serializable 介面而父類沒有實現該介面,則在序列化子類時,子類的屬性狀態會被寫入而父類的屬性狀態將不被寫入。所以如果想要父類屬性狀態也一起參與序列化,就要讓它也實現 Serializable 介面。

另外,如果父類未實現 Serializable 介面則反序列化生成的物件會再次呼叫父類的建構函式,以此完成對父類的初始化。所以父類屬性初始值一般都是型別的預設值。比如下面,Father 類的屬性不會參與序列化,反序列化時 Father 物件的屬性的值為預設值0。

public class Father {
	public int f;

	public Father() {
	}
}

public class Son extends Father implements Serializable {
	public int s;

	public Son() {
		super();
	}
}
複製程式碼

哪些欄位會序列化

在序列化時類的哪些欄位會參與到序列化中呢?其實有兩種方式決定哪些欄位會被序列化,

  1. 預設方式,Java物件中的非靜態和非transient的欄位都會被定義為需要序列的欄位。
  2. 另外一種方式是通過 ObjectStreamField 陣列來宣告類需要序列化的物件。

可以看到普通的欄位都是預設會被序列化的,而對於某些包含敏感資訊的欄位我們不希望它參與序列化,那麼最簡單的方式就是可以將該欄位宣告為 transient。

如何使用 ObjectStreamField?舉個例子,如下,A類中有 name 和 password 兩個欄位,通過 ObjectStreamField 陣列宣告只序列化 name 欄位。這種宣告的方式不用糾結為什麼這樣,這僅僅是約定了這樣而已。

public class A implements Serializable {
    String name;
    String password

    private static final ObjectStreamField[] serialPersistentFields
                 = {new ObjectStreamField("name", String.class)};
 }
複製程式碼

列舉型別的序列化

Enum 型別的序列化與普通的 Java 類的序列化有所不同,那麼在深入之前可以先看這篇文章深入瞭解下列舉,《 從JDK角度認識列舉enum》。

所以我們知道列舉被編譯後會變成一個繼承 java.lang.Enum 的類,而且列舉裡面的元素被宣告成 static final ,另外生成一個靜態程式碼塊 static{},最後還會生成 values 和 valueOf 兩個方法。Enum 類是一個抽象類,主要有 name 和 ordinal 兩個屬性,分別用於表示列舉元素的名稱和列舉元素的位置索引。

Enum 型別參與序列化時只會將列舉物件中的 name 屬性寫入,而其他的屬性則不參與進來。在反序列化時,則是先讀取 name 屬性,然後再通過 java.lang.Enum 類的 valueOf 方法找到對應的列舉型別。

除此之外,不能自定義 Enum 型別的序列化,所以 writeObject, readObject, readObjectNoData, writeReplace 以及 readResolve 等方法在序列化時會被忽略,類似的,serialPersistentFields 和 serialVersionUID 屬性都會被忽略。

最後,在序列化場景中,涉及到使用列舉的情況時要仔細設計好,不然很可能會因為後面升級修改了列舉類的結構而導致反序列化失敗。

Externalizable 介面作用

Externalizable 介面主要就是提供給使用者自己控制序列化內容,雖然前面我們也看到了 transient 和 ObjectStreamField 能定義序列化的欄位,但通過 Externalizable 介面則能更加靈活。可以看到它其實繼承了 Serializable 介面,提供了 writeExternal 和 readExternal 兩個方法,也就是在這兩個方法內控制序列化和反序列化的內容。

public interface Externalizable extends java.io.Serializable {
    
    void writeExternal(ObjectOutput out) throws IOException;

    void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
}
複製程式碼

比如下面的例子,我們可以在 writeExternal 方法中額外寫入 Date 物件,然後再寫入 value 值。對應的,反序列化時則是在 readExternal 方法中讀取 Date 物件和 value。這樣就完成了自定義序列化操作。

public class ExternalizableTest implements Externalizable {
	public String value = "test";

	public ExternalizableTest() {
	}

	public void writeExternal(ObjectOutput out) throws IOException {
		Date d = new Date();
		out.writeObject(d);
		out.writeObject(value);
	}

	public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
		Date d = (Date) in.readObject();
		System.out.println(d);
		System.out.println((String) in.readObject());
	}

}
複製程式碼

寫入時替換物件

正常情況下序列化某個物件時寫入的正是當前的物件,但如果說我們要替換當前的物件而寫入其他物件的話則可以通過 writeReplace 方法來實現。比如下面,person 類通過 writeReplace 方法最終可以寫入 Object 陣列物件。所以我們在反序列化時就不再是轉換成 Person 型別,而是要轉換為 Object 陣列物件。

class Person implements Serializable {
	private String name;
	private int age;

	public Person(String name, int age) {
		this.name = name;
		this.age = age;
	}

	private Object writeReplace() throws ObjectStreamException {
		Object[] properties = new Object[2];
		properties[0] = name;
		properties[1] = age;
		return properties;
	}
}
複製程式碼
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.o"));
Object[] properties = (Object[]) ois.readObject();
複製程式碼

讀取時替換物件

上面介紹了在寫入時可以替換物件,而在讀取時也同樣支援替換物件的,它是通過 readResolve 方法實現的。比如下面,在 readResolve 方法返回 2222,則反序列化讀取時不再是 Person 物件,而是 2222。

class Person implements Serializable {
	private String name;
	private int age;

	public Person(String name, int age) {
		this.name = name;
		this.age = age;
	}

	private Object readResolve() throws ObjectStreamException {
		return 2222;
	}
}
複製程式碼
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("test.o"));
Object o = ois.readObject();
複製程式碼

-------------推薦閱讀------------

從JDK角度認識列舉enum

我的2017文章彙總——機器學習篇

我的2017文章彙總——Java及中介軟體

我的2017文章彙總——深度學習篇

我的2017文章彙總——JDK原始碼篇

我的2017文章彙總——自然語言處理篇

我的2017文章彙總——Java併發篇

------------------廣告時間----------------

公眾號的選單已分為“分散式”、“機器學習”、“深度學習”、“NLP”、“Java深度”、“Java併發核心”、“JDK原始碼”、“Tomcat核心”等,可能有一款適合你的胃口。

鄙人的新書《Tomcat核心設計剖析》已經在京東銷售了,有需要的朋友可以購買。感謝各位朋友。

為什麼寫《Tomcat核心設計剖析》

歡迎關注:

這裡寫圖片描述

相關文章