你可能已忽略的关于序列化细节

1,720 阅读11分钟

原创文章&经验总结&从校招到 A 厂一路阳光一路沧桑

详情请戳www.codercc.com

1. 谨慎实现serializable接口

  • 问题

    序列化过程是将“一个对象编码成一个字节流”,相反的处理过程被称之为“反序列化过程”。当一个对象被序列化后,它的编码就可以从一台虚拟机传至另一个台虚拟机,可以被保存在磁盘上,方便以后反序列化使用。长期以来有一个误解,为了实现序列化,只需要实现Serializable接口即可,事实上这种方式存在诸多危害,贪图这种序列化方式的方便,会带来长期维护的成本。关于Serilizable有哪些注意事项?

  • 答案

    1. Serializable的缺点

      直接实现Serializable接口有如下这些缺点:

      • 降低灵活性:如果一个类实现了Serializable接口,它的字节流编码也变成了它导出API的一部分,一旦这个类被广泛使用,就必须永远支持这种序列化方法。并且,如果使用了默认的Serializable,**这个类中私有的和包级私有的实例域都会变成导出的API的一部分,这不符合域最小访问级别的设计原则。**另外,如果改变了类的内部结构的话,客户端企图用类的旧版本来序列化,而使用新版本来进行反序列化的话,程序就会出错。

        如果被序列化的类没有显示的指定serialVersionUID标识(序列版本UID),系统会自动根据这个类来调用一个复杂的运算过程生成该标识。此标识是根据类名称、接口名称、所有公有和受保护的成员名称生成的一个标志号。如果改变了类的内部结构,如添加了一个方法,自动产生的序列版本UID也会发生变化。因此,如果没有显式的声明一个版本号的话,兼容性就会遭到破坏,在运行时导致InvalidClassException。

      • 更容易引发Bug和安全漏洞:一般对象是由构造器创建的,而序列化也是一种对象创建机制,反序列化也可以构造对象。由于反序列化机制中没有显式的构造器,反序列化要确保:由真正的构造器建立的约束关系,并且不允许攻击者访问正在构造过程中的对象的内部信息。依靠默认的反序列化机制,很容易使对象的约束关系遭到破坏,以及遭受到非法访问。

      • 相关测试负担加重:当一个可序列化的类被修改后,需要检查“在新版中序列化一个实例,在旧版本中反序列化”及“在旧版本中序列化一个实例,在新版本反序列化”是否正常,当发布版本增多时,这种测试量与“可序列化的类的数量和发行版本号”的乘积成正比。

    2.Serializable适用场景

    若一个类要加入某个框架,而该框架是依赖序列化来实现对象的传输和持久化,那么该类实现Seriablizable就是有必要,更进一步来看,一个类属于一个组件,如果父组件实现了Seriablizable接口,那么该类也需要实现Seriablizable接口。根据经验,比如Date和BigInteger这样的值类应该实现Serializable,大多数集合类也需要实现。

    3.Serializable不适用场景

    • 为了继承而设计的类应该尽可能少地去实现Serializable接口,用户接口也应该尽可能不继承Serializable接口,原因是子类或实现类也要承担序列化的风险。大多数情况下需要遵守这条原则,极为特殊的情况可以打破这项原则,比如实现Serializable接口的的类有Throwable类(异常可以从服务器端传到客户端)、Component类(GUI可以被发送、保存和恢复)、HttpServlet抽象类(会话session可以被缓存);
    • 内部类不应该实现Serializable,内部类需要保存指向外部类实例的引用及保存来自外部作用域的局部变量的值。这些域如何对应到类定义中不确定。因此内部类的默认序列化形式定义不清楚。
  • 结论

    总之,千万不要将序列化就等同于简单的实现Serilizable接口,应该要考虑着重考虑Seriablizable的应用场景和上面所述的这些注意事项。

2. 考虑使用自定义的序列化形式

  • 问题

    设计一个类的序列化形式和设计该类的API 同样重要,因此在没有认真考虑好默认的序列化形式是否合适之前,不要贸然使用默认的序列化行为。在作出决定之前,你需要从灵活性、性能和正确性多个角度对这种编码形式进行考察。一般来讲,只有当你自行设计的自定义序列化形式与默认的形式基本相同时,才能接受默认的序列化形式。选择合适的序列化方式,有哪些注意事项?

  • 答案

    1. 默认的序列化形式描述了该兑现内部所包含的数据,以及每一个可以从这个对象到达的其他对象的内部数据,即完整了描述了所有对象被链接起来的拓扑结构。对于一个对象来说,理想的序列化形式应该只包含该对象所表示的逻辑数据,而逻辑数据和物理表示应该是相互独立。也就是说,如果一个对象的物理表示等同于它的逻辑内容的话,就适合使用默认的序列化形式。 有这样一个例子

      public class Name implements Serializable { 
            private final String lastName; 
            private final String firstName; 
            private final String middleName; 
            ... ... 
        }
      

      从逻辑的角度而言,Name类可以简单的由lastName、firstName以及middleName三个属性来进行表示,也就是说,这三个属性可以精确的反映出它的逻辑内容。因此,这种情况可以采用默认的序列化形式,同样也需要在readObject中进行参数有效性检测和保护性拷贝。

    2. 使用默认序列化形式,当一个或多个域字段被标记为transient 时,如果要进行反序列化,这些域字段都将被初始化为其类型默认值,如对象引用域被置为null,数值基本域的默认值为0,boolean域的默认值为false。如果这些值不能被任何transient 域所修饰,你就必须提供一个readObject方法。它首先调用defaultReadObject,然后再把这些transient 域进行恢复为之前的初始值;同样的,在序列化过程中,被transient修饰的实例域会被省略掉

    3. 在序列化过程中,虚拟机会试图调用对象类里的writeObject() 和readObject(),因此可以在readObject和writeObject方法中实现自己的序列化逻辑。就算没有实现特定的逻辑也应该调用默认的ObjectOutputStream.defaultWriteObject() 和ObjectInputStream.defaultReadObject()方法,这样就可以保证向前或者向后的兼容性;

    4. 无论你选择了哪种序列化形式,都要为自己编写的每个可序列化的类声明一个显式的序列版本UID。这样可以避免序列版本UID成为潜在的不兼容根源,同时也会带来小小的性能好处,因为不需要去算序列版本UID。

  • 结论

    当你决定将一个类设计成可序列化的时候,就应该详细考虑应该采用什么样的序列化形式。只有当默认的序列化形式能够合理的描述对象的逻辑状态时,才能使用默认的序列化形式。否则就要设计一个自定义的序列化形式,通过它合理的描述出对象的状态。

3. 谨慎使用readObject方法

  • 问题

    为了让程序更加安全可靠,需要针对可变域在构造器和访问方法中进行保护性拷贝,例如下面的代码:

    public final static class Period{
        private final Date start;
        private final 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 + "after" +end);
            }
        }
        public Date getStart() {
            return new Date(start.getTime());
        }
        public Date getEnd() {
            return new Date(end.getTime());
        }
    }
    

    但是如果将这个类进行序列化的时候,就可能这个类会出现不满足start和end的约束关系了,那么,应该怎样保证在序列化的时候也能保障对象的关键约束关系?

  • 答案

    除了构造器构造对象外,反序列化也是一种构造对象的方式,因此,也需要在构造对象的时候进行参数有效性检查以及保护性拷贝。所以在readObject方法也需要确保Period的关键约束不变以及保持它的不可变性:

  private void readObject(ObjectInputStream s)
  throws IOException, ClassNotFoundException {
      s.defaultReadObject();
      // Defensively copy our mutable components
      start = new Date(start.getTime());
      end = new Date(end.getTime());
      // Check that our invariants are satisfied
      if (start.compareTo(end) > 0)
          throw new InvalidObjectException(start +" after "+ end);
      }
  }

并且需要注意的是,保护性拷贝在参数有效性检查的前面,并且不能使用clone方法进行拷贝对象。

  • 结论

    总而言之,每当你编写readObject方法的时候,都要这样想:你正在编写一个公有的构造器,无论给它传递什么样的字节流,它都必须产生一个有效的实例。下面这些经验,有助于编写出更加健壮的readObject方法:

    1. 对于对象引用域必须保持为私有的,要保护性地拷贝这些域中的每个对象。不可变类的可变组件就属于这一类别;
    2. 对于任何约束条件,如果检查失败,则抛出一个InvalidObjectException异常。这些检查动作应该跟在所有的保护性拷贝之后;
    3. 如果整个对象图在被反序列化之后必须进行验证,就应该使用ObjectInputValidation接口;
    4. readObject方法中都不要调用可覆盖的方法,无论是间接的方式还是直接的方式

4. 使用枚举实现单例

  • 问题

    针对Singleton,最简单的一种方式是:

    public class Elvis {
        public static final Elvis INSTANCE = new Elvis();
        private Elvis() { ... }
        public void leaveTheBuilding() { ... }
    }
    

    如果类被序列化了,不论是采用默认的序列化方式还是采用自定义的序列化方式,或者在readObject方法中进行所谓的处理,这个类都将不会是单例的了。那么针对这种要满足可序列化的单例应该怎样实现?

  • 答案

    要满足可序列化的单例,有两种方式:

    1. 利用readResolve方法:readResolve特性允许你用readObject创建的实例代替另一个实例。对于一个正在被反序列化的对象,如果它的类定义了一个readResolve方法,并且具备正确的声明,那么在反序列化之后,新建对象上的readResolve方法就会被调用。然后,该方法返回的对象引用将被返回,取代新建的对象。因此,在每次反序列化的时候,就可以在readResolve方法中返回之前的实例对象,这样就可以确保被多次反序列化后的对象也只会有一个。示例代码为:

      // readResolve for instance control - you can do better!
      private Object readResolve() {
          // Return the one true Elvis and let the garbage collector
          // take care of the Elvis impersonator.
          return INSTANCE;
      }
      

      该方法忽略了被反序列化的对象,只返回该类初始化时创建的那个特殊的Elvis实例。事实上,如果依赖readResolve进行实例控制,带有对象引用类型的所有实例域则都必须声明为transient的。否则,利用readResolve方法实现的单例也会遭受到攻击。

    2. 采用枚举实现:可以采用枚举实现可序列化的单例,这种安全性由JVM提供保障,而且代码十分简洁,实例域也不需要用transient修饰:

      // Enum singleton - the preferred approach
      public enum Elvis {
          INSTANCE;
          private String[] favoriteSongs ={ "Hound Dog", "Heartbreak Hotel" };
          public void printFavorites() {
              System.out.println(Arrays.toString(favoriteSongs));
          }
      }
      
  • 结论

    实现可序列化最简单安全的方式是采用枚举的形式,应该尽可能采用这种方式。如果采用readResolve实现的话,可以确保该类的所有实例域都为基本类型,或者是transient的。