序列化梳理

一、爲什麼需要序列化?

   java進程運行時會把相關的類生成一堆實例,並放入堆棧空間中,如果進程執行結束,那麼內存中的實例對象就會被gc回收。如果想在新的程序中使用之前那個對象,應該怎麼辦?

  遠程接口調用時,兩端在各自的虛擬機中運行,因爲內存是不共享的,那麼入參和返回值如何傳遞?
序列化就是解決這個問題的。雖然內存不共享,但我們可以將對象轉化爲一段字節序列,並放到流中,接下來就交給 I/O,可以存儲在文件中、可以通過網絡傳輸……當我們想用到這些對象的時候,再通過 I/O,從文件、網絡上讀取字節序列,根據這些信息重建對象。而重建對象的過程也叫做“反序列化”。如果沒有 “反序列化”,那麼“序列化”是沒有任何意義的。

  用現實生活中的搬桌子爲例,桌子太大了不能通過比較小的門,我們要把它拆了再運進去,這個拆桌子的過程就是序列化。同理,反序列化就是等我們需要用桌子的時候再把它組合起來,這個過程就是反序列化。

  理解上面的背景知識後,序列化和反序列化概括起來就是下面這張圖:

序列號示意圖

  1. 根據某種序列化算法,將對象序列化。(結果可能是:字節序列、json串等,取決於使用的序列化算法)
  2. 將序列化的結果通過流寫入到載體中 (文件、數據庫、redis、網絡等 )
  3. 當要用到這些對象的時候,程序通過輸入流從載體中讀取出序列化的流信息,根據對應的反序列化算法,重建對象。

  雖然過程很簡單,但每一步都有很多東西需要了解,下面就逐一介紹。


二、反序列化時如何生成實例

  生成對象實例時,一般的做法是讓每個類提供一個默認的無參構造方法,等到反序列化的時候,自動調用這個構造方法來生成實例。看似蠻合理的、蠻簡單的,但是這種方式有幾個缺陷:

  1. 侵入性太大,爲了實現反序列化,類不得不提供無參構造。
  2. 不安全,如果有的類不希望提供構造方法給外界調用。那麼序列化/反序列化的這個“無理”要求將引起災難。

  因此上述方案是行不通的,只能另尋出路。這裏直接說底層源碼的做法 :不用類去顯示聲明無參構造方法,而是通過一種語言之外的對象創建機制;從底層源碼來看,生成實例時調用了 java.reflect.Constructor的 newInstance() 方法。

// 用反射生成實例
public T newInstance(Object... initargs) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
        // ... 此次省略
        return (T) constructorAccessor.newInstance(initargs);
}

  這裏就有兩個問題需要注意:

  1. 通過序列化可以任意創建實例,不受任何限制。單例模式如果實現序列化的話,通過反序列化也可以輕鬆創建對象。
  2. 由於不調用類自己的構造器,而是通過構造器Constructor來實現的。一些限制條件就很難滿足。比如,有兩個參數 max,min,構造時必須滿足 max>min。如果不滿足參數條件,系統存在被序列化攻擊的風險。
  3. 對象進行序列化時,類中所定義的被private、final等訪問控制符所修飾的字段是直接忽略這些訪問控制符而直接進行序列化的,因此,原本在本地定義的想要控制字段的訪問權限的工作都是不起作用的。對於序列化後的有序字節流來說一切都是可見的,對象中的任何private字段幾乎都是以明文的方式出現在套接字流中,這嚴重破壞了原有類的數據的“安全性”。幸運的是,我們可以讓序列化的類可以實現一個 writeObject方法;反序列化過程,我們將在同一個類上實現一個readObject方法,通過使用writeObject 和 readObject 可以實現密碼加密和簽名管理。
public class Person implements java.io.Serializable {
    public Person(String fn, String ln, int a) {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }
    // 省略 get set 方法
    // writeObject
    private void writeObject(java.io.ObjectOutputStream stream)
        throws java.io.IOException {
        age = age << 2;
        stream.defaultWriteObject();
    }
    // readObject
    private void readObject(java.io.ObjectInputStream stream)
        throws java.io.IOException, ClassNotFoundException {
        stream.defaultReadObject();
        age = age << 2;
    }    
    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
}

