簡介: 如果你只知道實現(xiàn) Serializable接口的對象,可以序列化為本地文件。那你最好再閱讀該篇文章,文章對序列化進行了更深一步的討論,用實際的例子代碼講述了序列化的高級認識,包括父類序列化的問題、靜態(tài)變量問題、transient 關(guān)鍵字的影響、序列化 ID問題。在筆者實際開發(fā)過程中,就多次遇到序列化的問題,在該文章中也會與讀者分享。
將 Java 對象序列化為二進制文件的 Java 序列化技術(shù)是 Java系列技術(shù)中一個較為重要的技術(shù)點,在大部分情況下,開發(fā)人員只需要了解被序列化的類需要實現(xiàn) Serializable 接口,使用ObjectInputStream 和 ObjectOutputStream進行對象的讀寫。然而在有些情況下,光知道這些還遠遠不夠,文章列舉了筆者遇到的一些真實情境,它們與 Java序列化相關(guān),通過分析情境出現(xiàn)的原因,使讀者輕松牢記 Java 序列化中的一些高級認識。
本文將逐一的介紹幾個情境,順序如下面的列表。
列表的每一部分講述了一個單獨的情境,讀者可以分別查看。
情境:兩個客戶端 A 和 B 試圖通過網(wǎng)絡(luò)傳遞對象數(shù)據(jù),A 端將對象 C 序列化為二進制數(shù)據(jù)再傳給 B,B 反序列化得到 C。
問題:C 對象的全類路徑假設(shè)為 com.inout.Test,在 A 和 B 端都有這么一個類文件,功能代碼完全一致。也都實現(xiàn)了 Serializable 接口,但是反序列化時總是提示不成功。
解決:虛擬機是否允許反序列化,不僅取決于類路徑和功能代碼是否一致,一個非常重要的一點是兩個類的序列化 ID 是否一致(就是 private static final long serialVersionUID = 1L)。清單 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,一個是隨機生成一個不重復(fù)的 long 類型數(shù)據(jù)(實際上是使用JDK 工具生成),在這里有一個建議,如果沒有特殊需求,就是用默認的 1L 就可以,這樣可以確保代碼一致時反序列化成功。那么隨機生成的序列化ID 有什么作用呢,有些時候,通過改變序列化 ID 可以用來限制某些用戶的使用。
特性使用案例
讀者應(yīng)該聽過 Fa?ade 模式,它是為應(yīng)用程序提供統(tǒng)一的訪問接口,案例程序中的 Client 客戶端使用了該模式,案例程序結(jié)構(gòu)圖如圖 1 所示。
Client 端通過 Fa?ade Object 才可以與業(yè)務(wù)邏輯對象進行交互。而客戶端的 Fa?ade Object 不能直接由Client 生成,而是需要 Server 端生成,然后序列化后通過網(wǎng)絡(luò)將二進制對象數(shù)據(jù)傳給 Client,Client 負責(zé)反序列化得到Fa?ade 對象。該模式可以使得 Client 端程序的使用需要服務(wù)器端的許可,同時 Client 端和服務(wù)器端的 Fa?ade Object類需要保持一致。當(dāng)服務(wù)器端想要進行版本更新時,只要將服務(wù)器端的 Fa?ade Object 類的序列化 ID 再次生成,當(dāng) Client端反序列化 Fa?ade Object 就會失敗,也就是強制 Client 端從服務(wù)器端獲取最新程序。
情境:查看清單 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方法,將對象序列化后,修改靜態(tài)變量的數(shù)值,再將序列化對象讀取出來,然后通過讀取出來的對象獲得靜態(tài)變量的數(shù)值并打印出來。依照清單 2,這個System.out.println(t.staticVar) 語句輸出的是 10 還是 5 呢?
最后的輸出是 10,對于無法理解的讀者認為,打印的 staticVar 是從讀取的對象里獲得的,應(yīng)該是保存時的狀態(tài)才對。之所以打印 10 的原因在于序列化時,并不保存靜態(tài)變量,這其實比較容易理解,序列化保存的是對象的狀態(tài),靜態(tài)變量屬于類的狀態(tài),因此 序列化并不保存靜態(tài)變量。
情境:一個子類實現(xiàn)了 Serializable 接口,它的父類都沒有實現(xiàn) Serializable 接口,序列化該子類對象,然后反序列化后輸出父類定義的某變量的數(shù)值,該變量數(shù)值與序列化時的數(shù)值不同。
解決:要想將父類對象也序列化,就需要讓父類也實現(xiàn)Serializable 接口。如果父類不實現(xiàn)的話的,就 需要有默認的無參的構(gòu)造函數(shù)。在父類沒有實現(xiàn) Serializable 接口時,虛擬機是不會序列化父對象的,而一個 Java對象的構(gòu)造必須先有父對象,才有子對象,反序列化也不例外。所以反序列化時,為了構(gòu)造父對象,只能調(diào)用父類的無參構(gòu)造函數(shù)作為默認的父對象。因此當(dāng)我們?nèi)「笇ο蟮淖兞恐禃r,它的值是調(diào)用父類無參構(gòu)造函數(shù)后的值。如果你考慮到這種序列化的情況,在父類無參構(gòu)造函數(shù)中對變量進行初始化,否則的話,父類變量值都是默認聲明的值,如 int 型的默認是 0,string 型的默認是 null。
Transient 關(guān)鍵字的作用是控制變量的序列化,在變量聲明前加上該關(guān)鍵字,可以阻止該變量被序列化到文件中,在被反序列化后,transient 變量的值被設(shè)為初始值,如 int 型的是 0,對象型的是 null。
特性使用案例
我們熟悉使用 Transient關(guān)鍵字可以使得字段不被序列化,那么還有別的方法嗎?根據(jù)父類對象序列化的規(guī)則,我們可以將不需要被序列化的字段抽取出來放到父類中,子類實現(xiàn)Serializable 接口,父類不實現(xiàn),根據(jù)父類序列化規(guī)則,父類的字段數(shù)據(jù)將不被序列化,形成類圖如圖 2 所示。
上圖中可以看出,attr1、attr2、attr3、attr5 都不會被序列化,放在父類中的好處在于當(dāng)有另外一個 Child 類時,attr1、attr2、attr3 依然不會被序列化,不用重復(fù)抒寫 transient,代碼簡潔。
情境:服務(wù)器端給客戶端發(fā)送序列化對象數(shù)據(jù),對象中有一些數(shù)據(jù)是敏感的,比如密碼字符串等,希望對該密碼字段在序列化時,進行加密,而客戶端如果擁有解密的密鑰,只有在客戶端進行反序列化時,才可以對密碼進行讀取,這樣可以一定程度保證序列化對象的數(shù)據(jù)安全。
解決:在序列化過程中,虛擬機會試圖調(diào)用對象類里的 writeObject 和 readObject方法,進行用戶自定義的序列化和反序列化,如果沒有這樣的方法,則默認調(diào)用是 ObjectOutputStream 的defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。用戶自定義的writeObject 和 readObject方法可以允許用戶控制序列化的過程,比如可以在序列化的過程中動態(tài)改變序列化的數(shù)值?;谶@個原理,可以在實際應(yīng)用中得到使用,用于敏感字段的加密工作,清單 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 進行解密,只有擁有密鑰的客戶端,才可以正確的解析出密碼,確保了數(shù)據(jù)的安全。執(zhí)行清單 3 后控制臺輸出如圖 3 所示。
特性使用案例
RMI 技術(shù)是完全基于 Java 序列化技術(shù)的,服務(wù)器端接口調(diào)用所需要的參數(shù)對象來至于客戶端,它們通過網(wǎng)絡(luò)相互傳輸。這就涉及 RMI的安全傳輸?shù)膯栴}。一些敏感的字段,如用戶名密碼(用戶登錄時需要對密碼進行傳輸),我們希望對其進行加密,這時,就可以采用本節(jié)介紹的方法在客戶端對密碼進行加密,服務(wù)器端進行解密,確保數(shù)據(jù)傳輸?shù)陌踩浴?/p>
情境:問題代碼如清單 3 所示。
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中對同一對象兩次寫入文件,打印出寫入一次對象后的存儲大小和寫入兩次后的存儲大小,然后從文件中反序列化出兩個對象,比較這兩個對象是否為同一對象。一般的思維是,兩次寫入對象,文件大小會變?yōu)閮杀兜拇笮?,反序列化時,由于從文件讀取,生成了兩個對象,判斷相等時應(yīng)該是輸入 false才對,但是最后結(jié)果輸出如圖 4 所示。
我們看到,第二次寫入對象時文件只增加了 5 字節(jié),并且兩個對象是相等的,這是為什么呢?
解答:Java序列化機制為了節(jié)省磁盤空間,具有特定的存儲規(guī)則,當(dāng)寫入文件的為同一對象時,并不會再將對象的內(nèi)容進行存儲,而只是再次存儲一份引用,上面增加的 5字節(jié)的存儲空間就是新增引用和一些控制信息的空間。反序列化時,恢復(fù)引用關(guān)系,使得清單 3 中的 t1 和 t2 指向唯一的對象,二者相等,輸出true。該存儲規(guī)則極大的節(jié)省了存儲空間。
特性案例分析
查看清單 4 的代碼。
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 屬性值。案例代碼的目的原本是希望一次性傳輸對象修改前后的狀態(tài)。
結(jié)果兩個輸出的都是 1,原因就是第一次寫入對象以后,第二次再試圖寫的時候,虛擬機根據(jù)引用關(guān)系知道已經(jīng)有一個相同對象已經(jīng)寫入文件,因此只保存第二次寫的引用,所以讀取時,都是第一次保存的對象。讀者在使用一個文件多次 writeObject 需要特別注意這個問題。
本文通過幾個具體的情景,介紹了 Java序列化的一些高級知識,雖說高級,并不是說讀者們都不了解,希望用筆者介紹的情景讓讀者加深印象,能夠更加合理的利用 Java序列化技術(shù),在未來開發(fā)之路上遇到序列化問題時,可以及時的解決。由于本人知識水平有限,文章中倘若有錯誤的地方,歡迎聯(lián)系我批評指正。