带你快速看完9.8分神作《Effective Java》—— 序列化篇(所有RPC框架的基石)

Posted 小王曾是少年

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了带你快速看完9.8分神作《Effective Java》—— 序列化篇(所有RPC框架的基石)相关的知识,希望对你有一定的参考价值。

🔥 Java学习:Java从入门到精通总结

🔥 Spring系列推荐:Spring源码解析

📆 最近更新:2022年1月20日

🍊 个人简介:通信工程本硕💪、阿里新晋猿同学🌕。我的故事充满机遇、挑战与翻盘,欢迎关注作者来共饮一杯鸡汤

🍊 点赞 👍 收藏 ⭐留言 📝 都是我最大的动力!

文章目录

85 其他方法优先于Java序列化

🔥 先说结论:

  1. 序列化是危险的,应该避免

  2. 如果从头开始设计一个系统,可以使用跨平台的结构化数据,如JSONprotobuf

  3. 如果必须编写可序列化的类,要加倍小心地进行试验


序列化的一个根本问题是它的可攻击范围太大,且难以防御:通过调用ObjectInputStream上的readObject方法反序列化对象。可以用来实例化类路径上任何类型的对象,只要该类型实现Serializable接口,有了实例化之后的对象,就可以执行这些类的代码,因此所有这些类都在攻击范围内。


static byte[] bomb() 
	Set<Object> root = new HashSet<>();
	Set<Object> s1 = root;
	Set<Object> s2 = new HashSet<>();
	for (int i = 0; i < 100; i++) 
		Set<Object> t1 = new HashSet<>();
		Set<Object> t2 = new HashSet<>();
		t1.add("foo"); // Make t1 unequal to t2
		s1.add(t1); s1.add(t2);
		s2.add(t1); s2.add(t2);
		s1 = t1;
		s2 = t2;
	
	return serialize(root); // Method omitted for brevity

对象图由201个HashSet实例组成,整个流的⻓度为5744字节,但是在对其进行反序列化之前,资源就已经耗尽了。问题在于,反序列化HashSet实例需要计算其内部元素的哈希码,深度为100,反序列化Set会导致hashCode方法被调用超过2^100次。


避免序列化利用的最好方法是永远不要反序列化任何东西。还有其他一些机制可以在对象和字节序列之间进行转换,从而避免了Java序列化的许多危险。

这些方法共同点是它们比Java序列化简单得多。它们不支持任意对象图的自动序列化和反序列化。而是支持简单的结构化数据对象,由一组「属性-值」对组成。只支持少数基本数据类型和数组数据类型。


最前沿的跨平台结构化数据表示是JSONprotobuf

JSONprotobuf之间最显著的区别是JSON是基于文本的,而protobuf是二进制的,但效率更高;

如果你不能完全避免Java序列化,这时的最佳选择是永远不要反序列化不可信的数据


86 谨慎实现 Serializable 接口

🔥 先说结论:

  1. 除非一个类只在受保护的环境下使用(版本之间无交互、服务器不会暴露给不可信任的人),否则必须认真考虑是否要实现 Serializable 接口

  2. 如果一个类允许继承,更要加倍小心

  3. 除了静态成员类之外,内部类不要实现这个接口


实现 Serializable 接口的一个主要代价是,一旦类的实现被发布,它就会降低修改灵活性

即使是设计良好的序列化形式,也会限制类的演化;而设计不良的序列化形式,则可能会造成严重后果。

可序列化会使类的演变受到限制,因为每个可序列化的类都有一个与之关联的唯一标识符UID(serial version UID)。

UID是自动产生的,这个值受到类的名称、实现的接口及其大多数成员的影响。例如,通过添加一个临时的方法,生成的序列版本UID就会更改。


实现 Serializable 接口的第二个代价是,增加了出现bug和安全漏洞的可能性

序列化是一种语言之外的对象创建机制。依赖默认的反序列化机制,会让对象容易收到不变性破坏和非法访问。


