高效Java(第三版) Effective Java
1. 考虑使用静态工厂方法替代构造方法 2. 当构造方法参数过多时使用builder模式 3. 使用私有构造方法或枚类实现Singleton属性 4. 使用私有构造方法执行非实例化 5. 使用依赖注入取代硬连接资源 6. 避免创建不必要的对象 7. 消除过期的对象引用 8. 避免使用Finalizer和Cleaner机制 9. 使用try-with-resources语句替代try-finally语句 10. 重写equals方法时遵守通用约定 11. 重写equals方法时同时也要重写hashcode方法 12. 始终重写 toString 方法 13. 谨慎地重写 clone 方法 14. 考虑实现Comparable接口 15. 使类和成员的可访问性最小化 16. 在公共类中使用访问方法而不是公共属性 17. 最小化可变性 18. 组合优于继承 19. 如果使用继承则设计,并文档说明,否则不该使用 20. 接口优于抽象类 21. 为后代设计接口 22. 接口仅用来定义类型 23. 优先使用类层次而不是标签类 24. 优先考虑静态成员类 25. 将源文件限制为单个顶级类 26. 不要使用原始类型 27. 消除非检查警告 28. 列表优于数组 29. 优先考虑泛型 30. 优先使用泛型方法 31. 使用限定通配符来增加API的灵活性 32. 合理地结合泛型和可变参数 33. 优先考虑类型安全的异构容器 34. 使用枚举类型替代整型常量 35. 使用实例属性替代序数 36. 使用EnumSet替代位属性 37. 使用EnumMap替代序数索引 38. 使用接口模拟可扩展的枚举 39. 注解优于命名模式 40. 始终使用Override注解 41. 使用标记接口定义类型 42. lambda表达式优于匿名类 43. 方法引用优于lambda表达式 44. 优先使用标准的函数式接口 45. 明智审慎地使用Stream 46. 优先考虑流中无副作用的函数 47. 优先使用Collection而不是Stream来作为方法的返回类型 48. 谨慎使用流并行 49. 检查参数有效性 50. 必要时进行防御性拷贝 51. 仔细设计方法签名 52. 明智而审慎地使用重载 53. 明智而审慎地使用可变参数 54. 返回空的数组或集合不要返回null 55. 明智而审慎地返回Optional 56. 为所有已公开的API元素编写文档注释 57. 最小化局部变量的作用域 58. for-each循环优于传统for循环 59. 熟悉并使用Java类库 60. 需要精确的结果时避免使用float和double类型 61. 基本类型优于装箱的基本类型 62. 当有其他更合适的类型时就不用字符串 63. 注意字符串连接的性能 64. 通过对象的接口引用对象 65. 接口优于反射 66. 明智谨慎地使用本地方法 67. 明智谨慎地进行优化 68. 遵守普遍接受的命名约定 69. 仅在发生异常的条件下使用异常 70. 对可恢复条件使用检查异常,对编程错误使用运行时异常 71. 避免不必要地使用检查异常 72. 赞成使用标准异常 73. 抛出合乎于抽象的异常 74. 文档化每个方法抛出的所有异常 75. 在详细信息中包含失败捕获信息 76. 争取保持失败原子性 77. 同步访问共享的可变数据 78. 避免过度同步 79. EXECUTORS, TASKS, STREAMS 优于线程 80. 优先使用并发实用程序替代wait和notify 81. 线程安全文档化 82. 明智谨慎地使用延迟初始化 83. 不要依赖线程调度器 84. 其他替代方式优于Java本身序列化 85. 非常谨慎地实现SERIALIZABLE接口 86. 考虑使用自定义序列化形式 87. 防御性地编写READOBJECT方法 88. 对于实例控制,枚举类型优于READRESOLVE 89. 考虑序列化代理替代序列化实例

防御性地编写READOBJECT方法

Tips
书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code
注意,书中的有些代码里方法是基于Java 9 API中的,所以JDK 最好下载 JDK 9以上的版本。

88. 防御性地编写READOBJECT方法

条目 50 里有一个不可变的日期范围类,它包含一个可变的私有Date属性。 该类通过在其构造方法和访问器中防御性地拷贝Date对象,竭尽全力维持其不变性(invariants and immutability)。 代码如下所示:

// Immutable class that uses defensive copying
public final class Period {

    private final Date start;
    private final Date end;

    /**
     * @param  start the beginning of the period
     * @param  end the end of the period; must not precede start
     * @throws IllegalArgumentException if start is after end
     * @throws NullPointerException if start or end is null
     */
    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 start () { return new Date(start.getTime()); }

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

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

    ... // Remainder omitted
}

假设要把这个类可序列化。由于Period对象的物理表示精确地反映了它的逻辑数据内容,所以使用默认的序列化形式是合理的(条目 87)。因此,要使类可序列化,似乎只需将implements Serializable 添加到类声明中就可以了。但是,如果这样做,该类不再保证它的关键不变性了。

问题是readObject方法实际上是另一个公共构造方法,它需要与任何其他构造方法一样的小心警惕。 正如构造方法必须检查其参数的有效性(条目 49)并在适当的地方对参数防御性拷贝(条目 50),readObject方法也要这样做。 如果readObject方法无法执行这两个操作中的任何一个,则攻击者违反类的不变性是相对简单的事情。

简而言之,readObject是一个构造方法,它将字节流作为唯一参数。 在正常使用中,字节流是通过序列化正常构造的实例生成的。当readObject展现一个字节流时,问题就出现了,这个字节流是人为构造的,用来生成一个违反类不变性的对象。 这样的字节流可用于创建一个不可能的对象,该对象无法使用普通构造方法创建。

假设我们只是将implements Serializablet添加到Period类声明中。 然后,这个丑陋的程序生成一个Period实例,其结束时间在其开始时间之前。 对byte类型的值进行强制转换,其高阶位被设置,这是由于Java缺乏byte字面量,并且错误地决定对byte类型进行签名:

public class BogusPeriod {
  // Byte stream couldn"t have come from a real Period instance!
  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);
    }
  }
}

用于初始化serializedForm的字节数组字面量(literal)是通过序列化正常的Period实例,并手动编辑生成的字节流生成的。 流的细节对于该示例并不重要,但是如果好奇,则在《Java Object Serialization Specification》[序列化,6]中描述了序列化字节流格式。 如果运行此程序,它会打印Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984。只需声明Period类为可序列化,我们就可以创建一个违反其类不变性的对象。

要解决此问题,请为Period提供一个readObject方法,该方法调用defaultReadObject,然后检查反序列化对象的有效性。如果有效性检查失败,readObject方法抛出InvalidObjectException异常,阻止反序列化完成:

// readObject method with validity checking - insufficient!
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实例,然后将额外引用附加到Period实例内部的私有Date属性。 攻击者从ObjectInputStream中读取Period实例,然后读取附加到流的“恶意对象引用”。 这些引用使攻击者可以访问Period对象中私有Date属性引用的对象。 通过改变这些Date实例,攻击者可以改变Period实例。 以下类演示了这种攻击:

public class MutablePeriod {
    // A period instance
    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实例且保持了其不变性,但可以随意修改其内部组件。 一旦拥有可变的Period实例,攻击者可能会通过将实例传递给依赖于Period的安全性不变性的类来造成巨大的伤害。 这并非如此牵强:有些类就是依赖于String的不变性来保证安全性的。

问题的根源是Period类的readObject方法没有做足够的防御性拷贝。 对象反序列化时,防御性地拷贝包含客户端不能拥有的对象引用的属性,是至关重要的。 因此,每个包含私有可变组件的可序列化不可变类,必须在其readObject方法中防御性地拷贝这些组件。 以下readObject方法足以确保Period的不变性并保持其不变性:

// readObject method with defensive copying and validity checking
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);
}

请注意,防御性拷贝在有效性检查之前执行,并且我们没有使用Date的clone方法来执行防御性拷贝。 需要这两个细节来保护Period免受攻击(条目 50)。 另请注意,final属性无法进行防御性拷贝。 要使用readObject方法,我们必须使start和end属性不能是final类型的。 这是不幸的,但它是这两个中较好的一个做法。 使用新的readObject方法并从startend属性中删除final修饰符后,MutablePeriod类不再无效。 上面的攻击程序现在生成如下输出:

Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017
Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017

下面是一个简单的石蕊测试(litmus test),用于确定类的默认readObject方法是否可接受:你是否愿意添加一个公共构造方法,该构造方法把对象中每个非瞬时状态的属性值作为参数,并在没有任何验证的情况下,将值保存在属性中?如果没有,则必须提供readObject方法,并且它必须执行构造方法所需的所有有效性检查和防御性拷贝。或者,可以使用序列化代理模式(serialization proxy pattern))(条目 90)。强烈推荐使用这种模式,因为它在安全反序列化方面花费了大量精力。

readObject方法和构造方法还有一个相似之处,它们适用于非final可序列化类。 与构造方法一样,readObject方法不能直接或间接调用可重写的方法(条目 19)。 如果违反此规则并且重写了相关方法,则重写方法会在子类状态被反序列化之前运行。 程序可能会导致失败[Bloch05,Puzzle 91]。

总而言之,无论何时编写readObject方法,都要采用这样一种思维方式,即正在编写一个公共构造方法,该构造方法必须生成一个有效的实例,而不管给定的是什么字节流。不要假设字节流一定表示实际的序列化实例。虽然本条目中的示例涉及使用默认序列化形式的类,但是所引发的所有问题都同样适用于具有自定义序列化形式的类。下面是编写readObject方法的指导原则:

  • 对于具有必须保持私有的对象引用属性的类,防御性地拷贝该属性中的每个对象。不可变类的可变组件属于这一类别。

  • 检查任何不变性,如果检查失败,则抛出InvalidObjectException异常。 检查应再任何防御性拷贝之后。

  • 如果必须在反序列化后验证整个对象图(object graph),那么使用ObjectInputValidation接口(在本书中没有讨论)。

  • 不要直接或间接调用类中任何可重写的方法。