天天看點

Java中的序列化Serialable進階詳解

引言

将 Java 對象序列化為二進制檔案的 Java 序列化技術是 Java 系列技術中一個較為重要的技術點,在大部分情況下,開發人員隻需要了解被序列化的類需要實作 Serializable 接口,使用 ObjectInputStream 和 ObjectOutputStream 進行對象的讀寫。然而在有些情況下,光知道這些還遠遠不夠,文章列舉了筆者遇到的一些真實情境,它們與 Java 序列化相關,通過分析情境出現的原因,使讀者輕松牢記 Java 序列化中的一些進階認識。

文章結構

本文将逐一的介紹幾個情境,順序如下面的清單。

  • 序列化 ID 的問題
  • 靜态變量序列化
  • 父類的序列化與 Transient 關鍵字
  • 對敏感字段加密
  • 序列化存儲規則

清單的每一部分講述了一個單獨的情境,讀者可以分别檢視。

序列化 ID 問題

情境:兩個用戶端 A 和 B 試圖通過網絡傳遞對象資料,A 端将對象 C 序列化為二進制資料再傳給 B,B 反序列化得到 C。

問題:C 對象的全類路徑假設為 com.inout.Test,在 A 和 B 端都有這麼一個類檔案,功能代碼完全一緻。也都實作了 Serializable 接口,但是反序列化時總是提示不成功。

解決:虛拟機是否允許反序列化,不僅取決于類路徑和功能代碼是否一緻,一個非常重要的一點是兩個類的序列化 ID 是否一緻(就是 private static final long serialVersionUID = 1L)。清單 1 中,雖然兩個類的功能代碼完全一緻,但是序列化 ID 不同,他們無法互相序列化和反序列化。

簡單來說,Java的序列化機制是通過在運作時判斷類的serialVersionUID來驗證版本一緻性的。在進行反序列化時,JVM會把傳來的位元組流中的serialVersionUID與本地相應實體(類)的serialVersionUID進行比較,如果相同就認為是一緻的,可以進行反序列化,否則就會出現序列化版本不一緻的異常。

當實作java.io.Serializable接口的實體(類)沒有顯式地定義一個名為serialVersionUID,類型為long的變量時,Java序列化機制會根據編譯的class自動生成一個serialVersionUID作序列化版本比較用,這種情況下,隻有同一次編譯生成的class才會生成相同的serialVersionUID 。

如果我們不希望通過編譯來強制劃分軟體版本,即實作序列化接口的實體能夠相容先前版本,未作更改的類,就需要顯式地定義一個名為serialVersionUID,類型為long的變量,不修改這個變量值的序列化實體都可以互相進行串行化和反串行化。

清單 1. 相同功能代碼不同序列化 ID 的類對比
package com.inout; 

 import java.io.Serializable; 

 public class A implements Serializable { 

	 private static final long serialVersionUID = 1L; 

	 private String name; 
	
	 public String getName() 
	 { 
		 return name; 
	 } 
	
	 public void setName(String name) 
	 { 
		 this.name = name; 
	 } 
 } 

 package com.inout; 

 import java.io.Serializable; 

 public class A implements Serializable { 

	 private static final long serialVersionUID = 2L; 
	
	 private String name; 
	
	 public String getName() 
	 { 
		 return name; 
	 } 
	
	 public void setName(String name) 
	 { 
		 this.name = name; 
	 } 
 }      

序列化 ID 在 Eclipse 下提供了兩種生成政策,一個是固定的 1L,一個是随機生成一個不重複的 long 類型資料(實際上是使用 JDK 工具生成),在這裡有一個建議,如果沒有特殊需求,就是用預設的 1L 就可以,這樣可以確定代碼一緻時反序列化成功。那麼随機生成的序列化 ID 有什麼作用呢,有些時候,通過改變序列化 ID 可以用來限制某些使用者的使用。

特性使用案例

讀者應該聽過 Façade 模式,它是為應用程式提供統一的通路接口,案例程式中的 Client 用戶端使用了該模式,案例程式結構圖如圖 1 所示。

圖 1. 案例程式結構
Java中的序列化Serialable進階詳解

Client 端通過 Façade Object 才可以與業務邏輯對象進行互動。而用戶端的 Façade Object 不能直接由 Client 生成,而是需要 Server 端生成,然後序列化後通過網絡将二進制對象資料傳給 Client,Client 負責反序列化得到 Façade 對象。該模式可以使得 Client 端程式的使用需要伺服器端的許可,同時 Client 端和伺服器端的 Façade Object 類需要保持一緻。當伺服器端想要進行版本更新時,隻要将伺服器端的 Façade Object 類的序列化 ID 再次生成,當 Client 端反序列化 Façade Object 就會失敗,也就是強制 Client 端從伺服器端擷取最新程式。