三、是不是所有的類都需要序列化

  是不是所有的類都需要序列化? 如果是,則序列化就應該是類的基本功能,如果這樣的話,序列化就應該對程序員徹底透明纔是,所以並不是所有的類都需要序列化,原因如下:

  1. 安全問題:有的類是敏感的類,裏面的數據不能公開。而實現了序列化就很容易被破解,可以說沒有祕密可言,隱私性沒有保證。
  2. 資源問題:序列化可以任意生成實例而不受限制,如果有的對象生成的實例是受限制的,比如只能生成10個實例,或者是單例的,這個很難保證。

  所以不是所有的類都需要序列化,那麼這就要提供一個接口/標識符,需要序列化的類要貼上標識符。未實現此接口的類將無法使其任何狀態序列化或反序列化。可序列化類的所有子類型本身都是可序列化的。這個標識符就是 Serializable 或 Externalizable(定製自己的序列化算法),實現了任何一個接口就代表可以被序列化。這就是JDK自帶的java序列化。當然還有很多的序列化框架,如:json序列化、dubbo序列化、fst、kryo、hessian2序列化等,他們的區別就在於序列化算法不同,把java實例生成的不同的序列流。先重點梳理JDK自帶的java序列化。

  java 有個工具可以查看一個類是否能夠使用"java序列化",用法如下:
在這裏插入圖片描述


四、java序列化(Serializable)和外部化(Externalizable)的主要區別

  通過Serializable接口對對象序列化的支持是內建於核心api的,也就是說只要類實現java.io.Serializable接口,java就會試圖存儲和重組你的對象。如果使用外部化,程序員就可以自由地完成讀取和存儲的方式(自定義序列化算法、反序列化算法)。

  • 序列化:
    • 優點:易於實現;直接實現Serializable接口即可。
    • 缺點:佔用空間過大、由於額外的開銷導致速度變比較慢,字節序列可讀性差。
  • 外部化:
    • 優點:開銷較少(程序員決定存儲什麼)速度可能有提升;
    • 缺點:虛擬機不提供任何幫助,也就是說所有的工作(開發自定義序列化算法、反序列化算法)都落到了開發人員的肩上。

  在兩者之間如何選擇要根據應用程序的需求來定。serializable通常是最簡單的解決方案,但是虛擬機必須弄清楚每個成員屬性的結構,所以可能會導致不可接受的性能問題或空間問題;在出現這些問題的情況下,externalizable可能是一條可行之路。要記住一點,如果一個類是可外部化的(externalizable),那麼externalizable方法將被用於序列化類的實例,即使這個類型也提供了serializable方法。

// write方法用於實現定製序列化,read方法用於實現定製反序列化
public interface Externalizable extends java.io.Serializable {
    void writeExternal(ObjectOutput out) throws IOException;
    void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
}

五、哪些東西需要序列化

  類裏那麼多東西 ,哪些需要進行序列化?序列化的原則就是:序列化的信息要足夠幫助我們在反序列化的時候恢復之前對象的狀態就可以了,空間能省則省(畢竟涉及到網絡傳輸問題)。那我們來分析下面這個Person.java類:

@Data
public class Person implements Serializable {
    private String name;
    protected String account;
    public String password;
    //private String newAdd;
    //static int i=0;
    //private transient Double dou;
}
public class TestSerial {
    public static void main(String[] args) throws Exception {
        //序列化  採用默認的序列化算法把對象轉換爲字節序列。
        SerialUtils.writeObject(new Person(), "personbyte");
        //反序列化
        SerialUtils.readObject("personbyte");
    }
}
public class SerialUtils {
    public static void writeObject(Object o, String fileName) throws IOException {
        File file = new File(fileName);
        FileOutputStream fos = new FileOutputStream(file);
        ObjectOutputStream oos = new ObjectOutputStream(fos);
        oos.writeObject(o);
        oos.flush();
        oos.close();
        fos.close();
        // 返回文件以字節爲單位的長度,或者文件不存在時返回 0
        System.out.println("長度:" + file.length());
    }
    public static Object readObject(String fileName) throws IOException, ClassNotFoundException {
        File file = new File(fileName);
        FileInputStream fis = new FileInputStream(file);
        ObjectInputStream ois = new ObjectInputStream(fis);
        Object o = ois.readObject();
        ois.close();
        fis.close();
        System.out.println("read form " + fileName + "get " + o);
        return o;
    }
}

