文章目錄
- 【引言】
- 【系列化serialVersionUID問題】
- 【靜態變數序列】
- 【父類的序列化與 Transient 關鍵字】
- 【對敏感欄位加密】
- 【序列化儲存規則】
【引言】
將 Java 對象序列化為二進位檔案的 Java 序列化技術是 Java 系列技術中一個較為重要的技術點,在大部分情況下,開發人員只需要瞭解被序列化的類需要實現 Serializable 介面,使用 ObjectInputStream 和 ObjectOutputStream 進行對象的讀寫。然而在有些情況下,光知道這些還遠遠不夠,文章列舉了筆者遇到的一些真實情境,它們與 Java 序列化相關,通過分析情境出現的原因,使讀者輕鬆牢記 Java 序列化中的一些進階認識。
【系列化serialVersionUID問題】
在Java系列化與反系列化中,虛擬機器是否允許還原序列化,不僅取決於類路徑和功能代碼是否一致,一個非常重要的一點是兩個類的序列化 ID 是否一致(就是 private static final long serialVersionUID = 1L),如果serialVersionUID不同,你將得到一個java.io.InvalidClassException,看如下代碼:
package wen.hui.test.serializable;import java.io.Serializable;/** * serializable測試 * * @author whwang * 2011-12-1 下午09:50:07 */public class A implements Serializable { private static final long serialVersionUID = 2L; public A() { } public void print() { System.err.println("test serializable"); } public static void main(String[] args) throws Exception { }}
package wen.hui.test.serializable;import java.io.FileInputStream;import java.io.FileOutputStream;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;/** * * @author whwang * 2011-12-1 下午09:54:36 */public class Test1 { public static void main(String[] args) throws Exception { // write object String fileName = "obj"; toWrite(fileName); // read object toRead(fileName); } public static void toWrite(String fileName) throws Exception { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream( fileName)); oos.writeObject(new A()); oos.close(); } public static void toRead(String fileName) throws Exception { ObjectInputStream ois = new ObjectInputStream( new FileInputStream("obj")); A t = (A) ois.readObject(); t.print(); ois.close(); }}
1、直接運行Test1的main方法,運行正確;
2、先將Test1的main方法中的toRead(fileName)注釋,把類A中的serialVersionUID 值改為1,運行Test1;然後在代開toRead(fileName),將toWrite(fileName)注釋,同時將類A中的serialVersionUID 值改為2;運行Test1,發現拋出異常,表明如果serialVersionUID不同,即使兩個“完全”相同的類也無法還原序列化。
Exception in thread "main" java.io.InvalidClassException: wen.hui.test.serializable.A; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2
序列化 ID 在 Eclipse 下提供了兩種建置原則,一個是固定的 1L,一個是隨機產生一個不重複的 long 類型資料(實際上是使用 JDK 工具產生),在這裡有一個建議,如果沒有特殊需求,就是用預設的 1L 就可以,這樣可以確保代碼一致時還原序列化成功。那麼隨機產生的序列化 ID 有什麼作用呢,有些時候,通過改變序列化 ID 可以用來限制某些使用者的使用。如Facade模式中,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 端從伺服器端擷取最新程式。
【靜態變數序列】
直接看代碼:
package wen.hui.test.serializable;import java.io.Serializable;/** * serializable測試 * * @author whwang * 2011-12-1 下午09:50:07 */public class A implements Serializable { private static final long serialVersionUID = 1L; public static int staticVar = 10; public A() { } public void print() { System.err.println("test serializable"); } public static void main(String[] args) throws Exception { }}
package wen.hui.test.serializable;import java.io.FileInputStream;import java.io.FileNotFoundException;import java.io.FileOutputStream;import java.io.IOException;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;/** * 序列化儲存的是對象的狀態,靜態變數屬於類的狀態,不會被序列化 * @author whwang * 2011-12-1 下午10:12:06 */public class Test2 { public static void main(String[] args) { try { // 初始時staticVar為10 ObjectOutputStream out = new ObjectOutputStream( new FileOutputStream("obj")); out.writeObject(new A()); out.close(); // 序列化後修改為100 A.staticVar = 100; ObjectInputStream oin = new ObjectInputStream(new FileInputStream( "obj")); A t = (A) oin.readObject(); oin.close(); // 再讀取,通過t.staticVar列印新的值 System.err.println(t.staticVar); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } }}
A類的靜態欄位staticVar初始化值為10,在Teste2的main方法中,將A類的一個執行個體系列化到硬碟,然後修改靜態欄位staticVar = 100,接著反系列化剛系列化的對象,輸出”該對象的“staticVar的值。輸出的是 100 還是 10 呢?
結果輸出是100,之所以列印 100 的原因在於序列化時,並不儲存靜態變數,這其實比較容易理解,序列化儲存的是對象的狀態,靜態變數屬於類的狀態,因此 序列化並不儲存靜態變數。
【父類的序列化與 Transient 關鍵字】
情境:一個子類實現了 Serializable 介面,它的父類都沒有實現 Serializable 介面,序列化該子類對象,然後還原序列化後輸出父類定義的某變數的數值,該變數數值與序列化時的數值不同。
解決:要想將父類對象也序列化,就需要讓父類也實現Serializable 介面。如果父類不實現的話的,就 需要有預設的無參的建構函式。在父類沒有實現 Serializable 介面時,虛擬機器是不會序列化父物件的,而一個 Java 對象的構造必須先有父物件,才有子物件,還原序列化也不例外。所以還原序列化時,為了構造父物件,只能調用父類的無參建構函式作為預設的父物件。因此當我們取父物件的變數值時,它的值是調用父類無參建構函式後的值。如果你考慮到這種序列化的情況,在父類無參建構函式中對變數進行初始化,否則的話,父類變數值都是預設聲明的值,如
int 型的預設是 0,string 型的預設是 null。
Transient 關鍵字的作用是控制變數的序列化,在變數聲明前加上該關鍵字,可以阻止該變數被序列化到檔案中,在被還原序列化後,transient 變數的值被設為初始值,如 int 型的是 0,對象型的是 null。
package wen.hui.test.serializable;/** * * @author whwang * 2011-12-1 下午10:23:10 */public class B { public int b1; public int b2; public B() { this.b2 = 100; }}
package wen.hui.test.serializable;import java.io.Serializable;/** * * @author whwang * 2011-12-1 下午09:49:51 */public class C extends B implements Serializable { private static final long serialVersionUID = 1L; public int c1; public int c2; public C() { // 給b1,b2賦值 this.b1 = 1; this.b2 = 2; this.c2 = -100; }}
package wen.hui.test.serializable;import java.io.FileInputStream;import java.io.FileNotFoundException;import java.io.FileOutputStream;import java.io.IOException;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;/** * 如果父類沒有實現Serializable,那麼父類不會被系列化,當反系列化子類時 * 會調用父類無參的構造方法。 * @author whwang * 2011-12-1 下午10:23:51 */public class Test3 { public static void main(String[] args) { try { ObjectOutputStream out = new ObjectOutputStream( new FileOutputStream("obj")); out.writeObject(new C()); out.close(); ObjectInputStream oin = new ObjectInputStream(new FileInputStream( "obj")); C t = (C) oin.readObject(); oin.close(); System.err.println(t.b1 + ", " + t.b2 + ", " + t.c1 + ", " + t.c2); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } }}
運行Test3的main方法,結果輸出0, 100, 0, -100;即在子類的構造方法中對父類的成員變數的初始化沒有被系列化;而反系列化時,則是調用父類的無參構造方法執行個體化父類。
【對敏感欄位加密】
情境:伺服器端給用戶端發送序列化對象資料,對象中有一些資料是敏感的,比如密碼字串等,希望對該密碼欄位在序列化時,進行加密,而用戶端如果擁有解密的密鑰,只有在用戶端進行還原序列化時,才可以對密碼進行讀取,這樣可以一定程度保證序列化對象的資料安全。
解決:在序列化過程中,虛擬機器會試圖調用對象類裡的 writeObject(ObjectOutputStread out) 和 readObject(ObjectInputStread in) 方法(通過反射機制),進行使用者自訂的序列化和還原序列化,如果沒有這樣的方法,則預設調用是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。使用者自訂的 writeObject 和 readObject
方法可以允許使用者控制序列化的過程,比如可以在序列化的過程中動態改變序列化的數值。基於這個原理,可以在實際應用中得到使用,用于敏感欄位的加密工作,如:
package wen.hui.test.serializable;import java.io.IOException;import java.io.ObjectInputStream;import java.io.ObjectInputStream.GetField;import java.io.Serializable;/** * @author whwang 2011-12-1 下午10:29:54 */public class D implements Serializable { private static final long serialVersionUID = 1L; private String password; public D() { } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; }// private void writeObject(ObjectOutputStream out) {// // } private void readObject(ObjectInputStream in) { try { GetField readFields = in.readFields(); Object object = readFields.get("password", ""); System.err.println("要解密的字串:" + object.toString()); password = "password";// 類比解密,需要獲得本地的密鑰 } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } }}
package wen.hui.test.serializable;import java.io.FileInputStream;import java.io.FileNotFoundException;import java.io.FileOutputStream;import java.io.IOException;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;/** * @ClassName: Test4 * @Description: 加密測試。 * @author whwang * @date 2011-12-1 下午05:01:34 * */public class Test4 { public static void main(String[] args) { try { ObjectOutputStream out = new ObjectOutputStream( new FileOutputStream("obj")); D t1 = new D(); t1.setPassword("encryption");// 加密後的(類比) out.writeObject(t1); out.close(); ObjectInputStream oin = new ObjectInputStream(new FileInputStream( "obj")); D t = (D) oin.readObject(); oin.close(); System.err.println("解密後的字串:" + t.getPassword()); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } } }
在系列化之前,將密碼欄位加密,然後系列化到硬碟,在反系列化時,通過類D中的readObject(ObjectInputStream in)做解密操作,確保了資料的安全。
如:RMI 技術是完全基於 Java 序列化技術的,伺服器端介面調用所需要的參數對象來至於用戶端,它們通過網路相互傳輸。這就涉及 RMI 的安全傳輸的問題。一些敏感的欄位,如使用者名稱密碼(使用者登入時需要對密碼進行傳輸),我們希望對其進行加密,這時,就可以採用本節介紹的方法在用戶端對密碼進行加密,伺服器端進行解密,確保資料轉送的安全性。
【序列化儲存規則】
請看如下代碼:
package wen.hui.test.serializable;import java.io.File;import java.io.FileInputStream;import java.io.FileNotFoundException;import java.io.FileOutputStream;import java.io.IOException;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;/** * * @author whwang 2011-12-1 下午11:00:35 */public class Test5 { public static void main(String[] args) { ObjectOutputStream out; try { out = new ObjectOutputStream(new FileOutputStream( "obj")); A t = new A(); // 試圖將對象兩次寫入檔案 out.writeObject(t); out.flush(); System.err.println("加入第一個類:" + new File("obj").length()); //t.a = 10; out.writeObject(t); out.close(); System.err.println("加入第二個類:" + new File("obj").length()); ObjectInputStream oin = new ObjectInputStream(new FileInputStream( "obj")); // 從檔案依次讀出兩個檔案 A t1 = (A) oin.readObject(); A t2 = (A) oin.readObject(); oin.close(); // 判斷兩個引用是否指向同一個對象 System.err.println(t1 == t2); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } }}
對同一對象兩次寫入檔案,列印出寫入一次對象後的儲存大小和寫入兩次後的儲存大小,然後從檔案中還原序列化出兩個對象,比較這兩個對象是否為同一對象。一般的思維是,兩次寫入對象,檔案大小會變為兩倍的大小,還原序列化時,由於從檔案讀取,產生了兩個對象,判斷相等時應該是輸入 false 才對。但實際結果是:第二次寫入對象時檔案只增加了 5 位元組,並且兩個對象是相等的,這是為什麼呢?
解答:Java 序列化機製為了節省磁碟空間,具有特定的儲存規則,當寫入檔案的為同一對象時(根據包名+類名),並不會再將對象的內容進行儲存,而只是再次儲存一份引用,上面增加的 5 位元組的儲存空間就是新增引用和一些控制資訊的空間。還原序列化時,恢複參考關聯性,使得程式 中的 t1 和 t2 指向唯一的對象,二者相等,輸出 true,該儲存規則極大的節省了儲存空間。
但需要注意,在上面程式中將//t.a = 10注釋開啟,執行的結果也一樣。 原因就是第一次寫入對象以後,第二次再試圖寫的時候,虛擬機器根據參考關聯性知道已經有一個相同對象已經寫入檔案,因此只儲存第二次寫的引用,所以讀取時,都是第一次儲存的對象。