靜态變量序列化

情境:檢視清單 2 的代碼。

清單 2. 靜态變量序列化問題代碼
public class Test implements Serializable {

	private static final long serialVersionUID = 1L;

	public static int staticVar = 5;

	public static void main(String[] args) {
		try {
			//初始時staticVar為5
			ObjectOutputStream out = new ObjectOutputStream(
					new FileOutputStream("result.obj"));
			out.writeObject(new Test());
			out.close();

			//序列化後修改為10
			Test.staticVar = 10;

			ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
					"result.obj"));
			Test t = (Test) oin.readObject();
			oin.close();
			
			//再讀取,通過t.staticVar列印新的值
			System.out.println(t.staticVar);
			
		} catch (FileNotFoundException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} catch (ClassNotFoundException e) {
			e.printStackTrace();
		}
	}
}      

清單 2 中的 main 方法,将對象序列化後,修改靜态變量的數值,再将序列化對象讀取出來,然後通過讀取出來的對象獲得靜态變量的數值并列印出來。依照清單 2,這個 System.out.println(t.staticVar) 語句輸出的是 10 還是 5 呢?

最後的輸出是 10,對于無法了解的讀者認為,列印的 staticVar 是從讀取的對象裡獲得的,應該是儲存時的狀态才對。之是以列印 10 的原因在于序列化時,并不儲存靜态變量,這其實比較容易了解,序列化儲存的是對象的狀态,靜态變量屬于類的狀态,是以 序列化并不儲存靜态變量。

父類的序列化與 Transient 關鍵字

情境:一個子類實作了 Serializable 接口,它的父類都沒有實作 Serializable 接口,序列化該子類對象,然後反序列化後輸出父類定義的某變量的數值,該變量數值與序列化時的數值不同。

解決:要想将父類對象也序列化,就需要讓父類也實作Serializable 接口。如果父類不實作的話的,就 需要有預設的無參的構造函數。在父類沒有實作 Serializable 接口時,虛拟機是不會序列化父對象的,而一個 Java 對象的構造必須先有父對象,才有子對象,反序列化也不例外。是以反序列化時,為了構造父對象,隻能調用父類的無參構造函數作為預設的父對象。是以當我們取父對象的變量值時,它的值是調用父類無參構造函數後的值。如果你考慮到這種序列化的情況,在父類無參構造函數中對變量進行初始化,否則的話,父類變量值都是預設聲明的值,如 int 型的預設是 0,string 型的預設是 null。

Transient 關鍵字的作用是控制變量的序列化,在變量聲明前加上該關鍵字,可以阻止該變量被序列化到檔案中,在被反序列化後,transient 變量的值被設為初始值,如 int 型的是 0,對象型的是 null。

特性使用案例

我們熟悉使用 Transient 關鍵字可以使得字段不被序列化,那麼還有别的方法嗎?根據父類對象序列化的規則,我們可以将不需要被序列化的字段抽取出來放到父類中,子類實作 Serializable 接口,父類不實作,根據父類序列化規則,父類的字段資料将不被序列化,形成類圖如圖 2 所示。

圖 2. 案例程式類圖
Java中的序列化Serialable進階詳解

上圖中可以看出,attr1、attr2、attr3、attr5 都不會被序列化,放在父類中的好處在于當有另外一個 Child 類時,attr1、attr2、attr3 依然不會被序列化,不用重複抒寫 transient,代碼簡潔。

對敏感字段加密

情境:伺服器端給用戶端發送序列化對象資料,對象中有一些資料是敏感的,比如密碼字元串等,希望對該密碼字段在序列化時,進行加密,而用戶端如果擁有解密的密鑰,隻有在用戶端進行反序列化時,才可以對密碼進行讀取,這樣可以一定程度保證序列化對象的資料安全。

解決:在序列化過程中,虛拟機會試圖調用對象類裡的 writeObject 和 readObject 方法,進行使用者自定義的序列化和反序列化,如果沒有這樣的方法,則預設調用是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。使用者自定義的 writeObject 和 readObject 方法可以允許使用者控制序列化的過程,比如可以在序列化的過程中動态改變序列化的數值。基于這個原理,可以在實際應用中得到使用,用于敏感字段的加密工作,清單 3 展示了這個過程。

