本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接http://item.jd.com/12299018.html


在前面几节,我们在将对象保存到文件时,使用的是DataOutputStream,从文件读入对象时,使用的是DataInputStream, 使用它们,需要逐个处理对象中的每个字段,我们提到,这种方式比较啰嗦,Java中有一种更为简单的机制,那就是序列化。

简单来说,序列化就是将对象转化为字节流,反序列化就是将字节流转化为对象。在Java中,具体如何来使用呢?它是如何实现的?有什么优缺点?本节就来探讨这些问题,我们先从它的基本用法谈起。

基本用法

Serializable

要让一个类支持序列化,只需要让这个类实现接口java.io.Serializable,Serializable没有定义任何方法,只是一个标记接口。比如,对于57节提到的Student类,为支持序列化,可改为:

  1. public class Student implements Serializable {
  2. String name;
  3. int age;
  4. double score;
  5.  
  6. public Student(String name, int age, double score) {
  7. ...
  8. }
  9. ...
  10. }

声明实现了Serializable接口后,保存/读取Student对象就可以使用另两个流了ObjectOutputStream/ObjectInputStream。

ObjectOutputStream/ObjectInputStream

ObjectOutputStream是OutputStream的子类,但实现了ObjectOutput接口,ObjectOutput是DataOutput的子接口,增加了一个方法:

  1. public void writeObject(Object obj) throws IOException

这个方法能够将对象obj转化为字节,写到流中。

ObjectInputStream是InputStream的子类,它实现了ObjectInput接口,ObjectInput是DataInput的子接口,增加了一个方法:

  1. public Object readObject() throws ClassNotFoundException, IOException

这个方法能够从流中读取字节,转化为一个对象。

使用这两个流,57节介绍的保存学生列表的代码就可以变为:

  1. public static void writeStudents(List<Student> students) throws IOException {
  2. ObjectOutputStream out = new ObjectOutputStream(
  3. new BufferedOutputStream(new FileOutputStream("students.dat")));
  4. try {
  5. out.writeInt(students.size());
  6. for (Student s : students) {
  7. out.writeObject(s);
  8. }
  9. } finally {
  10. out.close();
  11. }
  12. }

而从文件中读入学生列表的代码可以变为:

  1. public static List<Student> readStudents() throws IOException,
  2. ClassNotFoundException {
  3. ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(
  4. new FileInputStream("students.dat")));
  5. try {
  6. int size = in.readInt();
  7. List<Student> list = new ArrayList<>(size);
  8. for (int i = 0; i < size; i++) {
  9. list.add((Student) in.readObject());
  10. }
  11. return list;
  12. } finally {
  13. in.close();
  14. }
  15. }

实际上,只要List对象也实现了Serializable (ArrayList/LinkedList都实现了),上面代码还可以进一步简化,读写只需要一行代码,如下所示:

  1. public static void writeStudents(List<Student> students) throws IOException {
  2. ObjectOutputStream out = new ObjectOutputStream(
  3. new BufferedOutputStream(new FileOutputStream("students.dat")));
  4. try {
  5. out.writeObject(students);
  6. } finally {
  7. out.close();
  8. }
  9. }
  10.  
  11. public static List<Student> readStudents() throws IOException,
  12. ClassNotFoundException {
  13. ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(
  14. new FileInputStream("students.dat")));
  15. try {
  16. return (List<Student>) in.readObject();
  17. } finally {
  18. in.close();
  19. }
  20. }

是不是很神奇?只要将类声明实现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,这三个类的定义如下:

  1. class Common implements Serializable {
  2. String c;
  3.  
  4. public Common(String c) {
  5. this.c = c;
  6. }
  7. }
  8.  
  9. class A implements Serializable {
  10. String a;
  11. Common common;
  12.  
  13. public A(String a, Common common) {
  14. this.a = a;
  15. this.common = common;
  16. }
  17.  
  18. public Common getCommon() {
  19. return common;
  20. }
  21. }
  22.  
  23. class B implements Serializable {
  24. String b;
  25. Common common;
  26.  
  27. public B(String b, Common common) {
  28. this.b = b;
  29. this.common = common;
  30. }
  31.  
  32. public Common getCommon() {
  33. return common;
  34. }
  35. }

