1. 程式人生 > >Java 中序列化與反序列化

Java 中序列化與反序列化

一、 序列化和反序列化概念

Serialization(序列化)是一種將物件以一連串的位元組描述的過程;反序列化deserialization是一種將這些位元組重建成一個物件的過程。將程式中的物件,放入檔案中儲存就是序列化,將檔案中的位元組碼重新轉成物件就是反序列化。

二、 序列化和反序列化的必要性

當兩個程序進行遠端通訊時,可以相互發送各種型別的資料,包括文字、圖片、音訊、視訊等, 而這些資料都會以二進位制序列的形式在網路上傳送。
而java是面向物件的開發方式,一切都是java物件,想要實現java物件的網路傳輸,就可以使用序列化和反序列化來實現。傳送方將需要傳送的Java物件序列化轉換為位元組序列,然後在網路上傳送;接收方接收到字元序列後,使用反序列化從位元組序列中恢復出Java物件。

當我們瞭解了為什麼需要Java序列化和反序列化後,我們很自然地會想Java序列化的好處。其好處一是實現了資料的持久化,通過序列化可以把資料永久地儲存到硬碟上(通常存放在檔案裡);二是,利用序列化實現遠端通訊,即在網路上傳送物件的位元組序列。

總結,在網路中資料的傳輸必須是序列化形式來進行的。其他序列化的方式可以是json傳輸,xml形式傳輸。

三、 序列化和反序列化的實現

1)JDK類庫提供的序列化API

  • java.io.ObjectOutputStream:表示物件輸出流
    它的writeObject(Object obj)方法可以對引數指定的obj物件進行序列化,把得到的位元組序列寫到一個目標輸出流中。
  • java.io.ObjectInputStream:表示物件輸入流
    它的readObject()方法從源輸入流中讀取位元組序列,再把它們反序列化成為一個物件,並將其返回。

    2)實現序列化的要求

    只有實現了Serializable或Externalizable介面的類的物件才能被序列化,否則丟擲異常。

    3)實現Java物件序列化與反序列化的方法

    假定一個Student類,它的物件需要序列化,可以有如下三種方法:
  • 方法一:若Student類僅僅實現了Serializable介面,則可以按照以下方式進行序列化和反序列化。
    ObjectOutputStream採用預設的序列化方式,對Student物件的非transient的例項變數進行序列化。
    ObjcetInputStream採用預設的反序列化方式,對對Student物件的非transient的例項變數進行反序列化。
  • 方法二:若Student類僅僅實現了Serializable介面,並且還定義了readObject(ObjectInputStream in)和writeObject(ObjectOutputSteam out),則採用以下方式進行序列化與反序列化。
    ObjectOutputStream呼叫Student物件的writeObject(ObjectOutputStream out)的方法進行序列化。
    ObjectInputStream會呼叫Student物件的readObject(ObjectInputStream in)的方法進行反序列化。
  • 方法三:若Student類實現了Externalnalizable介面,且Student類必須實現readExternal(ObjectInput in)和writeExternal(ObjectOutput out)方法,則按照以下方式進行序列化與反序列化。
    ObjectOutputStream呼叫Student物件的writeExternal(ObjectOutput out))的方法進行序列化。
    ObjectInputStream會呼叫Student物件的readExternal(ObjectInput in)的方法進行反序列化。

    4)JDK類庫中序列化的步驟

    步驟一:建立一個物件輸出流,它可以包裝一個其它型別的目標輸出流,如檔案輸出流:
ObjectOutputStream out = new ObjectOutputStream(new fileOutputStream(“D:\\objectfile.obj”));

步驟二:通過物件輸出流的writeObject()方法寫物件:

out.writeObject(“Hello”);
out.writeObject(new Date());

5)JDK類庫中反序列化的步驟

步驟一:建立一個物件輸入流,它可以包裝一個其它型別輸入流,如檔案輸入流:

ObjectInputStream in = new ObjectInputStream(new fileInputStream(“D:\\objectfile.obj”));

步驟二:通過物件輸出流的readObject()方法讀取物件:

String obj1 = (String)in.readObject();
Date obj2 = (Date)in.readObject();

說明:為了正確讀取資料,完成反序列化,必須保證向物件輸出流寫物件的順序與從物件輸入流中讀物件的順序一致。
為了更好地理解Java序列化與反序列化,選擇方法一編碼實現。
Student類定義如下:

/**
 * 實現了序列化介面的學生類
 */
public class Student implements Serializable {
    private String name;
    private char sex;
    private int year;
    private double gpa;

    public Student() {
    }
    public Student(String name,char sex,int year,double gpa) {
        this.name = name;
        this.sex = sex;
        this.year = year;
        this.gpa = gpa;
    }

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

