Java編程的邏輯 (62) - 神奇的序列化

本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》,由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買,京東自營連接http://item.jd.com/12299018.htmlhtml


在前面幾節,咱們在將對象保存到文件時,使用的是DataOutputStream,從文件讀入對象時,使用的是DataInputStream, 使用它們,須要逐個處理對象中的每一個字段,咱們提到,這種方式比較囉嗦,Java中有一種更爲簡單的機制,那就是序列化。java

簡單來講,序列化就是將對象轉化爲字節流,反序列化就是將字節流轉化爲對象。在Java中,具體如何來使用呢?它是如何實現的?有什麼優缺點?本節就來探討這些問題,咱們先從它的基本用法談起。編程

基本用法
微信

Serializable網絡

要讓一個類支持序列化,只須要讓這個類實現接口java.io.Serializable,Serializable沒有定義任何方法,只是一個標記接口。好比,對於57節提到的Student類,爲支持序列化,可改成:工具

public class Student implements Serializable {
    String name;
    int age;
    double score;
    
    public Student(String name, int age, double score) {
         ...
    }
    ...
}

聲明實現了Serializable接口後,保存/讀取Student對象就可使用另兩個流了ObjectOutputStream/ObjectInputStream。性能

ObjectOutputStream/ObjectInputStreamthis

ObjectOutputStream是OutputStream的子類,但實現了ObjectOutput接口,ObjectOutput是DataOutput的子接口,增長了一個方法:spa

public void writeObject(Object obj) throws IOException

這個方法可以將對象obj轉化爲字節,寫到流中。代理

ObjectInputStream是InputStream的子類,它實現了ObjectInput接口,ObjectInput是DataInput的子接口,增長了一個方法:

public Object readObject() throws ClassNotFoundException, IOException

這個方法可以從流中讀取字節,轉化爲一個對象。

使用這兩個流,57節介紹的保存學生列表的代碼就能夠變爲:

public static void writeStudents(List<Student> students) throws IOException {
    ObjectOutputStream out = new ObjectOutputStream(
            new BufferedOutputStream(new FileOutputStream("students.dat")));
    try {
        out.writeInt(students.size());
        for (Student s : students) {
            out.writeObject(s);
        }
    } finally {
        out.close();
    }
}

而從文件中讀入學生列表的代碼能夠變爲:

public static List<Student> readStudents() throws IOException,
        ClassNotFoundException {
    ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(
            new FileInputStream("students.dat")));
    try {
        int size = in.readInt();
        List<Student> list = new ArrayList<>(size);
        for (int i = 0; i < size; i++) {
            list.add((Student) in.readObject());
        }
        return list;
    } finally {
        in.close();
    }
}

實際上,只要List對象也實現了Serializable (ArrayList/LinkedList都實現了),上面代碼還能夠進一步簡化,讀寫只須要一行代碼,以下所示:

public static void writeStudents(List<Student> students) throws IOException {
    ObjectOutputStream out = new ObjectOutputStream(
            new BufferedOutputStream(new FileOutputStream("students.dat")));
    try {
        out.writeObject(students);
    } finally {
        out.close();
    }
}

public static List<Student> readStudents() throws IOException,
        ClassNotFoundException {
    ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(
            new FileInputStream("students.dat")));
    try {
        return (List<Student>) in.readObject();
    } finally {
        in.close();
    }
}

是否是很神奇?只要將類聲明實現Serializable接口,而後就可使用ObjectOutputStream/ObjectInputStream直接讀寫對象了。咱們以前介紹的各類類,如String, Date, Double, ArrayList, LinkedList, HashMap, TreeMap等,都實現了Serializable。

複雜對象

上面例子中的Student對象是很是簡單的,若是對象比較複雜呢?好比:

  • 若是a, b兩個對象都引用同一個對象c,序列化後c是保存兩份仍是一份?在反序列化後還能讓a, b指向同一個對象嗎?
  • 若是a, b兩個對象有循環引用呢?即a引用了b,而b也引用了a。

咱們分別來看下。

引用同一個對象

咱們看個簡單的例子,類A和類B都引用了同一個類Common,它們都實現了Serializable,這三個類的定義以下:

class Common implements Serializable {
    String c;