实现 Serializable 接口的第三个代价是,如果要发布类的新版本,相关的测试负担就会增加

当一个可序列化的类被修改时,重要的是检查是否可以在新版本中序列化一个实例,并在旧版本中反序列化它。

如果在第一次编写类时精心设计了自定义序列化形式,那么测试的工作量就会减少,后面会讨论到


如果一个类要参与一个框架,该框架依赖于Java序列化来进行对象传输或持久化,这对于类来说实现Serializable 接口就是非常重要的。

根据经验,像BigIntegerInstant 这样的值类实现了 Serializable 接口,集合类也实现了 Serializable 接口。表示活动实体(如线程池)的类很少情况适合实现 Serializable 接口。


为继承而设计的类应该尽量不实现 Serializable 接口,接口也应该尽量不继承 Serializable

在为了继承而设计的类中,Throwable 类和 Component 类都实现了 Serializable 接口。正是因为
Throwable 实现了 Serializable 接口,RMI可以将异常从服务器发送到客户端,这其实是不好的。


内部类不应该实现Serializable,静态成员类可以实现这个接口。


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

即如何实现一个自定义的序列化形式,阿里内部最经典的RPC框架HSF其中有一大块就是序列化和反序列化的设计,所以这项技术有很高的实战价值,这里也给出了一些建议。

🔥 先说结论:

  1. 只有当默认的序列化形式能合理描述对象的逻辑状态时,才使用默认的序列化形式

  2. 其他情况,应该设计一个自定义的序列化形式,通过它来合理地描述对象的状态


如果对象的物理表示与其逻辑内容相同,则默认的序列化形式是合适的

例如,默认序列化形式对于Name类来说是合理的,它只表示一个人的名字:

public class Name implements Serializable 
    /**
     * Last name. Must be non-null.
     *
     * @serial
     */
    private final String lastName;

    /**
     * First name. Must be non-null.
     *
     * @serial
     */
    private final String firstName;

    /**
     * Middle name, or null if there is none.
     *
     * @serial
     */
    private final String middleName;

    public Name(String lastName, String firstName, String middleName) 
        this.lastName = lastName;
        this.firstName = firstName;
        this.middleName = middleName;
    

    // Remainder omitted

@serial 告诉Javadoc将此文档放在一个特殊的⻚面上,该⻚面记录序列化的形式。

从逻辑上讲,名字由三个字符串组成:姓、名和中间名。Name的实例字段精确地反映了这个逻辑内容。

此外,还必须提供readObject方法来确保约束关系和安全性。对于 Name 类而言,readObject 方法必须确保字段lastNamefirstName是非null的。


下面的类表示了一个字符串列表:

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;
	
	... // Remainder omitted

从逻辑上讲,这个类表示字符串序列。在物理上,它将序列表示为双向链表。当对象的物理表示与其逻辑数据内容有很大差异时,使用默认的序列化形式有四个缺点

  1. 它将导出的API永久地束缚在该类的内部实现上

在上面的例子中,私有StringList.Entry类成为公共API的一部分。如果在将来的版本中更改了实现,StringList 类仍然需要接受链表形式的输出,并产生链表形式的输出。这个类永远也摆脱不掉处理链表项所需要的所有代码,即使不再使用链表作为内部数据结构。

  1. 会占用过多的空间

这些链表项以及链接只不过是实现细节,不值得记录在序列化形式中。

  1. 会消耗过多的时间

序列化逻辑不知道对象图的拓扑结构,因此必须经过一个高开销的遍历过程。在上面的例子中,只要沿着next遍历就足够了。

  1. 可能导致堆栈溢出

StringList的合理序列化形式只需要包含列表中的字符串数量和字符串本身即可。这构成了由StringList表示的逻辑数据,去掉了其物理表示的细节。下面是修改后的StringList版本:

public final class StringList implements Serializable 
    private transient int size = 0;
    private transient Entry head = null;
    // No longer Serializable!

    private static class Entry 
        String data;
        Entry next;
        Entry previous;
    

    // Appends the specified string to the list
    public final void add(String s) 

    /**
     * Serialize this @code StringList instance.
     *
     * @serialData The size of the list (the number of strings
     * it contains) is emitted (@code int), followed by all of
     * its elements (each a @code String), in the proper
     * sequence.
     */
    private void writeObject(ObjectOutputStream s) throws IOException 
        s.defaultWriteObject();
        s.writeInt(size);
        // Write out all elements in the proper order.
        for (Entry e = head; e != null; e = e.next)
            s.writeObject(e.data);
    

    private void readObject(ObjectInputStream s) throws IOException,
            ClassNotFoundException 
        s.defaultReadObject();
        int numElements = s.readInt();
        // Read in all elements and insert them in list
        for (int i = 0; i < numElements; i++)
            add((String) s.readObject());
    
    // Remainder omitted

transient 修饰符表示要从类的默认序列化表单中省略该实例字段

writeObject 做的第一件事是调用defaultWriteObjectreadObject 做的第一件事是调用
defaultReadObject,即使StringList 的所有字段都是transient 的。

序列化规范要求你无论如何都要调用它们。这些调用的存在使得在以后的版本中添加非transient实例字段成为可能,同时保留了向后和向前兼容性。

方法的@serialData标记告诉Javadoc实用工具将此文档放在序列化形式的文档页面上。


如果使用默认的序列化形式,并且标记了一个或多个字段为 transient,请记住,当反序列化实例
时,这些字段将初始化为默认值。如果字段不能被设置为初始值,就必须提供一个readObject方法,该方法调用defaultReadObject方法,然后将transient字段恢复为可接受的值。

另一种方法是延迟加载。


如果在反序列化对象的方法上加了同步,则也必须在对象序列化的方法上加上同步。如果你有一个线程安全的对象,它通过同步每个方法来实现线程安全,并且你选择使用默认的序列化形式,那么使用以下writeObject方法:

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


无论选择哪种序列化形式,都要在编写的每个可序列化类中声明UID。只需添加一行:

private static final long serialVersionUID = randomLongValue;

一旦写好了randomLongValue之后就不要更改序列版本UID。


88 保护性地编写 readObject 方法

🔥 先说结论:

下面的几点有助于编写更健壮的 readObject 方法:

  1. 对于类中的private对象引用字段,要保护性的拷⻉这些字段中的每个对象。不可变类中的可变组件就属于这一类别

  2. 对于任何约束条件,如果检查失败就抛出一个InvalidObjectException异常。这些检查动作应该
    跟在所有的保护性拷⻉之后

  3. 如果整个对象图在被反序列化之后必须进行验证,就应该使用ObjectInputValidation接口

  4. 无论是直接方法还是间接方法,都不要调用类中任何可被覆盖的方法


之前编写过一个日期类:

public 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(this.start + " after " + this.end);
    

    public Date start() 
        return new Date(start.getTime());
    

    public Date end() 
        return new Date(end.getTime());
    

    public String toString() 
        return start + " - " + end;
    

假设要把这个类成为可序列化的。因为Period对象的物理表示法正好反映了它的逻辑数据内容,所以,使用默认的序列化形式是合理的。

实际上,如果仅在类的声明中增加implements Serializable,这个类就不再保证它的关键约束了。

问题在于readObject方法实际上相当于另外一个公有的构造器,它要求同其他构造器一样:必须检查其参数的有效性,并且在必要的时候对参数进行保护性拷⻉。


假设我们仅仅在Period类的声明加上了implements Serializable,这个代码可能会产生一个Period实例,他的结束时间比起始时间还早。