    public void setSex(char sex) {
        this.sex = sex;
    }

    public void setYear(int year) {
        this.year = year;
    }

    public void setGpa(double gpa) {
        this.gpa = gpa;
    }

    public String getName() {
        return this.name;
    }

    public char getSex() {
        return this.sex;
    }

    public int getYear() {
        return this.year;
    }

    public double getGpa() {
        return this.gpa;
    }
}

把Student類的物件序列化到檔案/Users/sschen/Documents/student.txt,並從該檔案中反序列化,向console顯示結果。程式碼如下:

public class UserStudent {
    public static void main(String[] args) {
        Student st = new Student("Tom",'M',20,3.6);
        File file = new File("/Users/sschen/Documents/student.txt");
        try {
            file.createNewFile();
        }
        catch(IOException e) {
            e.printStackTrace();
        }
        try {
            //Student物件序列化過程
            FileOutputStream fos = new FileOutputStream(file);
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(st);
            oos.flush();
            oos.close();
            fos.close();

            //Student物件反序列化過程
            FileInputStream fis = new FileInputStream(file);
            ObjectInputStream ois = new ObjectInputStream(fis);
            Student st1 = (Student) ois.readObject();
            System.out.println("name = " + st1.getName());
            System.out.println("sex = " + st1.getSex());
            System.out.println("year = " + st1.getYear());
            System.out.println("gpa = " + st1.getGpa());
            ois.close();
            fis.close();
        }
        catch(ClassNotFoundException e) {
            e.printStackTrace();
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}

而檢視檔案/Users/sschen/Documents/student.txt,其內儲存的內容並不是可以容易閱讀的內容:

aced 0005 7372 001f 636f 6d2e 7373 6368
656e 2e53 6572 6961 6c69 7a61 626c 652e
5374 7564 656e 74f1 5dbd a4a0 3472 4d02
0004 4400 0367 7061 4300 0373 6578 4900
0479 6561 724c 0004 6e61 6d65 7400 124c
6a61 7661 2f6c 616e 672f 5374 7269 6e67
3b78 7040 0ccc cccc cccc cd00 4d00 0000
1474 0003 546f 6d

四、序列化的必要條件

1、必須是同包,同名。
2、serialVersionUID必須一致。有時候兩個類的屬性稍微不一致的時候,可以通過將此屬性寫死值,實現序列化和反序列化。

五、序列化高階,使用情境分析

1. 序列化ID問題

情境:兩個客戶端 A 和 B 試圖通過網路傳遞物件資料,A 端將物件 C 序列化為二進位制資料再傳給 B,B 反序列化得到 C。
問題:C 物件的全類路徑假設為 com.inout.Test,在 A 和 B 端都有這麼一個類檔案,功能程式碼完全一致。也都實現了 Serializable 介面,但是反序列化時總是提示不成功。
解決:虛擬機器是否允許反序列化,不僅取決於類路徑和功能程式碼是否一致,一個非常重要的一點是兩個類的序列化 ID 是否一致(就是 private static final long serialVersionUID = 1L)。下面的程式碼中,雖然兩個類的功能程式碼完全一致,但是序列化 ID 不同,他們無法相互序列化和反序列化。

簡單來說,Java的序列化機制是通過在執行時判斷類的serialVersionUID來驗證版本一致性的。在進行反序列化時,JVM會把傳來的位元組流中的serialVersionUID與本地相應實體(類)的serialVersionUID進行比較,如果相同就認為是一致的,可以進行反序列化,否則就會出現序列化版本不一致的異常。
當實現java.io.Serializable介面的實體(類)沒有顯式地定義一個名為serialVersionUID,型別為long的變數時,Java序列化機制會根據編譯的class自動生成一個serialVersionUID作序列化版本比較用,這種情況下,只有同一次編譯生成的class才會生成相同的serialVersionUID 。

如果我們不希望通過編譯來強制劃分軟體版本,即實現序列化介面的實體能夠相容先前版本,未作更改的類,就需要顯式地定義一個名為serialVersionUID,型別為long的變數,不修改這個變數值的序列化實體都可以相互進行序列化和反序列化。
相同功能程式碼不同序列化 ID 的類對比,程式碼如下:

public class SerialVersionIDA implements Serializable {
    private static final long serialVersionUID=1L;

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    public SerialVersionIDA() {
    }
    public SerialVersionIDA(String name) {
        this.name = name;
    }
}
public class SerialVersionIDA implements Serializable {
    private static final long serialVersionUID=2L;

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    public SerialVersionIDA() {
    }
    public SerialVersionIDA(String name) {
        this.name = name;
    }
}

使用serialVersionUID為1L的類進行序列化,而使用serialVersionUID為2L的類進行反序列化,會提示異常,異常內容為:

java.io.InvalidClassException: com.sschen.Serializable.SerialVersionIDA; local class incompatible: stream classdesc serialVersionUID = 2, local class serialVersionUID = 1
    at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:616)
    at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1630)
    at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1521)
    at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1781)
    at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1353)
    at java.io.ObjectInputStream.readObject(ObjectInputStream.java:373)
    at com.sschen.Serializable.SerialVersionTest.main(SerialVersionTest.java:30)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)

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