執行上面的main方法,可以看到如下結果:
在這裏插入圖片描述
對象序列化使用的是一種特殊的文件格式來存儲對象。使用UltraEdit打開personbyte文件(上述案例生成),使用16進制的方式查看字節序列,如下:
在這裏插入圖片描述

  • AC ED:STREAM_MAGIC,聲明使用了序列化協議.
  • 00 05: STREAM_VERSION.序列化協議版本.
  • 0x73: TC_OBJECT.聲明這是一個新的對象.
  • 0x72: TC_CLASSDESC.聲明這裏開始一個新Class。
    可讀性極差,不再一一解釋這些16進制的含義。深挖下去意義不大。

1. 普通成員變量需要序列化

  無論是用什麼權限標識符修飾(public/private/protected)的成員變量,他們都是對象的狀態,不序列化成員變量的話,反序列化的實例也是不完整的。所以,普通成員變量必須序列化。

2. 靜態變量無需序列化

  靜態變量其實是類屬性,並不屬於某個具體實例,所以也不用保存。當恢復對象的時候,直接取類當前的靜態變量即可。(可以放開Person類中的static int i=0;註釋,執行結果不變同上圖)

3. 方法無需序列化

  方法只是類的無狀態指令。重建類的時候,可以直接從類的信息中獲取,所以也不需要被序列化。(同樣可以用代碼驗證)
  以上討論的都是很簡單的情形,下面看一些複雜場景:

4. 屬性是一個引用(需要被序列化)

  類a有b、c兩個引用屬性,b有引用d,d又引用f….看下圖:

在這裏插入圖片描述

  成員變量雖是引用,但也是對象必不可缺的屬性,如果序列化時不存儲這些信息,反序列化出來的對象是殘缺的,它的所有引用屬性都會是null。所以當一個對象的實例變量引用其他對象,序列化該對象時也把引用對象進行序列化。需要注意的是,上圖描述的是一個特殊的場景,含有對f實例的重複引用,序列化時f實例只會被序列化一次。循環引用本篇不展開介紹,詳見《解決fastjson內存對象重複/循環引用json錯誤》,這篇博文以fastJson序列化框架爲例,介紹了重複/循環引用的一種很實際序列化方案。

  java對象序列化不僅保留一個對象的數據,而且遞歸保存對象引用的每個對象的數據。可以將整個對象層次寫入字節流中,可以保存在文件中或在網絡連接上傳遞。利用對象序列化可以進行對象的"深複製",即複製對象本身及引用的對象本身。

  這種複雜對象也使得序列化和反序列化算法比較複雜,本篇不會深入解釋序列化算法。

5.有父類(較爲複雜)

  子類繼承父類 ,就像兒子繼承父親的特徵,比如姓氏。那麼姓氏也應該是兒子的一個狀態,所以父類的這些狀態還是要保存的。但並不絕對,因爲還有另一個問題,如果父類沒有實現序列化接口呢?

@Data
public class Person extends Animal implements Serializable {
    private String name;
    protected String account;
    public String password;
    //private String newAdd;
    //static int i=0;
    //private transient Double dou;
    public Person(){
        super(1);
    }
}
// 這裏的父類沒有實現序列化
public class Animal {
    private Integer age;
    public Animal(Integer age) {
        this.age = age;
        System.out.println("父 有參構造執行");
    }
    // 若去掉父類的無參構造方法,反序列化時會報語法錯誤。
    public Animal() {
        System.out.println("父 無參構造執行");
    }
}

修改Demo程序讓Person類繼承一個沒有實現序列化的Animal類,重新執行main方法。結果如下:
在這裏插入圖片描述
這說明沒有實現序列化的父類,沒有被寫入到文件中,序列化的僅僅是子類。而且注意一點 ,父類的構造方法被調用了兩次。爲什麼呢?這個挺好解釋的,因爲我們要創建一個子類的實例 ,必然要創建其父類的實例。第一個“父 有參構造執行”就是創建子類時,調用了父類的有參構造方法而打印出來的;第二個是我們反序列化的時候,生成子類實例的時候,調用了父類的無參構造方法,而打印出來的。若去掉父類的無參構造方法,反序列化時會報語法錯誤。

