十、对象序列化(object serialization)

简单概念
序列化:将对象编码成字节流
反序列化:从字节流编码中重新构建对象
序列化代理模式是重点。它可以帮助我们避免对象序列化的许多缺陷。

第74条 谨慎地实现 Serializable 接口

一个类实现了Serializable接口后,一旦被发布,就大大降低了“改变这个类的实现”的灵活性。
如果使用默认的序列化形式,这个类中私有的和包级私有的实例域都会成为导出API的一部分,打破了封装,不能隐藏实现细节。

如果使用默认的序列化形式,并改变了类的内部表示法。用这个类的旧版本序列化一个类,再用新版本反序列化,将导致程序失败。
解决方法:

  1. 使用ObjectOutputStream.putFields、readFields,这种做法比较困难,并会在代码中留下一些隐患。
  2. 设计一种高质量的序列化形式,而不是用默认的序列化方法。高质量的序列化形式???

使类序列化,第一个缺点是,序列化会使类的演变受到限制。
如:流唯一标识符(stream unique identifier)每个可序列化的类都有一个唯一的标识号与它关联。如果没有声明serialVersionUID域,系统就会自动地根据这个类来调用一个复杂的运算过程,在运行时产生该标识号。运算过程会受到类名称、所实现的接口名称、所有的公有、受保护的成员名所影响。如果通过任何方式改变了这些信息,自动产生的序列版本UID也会发生变化。如果没有声明一个显式的序列版本UID,兼容性会受到破坏,导致InvalidClassException

使类序列化,第二个缺点是,增加了出现Bug和安全漏洞的可能性。
序列化是一种语言外的对象创建机制(extralinguistic mechanism),反序列化是一个“隐藏的构造器”,具备与其他构造器相同的特点。
因为反序列化没有显示的构造器,所以容易忘记:要确保反序列化过程也必须保证类内元素的约束关系。使用默认的反序列化机制,很容易使对象的约束关系遭到破坏,及受到非法访问。

使类序列化,第三个缺点是,随着新版本的类发行,测试负担也增加了。
测试所需要的工作量与发行版本数成乘积正比关系,测试序列化与反序列化总次数为 n*n-1次,n是版本数量。测试内容包括两项:序列化与反序列化成功、产生的对象是原始对象的复制品。

因为上述三个缺点,所以考虑实现 Serializable 接口时,需要非常谨慎。

实现序列化有什么好处呢?什么时候需要序列化呢?什么时候要避免序列化呢?
如果一个类要加入到某个框架中,并且该框架依赖序列化来实现对象传输或持久化,此时序列化就很有必要。

根据经验:
比如Date BigInteger这样的值类应该实现Serializable,大多数的集合类也应该如此。Bean值类可以考虑实现。
代表活动实体的类,如线程池,一般不应该实现Serializable。

为了继承而设计的类应该尽可能少地去实现 Serializable,为程序员用户提供的接口也应该尽可能少地实现 Serializable。如果违反了这条规则,扩展这个类或实现这个接口的程序员就会背上沉重的负担。

内部类不要实现 Serializable,因为它引用的外围实例的序列化行为不可控。
内部静态类可以实现 Serializable。

确保对象完整性与可用性:是否已经初始化完毕,对象状态是否正确
使用原子引用实现一个 线程安全状态机(thread-safe state machine)

如果一个专门为了继承而设计的类,没有实现Serializable,并且没有提供可访问的无参构造器,那么子类将不能实现可序列化。

因此,对于为了继承而设计的不可序列化的类,应该考虑提供一个无参构造器,为子类实现可序列化提供可能。

而盲目地为一个类增加无参构器和单独的初始化方法,而它的约束关系仍由其他构造器来建立,这样做会使该类的状态更加复杂,增加出错的可能性。怎样解决此问题,参考以下代码:

// 父类
public class Fruit {
        private int weight;
        private float price;

        Fruit(){}// 父类注意声明一个无参构造器

        Fruit(int w, float p){
            initialize(w, p);
        }

        protected void initialize(int w, float p){
            this.weight = w;
            this.price = p;
        }

        public int getWeight(){
            return weight;
        }
        public float getPrice() {
            return price;
        }
    }

	// 子类
    private static class Pear extends Fruit implements Serializable {

        private String name;

        public Pear(int w, float p, String pear){
            super(w, p);
            this.name = pear;
        }

        public String getName() {
            return name;
        }

        private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
            s.defaultReadObject();

            int weight = s.readInt();  // 手动读出父类属性
            float price = s.readFloat();
            initialize(weight, price);
        }

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

            s.writeInt(getWeight());  // 手动写入父类属性
            s.writeFloat(getPrice());
        }

    }

简单说,千万不要认为实现Serializable 接口会很容易。实现Serializable 接口是个很严肃的承诺,必须认真对待。如果一个类是为了继承设计的,则需要加倍小心,在“允许子类实现 Serializable 接口”或 “禁止子类实现Serializable接口”之间一个折中设计方案是,提供一个可访问的无参构造器。这种方式允许但不要求子类实现 Serializable 接口。

第75条 考虑使用自定义的序列化形式