有三个对象, a, b, c,如下所示:

  1. Common c = new Common("common");
  2. A a = new A("a", c);
  3. B b = new B("b", c);

a和b引用同一个对象c,如果序列化这两个对象,反序列化后,它们还能指向同一个对象吗?答案是肯定的,我们看个实验。

  1. ByteArrayOutputStream bout = new ByteArrayOutputStream();
  2. ObjectOutputStream out = new ObjectOutputStream(bout);
  3. out.writeObject(a);
  4. out.writeObject(b);
  5. out.close();
  6.  
  7. ObjectInputStream in = new ObjectInputStream(
  8. new ByteArrayInputStream(bout.toByteArray()));
  9. A a2 = (A) in.readObject();
  10. B b2 = (B) in.readObject();
  11.  
  12. if (a2.getCommon() == b2.getCommon()) {
  13. System.out.println("reference the same object");
  14. } else {
  15. System.out.println("reference different objects");
  16. }

输出为:

  1. reference the same object

这也是Java序列化机制的神奇之处,它能自动处理这种引用同一个对象的情况。更神奇的是,它还能自动处理循环引用的情况,我们来看下。

循环引用

我们看个例子,有Parent和Child两个类,它们相互引用,类定义如下:

  1. class Parent implements Serializable {
  2. String name;
  3. Child child;
  4.  
  5. public Parent(String name) {
  6. this.name = name;
  7. }
  8. public Child getChild() {
  9. return child;
  10. }
  11. public void setChild(Child child) {
  12. this.child = child;
  13. }
  14. }
  15.  
  16. class Child implements Serializable {
  17. String name;
  18. Parent parent;
  19.  
  20. public Child(String name) {
  21. this.name = name;
  22. }
  23. public Parent getParent() {
  24. return parent;
  25. }
  26. public void setParent(Parent parent) {
  27. this.parent = parent;
  28. }
  29. }

定义两个对象:

  1. Parent parent = new Parent("老马");
  2. Child child = new Child("小马");
  3. parent.setChild(child);
  4. child.setParent(parent);

序列化parent, child两个对象,Java能正确序列化吗?反序列化后,还能保持原来的引用关系吗?答案是肯定的,我们看代码实验:

  1. ByteArrayOutputStream bout = new ByteArrayOutputStream();
  2. ObjectOutputStream out = new ObjectOutputStream(bout);
  3. out.writeObject(parent);
  4. out.writeObject(child);
  5. out.close();
  6.  
  7. ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(
  8. bout.toByteArray()));
  9. parent = (Parent) in.readObject();
  10. child = (Child) in.readObject();
  11.  
  12. if (parent.getChild() == child && child.getParent() == parent
  13. && parent.getChild().getParent() == parent
  14. && child.getParent().getChild() == child) {
  15. System.out.println("reference OK");
  16. } else {
  17. System.out.println("wrong reference");
  18. }

输出为:

  1. reference OK

神奇吧?

定制序列化

默认的序列化机制已经很强大了,它可以自动将对象中的所有字段自动保存和恢复,但这种默认行为有时候不是我们想要的。

比如,对于有些字段,它的值可能与内存位置有关,比如默认的hashCode()方法的返回值,当恢复对象后,内存位置肯定变了,基于原内存位置的值也就没有了意义。还有一些字段,可能与当前时间有关,比如表示对象创建时的时间,保存和恢复这个字段就是不正确的。

还有一些情况,如果类中的字段表示的是类的实现细节,而非逻辑信息,那默认序列化也是不适合的。为什么不适合呢?因为序列化格式表示一种契约,应该描述类的逻辑结构,而非与实现细节相绑定,绑定实现细节将使得难以修改,破坏封装。

比如,我们在容器类中介绍的LinkedList,它的默认序列化就是不适合的,为什么呢?因为LinkedList表示一个List,它的逻辑信息是列表的长度,以及列表中的每个对象,但LinkedList类中的字段表示的是链表的实现细节,如头尾节点指针,对每个节点,还有前驱和后继节点指针等。

那怎么办呢?Java提供了多种定制序列化的机制,主要的有两种,一种是transient关键字,另外一种是实现writeObject和readObject方法。

将字段声明为transient,默认序列化机制将忽略该字段,不会进行保存和恢复。比如,类LinkedList中,它的字段都声明为了transient,如下所示:

  1. transient int size = 0;
  2. transient Node<E> first;
  3. transient Node<E> last;

声明为了transient,不是说就不保存该字段了,而是告诉Java默认序列化机制,不要自动保存该字段了,可以实现writeObject/readObject方法来自己保存该字段。

类可以实现writeObject方法,以自定义该类对象的序列化过程,其声明必须为:

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

可以在这个方法中,调用ObjectOutputStream的方法向流中写入对象的数据。比如,LinkedList使用如下代码序列化列表的逻辑数据:

  1. private void writeObject(java.io.ObjectOutputStream s)
  2. throws java.io.IOException {
  3. // Write out any hidden serialization magic
  4. s.defaultWriteObject();
  5.  
  6. // Write out size
  7. s.writeInt(size);
  8.  
  9. // Write out all elements in the proper order.
  10. for (Node<E> x = first; x != null; x = x.next)
  11. s.writeObject(x.item);
  12. }

需要注意的是第一行代码:

  1. s.defaultWriteObject();

这一行是必须的,它会调用默认的序列化机制,默认机制会保存所有没声明为transient的字段,即使类中的所有字段都是transient,也应该写这一行,因为Java的序列化机制不仅会保存纯粹的数据信息,还会保存一些元数据描述等隐藏信息,这些隐藏的信息是序列化之所以能够神奇的重要原因。

与writeObject对应的是readObject方法,通过它自定义反序列化过程,其声明必须为:

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

在这个方法中,调用ObjectInputStream的方法从流中读入数据,然后初始化类中的成员变量。比如,LinkedList的反序列化代码为:

  1. private void readObject(java.io.ObjectInputStream s)
  2. throws java.io.IOException, ClassNotFoundException {
  3. // Read in any hidden serialization magic
  4. s.defaultReadObject();
  5.  
  6. // Read in size
  7. int size = s.readInt();
  8.  
  9. // Read in all elements in the proper order.
  10. for (int i = 0; i < size; i++)
  11. linkLast((E)s.readObject());
  12. }

注意第一行代码:

  1. 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自动生成的性能比较低,怎么自定义呢?在类中定义如下变量:

  1. private static final long serialVersionUID = 1L;

在Java IDE如Eclipse中,如果声明实现了Serializable而没有定义该变量,IDE会提示自动生成。这个变量的值可以是任意的,代表该类的版本号。在序列化时,会将该值写入流,在反序列化时,会将流中的值与类定义中的值进行比较,如果不匹配,会抛出InvalidClassException。

那如果版本号一样,但实际的字段不匹配呢?Java会分情况自动进行处理,以尽量保持兼容性,大概分为三种情况:

  • 字段删掉了:即流中有该字段,而类定义中没有,该字段会被忽略。
  • 新增了字段:即类定义中有,而流中没有,该字段会被设为默认值。
  • 字段类型变了:对于同名的字段,类型变了,会抛出InvalidClassException。

高级自定义

除了自定义writeObject/readObject方法,Java中还有如下自定义序列化过程的机制:

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

这些机制实际用到的比较少,我们简要说明下。

Externalizable是Serializable的子接口,定义了如下方法:

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

与writeObject/readObject的区别是,如果对象实现了Externalizable接口,则序列化过程会由这两个方法控制,默认序列化机制中的反射等将不再起作用,不再有类似defaultWriteObject和defaultReadObject调用,另一个区别是,反序列化时,会先调用类的无参构造方法创建对象,然后才调用readExternal。默认的序列化机制由于需要分析对象结构,往往比较慢,通过实现Externalizable接口,可以提高性能。

readResolve方法返回一个对象,声明为:

  1. Object readResolve()

如果定义了该方法,在反序列化之后,会额外调用该方法,该方法的返回值才会被当做真正的反序列化的结果。这个方法通常用于反序列化单例对象的场景。

writeReplace也是返回一个对象,声明为:

  1. 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编程及计算机技术的本质。用心原创,保留所有版权。

Java编程的逻辑 (62) - 神奇的序列化的更多相关文章

  1. Java编程的逻辑 (45) - 神奇的堆

    本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...

  2. 《Java编程的逻辑》 - 文章列表

    <计算机程序的思维逻辑>系列文章已整理成书<Java编程的逻辑>,由机械工业出版社出版,2018年1月上市,各大网店有售,敬请关注! 京东自营链接:https://item.j ...

  3. Java编程的逻辑 (63) - 实用序列化: JSON/XML/MessagePack

    本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...

  4. Java编程的逻辑 (85) - 注解

    ​本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http: ...

  5. Java编程的逻辑 (64) - 常见文件类型处理: 属性文件/CSV/EXCEL/HTML/压缩文件

    ​本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http: ...

  6. 《Java编程的逻辑》终于上市了!

    2018年1月下旬,<Java编程的逻辑>终于出版上市了! 这是老马过去两年死磕到底.无数心血的结晶啊! 感谢"博客园"的广大读者们,你们对老马文章的极高评价.溢美之词 ...

  7. Java编程的逻辑 (93) - 函数式数据处理 (下)

    本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...

  8. Java编程的逻辑 (90) - 正则表达式 (下 - 剖析常见表达式)

    本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...

  9. Java编程的逻辑 (84) - 反射

    ​本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http: ...

随机推荐

  1. java学习之switch 等值判断

    当匹配到相等的值时候 则进入case里面执行语句 当该语句有break时候 则退出匹配 当没有break时候 则继续往下匹配 直到遇到break才停止匹配

  2. 一本通1646GT 考试

    1646:GT 考试 时间限制: 1000 ms         内存限制: 524288 KB [题目描述] 阿申准备报名参加 GT 考试,准考证号为 n 位数 X1X2⋯Xn(0≤Xi≤9),他不 ...

  3. c++ std::function

    std::function 是一个模板类,用于封装各种类似于函数这样的对象,例如普通函数,仿函数,匿名函数等等.其强大的多态能力,让其使用只依赖于调用特征.在程序的升级中,可以实现一个调用表,以兼容新 ...

  4. MySQL删除数据库时的错误(errno: 39)

    由于mysql数据库是默认区分大小写的,部署的时候发现多了一些重复的表,于是就把多余的表删掉了.可是,剩下的重复的表再删除时会提示:表不存在. 于是,想把数据库删掉重新创建,可是,得到了 ERROR ...

  5. 【Revit API】获取链接模型中构件

    话不多说,直接代码 var doc = commandData.Application.ActiveUIDocument.Document; FilteredElementCollector link ...

  6. BZOJ 4380 [POI2015]Myjnie | DP

    链接 BZOJ 4380 题面 有n家洗车店从左往右排成一排,每家店都有一个正整数价格p[i]. 有m个人要来消费,第i个人会驶过第a[i]个开始一直到第b[i]个洗车店,且会选择这些店中最便宜的一个 ...

  7. 【bzoj3994】 SDOI2015—约数个数和

    http://www.lydsy.com/JudgeOnline/problem.php?id=3994 (题目链接) 题意 多组询问,给出${n,m}$,求${\sum_{i=1}^n\sum_{j ...

  8. [六字真言]5.咪.功力不足,学习前端JavaScript异常

    A Guide to Proper Error Handling in JavaScript 这是关于JavaScript中异常处理的故事.如果你相信 墨菲定律 ,那么任何事情都可能出错,不,一定会出 ...

  9. html5 canvas结构基础

    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/ ...

  10. [整理]C 内核源代码-学习资料

    GNU C gnu项目:http://www.gnu.org/software/software.html ftp:http://ftp.gnu.org/gnu/ 托管:http://savannah ...