長度:115
Exception in thread "main" java.io.InvalidClassException: com.learning.serializable.Person; no valid constructor
    at java.io.ObjectStreamClass$ExceptionInfo.newInvalidClassException(ObjectStreamClass.java:169)
    at java.io.ObjectStreamClass.checkDeserialize(ObjectStreamClass.java:874)
    at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2043)
    at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1573)
    at java.io.ObjectInputStream.readObject(ObjectInputStream.java:431)
    at com.learning.serializable.SerialUtils.readObject(SerialUtils.java:35)
    at com.learning.serializable.TestSerial.main(TestSerial.java:22)
Process finished with exit code 1

修改代碼,讓父類實現序列化:

public class Animal implements Serializable{
    private Integer age;
    public Animal(Integer age) {
        this.age = age;
        System.out.println("父 有參構造執行");
    }
    public Animal() {
        System.out.println("父 無參構造執行");
    }
}

執行main方法,結果如下:
在這裏插入圖片描述
注意:
  若父類沒有實現序列化,反序列化時要生成示例,就只能調用父類的無參構造方法,沒有無參構造就會報錯。如果父類實現了序列化,那麼父類的無參構造方法是不會被調用的。
結論如下:

  1. 父類實現序列化:父類的狀態被保存
  2. 父類未實現序列化:分兩種情況
    • 父類提供了無參構造方法:子類可以序列化,父類不能序列化.
    • 父類沒有提供無參構造方法:子類可以序列化,反序列化時程序報錯.
  3. 當一個父類實現序列化,子類自動實現序列化,不需要顯式實現Serializable接口

6. 有實現接口(接口內容無需序列化)

  接口一般是無狀態的,就算有也是static的,那麼毫無疑問,接口的信息也不會被序列化。

7. 用transient保護的敏感信息不用序列化

  前面說過了,類裏面的哪些信息要被序列化,非static的屬性是要被序列化的。所以用戶可能不想序列化某些敏感內容,比如前面例子中的password,因爲序列化之後,黑客可以輕而易舉的破解其內容,沒有絲毫安全性可言(在序列化進行傳輸的過程中,這個對象的private等域是不受保護的。)。如果我們是設計者的話,會怎麼做?無非有兩種做法:

  1. 對敏感屬性打一個標識,序列化的時候,不保存這些內容。
  2. 對敏感內容進行加密。

  java裏採用第一種方式,這個標識叫做transient(瞬態/臨時數據)。用法如下:

transient private String password;

  原理也很簡單,在序列化的時候,虛擬機會將標識 transient 的屬性排除在外。


六、java序列化爲什麼要使用serialversionUID

  If a serializable class does not explicitly declare a serialVersionUID, then the serialization runtime will calculate a default serialVersionUID value for that class based on various aspects of the class, as described in the Java™ Object Serialization Specification. However, it is strongly recommended that all serializable classes explicitly declare serialVersionUID values, since the default serialVersionUID computation is highly sensitive to class details that may vary depending on compiler implementations, and can thus result in unexpected InvalidClassExceptions during deserialization.

  以上是序列化接口上的註釋,如果用戶沒有自己聲明一個serialVersionUID,接口會默認生成一個serialVersionUID(根據包名、類名、繼承關係、非私有的方法和屬性以及參數、返回值等諸多因子計算得出的,生成極度複雜的一個64位的long值。基本上計算出來的這個值是唯一的),但是強烈建議用戶自定義一個serialVersionUID,因爲默認的serialVersinUID對於class的細節非常敏感,類修改後默認的serialVersionUID也會發生變化。如果序列化和反序列化時用的serialversionUID不同,會導致InvalidClassException異常。

  修改上面的測試用例,不顯式指定Person類的serialversionUID。將對象進行序列化保存到本地文件中;然後修改Person類(增減屬性);最後,從本地文件讀取序列化後的有序字節流,進行反序列化,重建對象。報如下錯誤:

Exception in thread "main" java.io.InvalidClassException: com.learning.serializable.Person; local class incompatible: stream classdesc serialVersionUID = 7407092434109718168, local class serialVersionUID = 2475206011077688084

    at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:699)
    at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1885)
    at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1751)
    at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2042)
    at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1573)
    at java.io.ObjectInputStream.readObject(ObjectInputStream.java:431)
    at com.learning.serializable.SerialUtils.readObject(SerialUtils.java:35)
    at com.learning.serializable.TestSerial.main(TestSerial.java:22)
Process finished with exit code 1

  當指定序列化版本serialversionUID時,序列化端 和 反序列化端的對象屬性個數、名字即使對應不上,也不會影響反序列化,對應不上的屬性會有默認值。也就是說對象顯式指定serialversionUID時,序列化和反序列化時,兼容性更好。這裏所說的兼容性,僅僅以反序列化成功爲標準。在很多複雜的業務中,兼容性還要滿足其他約束條件,不僅僅是滿足反序列化成功而已。


  以上就是JDK自帶的java序列化,虛擬機必須弄清楚每個成員屬性的結構,所以性能問題和空間問題較爲突出,生產中較少使用。成熟的序列化框架很多,如:json序列化、dubbo序列化、fst、kryo、hessian2序列化等,他們的性能、使用場景各有不同。下面重點介紹序列化應用。

七、序列化應用

1.dubbo RPC序列化

  在dubbo RPC中,服務調用的入參和出參就是通過序列化和反序列化實現的。dubbo RPC同時支持多種序列化方式,例如:

  1. dubbo序列化:阿里尚未開發成熟的高效java序列化實現,阿里不建議在生產環境使用它
  2. hessian2序列化:hessian是一種跨語言的高效二進制序列化方式。但這裏實際不是原生的hessian2序列化,而是阿里修改過的hessian lite,它是dubbo RPC默認啓用的序列化方式
  3. json序列化:目前有兩種實現,一種是採用的阿里的fastjson庫,另一種是採用dubbo中自己實現的簡單json庫,但其實現都不是特別成熟,而且json這種文本序列化性能一般不如上面兩種二進制序列化。
  4. java序列化:主要是採用JDK自帶的Java序列化實現,性能很不理想。

  對於dubbo RPC這種追求高性能的遠程調用方式來說,實際上只有第1、第2兩種高效序列化方式比較般配,而第1個dubbo序列化由於還不成熟,所以實際只剩下2可用,所以dubbo RPC默認採用hessian2序列化。但hessian是一個比較老的序列化實現了,而且它是跨語言的,所以不是單獨針對java進行優化的。而dubbo RPC實際上完全是一種Java to Java的遠程調用,其實沒有必要採用跨語言的序列化方式(當然肯定也不排斥跨語言的序列化)。

  除此之外,各種新的高效序列化方式層出不窮,不斷刷新序列化性能的上限,如專門針對Java語言的:Kryo,FST等等。

  使用Kryo和FST非常簡單,只需要在dubbo RPC的XML配置中添加一個屬性即可。詳見

2.緩存序列化

  在企業開發中緩存能提高系統的性能。無論是redis、memcache等存儲的都是序列化後的信息。例如在使用kryo序列化方法刷redis緩存時,常見的set、get方法,如下:

// set
public boolean setByKryo(String key, Object object_, int seconds) {
   if (object_ == null) {
      return false;
   }
   ShardedJedis commonJedis = null;
   byte[] data_ = null;
   boolean success = false;
   try {
      commonJedis = jedisPool.getResource();
      Kryo kryo = new Kryo();
      Output output = new Output( 256, 131072);
      kryo.writeObject(output, object_);
      data_ = output.toBytes();
      output.flush();
      output.close();
      commonJedis.setex(key.getBytes(), getRealCacheTime(seconds), data_);
      success = true;
   } catch (Exception e) {
      jedisPool.returnBrokenResource(commonJedis);
      RedisException.exceptionJedisLog(logger, key, commonJedis, e , "setByKryo");
      commonJedis = null;
   } finally {
      if (commonJedis != null) {
         jedisPool.returnResource(commonJedis);
      }
   }
   return success;
}
// get    
public Object getByKryo(String key, Class<?> myClass, int seconds) {
   ShardedJedis commonJedis = null;
   Object object_ = null;
   Input input = null;
   try {
      commonJedis = jedisPool.getResource();
      byte[] data_ = commonJedis.get(key.getBytes());
      if (data_ == null) {
         return null;
      }
      Kryo kryo = new Kryo();
      input = new Input(data_);
      object_ = kryo.readObject(input, myClass);
      if (seconds > 0) {
         commonJedis.expire(key, getRealCacheTime(seconds));
      }
   } catch (Exception e) {
      jedisPool.returnBrokenResource(commonJedis);
      RedisException.exceptionJedisLog(logger, key, commonJedis, e , "getByKryo");
      commonJedis = null;
   } finally {
      if (commonJedis != null) {
         jedisPool.returnResource(commonJedis);
      }
      if (input != null) {
         input.close();
      }
   }
   return object_;
}

  kryo序列化刷緩存有其缺陷,在實際開發中,bean增刪字段是很常見的事情,但kryo卻不支持這一操作。所以生產中採用json序列化刷緩存較爲普遍。

3.各種序列化方式比較(kryo/fst/json/hessian2)

  1. kryo序列
    • 優點:kryo序列化更快,採用可變長存儲方式,讓kryo序列化後的數據比Java序列化佔用的空間更小。
    • 缺點:可讀性差,kryo序列化不支持bean新增字段。因爲生成的byte數據中部包含field數據,對類升級的兼容性很差!所以,若用kryo序列化對象用於C/S架構的話,兩邊的Class結構要保持一致。
    • 踩坑記:在開發中,一些表對應的entity需要通過kryo序列化存儲進redis,而kryo反序列化是通過取key值進行的。如果對錶增減字段,那麼kryo取key值反序列化時,舊的緩存內容和新entity字段對不上,反序列時報錯。所以,無論修改哪張表,都先要確定這張表的bean是否需要序列化。如果存在 ICacheKey 取值,需要把數據重新刷到另外一個key上,替換原有緩存key值爲新的key值。這樣序列化與反序列化時,就不會出現字段對不上的情況,只要設計通過key值取值的地方均需要更改,且反覆校驗,是否有丟落的地方。

  在dubbo rpc中 ,kryo序列化方式,也同樣存在反序列化時不兼容的問題。

  1. fst序列化
      FST序列化/反序列化,這篇博客中重點介紹了序列化和反序列化的過程。結論是:FST序列化方式缺點和kyro類似,序列化反序列化速度很快,缺點也是和kyro類似,新增加字段時,會不兼容。解決方法分兩種:

    • 如果接口返回的bean不是List類型,可以直接加@Version註解。
    • 如果接口返回的bean是List類型。建議將dubbo的序列化方式由fst改成hession2。
  2. json序列化

    • 優點:可讀性強,兼容性強,增加字段不會出現無法反序列化的問題。
    • 缺點:序列化反序列化速度慢,由於是純字符串,序列化後的結果可能會較大,會引起IO問題。

  適用場景:
    業務會不斷變化,會經常有增加字段的業務場景,序列化需要有兼容性。

    能控制好key的大小、適當使用redis的命令(避免hgetall等操作,儘可能低地減少序列化反序列化和IO問題)。

  FastJson簡單實用、fastjson漏洞網上有很多分析看不太懂,參考Fastjson反序列化漏洞研究

  1. hessian2序列化
    • 優點:默認支持跨語言,兼容性好。
    • 缺點:性能不好。

序列化框架性能對比參考鏈接


八、參考鏈接:

  1. Java 對象序列化
  2. 序列化和反序列化
  3. 《序列化的祕密》
  4. 《Effective java 中文版(第二版)》
  5. 一篇搞懂java序列化Serializable
  6. FST序列化/反序列化
  7. 淺析kryo
  8. 在Dubbo中使用高效的Java序列化(Kryo和FST)
  9. 高效的Java序列化(Kryo和FST)
  10. Kryo官方文檔-中文翻譯
  11. 談談 JAVA 的對象序列化
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章