引言
將 Java 對(duì)象序列化為二進(jìn)制文件的 Java 序列化技術(shù)是 Java 系列技術(shù)中一個(gè)較為重要的技術(shù)點(diǎn)院尔,在大部分情況下箫荡,開發(fā)人員只需要了解被序列化的類需要實(shí)現(xiàn) Serializable
接口魁亦,
使用 ObjectInputStream
和 ObjectOutputStream
進(jìn)行對(duì)象的讀寫。然而在有些情況下羔挡,光知道這些還遠(yuǎn)遠(yuǎn)不夠洁奈,文章列舉了筆者遇到的一些真實(shí)情境绞灼,它們與 Java 序列化相關(guān),
通過分析情境出現(xiàn)的原因低矮,使讀者輕松牢記 Java 序列化中的一些高級(jí)認(rèn)識(shí)。
文章結(jié)構(gòu)
本文將逐一的介紹幾個(gè)情境军掂,順序如下面的列表轮蜕。
序列化 ID 的問題
靜態(tài)變量序列化
父類的序列化與 Transient 關(guān)鍵字
對(duì)敏感字段加密
序列化存儲(chǔ)規(guī)則
列表的每一部分講述了一個(gè)單獨(dú)的情境蝗锥,讀者可以分別查看跃洛。
序列化 ID 問題
解決:虛擬機(jī)是否允許反序列化玛追,不僅取決于類路徑和功能代碼是否一致,一個(gè)非常重要的一點(diǎn)是兩個(gè)類的序列化 ID 是否一致
(就是 private static final long serialVersionUID = 1L
)闲延。即使兩個(gè)類的功能代碼完全一致痊剖,但是序列化 ID 不同垒玲,他們也無法相互序列化和反序列化。
簡(jiǎn)單來說合愈,Java的序列化機(jī)制是通過在運(yùn)行時(shí)判斷類的serialVersionUID
來驗(yàn)證版本一致性的。
在進(jìn)行反序列化時(shí)益老,JVM會(huì)把傳來的字節(jié)流中的serialVersionUID與本地相應(yīng)實(shí)體(類)的serialVersionUID
進(jìn)行比較寸莫,
如果相同就認(rèn)為是一致的,可以進(jìn)行反序列化膘茎,否則就會(huì)出現(xiàn)序列化版本不一致的異常酷誓。
當(dāng)實(shí)現(xiàn)java.io.Serializable
接口的實(shí)體(類)沒有顯式地定義一個(gè)名為serialVersionUID
态坦,類型為long的變量時(shí),
Java序列化機(jī)制會(huì)根據(jù)編譯的class自動(dòng)生成一個(gè)serialVersionUID
作序列化版本比較用玫氢,這種情況下壮锻,只有同一次編譯生成的class才會(huì)生成相同的serialVersionUID 。
如果我們不希望通過編譯來強(qiáng)制劃分軟件版本猜绣,即實(shí)現(xiàn)序列化接口的實(shí)體能夠兼容先前版本掰邢,未作更改的類,就需要顯式地定義一個(gè)名為serialVersionUID
辣之,
類型為long的變量掰伸,不修改這個(gè)變量值的序列化實(shí)體都可以相互進(jìn)行串行化和反串行化。
序列化 ID 在 Eclipse 下提供了兩種生成策略怀估,一個(gè)是固定的 1L,一個(gè)是隨機(jī)生成一個(gè)不重復(fù)的 long 類型數(shù)據(jù)(實(shí)際上是使用 JDK 工具生成)多搀,在這里有一個(gè)建議,
如果沒有特殊需求惯退,就是用默認(rèn)的 1L 就可以从藤,這樣可以確保代碼一致時(shí)反序列化成功。那么隨機(jī)生成的序列化 ID 有什么作用呢懊蒸,有些時(shí)候悯搔,通過改變序列化 ID 可以用來限制某些用戶的使用。
靜態(tài)變量序列化
情境:查看清單 1 的代碼。
清單 1. 靜態(tài)變量序列化問題代碼
public class Test implements Serializable {
private static final long serialVersionUID = 1L;
public static int staticVar = 5;
public static void main(String[] args) {
try {
//初始時(shí)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();
}
}
}
清單 1 中的 main 方法推姻,將對(duì)象序列化后框沟,修改靜態(tài)變量的數(shù)值藏古,再將序列化對(duì)象讀取出來忍燥,然后通過讀取出來的對(duì)象獲得靜態(tài)變量的數(shù)值并打印出來。依照清單 2梅垄,
這個(gè) System.out.println(t.staticVar)
語句輸出的是 10 還是 5 呢?
最后的輸出是 10靡馁,對(duì)于無法理解的讀者認(rèn)為机久,打印的 staticVar 是從讀取的對(duì)象里獲得的,應(yīng)該是保存時(shí)的狀態(tài)才對(duì)胧弛。之所以打印 10 的原因在于序列化時(shí)侠畔,
并不保存靜態(tài)變量,這其實(shí)比較容易理解践图,序列化保存的是對(duì)象的狀態(tài)码党,靜態(tài)變量屬于類的狀態(tài)斥黑,因此 序列化并不保存靜態(tài)變量。
父類的序列化與 Transient 關(guān)鍵字
情境:一個(gè)子類實(shí)現(xiàn)了 Serializable 接口锌奴,它的父類都沒有實(shí)現(xiàn) Serializable 接口,序列化該子類對(duì)象箕慧,然后反序列化后輸出父類定義的某變量的數(shù)值,該變量數(shù)值與序列化時(shí)的數(shù)值不同斩熊。
解決:要想將父類對(duì)象也序列化伐庭,就需要讓父類也實(shí)現(xiàn)Serializable 接口。如果父類不實(shí)現(xiàn)的話的霸株,就 需要有默認(rèn)的無參的構(gòu)造函數(shù)集乔。在父類沒有實(shí)現(xiàn) Serializable 接口時(shí),
虛擬機(jī)是不會(huì)序列化父對(duì)象的箫攀,而一個(gè) Java 對(duì)象的構(gòu)造必須先有父對(duì)象幼衰,才有子對(duì)象,反序列化也不例外渡嚣。所以反序列化時(shí)识椰,為了構(gòu)造父對(duì)象,
只能調(diào)用父類的無參構(gòu)造函數(shù)作為默認(rèn)的父對(duì)象腹鹉。因此當(dāng)我們?nèi)「笇?duì)象的變量值時(shí),它的值是調(diào)用父類無參構(gòu)造函數(shù)后的值愉阎。如果你考慮到這種序列化的情況力奋,
在父類無參構(gòu)造函數(shù)中對(duì)變量進(jìn)行初始化,否則的話溅呢,父類變量值都是默認(rèn)聲明的值,如 int 型的默認(rèn)是 0驶鹉,string 型的默認(rèn)是 null铣墨。
Transient 關(guān)鍵字的作用是控制變量的序列化,在變量聲明前加上該關(guān)鍵字词顾,可以阻止該變量被序列化到文件中碱妆,在被反序列化后,transient 變量的值被設(shè)為初始值疹尾,
如 int 型的是 0,對(duì)象型的是 null窍蓝。
特性使用案例
我們熟悉使用 Transient 關(guān)鍵字可以使得字段不被序列化繁成,那么還有別的方法嗎?<font color=#0099ff>根據(jù)父類對(duì)象序列化的規(guī)則面睛,我們可以將不需要被序列化的字段抽取出來放到父類中尊搬,
子類實(shí)現(xiàn) Serializable 接口,父類不實(shí)現(xiàn)幌墓,根據(jù)父類序列化規(guī)則冀泻,父類的字段數(shù)據(jù)將不被序列化,不用重復(fù)抒寫 transient袭祟,代碼簡(jiǎn)潔捞附。</font>
對(duì)敏感字段加密
情境:服務(wù)器端給客戶端發(fā)送序列化對(duì)象數(shù)據(jù),對(duì)象中有一些數(shù)據(jù)是敏感的胆绊,比如密碼字符串等欧募,希望對(duì)該密碼字段在序列化時(shí),進(jìn)行加密种冬,
而客戶端如果擁有解密的密鑰舔糖,只有在客戶端進(jìn)行反序列化時(shí),才可以對(duì)密碼進(jìn)行讀取金吗,這樣可以一定程度保證序列化對(duì)象的數(shù)據(jù)安全摇庙。
解決:在序列化過程中,虛擬機(jī)會(huì)試圖調(diào)用對(duì)象類里的 writeObject
和 readObject
方法卫袒,進(jìn)行用戶自定義的序列化和反序列化夕凝,如果沒有這樣的方法,
則默認(rèn)調(diào)用是 ObjectOutputStream 的 defaultWriteObject
方法以及 ObjectInputStream 的 defaultReadObject
方法讽营。
用戶自定義的 writeObject 和 readObject 方法可以允許用戶控制序列化的過程泡徙,比如可以在序列化的過程中動(dòng)態(tài)改變序列化的數(shù)值。
基于這個(gè)原理堪藐,可以在實(shí)際應(yīng)用中得到使用礁竞,用于敏感字段的加密工作,清單 3 展示了這個(gè)過程模捂。
清單 3. 靜態(tài)變量序列化問題代碼
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 方法中综看,對(duì)密碼進(jìn)行了加密,在 readObject 中則對(duì) password 進(jìn)行解密舞吭,只有擁有密鑰的客戶端析珊,才可以正確的解析出密碼,確保了數(shù)據(jù)的安全忠寻。
特性使用案例
RMI 技術(shù)是完全基于 Java 序列化技術(shù)的锡溯,服務(wù)器端接口調(diào)用所需要的參數(shù)對(duì)象來至于客戶端,它們通過網(wǎng)絡(luò)相互傳輸祭饭。
這就涉及 RMI 的安全傳輸?shù)膯栴}倡蝙。一些敏感的字段,如用戶名密碼(用戶登錄時(shí)需要對(duì)密碼進(jìn)行傳輸)寺鸥,我們希望對(duì)其進(jìn)行加密,
這時(shí)烤低,就可以采用本節(jié)介紹的方法在客戶端對(duì)密碼進(jìn)行加密笆载,服務(wù)器端進(jìn)行解密,確保數(shù)據(jù)傳輸?shù)陌踩浴?/p>
序列化存儲(chǔ)規(guī)則
情境:?jiǎn)栴}代碼如清單 4 所示腻要。
清單 4. 存儲(chǔ)規(guī)則問題代碼
ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream("result.obj"));
Test test = new Test();
//試圖將對(duì)象兩次寫入文件
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"));
//從文件依次讀出兩個(gè)文件
Test t1 = (Test) oin.readObject();
Test t2 = (Test) oin.readObject();
oin.close();
//判斷兩個(gè)引用是否指向同一個(gè)對(duì)象
System.out.println(t1 == t2);
清單 4 中對(duì)同一對(duì)象兩次寫入文件雄家,打印出寫入一次對(duì)象后的存儲(chǔ)大小和寫入兩次后的存儲(chǔ)大小胀滚,然后從文件中反序列化出兩個(gè)對(duì)象乱投,
比較這兩個(gè)對(duì)象是否為同一對(duì)象咙好。一般的思維是褐荷,兩次寫入對(duì)象,文件大小會(huì)變?yōu)閮杀兜拇笮〔愎葱蛄谢瘯r(shí)其监,由于從文件讀取,生成了兩個(gè)對(duì)象毁菱,
判斷相等時(shí)應(yīng)該是輸入 false 才對(duì)锌历,但是第二次寫入對(duì)象時(shí)文件只增加了 5 字節(jié),并且兩個(gè)對(duì)象是相等的窗慎,這是為什么呢卤材?
解答:Java 序列化機(jī)制為了節(jié)省磁盤空間,具有特定的存儲(chǔ)規(guī)則术吗,當(dāng)寫入文件的為同一對(duì)象時(shí)帆精,并不會(huì)再將對(duì)象的內(nèi)容進(jìn)行存儲(chǔ),而只是再次存儲(chǔ)一份引用吝镣,
上面增加的 5 字節(jié)的存儲(chǔ)空間就是新增引用和一些控制信息的空間昆庇。反序列化時(shí),恢復(fù)引用關(guān)系拱撵,使得清單 4 中的 t1 和 t2 指向唯一的對(duì)象,二者相等拴测,輸出 true。該存儲(chǔ)規(guī)則極大的節(jié)省了存儲(chǔ)空間屿愚。
特性案例分析
查看清單 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);
清單 5 的目的是希望將 test 對(duì)象兩次保存到 result.obj 文件中函匕,寫入一次以后修改對(duì)象屬性值再次保存第二次,然后從 result.obj 中再依次讀出兩個(gè)對(duì)象中剩,
輸出這兩個(gè)對(duì)象的 i 屬性值抒寂。案例代碼的目的原本是希望一次性傳輸對(duì)象修改前后的狀態(tài)。
結(jié)果兩個(gè)輸出的都是 1蓬推, 原因就是第一次寫入對(duì)象以后沸伏,第二次再試圖寫的時(shí)候,虛擬機(jī)根據(jù)引用關(guān)系知道已經(jīng)有一個(gè)相同對(duì)象已經(jīng)寫入文件红选,
因此只保存第二次寫的引用姆另,所以讀取時(shí),都是第一次保存的對(duì)象蝶防。讀者在使用一個(gè)文件多次 writeObject 需要特別注意這個(gè)問題明吩。
小結(jié)
本文通過幾個(gè)具體的情景,介紹了 Java 序列化的一些高級(jí)知識(shí),雖說高級(jí)详羡,并不是說讀者們都不了解嘿悬,希望用筆者介紹的情景讓讀者加深印象,
能夠更加合理的利用 Java 序列化技術(shù)窒盐,在未來開發(fā)之路上遇到序列化問題時(shí)躯概,可以及時(shí)的解決娶靡。由于本人知識(shí)水平有限看锉,文章中倘若有錯(cuò)誤的地方,歡迎聯(lián)系我批評(píng)指正伯铣。