    public Common(String c) {
        this.c = c;
    }
}

class A implements Serializable {
    String a;
    Common common;
    
    public A(String a, Common common) {
        this.a = a;
        this.common = common;
    }

    public Common getCommon() {
        return common;
    }
}

class B implements Serializable {
    String b;
    Common common;
    
    public B(String b, Common common) {
        this.b = b;
        this.common = common;
    }

    public Common getCommon() {
        return common;
    }
}

有三個對象, a, b, c,以下所示:

Common c = new Common("common");
A a = new A("a", c);
B b = new B("b", c);

a和b引用同一個對象c,若是序列化這兩個對象,反序列化後,它們還能指向同一個對象嗎?答案是確定的,咱們看個實驗。

ByteArrayOutputStream bout = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bout);
out.writeObject(a);
out.writeObject(b);
out.close();

ObjectInputStream in = new ObjectInputStream(
        new ByteArrayInputStream(bout.toByteArray()));
A a2 = (A) in.readObject();
B b2 = (B) in.readObject();

if (a2.getCommon() == b2.getCommon()) {
    System.out.println("reference the same object");
} else {
    System.out.println("reference different objects");
}

輸出爲:

reference the same object

這也是Java序列化機制的神奇之處,它能自動處理這種引用同一個對象的狀況。更神奇的是,它還能自動處理循環引用的狀況,咱們來看下。

循環引用

咱們看個例子,有Parent和Child兩個類,它們相互引用,類定義以下:

class Parent implements Serializable {
    String name;
    Child child;
    
    public Parent(String name) {
        this.name = name;
    }
    public Child getChild() {
        return child;
    }
    public void setChild(Child child) {
        this.child = child;
    }
}

class Child implements Serializable {
    String name;
    Parent parent;
    
    public Child(String name) {
        this.name = name;
    }
    public Parent getParent() {
        return parent;
    }
    public void setParent(Parent parent) {
        this.parent = parent;
    }    
} 

定義兩個對象:

Parent parent = new Parent("老馬");
Child child = new Child("小馬");
parent.setChild(child);
child.setParent(parent);

序列化parent, child兩個對象,Java能正確序列化嗎?反序列化後,還能保持原來的引用關係嗎?答案是確定的,咱們看代碼實驗:

ByteArrayOutputStream bout = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bout);
out.writeObject(parent);
out.writeObject(child);
out.close();

ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(
        bout.toByteArray()));
parent = (Parent) in.readObject();
child = (Child) in.readObject();

if (parent.getChild() == child && child.getParent() == parent
        && parent.getChild().getParent() == parent
        && child.getParent().getChild() == child) {
    System.out.println("reference OK");
} else {
    System.out.println("wrong reference");
} 

輸出爲:

reference OK

神奇吧?

定製序列化

默認的序列化機制已經很強大了,它能夠自動將對象中的全部字段自動保存和恢復,但這種默認行爲有時候不是咱們想要的。

好比,對於有些字段,它的值可能與內存位置有關,好比默認的hashCode()方法的返回值,當恢復對象後,內存位置確定變了,基於原內存位置的值也就沒有了意義。還有一些字段,可能與當前時間有關,好比表示對象建立時的時間,保存和恢復這個字段就是不正確的。

還有一些狀況,若是類中的字段表示的是類的實現細節,而非邏輯信息,那默認序列化也是不適合的。爲何不適合呢?由於序列化格式表示一種契約,應該描述類的邏輯結構,而非與實現細節相綁定,綁定實現細節將使得難以修改,破壞封裝。

好比,咱們在容器類中介紹的LinkedList,它的默認序列化就是不適合的,爲何呢?由於LinkedList表示一個List,它的邏輯信息是列表的長度,以及列表中的每一個對象,但LinkedList類中的字段表示的是鏈表的實現細節,如頭尾節點指針,對每一個節點,還有前驅和後繼節點指針等。

那怎麼辦呢?Java提供了多種定製序列化的機制,主要的有兩種,一種是transient關鍵字,另一種是實現writeObject和readObject方法。

將字段聲明爲transient,默認序列化機制將忽略該字段,不會進行保存和恢復。好比,類LinkedList中,它的字段都聲明爲了transient,以下所示:

transient int size = 0;
transient Node<E> first;
transient Node<E> last;

聲明爲了transient,不是說就不保存該字段了,而是告訴Java默認序列化機制,不要自動保存該字段了,能夠實現writeObject/readObject方法來本身保存該字段。

類能夠實現writeObject方法,以自定義該類對象的序列化過程,其聲明必須爲:

private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException

能夠在這個方法中,調用ObjectOutputStream的方法向流中寫入對象的數據。好比,LinkedList使用以下代碼序列化列表的邏輯數據:

private void writeObject(java.io.ObjectOutputStream s)
    throws java.io.IOException {
    // Write out any hidden serialization magic
    s.defaultWriteObject();

    // Write out size
    s.writeInt(size);

    // Write out all elements in the proper order.
    for (Node<E> x = first; x != null; x = x.next)
        s.writeObject(x.item);
}

須要注意的是第一行代碼:

s.defaultWriteObject(); 

這一行是必須的,它會調用默認的序列化機制,默認機制會保存全部沒聲明爲transient的字段,即便類中的全部字段都是transient,也應該寫這一行,由於Java的序列化機制不只會保存純粹的數據信息,還會保存一些元數據描述等隱藏信息,這些隱藏的信息是序列化之因此可以神奇的重要緣由。

與writeObject對應的是readObject方法,經過它自定義反序列化過程,其聲明必須爲:

private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException 

在這個方法中,調用ObjectInputStream的方法從流中讀入數據,而後初始化類中的成員變量。好比,LinkedList的反序列化代碼爲:

private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    // Read in any hidden serialization magic
    s.defaultReadObject();

    // Read in size
    int size = s.readInt();

    // Read in all elements in the proper order.
    for (int i = 0; i < size; i++)
        linkLast((E)s.readObject());
}

注意第一行代碼:

s.defaultReadObject(); 

這一行代碼也是必須的。

序列化的基本原理

稍微總結一下:

  • 若是類的字段表示的就是類的邏輯信息,如上面的Student類,那就可使用默認序列化機制,只要聲明實現Serializable接口便可。
  • 不然的話,如LinkedList,那就可使用transient關鍵字,實現writeObject和readObject來自定義序列化過程。
  • Java的序列化機制能夠自動處理如引用同一個對象、循環引用等狀況。

但,序列化究竟是如何發生的呢?關鍵在ObjectOutputStream的writeObject和ObjectInputStream的readObject方法內。它們的實現都很是複雜,正由於這些複雜的實現才使得序列化看上去很神奇,咱們簡單介紹下其基本邏輯。

writeObject的基本邏輯是:

  • 若是對象沒有實現Serializable,拋出異常NotSerializableException。
  • 每一個對象都有一個編號,若是以前已經寫過該對象了,則本次只會寫該對象的引用,這能夠解決對象引用和循環引用的問題。
  • 若是對象實現了writeObject方法,調用它的自定義方法。
  • 默認是利用反射機制(反射咱們留待後續文章介紹),遍歷對象結構圖,對每一個沒有標記爲transient的字段,根據其類型,分別進行處理,寫出到流,流中的信息包括字段的類型即完整類名、字段名、字段值等。

readObject的基本邏輯是:

  • 不調用任何構造方法。
  • 它本身就至關因而一個獨立的構造方法,根據字節流初始化對象,利用的也是反射機制。
  • 在解析字節流時,對於引用到的類型信息,會動態加載,若是找不到類,會拋出ClassNotFoundException。

版本問題

上面的介紹,咱們忽略了一個問題,那就是版本問題。咱們知道,代碼是在不斷演化的,而序列化的對象多是持久保存在文件上的,若是類的定義發生了變化,那持久化的對象還能反序列化嗎?

默認狀況下,Java會給類定義一個版本號,這個版本號是根據類中一系列的信息自動生成的。在反序列化時,若是類的定義發生了變化,版本號就會變化,與流中的版本號就會不匹配,反序列化就會拋出異常,類型爲java.io.InvalidClassException。

一般狀況下,咱們但願自定義這個版本號,而非讓Java自動生成,一方面是爲了更好的控制,另外一方面是爲了性能,由於Java自動生成的性能比較低,怎麼自定義呢?在類中定義以下變量:

private static final long serialVersionUID = 1L;

在Java IDE如Eclipse中,若是聲明實現了Serializable而沒有定義該變量,IDE會提示自動生成。這個變量的值能夠是任意的,表明該類的版本號。在序列化時,會將該值寫入流,在反序列化時,會將流中的值與類定義中的值進行比較,若是不匹配,會拋出InvalidClassException。

那若是版本號同樣,但實際的字段不匹配呢?Java會分狀況自動進行處理,以儘可能保持兼容性,大概分爲三種狀況:

  • 字段刪掉了:即流中有該字段,而類定義中沒有,該字段會被忽略。
  • 新增了字段:即類定義中有,而流中沒有,該字段會被設爲默認值。
  • 字段類型變了:對於同名的字段,類型變了,會拋出InvalidClassException。 

高級自定義

除了自定義writeObject/readObject方法,Java中還有以下自定義序列化過程的機制:

  • Externalizable接口
  • readResolve方法
  • writeReplace方法 

這些機制實際用到的比較少,咱們簡要說明下。

Externalizable是Serializable的子接口,定義了以下方法:

void writeExternal(ObjectOutput out) throws IOException
void readExternal(ObjectInput in) throws IOException, ClassNotFoundException

與writeObject/readObject的區別是,若是對象實現了Externalizable接口,則序列化過程會由這兩個方法控制,默認序列化機制中的反射等將再也不起做用,再也不有相似defaultWriteObject和defaultReadObject調用,另外一個區別是,反序列化時,會先調用類的無參構造方法建立對象,而後才調用readExternal。默認的序列化機制因爲須要分析對象結構,每每比較慢,經過實現Externalizable接口,能夠提升性能。

readResolve方法返回一個對象,聲明爲:

Object readResolve()  

若是定義了該方法,在反序列化以後,會額外調用該方法,該方法的返回值纔會被當作真正的反序列化的結果。這個方法一般用於反序列化單例對象的場景。

writeReplace也是返回一個對象,聲明爲:

Object writeReplace()

若是定義了該方法,在序列化時,會先調用該方法,該方法的返回值纔會被當作真正的對象進行序列化。

writeReplace和readResolve能夠構成一種所謂的序列化代理模式,這個模式描述在<Effective Java> 第二版78條中,Java容器類中的EnumSet使用了該模式,咱們通常用的比較少,就不詳細介紹了。

序列化特色分析

序列化的主要用途有兩個,一個是對象持久化,另外一個是跨網絡的數據交換、遠程過程調用。

Java標準的序列化機制有不少優勢,使用簡單,可自動處理對象引用和循環引用,也能夠方便的進行定製,處理版本問題等,但它也有一些重要的侷限性:

  • Java序列化格式是一種私有格式,是一種Java語言特有的技術,不能被其餘語言識別,不能實現跨語言的數據交換。
  • Java在序列化字節中保存了不少描述信息,使得序列化格式比較大。
  • Java的默認序列化使用反射分析遍歷對象結構,性能比較低。
  • Java的序列化格式是二進制的,不方便查看和修改。

因爲這些侷限性,實踐中每每會使用一些替代方案。在跨語言的數據交換格式中,XML/JSON是被普遍採用的文本格式,各類語言都有對它們的支持,文件格式清晰易讀,有不少查看和編輯工具,它們的不足之處是性能和序列化大小,在性能和大小敏感的領域,每每會採用更爲精簡高效的二進制方式如ProtoBuf, Thrift, MessagePack等。

小結

本節介紹了Java的標準序列化機制,咱們介紹了它的用法和基本原理,最後分析了它的特色,它是一種神奇的機制,經過簡單的Serializable接口就能自動處理不少複雜的事情,但它也有一些重要的限制,最重要的是不能跨語言。

在接來下的幾節中,咱們來看一些替代方案,包括XML/JSON和MessagePack。

----------------

未完待續,查看最新文章,敬請關注微信公衆號「老馬說編程」(掃描下方二維碼),從入門到高級,深刻淺出,老馬和你一塊兒探索Java編程及計算機技術的本質。用心原創,保留全部版權。

相關文章
相關標籤/搜索