Effective Java--序列化--你以为只要实现Serializable接口就行了吗

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/smileiam/article/details/75315005

前言

相信大家对于序列化都有一些了解,实现也很简单,只需要在相应的类定义后面加上implement Serializable,JVM就知道此类可以被序列化,可被默认的序列化机制序列化。编译器就会自动给我们类对象添加序列化和反序列化实现。使用如此简单,为什么我还要研究序列化,因为它远远不只这些东西,咱们想想以下问题:

  • 子类和父类序列化,父类不序列化,子类序列化,父类变量是否会被序列化,应该怎么实现父类的变量也能序列化?
  • 自定义序列化怎么实现?writeObject\readObject都是private方法,为什么能被运行?
  • 单例怎么实现序列化,为什么枚举优于readResolve方法?
  • 怎么实现序列化代理,什么情况下需要用到序列化代理,它使用有哪些注意事项?

带着这些问题我们先来了解下序列化的一些基本知识:

序列化干了什么?
序列化过程,类似一个”freeze”过程,是对对象状态进行了保存,再进行存储,等再次需要的时候,再将这个对象de-freeze,状态恢复。

序列化:将一个对象编码成一个字节流,通过保存或传输这些字节流数据来达到数据持久化的目的;
反序列化:将字节流转换成一个对象;

序列化作用?
当一个对象被序列化后,它的编码就可以从一台虚拟机传至另一个台虚拟机,可以被保存在磁盘上,方便以后反序列化使用。

下面是按照Effect Java的建议条目来进行标题编号的,因为后面我还要系统解读Effect Java其他系列。

74. 谨慎地实现Serializable接口

一个类实现了Serializable接口,这个类中私有和包级私有的实例域转成的字节流编码都将变成导出API的一部分,类修改就没那么灵活,不符合“访问域最小化”准则。

74.1 实现Serializable接口的缺点

1. 类被发布后,改变类的灵活性变小

如果一个类实现了Serializable接口,它的字节流编码也变成了它导出API的一部分,它的子类都等价于实现了序列化,以后如果想要改变这个类的内部表示法,可能导致序列化形式不兼容。
如果被序列化的类没有显示的指定serialVersionUID标识(序列版本UID),系统会自动根据这个类来调用一个复杂的运算过程生成该标识。此标识是根据类名称、接口名称、所有公有和受保护的成员名称生成的一个64位的Hash字段,若我们改变了这些信息,如增加一个方法,自动产生的序列版本UID就会发生变化,等价于客户端用这个类的旧版本序列化一个类,而用新版本进行反序列化,从而导致程序失败,类兼容性遭到破坏。

2. 更容易引发Bug和安全漏洞

一般对象是由构造器创建的,而序列化也是一种对象创建机制,反序列化也可以构造对象。由于反序列化机制中没有显式的构造器,开发者一般很容易忽略它的存在。
构造器创建对象有它的约束条件:不允许攻击者访问正在构造过程中的对象内部信息,而用默认的反序列化机制构造对象过程中,很容易遭到非法访问,使构造出来的对象,并不是原始对象,引发程序Bug和其他安全问题。

3. 随着类发行新版本,相关测试负担加重

当一个可序列化的类被修改后,需要检查“在新版中序列化一个实例,在旧版本中反序列化”及“在旧版本中序列化一个实例,在新版本反序列化”是否正常,当发布版本增多时,这种测试量幂级增加。如果开发者早期进行了良好的序列化设计,就可能不需要这些测试。

4. 开销大

序列化对象时,不仅会序列化当前对象本身,还会对该对象引用的其他对象也进行序列化。如果一个对象包含的成员变量是容器类等并深层引用时(对象是链表形式),此时序列化开销会很大,这时必须要采用其他一些手段处理。

74.2 什么情况下实现Serializable接口是有必要的

  • 若要加入的类所属框架需要依赖序列化来实现对象传输或持久化,则实现Serializable接口就非常有必要;

  • 若要加入的类所属组件必须实现Serializable接口,则要加入的类也需要实现Serializable接口;

74.3 哪些情况下不适合使用序列化

(1)为了继承而设计的类应该尽可能少地去实现Serializable接口,用户接口也应该尽可能不继承Serializable接口
真正实现Serializable接口的的类有Throwable类(异常可以从服务器端传到客户端)、Component类(GUI可以被发送、保存和恢复)、HttpServlet抽象类(会话session可以被缓存)。
关于父类子类序列化的注意点:
1)如果父类实现了Serializable,子类自动序列化了,不需要实现Serializable;
2)序列化时,只对对象状态进行了保存,对象方法和类变量等并没有保存,因此序列化并不保存静态变量值;
3)当一个对象的实例变量引用其他对象,序列化该对象时也把引用对象序列化了;
4)不是所有对象都可以序列化,基于安全和资源方面考虑,如Socket/thread若可序列化,进行传输或保存,无法对他们重新分配资源;
5)若父类未实现Serializable,而子类序列化了,父类属性值不会被保存,反序列化后父类属性值丢失;
6)若父类没有实现Serializable,而子类需要序列化,需要父类有一个无参的构造器,子类要负责序列化(反序列化)父类的域,子类要先序列化自身,再序列化父类的域。
具体实现,子类要包含以下两个方法

private void writeObject(java.io.ObjectOutputStream out) 
  throws IOException{ 
   out.defaultWriteObject();//先序列化对象 
   out.writeInt(parentvalue);//再序列化父类的域 
  } 
  private void readObject(java.io.ObjectInputStream in) 
  throws IOException, ClassNotFoundException{ 
   in.defaultReadObject();//先反序列化对象 
     parentvalue=in.readInt();//再反序列化父类的域 
  } 

至于为什么父类要有无参构造器,因为父类没有实现Serializable接口时,虚拟机不会序列化父对象,而一个Java对象的构造必须先有父对象,才有子对象,反序列也是构造对象的一种方法,所以反序列化时,为了构造父对象,只能调用父类的无参构造函数作为默认的父对象。

(2) 内部类不应该实现Serializable

内部类是使用编译器产生的合成域来保存指向外部类实例的引用及保存来自外部作用域的局部变量的值。这些域如何对应到类定义中不确定。因此内部类的默认序列化形式定义不清楚。

74.4 关键字transient

(1) transient关键字作用是阻止变量的序列化,在变量声明前加上此关键字,在被反序列化时,transient的变量值被设为初始值,如int型是0, 对象型是null;
(2) transient关键字只能修饰变量,而不能修饰方法和类;
(3) 静态变量不管是否被transient修饰,均不能被序列化;

74.5 writeObject,readObject

74.3节中用来两个private方法来实现自身序列化,这两个函数为什么会被调用到?
writeObject: 用来处理对象的序列化,如果声明该方法,它会被ObjectOutputStream调用,而不是默认的序列化进程;
readObject: 和writeObject相对应,用来处理对象的反序列化。
ObjectOutputStream使用反射getPrivateMethod来寻找默认序列化的类是否声明了这两个方法,所以这两个方法必须声明为private提供ObjectOutputStream使用。虚拟机会先试图调用对象里的writeObject, readObject方法,进行用户自定义序列化和反序列化,若没有这样的方法,就会使用默认的ObjectOutputSteam的defaultWriteObject及ObjectInputStream里的defaultReadObject方法。

75. 使用自定义的序列化形式

(1) 若一个对象的物理表示法等同于它的逻辑内容,则可以使用默认的序列化形式

默认序列化形式描述对象内部所包含的数据,及每一个可以从这个对象到达其他对象的内部数据。

若一个对象的物理表示法与逻辑数据内容有实质性区别时,如下面的类:

public final class StringList implements Serializable {
    private int size = 0;
    private Entry head = null;

    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }
}

该类包含一个字符串序列,该序列是双向链表形式,使用默认序列化有以下4个缺点:
a) 该类导出API被束缚在该类的内部表示法上,链表类也变成了公有API的一部分,若将来内部表示法发生变化,仍需要接受链表形式的输入,并产生链式形式的输出。
b) 消耗过多空间:像上面的例子,序列化既表示了链表中的每个项,也表示了所有链表关系,而这是不必要的。这样使序列化过于庞大,把它写到磁盘中或网络上发送都很慢;
c) 消耗过多时间:序列化逻辑并不了解对象图的拓扑关系,所以它必须要经过一个图遍历过程。
d) 引起栈溢出:默认的序列化过程要对对象图执行一遍递归遍历,这样的操作可能会引起栈溢出。

对于StringList类,可以用treansient修饰head和size变量控制其序列化,自定义writeObject,readObject进行序列化。
具体改进如下:

public final class StringList implements Serializable {
    private transient int size = 0;
    private transient Entry head = null;

    //此类不再实现Serializable接口
    private static class Entry {
        String data;
        Entry next;
        Entry previous;
    }

    private final void add(String s) {
        size++;
        Entry entry = new Entry();
        entry.data = s;
        head.next = entry;
    }

    /**
     * 自定义序列化
     * @param s
     * @throws IOException
     */
    private void writeObject(ObjectOutputStream s) throws IOException{
        s.defaultWriteObject();
        s.writeInt(size);
        for (Entry e = head; e != null; e = e.next) {
            s.writeObject(e.data);
        }
    }

    /**
     * 自定义反序列化
     * @param s
     * @throws IOException
     */
    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException{
        s.defaultReadObject();
        size = s.readInt();
        for (Entry e = head; e != null; e = e.next) {
            add((String) s.readObject());
        }
    }

}

(2) 如果对象状态需要同步,则对象序列化也需要同步

如果选择使用了默认序列化形式,就要使用下列的writeObject方法

private synchronized void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject();
    }

76. 保护性编写readObject方法

readObject相当于一个公有构造器,而构造器需要检查参数有效性及必要时对参数进行保护性拷贝。而如果序列化的类包含了私有的可变组件,就需要在readObject方法中进行保护性拷贝。
如下面的类:

public final class Period implements Serializable {
        private Date start;
        private Date end;
        public Period(Date start, Date end) {
            this.start = new Date(start.getTime());//保护性拷贝
            this.end = new Date(end.getTime());
            if (this.start.compareTo(this.end) > 0) {
                throw new IllegalArgumentException("start bigger end");
            }
        }

        private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
            s.defaultReadObject();
            start = new Date(start.getTime());//保护性拷贝
            end = new Date(end.getTime());
            if (this.start.compareTo(this.end) > 0) {
                throw new IllegalArgumentException("start bigger end");
            }
        }
    }

readObject方法不可以调用可以被覆盖的方法,因为被覆盖的方法将在子类的状态被反序列化之前先运行,这样程序很可能会crash.

77. 单例模式序列化,枚举类型优先于readObsolve

77.1 采用readObsolve方法实现单例序列化

对于下面的单例

public class Elvis {
        private static final Elvis INSTANCE = new Elvis();
        private Elvis() { }
        public static Elvis getINSTANCE() {
            return INSTANCE;
        }
    }

通过序列化工具,可以将一个类的单例的实例对象写到磁盘再读回来,从而有效获得一个实例。如果想要单例实现Serializable,任何readObject方法,不管显示还是默认的,它会返回一个新建的实例,这个新建实例不同于该类初始化时创建的实例。从而导致单例获取失败。但序列化工具可以让开发人员通过readResolve来替换readObject中创建的实例,即使构造方法是私有的。在反序列化时,新建对象上的readResolve方法会被调用,返回的对象将会取代readObject中新建的对象
具体方法是在类中添加如下方法就可以保证类的Singleton属性:

//该方法忽略了被反序列化的对象,只返回该类初始化时创建的那个Elvis实例
private Object readResolve() {
            return INSTANCE;
        }

由于Elvis实例的序列化形式不需要包含任何实际的数据,因此该类的所有的类成员(field)、带有对象引用类型的实例域都应该被transient修饰。

77.2 采用枚举实现单例序列化

采用readResolve的一些缺点:
1) readResolve的可访问性需要控制好,否则很容易出问题。如果readResolve方法是受保护或是公有的,且子类没有覆盖它,序列化的子类实例进行反序列化时,就会产生一个超类实例,这时可能导致ClassCastException异常。
2) readResolve需要类的所有实例域都用transient来修饰,否则可能被攻击。
而将一个可序列化的实例受控类用枚举实现,可以保证除了声明的常量外,不会有别的实例。
所以如果一个单例需要序列化,最好用枚举来实现:

public enum Elvis implements Serializable {
        INSTANCE;
        private String[] favriteSongs = {"test", "abc"};//如果不是枚举,需要将该变量用transient修饰
    }

78. 考虑用序列化代理代替序列化实例

序列化代理类:为可序列化的类设计一个私有静态嵌套类,精确地表示外部类的实例的逻辑状态。

(1) 使用场景

1) 必须在一个不能被客户端扩展的类上编写readObject或writeObject方法时,可以考虑使用序列化代理模式;
2) 想稳定地将带有重要约束条件的对象序列化时

(2) 序列化代理类的使用方法

序列代理类应该有一个单独的构造器,参数就是外部类,此构造器只能参数中拷贝数据,不需要一致性检查或是保护性拷贝。外部类及其序列化代理类都必须声明实现Serializable接口。
writeReplace: 如果实现了writeReplace方法后,在序列化时会先调用writeReplace方法将当前对象替换成另一个对象(该方法会返回替换后的对象)并将其写入数据流。
具体实现如下:

class Person implements Serializable {  
    private String name;  
    private int age;  

    public Person(String name, int age) {  
        this.name = name;  
        this.age = age;  
    }  

    private Object writeReplace() throws ObjectStreamException {  //直接将对象转成一个list也是可以保存
        ArrayList<Object> list = new ArrayList<>();  
        list.add(name);  
        list.add(age);  
        return list;  
    }  
}  

(3) 代理类的局限性

不能与可以被客户端扩展的类兼容;
不能与对象图中包仿循环的某些类兼容;

(4) 使用writeReplace的几点注意事项

1) 实现了writeReplace就不要实现writeObject方法,因为writeReplace返回值会被自动写入输出流中,相当于自动调用了writeObject(writeReplace())
2) writeReplace的返回值必须是可序列化的;
3) 若返回的是自定义类型的对象,该类型必须是实现了序列化。
4) 使用writeReplace替换写入后的对象不能通过实现readObject方法实现自动恢复,因为对象默认被彻底替换了,就不存在自定义序列化问题,直接自动反序列化了。
5) writeObject和readObject配合使用,实现了writeReplace就不再需要writeObject和readObject。

总结

能读到这里,估计你对序列化的知识已基本掌握了,但还需要好好消化,最好写一些demo验证下加深映像。虽然说类实现序列化只需要简单实现Serializable接口即可,可是细究才发现里边有这么多知识。
我们再回头看看前言中的问题:

  • 子类和父类序列化,父类不序列化,子类序列化,父类变量是否会被序列化,应该怎么实现父类的变量也能序列化?
  • 自定义序列化怎么实现?writeObject\readObject都是private方法,为什么能被运行?
  • 单例怎么实现序列化,为什么枚举优于readResolve方法?
  • 怎么实现序列化代理,什么情况下需要用到序列化代理,它使用有哪些注意事项?

相信读完此博文,上面的这些问题,你应该都能立马答出来吧,如果不能答出来,说明你还要细细研究哦,虽说这些知识平时写应用时很少用到,可是要是真遇到问题了,那就大不一样了。

猜你喜欢

转载自blog.csdn.net/smileiam/article/details/75315005