不要贸然使用默认的序列化方式。
一般来讲,只有当自定义序列化形式与默认的序列化形式基本相同时,才能接受默认的序列化形式。

考虑以一个对象为根的对象图,理想的序列化形式应该只包含该对象表示的逻辑数据,逻辑数据与物理表示法是独立的。如果一个对象的物理表示法等同于逻辑内容,可能就适合于使用默认的序列化形式。

即便确定了默认的序列化形式是合适的,也必须还要提供一个readObject方法以保证约束关系和安全性。
被序列化的域,即便是私有的,也要写相应的注释,并用@serial标签告诉Javadoc工具,将文档信息放在有关序列化形式的特殊文档页中。

逻辑数据 与 物理表示法指什么?
举例:一个类表示一个字符串序列,这个字符串是逻辑数据。但从物理表示法上,把该序列表示为一个双向链表。
理想的序列化,只关心这个字符串,而不关心它的存储方式。

当逻辑数据 与 物理表示法 大不同时,使用默认序列化形式时有4大缺点:

  1. 它使这个类的导出API永远地束缚在该类的内部表示法上。
  2. 消耗过多的空间。序列化的过程会解析所有引用的对象及引用对象的域,如此扩散出去。典型的结构是链表。
  3. 消耗过多的时间。序列化过程会做一个昂贵的图遍历(traversal)过程。如上第2条。
  4. 引起栈溢出。默认的序列化程要对Object的对象图执行一次递归遍历,即便对于一个中等规模的对象图,这样的操作也可能会引起栈溢出。多少个元素会引发栈溢出与JVM具体实现与启动参数有关。

综合以上,一般不要使用默认的序列化形式,除非类结构非常简单。

使用关键字 transient 标记一个实例域,表明此实例域将从默认的序列化过程中省略掉。

自定义序列化方法:

  1. 实现 writeObject、readObject两个方法。方法实现的第一句是 defaultWriteObject()、defaultReadObject(),即使类的所有域都是 transient 的。

defaultWriteObject()、defaultReadObject() 调用它俩,可以增强类的灵活性,允许在以后的发行版本中增加非 transient 的实例域,并且能保持向前或向后的兼容性:如果一个新版本的实例被序列化,然后被反序列化成旧版本的对象,那么新版本中增加的域将被忽略掉。如果旧版本中readObject 方法没有调用 defaultReadObject ,反序列化过程将失败,抛出 StreamCorrupted Exception 。

writeObject、readObject 也要写文档注释,并用 @serial 标记。

  1. 每个可以被标记为 transient 的实例域都应该做上标记。
    包括类中的冗余的域,就是可以通过其他值计算出来的。
    包括非稳定状态的域(其值依赖于JVM某一次运行),如一个long值代表一个指向本地数据结构的指针。
    如果使用自定义序列化,类中大多数域或所有域都应该被标记为 transient ,除了表示逻辑数据的。
    如果使用默认的序列化,需要注意反序列化出来的对象,其 transient 域的值将被初始化为类型默认值,如:对象引用域为null,数值域为0等。

  2. 无论哪种序列化形式,都要声明一个显式的序列版本号 UID(serial version ID)
    可以避免版本不同成为不兼容的原因。也能带来小小的发性能好处。如果没有显式提供UID,就需要在运行时,通过一个高开销的计算过程产生一个UID。

核心词汇:
对象的物理表示法 与 逻辑数据内容

defaultWriteObject()
defaultReadObject()
transient
private static final long serialVersionUID = 2503101005223361881L;

最后强调一次:设计一个支持序列化的类是一件严肃的事,应该分配足够多的时间去设计。因为如果类被公开以后,尤其被大量客户使用后,你无法在将来的版本中去掉公有方法,也不能去掉序列化形式中的域;它们必须被永久地保留下去,以确保序列化兼容性(serialization compalibility),选择错误的序列化形式对于一个类的复杂性、灵活性、性能都会有永久的负面影响。一个保守的决定是所有序列化类都采用自定义序列化的形式实现。

第76条 保护性的编写readObject方法(针对安全性)

readObject 方法实际上相当于另一个公有的构造方法。与一般的构造方法一样,也需要注意 检查参数有效性,必要时对参数进行保护性拷贝。否则,对于攻击者来说,要违反此类的约束条件就比较简单了。

最优做法:

  1. 实现 readObject 方法
  2. 首先调用 defaultReadObject
  3. 然后如有必要,就要做保护性拷贝
  4. 然后进行参数有效性校验,如果检查失败则抛出 InvalidObjectException 异常,使程序迅速失败。

对象被反序列后,约束条件没有被破坏,但是如果内部对象的引用暴露了,要随意修改它的内部组件还是有可能的。一旦攻击者构建了一个内部数据非法的对象,并将它传给一个“安全性依赖于此类不可变性”的类,从而造成更大的危害。这种推断是很实际的,如 有许多类的安全性就是依赖于 String 的不可变性。
问题根源在于,反序列化的 readObject 方法并没有做保护性拷贝。当一个对象被反序列化时,对于客户端不应该拥有的对象引用,如果哪个域包含了这样的引用,就必须要做保护性拷贝,杜绝内部对象的引用被外部非法持有,这是非常重要的。

