談談我對Serializable接口的理解

二哥學java 發佈 2022-04-25T17:03:18.910998+00:00

網上找了一些博客看過之後,知道這個接口的作用是實現序列化。Serializable接口是一個標記接口 ,不用實現任何方法,標記當前類對象是可以序列化的,是給JVM看的。

Serializable接口的理解

1.序列化介紹

查看 官方文檔 就會發現 Serializable接口中一個成員函數或者成員變量也沒有。那麼這個接口的作用是什麼呢。網上找了一些博客看過之後,知道這個接口的作用是實現序列化。

序列化:對象的壽命通常隨著生成該對象的程序的終止而終止,有時候需要把在內存中的各種對象的狀態(也就是實例變量,不是方法)保存下來,並且可以在需要時再將對象恢復。雖然你可以用你自己的各種各樣的方法來保存對象的狀態,但是Java給你提供一種應該比你自己的好的保存對象狀態的機制,那就是序列化。
[格式的轉變] 轉變前的格式是對象狀態信息,轉變後的格式是「可以存儲或傳輸的形式」
[轉變的目的] 轉變成字節流後的目的主要有兩個:1. 存儲到磁碟; 2. 通過網絡進行傳輸
複製代碼

總結:Java 序列化技術可以使你將一個對象的狀態寫入一個Byte 流里(序列化),並且可以從其它地方把該Byte 流里的數據讀出來(反序列化)。Serializable接口是一個標記接口 ,不用實現任何方法,標記當前類對象是可以序列化的,是給JVM看的。

序列化的作用又可以簡單理解為:把內存中的數據存儲到磁碟中的過程

序列化機制允許將這些實現序列化接口的對象轉化為字節序列,這些字節序列可以保證在磁碟上或者網絡傳輸後恢復成原來的對象。序列化就是把對象存儲在JVM以外的地方,序列化機制可以讓對象脫離程序的運行而獨立存在。