特性使用案例
讀者應該聽過 Façade 模式,它是為應用程式提供統一的訪問介面,案例程式中的 Client 客戶端使用了該模式,案例程式結構圖下圖所示。

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. 靜態變數序列化

public class SerialStaticTest implements Serializable {

    private static final long serialVersionUID = 1L;

    public static int staticVar = 5;

    public static void main(String[] args) {
        try {
            File file = new File("/Users/sschen/Documents/student.txt");
            try {
                file.createNewFile();
            }
            catch(IOException e) {
                e.printStackTrace();
            }
            //初始時staticVar為5
            ObjectOutputStream out = new ObjectOutputStream(
                    new FileOutputStream(file));
            out.writeObject(new SerialStaticTest());
            out.close();

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

            ObjectInputStream oin = new ObjectInputStream(new FileInputStream(file));
            SerialStaticTest t = (SerialStaticTest) 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();
        }
    }
}

上面程式碼中的 main 方法,將物件序列化儲存到檔案後,修改靜態變數的數值,再將序列化物件讀取出來,然後通過讀取出來的物件獲得靜態變數的數值並打印出來。依照程式碼,這個 System.out.println(t.staticVar) 語句輸出的是 10 還是 5 呢?
最後的輸出是 10,對於無法理解的讀者認為,列印的 staticVar 是從讀取的物件裡獲得的,應該是儲存時的狀態才對。之所以列印 10 的原因在於序列化時,並不儲存靜態變數,這其實比較容易理解,序列化儲存的是物件的狀態,靜態變數屬於類的狀態,因此 序列化並不儲存靜態變數。

3. 父類的序列化與 Transient 關鍵字

情境:一個子類實現了 Serializable 介面,它的父類都沒有實現 Serializable 介面,序列化該子類物件,然後反序列化後輸出父類定義的某變數的數值,該變數數值與序列化時的數值不同。
解決:要想將父類物件也序列化,就需要讓父類也實現Serializable 介面。如果父類不實現的話的,就 需要有預設的無參的建構函式。在父類沒有實現 Serializable 介面時,虛擬機器是不會序列化父物件的,而一個 Java 物件的構造必須先有父物件,才有子物件,反序列化也不例外。所以反序列化時,為了構造父物件,只能呼叫父類的無參建構函式作為預設的父物件。因此當我們取父物件的變數值時,它的值是呼叫父類無參建構函式後的值。如果你考慮到這種序列化的情況,在父類無參建構函式中對變數進行初始化,否則的話,父類變數值都是預設宣告的值,如 int 型的預設是 0,string 型的預設是 null。
Transient 關鍵字的作用是控制變數的序列化,在變數宣告前加上該關鍵字,可以阻止該變數被序列化到檔案中,在被反序列化後,transient 變數的值被設為初始值,如 int 型的是 0,物件型的是 null。
特性使用案例
我們熟悉使用 Transient 關鍵字可以使得欄位不被序列化,那麼還有別的方法嗎?根據父類物件序列化的規則,我們可以將不需要被序列化的欄位抽取出來放到父類中,子類實現 Serializable 介面,父類不實現,根據父類序列化規則,父類的欄位資料將不被序列化,形成類圖如圖 2 所示。

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

4. 對敏感欄位加密

情境:伺服器端給客戶端傳送序列化物件資料,物件中有一些資料是敏感的,比如密碼字串等,希望對該密碼欄位在序列化時,進行加密,而客戶端如果擁有解密的金鑰,只有在客戶端進行反序列化時,才可以對密碼進行讀取,這樣可以一定程度保證序列化物件的資料安全。
解決:在序列化過程中,虛擬機器會試圖呼叫物件類裡的 writeObject 和 readObject 方法,進行使用者自定義的序列化和反序列化,如果沒有這樣的方法,則預設呼叫是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。使用者自定義的 writeObject 和 readObject 方法可以允許使用者控制序列化的過程,比如可以在序列化的過程中動態改變序列化的數值。基於這個原理,可以在實際應用中得到使用,用於敏感欄位的加密工作,下面的程式碼展示了這個過程。

public class SerialPwdTest implements Serializable {
    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 {
            ObjectOutputStream.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 {
            ObjectInputStream.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) {
        File file = new File("/Users/sschen/Documents/student.txt");
        try {
            file.createNewFile();
        }
        catch(IOException e) {
            e.printStackTrace();
        }
        try {
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(file));
            out.writeObject(new SerialPwdTest());
            out.close();

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

SerialPwdTest的 writeObject 方法中,對密碼進行了加密,在加密後進行序列化儲存到檔案中,在 readObject 中則在讀取到密碼後,對 password 進行解密,只有擁有金鑰的客戶端,才可以正確的解析出密碼,確保了資料的安全。上面程式碼的執行結果為:

原密碼:pass
加密後的密碼encryption
要解密的字串:encryption
解密後的字串:pass

特性使用案例
RMI 技術是完全基於 Java 序列化技術的,伺服器端介面呼叫所需要的引數物件來至於客戶端,它們通過網路相互傳輸。這就涉及 RMI 的安全傳輸的問題。一些敏感的欄位,如使用者名稱密碼(使用者登入時需要對密碼進行傳輸),我們希望對其進行加密,這時,就可以採用本節介紹的方法在客戶端對密碼進行加密,伺服器端進行解密,確保資料傳輸的安全性。

5. 序列化儲存規則

情境:問題程式碼如清單 4 所示。
清單 4. 儲存規則問題程式碼

public class SerialSaveTest implements Serializable {
    public static void main(String[] args) {
        File file = new File("/Users/sschen/Documents/student.txt");
        try {
            file.createNewFile();
        }
        catch(IOException e) {
            e.printStackTrace();
        }
        try {
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(file));
            SerialSaveTest test = new SerialSaveTest();
            //試圖將物件兩次寫入檔案
            out.writeObject(test);
            out.flush();
            System.out.println(file.length());
            out.writeObject(test);
            out.close();
            System.out.println(file.length());

            ObjectInputStream oin = new ObjectInputStream(new FileInputStream(file));
            //從檔案依次讀出兩個檔案
            SerialSaveTest t1 = (SerialSaveTest) oin.readObject();
            SerialSaveTest t2 = (SerialSaveTest) oin.readObject();
            oin.close();

            //判斷兩個引用是否指向同一個物件
            System.out.println(t1 == t2);
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

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

59
64
true

我們看到,第二次寫入物件時檔案只增加了 5 位元組,並且兩個物件是相等的,這是為什麼呢?
解答:Java 序列化機制為了節省磁碟空間,具有特定的儲存規則,當寫入檔案的為同一物件時,並不會再將物件的內容進行儲存,而只是再次儲存一份引用,上面增加的 5 位元組的儲存空間就是新增引用和一些控制資訊的空間。反序列化時,恢復引用關係,使得清單 3 中的 t1 和 t2 指向唯一的物件,二者相等,輸出 true。該儲存規則極大的節省了儲存空間。
特性案例分析
檢視清單 5 的程式碼。
清單 5. 案例程式碼

public class SerialSaveTest implements Serializable {
    private int id;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public static void main(String[] args) {
        File file = new File("/Users/sschen/Documents/student.txt");
        try {
            file.createNewFile();
        }
        catch(IOException e) {
            e.printStackTrace();
        }
        try {
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(file));
            SerialSaveTest test = new SerialSaveTest();
            test.setId(1);
            //試圖將物件兩次寫入檔案
            out.writeObject(test);
            out.flush();
            System.out.println(file.length());
            test.setId(5);
            out.writeObject(test);
            out.close();
            System.out.println(file.length());

            ObjectInputStream oin = new ObjectInputStream(new FileInputStream(file));
            //從檔案依次讀出兩個檔案
            SerialSaveTest t1 = (SerialSaveTest) oin.readObject();
            SerialSaveTest t2 = (SerialSaveTest) oin.readObject();
            oin.close();

            //判斷兩個引用是否指向同一個物件
            System.out.println(t1 == t2);

            System.out.println(t1.getId());
            System.out.println(t2.getId());
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

清單 4 的目的是希望將 test 物件兩次儲存到/Users/sschen/Documents/student.txt檔案中,寫入一次以後修改物件屬性值再次儲存第二次,然後從/Users/sschen/Documents/student.txt中再依次讀出兩個物件,輸出這兩個物件的 i 屬性值。案例程式碼的目的原本是希望一次性傳輸物件修改前後的狀態。
結果兩個輸出的都是 1, 原因就是第一次寫入物件以後,第二次再試圖寫的時候,虛擬機器根據引用關係知道已經有一個相同物件已經寫入檔案,因此只儲存第二次寫的引用,所以讀取時,都是第一次儲存的物件。讀者在使用一個檔案多次 writeObject 需要特別注意這個問題