所以,对于每个可序列化的不可变类,如果它包含了私有的可变组件(一般是对象引用),那么在它的 readObject 方法中,必须要对这些组件进行保护性拷贝。

最后,每当编写 readObject 方法的时候,注意:你正在编写一个公有构造方法,无论给它传递什么样的字节流,它都必须产生一个有效的实例。

实现指导:

  1. 实现 java.io.ObjectInputValidation 接口(见下面代码),以在readObject 构造完对象以后,对对象进行校验,校验失败 抛出 InvalidObjectException。
  2. 在 readObject 方法中,无论直接还是间接,都不要调用类中任何可被覆盖的方法。
public interface ObjectInputValidation {
    /**
     * Validates the object.
     *
     * @exception InvalidObjectException If the object cannot validate itself.
     */
    public void validateObject() throws InvalidObjectException;
}

第 77 条 对于实例控制,枚举类型优先于 readResolve
暂时略。

第 78条 考虑用“序列化代理”代替序列化实例

先看代码:

public class SerializeProxyTest {

    public static void main(String[] args) {
        String path = "D:\\uida0269\\Desktop\\aa.txt";

        Fruit fruit = new Fruit(20, 1.5f, new Date());
        ObjectOutputStream objectOutputStream = null;
        try {
            objectOutputStream = new ObjectOutputStream(new FileOutputStream(path));
            objectOutputStream.writeObject(fruit);
        } catch (IOException ex) {
            ex.printStackTrace();
        } finally {
            try {
                if (objectOutputStream != null) {
                    objectOutputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        ObjectInputStream objectInputStream = null;
        try {
            System.out.println("begin:" + System.nanoTime());
            objectInputStream = new ObjectInputStream(new FileInputStream(path));
            Fruit fruit1 = (Fruit) objectInputStream.readObject();
            System.out.println("end:" + System.nanoTime());
            System.out.println("weight: " + fruit1.getWeight() + " price: " + fruit1.getPrice()
                    + " date: " + fruit1.getProductDate().toString());
        } catch (IOException | ClassNotFoundException ex) {
            ex.printStackTrace();
        } finally {
            try {
                if (objectInputStream != null) {
                    objectInputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public static class Fruit implements Serializable {
        private final int weight;
        private final float price;
        private final Date productDate;

        public Fruit(int w, float p, Date pDate){
            this.weight = w;
            this.price = p;
            this.productDate = pDate;
        }

        public int getWeight(){
            return weight;
        }
        public float getPrice() {
            return price;
        }
        public Date getProductDate() {
            return productDate;
        }

        // 序列化输出 FruitSerializationProxy 的实例
        private Object writeReplace(){
            return new FruitSerializationProxy(this);
        }

        // 防止攻击者 通过反序列化 构造类的虚假实例
        private void readObject(ObjectInputStream stream) throws InvalidObjectException {
            throw new InvalidObjectException("Proxy required");
        }

        private static class FruitSerializationProxy implements Serializable{

            private final int weight;
            private final float price;
            private final Date productDate;

            FruitSerializationProxy(Fruit fruit) {
                this.weight = fruit.getWeight();
                this.price = fruit.getPrice();
                this.productDate = fruit.getProductDate();
            }

            // 反序列化输出对象时,转为Fruit对象
            private Object readResolve() {
                return new Fruit(weight, price, productDate);
            }
        }
    }
}

序列化代理模式(Serialization proxy pattern),可以极大地减少由反序列化带来的对类对象构造攻击的风险。
序列化代理模式的优点:

  1. 与保护性拷贝一样,可以阻止伪字节流攻击
  2. 此模式允许类的域为 final的
  3. 考虑较少,不用考虑哪些域会受到攻击
  4. 反序列化时,也不用显式执行 有效性检查
  5. 允许反序列化出来的对象与原始对象不是同一个类。如 EnumSet 的实现
private static class SerializationProxy <E extends Enum<E>>
        implements java.io.Serializable 
{
	private Object readResolve() {
		EnumSet<E> result = EnumSet.noneOf(elementType);
		for (Enum<?> e : elements)
			result.add((E)e);
		return result;
	}
}

// class EnumSet: 
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
	Enum<?>[] universe = getUniverse(elementType);
	if (universe == null)
		throw new ClassCastException(elementType + " not an enum");

	if (universe.length <= 64)
		return new RegularEnumSet<>(elementType, universe);
	else
		return new JumboEnumSet<>(elementType, universe);
}

“序列化代理模式”的局限性:

  1. 不能使用在 为继承而设计的类上
  2. 不能与对象图中包含循环的某些类兼容:如果企图从一个对象的序列化代理的 readResolve 方法内部调用源对象中的方法,就会得到一个ClassCastException 异常,因为此时还反序列化出这个对象,只有它的序列化代理。

最后,如果要序列化一个 final 类,最适合使用“序列化代理模式”了。要想稳健地将带有重要约束条件的对象序列化时,这种模式是最容易实现的方式。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

洛克Lee

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值