例子:people
public class People {
    private Long id;
    public People(Long id) {
        this.id = id;
    }
    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }
    @Override
    public String toString() {
        return "People{" +
                "id=" + id +
                '}';
    }
}
複製代碼
  /**
     * <h1>序列化和反序列化 People 對象</h1>
     */
    private static void testSerializablePeople() throws Exception {

        // 序列化的步驟
​
        // 用於存儲序列化的文件,這裡的java_下劃線僅僅為了說明是java序列化對象,沒有任何其他含義
        File file = new File("/tmp/people_10.java_");
        if (!file.exists()) {
            // 1,先得到文件的上級目錄,並創建上級目錄
            file.getParentFile().mkdirs();
            try {
                // 2,再創建文件
                file.createNewFile();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        People p = new People(10L);
​
        // 創建一個輸出流
        ObjectOutputStream oos = new ObjectOutputStream(
                new FileOutputStream(file)
        );
        // 輸出可序列化對象
        oos.writeObject(p);
        // 關閉輸出流
        oos.close();
​
        // 反序列化的步驟
​
        // 創建一個輸入流
        ObjectInputStream ois = new ObjectInputStream(
                new FileInputStream(file)
        );
        // 得到反序列化的對象,這裡可以強轉為People類型
        Object newPerson = ois.readObject();
        // 關閉輸入流
        ois.close();
​
        System.out.println(newPerson);
    }
複製代碼

tips:

1.靜態成員變量是不能被序列化的——序列化是針對對象屬性的,而靜態成員變量是屬於類的。

2.當一個父類實現序列化,子類就會自動實現序列化,不需要顯式實現Serializable接口。

3.當一個對象的實例變量引用其他對象,序列化該對象時也把引用對象進行序列化。

應用場景:當需要將一個對象存儲起來,如資料庫,文檔,或在網絡中傳輸。那麼需要序列化,這樣再次讀取 的時候能夠直接獲取為對象,而不是字符串。

  • Java的JavaBeans:Bean的狀態信息通常是在設計時配置的,Bean的狀態信息必須被存起來,以便當程序運行時能恢復這些狀態信息,這需要將對象的狀態保存到文件中,而後能夠通過讀入對象狀態來重新構造對象,恢復程序狀態。例如Java.io包有兩個序列化對象的類。ObjectOutputStream負責將對象寫入字節流,ObjectInputStream從字節流重構對象。
//指定ID很重要,當ID變了,就不能反序列化,不指定id,java就會根據類的信息自動生成一個,因此當類變化時ID也變了,導致不能反序列化舊對象。
//但是對象新曾屬性也能反序列化之前的屬性
private static final long serialVersionUID = 1L;
複製代碼

2.繼承問題

測試:去掉父類People的implements Serializable,讓父類不實現序列化接口,子類Worker實現序列化接口

public class Worker extends People implements Serializable {
​
    private String name;
    private Integer age;
​
    public Worker(Long id, String name, Integer age) {
        super(id);
        this.name = name;
        this.age = age;
    }
}
複製代碼
/**
     * <h2>子類實現序列化, 父類不實現序列化</h2>
     * */
    private static void testSerizableWorker() throws Exception {
​
        File file = new File("/tmp/worker_10.java_");
        if (!file.exists()) {
            // 1,先得到文件的上級目錄,並創建上級目錄
            file.getParentFile().mkdirs();
            try {
                // 2,再創建文件
                file.createNewFile();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        Worker p = new Worker(10L, "lcy", 18);
​
        // 創建一個輸出流
        ObjectOutputStream oos = new ObjectOutputStream(
                new FileOutputStream(file)
        );
        // 輸出可序列化對象
        oos.writeObject(p);
        // 關閉輸出流
        oos.close();
​
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        Object newWorker = ois.readObject(); // 父類沒有序列化的時候,需要調用父類的無參數構造方法
        ois.close();
        System.out.println(newWorker);
複製代碼

測試運行:

結果卻發現列印的不是Worker,而是父類People,因為子類沒有實現toString而調用父類的toString,所以列印了People對象,至於父類成員變量id為什麼是null,原因如下:

一個子類實現了 Serializable 接口,它的父類都沒有實現 Serializable接口,序列化該子類對象。要想反序列化後輸出父類定義的某變量的數值,就需要讓父類也實現Serializable接口或者父類有默認的無參的構造函數。
​
  在父類沒有實現Serializable 接口時,虛擬機是不會序列化父對象的,而一個 Java對象的構造必須先有父對象,才有子對象,反序列化也不例外。所以反序列化時,為了構造父對象,只能調用父類的無參構造函數作為默認的父對象。因此當我們取父對象的變量值時,它的值是調用父類無參構造函數後的值,如果在父類無參構造函數中沒有對變量賦值,那麼父類成員變量值都是默認值,如這裡的Long型就是null。
​
  根據以上特性,我們可以將不需要被序列化的欄位抽取出來放到父類中,子類實現 Serializable接口,父類不實現Serializable接口但提供一個空構造方法,則父類的欄位數據將不被序列化。
複製代碼

總結: 子類實現Serializable接口,父類沒有實現,子類可以序列化!! 這種情況父類一定要提供空構造方法,不要忘了子類的toString方法!

3.類中存在引用對象的情況

類中存在引用對象,這個類對象在什麼情況下可以實現序列化?

//引用people對象,people沒有實現序列化接口
public class Combo implements Serializable {
​
    private int id;
    private People people;
​
    public Combo(int id, People people) {
        this.id = id;
        this.people = people;
    }
​
    public int getId() {
        return id;
    }
​
    public void setId(int id) {
        this.id = id;
    }
​
    public People getPeople() {
        return people;
    }
​
    public void setPeople(People people) {
        this.people = people;
    }
    
    @Override
    public String toString() {
        return "Combo{" +
                "id=" + id +
                ", people=" + people +
                '}';
    }
}
複製代碼
File file = new File("/tmp/combo_10.java_");
        if (!file.exists()) {
            // 1,先得到文件的上級目錄,並創建上級目錄
            file.getParentFile().mkdirs();
            try {
                // 2,再創建文件
                file.createNewFile();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        Combo p = new Combo(1, new People(10L));
​
        // 創建一個輸出流
        ObjectOutputStream oos = new ObjectOutputStream(
                new FileOutputStream(file)
        );
        // 輸出可序列化對象
        oos.writeObject(p);
        // 關閉輸出流
        oos.close();
​
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        Object newCombo = ois.readObject();
        ois.close();
        System.out.println(newCombo);
    }
複製代碼

運行結果:

總結:   一個類裡面所有的屬性必須是可序列化的,這個類才能順利的序列化。比如,類中存在引用對象,那麼這個引用對象必須是可序列化的,這個類才能序列化。

4.同一個對象多次序列化之間有屬性更新,前後的序列化有什麼區別?

結論:當對象第一次序列化成功後,後續這個對象屬性即使有修改,也不會對後面的序列化造成成影響。

原因:是序列化算法的原因,所有要序列化的對象都有一個序列化的編碼號,當試圖序列化一個對象,會檢查這個對象是否已經序列化過,若從未序列化過,才會序列化為字節序列去輸出。若已經序列化過,則會輸出一個編碼符號,不會重複序列化一個對象。如下

5.Serializable 在序列化和反序列化過程中大量使用了反射,因此其過程會產生的大量的內存碎片

6.serialVersionUID與兼容性問題

serialVersionUID作用:這個值是用於確保類序列化與反序列化的兼容性問題的,如果序列化和反序列化過程中這兩個值不一樣,那麼將導致序列化失敗,標識serialVersionUID,是為了反序列化時能正確標識。

生成:

兼容性問題:在反序列化階段,檢測到 serialVersionUID 不一致導致

serialVersionUID 發生改變有三種情況:

!!!JVM 規範強烈建議我們手動聲明一個版本號,這個數字可以是隨機的,只要固定不變就可以。同時最好是 private 和 final 的,儘量保證不變。
複製代碼

7.自定義序列化——Externalizable 接口

Serializable 接口內部序列化是 JVM 自動實現的,如果我們想自定義序列化過程,就可以使用以上這個接口來實現,它內部提供兩個接口方法:

public interface Externalizable extends Serializable {
    //將要序列化的對象屬性通過 var1.wrietXxx() 寫入到序列化流中
    void writeExternal(ObjectOutput var1) throws IOException;
        //將要反序列化的對象屬性通過 var1.readXxx() 讀出來
    void readExternal(ObjectInput var1) throws IOException, ClassNotFoundException;
}
複製代碼
  • Externalizable 的使用:
  • public class Person implements Externalizable { ​ private static final long serialVersionUID = -7424420983806112577L; private String name; private int age; /* 實現了Externalizable這個接口需要提供無參構造,在反序列化時會檢測 */ public Person() { System.out.println("Person: empty"); } ​ public Person(String name, int age) { this.name = name; this.age = age; } ​ @Override public void writeExternal(ObjectOutput out) throws IOException { System.out.println("person writeExternal..."); out.writeObject(name); out.writeInt(age); } ​ @Override public void readExternal(ObjectInput in) throws ClassNotFoundException, IOException { System.out.println("person readExternal..."); ​ name = (String) in.readObject(); age = in.readInt(); } ​ @Override public String toString() { return "Person{" + "name='" + name + ''' + ", age=" + age + '}'; } } 複製代碼

7.1 防止序列化關鍵字

對於不想進行序列化的變量,使用transient關鍵字修飾。

transient關鍵字的作用是:阻止實例中那些用此關鍵字修飾的的變量序列化;當對象被反序列化時,被transient修飾的變量值不會被持久化和恢復。transient只能修飾變量,不能修飾類和方法。

8.Java 的序列化步驟與數據結構分析

一般操作:

  • 將對象實例相關的類元數據輸出。
  • 遞歸地輸出類的超類描述直到不再有超類。
  • 類元數據完了以後,開始從最頂層的超類開始輸出對象實例的實際數據值。
  • 從上至下遞歸輸出實例的數據
  • writeObejct 的過程就是上面的4個步驟

8.1readObject/writeObject原理分析

writeObject 原理分析:

//ObjectOutputStream 構造函數:
public ObjectOutputStream(OutputStream out) throws IOException {
    verifySubclass();
    bout = new BlockDataOutputStream(out);//①1
    handles = new HandleTable(10, (float) 3.00);
    subs = new ReplaceTable(10, (float) 3.00);
    enableOverride = false;//②2
    writeStreamHeader();//③3
    bout.setBlockDataMode(true);
    if (extendedDebugInfo) {
        debugInfoStack = new DebugTraceInfoStack();
    } else {
        debugInfoStack = null;
    }
}
複製代碼

①bout:用於寫入一些類元數據還有對象中基本數據類型的值,在下面會分析。

②enableOverride :false 表示不支持重寫序列化過程,如果為 true ,那麼需要重寫 writeObjectOverride 方法。這個一般不用管它。

③writeStreamHeader() 寫入頭信息 ↓

protected void writeStreamHeader() throws IOException {
    bout.writeShort(STREAM_MAGIC);//①聲明使用了序列化協議,bout 就是一個流,將對應的頭數據寫入該流中
    bout.writeShort(STREAM_VERSION);//② 指定序列化協議版本
}
複製代碼

writeObject :

public final void writeObject(Object obj) throws IOException {
    if (enableOverride) {//一般不會走這裡,因為在 ObjectOutputStream 構造設置為 false 了
        writeObjectOverride(obj);
        return;
    }
    try {//代碼會執行這裡
        writeObject0(obj, false);
    } catch (IOException ex) {
        ...
    }
}
複製代碼

writeObject0:

private void writeObject0(Object obj, boolean unshared)
    throws IOException
{
    ...
    try {
     
        Object orig = obj;
        Class<?> cl = obj.getClass();
        ObjectStreamClass desc;
       
      /** 1. lookup 函數用於查找當前類的 ObjectStreamClass ,它是用於描述一個類的結構信息的,通過它就可以獲取對象及其對象屬性的相關信息,並且它內部持有該對象的父類的 ObjectStreamClass 實例。其內部大量使用了反射。**/
        desc = ObjectStreamClass.lookup(cl, true);
        ...
        //②2 根據 obj 的類型去執行序列化操作,如果不符合序列化要求,那麼會③位置拋出 NotSerializableException 異常。(如果一個需要序列化的對象的某個屬性沒有實現序列化接口,那麼就會此處拋出異常)
        if (obj instanceof Class) {
            writeClass((Class) obj, unshared);
        } else if (obj instanceof ObjectStreamClass) {
            writeClassDesc((ObjectStreamClass) obj, unshared);
        // END Android-changed:  Make Class and ObjectStreamClass replaceable.
        } else if (obj instanceof String) {
            writeString((String) obj, unshared);
        } else if (cl.isArray()) {
            writeArray(obj, desc, unshared);
        } else if (obj instanceof Enum) {
            writeEnum((Enum<?>) obj, desc, unshared);
        } else if (obj instanceof Serializable) {
            writeOrdinaryObject(obj, desc, unshared);
        } else {
                //③3
            if (extendedDebugInfo) {
                throw new NotSerializableException(
                    cl.getName() + "\n" + debugInfoStack.toString());
            } else {
                throw new NotSerializableException(cl.getName());
            }
        }
    } 
    ...
}
複製代碼

writeOrdinaryObject:

private void writeOrdinaryObject(Object obj,
                                 ObjectStreamClass desc,
                                 boolean unshared)
    throws IOException
{
    ...
    try {
        desc.checkSerialize();
        //①寫入類的元數據,TC_OBJECT. 聲明這是一個新的對象,如果寫入的是一個 String 類型的數據,那麼就需要 TC_STRING 這個標識。
        bout.writeByte(TC_OBJECT);
        //②writeClassDesc 方法主要作用就是自上而下(從父類寫到子類,注意只會遍歷那些實現了序列化接口的類)寫入描述信息。該方法內部會不斷的遞歸調用,從這裡可以知道,序列化過程需要額外的寫入很多數據,例如描述信息,類數據等,因此序列化後占用的空間肯定會更大。
        writeClassDesc(desc, false);
        handles.assign(unshared ? null : obj);
        //③desc.isExternalizable() 判斷需要序列化的對象是否實現了 Externalizable 接口,這個在上面已經演示過怎麼使用的,在序列化過程就是在這個地方進行判斷的。如果有,那麼序列化的過程就會由程式設計師自己控制了哦,writeExternalData 方法會回調,在這裡就可以編寫需要序列化的數據。
        if (desc.isExternalizable() && !desc.isProxy()) {
            writeExternalData((Externalizable) obj);
        } else {
                //④writeSerialData 在沒有實現 Externalizable 接口時,就執行這個方法
            writeSerialData(obj, desc);
        }
    } finally {
        if (extendedDebugInfo) {
            debugInfoStack.pop();
        }
    }
}
複製代碼

writeSerialData:

private void writeSerialData(Object obj, ObjectStreamClass desc)
    throws IOException
{
        //①  desc.getClassDataLayout 會返回 ObjectStreamClass.ClassDataSlot[] ,我們來看看 ClassDataSlot 類,可以看到它是封裝了 ObjectStreamClass 而已,所以我們就簡單的認為 ① 這一步就是用於返回序列化對象及其父類的 ClassDataSlot[] 數組,我們可以從 ClassDataSlot 中獲取對應 ObjectStreamClass 描述信息。
    ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();
    for (int i = 0; i < slots.length; i++) {
    
        ObjectStreamClass slotDesc = slots[i].desc;
        
        if (slotDesc.hasWriteObjectMethod()) {
    //②開始遍歷返回的數組,slotDesc 這個我們就簡單將其看成對一個對象的描述吧。hasWriteObjectMethod 表示的是什麼呢?這個其實就是你要序列化這個對象是否有 writeObject 這個 private 方法,注意這個方法並不是任何接口的方法,而是我們手動寫的,--可以參考 ArrayList 代碼,它內部就有這個方法。作用是自定義序列化過程
            PutFieldImpl oldPut = curPut;
            curPut = null;
            SerialCallbackContext oldContext = curContext;
            if (extendedDebugInfo) {
                debugInfoStack.push(
                    "custom writeObject data (class "" +
                    slotDesc.getName() + "")");
            }
            try {
                curContext = new SerialCallbackContext(obj, slotDesc);
                bout.setBlockDataMode(true);
                slotDesc.invokeWriteObject(obj, this);
                bout.setBlockDataMode(false);
                bout.writeByte(TC_ENDBLOCKDATA);
            } finally {
                curContext.setUsed();
                curContext = oldContext;
                if (extendedDebugInfo) {
                    debugInfoStack.pop();
                }
            }
            curPut = oldPut;
        } else {
            defaultWriteFields(obj, slotDesc);//③defaultWriteFields 這個方法就是 JVM 自動幫我們序列化了,
        }
    }
}
複製代碼

writeObject:不像實現 Externalizable 接口那樣,自己完全去自定義序列化數據。

private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
    //執行 JVM 默認的序列化操作
    s.defaultWriteObject();
    //手動序列化 arr  前面30個元素
    for (int i = 0; i < 30; i++) {
        s.writeObject(arr[i]);
    }
}
複製代碼

defaultWriteFields:

private void defaultWriteFields(Object obj, ObjectStreamClass desc)
    throws IOException
{
    Class<?> cl = desc.forClass();
​
    desc.checkDefaultSerialize();
    int primDataSize = desc.getPrimDataSize();
    if (primVals == null || primVals.length < primDataSize) {
        primVals = new byte[primDataSize];
    }
    desc.getPrimFieldValues(obj, primVals);
    //①1① 寫入基本數據類型的數據
    bout.write(primVals, 0, primDataSize, false);
    ObjectStreamField[] fields = desc.getFields(false);
    Object[] objVals = new Object[desc.getNumObjFields()];
    int numPrimFields = fields.length - objVals.length;
    desc.getObjFieldValues(obj, objVals);
    
    //②2②寫入引用數據類型的數據,這裡最終又調用到了 writeObject0() 方法
    for (int i = 0; i < objVals.length; i++) {
        if (extendedDebugInfo) {
            debugInfoStack.push(
                "field (class "" + desc.getName() + "", name: "" +
                fields[numPrimFields + i].getName() + "", type: "" +
                fields[numPrimFields + i].getType() + "")");
        }
        try {
            writeObject0(objVals[i],
                         fields[numPrimFields + i].isUnshared());
        } finally {
            if (extendedDebugInfo) {
                debugInfoStack.pop();
            }
        }
    }
}
複製代碼

9.作用及意義

為什麼要進行序列化?↓

9.1 一些不夠完整的解釋

  • 跨語言:某種程式語言(Java)在磁碟上存儲的數據,有可能被別的程式語言(C++)讀取
  • 跨平台:這個問題在網絡傳輸時更為突出,在A機器上可能為小端序,在B機器上則為大端序

9.2 完整的解釋

序列化其實主要是進行了數據格式的轉換,即從內存格式轉換為磁碟格式。進行該轉換還有兩個很重要的原因:去地址和節省空間。

去地址:

對於一些包含地址或引用的數據結構(如二叉樹),對象第一次在內存中的地址,和數據落盤後重新加載到內存中的地址,極有可能是不同的。
因此,需要對這種數據結構的對象,進行一些「去地址」的操作。該操作往往便是通過「序列化」來完成。
​
可能有人會想到在將對象落盤時,同時記錄下對象中的內存地址。第二次加載對象時,按照之前記錄的地址進行內存分配。
但內存一般是由多個應用共享的,第二次加載對象時,之前地址對應的內存空間可能已經被占用了。
複製代碼

節省空間:(直接將內存中的數據複製到硬碟和序列化的區別)

複製操作對於一些簡單的數據結構(尤其是內存連續的數據結構)是可行的,比如說一個byte。在不考慮字節順序(大/小端序)的前提下,一個int也是可行的。實際上,序列化操作對於這些簡單數據結構也是這麼複製處理的。
但對於二叉樹這種複雜的數據結構,複製操作便不可行了。
​
現代作業系統的內存管理往往了採用了「內存分頁」、「邏輯空間」的機制。邏輯空間連續的頁面在物理空間中往往是分散的。
對於二叉樹這種複雜的數據結構,樹中不同的節點可能存儲在不同的內存頁面中,這些頁面分散在內存的不同地方。
如下圖所示,一棵二叉樹的三個節點分別對應內存中編號為1,10,15的三塊內存空間。如果進行簡單複製,需要將1~15編號的整塊內存數據複製到磁碟中,即使2,3,4等編號的內存空間跟當前二叉樹無關。這便造成了嚴重的磁碟空間浪費。
複製代碼
採用「序列化」的方法的目的之一,也是為了解決「磁碟空間浪費」的問題。與磁碟層面的管理不同,「序列化」相當於在應用層面進行了管理,將數據更緊密地存儲在一起,如下圖所示:
複製代碼

9.3總結——序列化的主要目的

  • 實現數據的跨語言使用
  • 實現數據的跨平台使用
  • 數據去內存地址
  • 降低磁碟存儲空間

10.為什麼Java類需要實現Serializable接口?

例子: 比如在SSM或者SpringBoot開發的項目中,幾乎都是基於Restful風格,HTTP+JSON格式進行數據傳輸,在Controller層返回數據響應到瀏覽器之前,會將數據轉換為JSON字符串,那麼你思考過為何要這麼做嗎,很簡單,因為String字符串底層也實現了序列化,因為後端的數據想要響應給瀏覽器,就必須進行網絡傳輸,也就意味著需要序列化操作。

10.1什麼時候Java類需要實現序列化?

  • 對象序列化可以實現分布式對象。主要應用例如:RMI(即遠程調用Remote Method Invocation)要利用對象序列化運行遠程主機上的服務,就像在本地機上運行對象時一樣。
  • java對象序列化不僅保留一個對象的數據,而且遞歸保存對象引用的每個對象的數據。可以將整個對象層次寫入字節流中,可以保存在文件中或在網絡連接上傳遞。利用對象序列化可以進行對象的"深複製",即複製對象本身及引用的對象本身。序列化一個對象可能得到整個對象序列。
  • 序列化可以將內存中的類寫入文件或資料庫中。比如將某個類序列化後存為文件,下次讀取時只需將文件中的數據反序列化就可以將原先的類還原到內存中。也可以將類序列化為流數據進行傳輸。總的來說就是將一個已經實例化的類轉成文件存儲,下次需要實例化的時候只要反序列化即可將類實例化到內存中並保留序列化時類中的所有變量和狀態。
  • 對象、文件、數據,有許多不同的格式,很難統一傳輸和保存





關鍵字: