天天看點

深入了解JAVA序列化(轉)深入了解JAVA序列化

深入了解JAVA序列化

原文連結 : https://www.cnblogs.com/wxgblogs/p/5849951.html

  如果你隻知道實作 Serializable 接口的對象,可以序列化為本地檔案。那你最好再閱讀該篇文章,文章對序列化進行了更深一步的讨論,用實際的例子代碼講述了序列化的進階認識,包括父類序列化的問題、靜态變量問題、transient 關鍵字的影響、序列化 ID 問題。在筆者實際開發過程中,就多次遇到序列化的問題,在該文章中也會與讀者分享。

引言

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

序列化 ID 問題

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

  問題:C 對象的全類路徑假設為 com.inout.Test,在 A 和 B 端都有這麼一個類檔案,功能代碼完全一緻。也都實作了 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,一個是随機生成一個不重複的 long 類型資料(實際上是使用 JDK 工具生成),在這裡有一個建議,如果沒有特殊需求,就是用預設的 1L 就可以,這樣可以確定代碼一緻時反序列化成功。那麼随機生成的序列化 ID 有什麼作用呢,有些時候,通過改變序列化 ID 可以用來限制某些使用者的使用。

特性使用案例

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

深入了解JAVA序列化(轉)深入了解JAVA序列化

  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. 靜态變量序列化問題代碼

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

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 所示。

深入了解JAVA序列化(轉)深入了解JAVA序列化

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

對敏感字段加密

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

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 所示。

深入了解JAVA序列化(轉)深入了解JAVA序列化

特性使用案例

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

序列化存儲規則

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

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 所示。

深入了解JAVA序列化(轉)深入了解JAVA序列化

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

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

特性案例分析

1

2

3

4

5

6

7

8

9

10

11

12

13

14

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 序列化技術,在未來開發之路上遇到序列化問題時,可以及時的解決。由于本人知識水準有限,文章中倘若有錯誤的地方,歡迎聯系我批評指正。