public class BogusPeriod 

    private static final byte[] serializedForm = 
            (byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
            0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte) 0xf8,
            0x2b, 0x4f, 0x46, (byte) 0xc0, (byte) 0xf4, 0x02, 0x00, 0x02,
            0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
            0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
            0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
            0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
            0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
            0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
            (byte) 0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
            0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte) 0xdf,
            0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
            0x77, 0x08, 0x00, 0x00, 0x00, (byte) 0xd5, 0x17, 0x69, 0x22,
            0x00, 0x78
    ;

    public static void main(String[] args) 
        Period p = (Period) deserialize(serializedForm);
        System.out.println(p);
    

    // Returns the object with the specified serialized form
    static Object deserialize(byte[] sf) 
        try 
            return new ObjectInputStream(
                    new ByteArrayInputStream(sf)).readObject();
         catch (IOException | ClassNotFoundException e) 
            throw new IllegalArgumentException(e);
        
    

如果运行这个程序,它会打印出「Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984」

为了修整这个问题,可以为Period提供一个readObject方法,该方法首先调用defaultReadObject,然后检查被反序列化之后的对象有效性。

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException
	s.defaultReadObject();
	// Check that our invariants are satisfied
	if (start.compareTo(end) > 0)
		throw new InvalidObjectException(start +" after "+ end);

这里依旧隐藏着一个更为微妙的问题。通过伪造字节流,要想创建可变的Period实例仍是有可能的,做法是:

  • 字节流以一个有效的Period实例开头,然后加上两个额外的引用,指向Period实例中的startend。攻击者从ObjectInputStream中读取Period实例,然后读取附加在其后面的引用。这些对象引用使得攻击者能够访问到Period对象内部的startend,攻击者可以改变Period实例。
public class MutablePeriod 
    public final Period period;
    // period's start field, to which we shouldn't have access
    public final Date start;
    // period's end field, to which we shouldn't have access
    public final Date end;

    public MutablePeriod() 
        try 
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(bos);
            // Serialize a valid Period instance
            out.writeObject(new Period(new Date(), new Date()));
            /*
             * Append rogue "previous object refs" for internal
             * Date fields in Period. For details, see "Java
             * Object Serialization Specification," Section 6.4.
             */
            byte[] ref = 0x71, 0, 0x7e, 0, 5;
            // Ref #5
            bos.write(ref);
            // The start field
            ref[4] = 4;
            // Ref # 4
            bos.write(ref);
            // The end field
            // Deserialize Period and "stolen" Date references
            ObjectInputStream in = new ObjectInputStream(
                    new ByteArrayInputStream(bos.toByteArray()));
            period = (Period) in.readObject();
            start = (Date) in.readObject();
            end = (Date) in.readObject();
         catch (IOException | ClassNotFoundException e) 
            throw new AssertionError(e);
        
    

运行下面的程序可以查看正在进行的攻击:

public static void main(String[] args) 
    MutablePeriod mp = new MutablePeriod();
    Period p = mp.period;
    Date pEnd = mp.end;
    // Let's turn back the clock
    pEnd.setYear(78);
    System.out.println(p);
    
    // Bring back the 60s!
    pEnd.setYear(69);
    System.out.println(p);

这个程序产生的输出结果如下:

Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969

这个结果明显是错误的,问题的根源在于,Period 的readObject 方法并没有完成足够的保护性拷⻉。

当一个对象被反序列化的时候,对于客户端不应该拥有的对象引用,就必须做保护性拷贝。下面的这些 readObject 方法可以确保 Period 类的约束条件不会遭到破坏:

// readObject method with defensive copying and validity checking
private void readObject以上是关于带你快速看完9.8分神作《Effective Java》—— 序列化篇(所有RPC框架的基石)的主要内容,如果未能解决你的问题,请参考以下文章

带你快速看完9.8分神作《Effective Java》—— 序列化篇(所有RPC框架的基石)

带你快速看完9.8分神作《Effective Java》—— 并发篇(工作里的这些坑你都遇到过吗?)

带你快速浏览Xcode 9新特性

Django1.9.8 + Xadmin + Windows 快速搭建网站

50k大牛告诉你Python怎么学,10个特性带你快速了解python

Effective Java 第三版——20. 接口优于抽象类