清單 3. 靜态變量序列化問題代碼
private static final long serialVersionUID = 1L;

	private String password = "pass";

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	private void writeObject(ObjectOutputStream out) {
		try {
			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 {
			GetField readFields = in.readFields();
			Object object = readFields.get("password", "");
			System.out.println("要解密的字元串:" + object.toString());
			password = "pass";//模拟解密,需要獲得本地的密鑰
		} catch (IOException e) {
			e.printStackTrace();
		} catch (ClassNotFoundException e) {
			e.printStackTrace();
		}

	}

	public static void main(String[] args) {
		try {
			ObjectOutputStream out = new ObjectOutputStream(
					new FileOutputStream("result.obj"));
			out.writeObject(new Test());
			out.close();

			ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
					"result.obj"));
			Test t = (Test) oin.readObject();
			System.out.println("解密後的字元串:" + t.getPassword());
			oin.close();
		} catch (FileNotFoundException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} catch (ClassNotFoundException e) {
			e.printStackTrace();
		}
	}      

在清單 3 的 writeObject 方法中,對密碼進行了加密,在 readObject 中則對 password 進行解密,隻有擁有密鑰的用戶端,才可以正确的解析出密碼,確定了資料的安全。執行清單 3 後控制台輸出如圖 3 所示。

圖 3. 資料加密示範
Java中的序列化Serialable進階詳解

特性使用案例

RMI 技術是完全基于 Java 序列化技術的,伺服器端接口調用所需要的參數對象來至于用戶端,它們通過網絡互相傳輸。這就涉及 RMI 的安全傳輸的問題。一些敏感的字段,如使用者名密碼(使用者登入時需要對密碼進行傳輸),我們希望對其進行加密,這時,就可以采用本節介紹的方法在用戶端對密碼進行加密,伺服器端進行解密,確定資料傳輸的安全性。

序列化存儲規則

情境:問題代碼如清單 4 所示。

清單 4. 存儲規則問題代碼
ObjectOutputStream out = new ObjectOutputStream(
					new FileOutputStream("result.obj"));
	Test test = new Test();
	//試圖将對象兩次寫入檔案
	out.writeObject(test);
	out.flush();
	System.out.println(new File("result.obj").length());
	out.writeObject(test);
	out.close();
	System.out.println(new File("result.obj").length());

	ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
			"result.obj"));
	//從檔案依次讀出兩個檔案
	Test t1 = (Test) oin.readObject();
	Test t2 = (Test) oin.readObject();
	oin.close();
			
	//判斷兩個引用是否指向同一個對象
	System.out.println(t1 == t2);      

清單 3 中對同一對象兩次寫入檔案,列印出寫入一次對象後的存儲大小和寫入兩次後的存儲大小,然後從檔案中反序列化出兩個對象,比較這兩個對象是否為同一對象。一般的思維是,兩次寫入對象,檔案大小會變為兩倍的大小,反序列化時,由于從檔案讀取,生成了兩個對象,判斷相等時應該是輸入 false 才對,但是最後結果輸出如圖 4 所示。

圖 4. 示例程式輸出
Java中的序列化Serialable進階詳解

我們看到,第二次寫入對象時檔案隻增加了 5 位元組,并且兩個對象是相等的,這是為什麼呢?

解答:Java 序列化機制為了節省磁盤空間,具有特定的存儲規則,當寫入檔案的為同一對象時,并不會再将對象的内容進行存儲,而隻是再次存儲一份引用,上面增加的 5 位元組的存儲空間就是新增引用和一些控制資訊的空間。反序列化時,恢複引用關系,使得清單 3 中的 t1 和 t2 指向唯一的對象,二者相等,輸出 true。該存儲規則極大的節省了存儲空間。

特性案例分析

檢視清單 5 的代碼。

清單 5. 案例代碼
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
Test test = new Test();
test.i = 1;
out.writeObject(test);
out.flush();
test.i = 2;
out.writeObject(test);
out.close();
ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
					"result.obj"));
Test t1 = (Test) oin.readObject();
Test t2 = (Test) oin.readObject();
System.out.println(t1.i);
System.out.println(t2.i);      

清單 4 的目的是希望将 test 對象兩次儲存到 result.obj 檔案中,寫入一次以後修改對象屬性值再次儲存第二次,然後從 result.obj 中再依次讀出兩個對象,輸出這兩個對象的 i 屬性值。案例代碼的目的原本是希望一次性傳輸對象修改前後的狀态。

結果兩個輸出的都是 1, 原因就是第一次寫入對象以後,第二次再試圖寫的時候,虛拟機根據引用關系知道已經有一個相同對象已經寫入檔案,是以隻儲存第二次寫的引用,是以讀取時,都是第一次儲存的對象。讀者在使用一個檔案多次 writeObject 需要特别注意這個問題。

小結

本文通過幾個具體的情景,介紹了 Java 序列化的一些進階知識,雖說進階,并不是說讀者們都不了解,希望用筆者介紹的情景讓讀者加深印象,能夠更加合理的利用 Java 序列化技術,在未來開發之路上遇到序列化問題時,可以及時的解決。由于本人知識水準有限,文章中倘若有錯誤的地方,歡迎聯系我批評指正。