diff --git a/docs/doc/effective-java-3rd-chinese.md b/docs/doc/effective-java-3rd-chinese.md index c348727..307846b 100644 --- a/docs/doc/effective-java-3rd-chinese.md +++ b/docs/doc/effective-java-3rd-chinese.md @@ -1,3 +1,8 @@ +# Effctive Java 第三版中文版 + +- github: https://github.com/sjsdfg/effective-java-3rd-chinese +- 在线阅读:https://sjsdfg.github.io/effective-java-3rd-chinese/#/ + # 1. 考虑使用静态工厂方法替代构造方法 @@ -5074,4094 +5079,4 @@ from a supertype ^ ``` -  你会立刻意识到你做错了什么,在额头上狠狠地打了一下,用一个正确的(条目 10)来替换出错的 `equals` 实现: - -```java -@Override public boolean equals(Object o) { - if (!(o instanceof Bigram)) - return false; - Bigram b = (Bigram) o; - return b.first == first && b.second == second; -} -``` - -  因此,应该在你认为要重写父类声明的每个方法声明上使用 `Override` 注解。 这条规则有一个小例外。 如果正在编写一个没有标记为抽象的类,并且确信它重写了其父类中的抽象方法,则无需将 `Override` 注解放在该方法上。 在没有声明为抽象的类中,如果无法重写抽象父类方法,编译器将发出错误消息。 但是,你可能希望关注类中所有重写父类方法的方法,在这种情况下,也应该随时注解这些方法。 大多数 IDE 可以设置为在选择重写方法时自动插入 `Override` 注解。 - -  大多数 IDE 提供了是种使用 `Override` 注解的另一个理由。 如果启用适当的检查功能,如果有一个方法没有 `Override` 注解但是重写父类方法,则 IDE 将生成一个警告。 如果始终使用 `Override` 注解,这些警告将提醒你无意识的重写。 它们补充了编译器的错误消息,这些消息会提醒你无意识重写失败。 IDE 和编译器,可以确保你在任何你想要的地方和其他地方重写方法,万无一失。 - -  `Override` 注解可用于重写来自接口和类的方法声明。 随着 `default` 默认方法的出现,在接口方法的具体实现上使用 `Override` 以确保签名是正确的是一个好习惯。 如果知道某个接口没有默认方法,可以选择忽略接口方法的具体实现上的 `Override` 注解以减少混乱。 - -  然而,在一个抽象类或接口中,值得标记的是你认为重写父类或父接口方法的所有方法,无论是具体的还是抽象的。 例如,`Set` 接口不会向 `Collection` 接口添加新方法,因此它应该在其所有方法声明中包含 `Override` 注解以确保它不会意外地向 `Collection` 接口添加任何新方法。 - -  总之,如果在每个方法声明中使用 `Override` 注解,并且认为要重写父类声明,那么编译器可以保护免受很多错误的影响,但有一个例外。 在具体的类中,不需要注解标记你确信可以重写抽象方法声明的方法(尽管这样做也没有坏处)。 -# 41. 使用标记接口定义类型 - -  标记接口(marker interface),不包含方法声明,只是指定(或“标记”)一个类实现了具有某些属性的接口。 例如,考虑 `Serializable` 接口(第 12 章)。通过实现这个接口,一个类表明它的实例可以写入 `ObjectOutputStream`(或“序列化”)。 - -  你可能会听说过标记注解(条目 39)标记一个接口是废弃过时的。 这个断言是不正确的。 标记接口与标记注解相比具有两个优点。 - -  首先,**标记接口定义了一个由标记类实例实现的类型;标记注解则不会。** 标记接口类型的存在允许在编译时捕获错误,如果使用标记注解,则直到运行时才能捕获错误。 - -  Java 的序列化机制(第 6 章)使用 `Serializable` 标记接口来指示某个类型是可序列化的。 对传递给它的对象进行序列化的 `ObjectOutputStream.writeObject` 方法要求其参数可序列化。 如果此方法的参数是 `Serializable` 类型,则在编译时会检测到序列化不适当对象的尝试(通过类型检查)。 编译时错误检测是标记接口的意图,但不幸的是,`ObjectOutputStream.write` API 没有利用 `Serializable` 接口:它的参数被声明为 `Object` 类型,所以尝试序列化一个不可序列化的对象直到运行时才会失败。 - -  **标记接口对于标记注解的另一个优点是可以更精确地定位目标。** 如果使用目标 `ElementType.TYPE` 声明注解类型,它可以应用于任何类或接口。 假设有一个标记仅适用于特定接口的实现。 如果将其定义为标记接口,则可以扩展它适用的唯一接口,保证所有标记类型也是适用的唯一接口的子类型。 - -  可以说,`Set` 接口就是这样一个受限的标记接口。 它仅适用于 `Collection` 子类型,但不会添加超出 `Collection` 定义的方法。 它通常不被认为是标记接口,因为它改进了几个 `Collection` 方法的契约,包括 `add`,`equals` 和 `hashCode`。 但很容易想象一个标记接口,它仅适用于某些特定接口的子类型,并且不会改进任何接口方法的契约。 这样的标记接口可以描述整个对象的一些约束条件(invariant),或者说明实例有资格被某个其他类的方法处理(就像 `Serializable` 接口指示实例有资格被 `ObjectOutputStream` 处理的方式)。 - -  标记注解优于标记接口的主要优点是它们是较大的注解工具的一部分。因此,标记注解允许在基于注解的框架中保持一致性。 - -  所以什么时候应该使用标记注解,什么时候应该使用标记接口?显然,如果标记适用于除类或接口以外的任何程序元素,则必须使用注解,因为只能使用类和接口来实现或扩展接口。如果标记仅适用于类和接口,那么问自己问题:“可能我想编写一个或多个只接受具有此标记的对象的方法呢?”如果是这样,则应该优先使用标记接口而不是注解。这将使你可以将接口用作所讨论方法的参数类型,这将带来编译时类型检查的好处。如果你能说服自己,永远不会想写一个只接受带有标记的对象的方法,那么最好使用标记注解。另外,如果标记是大量使用注解的框架的一部分,则标记注解是明确的选择。 - -  总之,标记接口和标记注释都有其用处。 如果你想定义一个没有任何关联的新方法的类型,一个标记接口是一种可行的方法。 如果要标记除类和接口以外的程序元素,或者将标记符合到已经大量使用注解类型的框架中,那么标记注解是正确的选择。 **如果发现自己正在编写目标为 `ElementType.TYPE` 的标记注解类型,请花点时间确定它是否应该是注释类型,是不是标记接口是否更合适。** - -  从某种意义来说,本条目与条目 22 的的意思正好相反,条目 22 的意思是:“如果你不想定义一个类型,不要使用接口”。本条目的意思是:如果想定义一个类型,一定要使用接口。 - - - - -# 42. lambda 表达式优于匿名类 - -  在 Java 8 中,添加了函数式接口,`lambda` 表达式和方法引用,以便更容易地创建函数对象。 Stream API 随着其他语言的修改一同被添加进来,为处理数据元素序列提供类库支持。 在本章中,我们将讨论如何充分利用这些功能。 -   -  以往,使用单一抽象方法的接口(或者很少使用抽象类)被用作函数类型。 它们的实例(称为函数对象)表示函数(functions)或行动(actions)。 自从 JDK 1.1 于 1997 年发布以来,创建函数对象的主要手段就是匿名类(条目 24)。 下面是一段代码片段,按照字符串长度顺序对列表进行排序,使用匿名类创建排序的比较方法(强制排序顺序): - -```java -// Anonymous class instance as a function object - obsolete! -Collections.sort(words, new Comparator() { - public int compare(String s1, String s2) { - return Integer.compare(s1.length(), s2.length()); - } -}); -``` - -  匿名类适用于需要函数对象的经典面向对象设计模式,特别是策略模式[Gamma95]。 比较器接口表示排序的抽象策略; 上面的匿名类是排序字符串的具体策略。 然而,匿名类的冗长,使得 Java 中的函数式编程成为一种吸引人的前景。 -   -  在 Java 8 中,语言形式化了这样的概念,即使用单个抽象方法的接口是特别的,应该得到特别的对待。 这些接口现在称为函数式接口,并且该语言允许你使用 **lambda** 表达式或简称 **lambda** 来创建这些接口的实例。 **Lambdas** 在功能上与匿名类相似,但更为简洁。 下面的代码使用 **lambdas** 替换上面的匿名类。 样板不见了,行为清晰明了: - -```java -// Lambda expression as function object (replaces anonymous class) -Collections.sort(words, - (s1, s2) -> Integer.compare(s1.length(), s2.length())); -``` - -  请注意,代码中不存在 **lambda**(`Comparator `),其参数(s1 和 s2,都是 `String` 类型)及其返回值(int)的类型。 编译器使用称为类型推断的过程从上下文中推导出这些类型。 在某些情况下,编译器将无法确定类型,必须指定它们。 类型推断的规则很复杂:他们在 JLS 中占据了整个章节[JLS,18]。 很少有程序员详细了解这些规则,但没关系。 除非它们的存在使你的程序更清晰,否则省略所有 **lambda** 参数的类型。 如果编译器生成一个错误,告诉你它不能推断出 **lambda** 参数的类型,那么指定它。 有时你可能不得不强制转换返回值或整个 **lambda** 表达式,但这很少见。 -   -  关于类型推断需要注意一点。 条目 26 告诉你不要使用原始类型,条目 29 告诉你偏好泛型类型,条目 30 告诉你偏向泛型方法。 当使用 **lambda** 表达式时,这个建议是非常重要的,因为编译器获得了大部分允许它从泛型进行类型推断的类型信息。 如果你没有提供这些信息,编译器将无法进行类型推断,你必须在 **lambdas** 中手动指定类型,这将大大增加它们的冗余度。 举例来说,如果变量被声明为原始类型 `List` 而不是参数化类型 `List`,则上面的代码片段将不会编译。 -   -  顺便提一句,如果使用比较器构造方法代替 **lambda**,则代码中的比较器可以变得更加简洁(条目 14,43): - -```java -Collections.sort(words, comparingInt(String::length)); -``` -  实际上,通过利用添加到 Java 8 中的 `List` 接口的 `sort` 方法,可以使片段变得更简短: -```java -words.sort(comparingInt(String::length)); -``` - -  将 **lambdas** 添加到该语言中,使得使用函数对象在以前没有意义的地方非常实用。例如,考虑条目 34 中的 `Operation` 枚举类型。由于每个枚举都需要不同的应用程序行为,所以我们使用了特定于常量的类主体,并在每个枚举常量中重写了 `apply` 方法。为了刷新你的记忆,下面是之前的代码: - -```java -// Enum type with constant-specific class bodies & data -public enum Operation { - PLUS("+") { - public double apply(double x, double y) { return x + y; } - }, - - MINUS("-") { - public double apply(double x, double y) { return x - y; } - }, - - TIMES("*") { - public double apply(double x, double y) { return x * y; } - }, - - DIVIDE("/") { - public double apply(double x, double y) { return x / y; } - }; - - private final String symbol; - - Operation(String symbol) { this.symbol = symbol; } - - @Override - public String toString() { return symbol; } - - public abstract double apply(double x, double y); -} -``` - -  第 34 条目说,枚举实例属性比常量特定的类主体更可取。 **Lambdas** 可以很容易地使用前者而不是后者来实现常量特定的行为。 仅仅将实现每个枚举常量行为的 **lambda** 传递给它的构造方法。 构造方法将 **lambda** 存储在实例属性中,`apply` 方法将调用转发给 **lambda**。 由此产生的代码比原始版本更简单,更清晰: - -```java -public enum Operation { - PLUS ("+", (x, y) -> x + y), - MINUS ("-", (x, y) -> x - y), - TIMES ("*", (x, y) -> x * y), - DIVIDE("/", (x, y) -> x / y); - - private final String symbol; - private final DoubleBinaryOperator op; - - Operation(String symbol, DoubleBinaryOperator op) { - this.symbol = symbol; - this.op = op; - } - - @Override - public String toString() { return symbol; } - - public double apply(double x, double y) { - return op.applyAsDouble(x, y); - } -} -``` - -  请注意,我们使用表示枚举常量行为的 **lambdas** 的 `DoubleBinaryOperator` 接口。 这是 `java.util.function` 中许多预定义的函数接口之一(条目 44)。 它表示一个函数,它接受两个 **double** 类型参数并返回 **double** 类型的结果。 - -  看看基于 **lambda** 的 `Operation` 枚举,你可能会认为常量特定的方法体已经失去了它们的用处,但事实并非如此。 与方法和类不同,**lambda 没有名称和文档; 如果计算不是自解释的,或者超过几行,则不要将其放入 lambda 表达式中。** 一行代码对于 **lambda** 说是理想的,三行代码是合理的最大值。 如果违反这一规定,可能会严重损害程序的可读性。 如果一个 **lambda** 很长或很难阅读,要么找到一种方法来简化它或重构你的程序来消除它。 此外,传递给枚举构造方法的参数在静态上下文中进行评估。 因此,枚举构造方法中的 **lambda** 表达式不能访问枚举的实例成员。 如果枚举类型具有难以理解的常量特定行为,无法在几行内实现,或者需要访问实例属性或方法,那么常量特定的类主体仍然是行之有效的方法。 - -  同样,你可能会认为匿名类在 **lambda** 时代已经过时了。 这更接近事实,但有些事情你可以用匿名类来做,而却不能用 **lambdas** 做。 **Lambda** 仅限于函数式接口。 如果你想创建一个抽象类的实例,你可以使用匿名类来实现,但不能使用 **lambda**。 同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。 最后,**lambda** 不能获得对自身的引用。 在 **lambda** 中,`this` 关键字引用封闭实例,这通常是你想要的。 在匿名类中,`this` 关键字引用匿名类实例。 如果你需要从其内部访问函数对象,则必须使用匿名类。 - -  **Lambdas** 与匿名类共享无法可靠地序列化和反序列化实现的属性。**因此,应该很少 (如果有的话) 序列化一个 lambda(或一个匿名类实例)。** 如果有一个想要进行序列化的函数对象,比如一个 `Comparator`,那么使用一个私有静态嵌套类的实例(条目 24)。 - -  综上所述,从 Java 8 开始,**lambda** 是迄今为止表示小函数对象的最佳方式。 **除非必须创建非函数式接口类型的实例,否则不要使用匿名类作为函数对象。** 另外,请记住,**lambda** 表达式使代表小函数对象变得如此简单,以至于它为功能性编程技术打开了一扇门,这些技术在 Java 中以前并不实用。 - - -# 43. 方法引用优于 lambda 表达式 - - -  lambda 优于匿名类的主要优点是它更简洁。Java 提供了一种生成函数对象的方法,比 lambda 还要简洁,那就是:方法引用( method references)。下面是一段程序代码片段,它维护一个从任意键到整数值的映射。如果将该值解释为键的实例个数,则该程序是一个多重集合的实现。该代码的功能是,根据键找到整数值,然后在此基础上加 1: - -```java -map.merge(key, 1, (count, incr) -> count + incr); -``` - -  请注意,此代码使用 `merge` 方法,该方法已添加到 Java 8 中的 `Map` 接口中。如果没有给定键的映射,则该方法只是插入给定值; 如果映射已经存在,则合并给定函数应用于当前值和给定值,并用结果覆盖当前值。 此代码表示 `merge` 方法的典型用例。 - -  代码很好读,但仍然有一些样板的味道。 参数 `count` 和 `incr` 不会增加太多价值,并且占用相当大的空间。 真的,所有的 lambda 都告诉你函数返回两个参数的和。 从 Java 8 开始,`Integer` 类(和所有其他包装数字基本类型)提供了一个静态方法总和,和它完全相同。 我们可以简单地传递一个对这个方法的引用,并以较少的视觉混乱得到相同的结果: - -```java -map.merge(key, 1, Integer::sum); -``` - -  方法的参数越多,你可以通过方法引用消除更多的样板。 然而,在一些 lambda 中,选择的参数名称提供了有用的文档,使得 lambda 比方法引用更具可读性和可维护性,即使 lambda 看起来更长。 - -  对于一个方法引用,你无能为力,因为你不能对 lambda 执行任何操作(只有一个难懂的异常 - 如果你好奇的话,参见 JLS,9.9-2)。 也就是说,方法引用通常会导致更短,更清晰的代码。 如果 lambda 变得太长或太复杂,它们也会给你一个结果:你可以从 lambda 中提取代码到一个新的方法中,并用对该方法的引用代替 lambda。 你可以给这个方法一个好名字,并把它文档记录下来。 - -  如果你使用 IDE 编程,它将提供替换 lambda 的方法,并在任何地方使用方法引用。通常情况下,你应该接受这个提议。偶尔,lambda 会比方法引用更简洁。这种情况经常发生在方法与 lambda 相同的类中。例如,考虑这段代码,它被假定出现在一个名为 `GoshThisClassNameIsHumongous` 的类中: - -```java -service.execute(GoshThisClassNameIsHumongous::action); -``` - -这个 lambda 类似于等价于下面的代码: - -```java -service.execute(() -> action()); -``` - -  使用方法引用的代码段既不比使用 lambda 的代码片段更短也不清晰,所以更喜欢后者。 在类似的代码行中,`Function` 接口提供了一个通用的静态工厂方法来返回标识函数 `Function.identity()`。 它通常更短,更清洁,而不使用这种方法,而是使用等效的 lambda 内联代码:`x - > x`。 - -  许多方法引用是指静态方法,但有四种方法没有。 等同的 Lambda 其中两个是特定(bound)和任意(unbound)对象方法引用。 在特定对象引用中,接收对象在方法引用中指定。 特定对象引用在本质上与静态引用类似:函数对象与引用的方法具有相同的参数。 在任意对象引用中,接收对象在应用函数对象时通过方法的声明参数之前的附加参数指定。 任意对象引用通常用作流管道(pipelines)中的映射和过滤方法(条目 45)。 最后,对于类和数组,有两种构造方法引用。 构造方法引用用作工厂对象。 下表总结了所有五种方法引用: - -|方法引用类型|举例|等同的 Lambda| -|:--:|:--:|:--: | -|Static|Integer::parseInt|str -> Integer.parseInt(str)| -|Bound|Instant.now()::isAfter|Instant then = Instant.now();
t -> then.isAfter(t)| -|Unbound|String::toLowerCase|str -> str.toLowerCase()| -|Class Constructor|TreeMap::new|() -> new TreeMap| -|Array Constructor|int[]::new |len -> new int[len]| - -  总之,方法引用通常为 lambda 提供一个更简洁的选择。 **如果方法引用看起来更简短更清晰,请使用它们;否则,还是坚持 lambda。** - - -# 44. 优先使用标准的函数式接口 - -  现在 Java 已经有 lambda 表达式,编写 API 的最佳实践已经发生了很大的变化。 例如,模板方法模式[Gamma95],其中一个子类重写原始方法以专门化其父类的行为,变得没有那么吸引人。 现代替代的选择是提供一个静态工厂或构造方法来接受函数对象以达到相同的效果。 通常地说,可以编写更多以函数对象为参数的构造方法和方法。 选择正确的函数式参数类型需要注意。 - -  考虑 `LinkedHashMap`。 可以通过重写其受保护的 `removeEldestEntry` 方法将此类用作缓存,每次将新的 key 值加入到 map 时都会调用该方法。 当此方法返回 true 时,map 将删除传递给该方法的最久条目。 以下代码重写允许 map 增长到一百个条目,然后在每次添加新 key 值时删除最老的条目,并保留最近的一百个条目: - -```java -protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > 100; -} -``` - -  这种技术很有效,但是你可以用 lambdas 做得更好。如果 `LinkedHashMap` 是现在编写的,那么它将有一个静态的工厂或构造方法来获取函数对象。查看 `removeEldestEntry` 方法的声明,你可能会认为函数对象应该接受一个 `Map.Entry` 并返回一个布尔值,但是这并不完全是这样:`removeEldestEntry` 方法调用 `size()` 方法来获取条目的数量,因为 `removeEldestEntry` 是 map 上的一个实例方法。传递给构造方法的函数对象不是 map 上的实例方法,无法捕获,因为在调用其工厂或构造方法时 map 还不存在。因此,map 必须将自己传递给函数对象,函数对象把 map 以及最就的条目作为输入参数。如果要声明这样一个功能接口,应该是这样的: - -```java -// Unnecessary functional interface; use a standard one instead. -@FunctionalInterface -interface EldestEntryRemovalFunction{ - boolean remove(Map map, Map.Entry eldest); -} -``` - -  这个接口可以正常工作,但是你不应该使用它,因为你不需要为此目的声明一个新的接口。 `java.util.function` 包提供了大量标准函数式接口供你使用。 如果其中一个标准函数式接口完成这项工作,则通常应该优先使用它,而不是专门构建的函数式接口。 这将使你的 API 更容易学习,通过减少其不必要概念,并将提供重要的互操作性好处,因为许多标准函数式接口提供了有用的默认方法。 例如,`Predicate` 接口提供了组合判断的方法。 在我们的 `LinkedHashMap` 示例中,标准的 `BiPredicate, Map.Entry>` 接口应优先于自定义的 `EldestEntryRemovalFunction` 接口的使用。 - -  在 `java.util.Function` 中有 43 个接口。不能指望全部记住它们,但是如果记住了六个基本接口,就可以在需要它们时派生出其余的接口。基本接口操作于对象引用类型。`Operator` 接口表示方法的结果和参数类型相同。`Predicate` 接口表示其方法接受一个参数并返回一个布尔值。`Function` 接口表示方法其参数和返回类型不同。`Supplier` 接口表示一个不接受参数和返回值 (或“供应”) 的方法。最后,`Consumer` 表示该方法接受一个参数而不返回任何东西,本质上就是使用它的参数。六种基本函数式接口概述如下: - -|接口|方法|示例| -|:--:|:--:|:--:| -|`UnaryOperator`|T apply(T t)|String::toLowerCase| -|`BinaryOperator`|T apply(T t1, T t2)|BigInteger::add| -|`Predicate`|boolean test(T t)|Collection::isEmpty| -|`Function`|R apply(T t)|Arrays::asList| -|`Supplier`|T get()|Instant::now| -|`Consumer`|void accept(T t)|System.out::println| - -  在处理基本类型 int,long 和 double 的操作上,六个基本接口中还有三个变体。 它们的名字是通过在基本接口前加一个基本类型而得到的。 因此,例如,一个接受 int 的 `Predicate` 是一个 `IntPredicate`,而一个接受两个 long 值并返回一个 long 的二元运算符是一个 `LongBinaryOperator`。 除 `Function` 接口变体通过返回类型进行了参数化,其他变体类型都没有参数化。 例如,`LongFunction` 使用 long 类型作为参数并返回了 int [] 类型。 - -  `Function` 接口还有九个额外的变体,当结果类型为基本类型时使用。 源和结果类型总是不同,因为从类型到它自身的函数是 `UnaryOperator`。 如果源类型和结果类型都是基本类型,则使用带有 `SrcToResult` 的前缀 `Function`,例如 `LongToIntFunction`(六个变体)。如果源是一个基本类型,返回结果是一个对象引用,那么带有 `ToObj` 的前缀 `Function`,例如 `DoubleToObjFunction` (三种变体)。 - -  有三个包含两个参数版本的基本功能接口,使它们有意义:`BiPredicate `,`BiFunction ` 和 `BiConsumer `。 也有返回三种相关基本类型的 `BiFunction` 变体:`ToIntBiFunction `,`ToLongBiFunction` 和 `ToDoubleBiFunction `。`Consumer` 有两个变量,它们带有一个对象引用和一个基本类型:`ObjDoubleConsumer `,`ObjIntConsumer ` 和 `ObjLongConsumer `。 总共有九个两个参数版本的基本接口。 - -  最后,还有一个 `BooleanSupplier` 接口,它是 `Supplier` 的一个变体,它返回布尔值。 这是任何标准函数式接口名称中唯一明确提及的布尔类型,但布尔返回值通过 `Predicate` 及其四种变体形式支持。 前面段落中介绍的 `BooleanSupplier` 接口和 42 个接口占所有四十三个标准功能接口。 无可否认,这是非常难以接受的,并且不是非常正交的。 另一方面,你所需要的大部分功能接口都是为你写的,而且它们的名字是经常性的,所以在你需要的时候不应该有太多的麻烦。 - -  大多数标准函数式接口仅用于提供对基本类型的支持。 **不要试图使用基本的函数式接口来装箱基本类型的包装类而不是基本类型的函数式接口。** 虽然它起作用,但它违反了第 61 条中的建议:“优先使用基本类型而不是基本类型的包装类”。使用装箱基本类型的包装类进行批量操作的性能后果可能是致命的。 - -  现在你知道你应该通常使用标准的函数式接口来优先编写自己的接口。 但是,你应该什么时候写自己的接口? 当然,如果没有一个标准模块能够满足您的需求,例如,如果需要一个带有三个参数的 `Predicate`,或者一个抛出检查异常的 `Predicate`,那么需要编写自己的代码。 但有时候你应该编写自己的函数式接口,即使与其中一个标准的函数式接口的结构相同。 - -  考虑我们的老朋友 `Comparator `,它的结构与 `ToIntBiFunction ` 接口相同。 即使将前者添加到类库时后者的接口已经存在,使用它也是错误的。 `Comparator` 值得拥有自己的接口有以下几个原因。 首先,它的名称每次在 API 中使用时都会提供优秀的文档,并且使用了很多。 其次,`Comparator` 接口对构成有效实例的构成有强大的要求,这些要求构成了它的普遍契约。 通过实现接口,就要承诺遵守契约。 第三,接口配备很多了有用的默认方法来转换和组合多个比较器。 - -  如果需要一个函数式接口与 `Comparator` 共享以下一个或多个特性,应该认真考虑编写一个专用函数式接口,而不是使用标准函数式接口: -   - - 它将被广泛使用,并且可以从描述性名称中受益。 - - 它拥有强大的契约。 - - 它会受益于自定义的默认方法。 - -  如果选择编写你自己的函数式接口,请记住它是一个接口,因此应非常小心地设计(条目 21)。 - -  请注意,`EldestEntryRemovalFunction` 接口(第 199 页)`标有 @FunctionalInterface` 注解。 这种注解在类型类似于 `@Override`。 这是一个程序员意图的陈述,它有三个目的:它告诉读者该类和它的文档,该接口是为了实现 lambda 表达式而设计的;它使你保持可靠,因为除非只有一个抽象方法,否则接口不会编译; 它可以防止维护人员在接口发生变化时不小心地将抽象方法添加到接口中。 **始终使用 `@FunctionalInterface` 注解标注你的函数式接口。** - -  最后一点应该是关于在 api 中使用函数接口的问题。不要提供具有多个重载的方法,这些重载在相同的参数位置上使用不同的函数式接口,如果这样做可能会在客户端中产生歧义。这不仅仅是一个理论问题。`ExecutorService` 的 `submit` 方法可以采用 `Callable` 或 `Runnable` 接口,并且可以编写需要强制类型转换以指示正确的重载的客户端程序 (条目 52)。避免此问题的最简单方法是不要编写在相同的参数位置中使用不同函数式接口的重载。这是条目 52 中建议的一个特例,“明智地使用重载”。 - -  总之,现在 Java 已经有了 lambda 表达式,因此必须考虑 lambda 表达式来设计你的 API。 在输入上接受函数式接口类型并在输出中返回它们。 一般来说,最好使用 `java.util.function.Function` 中提供的标准接口,但请注意,在相对罕见的情况下,最好编写自己的函数式接口。 - -# 45. 明智审慎地使用 Stream - -  在 Java 8 中添加了 Stream API,以简化顺序或并行执行批量操作的任务。 该 API 提供了两个关键的抽象:流 (Stream),表示有限或无限的数据元素序列,以及流管道 (stream pipeline),表示对这些元素的多级计算。 Stream 中的元素可以来自任何地方。 常见的源包括集合,数组,文件,正则表达式模式匹配器,伪随机数生成器和其他流。 流中的数据元素可以是对象引用或基本类型。 支持三种基本类型:int,long 和 double。 - -  流管道由源流(source stream)的零或多个中间操作和一个终结操作组成。每个中间操作都以某种方式转换流,例如将每个元素映射到该元素的函数或过滤掉所有不满足某些条件的元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同或不同。终结操作对流执行最后一次中间操作产生的最终计算,例如将其元素存储到集合中、返回某个元素或打印其所有元素。 - -  管道延迟(lazily)计算求值:计算直到终结操作被调用后才开始,而为了完成终结操作而不需要的数据元素永远不会被计算出来。 这种延迟计算求值的方式使得可以使用无限流。 请注意,没有终结操作的流管道是静默无操作的,所以不要忘记包含一个。 - -  Stream API 流式的(fluent)::它设计允许所有组成管道的调用被链接到一个表达式中。事实上,多个管道可以链接在一起形成一个表达式。 - -  默认情况下,流管道按顺序 (sequentially) 运行。 使管道并行执行就像在管道中的任何流上调用并行方法一样简单,但很少这样做(第 48 个条目)。 - -  Stream API 具有足够的通用性,实际上任何计算都可以使用 Stream 执行,但仅仅因为可以,并不意味着应该这样做。如果使用得当,流可以使程序更短更清晰;如果使用不当,它们会使程序难以阅读和维护。对于何时使用流没有硬性的规则,但是有一些启发。 - -  考虑以下程序,该程序从字典文件中读取单词并打印其大小符合用户指定的最小值的所有变位词(anagram)组。如果两个单词由长度相通,不同顺序的相同字母组成,则它们是变位词。程序从用户指定的字典文件中读取每个单词并将单词放入 map 对象中。map 对象的键是按照字母排序的单词,因此『staple』的键是『aelpst』,『petals』的键也是『aelpst』:这两个单词就是同位词,所有的同位词共享相同的依字母顺序排列的形式(或称之为 alphagram)。map 对象的值是包含共享字母顺序形式的所有单词的列表。 处理完字典文件后,每个列表都是一个完整的同位词组。然后程序遍历 map 对象的 `values()` 的视图并打印每个大小符合阈值的列表: - -  流管道由源流(source stream)的零或多个中间操作和一个终结操作组成。每个中间操作都以某种方式转换流,例如将每个元素映射到该元素的函数或过滤掉所有不满足某些条件的元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同或不同。终结操作对流执行最后一次中间操作产生的最终计算,例如将其元素存储到集合中、返回某个元素或打印其所有元素。 - -  管道延迟(lazily)计算求值:计算直到终结操作被调用后才开始,而为了完成终结操作而不需要的数据元素永远不会被计算出来。 这种延迟计算求值的方式使得可以使用无限流。 请注意,没有终结操作的流管道是静默无操作的,所以不要忘记包含一个。 - -  Stream API 流式的(fluent)::它设计允许所有组成管道的调用被链接到一个表达式中。事实上,多个管道可以链接在一起形成一个表达式。 - -  默认情况下,流管道按顺序 (sequentially) 运行。 使管道并行执行就像在管道中的任何流上调用并行方法一样简单,但很少这样做(第 48 个条目)。 - -  Stream API 具有足够的通用性,实际上任何计算都可以使用 Stream 执行,但仅仅因为可以,并不意味着应该这样做。如果使用得当,流可以使程序更短更清晰;如果使用不当,它们会使程序难以阅读和维护。对于何时使用流没有硬性的规则,但是有一些启发。 - -  考虑以下程序,该程序从字典文件中读取单词并打印其大小符合用户指定的最小值的所有变位词(anagram)组。如果两个单词由长度相通,不同顺序的相同字母组成,则它们是变位词。程序从用户指定的字典文件中读取每个单词并将单词放入 map 对象中。map 对象的键是按照字母排序的单词,因此『staple』的键是『aelpst』,『petals』的键也是『aelpst』:这两个单词就是同位词,所有的同位词共享相同的依字母顺序排列的形式(或称之为 alphagram)。map 对象的值是包含共享字母顺序形式的所有单词的列表。 处理完字典文件后,每个列表都是一个完整的同位词组。然后程序遍历 map 对象的 values() 的视图并打印每个大小符合阈值的列表: - - -```java -// Prints all large anagram groups in a dictionary iteratively -public class Anagrams { - public static void main(String[] args) throws IOException { - File dictionary = new File(args[0]); - int minGroupSize = Integer.parseInt(args[1]); - Map> groups = new HashMap<>(); - try (Scanner s = new Scanner(dictionary)) { - while (s.hasNext()) { - - String word = s.next(); - groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word); - } - } - - for (Set group : groups.values()) - if (group.size() >= minGroupSize) - System.out.println(group.size() + ": " + group); - } - - private static String alphabetize(String s) { - char[] a = s.toCharArray(); - Arrays.sort(a); - return new String(a); - } -} -``` - -  这个程序中的一个步骤值得注意。将每个单词插入到 map 中 (以粗体显示) 中使用了 `computeIfAbsent` 方法,该方法是在 Java 8 中添加的。这个方法在 map 中查找一个键:如果键存在,该方法只返回与其关联的值。如果没有,该方法通过将给定的函数对象应用于键来计算值,将该值与键关联,并返回计算值。`computeIfAbsent` 方法简化了将多个值与每个键关联的 map 的实现。 - -  现在考虑以下程序,它解决了同样的问题,但大量过度使用了流。 请注意,整个程序(打开字典文件的代码除外)包含在单个表达式中。 在单独的表达式中打开字典文件的唯一原因是允许使用 `try-with-resources` 语句,该语句确保关闭字典文件: - -```java -// Overuse of streams - don't do this! -public class Anagrams { - public static void main(String[] args) throws IOException { - Path dictionary = Paths.get(args[0]); - int minGroupSize = Integer.parseInt(args[1]); - try (Stream words = Files.lines(dictionary)) { - words.collect( - groupingBy(word -> word.chars().sorted() - .collect(StringBuilder::new, - (sb, c) -> sb.append((char) c), - StringBuilder::append).toString())) - .values().stream() - .filter(group -> group.size() >= minGroupSize) - .map(group -> group.size() + ": " + group) - .forEach(System.out::println); - } - } -} -``` - -  如果你发现这段代码难以阅读,不要担心;你不是一个人。它更短,但是可读性也更差,尤其是对于那些不擅长使用流的程序员来说。**过度使用流使程序难于阅读和维护。** - -  幸运的是,有一个折中的办法。下面的程序解决了同样的问题,使用流而不过度使用它们。其结果是一个比原来更短更清晰的程序: - -```java -// Tasteful use of streams enhances clarity and conciseness -public class Anagrams { - - public static void main(String[] args) throws IOException { - Path dictionary = Paths.get(args[0]); - int minGroupSize = Integer.parseInt(args[1]); - - try (Stream words = Files.lines(dictionary)) { - words.collect(groupingBy(word -> alphabetize(word))) - .values().stream() - .filter(group -> group.size() >= minGroupSize) - .forEach(g -> System.out.println(g.size() + ": " + g)); - } - } - // alphabetize method is the same as in original version -} -``` - -  即使以前很少接触流,这个程序也不难理解。它在一个 `try-with-resources` 块中打开字典文件,获得一个由文件中的所有行组成的流。流变量命名为 words,表示流中的每个元素都是一个单词。此流上的管道没有中间操作;它的终结操作将所有单词收集到个 map 对象中,按照字母排列的形式对单词进行分组 (第 46 项)。这与之前两个版本的程序构造的 map 完全相同。然后在 map 的 `values()` 视图上打开一个新的流 `List`。当然,这个流中的元素是同位词组。对流进行过滤,以便忽略大小小于 minGroupSize 的所有组,最后由终结操作 forEach 打印剩下的同位词组。 - -  请注意,仔细选择 lambda 参数名称。 上面程序中参数 g 应该真正命名为 group,但是生成的代码行对于本书来说太宽了。 在没有显式类型的情况下,仔细命名 lambda 参数对于流管道的可读性至关重要。 - -  另请注意,单词字母化是在单独的 `alphabetize` 方法中完成的。 这通过提供操作名称并将实现细节保留在主程序之外来增强可读性。 **使用辅助方法对于流管道中的可读性比在迭代代码中更为重要,因为管道缺少显式类型信息和命名临时变量。** - -  字母顺序方法可以使用流重新实现,但基于流的字母顺序方法本来不太清楚,更难以正确编写,并且可能更慢。 这些缺陷是由于 Java 缺乏对原始字符流的支持(这并不意味着 Java 应该支持 char 流;这样做是不可行的)。 要演示使用流处理 char 值的危害,请考虑以下代码: - -```java -"Hello world!".chars().forEach(System.out::print); -``` - - -  你可能希望它打印 `Hello world!`,但如果运行它,发现它打印 721011081081113211911111410810033。这是因为 `“Hello world!”.chars()` 返回的流的元素不是 char 值,而是 int 值,因此调用了 `print` 的 int 重载。无可否认,一个名为 chars 的方法返回一个 int 值流是令人困惑的。可以通过强制调用正确的重载来修复该程序: - -  请注意,仔细选择 lambda 参数名称。 上面程序中参数 g 应该真正命名为 group,但是生成的代码行对于本书来说太宽了。 **在没有显式类型的情况下,仔细命名 lambda 参数对于流管道的可读性至关重要。** - -```java -"Hello world!".chars().forEach(x -> System.out.print((char) x)); -``` - -  **但理想情况下,应该避免使用流来处理 char 值。** - -  当开始使用流时,你可能会感到想要将所有循环语句转换为流方式的冲动,但请抵制这种冲动。尽管这是可能的,但可能会损害代码库的可读性和可维护性。 通常,使用流和迭代的某种组合可以最好地完成中等复杂的任务,如上面的 `Anagrams` 程序所示。 **因此,重构现有代码以使用流,并仅在有意义的情况下在新代码中使用它们。** - -  如本项目中的程序所示,流管道使用函数对象 (通常为 lambdas 或方法引用) 表示重复计算,而迭代代码使用代码块表示重复计算。从代码块中可以做一些从函数对象中不能做的事情: - - - 从代码块中,可以读取或修改范围内的任何局部变量; 从 lambda 中,只能读取最终或有效的最终变量[JLS 4.12.4],并且无法修改任何局部变量。 - - 从代码块中,可以从封闭方法返回,中断或继续封闭循环,或抛出声明此方法的任何已检查异常; 从一个 lambda 你不能做这些事情。 - -  如果使用这些技术最好地表达计算,那么它可能不是流的良好匹配。 相反,流可以很容易地做一些事情: - - - 统一转换元素序列 - - 过滤元素序列 - - 使用单个操作组合元素序列 (例如添加、连接或计算最小值) - - 将元素序列累积到一个集合中,可能通过一些公共属性将它们分组 - - 在元素序列中搜索满足某些条件的元素 - -  如果使用这些技术最好地表达计算,那么使用流是这些场景很好的候选者。 - -  对于流来说,很难做到的一件事是同时访问管道的多个阶段中的相应元素:一旦将值映射到其他值,原始值就会丢失。一种解决方案是将每个值映射到一个包含原始值和新值的 `pair` 对象,但这不是一个令人满意的解决方案,尤其是在管道的多个阶段需要一对对象时更是如此。生成的代码既混乱又冗长,破坏了流的主要用途。当它适用时,一个更好的解决方案是在需要访问早期阶段值时转换映射。 - -  例如,让我们编写一个程序来打印前 20 个梅森素数 (Mersenne primes)。 梅森素数是一个 2p − 1 形式的数字。如果 p 是素数,相应的梅森数可能是素数; 如果是这样的话,那就是梅森素数。 作为我们管道中的初始流,我们需要所有素数。 这里有一个返回该(无限)流的方法。 我们假设使用静态导入来轻松访问 `BigInteger` 的静态成员: - -```java -static Stream primes() { - return Stream.iterate(TWO, BigInteger::nextProbablePrime); -} -``` - -  方法的名称(primes)是一个复数名词,描述了流的元素。 强烈建议所有返回流的方法使用此命名约定,因为它增强了流管道的可读性。 该方法使用静态工厂 `Stream.iterate`,它接受两个参数:流中的第一个元素,以及从前一个元素生成流中的下一个元素的函数。 这是打印前 20 个梅森素数的程序: - -```java -public static void main(String[] args) { - primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE)) - .filter(mersenne -> mersenne.isProbablePrime(50)) - .limit(20) - .forEach(System.out::println); -} -``` - -  这个程序是上面的梅森描述的直接编码:它从素数开始,计算相应的梅森数,过滤掉除素数之外的所有数字(幻数 50 控制概率素性测试 the magic number 50 controls the probabilistic primality test),将得到的流限制为 20 个元素, 并打印出来。 - -  现在假设我们想在每个梅森素数前面加上它的指数 (p),这个值只出现在初始流中,因此在终结操作中不可访问,而终结操作将输出结果。幸运的是通过反转第一个中间操作中发生的映射,可以很容易地计算出 `Mersenne` 数的指数。 指数是二进制表示中的位数,因此该终结操作会生成所需的结果: - -```java -.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp)); -``` - -  有很多任务不清楚是使用流还是迭代。例如,考虑初始化一副新牌的任务。假设 `Card` 是一个不可变的值类,它封装了 `Rank` 和 `Suit`,它们都是枚举类型。这个任务代表任何需要计算可以从两个集合中选择的所有元素对。数学家们称它为两个集合的笛卡尔积。下面是一个迭代实现,它有一个嵌套的 `for-each` 循环,你应该非常熟悉: - -```java -// Iterative Cartesian product computation -private static List newDeck() { - List result = new ArrayList<>(); - - for (Suit suit : Suit.values()) - for (Rank rank : Rank.values()) - result.add(new Card(suit, rank)); - - return result; -} -``` - -  下面是一个基于流的实现,它使用了中间操作 `flatMap` 方法。这个操作将一个流中的每个元素映射到一个流,然后将所有这些新流连接到一个流 (或展平它们)。注意,这个实现包含一个嵌套的 lambda 表达式`(rank -> new Card(suit, rank))`: - -```java -// Stream-based Cartesian product computation -private static List newDeck() { - return Stream.of(Suit.values()) - .flatMap(suit -> - Stream.of(Rank.values()) - .map(rank -> new Card(suit, rank))) - .collect(toList()); -} -``` - -  newDeck 的两个版本中哪一个更好? 它归结为个人偏好和你的编程的环境。 第一个版本更简单,也许感觉更自然。 大部分 Java 程序员将能够理解和维护它,但是一些程序员会对第二个(基于流的)版本感觉更舒服。 如果对流和函数式编程有相当的精通,那么它会更简洁,也不会太难理解。 如果不确定自己喜欢哪个版本,则迭代版本可能是更安全的选择。 如果你更喜欢流的版本,并且相信其他使用该代码的程序员会与你共享你的偏好,那么应该使用它。 - -  总之,有些任务最好使用流来完成,有些任务最好使用迭代来完成。将这两种方法结合起来,可以最好地完成许多任务。对于选择使用哪种方法进行任务,没有硬性规定,但是有一些有用的启发式方法。在许多情况下,使用哪种方法将是清楚的;在某些情况下,则不会很清楚。**如果不确定一个任务是通过流还是迭代更好地完成,那么尝试这两种方法,看看哪一种效果更好。** -# 46. 优先考虑流中无副作用的函数 - -  如果你是一个刚开始使用流的新手,那么很难掌握它们。仅仅将计算表示为流管道是很困难的。当你成功时,你的程序将运行,但对你来说可能没有意识到任何好处。流不仅仅是一个 API,它是基于函数式编程的范式(paradigm)。为了获得流提供的可表达性、速度和某些情况下的并行性,你必须采用范式和 API。 - -  流范式中最重要的部分是将计算结构化为一系列转换,其中每个阶段的结果尽可能接近前一阶段结果的纯函数( pure function)。 纯函数的结果仅取决于其输入:它不依赖于任何可变状态,也不更新任何状态。 为了实现这一点,你传递给流操作的任何函数对象(中间操作和终结操作)都应该没有副作用。 - -  有时,可能会看到类似于此代码片段的流代码,该代码构建了文本文件中单词的频率表: - -```java -// Uses the streams API but not the paradigm--Don't do this! -Map freq = new HashMap<>(); -try (Stream words = new Scanner(file).tokens()) { - words.forEach(word -> { - freq.merge(word.toLowerCase(), 1L, Long::sum); - }); -} -``` - -  这段代码出了什么问题? 毕竟,它使用了流,lambdas 和方法引用,并得到正确的答案。 简而言之,它根本不是流代码; 它是伪装成流代码的迭代代码。 它没有从流 API 中获益,并且它比相应的迭代代码更长,更难读,并且更难于维护。 问题源于这样一个事实:这个代码在一个终结操作 `forEach` 中完成所有工作,使用一个改变外部状态(频率表)的 lambda。`forEach` 操作除了表示由一个流执行的计算结果外,什么都不做,这是“代码中的臭味”,就像一个改变状态的 lambda 一样。那么这段代码应该是什么样的呢? - -```java -// Proper use of streams to initialize a frequency table -Map freq; -try (Stream words = new Scanner(file).tokens()) { - freq = words - .collect(groupingBy(String::toLowerCase, counting())); -} -``` - -  此代码段与前一代码相同,但正确使用了流 API。 它更短更清晰。 那么为什么有人会用其他方式写呢? 因为它使用了他们已经熟悉的工具。 Java 程序员知道如何使用 for-each 循环,而 `forEach` 终结操作是类似的。 但 `forEach` 操作是终端操作中最不强大的操作之一,也是最不友好的流操作。 它是明确的迭代,因此不适合并行化。 **`forEach` 操作应仅用于报告流计算的结果,而不是用于执行计算。** 有时,将 `forEach` 用于其他目的是有意义的,例如将流计算的结果添加到预先存在的集合中。 - -  改进后的代码使用了收集器(collector),这是使用流必须学习的新概念。`Collectors` 的 API 令人生畏:它有 39 个方法,其中一些方法有多达 5 个类型参数。好消息是,你可以从这个 API 中获得大部分好处,而不必深入研究它的全部复杂性。对于初学者来说,可以忽略收集器接口,将收集器看作是封装缩减策略(reduction strategy)的不透明对象。在此上下文中,reduction 意味着将流的元素组合为单个对象。 收集器生成的对象通常是一个集合(它代表名称收集器)。 - -  将流的元素收集到真正的集合中的收集器非常简单。有三个这样的收集器:`toList()`、`toSet()` 和 `toCollection(collectionFactory)`。它们分别返回集合、列表和程序员指定的集合类型。有了这些知识,我们就可以编写一个流管道从我们的频率表中提取出现频率前 10 个单词的列表。 - -```java -// Pipeline to get a top-ten list of words from a frequency table -List topTen = freq.keySet().stream() - .sorted(comparing(freq::get).reversed()) - .limit(10) - .collect(toList()); -``` - -  注意,我们没有对 `toList` 方法的类收集器进行限定。**静态导入收集器的所有成员是一种惯例和明智的做法,因为它使流管道更易于阅读。** - -  这段代码中唯一比较棘手的部分是我们把 `comparing(freq::get).reverse()` 传递给 `sort` 方法。comparing 是一种比较器构造方法 (条目 14),它具有一个 key 的提取方法。该函数接受一个单词,而“提取”实际上是一个表查找:绑定方法引用 `freq::get` 在 `frequency` 表中查找单词,并返回单词出现在文件中的次数。最后,我们在比较器上调用 `reverse` 方法,因此我们将单词从最频繁到最不频繁进行排序。然后,将流限制为 10 个单词并将它们收集到一个列表中就很简单了。 - -  前面的代码片段使用 `Scanner` 的 `stream` 方法在 `scanner` 实例上获取流。这个方法是在 Java 9 中添加的。如果正在使用较早的版本,可以使用类似于条目 47 中 (`streamOf(Iterable)`) 的适配器将实现了 `Iterator` 的 `scanner` 序转换为流。 - -  那么收集器中的其他 36 种方法呢?它们中的大多数都是用于将流收集到 map 中的,这比将流收集到真正的集合中要复杂得多。每个流元素都与一个键和一个值相关联,多个流元素可以与同一个键相关联。 - -  最简单的映射收集器是 toMap(keyMapper、valueMapper),它接受两个函数,一个将流元素映射到键,另一个映射到值。在条目 34 中的 `fromString` 实现中,我们使用这个收集器从 enum 的字符串形式映射到 enum 本身: - -```java -// Using a toMap collector to make a map from string to enum -private static final Map stringToEnum = - Stream.of(values()).collect( - toMap(Object::toString, e -> e)); -``` - -  如果流中的每个元素都映射到唯一键,则这种简单的 `toMap` 形式是完美的。 如果多个流元素映射到同一个键,则管道将以 `IllegalStateException` 终止。 - -  `toMap` 更复杂的形式,以及 `groupingBy` 方法,提供了处理此类冲突 (collisions) 的各种方法。一种方法是向 `toMap` 方法提供除键和值映射器 (mappers) 之外的 `merge` 方法。`merge` 方法是一个 `BinaryOperator`,其中 V是 map 的值类型。与键关联的任何附加值都使用 `merge` 方法与现有值相结合,因此,例如,如果 merge 方法是乘法,那么最终得到的结果是是值 `mapper` 与键关联的所有值的乘积。 - -  `toMap` 的三个参数形式对于从键到与该键关联的选定元素的映射也很有用。例如,假设我们有一系列不同艺术家(artists)的唱片集(albums),我们想要一张从唱片艺术家到最畅销专辑的 map。这个收集器将完成这项工作。 - -```java -// Collector to generate a map from key to chosen element for key -Map topHits = albums.collect( - toMap(Album::artist, a->a, maxBy(comparing(Album::sales)))); -``` - -  请注意,比较器使用静态工厂方法 `maxBy`,它是从 BinaryOperator 静态导入的。 此方法将 `Comparator` 转换为 `BinaryOperator`,用于计算指定比较器隐含的最大值。 在这种情况下,比较器由比较器构造方法 comparing 返回,它采用 key 提取器函数 `Album::sales`。 这可能看起来有点复杂,但代码可读性很好。 简而言之,它说,“将专辑(albums)流转换为地 map,将每位艺术家(artist)映射到销售量最佳的专辑。”这与问题陈述出奇得接近。 - -  `toMap` 的三个参数形式的另一个用途是产生一个收集器,当发生冲突时强制执行 last-write-wins 策略。 对于许多流,结果是不确定的,但如果映射函数可能与键关联的所有值都相同,或者它们都是可接受的,则此收集器的行为可能正是您想要的: - -```java -// Collector to impose last-write-wins policy -toMap(keyMapper, valueMapper, (oldVal, newVal) ->newVal) -``` - -  `toMap` 的第三个也是最后一个版本采用第四个参数,它是一个 map 工厂,用于指定特定的 map 实现,例如 `EnumMap` 或 `TreeMap`。 - -  `toMap` 的前三个版本也有变体形式,名为 `toConcurrentMap`,它们并行高效运行并生成 `ConcurrentHashMap` 实例。 - -  除了 toMap 方法之外,Collectors API 还提供了 `groupingBy` 方法,该方法返回收集器以生成基于分类器函数 (classifier function) 将元素分组到类别中的 map。 分类器函数接受一个元素并返回它所属的类别。 此类别来用作元素的 map 的键。 `groupingBy` 方法的最简单版本仅采用分类器并返回一个 map,其值是每个类别中所有元素的列表。 这是我们在条目 45 中的 `Anagram` 程序中使用的收集器,用于生成从按字母顺序排列的单词到单词列表的 map: - -```java -Map freq = words - .collect(groupingBy(String::toLowerCase, counting())); -``` -  `groupingBy` 的第三个版本允许指定除 `downstream` 收集器之外的 map 工厂。 请注意,这种方法违反了标准的可伸缩参数列表模式 (standard telescoping argument list pattern):`mapFactory` 参数位于 `downStream` 参数之前,而不是之后。 此版本的 `groupingBy` 可以控制包含的 map 以及包含的集合,因此,例如,可以指定一个收集器,它返回一个 `TreeMap`,其值是 `TreeSet`。 - -  `groupingByConcurrent` 方法提供了 `groupingBy` 的所有三个重载的变体。 这些变体并行高效运行并生成 `ConcurrentHashMap` 实例。 还有一个很少使用的 grouping 的亲戚称为 `partitioningBy`。 代替分类器方法,它接受 `predicate` 并返回其键为布尔值的 map。 此方法有两种重载,除了 `predicate` 之外,其中一种方法还需要 `downstream` 收集器。 - -  通过 `counting` 方法返回的收集器仅用作下游收集器。 Stream 上可以通过 count 方法直接使用相同的功能,因此没有理由说 `collect(counting())`。 此属性还有十五种收集器方法。 它们包括九个方法,其名称以 `summing`,`averaging` 和 `summarizing` 开头(其功能在相应的原始流类型上可用)。 它们还包括 `reduce` 方法的所有重载,以及 filter,`mapping`,`flatMapping` 和 `collectingAndThen` 方法。 大多数程序员可以安全地忽略大多数这些方法。 从设计的角度来看,这些收集器代表了尝试在收集器中部分复制流的功能,以便下游收集器可以充当“迷你流 (ministreams)”。 - -  我们还有三种收集器方法尚未提及。 虽然他们在收 `Collectors` 类中,但他们不涉及集合。 前两个是 `minBy` 和 `maxBy`,它们取比较器并返回比较器确定的流中的最小或最大元素。 它们是 `Stream` 接口中 `min` 和 `max` 方法的次要总结,是 `BinaryOperator` 中类似命名方法返回的二元运算符的类似收集器。 回想一下,我们在最畅销的专辑中使用了 `BinaryOperator.maxBy` 方法。 - -  最后的 `Collectors` 中方法是 `join`,它仅对 `CharSequence` 实例(如字符串)的流进行操作。 在其无参数形式中,它返回一个简单地连接元素的收集器。 它的一个参数形式采用名为 `delimiter` 的单个 `CharSequence` 参数,并返回一个连接流元素的收集器,在相邻元素之间插入分隔符。 如果传入逗号作为分隔符,则收集器将返回逗号分隔值字符串(但请注意,如果流中的任何元素包含逗号,则字符串将不明确)。 除了分隔符之外,三个参数形式还带有前缀和后缀。 生成的收集器会生成类似于打印集合时获得的字符串,例如`[came, saw, conquered]`。 - -  总之,编程流管道的本质是无副作用的函数对象。 这适用于传递给流和相关对象的所有许多函数对象。 终结操作 forEach 仅应用于报告流执行的计算结果,而不是用于执行计算。 为了正确使用流,必须了解收集器。 最重要的收集器工厂是 `toList`,`toSet`,`toMap`,`groupingBy` 和 `join`。 -# 47. 优先使用 Collection 而不是 Stream 来作为方法的返回类型 - -  许多方法返回元素序列(sequence)。在 Java 8 之前,通常方法的返回类型是 `Collection`,`Set` 和 `List` 这些接口;还包括 `Iterable` 和数组类型。通常,很容易决定返回哪一种类型。规范(norm)是集合接口。如果该方法仅用于启用 for-each 循环,或者返回的序列不能实现某些 `Collection` 方法 (通常是 `contains(Object)`),则使用迭代(`Iterable`)接口。如果返回的元素是基本类型或有严格的性能要求,则使用数组。在 Java 8 中,将流(Stream)添加到平台中,这使得为序列返回方法选择适当的返回类型的任务变得非常复杂。 - -  你可能听说过,流现在是返回元素序列的明显的选择,但是正如条目 45 所讨论的,流不会使迭代过时:编写好的代码需要明智地结合流和迭代。如果一个 API 只返回一个流,并且一些用户想用 for-each 循环遍历返回的序列,那么这些用户肯定会感到不安。这尤其令人沮丧,因为 `Stream` 接口在 `Iterable` 接口中包含唯一的抽象方法,`Stream` 的方法规范与 `Iterable` 兼容。阻止程序员使用 for-each 循环在流上迭代的唯一原因是 `Stream` 无法继承 `Iterable`。 - -  遗憾的是,这个问题没有好的解决方法。 乍一看,似乎可以将方法引用传递给 `Stream` 的 iterator 方法。 结果代码可能有点嘈杂和不透明,但并非不合理: - -```java -// Won't compile, due to limitations on Java's type inference -for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) { - // Process the process -} -``` - -  不幸的是,如果你试图编译这段代码,会得到一个错误信息: - -```java -Test.java:6: error: method reference not expected here -for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) { -``` - -  为了使代码编译,必须将方法引用强制转换为适当参数化的 `Iterable` 类型: - -```java -// Hideous workaround to iterate over a stream -for (ProcessHandle ph : (Iterable)ProcessHandle.allProcesses()::iterator) -``` - -  此代码有效,但在实践中使用它太嘈杂和不透明。 更好的解决方法是使用适配器方法。 JDK 没有提供这样的方法,但是使用上面的代码片段中使用的相同技术,很容易编写一个方法。 请注意,在适配器方法中不需要强制转换,因为 Java 的类型推断在此上下文中能够正常工作: - -```java -// Adapter from Stream to Iterable -public static Iterable iterableOf(Stream stream) { - return stream::iterator; -} -``` - -  使用此适配器,可以使用 for-each 语句迭代任何流: - -```java -for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) { - // Process the process -} -``` - -  注意,条目 34 中的 `Anagrams` 程序的流版本使用 `Files.lines` 方法读取字典,而迭代版本使用了 scanner。`Files.lines` 方法优于 scanner,scanner 在读取文件时无声地吞噬所有异常。理想情况下,我们也会在迭代版本中使用 `Files.lines`。如果 API 只提供对序列的流访问,而程序员希望使用 for-each 语句遍历序列,那么他们就要做出这种妥协。 - -  相反,如果一个程序员想要使用流管道来处理一个序列,那么一个只提供 `Iterable` 的 API 会让他感到不安。JDK 同样没有提供适配器,但是编写这个适配器非常简单: - -```java -// Adapter from Iterable to Stream -public static Stream streamOf(Iterable iterable) { - return StreamSupport.stream(iterable.spliterator(), false); -} -``` - -  如果你正在编写一个返回对象序列的方法,并且它只会在流管道中使用,那么当然可以自由地返回流。类似地,返回仅用于迭代的序列的方法应该返回一个 `Iterable`。但是如果你写一个公共 API,它返回一个序列,你应该为用户提供哪些想写流管道,哪些想写 for-each 语句,除非你有充分的理由相信大多数用户想要使用相同的机制。 - -  `Collection` 接口是 `Iterable` 的子类型,并且具有 `stream` 方法,因此它提供迭代和流访问。 因此,**`Collection` 或适当的子类型通常是公共序列返回方法的最佳返回类型。** 数组还使用 `Arrays.asList` 和 `Stream.of` 方法提供简单的迭代和流访问。 如果返回的序列小到足以容易地放入内存中,那么最好返回一个标准集合实现,例如 `ArrayList` 或 `HashSet`。 **但是不要在内存中存储大的序列,只是为了将它作为集合返回。** - -  如果返回的序列很大但可以简洁地表示,请考虑实现一个专用集合。 例如,假设返回给定集合的幂集(power set:就是原集合中所有的子集(包括全集和空集)构成的集族),该集包含其所有子集。 {a,b,c} 的幂集为 {{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b, c}}。 如果一个集合具有 n 个元素,则幂集具有 2n 个。 因此,你甚至不应考虑将幂集存储在标准集合实现中。 但是,在 `AbstractList` 的帮助下,很容易为此实现自定义集合。 - -  诀窍是使用幂集中每个元素的索引作为位向量(bit vector),其中索引中的第 n 位指示源集合中是否存在第 n 个元素。 本质上,从 0 到 2n-1 的二进制数和 n 个元素集和的幂集之间存在自然映射。 这是代码: - -```java -// Returns the power set of an input set as custom collection -public class PowerSet { - public static final Collection> of(Set s) { - List src = new ArrayList<>(s); - - if (src.size() > 30) - throw new IllegalArgumentException("Set too big " + s); - - return new AbstractList>() { - @Override - public int size() { - return 1 << src.size(); // 2 to the power srcSize - } - - @Override - public boolean contains(Object o) { - return o instanceof Set && src.containsAll((Set)o); - } - - @Override - public Set get(int index) { - Set result = new HashSet<>(); - for (int i = 0; index != 0; i++, index >>= 1) - if ((index & 1) == 1) - result.add(src.get(i)); - return result; - } - }; - } -} -``` - -  请注意,如果输入集合超过 30 个元素,则 `PowerSet.of` 方法会引发异常。 这突出了使用 `Collection` 作为返回类型而不是 `Stream` 或 `Iterable` 的缺点:`Collection` 有 int 返回类型的 size 的方法,该方法将返回序列的长度限制为 `Integer.MAX_VALUE` 或 231-1。`Collection` 规范允许 size 方法返回 231 - 1,如果集合更大,甚至无限,但这不是一个完全令人满意的解决方案。 - -  为了在 `AbstractCollection` 上编写 `Collection` 实现,除了 `Iterable` 所需的方法之外,只需要实现两种方法:`contains` 和 `size`。 通常,编写这些方法的有效实现很容易。 如果不可行,可能是因为在迭代发生之前未预先确定序列的内容,返回 `Stream` 还是 `Iterable` 的,无论哪种感觉更自然。 如果选择,可以使用两种不同的方法分别返回。 - -  有时,你会仅根据实现的易用性选择返回类型。例如,假设希望编写一个方法,该方法返回输入列表的所有 (连续的) 子列表。生成这些子列表并将它们放到标准集合中只需要三行代码,但是保存这个集合所需的内存是源列表大小的二次方。虽然这没有指数幂集那么糟糕,但显然是不可接受的。实现自定义集合 (就像我们对幂集所做的那样) 会很乏味,因为 JDK 缺少一个框架 `Iterator` 实现来帮助我们。 - -  然而,实现输入列表的所有子列表的流是直截了当的,尽管它确实需要一点的洞察力(insight)。 让我们调用一个子列表,该子列表包含列表的第一个元素和列表的前缀。 例如,(a,b,c)的前缀是(a),(a,b)和(a,b,c)。 类似地,让我们调用包含后缀的最后一个元素的子列表,因此(a,b,c)的后缀是(a,b,c),(b,c)和(c)。 洞察力是列表的子列表只是前缀的后缀(或相同的后缀的前缀)和空列表。 这一观察直接展现了一个清晰,合理简洁的实现: - -```java -// Returns a stream of all the sublists of its input list -public class SubLists { - - public static Stream> of(List list) { - return Stream.concat(Stream.of(Collections.emptyList()), - prefixes(list).flatMap(SubLists::suffixes)); - } - - private static Stream> prefixes(List list) { - return IntStream.rangeClosed(1, list.size()) - .mapToObj(end -> list.subList(0, end)); - } - - private static Stream> suffixes(List list) { - return IntStream.range(0, list.size()) - .mapToObj(start -> list.subList(start, list.size())); - } -} -``` - -  请注意,`Stream.concat` 方法用于将空列表添加到返回的流中。 还有,`flatMap` 方法(条目 45)用于生成由所有前缀的所有后缀组成的单个流。 最后,通过映射 `IntStream.range` 和 `IntStream.rangeClosed` 返回的连续 int 值流来生成前缀和后缀。这个习惯用法,粗略地说,流等价于整数索引上的标准 for 循环。因此,我们的子列表实现似于明显的嵌套 for 循环: - -```java -for (int start = 0; start < src.size(); start++) - for (int end = start + 1; end <= src.size(); end++) - System.out.println(src.subList(start, end)); -``` - -  可以将这个 for 循环直接转换为流。结果比我们以前的实现更简洁,但可能可读性稍差。它类似于条目 45 中的笛卡尔积的使用流的代码: - -```java -// Returns a stream of all the sublists of its input list -public static Stream> of(List list) { - return IntStream.range(0, list.size()) - .mapToObj(start -> - IntStream.rangeClosed(start + 1, list.size()) - .mapToObj(end -> list.subList(start, end))) - .flatMap(x -> x); -} -``` -  与之前的 for 循环一样,此代码不会包换空列表。 为了解决这个问题,可以使用 `concat` 方法,就像我们在之前版本中所做的那样,或者在 `rangeClosed` 调用中用 `(int) Math.signum(start)` 替换 1。 - -  这两种子列表的流实现都可以,但都需要一些用户使用流-迭代适配器 ( Stream-to-Iterable adapte),或者在更自然的地方使用流。流-迭代适配器不仅打乱了客户端代码,而且在我的机器上使循环速度降低了 2.3 倍。一个专门构建的 Collection 实现 (此处未显示) 要冗长,但运行速度大约是我的机器上基于流的实现的 1.4 倍。 - -  总之,在编写返回元素序列的方法时,请记住,某些用户可能希望将它们作为流处理,而其他用户可能希望迭代方式来处理它们。 尽量适应两个群体。 如果返回集合是可行的,请执行此操作。 如果已经拥有集合中的元素,或者序列中的元素数量足够小,可以创建一个新的元素,那么返回一个标准集合,比如 `ArrayList`。 否则,请考虑实现自定义集合,就像我们为幂集程序里所做的那样。 如果返回集合是不可行的,则返回流或可迭代的,无论哪个看起来更自然。 如果在将来的 Java 版本中,`Stream` 接口声明被修改为继承 `Iterable`,那么应该随意返回流,因为它们将允许流和迭代处理。 - -# 48. 谨慎使用流并行 - -  在主流语言中,Java 一直处于提供简化并发编程任务的工具的最前沿。 当 Java 于 1996 年发布时,它内置了对线程的支持,包括同步和 wait / notify 机制。 Java 5 引入了 `java.util.concurrent` 类库,带有并发集合和执行器框架。 Java 7 引入了 fork-join 包,这是一个用于并行分解的高性能框架。 Java 8 引入了流,可以通过对 `parallel` 方法的单个调用来并行化。 用 Java 编写并发程序变得越来越容易,但编写正确快速的并发程序还像以前一样困难。 安全和活跃度违规(liveness violation)是并发编程中的事实,并行流管道也不例外。 - -  考虑条目 45 中的程序: - -```java -// Stream-based program to generate the first 20 Mersenne primes -public static void main(String[] args) { - primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE)) - .filter(mersenne -> mersenne.isProbablePrime(50)) - .limit(20) - .forEach(System.out::println); -} - -static Stream primes() { - return Stream.iterate(TWO, BigInteger::nextProbablePrime); -} -``` - -  在我的机器上,这个程序立即开始打印素数,运行到完成需要 12.5 秒。假设我天真地尝试通过向流管道中添加一个到 `parallel()` 的调用来加快速度。你认为它的表现会怎样?它会快几个百分点吗?慢几个百分点?遗憾的是,它不会打印任何东西,但是 CPU 使用率会飙升到 90%,并且会无限期地停留在那里 (liveness failure:活性失败)。这个程序可能最终会终止,但我不愿意去等待;半小时后我强行阻止了它。 - -  这里发生了什么?简而言之,流类库不知道如何并行化此管道并且启发式失败(heuristics fail)。 **即使在最好的情况下,如果源来自 Stream.iterate 方法,或者使用中间操作 limit 方法,并行化管道也不太可能提高其性能。** 这个管道必须应对这两个问题。更糟糕的是,默认的并行策略处理不可预测性的 `limit` 方法,假设在处理一些额外的元素和丢弃任何不必要的结果时没有害处。在这种情况下,找到每个梅森素数的时间大约是找到上一个素数的两倍。因此,计算单个额外元素的成本大致等于计算所有先前元素组合的成本,并且这种无害的管道使自动并行化算法瘫痪。这个故事的寓意很简单:不要无差别地并行化流管道(stream pipelines)。性能后果可能是灾难性的。 - -  **通常,并行性带来的性能收益在 `ArrayList`、`HashMap`、`HashSet` 和 `ConcurrentHashMap` 实例、数组、`int` 类型范围和 `long` 类型的范围的流上最好。** 这些数据结构的共同之处在于,它们都可以精确而廉价地分割成任意大小的子程序,这使得在并行线程之间划分工作变得很容易。用于执行此任务的流泪库使用的抽象是 `spliterator`,它由 `spliterator` 方法在 `Stream` 和 `Iterable` 上返回。 - -  所有这些数据结构的共同点的另一个重要因素是它们在顺序处理时提供了从良好到极好的引用位置( locality of reference):顺序元素引用在存储器中存储在一块。 这些引用所引用的对象在存储器中可能彼此不接近,这降低了引用局部性。 对于并行化批量操作而言,引用位置非常重要:没有它,线程大部分时间都处于空闲状态,等待数据从内存传输到处理器的缓存中。 具有最佳引用位置的数据结构是基本类型的数组,因为数据本身连续存储在存储器中。 - -  流管道终端操作的性质也会影响并行执行的有效性。 如果与管道的整体工作相比,在终端操作中完成了大量的工作,并且这种操作本质上是连续的,那么并行化管道的有效性将是有限的。 并行性的最佳终操作是缩减(reductions),即使用流的 `reduce` 方法组合管道中出现的所有元素,或者预先打包的 `reduce`(如 `min`、`max`、`count` 和 `sum`)。短路操作 `anyMatch`、`allMatch` 和 `noneMatch` 也可以支持并行性。由 `Stream` 的 `collect` 方法执行的操作,称为可变缩减(mutable reductions),不适合并行性,因为组合集合的开销非常大。 - -  如果编写自己的 `Stream`,`Iterable` 或 `Collection` 实现,并且希望获得良好的并行性能,则必须重写 `spliterator` 方法并广泛测试生成的流的并行性能。 编写高质量的 `spliterator` 很困难,超出了本书的范围。 - -  **并行化一个流不仅会导致糟糕的性能,包括活性失败(liveness failures);它会导致不正确的结果和不可预知的行为 (安全故障)。** 使用映射器(`mappers`),过滤器(`filters`)和其他程序员提供的不符合其规范的功能对象的管道并行化可能会导致安全故障。 Stream 规范对这些功能对象提出了严格的要求。 例如,传递给 `Stream` 的 `reduce` 方法操作的累加器(`accumulator`)和组合器(`combiner`)函数必须是关联的,非干扰的和无状态的。 如果违反了这些要求(其中一些在第 46 项中讨论过),但按顺序运行你的管道,则可能会产生正确的结果; 如果将它并行化,它可能会失败,也许是灾难性的。 - -  沿着这些思路,值得注意的是,即使并行的梅森素数程序已经运行完成,它也不会以正确的 (升序的) 顺序打印素数。为了保持顺序版本显示的顺序,必须将 `forEach` 终端操作替换为 `forEachOrdered` 操作,它保证以遇出现顺序(encounter order)遍历并行流。 - -  即使假设正在使用一个高效的可拆分的源流、一个可并行化的或廉价的终端操作以及非干扰的函数对象,也无法从并行化中获得良好的加速效果,除非管道做了足够的实际工作来抵消与并行性相关的成本。作为一个非常粗略的估计,流中的元素数量乘以每个元素执行的代码行数应该至少是 100,000 [Lea14]。 - -  重要的是要记住并行化流是严格的性能优化。 与任何优化一样,必须在更改之前和之后测试性能,以确保它值得做(第 67 项)。 理想情况下,应该在实际的系统设置中执行测试。 通常,程序中的所有并行流管道都在公共 fork-join 池中运行。 单个行为不当的管道可能会损害系统中不相关部分的其他行为。 - -  如果在并行化流管道时,这种可能性对你不利,那是因为它们确实存在。一个认识的人,他维护一个数百万行代码库,大量使用流,他发现只有少数几个地方并行流是有效的。这并不意味着应该避免并行化流。**在适当的情况下,只需向流管道添加一个 `parallel` 方法调用,就可以实现处理器内核数量的近似线性加速。** 某些领域,如机器学习和数据处理,特别适合这些加速。 - -```java -// 作为并行性有效的流管道的简单示例,请考虑此函数来计算π(n),素数小于或等于 n: -// Prime-counting stream pipeline - benefits from parallelization -static long pi(long n) { - return LongStream.rangeClosed(2, n) - .mapToObj(BigInteger::valueOf) - .filter(i -> i.isProbablePrime(50)) - .count(); -} -``` - -  在我的机器上,使用此功能计算π(108)需要 31 秒。 只需添加 `parallel()` 方法调用即可将时间缩短为 9.2 秒: - -```java -// Prime-counting stream pipeline - parallel version -static long pi(long n) { - return LongStream.rangeClosed(2, n) - .parallel() - .mapToObj(BigInteger::valueOf) - .filter(i -> i.isProbablePrime(50)) - .count(); -} -``` - -  换句话说,在我的四核计算机上,并行计算速度提高了 3.7 倍。值得注意的是,这不是你在实践中如何计算π(n) 为 n 的值。还有更有效的算法,特别是 Lehmer’s formula。 - -  如果要并行化随机数流,请从 `SplittableRandom` 实例开始,而不是 `ThreadLocalRandom`(或基本上过时的 `Random`)。 `SplittableRandom` 专为此用途而设计,具有线性加速的潜力。`ThreadLocalRandom` 设计用于单个线程,并将自身适应作为并行流源,但不会像 `SplittableRandom` 一样快。`Random` 实例在每个操作上进行同步,因此会导致过度的并行杀死争用(parallelism-killing contention)。 - -  总之,甚至不要尝试并行化流管道,除非你有充分的理由相信它将保持计算的正确性并提高其速度。不恰当地并行化流的代价可能是程序失败或性能灾难。如果您认为并行性是合理的,那么请确保您的代码在并行运行时保持正确,并在实际情况下进行仔细的性能度量。如果您的代码是正确的,并且这些实验证实了您对性能提高的怀疑,那么并且只有这样才能在生产代码中并行化流。 -# 49. 检查参数有效性 - -  本章(第 8 章)讨论了方法设计的几个方面:如何处理参数和返回值,如何设计方法签名以及如何记载方法文档。 本章中的大部分内容适用于构造方法和其他普通方法。 与第 4 章一样,本章重点关注可用性,健壮性和灵活性上。 - -  大多数方法和构造方法对可以将哪些值传递到其对应参数中有一些限制。 例如,索引值必须是非负数,对象引用必须为非 `null`。 你应该清楚地在文档中记载所有这些限制,并在方法主体的开头用检查来强制执行。 应该尝试在错误发生后尽快检测到错误,这是一般原则的特殊情况。 如果不这样做,则不太可能检测到错误,并且一旦检测到错误就更难确定错误的来源。 - -  如果将无效参数值传递给方法,并且该方法在执行之前检查其参数,则它抛出适当的异常然后快速且清楚地以失败结束。 如果该方法无法检查其参数,可能会发生一些事情。 在处理过程中,该方法可能会出现令人困惑的异常。 更糟糕的是,该方法可以正常返回,但默默地计算错误的结果。 最糟糕的是,该方法可以正常返回但是将某个对象置于受损状态,在将来某个未确定的时间在代码中的某些不相关点处导致错误。 换句话说,验证参数失败可能导致违反故障原子性(failure atomicity )(条目 76)。 - -  对于公共方法和受保护方法,请使用 Java 文档`@throws`注解来记在在违反参数值限制时将引发的异常(条目 74)。 通常,生成的异常是`IllegalArgumentException`,`IndexOutOfBoundsException`或`NullPointerException`(条目 72)。 一旦记录了对方法参数的限制,并且记录了违反这些限制时将引发的异常,那么强制执行这些限制就很简单了。 这是一个典型的例子: - -```java -/** - * Returns a BigInteger whose value is (this mod m). This method - * differs from the remainder method in that it always returns a - * non-negative BigInteger. - * - * @param m the modulus, which must be positive - * @return this mod m - * @throws ArithmeticException if m is less than or equal to 0 - */ -public BigInteger mod(BigInteger m) { - if (m.signum() <= 0) - throw new ArithmeticException("Modulus <= 0: " + m); - - ... // Do the computation - -} -``` - -  请注意,文档注释没有说“如果 m 为 null,mod 抛出 `NullPointerException`”,尽管该方法正是这样做的,这是调用`m.sgn()`的副产品。这个异常记载在类级别文档注释中,用于包含的`BigInteger`类。类级别的注释应用于类的所有公共方法中的所有参数。这是避免在每个方法上分别记录每个`NullPointerException`的好方法。它可以与`@Nullable`或类似的注释结合使用,以表明某个特定参数可能为空,但这种做法不是标准的,为此使用了多个注解。 - -  在 Java 7 中添加的`Objects.requireNonNull 方`法灵活方便,因此没有理由再手动执行空值检查。 如果愿意,可以指定自定义异常详细消息。 该方法返回其输入的值,因此可以在使用值的同时执行空检查: - -```java -// Inline use of Java's null-checking facility -this.strategy = Objects.requireNonNull(strategy, "strategy"); -``` - -  你也可以忽略返回值,并使用`Objects.requireNonNull`作为满足需求的独立空值检查。 - -  在 Java 9 中,java.util.Objects 类中添加了范围检查工具。 此工具包含三个方法:`checkFromIndexSize`,`checkFromToIndex`和`checkIndex`。 此工具不如空检查方法灵活。 它不允许指定自己的异常详细消息,它仅用于列表和数组索引。 它不处理闭合范围(包含两个端点)。 但如果它能满足你的需要,那就很方便了。 - -  对于未导出的方法,作为包的作者,控制调用方法的环境,这样就可以并且应该确保只传入有效的参数值。因此,非公共方法可以使用断言检查其参数,如下所示: - -```java -// Private helper function for a recursive sort -private static void sort(long a[], int offset, int length) { - assert a != null; - assert offset >= 0 && offset <= a.length; - assert length >= 0 && length <= a.length - offset; - ... // Do the computation -} -``` - -  本质上,这些断言声称断言条件将成立,无论其客户端如何使用封闭包。与普通的有效性检查不同,断言如果失败会抛出`AssertionError`。与普通的有效性检查不同的是,除非使用`-ea`(或者-enableassertions)标记传递给 java 命令来启用它们,否则它们不会产生任何效果,本质上也不会产生任何成本。有关断言的更多信息,请参阅教程[assert](https://docs.oracle.com/javase/8/docs/technotes/guides/language/assert.html)。 - -  检查方法中未使用但存储以供以后使用的参数的有效性尤为重要。例如,考虑第 101 页上的静态工厂方法,它接受一个 int 数组并返回数组的 List 视图。如果客户端传入 null,该方法将抛出 `NullPointerException`,因为该方法具有显式检查 (调用 `Objects.requireNonNull` 方法)。如果省略了该检查,则该方法将返回对新创建的 `List` 实例的引用,该实例将在客户端尝试使用它时立即抛出 `NullPointerException`。 到那时,List 实例的来源可能很难确定,这可能会使调试任务大大复杂化。 - -  构造方法是这个原则的一个特例,你应该检查要存储起来供以后使用的参数的有效性。检查构造方法参数的有效性对于防止构造对象违反类不变性(class invariants)非常重要。 - -  你应该在执行计算之前显式检查方法的参数,但这一规则也有例外。 一个重要的例外是有效性检查昂贵或不切实际的情况,并且在进行计算的过程中隐式执行检查。 例如,考虑一种对对象列表进行排序的方法,例如`Collections.sort(List)`。 列表中的所有对象必须是可相互比较的。 在对列表进行排序的过程中,列表中的每个对象都将与其他对象进行比较。 如果对象不可相互比较,则某些比较操作抛出 `ClassCastException` 异常,这正是`sort`方法应该执行的操作。 因此,提前检查列表中的元素是否具有可比性是没有意义的。 但请注意,不加选择地依赖隐式有效性检查会导致失败原子性(failure atomicity)的丢失(条目 76)。 - -  有时,计算会隐式执行必需的有效性检查,但如果检查失败则会抛出错误的异常。 换句话说,计算由于无效参数值而自然抛出的异常与文档记录方法抛出的异常不匹配。 在这些情况下,你应该使用条目 73 中描述的异常翻译(exception translation)习惯用法将自然异常转换为正确的异常。 - -  不要从本条目中推断出对参数的任意限制都是一件好事。 相反,你应该设计一些方法,使其尽可能通用。 假设方法可以对它接受的所有参数值做一些合理的操作,那么对参数的限制越少越好。 但是,通常情况下,某些限制是正在实现的抽象所固有的。 - -  总而言之,每次编写方法或构造方法时,都应该考虑对其参数存在哪些限制。 应该记在这些限制,并在方法体的开头使用显式检查来强制执行这些限制。 养成这样做的习惯很重要。 在第一次有效性检查失败时,它所需要的少量工作将会得到对应的回报。 -# 50. 必要时进行防御性拷贝 - -  愉快使用 Java 的原因,它是一种安全的语言(safe language)。 这意味着在缺少本地方法(native methods)的情况下,它不受缓冲区溢出,数组溢出,野指针以及其他困扰 C 和 C++ 等不安全语言的内存损坏错误的影响。 在一种安全的语言中,无论系统的任何其他部分发生什么,都可以编写类并确切地知道它们的不变量会保持不变。 在将所有内存视为一个巨大数组的语言中,这是不可能的。 - -  即使在一种安全的语言中,如果不付出一些努力,也不会与其他类隔离。**必须防御性地编写程序,假定类的客户端尽力摧毁类其不变量**。随着人们更加努力地试图破坏系统的安全性,这种情况变得越来越真实,但更常见的是,你的类将不得不处理由于善意得程序员诚实错误而导致的意外行为。不管怎样,花时间编写在客户端行为不佳的情况下仍然保持健壮的类是值得的。 - -  如果没有对象的帮助,另一个类是不可能修改对象的内部状态的,但是在无意的情况下提供这样的帮助却非常地容易。例如,考虑以下类,表示一个不可变的时间期间: - -```java -// Broken "immutable" time period class -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) { - if (start.compareTo(end) > 0) - throw new IllegalArgumentException( - start + " after " + end); - - this.start = start; - this.end = end; - } - - public Date start() { - return start; - } - - public Date end() { - return end; - } - ... // Remainder omitted -} -``` - -  乍一看,这个类似乎是不可变的,并强制执行不变式,即 `period` 实例的开始时间并不在结束时间之后。然而,利用 `Date` 类是可变的这一事实很容易违反这个不变式: - -```java -// Attack the internals of a Period instance -Date start = new Date(); -Date end = new Date(); -Period p = new Period(start, end); -end.setYear(78); // Modifies internals of p! -``` - -  从 Java 8 开始,解决此问题的显而易见的方法是使用 `Instant`(或 `LocalDateTime` 或 `ZonedDateTime`)代替`Date`,因为`Instant`和其他 `java.time` 包下的类是不可变的(条目 17)。**Date 已过时,不应再在新代码中使用。** 也就是说,问题仍然存在:有时必须在 API 和内部表示中使用可变值类型,本条目中讨论的技术也适用于这些时间。 - -  为了保护 `Period` 实例的内部不受这种攻击,必须将每个可变参数的防御性拷贝应用到构造方法中,并将拷贝用作 `Period` 实例的组件,以替代原始实例: - -```java -// Repaired constructor - makes defensive copies of parameters -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); -} -``` - -  有了新的构造方法后,前面的攻击将不会对 `Period` 实例产生影响。注意,防御性拷贝是在检查参数 (条目 49) 的有效性之前进行的,有效性检查是在拷贝上而不是在原始实例上进行的。虽然这看起来不自然,但却是必要的。它在检查参数和拷贝参数之间的漏洞窗口期间保护类不受其他线程对参数的更改的影响。在计算机安全社区中,这称为 time-of-check/time-of-use 或 TOCTOU 攻击[Viega01]。 - -  还请注意,我们没有使用 Date 的 `clone` 方法来创建防御性拷贝。因为 Date 是非 final 的,所以 clone 方法不能保证返回类为 `java.util.Date` 的对象,它可以返回一个不受信任的子类的实例,这个子类是专门为恶意破坏而设计的。例如,这样的子类可以在创建时在私有静态列表中记录对每个实例的引用,并允许攻击者访问该列表。这将使攻击者可以自由控制所有实例。为了防止这类攻击,不要使用 `clone` 方法对其类型可由不可信任子类化的参数进行防御性拷贝。 - -  虽然替换构造方法成功地抵御了先前的攻击,但是仍然可以对 `Period` 实例进行修改,因为它的访问器提供了对其可变内部结构的访问: - -```java -// Second attack on the internals of a Period instance -Date start = new Date(); -Date end = new Date(); -Period p = new Period(start, end); -p.end().setYear(78); // Modifies internals of p! -``` - -  为了抵御第二次攻击,只需修改访问器以返回可变内部字属性的防御性拷贝: - -```java -// Repaired accessors - make defensive copies of internal fields -public Date start() { - return new Date(start.getTime()); -} - -public Date end() { - return new Date(end.getTime()); -} -``` - -  使用新的构造方法和新的访问器,`Period` 是真正不可变的。 无论程序员多么恶意或不称职,根本没有办法违反一个 `period` 实例的开头不跟随其结束的不变量(不使用诸如本地方法和反射之类的语言外方法)。 这是正确的,因为除了 `period` 本身之外的任何类都无法访问 `period` 实例中的任何可变属性。 这些属性真正封装在对象中。 - -  在访问器中,与构造方法不同,允许使用 `clone` 方法来制作防御性拷贝。 这是因为我们知道 `Period` 的内部 `Date` 对象的类是 `java.util.Date`,而不是一些不受信任的子类。 也就是说,由于条目 13 中列出的原因,通常最好使用构造方法或静态工厂来拷贝实例。 - -  参数的防御性拷贝不仅仅适用于不可变类。 每次编写在内部数据结构中存储对客户端提供的对象的引用的方法或构造函数时,请考虑客户端提供的对象是否可能是可变的。 如果是,请考虑在将对象输入数据结构后,你的类是否可以容忍对象的更改。 如果答案是否定的,则必须防御性地拷贝对象,并将拷贝输入到数据结构中,以替代原始数据结构。 例如,如果你正在考虑使用客户端提供的对象引用作为内部 set 实例中的元素或作为内部 map 实例中的键,您应该意识到如果对象被修改后插入,对象的 set 或 map 的不变量将被破坏。 - -  在将内部组件返回给客户端之前进行防御性拷贝也是如此。无论你的类是否是不可变的,在返回对可拜年的内部组件的引用之前,都应该三思。可能的情况是,应该返回一个防御性拷贝。记住,非零长度数组总是可变的。因此,在将内部数组返回给客户端之前,应该始终对其进行防御性拷贝。或者,可以返回数组的不可变视图。这两项技术都记载于条目 15。 - -  可以说,所有这些的真正教训是,在可能的情况下,应该使用不可变对象作为对象的组件,这样就不必担心防御性拷贝 (条目 17)。在我们的 `Period` 示例中,使用 `Instant`(或 `LocalDateTime` 或 `ZonedDateTime`),除非使用的是 Java 8 之前的版本。如果使用的是较早的版本,则一个选项是存储 `Date.getTime()` 返回的基本类型 long 来代替 Date 引用。 - -  可能存在与防御性拷贝相关的性能损失,并且它并不总是合理的。如果一个类信任它的调用者不修改内部组件,也许是因为这个类和它的客户端都是同一个包的一部分,那么它可能不需要防御性的拷贝。在这些情况下,类文档应该明确指出调用者不能修改受影响的参数或返回值。 - -  即使跨越包边界,在将可变参数集成到对象之前对其进行防御性拷贝也并不总是合适的。有些方法和构造方法的调用指示参数引用的对象的显式切换。当调用这样的方法时,客户端承诺不再直接修改对象。希望获得客户端提供的可变对象的所有权的方法或构造方法必须在其文档中明确说明这一点。 - -  包含方法或构造方法的类,这些方法或构造方法的调用指示控制权的转移,这些类无法防御恶意客户端。 只有当一个类和它的客户之间存在相互信任,或者当对类的不变量造成损害时,除了客户之外,任何人都不会受到损害。 后一种情况的一个例子是包装类模式(第 18 项)。 根据包装类的性质,客户端可以通过在包装后直接访问对象来破坏类的不变性,但这通常只会损害客户端。 - -  总之,如果一个类有从它的客户端获取或返回的可变组件,那么这个类必须防御性地拷贝这些组件。如果拷贝的成本太高,并且类信任它的客户端不会不适当地修改组件,则可以用文档替换防御性拷贝,该文档概述了客户端不得修改受影响组件的责任。 -# 51. 仔细设计方法签名 - -  这一条目是 API 设计提示的大杂烩,但它们本身并足以设立一个单独的条目。综合起来,这些设计提示将帮助你更容易地学习和使用 API,并且更不容易出错。 - -  **仔细选择方法名名称**。名称应始终遵守标准命名约定 (条目 68)。你的主要目标应该是选择与同一包中的其他名称一致且易于理解的名称。其次是应该是选择与更广泛的共识一致的名称。避免使用较长的方法名。如果有疑问,可以从 Java 类库 API 中寻求指导。尽管类库中也存在许多不一致之处 (考虑到这些类库的规模和范围,这是不可避免的),也提供了相当客观的认可和共识。 - -  **不要过分地提供方便的方法**。每种方法都应该“尽其所能”。太多的方法使得类难以学习、使用、文档化、测试和维护。对于接口更是如此,在接口中,太多的方法使实现者和用户的工作变得复杂。对于类或接口支持的每个操作,提供一个功能完整的方法。只有在经常使用时,才考虑提供“快捷方式(shortcut)”。**如果有疑问,请将其删除**。 - -  **避免过长的参数列表**。目标是四个或更少的参数。大多数程序员不能记住更长的参数列表。如果你的许多方法超过了这个限制,如果未经常引用其文档的情况下,那么你的 API 将无法使用。现代 IDE 编辑器会提供帮助,但是使用简短的参数列表仍然会更好。**相同类型参数的长序列尤其有害**。用户不仅不能记住参数的顺序,而且当他们意外地弄错参数顺序时,他们的程序仍然会编译和运行。只是不会按照作者的意图去执行。 - -  有三种技术可以缩短过长的参数列表。 一种方法是将方法分解为多个方法,每个方法只需要参数的一个子集。 如果不小心,这可能会导致太多方法,但它也可以通过增加正交性(orthogonality)来减少方法个数。 例如,考虑 java.util.List 接口。 它没有提供查找子列表中元素的第一个或最后一个索引的方法,这两个索引都需要三个参数。 相反,它提供了 `subList` 方法,该方法接受两个参数并返回子列表的视图。 此方法可以与 `indexOf` 或 `lastIndexOf` 方法结合使用,这两个方法都有一个参数,以生成所需的功能。 此外,`subList` 方法可以与在 List 实例上操作的任何方法组合,以对子列表执行任意计算。 得到的 API 具有非常高的功率重量 ( power-to-weight) 比。 - -  缩短过长参数列表的第二种技术是创建辅助类来保存参数组。这些辅助类通常是静态成员类 (条目 24)。如果看到一个频繁出现的参数序列表示某个不同的实体,建议使用这种技术。例如,假设正在编写一个表示纸牌游戏的类,并且发现不断地传递一个由两个参数组成的序列,这些参数表示纸牌的点数和花色。如果添加一个辅助类来表示卡片,并用辅助类的单个参数替换参数序列的每次出现,那么 API 和类的内部结构可能会受益。 - -  结合前两个方面的第三种技术是,从对象构造到方法调用采用 Builder 模式 (条目 2)。如果你有一个方法有许多参数,特别是其中一些是可选的,那么可以定义一个对象来表示所有的参数,并允许客户端在这个对象上进行多个 "setter" 调用,每次设置一个参数或较小相关的组。设置好所需的参数后,客户端调用对象的 "execute" 方法,该方法对参数进行最后的有效性检查,并执行实际的计算。 - -  **对于参数类型,优先选择接口而不是类**(条目 64)。如果有一个合适的接口来定义一个参数,那么使用它来支持一个实现该接口的类。例如,没有理由在编写方法时使用 HashMap 作为输入参数,相反,而是使用 Map 作为参数,这允许传入 HashMap、TreeMap、ConcurrentHashMap、TreeMap 的子 Map(submap)或任何尚未编写的 Map 实现。通过使用的类而不是接口,就把客户端限制在特定的实现中,如果输入数据碰巧以其他形式存在,则强制执行不必要的、代价高昂的复制操作。 - -  **与布尔型参数相比,优先使用两个元素枚举类型**,除非布尔型参数的含义在方法名中是明确的。枚举类型使代码更容易阅读和编写。此外,它们还可以方便地在以后添加更多选项。例如,你可能有一个 `Thermometer` 类型的静态工厂方法,这个方法的签名是以下这个枚举: - -```java -public enum TemperatureScale { FAHRENHEIT, CELSIUS } -``` - -  `Thermometer.newInstance(TemperatureScale.CELSIUS)` 不仅比 `Thermometer.newInstance(true)` 更有意义,而且可以在将来的版本中将`KELVIN`添加到 `TemperatureScale` 中,而无需向 `Thermometer` 添加新的静态工厂。 此外,还可以将温度刻度(temperature-scale)依赖关系重构为枚举常量的方法(条目 34)。 例如,每个刻度常量可以有一个采用 double 值并将其转换为 `Celsius` 的方法。 -# 52. 明智审慎地使用重载 - -  下面的程序是一个善意的尝试,根据 Set、List 或其他类型的集合对它进行分类: - -```java -// Broken! - What does this program print? -public class CollectionClassifier { - - public static String classify(Set s) { - return "Set"; - } - - public static String classify(List lst) { - return "List"; - } - - public static String classify(Collection c) { - return "Unknown Collection"; - } - - public static void main(String[] args) { - Collection[] collections = { - new HashSet(), - new ArrayList(), - new HashMap().values() - }; - - for (Collection c : collections) - System.out.println(classify(c)); - } -} -``` - -  您可能希望此程序打印 Set,然后是 List 和 Unknown Collection 字符串,实际上并没有。 而是打印了三次 Unknown Collection 字符串。 为什么会这样? 因为`classify`方法被重载了,**在编译时选择要调用哪个重载方法**。 对于循环的所有三次迭代,参数的编译时类型是相同的:`Collection`。 运行时类型在每次迭代中都不同,但这不会影响对重载方法的选择。 因为参数的编译时类型是`Collection,`,所以唯一适用的重载是第三个`classify(Collection c)`方法,并且在循环的每次迭代中调用这个重载。 - -  此程序的行为是违反直觉的,因为**重载(overloaded)方法之间的选择是静态的,而重写(overridden)方法之间的选择是动态的**。 根据调用方法的对象的运行时类型,在运行时选择正确版本的重写方法。 作为提醒,当子类包含与父类中具有相同签名的方法声明时,会重写此方法。 如果在子类中重写实例方法并且在子类的实例上调用,则无论子类实例的编译时类型如何,都会执行子类的重写方法。 为了具体说明,请考虑以下程序: - -```java -class Wine { - String name() { return "wine"; } -} - -class SparklingWine extends Wine { - @Override String name() { return "sparkling wine"; } -} - -class Champagne extends SparklingWine { - @Override String name() { return "champagne"; } -} - -public class Overriding { - public static void main(String[] args) { - List wineList = List.of( - new Wine(), new SparklingWine(), new Champagne()); - - for (Wine wine : wineList) - System.out.println(wine.name()); - } -} -``` - - - -  `name`方法在`Wine`类中声明,并在子类`SparklingWine`和`Champagne`中重写。 正如你所料,此程序打印出 wine,sparkling wine 和 champagne,即使实例的编译时类型在循环的每次迭代中都是`Wine`。 当调用重写方法时,对象的编译时类型对执行哪个方法没有影响; 总是会执行“最具体 (most specific)”的重写方法。 将此与重载进行比较,其中对象的运行时类型对执行的重载没有影响; 选择是在编译时完成的,完全基于参数的编译时类型。 - -  在`CollectionClassifier`示例中,程序的目的是通过基于参数的运行时类型自动调度到适当的方法重载来辨别参数的类型,就像 Wine 类中的 name 方法一样。 方法重载根本不提供此功能。 假设需要一个静态方法,修复`CollectionClassifier`程序的最佳方法是用一个执行显式`instanceof`测试的方法替换`classify`的所有三个重载: - -```java - public static String classify(Collection c) { - return c instanceof Set ? "Set" : - c instanceof List ? "List" : "Unknown Collection"; -} -``` - -  因为重写是规范,而重载是例外,所以重写设置了人们对方法调用行为的期望。 正如`CollectionClassifier`示例所示,重载很容易混淆这些期望。 编写让程序员感到困惑的代码的行为是不好的实践。 对于 API 尤其如此。 如果 API 的日常用户不知道将为给定的参数集调用多个方法重载中的哪一个,则使用 API 可能会导致错误。 这些错误很可能表现为运行时的不稳定行为,许多程序员很难诊断它们。 因此,**应该避免混淆使用重载**。 - -  究竟是什么构成了重载的混乱用法还有待商榷。**一个安全和保守的策略是永远不要导出两个具有相同参数数量的重载**。如果一个方法使用了可变参数,除非如第 53 条目所述,保守策略是根本不重载它。如果遵守这些限制,程序员就不会怀疑哪些重载适用于任何一组实际参数。这些限制并不十分繁重,因为**总是可以为方法赋予不同的名称,而不是重载它们**。 - -  例如,考虑`ObjectOutputStream`类。对于每个基本类型和几个引用类型,它都有其`write`方法的变体。这些变体都有不同的名称,例如`writeBoolean(boolean)`、`writeInt(int)`和`writeLong(long)`,而不是重载`write`方法。与重载相比,这种命名模式的另一个好处是,可以为`read`方法提供相应的名称,例如`readBoolean()`、`readInt()`和`readLong()`。`ObjectInputStream`类实际上提供了这样的读取方法。 - -  对于构造方法,无法使用不同的名称:类的多个构造函数总是被重载。 在许多情况下,可以选择导出静态工厂而不是构造方法(条目 1)。 此外,使用构造方法,不必担心重载和重写之间的影响,因为构造方法不能被重写。 你可能有机会导出具有相同数量参数的多个构造函数,因此知道如何安全地执行它是值得的。 - -  如果总是清楚哪个重载将应用于任何给定的实际参数集,那么用相同数量的参数导出多个重载不太可能让程序员感到困惑。在这种情况下,每对重载中至少有一个对应的形式参数在这两个重载中具有“完全不同的”类型。如果显然不可能将任何非空表达式强制转换为这两种类型,那么这两种类型是完全不同的。在这些情况下,应用于给定实际参数集的重载完全由参数的运行时类型决定,且不受其编译时类型的影响,因此消除了一个主要的混淆。例如,ArrayList 有一个接受 int 的构造方法和第二个接受 Collection 的构造方法。很难想象在任何情况下,这两个构造方法在调用时哪个会产生混淆。 - -  在 Java 5 之前,所有基本类型都与引用类型完全不同,但在自动装箱存在的情况下,则并非如此,并且它已经造成了真正的麻烦。 考虑以下程序: - -```java -public class SetList { - public static void main(String[] args) { - Set set = new TreeSet<>(); - List list = new ArrayList<>(); - - for (int i = -3; i < 3; i++) { - set.add(i); - list.add(i); - } - - for (int i = 0; i < 3; i++) { - set.remove(i); - list.remove(i); - } - - System.out.println(set + " " + list); - } -} -``` - - - -  首先,程序将从-3 到 2 的整数添加到有序集合和列表中。 然后,它在集合和列表上进行三次相同的`remove`方法调用。 如果你和大多数人一样,希望程序从集合和列表中删除非负值(0, 1 和 2)并打印[-3, -2, -1] [ - 3, -2, -1]。 实际上,程序从集合中删除非负值,从列表中删除奇数值,并打印[-3, -2, -1] [-2, 0, 2]。 称这种混乱的行为是一种保守的说法。 - -  实际情况是:调用`set.remove(i)`选择重载`remove(E)`方法,其中`E`是`set (Integer)`的元素类型,将基本类型 i 由 int 自动装箱为 Integer 中。这是你所期望的行为,因此程序最终会从集合中删除正值。另一方面,对`list.remove(i)`的调用选择重载`remove(int i)`方法,它将删除列表中指定位置的元素。如果从列表\[-3, -2, -1, 0, 1, 2\] 开始,移除第 0 个元素,然后是第 1 个,然后是第二个,就只剩下\[-2, 0, 2\],谜底就解开了。若要修复此问题,请强制转换`list.remove`的参数为`Integer`类型,迫使选择正确的重载。或者,也可以调用`Integer.valueOf(i)`,然后将结果传递给`list.remove`方法。无论哪种方式,程序都会按预期打印\[-3, -2, -1\]\[-3, -2, -1\]: - -```java -for (int i = 0; i < 3; i++) { - set.remove(i); - list.remove((Integer) i); // or remove(Integer.valueOf(i)) -} -``` - -  前一个示例所演示的令人混乱的行为是由于`List`接口对`remove`方法有两个重载:`remove(E)`和`remove(int)`。在 Java 5 之前,当 List 接口被“泛型化”时,它有一个`remove(Object)`方法代替`remove(E)`,而相应的参数类型 Object 和 int 则完全不同。但是,在泛型和自动装箱的存在下,这两种参数类型不再完全不同了。换句话说,在语言中添加泛型和自动装箱破坏了 List 接口。幸运的是,Java 类库中的其他 API 几乎没有受到类似的破坏,但是这个故事清楚地表明,自动装箱和泛型在重载时增加了谨慎的重要性。 - -  在 Java 8 中添加 lambda 表达式和方法引用以后,进一步增加了重载混淆的可能性。 例如,考虑以下两个代码片段: - -```java -new Thread(System.out::println).start(); - -ExecutorService exec = Executors.newCachedThreadPool(); - -exec.submit(System.out::println); -``` - -  虽然 Thread 构造方法调用和`submit`方法调用看起来很相似,但是前者编译而后者不编译。参数是相同的 (`System.out::println`),两者都有一个带有`Runnable`的重载。这里发生了什么?令人惊讶的答案是,`submit`方法有一个带有`Callable `参数的重载,而`Thread`构造方法却没有。你可能认为这不会有什么区别,因为`println`方法的所有重载都会返回`void`,因此方法引用不可能是`Callable` - 。这很有道理,但重载解析算法不是这样工作的。也许同样令人惊讶的是,如果`println`方法没有被重载,那么`submit`方法调用是合法的。正是被引用的方法 (println) 的重载和被调用的方法 (submit) 相结合,阻止了重载解析算法按照你所期望的方式运行。 - -  从技术上讲,问题是`System.out :: println`是一个不精确的方法引用[JLS,15.13.1],并且『包含隐式类型的 lambda 表达式或不精确的方法引用的某些参数表达式被适用性测试忽略,因为在选择目标类型之前无法确定它们的含义[JLS,15.12.2]。』如果你不理解这段话也不要担心; 它针对的是编译器编写者。 关键是在同一参数位置中具有不同功能接口的重载方法或构造方法会导致混淆。 因此,**不要在相同参数位置重载采用不同函数式接口的方法**。 在此条目的说法中,不同的函数式接口并没有根本不同。 如果传递命令行开关`-Xlint:overloads`,Java 编译器将警告这种有问题的重载。 - -  数组类型和 Object 以外的类是完全不同的。此外,除了`Serializable`和`Cloneable`之外,数组类型和其他接口类型也完全不同。如果两个不同的类都不是另一个类的后代[JLS, 5.5],则称它们是不相关的。例如,`String`和`Throwable`是不相关的。任何对象都不可能是两个不相关类的实例,所以不相关的类也是完全不同的。 - -  还有其他『类型对 (pairs of types)』不能在任何方向转换[JLS, 5.1.12],但是一旦超出上面描述的简单情况,大多数程序员就很难辨别哪些重载 (如果有的话) 适用于一组实际参数。决定选择哪个重载的规则非常复杂,并且随着每个版本的发布而变得越来越复杂。很少有程序员能理解它们所有的微妙之处。 - -  有时候,可能觉得有必要违反这一条目中的指导原则,特别是在演化现有类时。例如,考虑 String,它从 Java 4 开始就有一个`contenttequals (StringBuffer)`方法。在 Java 5 中,添加了`CharSequence`接口,来为`StringBuffer`、`StringBuilder`、`String`、`CharBuffer`和其他类似类型提供公共接口。在添加`CharSequence`的同时,String 还配备了一个重载的`contenttequals`方法,该方法接受`CharSequence`参数。 - -  虽然上面的重载明显违反了此条目中的指导原则,但它不会造成任何危害,因为当在同一个对象引用上调用这两个重载方法时,它们做的是完全相同的事情。程序员可能不知道将调用哪个重载,但只要它们的行为相同,就没有什么后果。确保这种行为的标准方法是,将更具体的重载方法调用转发给更一般的重载方法: - -```java -// Ensuring that 2 methods have identical behavior by forwarding -public boolean contentEquals(StringBuffer sb) { - return contentEquals((CharSequence) sb); -} -``` - -  虽然 Java 类库在很大程度上遵循了这一条目中的建议,但是有一些类违反了它。例如,String 导出两个重载的静态工厂方法`valueOf(char[])`和`valueOf(Object)`,它们在传递相同的对象引用时执行完全不同的操作。对此没有任何正当的理由理由,它应该被视为一种异常现象,有可能造成真正的混乱。 - -  总而言之,仅仅可以重载方法并不意味着应该这样做。通常,最好避免重载具有相同数量参数的多个签名的方法。在某些情况下,特别是涉及构造方法的情况下,可能无法遵循此建议。在这些情况下,至少应该避免通过添加强制转换将相同的参数集传递给不同的重载。如果这是无法避免的,例如,因为要对现有类进行改造以实现新接口,那么应该确保在传递相同的参数时,所有重载的行为都是相同的。如果做不到这一点,程序员将很难有效地使用重载方法或构造方法,也无法理解为什么它不能工作。 -# 53. 明智审慎地使用可变参数 - -  可变参数方法正式名称称为可变的参数数量方法『variable arity methods』 [JLS, 8.4.1],接受零个或多个指定类型的参数。 可变参数机制首先创建一个数组,其大小是在调用位置传递的参数数量,然后将参数值放入数组中,最后将数组传递给方法。 - -  例如,这里有一个可变参数方法,它接受一系列 int 类型的参数并返回它们的总和。如你所料, `sum(1,2,3)` 的值为 6, `sum()` 的值为 0: - -```java -// Simple use of varargs -static int sum(int... args) { - int sum = 0; - for (int arg : args) - sum += arg; - return sum; -} -``` - -  有时,编写一个需要某种类型的一个或多个参数的方法是合适的,而不是零或更多。 例如,假设要编写一个计算其多个参数最小值的方法。 如果客户端不传递任何参数,则此方法定义不明确。 你可以在运行时检查数组长度: - -```java -// The WRONG way to use varargs to pass one or more arguments! -static int min(int... args) { - if (args.length == 0) - throw new IllegalArgumentException("Too few arguments"); - int min = args[0]; - for (int i = 1; i < args.length; i++) - if (args[i] < min) - min = args[i]; - return min; -} -``` - -  该解决方案存在几个问题。 最严重的是,如果客户端在没有参数的情况下调用此方法,则它在运行时而不是在编译时失败。 另一个问题是它很难看。 必须在 args 参数上包含显式有效性检查,除非将 `min` 初始化为 `Integer.MAX_VALUE`,否则不能使用 for-each 循环,这也很难看。 - -  幸运的是,有一种更好的方法可以达到预期的效果。 声明方法采用两个参数,一个指定类型的普通参数,另一个此类型的可变参数。 该解决方案纠正了前一个示例的所有缺陷: - -```java -// The right way to use varargs to pass one or more arguments -static int min(int firstArg, int... remainingArgs) { - int min = firstArg; - for (int arg : remainingArgs) - if (arg < min) - min = arg; - return min; -} -``` - -  从这个例子中可以看出,在需要参数数量可变的方法时,可变参数是有效的。可变参数是为 `printf` 方法而设计的,该方法与可变参数同时添加到 Java 平台中,以及包括经过改造的核心反射机制。`printf` 和反射机制都从可变参数中受益匪浅。 - -  在性能关键的情况下使用可变参数时要小心。每次调用可变参数方法都会导致数组分配和初始化。如果你从经验上确定负担不起这个成本,但是还需要可变参数的灵活性,那么有一种模式可以让你鱼与熊掌兼得。假设你已确定 95% 的调用是三个或更少的参数的方法,那么声明该方法的五个重载。每个重载方法包含 0 到 3 个普通参数,当参数数量超过 3 个时,使用一个可变参数方法: - -```java -public void foo() { } - -public void foo(int a1) { } - -public void foo(int a1, int a2) { } - -public void foo(int a1, int a2, int a3) { } - -public void foo(int a1, int a2, int a3, int... rest) { } -``` - -  现在你知道,在所有参数数量超过 3 个的方法调用中,只有 5%的调用需要支付创建数组的成本。与大多数性能优化一样,这种技术通常不太合适,但一旦真正需要的时候,它是一个救星。 - -  `EnumSet` 的静态工厂使用这种技术将创建枚举集合的成本降到最低。这是适当的,因为枚举集合为比特属性提供具有性能竞争力的替换(performance-competitive replacement for bit fields)是至关重要的 (条目 36)。 - -  总之,当需要使用可变数量的参数定义方法时,可变参数非常有用。 在使用可变参数前加上任何必需的参数,并注意使用可变参数的性能后果。 -# 54. 返回空的数组或集合,不要返回 null - -  像如下的方法并不罕见: - -```java -// Returns null to indicate an empty collection. Don't do this! -private final List cheesesInStock = ...; - -/** - * @return a list containing all of the cheeses in the shop, - * or null if no cheeses are available for purchase. - */ -public List getCheeses() { - return cheesesInStock.isEmpty() ? null - : new ArrayList<>(cheesesInStock); -} -``` - -  没有理由对没有奶酪 (Cheese) 可供购买的情况进行特殊处理。这样需要在客户端做额外的代码处理可能为 null 的返回值,例如: - -```java -List cheeses = shop.getCheeses(); -if (cheeses != null && cheeses.contains(Cheese.STILTON)) - System.out.println("Jolly good, just the thing."); -``` - -  在几乎每次使用返回 null 来代替空集合或数组的方法时,都需要使用这种迂回的方式。 它容易出错,因为编写客户端的程序员可能忘记编写特殊情况代码来处理 null 返回。 多年来这种错误可能会被忽视,因为这种方法通常会返回一个或多个对象。 此外,返回 null 代替空容器会使返回容器的方法的实现变得复杂。 - -  有时有人认为,null 返回值比空集合或数组更可取,因为它避免了分配空容器的开销。这个论点有两点是不成立的。首先,除非测量结果表明所讨论的分配是性能问题的真正原因,否则不宜担心此级别的性能(详见第 67 条)。第二,可以在不分配空集合和数组的情况下返回它们。下面是返回可能为空的集合的典型代码。通常,这就是你所需要的: - -```java -//The right way to return a possibly empty collection -public List getCheeses() { - return new ArrayList<>(cheesesInStock); -} -``` - -  如果有证据表明分配空集合会损害性能,可以通过重复返回相同的不可变空集合来避免分配,因为不可变对象可以自由共享 (条目 17)。下面的代码就是这样做的,使用了 `Collections.emptyList` 方法。如果你要返回一个 Set,可以使用 `Collections.emptySet` ;如果要返回 Map,则使用 `Collections.emptyMap`。但是请记住,这是一个优化,很少需要它。如果你认为你需要它,测量一下前后的性能表现,确保它确实有帮助: - -```java -// Optimization - avoids allocating empty collections -public List getCheeses() { - return cheesesInStock.isEmpty() ? Collections.emptyList() - : new ArrayList<>(cheesesInStock); -} -``` - -  数组的情况与集合的情况相同。 永远不要返回 null,而是返回长度为零的数组。 通常,应该只返回一个正确长度的数组,这个长度可能为零。 请注意,我们将一个长度为零的数组传递给 `toArray` 方法,以指示所需的返回类型,即 `Cheese []`: - -```java -//The right way to return a possibly empty array -public Cheese[] getCheeses() { - return cheesesInStock.toArray(new Cheese[0]); -} -``` - -  如果你认为分配零长度数组会损害性能,则可以重复返回相同的零长度数组,因为所有零长度数组都是不可变的: - -```java -// Optimization - avoids allocating empty arrays -private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0]; - -public Cheese[] getCheeses() { - return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY); -} -``` - -  在优化的版本中,我们将相同的空数组传递到每个 `toArray` 调用中,当 `cheesesInStock` 为空时,这个数组将从 `getCheeses` 返回。不要为了提高性能而预先分配传递给 `toArray` 的数组。研究表明,这样做会适得其反[Shipilev16]: - -```java -// Don’t do this - preallocating the array harms performance! -return cheesesInStock.toArray(new Cheese[cheesesInStock.size()]); -``` - -  总之,**永远不要返回 null 来代替空数组或集合**。它使你的 API 更难以使用,更容易出错,并且没有性能优势。 -# 55. 明智审慎地返回 Optional - -  在 Java 8 之前,编写在特定情况下无法返回任何值的方法时,可以采用两种方法。要么抛出异常,要么返回 null(假设返回类型是对象是引用类型)。但这两种方法都不完美。应该为异常条件保留异常 (条目 69),并且抛出异常代价很高,因为在创建异常时捕获整个堆栈跟踪。返回 null 没有这些缺点,但是它有自己的缺陷。如果方法返回 null,客户端必须包含特殊情况代码来处理 null 返回的可能性,除非程序员能够证明 null 返回是不可能的。如果客户端忽略检查 null 返回并将 null 返回值存储在某个数据结构中,那么会在将来的某个时间在与这个问题不相关的代码位置上,抛出`NullPointerException`异常的可能性。 - -  在 Java 8 中,还有第三种方法来编写可能无法返回任何值的方法。`Optional`类表示一个不可变的容器,它可以包含一个非 null 的`T`引用,也可以什么都不包含。不包含任何内容的 Optional 被称为空(empty)。非空的包含值称的 Optional 被称为存在(present)。Optional 的本质上是一个不可变的集合,最多可以容纳一个元素。`Optional`没有实现`Collection`接口,但原则上是可以。 - -  在概念上返回 T 的方法,但在某些情况下可能无法这样做,可以声明为返回一个`Optional`。这允许该方法返回一个空结果,以表明不能返回有效的结果。返回 Optional 的方法比抛出异常的方法更灵活、更容易使用,而且比返回 null 的方法更不容易出错。 - -  在条目 30 中,我们展示了根据集合中元素的自然顺序计算集合最大值的方法。 - -```java -// Returns maximum value in collection - throws exception if empty -public static > E max(Collection c) { - if (c.isEmpty()) - throw new IllegalArgumentException("Empty collection"); - - E result = null; - for (E e : c) - if (result == null || e.compareTo(result) > 0) - result = Objects.requireNonNull(e); - return result; -} -``` - -  如果给定集合为空,此方法将抛出`IllegalArgumentException`异常。我们在条目 30 中提到,更好的替代方法是返回`Optional`。下面是修改后的方法: - -```java -// Returns maximum value in collection as an Optional -public static > - Optional max(Collection c) { - if (c.isEmpty()) - return Optional.empty(); - - E result = null; - for (E e : c) - if (result == null || e.compareTo(result) > 0) - result = Objects.requireNonNull(e); - return Optional.of(result); -} -``` - -  如你所见,返回 Optional 很简单。 你所要做的就是使用适当的静态工厂创建 Optional。 在这个程序中,我们使用两个:`Optional.empty()` 返回一个空的 Optional, `Optional.of(value)` 返回一个包含给定非 null 值的 Optional。 将 null 传递给 `Optional.of(value)` 是一个编程错误。 如果这样做,该方法通过抛出 `NullPointerException` 异常作为回应。 `Optional.of(value)` 方法接受一个可能为 null 的值,如果传入 null 则返回一个空的 Optional。**永远不要通过返回 Optional 的方法返回一个空值**:它破坏 Optional 设计的初衷。 - -  `Stream` 上的很多终止操作返回 Optional。如果我们重写 max 方法来使用一个`Stream`,那么 `Stream` 的 `max` 操作会为我们生成 Optional 的工作 (尽管我们还是传递一个显式的`Comparator`): - -```java -// Returns max val in collection as Optional - uses stream -public static > - Optional max(Collection c) { - return c.stream().max(Comparator.naturalOrder()); -} -``` - -  那么,如何选择返回 Optional 而不是返回 null 或抛出异常呢?`Optional`在本质上类似于检查异常(checked exceptions)(条目 71),因为它们迫使 API 的用户面对可能没有返回任何值的事实。抛出未检查的异常或返回 null 允许用户忽略这种可能性,从而带来潜在的可怕后果。但是,抛出一个检查异常需要在客户端中添加额外的样板代码。 - -  如果方法返回一个 Optional,则客户端可以选择在方法无法返回值时要采取的操作。 可以指定默认值: - -```java -// Using an optional to provide a chosen default value -String lastWordInLexicon = max(words).orElse("No words..."); -``` - -  或者可以抛出任何适当的异常。注意,我们传递的是异常工厂,而不是实际的异常。这避免了创建异常的开销,除非它真的实际被抛出: - -```java -// Using an optional to throw a chosen exception -Toy myToy = max(toys).orElseThrow(TemperTantrumException::new); -``` - -  如果你能证明 Optional 非空,你可以从 Optional 获取值,而不需要指定一个操作来执行。但是如果 Optional 是空的,你判断错了,代码会抛出一个 `NoSuchElementException` 异常: - -```java -// Using optional when you know there’s a return value -Element lastNobleGas = max(Elements.NOBLE_GASES).get(); -``` - -  有时候,可能会遇到这样一种情况:获取默认值的代价很高,除非必要,否则希望避免这种代价。对于这些情况,Optional 提供了一个方法,该方法接受 `Supplier`,并仅在必要时调用它。这个方法被称为 `orElseGet`,但是或许应该被称为 `orElseCompute`,因为它与以`compute`开头的三个 Map 方法密切相关。有几个 Optional 的方法来处理更特殊的用例:`filter`、`map` 、`flatMap` 和 `ifPresent`。在 Java 9 中,又添加了两个这样的方法: `or` 和 `ifPresentOrElse`。如果上面描述的基本方法与你的用例不太匹配,请查看这些更高级方法的文档,并查看它们是否能够完成任务。 - -  如果这些方法都不能满足你的需要,Optional 提供 `isPresent()` 方法,可以将其视为安全阀。如果 Optional 包含值,则返回 true;如果为空,则返回 false。你可以使用此方法对可选结果执行任何喜欢的处理,但请确保明智地使用它。`isPresent` 的许多用途都可以被上面提到的一种方法所替代。生成的代码通常更短、更清晰、更符合习惯。 - -  例如,请考虑此代码段,它打印一个进程的父进程 ID,如果进程没有父进程,则打印 N/A. 该代码段使用 Java 9 中引入的 `ProcessHandle` 类: - -```java -Optional parentProcess = ph.parent(); -System.out.println("Parent PID: " + (parentProcess.isPresent() ? - String.valueOf(parentProcess.get().pid()) : "N/A")); -``` - -  上面的代码可以被如下代码所替代,使用了 Optional 的 `map` 方法: - -```java -System.out.println("Parent PID: " + - ph.parent().map(h -> String.valueOf(h.pid())).orElse("N/A")); -``` - -  当使用 Stream 进行编程时,通常会发现使用的是一个 `Stream>`,并且需要一个 `Stream`,其中包含非 Optional 中的所有元素,以便继续进行。如果你正在使用 Java 8,下面是弥补这个差距的代码: - -```java -streamOfOptionals - .filter(Optional::isPresent) - .map(Optional::get) -``` - -  在 Java 9 中,Optional 配备了一个 `stream()` 方法。这个方法是一个适配器, 此方法是一个适配器,它将 Optional 变为包含一个元素的 Stream,如果 Optional 为空,则不包含任何元素。此方法与 Stream 的 `flatMap` 方法 (条目 45) 相结合,这个方法可以简洁地替代上面的方法: - -```java -streamOfOptionals. - .flatMap(Optional::stream) -``` - -  并不是所有的返回类型都能从 Optional 的处理中获益。**容器类型,包括集合、映射、Stream、数组和 Optional,不应该封装在 Optional 中**。与其返回一个空的`Optional>`,不还如返回一个空的 `List`(条目 54)。返回空容器将消除客户端代码处理 Optional 的需要。`ProcessHandle` 类确实有 `arguments` 方法,它返回`Optional`,但是这个方法应该被视为一种异常,不该被效仿。 - -  那么什么时候应该声明一个方法来返回 `Optional ` 而不是 `T` 呢? 通常,**如果可能无法返回结果,并且在没有返回结果,客户端还必须执行特殊处理的情况下,则应声明返回 Optional 的方法**。也就是说,返回 `Optional ` 并非没有成本。 Optional 是必须分配和初始化的对象,从 Optional 中读取值需要额外的迂回。 这使得 Optional 不适合在某些性能关键的情况下使用。 特定方法是否属于此类别只能通过仔细测量来确定(详见第 67 条)。 - -  与返回装箱的基本类型相比,返回包含已装箱基本类型的 Optional 的代价高得惊人,因为 Optional 有两个装箱级别,而不是零。因此,类库设计人员认为为基本类型 int、long 和 double 提供类似 `Option` 是合适的。这些 Option 是 `OptionalInt`、`OptionalLong` 和 `OptionalDouble`。它们包含 `Optional` 上的大多数方法,但不是所有方法。因此,除了“次要基本类型(minor primitive types)”Boolean,Byte,Character,Short 和 Float 之外,**永远不应该返回装箱的基本类型的 Optional**。 - -  到目前为止,我们已经讨论了返回 Optional 并在返回后处理它们的方法。我们还没有讨论其他可能的用法,这是因为大多数其他 Optional 的用法都是可疑的。例如,永远不要将 Optional 用作映射值。如果这样做,则有两种方法可以表示键(key)在映射中逻辑上的缺失:键要么不在映射中,要么存在的话映射到一个空的 Optional。这反映了不必要的复杂性,很有可能导致混淆和错误。更通俗地说,在集合或数组中使用 Optional 的键、值或元素几乎都是不合适的。 - -  这里留下了一个悬而未决的大问题。在实例中存储 Optional 属性是否合适吗?通常这是一种“不好的味道”:它建议你可能应该有一个包含 Optional 属性的子类。但有时这可能是合理的。考虑条目 2 中的 `NutritionFacts` 类的情况。`NutritionFacts` 实例包含许多不需要的属性。不可能为这些属性的每个可能组合都提供一个子类。此外,属性包含基本类型,这使得很难直接表示这种缺失。对于 `NutritionFacts` 最好的 API 将为每个 Optional 属性从 getter 方法返回一个 Optional,因此将这些 Optional 作为属性存储在对象中是很有意义的。 - -  总之,如果发现自己编写的方法不能总是返回值,并且认为该方法的用户在每次调用时考虑这种可能性很重要,那么或许应该返回一个 Optional 的方法。但是,应该意识到,返回 Optional 会带来实际的性能后果;对于性能关键的方法,最好返回 null 或抛出异常。最后,除了作为返回值之外,不应该在任何其他地方中使用 Optional。 -# 56. 为所有已公开的 API 元素编写文档注释 - -  如果 API 要可用,就必须对其进行文档化。传统上,API 文档是手工生成的,保持文档与代码的同步是一件苦差事。Java 编程环境使用 `Javadoc` 实用程序简化了这一任务。`Javadoc` 使用特殊格式的文档注释 (通常称为 doc 注释),从源代码自动生成 API 文档。 - -  虽然文档注释约定不是 Java 语言的正式一部分,但它们构成了每个 Java 程序员都应该知道的事实上的 API。“如何编写文档注释(How to Write Doc Comments web page)”的网页[Javadoc-guide] 中介绍了这些约定。 虽然自 Java 4 发布以来该页面尚未更新,但它仍然是一个非常宝贵的资源。 Java 9 中添加了一个重要的文档标签,`{@ index}`; Java 8 中有一个,`{@implSpec}`;Java 5 中有两个,`{@literal}` 和 `{@code}`。 上述网页中缺少这些标签的介绍,但在此条目中进行讨论。 - -  **要正确地记录 API,必须在每个导出的类、接口、构造方法、方法和属性声明之前加上文档注释**。如果一个类是可序列化的,还应该记录它的序列化形式 (条目 87)。在没有文档注释的情况下,Javadoc 可以做的最好的事情是将声明重现为受影响的 API 元素的唯一文档。使用缺少文档注释的 API 是令人沮丧和容易出错的。公共类不应该使用默认构造方法,因为无法为它们提供文档注释。要编写可维护的代码,还应该为大多数未导出的类、接口、构造方法、方法和属性编写文档注释,尽管这些注释不需要像导出 API 元素那样完整。 - -  方法的文档注释应该简洁地描述方法与其客户端之间的契约。除了为继承而设计的类中的方法 (详见第 19 条)之外,契约应该说明方法做什么,而不是它如何工作的。文档注释应该列举方法的所有前置条件 (这些条件必须为真,以便客户端调用它们),以及后置条件 (这些条件是在调用成功完成后才为真)。通常,对于未检查的异常,前置条件由 `@throw` 标签隐式地描述;每个未检查异常对应于一个先决条件违反( precondition violation)。此外,可以在受影响的参数的 `@param` 标签中指定前置条件。 - -  除了前置条件和后置条件之外,方法还应在文档中记录它的副作用(side effort)。 副作用是系统状态的可观察到的变化,这对于实现后置条件而言显然不是必需的。 例如,如果方法启动后台线程,则文档应记录它。 - -  完整地描述方法的契约,文档注释应该为每个参数都有一个 `@param` 标签,一个 `@return` 标签 (除非方法有 void 返回类型),以及一个 `@throw` 标签 (无论是检查异常还是非检查异常)(条目 74)。如果 `@return` 标签中的文本与方法的描述相同,则可以忽略它,这取决于你所遵循的编码标准。 - -  按照惯例,`@param` 或 `@retur` 标签后面的文本应该是一个名词短语,描述参数或返回值所表示的值。 很少使用算术表达式代替名词短语; 请参阅 `BigInteger` 的示例。`@throw` 标签后面的文本应该包含单词“if”,后面跟着一个描述抛出异常的条件的子句。按照惯例,`@param` 、`@return` 或 `@throw` 标签后面的短语或子句不以句号结束。以下的文档注释说明了所有这些约定: - -```java -/** - * Returns the element at the specified position in this list. - * - *

This method is not guaranteed to run in constant - * time. In some implementations it may run in time proportional - * to the element position. - * - * @param index index of element to return; must be - * non-negative and less than the size of this list - * @return the element at the specified position in this list - * @throws IndexOutOfBoundsException if the index is out of range - * ({@code index < 0 || index >= this.size()}) - */ -E get(int index); -``` - -  请注意在此文档注释(`

`和``)中使用 HTML 标记。 Javadoc 实用工具将文档注释转换为 HTML,文档注释中的任意 HTML 元素最终都会生成 HTML 文档。 有时候,程序员甚至会在他们的文档注释中嵌入 HTML 表格,尽管这种情况很少见。 - -  还要注意在`@throw`子句中的代码片段周围使用 Javadoc 的 `{@code}`标签。这个标签有两个目的:它使代码片段以代码字体形式呈现,并且它抑制了代码片段中 HTML 标记和嵌套 Javadoc 标记的处理。后一个属性允许我们在代码片段中使用小于号 (<),即使它是一个 HTML 元字符。要在文档注释中包含多行代码示例,请使用包装在 HTML `

`标记中的 Javadoc`{@code}`标签。换句话说,在代码示例前面加上字符`
{@code,然后在代码后面加上}
`。这保留了代码中的换行符,并消除了转义 HTML 元字符的需要,但不需要转义 at 符号 (@),如果代码示例使用注释,则必须转义 at 符号 (@)。 - -  最后,请注意文档注释中使用的单词“this list”。按照惯例,“this”指的是在实例方法的文档注释中,指向方法调用所在的对象。 - -  正如条目 15 中提到的,当你为继承设计一个类时,必须记录它的自用模式( self-use patterns),以便程序员知道重写它的方法的语义。这些自用模式应该使用在 Java 8 中添加的`@implSpec` 标签来文档记录。回想一下,普通的问问昂注释描述了方法与其客户端之间的契约;相反,`@implSpec` 注释描述了方法与其子类之间的契约,如果它继承了方法或通过 super 调用方法,那么允许子类依赖于实现行为。下面是实际应用中的实例: - -```java - /** - * Returns true if this collection is empty. - * - * @implSpec - * This implementation returns {@code this.size() == 0}. - * - * @return true if this collection is empty - */ -public boolean isEmpty() { ... } -``` - -  从 Java 9 开始,Javadoc 实用工具仍然忽略 @implSpec 标签,除非通过命令行开关:`-tag "implSpec:a:Implementation Requirements:"`。希望在后续的版本中可以修正这个错误。 - -  不要忘记,你必须采取特殊操作来生成包含 HTML 元字符的文档,例如小于号(<),大于号(>)和 and 符号(&)。 将这些字符放入文档的最佳方法是使用`{@literal}`标签将它们包围起来,该标签禁止处理 HTML 标记和嵌套的 Javadoc 标记。 它就像`{@code}`标签一样,除了不会以代码字体呈现文本以外。 例如,这个 Javadoc 片段: - -```java -* A geometric series converges if {@literal |r| < 1}. -``` - -  它会生成文档:“A geometric series converges if |r| < 1.”。`{@literal}`标签可能只放在小于号的位置,而不是整个不等式,并且生成的文档是一样的,但是文档注释在源代码中的可读性较差。 这说明了**文档注释在源代码和生成的文档中都应该是可读的通用原则**。 如果无法实现这两者,则生成的文档的可读性要胜过在源代码中的可读性。 - -  每个文档注释的第一个“句子”(如下定义)成为注释所在元素的概要描述。 例如,第 255 页上的文档注释中的概要描述为:“返回此列表中指定位置的元素”。概要描述必须独立描述其概述元素的功能。 为避免混淆,**类或接口中的两个成员或构造方法不应具有相同的概要描述**。 要特别注意重载方法,为此通常使用相同的第一句话是自然的(但在文档注释中是不可接受的)。 - -  请小心,如果预期的概要描述包含句点,因为句点可能会提前终止描述。例如,以“A college degree, such as B.S., M.S. or Ph.D.”会导致概要描述为“A college degree, such as B.S., M.S”。问题在于概要描述在第一个句点结束,然后是空格、制表符或行结束符 (或第一个块标签处)[Javadoc-ref]。这里是缩写“M.S.”中的第二个句号后面跟着一个空格。最好的解决方案是用`{@literal}`标签来包围不愉快的句点和任何相关的文本,这样源代码中的句点后面就不会有空格了: - -```java -/** - * A college degree, such as B.S., {@literal M.S.} or Ph.D. - */ -public class Degree { ... } -``` - -  说概要描述是文档注释中的第一句子,其实有点误导人。按照惯例,它很少应该是一个完整的句子。对于方法和构造方法,概要描述应该是一个动词短语 (包括任何对象),描述了该方法执行的操作。例如: - -- `ArrayList(int initialCapacity)` —— 构造具有指定初始容量的空列表。 -- `Collection.size()` —— 返回此集合中的元素个数。 - -  如这些例子所示,使用第三人称陈述句时态 (“returns the number”) 而不是第二人称祈使句 (“return the number”)。 - -  对于类,接口和属性,概要描述应该是描述由类或接口的实例或属性本身表示的事物的名词短语。 例如: - -- `Instant` —— 时间线上的瞬时点。 -- `Math.PI`—— 更加接近 pi 的 double 类型数值,即圆的周长与其直径之比。 - -  在 Java 9 中,客户端索引被添加到 Javadoc 生成的 HTML 中。这个索引以页面右上角的搜索框的形式出现,它简化了导航大型 API 文档集的任务。当你在框中键入时,得到一个匹配页面的下拉菜单。API 元素 (如类、方法和属性) 是自动索引的。有时,可能希望索引对你的 API 很重要的其他术语。为此添加了`{@index}`标签。对文档注释中出现的术语进行索引,就像将其包装在这个标签中一样简单,如下面的片段所示: - -```java -* This method complies with the {@index IEEE 754} standard. -``` - -  泛型,枚举和注释需要特别注意文档注释。 **记录泛型类型或方法时,请务必记录所有类型参数**: - -```java -/** - * An object that maps keys to values. A map cannot contain - * duplicate keys; each key can map to at most one value. - * - * (Remainder omitted) - * - * @param the type of keys maintained by this map - * @param the type of mapped values - */ -public interface Map { ... } -``` - -  **在记录枚举类型时,一定要记录常量**,以及类型和任何公共方法。注意,如果文档很短,可以把整个文档注释放在一行: - -```java -/** - * An instrument section of a symphony orchestra. - */ -public enum OrchestraSection { - /** Woodwinds, such as flute, clarinet, and oboe. */ - WOODWIND, - - /** Brass instruments, such as french horn and trumpet. */ - BRASS, - - /** Percussion instruments, such as timpani and cymbals. */ - PERCUSSION, - - /** Stringed instruments, such as violin and cello. */ - STRING; -} -``` - -  **在为注解类型记录文档时,一定要记录任何成员**,以及类型本身。用名词短语表示的文档成员,就好像它们是属性一样。对于类型的概要描述,请使用动词短语,它表示当程序元素具有此类型注解的所表示的含义: - -```java -/** - * Indicates that the annotated method is a test method that - * must throw the designated exception to pass. - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.METHOD) -public @interface ExceptionTest { - /** - * The exception that the annotated test method must throw - * in order to pass. (The test is permitted to throw any - * subtype of the type described by this class object.) - */ - Class value(); -} -``` - -  包级别文档注释应放在名为 package-info.java 的文件中。 除了这些注释之外,package-info.java 还必须包含一个包声明,并且可以在此声明中包含注解。 同样,如果使用模块化系统(条目 15),则应将模块级别注释放在 module-info.java 文件中。 - -  在文档中经常忽略的 API 的两个方面,分别是线程安全性和可序列化性。**无论类或静态方法是否线程安全,都应该在文档中描述其线程安全级别**,如条目 82 中所述。如果一个类是可序列化的,应该记录它的序列化形式,如条目 87 中所述。 - -  Javadoc 具有“继承(inherit)”方法注释的能力。 如果 API 元素没有文档注释,Javadoc 将搜索最具体的适用文档注释,接口文档优先于超类文档。 搜索算法的详细信息可以在 The Javadoc Reference Guide [Javadoc-ref] 中找到。 还可以使用`{@inheritDoc}`标签从超类继承部分文档注释。 这意味着,除其他外,类可以重用它们实现的接口的文档注释,而不是复制这些注释。 该工具有可能减轻维护多组几乎相同的文档注释的负担,但使用起来很棘手并且有一些限制。 详细信息超出了本书的范围。 - -  关于文档注释,应该添加一个警告说明。虽然有必要为所有导出的 API 元素提供文档注释,但这并不总是足够的。对于由多个相互关联的类组成的复杂 API,通常需要用描述 API 总体架构的外部文档来补充文档注释。如果存在这样的文档,相关的类或包文档注释应该包含到外部文档的链接。 - -  Javadoc 会自动检查是否符合此条目中的许多建议。在 Java 7 中,需要命令行开关`-Xdoclint`来获得这种行为。在 Java 8 和 Java 9 中,默认情况下启用了此检查。诸如 checkstyle 之类的 IDE 插件会进一步检查是否符合这些建议[Burn01]。还可以通过 HTML 有效性检查器运行 Javadoc 生成的 HTML 文件来降低文档注释中出现错误的可能性。可以检测 HTML 标记的许多错误用法。有几个这样的检查器可供下载,可以使用 W3C markup validation service 在线验证 HTML 格式。在验证生成的 HTML 时,请记住,从 Java 9 开始,Javadoc 就能够生成 HTML5 和 HTML 4.01,尽管默认情况下仍然生成 HTML 4.01。如果希望 Javadoc 生成 HTML5,请使用`-html5`命令行开关。# 56. 为所有已公开的 API 元素编写文档注释 - -  如果 API 要可用,就必须对其进行文档化。传统上,API 文档是手工生成的,保持文档与代码的同步是一件苦差事。Java 编程环境使用`Javadoc`实用程序简化了这一任务。`Javadoc`使用特殊格式的文档注释 (通常称为 doc 注释),从源代码自动生成 API 文档。 - -  虽然文档注释约定不是 Java 语言的正式一部分,但它们构成了每个 Java 程序员都应该知道的事实上的 API。“如何编写文档注释(How to Write Doc Comments web page)”的网页[Javadoc-guide] 中介绍了这些约定。 虽然自 Java 4 发布以来该页面尚未更新,但它仍然是一个非常宝贵的资源。 Java 9 中添加了一个重要的文档标签,`{@ index}`; Java 8 中有一个,`{@implSpec}`;Java 5 中有两个,`{@literal}`和`{@code}`。 上述网页中缺少这些标签的介绍,但在此条目中进行讨论。 - -  **要正确地记录 API,必须在每个导出的类、接口、构造方法、方法和属性声明之前加上文档注释**。如果一个类是可序列化的,还应该记录它的序列化形式 (条目 87)。在没有文档注释的情况下,Javadoc 可以做的最好的事情是将声明重现为受影响的 API 元素的唯一文档。使用缺少文档注释的 API 是令人沮丧和容易出错的。公共类不应该使用默认构造方法,因为无法为它们提供文档注释。要编写可维护的代码,还应该为大多数未导出的类、接口、构造方法、方法和属性编写文档注释,尽管这些注释不需要像导出 API 元素那样完整。 - -  方法的文档注释应该简洁地描述方法与其客户端之间的契约。除了为继承而设计的类中的方法 (条目 19) 之外,契约应该说明方法做什么,而不是它如何工作的。文档注释应该列举方法的所有前置条件 (这些条件必须为真,以便客户端调用它们),以及后置条件 (这些条件是在调用成功完成后才为真)。通常,对于未检查的异常,前置条件由`@throw`标签隐式地描述;每个未检查异常对应于一个先决条件违反( precondition violation.)。此外,可以在受影响的参数的`@param`标签中指定前置条件。 - -  除了前置条件和后置条件之外,方法还应在文档中记录它的副作用(side effort)。 副作用是系统状态的可观察到的变化,这对于实现后置条件而言显然不是必需的。 例如,如果方法启动后台线程,则文档应记录它。 - -  完整地描述方法的契约,文档注释应该为每个参数都有一个`@param`标签,一个`@return`标签 (除非方法有 void 返回类型),以及一个`@throw`标签 (无论是检查异常还是非检查异常)(条目 74)。如果`@return`标签中的文本与方法的描述相同,则可以忽略它,这取决于你所遵循的编码标准。 - -  按照惯例,`@param`或`@retur`标签后面的文本应该是一个名词短语,描述参数或返回值所表示的值。 很少使用算术表达式代替名词短语; 请参阅`BigInteger`的示例。`@throw`标签后面的文本应该包含单词“if”,后面跟着一个描述抛出异常的条件的子句。按照惯例,`@param`、`@return`或`@throw`标签后面的短语或子句不以句号结束。以下的文档注释说明了所有这些约定: - -```java -/** - * Returns the element at the specified position in this list. - * - *

This method is not guaranteed to run in constant - * time. In some implementations it may run in time proportional - * to the element position. - * - * @param index index of element to return; must be - * non-negative and less than the size of this list - * @return the element at the specified position in this list - * @throws IndexOutOfBoundsException if the index is out of range - * ({@code index < 0 || index >= this.size()}) - */ -E get(int index); -``` - -  请注意在此文档注释(`

`和``)中使用 HTML 标记。 Javadoc 实用工具将文档注释转换为 HTML,文档注释中的任意 HTML 元素最终都会生成 HTML 文档。 有时候,程序员甚至会在他们的文档注释中嵌入 HTML 表格,尽管这种情况很少见。 - -  还要注意在`@throw`子句中的代码片段周围使用 Javadoc 的 `{@code}`标签。这个标签有两个目的:它使代码片段以代码字体形式呈现,并且它抑制了代码片段中 HTML 标记和嵌套 Javadoc 标记的处理。后一个属性允许我们在代码片段中使用小于号 (<),即使它是一个 HTML 元字符。要在文档注释中包含多行代码示例,请使用包装在 HTML `

`标记中的 Javadoc `{@code}` 标签。换句话说,在代码示例前面加上字符 `
{@code,然后在代码后面加上}
` 。这保留了代码中的换行符,并消除了转义 HTML 元字符的需要,但不需要转义 at 符号 (@),如果代码示例使用注释,则必须转义 at 符号 (@)。 - -  最后,请注意文档注释中使用的单词“this list”。按照惯例,“this”指的是在实例方法的文档注释中,指向方法调用所在的对象。 - -  正如条目 15 中提到的,当你为继承设计一个类时,必须记录它的自用模式( self-use patterns),以便程序员知道重写它的方法的语义。这些自用模式应该使用在 Java 8 中添加的@implSpec 标签来文档记录。回想一下,普通的问问昂注释描述了方法与其客户端之间的契约;相反,@implSpec 注释描述了方法与其子类之间的契约,如果它继承了方法或通过 super 调用方法,那么允许子类依赖于实现行为。下面是实际应用中的实例: - -```java - /** - * Returns true if this collection is empty. - * - * @implSpec - * This implementation returns {@code this.size() == 0}. - * - * @return true if this collection is empty - */ -public boolean isEmpty() { ... } -``` - -  从 Java 9 开始,Javadoc 实用工具仍然忽略@implSpec 标签,除非通过命令行开关:`-tag "implSpec:a:Implementation Requirements:"`。希望在后续的版本中可以修正这个错误。 - -  不要忘记,你必须采取特殊操作来生成包含 HTML 元字符的文档,例如小于号(<),大于号(>)和 and 符号(&)。 将这些字符放入文档的最佳方法是使用`{@literal}`标签将它们包围起来,该标签禁止处理 HTML 标记和嵌套的 Javadoc 标记。 它就像`{@code}`标签一样,除了不会以代码字体呈现文本以外。 例如,这个 Javadoc 片段: - -```java -* A geometric series converges if {@literal |r| < 1}. -``` - -  它会生成文档:“A geometric series converges if |r| < 1.”。`{@literal}`标签可能只放在小于号的位置,而不是整个不等式,并且生成的文档是一样的,但是文档注释在源代码中的可读性较差。 这说明了**文档注释在源代码和生成的文档中都应该是可读的通用原则**。 如果无法实现这两者,则生成的文档的可读性要胜过在源代码中的可读性。 - -  每个文档注释的第一个“句子”(如下定义)成为注释所在元素的概要描述。 例如,第 255 页上的文档注释中的概要描述为:“返回此列表中指定位置的元素”。概要描述必须独立描述其概述元素的功能。 为避免混淆,**类或接口中的两个成员或构造方法不应具有相同的概要描述**。 要特别注意重载方法,为此通常使用相同的第一句话是自然的(但在文档注释中是不可接受的)。 - -  请小心,如果预期的概要描述包含句点,因为句点可能会提前终止描述。例如,以“A college degree, such as B.S., M.S. or Ph.D.”会导致概要描述为“A college degree, such as B.S., M.S”。问题在于概要描述在第一个句点结束,然后是空格、制表符或行结束符 (或第一个块标签处)[Javadoc-ref]。这里是缩写“M.S.”中的第二个句号后面跟着一个空格。最好的解决方案是用 `{@literal}` 标签来包围不愉快的句点和任何相关的文本,这样源代码中的句点后面就不会有空格了: - -```java -/** - * A college degree, such as B.S., {@literal M.S.} or Ph.D. - */ -public class Degree { ... } -``` - -  说概要描述是文档注释中的第一句子,其实有点误导人。按照惯例,它很少应该是一个完整的句子。对于方法和构造方法,概要描述应该是一个动词短语 (包括任何对象),描述了该方法执行的操作。例如: - -- `rrayList(int initialCapacity)` —— 构造具有指定初始容量的空列表。 -- `Collection.size()` —— 返回此集合中的元素个数。 - -  如这些例子所示,使用第三人称陈述句时态 (“returns the number”) 而不是第二人称祈使句 (“return the number”)。 - -  对于类,接口和属性,概要描述应该是描述由类或接口的实例或属性本身表示的事物的名词短语。 例如: - -- `Instant` —— 时间线上的瞬时点。 -- `Math.PI`—— 更加接近 pi 的 double 类型数值,即圆的周长与其直径之比。 - -  在 Java 9 中,客户端索引被添加到 Javadoc 生成的 HTML 中。这个索引以页面右上角的搜索框的形式出现,它简化了导航大型 API 文档集的任务。当你在框中键入时,得到一个匹配页面的下拉菜单。API 元素 (如类、方法和属性) 是自动索引的。有时,可能希望索引对你的 API 很重要的其他术语。为此添加了 `{@index}` 标签。对文档注释中出现的术语进行索引,就像将其包装在这个标签中一样简单,如下面的片段所示: - -```java -* This method complies with the {@index IEEE 754} standard. -``` - -  泛型,枚举和注释需要特别注意文档注释。 **记录泛型类型或方法时,请务必记录所有类型参数**: - -```java -/** - * An object that maps keys to values. A map cannot contain - * duplicate keys; each key can map to at most one value. - * - * (Remainder omitted) - * - * @param the type of keys maintained by this map - * @param the type of mapped values - */ -public interface Map { ... } -``` - -  **在记录枚举类型时,一定要记录常量**,以及类型和任何公共方法。注意,如果文档很短,可以把整个文档注释放在一行: - -```java -/** - * An instrument section of a symphony orchestra. - */ -public enum OrchestraSection { - /** Woodwinds, such as flute, clarinet, and oboe. */ - WOODWIND, - - /** Brass instruments, such as french horn and trumpet. */ - BRASS, - - /** Percussion instruments, such as timpani and cymbals. */ - PERCUSSION, - - /** Stringed instruments, such as violin and cello. */ - STRING; -} -``` - -  **在为注解类型记录文档时,一定要记录任何成员**,以及类型本身。用名词短语表示的文档成员,就好像它们是属性一样。对于类型的概要描述,请使用动词短语,它表示当程序元素具有此类型注解的所表示的含义: - -```java -/** - * Indicates that the annotated method is a test method that - * must throw the designated exception to pass. - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.METHOD) -public @interface ExceptionTest { - /** - * The exception that the annotated test method must throw - * in order to pass. (The test is permitted to throw any - * subtype of the type described by this class object.) - */ - Class value(); -} -``` - -  包级别文档注释应放在名为 package-info.java 的文件中。 除了这些注释之外,package-info.java 还必须包含一个包声明,并且可以在此声明中包含注解。 同样,如果使用模块化系统(详见第 15 条),则应将模块级别注释放在 module-info.java 文件中。 - -  在文档中经常忽略的 API 的两个方面,分别是线程安全性和可序列化性。**无论类或静态方法是否线程安全,都应该在文档中描述其线程安全级别**,如条目 82 中所述。如果一个类是可序列化的,应该记录它的序列化形式,如条目 87 中所述。 - -  Javadoc 具有“继承(inherit)”方法注释的能力。 如果 API 元素没有文档注释,Javadoc 将搜索最具体的适用文档注释,接口文档优先于超类文档。 搜索算法的详细信息可以在 The Javadoc Reference Guide [Javadoc-ref] 中找到。 还可以使用 `{@inheritDoc}` 标签从超类继承部分文档注释。 这意味着,除其他外,类可以重用它们实现的接口的文档注释,而不是复制这些注释。 该工具有可能减轻维护多组几乎相同的文档注释的负担,但使用起来很棘手并且有一些限制。 详细信息超出了本书的范围。 - -  关于文档注释,应该添加一个警告说明。虽然有必要为所有导出的 API 元素提供文档注释,但这并不总是足够的。对于由多个相互关联的类组成的复杂 API,通常需要用描述 API 总体架构的外部文档来补充文档注释。如果存在这样的文档,相关的类或包文档注释应该包含到外部文档的链接。 - -  Javadoc 会自动检查是否符合此条目中的许多建议。在 Java 7 中,需要命令行开关 `-Xdoclint` 来获得这种行为。在 Java 8 和 Java 9 中,默认情况下启用了此检查。诸如 checkstyle 之类的 IDE 插件会进一步检查是否符合这些建议[Burn01]。还可以通过 HTML 有效性检查器运行 Javadoc 生成的 HTML 文件来降低文档注释中出现错误的可能性。可以检测 HTML 标记的许多错误用法。有几个这样的检查器可供下载,可以使用 W3C markup validation service 在线验证 HTML 格式。在验证生成的 HTML 时,请记住,从 Java 9 开始,Javadoc 就能够生成 HTML5 和 HTML 4.01,尽管默认情况下仍然生成 HTML 4.01。如果希望 Javadoc 生成 HTML5,请使用 `-html5` 命令行开关。 - -  本条目中描述的约定涵盖了基本内容。尽管撰写本文时已经有 15 年的历史,但编写文档注释的最终指南仍然是《 How to Write Doc Comments》[Javadoc-guide]。 - -  如果你遵循本项目中的指导原则,生成的文档应该提供对 API 的清晰描述。然而,唯一确定的方法,**是阅读 Javadoc 实用工具生成的 web 页面**。对于其他人将使用的每个 API,都值得这样做。正如测试程序几乎不可避免地会导致对代码的一些更改一样,阅读文档通常也会导致对文档注释的一些少许的修改。 - -  总之,文档注释是记录 API 的最佳、最有效的方法。对于所有导出的 API 元素,它们的使用应被视为必需的。 采用符合标准惯例的一致风格 。请记住,在文档注释中允许任意 HTML,但必须转义 HTML 的元字符。 - -  本条目中描述的约定涵盖了基本内容。尽管撰写本文时已经有 15 年的历史,但编写文档注释的最终指南仍然是《 How to Write Doc Comments》[Javadoc-guide]。 - -  如果你遵循本项目中的指导原则,生成的文档应该提供对 API 的清晰描述。然而,唯一确定的方法,**是阅读 Javadoc 实用工具生成的 web 页面**。对于其他人将使用的每个 API,都值得这样做。正如测试程序几乎不可避免地会导致对代码的一些更改一样,阅读文档通常也会导致对文档注释的一些少许的修改。 - -  总之,文档注释是记录 API 的最佳、最有效的方法。对于所有导出的 API 元素,它们的使用应被视为必需的。 采用符合标准惯例的一致风格 。请记住,在文档注释中允许任意 HTML,但必须转义 HTML 的元字符。 -# 57. 最小化局部变量的作用域 - -  这条目在性质上类似于条目 15,即“最小化类和成员的可访问性”。通过最小化局部变量的作用域,可以提高代码的可读性和可维护性,并降低出错的可能性。 - -  较早的编程语言(如 C)要求必须在代码块的头部声明局部变量,并且一些程序员继续习惯这样做。 这是一个值得改进的习惯。 作为提醒,Java 允许你在任何合法的语句的地方声明变量(as does C, since C99)。 - -  **用于最小化局部变量作用域的最强大的技术是再首次使用的地方声明它**。 如果变量在使用之前被声明,那就变得更加混乱—— 这也会对试图理解程序的读者来讲,又增加了一件分散他们注意力的事情。 到使用该变量时,读者可能不记得变量的类型或初始值。 - -  过早地声明局部变量可能导致其作用域不仅过早开始而且结束太晚。 局部变量的作用域从声明它的位置延伸到封闭块的末尾。 如果变量在使用它的封闭块之外声明,则在程序退出该封闭块后它仍然可见。如果在其预定用途区域之前或之后意外使用变量,则后果可能是灾难性的。 - -  **几乎每个局部变量声明都应该包含一个初始化器**。如果还没有足够的信息来合理地初始化一个变量,那么应该推迟声明,直到认为可以这样做。这个规则的一个例外是 try-catch 语句。如果一个变量被初始化为一个表达式,该表达式的计算结果可以抛出一个已检查的异常,那么该变量必须在 try 块中初始化 (除非所包含的方法可以传播异常)。如果该值必须在 try 块之外使用,那么它必须在 try 块之前声明,此时它还不能被“合理地初始化”。例如,参照条目 65 中的示例。 - -  循环提供了一个特殊的机会来最小化变量的作用域。传统形式的 for 循环和 for-each 形式都允许声明循环变量,将其作用域限制在需要它们的确切区域。 (该区域由循环体和 for 关键字与正文之间的括号中的代码组成)。因此,如果循环终止后不需要循环变量的内容,那么**优先选择 for 循环而不是 while 循环**。 - -  例如,下面是遍历集合的首选方式(条目 58): - -```java -// Preferred idiom for iterating over a collection or array -for (Element e : c) { - ... // Do Something with e -} -``` - -  如果需要访问迭代器,也许是为了调用它的 remove 方法,首选的习惯用法,使用传统的 for 循环代替 for-each 循环: - -```java -// Idiom for iterating when you need the iterator -for (Iterator i = c.iterator(); i.hasNext(); ) { - Element e = i.next(); - ... // Do something with e and i -} -``` - -  要了解为什么这些 for 循环优于 while 循环,请考虑以下代码片段,其中包含两个 while 循环和一个 bug: - -```java -Iterator i = c.iterator(); -while (i.hasNext()) { - doSomething(i.next()); -} -... -Iterator i2 = c2.iterator(); -while (i.hasNext()) { // BUG! - doSomethingElse(i2.next()); -} -``` - -  第二个循环包含一个复制粘贴错误:它初始化一个新的循环变量 i2,但是使用旧的变量 i,不幸的是,它仍在范围内。 生成的代码编译时没有错误,并且在不抛出异常的情况下运行,但它做错了。 第二个循环不是在 c2 上迭代,而是立即终止,给出了 c2 为空的错误印象。 由于程序无声地出错,因此错误可能会长时间无法被检测到。 - -  如果将类似的复制粘贴错误与 for 循环 (for-each 循环或传统循环) 结合使用,则生成的代码甚至无法编译。第一个循环中的元素 (或迭代器) 变量不在第二个循环中的作用域中。下面是它与传统 for 循环的示例: - -```java -for (Iterator i = c.iterator(); i.hasNext(); ) { - Element e = i.next(); - ... // Do something with e and i -} -... - -// Compile-time error - cannot find symbol i -for (Iterator i2 = c2.iterator(); i.hasNext(); ) { - Element e2 = i2.next(); - ... // Do something with e2 and i2 -} -``` - -  此外,如果使用 for 循环,那么发送这种复制粘贴错误的可能性要小得多,因为没有必要在两个循环中使用不同的变量名。 循环是完全独立的,因此重用元素(或迭代器)变量名称没有坏处。 事实上,这样做通常很流行。 - -  for 循环比 while 循环还有一个优点:它更短,增强了可读性。 - -  下面是另一种循环习惯用法,它最小化了局部变量的作用域: - -```java -for (int i = 0, n = expensiveComputation(); i < n; i++) { - ... // Do something with i; -} -``` - -  关于这个做法需要注意的重要一点是,它有两个循环变量,i 和 n,它们都具有完全相同的作用域。第二个变量 n 用于存储第一个变量的限定值,从而避免了每次迭代中冗余计算的代价。作为一个规则,如果循环测试涉及一个方法调用,并且保证在每次迭代中返回相同的结果,那么应该使用这种用法。 - -  最小化局部变量作用域的最终技术是**保持方法小而集中**。 如果在同一方法中组合两个行为(activities),则与一个行为相关的局部变量可能会位于执行另一个行为的代码范围内。 为了防止这种情况发生,只需将方法分为两个:每个行为对应一个方法。 -# 58. for-each 循环优于传统 for 循环 - -  正如在条目 45 中所讨论的,一些任务最好使用 Stream 来完成,一些任务最好使用迭代。下面是一个传统的 for 循环来遍历一个集合: - -```java -// Not the best way to iterate over a collection! -for (Iterator i = c.iterator(); i.hasNext(); ) { - Element e = i.next(); - ... // Do something with e -} -``` - -下面是迭代数组的传统 for 循环的实例: - -```java -// Not the best way to iterate over an array! -for (int i = 0; i < a.length; i++) { - ... // Do something with a[i] -} -``` - -  这些习惯用法比 while 循环更好(详见第 57 条),但是它们并不完美。迭代器和索引变量都很混乱——你只需要元素而已。此外,它们也代表了出错的机会。迭代器在每个循环中出现三次,索引变量出现四次,这使你有很多机会使用错误的变量。如果这样做,就不能保证编译器会发现到问题。最后,这两个循环非常不同,引起了对容器类型的不必要注意,并且增加了更改该类型的小麻烦。 - -  for-each 循环 (官方称为“增强的 for 语句”) 解决了所有这些问题。它通过隐藏迭代器或索引变量来消除混乱和出错的机会。由此产生的习惯用法同样适用于集合和数组,从而简化了将容器的实现类型从一种转换为另一种的过程: - -```java -// The preferred idiom for iterating over collections and arrays -for (Element e : elements) { - ... // Do something with e -} -``` - -  当看到冒号 (:) 时,请将其读作“in”。因此,上面的循环读作“对于元素 elements 中的每个元素 e”。“使用 for-each 循环不会降低性能,即使对于数组也是如此:它们生成的代码本质上与手工编写的代码相同。 - -  当涉及到嵌套迭代时,for-each 循环相对于传统 for 循环的优势甚至更大。下面是人们在进行嵌套迭代时经常犯的一个错误: - -```java -// Can you spot the bug? -enum Suit { CLUB, DIAMOND, HEART, SPADE } -enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, - NINE, TEN, JACK, QUEEN, KING } -... -static Collection suits = Arrays.asList(Suit.values()); -static Collection ranks = Arrays.asList(Rank.values()); - -List deck = new ArrayList<>(); -for (Iterator i = suits.iterator(); i.hasNext(); ) - for (Iterator j = ranks.iterator(); j.hasNext(); ) - deck.add(new Card(i.next(), j.next())); -``` - -  如果没有发现这个 bug,也不必感到难过。许多专业程序员都曾犯过这样或那样的错误。问题是,对于外部集合 (suit),next 方法在迭代器上调用了太多次。它应该从外部循环调用,因此每花色调用一次,但它是从内部循环调用的,因此每一张牌调用一次。在 suit 用完之后,循环抛出 `NoSuchElementException` 异常。 - -  如果你真的不走运,外部集合的大小是内部集合大小的倍数——也许它们是相同的集合——循环将正常终止,但它不会做你想要的。 例如,考虑这种错误的尝试,打印一对骰子的所有可能的掷法: - -```java -// Same bug, different symptom! -enum Face { ONE, TWO, THREE, FOUR, FIVE, SIX } -... -Collection faces = EnumSet.allOf(Face.class); - -for (Iterator i = faces.iterator(); i.hasNext(); ) - for (Iterator j = faces.iterator(); j.hasNext(); ) - System.out.println(i.next() + " " + j.next()); -``` - -  该程序不会抛出异常,但它只打印 6 个重复的组合 (从“ONE ONE”到“SIX SIX”),而不是预期的 36 个组合。 - -  要修复例子中的错误,必须在外部循环的作用域内添加一个变量来保存外部元素: - -```java -/ Fixed, but ugly - you can do better! -for (Iterator i = suits.iterator(); i.hasNext(); ) { - Suit suit = i.next(); - for (Iterator j = ranks.iterator(); j.hasNext(); ) - deck.add(new Card(suit, j.next())); -} -``` - -  相反,如果使用嵌套 for-each 循环,问题就会消失。生成的代码也尽可能地简洁: - -```java -// Preferred idiom for nested iteration on collections and arrays -for (Suit suit : suits) - for (Rank rank : ranks) - deck.add(new Card(suit, rank)); -``` - -  但是,有三种常见的情况是你不能分别使用 for-each 循环的: - -- **有损过滤(Destructive filtering)**——如果需要遍历集合,并删除指定选元素,则需要使用显式迭代器,以便可以调用其 remove 方法。 通常可以使用在 Java 8 中添加的 Collection 类中的 removeIf 方法,来避免显式遍历。 -- **转换**——如果需要遍历一个列表或数组并替换其元素的部分或全部值,那么需要列表迭代器或数组索引来替换元素的值。 -- **并行迭代**——如果需要并行地遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步进行 (正如上面错误的 card 和 dice 示例中无意中演示的那样)。 - -  如果发现自己处于这些情况中的任何一种,请使用传统的 for 循环,并警惕本条目中提到的陷阱。 - -  for-each 循环不仅允许遍历集合和数组,还允许遍历实现 Iterable 接口的任何对象,该接口由单个方法组成。接口定义如下: - -```java -public interface Iterable { - // Returns an iterator over the elements in this iterable - Iterator iterator(); -} -``` - -  如果必须从头开始编写自己的 Iterator 实现,那么实现 Iterable 会有点棘手,但是如果你正在编写表示一组元素的类型,那么你应该强烈考虑让它实现 Iterable 接口,甚至可以选择不让它实现 Collection 接口。这允许用户使用 for-each 循环遍历类型,他们会永远感激不尽的。 - -  总之,for-each 循环在清晰度,灵活性和错误预防方面提供了超越传统 for 循环的令人注目的优势,而且没有性能损失。 尽可能使用 for-each 循环优先于 for 循环。 -# 59. 了解并使用库 - -  假设你想要生成 0 到某个上界之间的随机整数。面对这个常见任务,许多程序员会编写一个类似这样的小方法: - -```java -// Common but deeply flawed! -static Random rnd = new Random(); -static int random(int n) { - return Math.abs(rnd.nextInt()) % n; -} -``` - -  这个方法看起来不错,但它有三个缺点。首先,如果 n 是小的平方数,随机数序列会在相当短的时间内重复。第二个缺陷是,如果 n 不是 2 的幂,那么平均而言,一些数字将比其他数字更频繁地返回。如果 n 很大,这种效果会很明显。下面的程序有力地证明了这一点,它在一个精心选择的范围内生成 100 万个随机数,然后打印出有多少个数字落在范围的下半部分: - -```java -public static void main(String[] args) { - int n = 2 * (Integer.MAX_VALUE / 3); - int low = 0; - for (int i = 0; i < 1000000; i++) - if (random(n) < n/2) - low++; - System.out.println(low); -} -``` - -  如果 random 方法工作正常,程序将输出一个接近 50 万的数字,但是如果运行它,你将发现它输出一个接近 666666 的数字。随机方法生成的数字中有三分之二落在其范围的下半部分! - -  random 方法的第三个缺陷是,在极少数情况下会返回超出指定范围的数字,这是灾难性的结果。这是因为该方法试图通过调用 `Math.abs` 将 `rnd.nextInt()` 返回的值映射到非负整数。如果 `nextInt()` 返回整数。`Integer.MIN_VALUE`、`Math.abs` 也将返回整数。假设 n 不是 2 的幂,那么 `Integer.MIN_VALUE` 和求模运算符 `(%)` 将返回一个负数。几乎肯定的是,这会导致你的程序失败,并且这种失败可能难以重现。 - -  要编写一个 random 方法来纠正这些缺陷,你必须对伪随机数生成器、数论和 2 的补码算法有一定的了解。幸运的是,你不必这样做(这是为你而做的成果)。它被称为 `Random.nextInt(int)`。你不必关心它如何工作的(尽管如果你感兴趣,可以研究文档或源代码)。一位具有算法背景的高级工程师花了大量时间设计、实现和测试这种方法,然后将其展示给该领域的几位专家,以确保它是正确的。然后,这个库经过 beta 测试、发布,并被数百万程序员广泛使用了近 20 年。该方法还没有发现任何缺陷,但是如果发现了缺陷,将在下一个版本中进行修复。**通过使用标准库,你可以利用编写它的专家的知识和以前使用它的人的经验。** - -  从 Java 7 开始,就不应该再使用 Random。在大多数情况下,**选择的随机数生成器现在是 ThreadLocalRandom。** 它能产生更高质量的随机数,而且速度非常快。在我的机器上,它比 Random 快 3.6 倍。对于 fork 连接池和并行流,使用 SplittableRandom。 - -  使用这些库的第二个好处是,你不必浪费时间为那些与你的工作无关的问题编写专门的解决方案。如果你像大多数程序员一样,那么你宁愿将时间花在应用程序上,而不是底层管道上。 - -  使用标准库的第三个优点是,随着时间的推移,它们的性能会不断提高,而你无需付出任何努力。由于许多人使用它们,而且它们是在行业标准基准中使用的,所以提供这些库的组织有很强的动机使它们运行得更快。多年来,许多 Java 平台库都被重新编写过,有时甚至是反复编写,从而带来了显著的性能改进。使用库的第四个好处是,随着时间的推移,它们往往会获得新功能。如果一个库丢失了一些东西,开发人员社区会将其公布于众,并且丢失的功能可能会在后续版本中添加。 - -  使用标准库的最后一个好处是,可以将代码放在主干中。这样的代码更容易被开发人员阅读、维护和重用。 - -  考虑到所有这些优点,使用库工具而不选择专门的实现似乎是合乎逻辑的,但许多程序员并不这样做。为什么不呢?也许他们不知道库的存在。**在每个主要版本中,都会向库中添加许多特性,了解这些新增特性是值得的。** 每次发布 Java 平台的主要版本时,都会发布一个描述其新特性的 web 页面。这些页面非常值得一读 [Java8-feat, Java9-feat]。为了强调这一点,假设你想编写一个程序来打印命令行中指定的 URL 的内容(这大致是 Linux curl 命令所做的)。在 Java 9 之前,这段代码有点乏味,但是在 Java 9 中,transferTo 方法被添加到 InputStream 中。这是一个使用这个新方法执行这项任务的完整程序: - -```java -// Printing the contents of a URL with transferTo, added in Java 9 -public static void main(String[] args) throws IOException { - try (InputStream in = new URL(args[0]).openStream()) { - in.transferTo(System.out); - } -} -``` - -  库太大,无法学习所有文档 [Java9-api],但是 **每个程序员都应该熟悉 java.lang、java.util 和 java.io 的基础知识及其子包。** 其他库的知识可以根据需要获得。概述库中的工具超出了本项目的范围,这些工具多年来已经发展得非常庞大。 - -  有几个图书馆值得一提。collections 框架和 streams 库(可参看 Item 45-48)应该是每个程序员的基本工具包的一部分,`java.util.concurrent` 中的并发实用程序也应该是其中的一部分。这个包既包含高级的并发工具来简化多线程的编程任务,还包含低级别的并发基本类型,允许专家们自己编写更高级的并发抽象。`java.util.concurrent` 的高级部分,在第 80 条和第 81 条中讨论。 - -  有时,类库工具可能无法满足你的需求。你的需求越专门化,发生这种情况的可能性就越大。虽然你的第一个思路应该是使用这些库,但是如果你已经了解了它们在某些领域提供的功能,而这些功能不能满足你的需求,那么可以使用另一种实现。任何有限的库集所提供的功能总是存在漏洞。如果你在 Java 平台库中找不到你需要的东西,你的下一个选择应该是寻找高质量的第三方库,比如谷歌的优秀的开源 Guava 库 [Guava]。如果你无法在任何适当的库中找到所需的功能,你可能别无选择,只能自己实现它。 - -  总而言之,不要白费力气重新发明轮子。如果你需要做一些看起来相当常见的事情,那么库中可能已经有一个工具可以做你想做的事情。如果有,使用它;如果你不知道,检查一下。一般来说,库代码可能比你自己编写的代码更好,并且随着时间的推移可能会得到改进。这并不反映你作为一个程序员的能力。规模经济决定了库代码得到的关注要远远超过大多数开发人员所能承担的相同功能。 -# 60. 若需要精确答案就应避免使用 float 和 double 类型 - -  float 和 double 类型主要用于科学计算和工程计算。它们执行二进制浮点运算,该算法经过精心设计,能够在很大范围内快速提供精确的近似值。但是,它们不能提供准确的结果,也不应该在需要精确结果的地方使用。**float 和 double 类型特别不适合进行货币计算**,因为不可能将 0.1(或 10 的任意负次幂)精确地表示为 float 或 double。 - -  例如,假设你口袋里有 1.03 美元,你消费了 42 美分。你还剩下多少钱?下面是一个简单的程序片段,试图回答这个问题: - -```java -System.out.println(1.03 - 0.42); -``` - -  不幸的是,它输出了 0.6100000000000001。这不是一个特例。假设你口袋里有一美元,你买了 9 台洗衣机,每台 10 美分。你能得到多少零钱? - -```java -System.out.println(1.00 - 9 * 0.10); -``` - -  根据这个程序片段,可以得到 0.0999999999999999998 美元。 - -  你可能认为,只需在打印之前将结果四舍五入就可以解决这个问题,但不幸的是,这种方法并不总是有效。例如,假设你口袋里有一美元,你看到一个架子上有一排好吃的糖果,它们的价格仅仅是 10 美分,20 美分,30 美分,以此类推,直到 1 美元。你每买一颗糖,从 10 美分的那颗开始,直到你买不起货架上的下一颗糖。你买了多少糖果,换了多少零钱?这里有一个简单的程序来解决这个问题: - -```java -// Broken - uses floating point for monetary calculation! -public static void main(String[] args) { - double funds = 1.00; - int itemsBought = 0; - for (double price = 0.10; funds >= price; price += 0.10) { - funds -= price; - itemsBought++; - } - System.out.println(itemsBought +"items bought."); - System.out.println("Change: $" + funds); -} -``` - -  如果你运行这个程序,你会发现你可以买得起三块糖,你还有 0.399999999999999999 美元。这是错误的答案!解决这个问题的正确方法是 **使用 BigDecimal、int 或 long 进行货币计算。** - -  这里是前一个程序的一个简单改版,使用 BigDecimal 类型代替 double。注意,使用 BigDecimal 的 String 构造函数而不是它的 double 构造函数。这是为了避免在计算中引入不准确的值 [Bloch05, Puzzle 2]: - -```java -public static void main(String[] args) { - final BigDecimal TEN_CENTS = new BigDecimal(".10"); - int itemsBought = 0; - BigDecimal funds = new BigDecimal("1.00"); - for (BigDecimal price = TEN_CENTS;funds.compareTo(price) >= 0;price = price.add(TEN_CENTS)) { - funds = funds.subtract(price); - itemsBought++; - } - System.out.println(itemsBought +"items bought."); - System.out.println("Money left over: $" + funds); -} -``` - -  如果你运行修改后的程序,你会发现你可以买四颗糖,最终剩下 0 美元。这是正确答案。 - -  然而,使用 BigDecimal 有两个缺点:它与原始算术类型相比很不方便,而且速度要慢得多。如果你只解决一个简单的问题,后一种缺点是无关紧要的,但前者可能会让你烦恼。 - -  除了使用 BigDecimal,另一种方法是使用 int 或 long,这取决于涉及的数值大小,还要自己处理十进制小数点。在这个例子中,最明显的方法是用美分而不是美元来计算。下面是一个采用这种方法的简单改版: - -```java -public static void main(String[] args) { - int itemsBought = 0; - int funds = 100; - for (int price = 10; funds >= price; price += 10) { - funds -= price; - itemsBought++; - } - System.out.println(itemsBought +"items bought."); - System.out.println("Cash left over: " + funds + " cents"); -} -``` - -  总之,对于任何需要精确答案的计算,不要使用 float 或 double 类型。如果希望系统来处理十进制小数点,并且不介意不使用基本类型带来的不便和成本,请使用 BigDecimal。使用 BigDecimal 的另一个好处是,它可以完全控制舍入,当执行需要舍入的操作时,可以从八种舍入模式中进行选择。如果你使用合法的舍入行为执行业务计算,这将非常方便。如果性能是最重要的,那么你不介意自己处理十进制小数点,而且数值不是太大,可以使用 int 或 long。如果数值不超过 9 位小数,可以使用 int;如果不超过 18 位,可以使用 long。如果数量可能超过 18 位,则使用 BigDecimal。 -# 61. 基本数据类型优于包装类 - -  Java 有一个由两部分组成的类型系统,包括基本类型(如 int、double 和 boolean)和引用类型(如 String 和 List)。每个基本类型都有一个对应的引用类型,称为包装类型。与 int、double 和 boolean 对应的包装类是 Integer、Double 和 Boolean。 - -  正如条目 6 中提到的,自动装箱和自动拆箱模糊了基本类型和包装类型之间的区别,但不会消除它们。这两者之间有真正的区别,重要的是你要始终意识到正在使用的是哪一种,并在它们之间仔细选择。 - -  基本类型和包装类型之间有三个主要区别。首先,基本类型只有它们的值,而包装类型具有与其值不同的标识。换句话说,两个包装类型实例可以具有相同的值和不同的标识。第二,基本类型只有全功能值,而每个包装类型除了对应的基本类型的所有功能值外,还有一个非功能值,即 null。最后,基本类型比包装类型更节省时间和空间。如果你不小心的话,这三种差异都会给你带来真正的麻烦。 - -  考虑下面的比较器,它的设计目的是表示 Integer 值上的升序数字排序。(回想一下,比较器的 compare 方法返回一个负数、零或正数,这取决于它的第一个参数是小于、等于还是大于第二个参数。)你不需要在实际使用中编写这个比较器,因为它实现了 Integer 的自然排序,但它提供了一个有趣的例子: - -```java -// Broken comparator - can you spot the flaw? -Comparator naturalOrder =(i, j) -> (i < j) ? -1 : (i == j ? 0 : 1); -``` - -  这个比较器看起来应该可以工作,它将通过许多测试。例如,它可以与 `Collections.sort` 一起使用,以正确地排序一个百万元素的 List,无论该 List 是否包含重复的元素。但这个比较存在严重缺陷。要使自己相信这一点,只需打印 `naturalOrder.compare(new Integer(42), new Integer(42))` 的值。两个 Integer 实例都表示相同的值 `(42)`,所以这个表达式的值应该是 0,但它是 1,这表明第一个 Integer 值大于第二个! - -  那么问题出在哪里呢?naturalOrder 中的第一个测试工作得很好。计算表达式 `i < j` 会使 i 和 j 引用的 Integer 实例自动拆箱;也就是说,它提取它们的基本类型值。计算的目的是检查得到的第一个 int 值是否小于第二个 int 值。但假设它不是。然后,下一个测试计算表达式 `i==j`,该表达式对两个对象引用执行标识比较。如果 i 和 j 引用表示相同 int 值的不同 Integer 实例,这个比较将返回 false,比较器将错误地返回 1,表明第一个整型值大于第二个整型值。**将 == 操作符应用于包装类型几乎都是错误的。** - -  在实际使用中,如果你需要一个比较器来描述类型的自然顺序,你应该简单地调用 `Comparator.naturalOrder()`,如果你自己编写一个比较器,你应该使用比较器构造方法,或者对基本类型使用静态比较方法(详见第 14 条)。也就是说,你可以通过添加两个局部变量来存储基本类型 int 值,并对这些变量执行所有的比较,从而修复损坏的比较器中的问题。这避免了错误的标识比较: - -```java -Comparator naturalOrder = (iBoxed, jBoxed) -> { - int i = iBoxed, j = jBoxed; // Auto-unboxing - return i < j ? -1 : (i == j ? 0 : 1); -}; -``` - -  接下来,考虑一下这个有趣的小程序: - -```java -public class Unbelievable { -static Integer i; -public static void main(String[] args) { - if (i == 42) - System.out.println("Unbelievable"); - } -} -``` - -  不,它不会打印出令人难以置信的东西,但它的行为很奇怪。它在计算表达式 `i==42` 时抛出 NullPointerException。问题是,i 是 Integer,而不是 int 数,而且像所有非常量对象引用字段一样,它的初值为 null。当程序计算表达式 `i==42` 时,它是在比较 Integer 与 int。**在操作中混合使用基本类型和包装类型时,包装类型就会自动拆箱**,这种情况无一例外。如果一个空对象引用自动拆箱,那么你将得到一个 NullPointerException。正如这个程序所演示的,它几乎可以在任何地方发生。修复这个问题非常简单,只需将 i 声明为 int 而不是 Integer。 - -  最后,考虑条目 6 中第 24 页的程序: - -```java -// Hideously slow program! Can you spot the object creation? -public static void main(String[] args) { - Long sum = 0L; - for (long i = 0; i < Integer.MAX_VALUE; i++) { - sum += i; - } - System.out.println(sum); -} -``` - -  这个程序比它预期的速度慢得多,因为它意外地声明了一个局部变量 `(sum)`,它是包装类型 Long,而不是基本类型 long。程序在没有错误或警告的情况下编译,变量被反复装箱和拆箱,导致产生明显的性能下降。 - -  在本条目中讨论的所有三个程序中,问题都是一样的:程序员忽略了基本类型和包装类型之间的区别,并承担了恶果。在前两个项目中,结果是彻底的失败;第三个例子还产生了严重的性能问题。 - -  那么,什么时候应该使用包装类型呢?它们有几个合法的用途。第一个是作为集合中的元素、键和值。不能将基本类型放在集合中,因此必须使用包装类型。这是一般情况下的特例。在参数化类型和方法(Chapter 5)中,必须使用包装类型作为类型参数,因为 Java 不允许使用基本类型。例如,不能将变量声明为 `ThreadLocal` 类型,因此必须使用 `ThreadLocal`。最后,在进行反射方法调用时,必须使用包装类型(详见第 65 条)。 - -  总之,只要有选择,就应该优先使用基本类型,而不是包装类型。基本类型更简单、更快。如果必须使用包装类型,请小心!**自动装箱减少了使用包装类型的冗长,但没有减少危险。** 当你的程序使用 `==` 操作符比较两个包装类型时,它会执行标识比较,这几乎肯定不是你想要的。当你的程序执行包含包装类型和基本类型的混合类型计算时,它将进行拆箱,**当你的程序执行拆箱时,将抛出 NullPointerException。** 最后,当你的程序将基本类型装箱时,可能会导致代价高昂且不必要的对象创建。 -# 62. 当使用其他类型更合适时应避免使用字符串 - -  字符串被设计用来表示文本,它们在这方面做得很好。因为字符串是如此常见,并且受到 Java 的良好支持,所以很自然地会将字符串用于其他目的,而不是它们适用的场景。本条目讨论了一些不应该使用字符串的场景。 - -  **字符串是其他值类型的糟糕替代品。** 当一段数据从文件、网络或键盘输入到程序时,它通常是字符串形式的。有一种很自然的倾向是保持这种格式不变,但是这种倾向只有在数据本质上是文本的情况下才合理。如果是数值类型,则应将其转换为适当的数值类型,如 int、float 或 BigInteger。如果是问题的答案,如「是」或「否」这类形式,则应将其转换为适当的枚举类型或布尔值。更一般地说,如果有合适的值类型,无论是基本类型还是对象引用,都应该使用它;如果没有,你应该写一个。虽然这条建议似乎很多余,但经常被违反。 - -  **字符串是枚举类型的糟糕替代品。** 正如条目 34 中所讨论的,枚举类型常量比字符串更适合于枚举类型常量。 - -  **字符串是聚合类型的糟糕替代品。** 如果一个实体有多个组件,将其表示为单个字符串通常是一个坏主意。例如,下面这行代码来自一个真实的系统标识符,它的名称已经被更改,以免引发罪责: - -```java -// Inappropriate use of string as aggregate type -String compoundKey = className + "#" + i.next(); -``` - -  这种方法有很多缺点。如果用于分隔字段的字符出现在其中一个字段中,可能会导致混乱。要访问各个字段,你必须解析字符串,这是缓慢的、冗长的、容易出错的过程。你不能提供 equals、toString 或 compareTo 方法,但必须接受 String 提供的行为。更好的方法是编写一个类来表示聚合,通常是一个私有静态成员类(详见第 24 条)。 - -  **字符串不能很好地替代 capabilities。** 有时,字符串用于授予对某些功能的访问权。例如,考虑线程本地变量机制的设计。这样的机制提供了每个线程都有自己的变量值。自 1.2 版以来,Java 库就有了一个线程本地变量机制,但在此之前,程序员必须自己设计。许多年前,当面临设计这样一个机制的任务时,有人提出了相同的设计,其中客户端提供的字符串键,用于标识每个线程本地变量: - -```java -// Broken - inappropriate use of string as capability! -public class ThreadLocal { - private ThreadLocal() { } // Noninstantiable - - // Sets the current thread's value for the named variable. - public static void set(String key, Object value); - - // Returns the current thread's value for the named variable. - public static Object get(String key); -} -``` - -  这种方法的问题在于,字符串键表示线程本地变量的共享全局名称空间。为了使这种方法有效,客户端提供的字符串键必须是惟一的:如果两个客户端各自决定为它们的线程本地变量使用相同的名称,它们无意中就会共享一个变量,这通常会导致两个客户端都失败。而且,安全性很差。恶意客户端可以故意使用与另一个客户端相同的字符串密钥来非法访问另一个客户端的数据。 - -  这个 API 可以通过用一个不可伪造的键(有时称为 capability)替换字符串来修复: - -```java -public class ThreadLocal { - private ThreadLocal() { } // Noninstantiable - - public static class Key { // (Capability) - Key() { } -} - -// Generates a unique, unforgeable key -public static Key getKey() { - return new Key(); -} - -public static void set(Key key, Object value); - -public static Object get(Key key); -} -``` - -  虽然这解决了 API 中基于字符串的两个问题,但是你可以做得更好。你不再真正需要静态方法。它们可以变成键上的实例方法,此时键不再是线程局部变量:而是线程局部变量。此时,顶层类不再为你做任何事情,所以你可以删除它,并将嵌套类重命名为 ThreadLocal: - -```java -public final class ThreadLocal { - public ThreadLocal(); - public void set(Object value); - public Object get(); -} -``` - -  这个 API 不是类型安全的,因为在从线程本地变量检索值时,必须将值从 Object 转换为它的实际类型。原始的基于 String 类型 API 的类型安全是不可能实现的,基于键的 API 的类型安全也是很难实现的,但是通过将 ThreadLocal 作为一个参数化的类来实现这个 API 的类型安全很简单(详见第 29 条): - -```java -public final class ThreadLocal { - public ThreadLocal(); - public void set(T value); - public T get(); -} -``` - -  粗略地说,这就是 `java.lang.ThreadLocal` 提供的 API,除了解决基于字符串的问题之外,它比任何基于键的 API 都更快、更优雅。 - -  总之,当存在或可以编写更好的数据类型时,应避免将字符串用来表示对象。如果使用不当,字符串比其他类型更麻烦、灵活性更差、速度更慢、更容易出错。字符串经常被误用的类型包括基本类型、枚举和聚合类型。 -# 63. 当心字符串连接引起的性能问题 - -  字符串连接操作符 `(+)` 是将几个字符串组合成一个字符串的简便方法。对于生成单行输出或构造一个小的、固定大小的对象的字符串表示形式,它是可以的,但是它不能伸缩。使用 **字符串串联运算符重复串联 n 个字符串需要 n 的平方级时间。** 这是字符串不可变这一事实导致的结果([Item-17](https://github.com/clxering/Effective-Java-3rd-edition-Chinese-English-bilingual/blob/master/Chapter-4/Chapter-4-Item-17-Minimize-mutability.md))。当连接两个字符串时,将复制这两个字符串的内容。 - -例如,考虑这个方法,它通过将每个账单项目重复连接到一行来构造账单语句的字符串表示: - -```java -// Inappropriate use of string concatenation - Performs poorly! -public String statement() { - String result = ""; - for (int i = 0; i < numItems(); i++) - result += lineForItem(i); // String concatenation - return result; -} -``` - -  如果项的数量很大,则该方法的性能非常糟糕。**要获得能接受的性能,请使用 StringBuilder 代替 String** 来存储正在构建的语句: - -```java -public String statement() { - StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH); - for (int i = 0; i < numItems(); i++) - b.append(lineForItem(i)); - return b.toString(); -} -``` - -  自 Java 6 以来,为了使字符串连接更快,已经做了大量工作,但是这两个方法在性能上的差异仍然很大:如果 numItems 返回 100,lineForItem 返回 80 个字符串,那么第二个方法在我的机器上运行的速度是第一个方法的 6.5 倍。由于第一种方法在项目数量上是平方级的,而第二种方法是线性的,所以随着项目数量的增加,性能差异会变得越来越大。注意,第二个方法预先分配了一个足够大的 StringBuilder 来保存整个结果,从而消除了自动增长的需要。即使使用默认大小的 StringBuilder,它仍然比第一个方法快 5.5 倍。 - -  道理很简单:**不要使用字符串连接操作符合并多个字符串**,除非性能无关紧要。否则使用 StringBuilder 的 append 方法。或者,使用字符数组,再或者一次只处理一个字符串,而不是组合它们。 -# 64. 通过接口引用对象 - -  条目 51 指出,应该使用接口而不是类作为参数类型。更一般地说,你应该优先使用接口而不是类来引用对象。**如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。** 惟一真正需要引用对象的类的时候是使用构造函数创建它的时候。为了具体说明这一点,考虑 LinkedHashSet 的情况,它是 Set 接口的一个实现。声明时应养成这样的习惯: - -``` -// Good - uses interface as type -Set sonSet = new LinkedHashSet<>(); -``` - -  而不是这样: - -``` -// Bad - uses class as type! -LinkedHashSet sonSet = new LinkedHashSet<>(); -``` - -  **如果你养成了使用接口作为类型的习惯,那么你的程序将更加灵活。** 如果你决定要切换实现,只需在构造函数中更改类名(或使用不同的静态工厂)。例如,第一个声明可以改为: - -``` -Set sonSet = new HashSet<>(); -``` - -  所有的代码都会继续工作。周围的代码不知道旧的实现类型,所以它不会在意更改。 - -  有一点值得注意:如果原实现提供了接口的通用约定不需要的一些特殊功能,并且代码依赖于该功能,那么新实现提供相同的功能就非常重要。例如,如果围绕第一个声明的代码依赖于 LinkedHashSet 的排序策略,那么在声明中将 HashSet 替换为 LinkedHashSet 将是不正确的,因为 HashSet 不保证迭代顺序。 - -  那么,为什么要更改实现类型呢?因为第二个实现比原来的实现提供了更好的性能,或者因为它提供了原来的实现所缺乏的理想功能。例如,假设一个字段包含一个 HashMap 实例。将其更改为 EnumMap 将为迭代提供更好的性能和与键的自然顺序,但是你只能在键类型为 enum 类型的情况下使用 EnumMap。将 HashMap 更改为 LinkedHashMap 将提供可预测的迭代顺序,性能与 HashMap 相当,而不需要对键类型作出任何特殊要求。 - -  你可能认为使用变量的实现类型声明变量是可以的,因为你可以同时更改声明类型和实现类型,但是不能保证这种更改会正确编译程序。如果客户端代码对原实现类型使用了替换时不存在的方法,或者客户端代码将实例传递给需要原实现类型的方法,那么在进行此更改之后,代码将不再编译。使用接口类型声明变量可以保持一致。 - -  **如果没有合适的接口存在,那么用类引用对象是完全合适的。** 例如,考虑值类,如 String 和 BigInteger。值类很少在编写时考虑到多个实现。它们通常是 final 的,很少有相应的接口。使用这样的值类作为参数、变量、字段或返回类型非常合适。 - -  没有合适接口类型的第二种情况是属于框架的对象,框架的基本类型是类而不是接口。如果一个对象属于这样一个基于类的框架,那么最好使用相关的基类来引用它,这通常是抽象的,而不是使用它的实现类。在 java.io 类中许多诸如 OutputStream 之类的就属于这种情况。 - -  没有合适接口类型的最后一种情况是,实现接口但同时提供接口中不存在的额外方法的类,例如,PriorityQueue 有一个在 Queue 接口上不存在的比较器方法。只有当程序依赖于额外的方法时,才应该使用这样的类来引用它的实例,这种情况应该非常少见。 - -  这三种情况并不是面面俱到的,而仅仅是为了传达适合通过类引用对象的情况。在实际应用中,给定对象是否具有适当的接口应该是显而易见的。如果是这样,如果使用接口引用对象,程序将更加灵活和流行。**如果没有合适的接口,就使用类层次结构中提供所需功能的最底层的类** -# 65. 接口优于反射 - -  核心反射机制 `java.lang.reflect` 提供对任意类的编程访问。给定一个 Class 对象,你可以获得 Constructor、Method 和 Field 实例,分别代表了该 Class 实例所表示的类的构造器、方法和字段。这些对象提供对类的成员名、字段类型、方法签名等的编程访问。 - -  此外,Constructor、Method 和 Field 实例允许你反射性地操作它们的底层对应项:你可以通过调用 Constructor、Method 和 Field 实例上的方法,可以构造底层类的实例、调用底层类的方法,并访问底层类中的字段。例如,Method.invoke 允许你在任何类的任何对象上调用任何方法(受默认的安全约束)。反射允许一个类使用另一个类,即使在编译前者时后者并不存在。然而,这种能力是有代价的: - -- **你失去了编译时类型检查的所有好处,** 包括异常检查。如果一个程序试图反射性地调用一个不存在的或不可访问的方法,它将在运行时失败,除非你采取了特殊的预防措施。 -- **执行反射访问所需的代码既笨拙又冗长。** 写起来很乏味,读起来也很困难。 -- **性能降低。** 反射方法调用比普通方法调用慢得多。到底慢了多少还很难说,因为有很多因素在起作用。在我的机器上,调用一个没有输入参数和返回 int 类型的方法时,用反射执行要慢 11 倍。 - -  有一些复杂的应用程序需要反射。包括代码分析工具和依赖注入框架。即使是这样的工具,随着它的缺点变得越来越明显,人们也在逐渐远离并反思这种用法。如果你对应用程序是否需要反射有任何疑问,那么它可能不需要。 - -  **通过非常有限的形式使用反射,你可以获得反射的许多好处,同时花费的代价很少。** 对于许多程序,它们必须用到在编译时无法获取的类,在编译时存在一个适当的接口或超类来引用该类(详见第 64 条)。如果是这种情况,**可以用反射方式创建实例,并通过它们的接口或超类正常地访问它们。** - -  例如,这是一个创建 `Set` 实例的程序,类由第一个命令行参数指定。程序将剩余的命令行参数插入到集合中并打印出来。不管第一个参数是什么,程序都会打印剩余的参数,并去掉重复项。然而,打印这些参数的顺序取决于第一个参数中指定的类。如果你指定 `java.util.HashSet`,它们显然是随机排列的;如果你指定 `java.util.TreeSet`,它们是按字母顺序打印的,因为 TreeSet 中的元素是有序的: - -```java -// Reflective instantiation with interface access -public static void main(String[] args) { - - // Translate the class name into a Class object - Class> cl = null; - try { - cl = (Class>) // Unchecked cast! - Class.forName(args[0]); - } catch (ClassNotFoundException e) { - fatalError("Class not found."); - } - - // Get the constructor - Constructor> cons = null; - try { - cons = cl.getDeclaredConstructor(); - } catch (NoSuchMethodException e) { - fatalError("No parameterless constructor"); - } - - // Instantiate the set - Set s = null; - try { - s = cons.newInstance(); - } catch (IllegalAccessException e) { - fatalError("Constructor not accessible"); - } catch (InstantiationException e) { - fatalError("Class not instantiable."); - } catch (InvocationTargetException e) { - fatalError("Constructor threw " + e.getCause()); - } catch (ClassCastException e) { - fatalError("Class doesn't implement Set"); - } - - // Exercise the set - s.addAll(Arrays.asList(args).subList(1, args.length)); - System.out.println(s); -} - -private static void fatalError(String msg) { - System.err.println(msg); - System.exit(1); -} -``` - -  虽然这个程序只是一个小把戏,但它演示的技术非常强大。这个程序可以很容易地转换成一个通用的集合测试器,通过积极地操作一个或多个实例并检查它们是否遵守 Set 接口约定来验证指定的 Set 实现。类似地,它可以变成一个通用的集合性能分析工具。事实上,该技术足够强大,可以实现一个成熟的服务提供者框架(详见第 1 条)。 - -  这个例子也说明了反射的两个缺点。首先,该示例可以在运行时生成六个不同的异常,如果没有使用反射实例化,所有这些异常都将是编译时错误。(有趣的是,你可以通过传入适当的命令行参数,使程序生成六个异常中的每一个。)第二个缺点是,根据类的名称生成类的实例需要 25 行冗长的代码,而构造函数调用只需要一行。通过捕获 ReflectiveOperationException(Java 7 中引入的各种反射异常的超类),可以减少程序的长度。这两个缺点都只限于实例化对象的程序部分。实例化后,与任何其他 Set 实例将难以区分。在实际的程序中,通过这种限定使用反射的方法,大部分代码可以免受影响。 - -  如果编译此程序,将得到 unchecked 的强制转换警告。这个警告是合法的,即使指定的类不是 Set 实现,`Class>` 也会成功,在这种情况下,程序在实例化类时抛出 ClassCastException。要了解如何抑制警告,请阅读条目 27。 - -  反射的合法用途(很少)是管理类对运行时可能不存在的其他类、方法或字段的依赖关系。如果你正在编写一个包,并且必须针对其他包的多个版本运行,此时反射将非常有用。该技术是根据支持包所需的最小环境(通常是最老的版本)编译包,并反射性地访问任何较新的类或方法。如果你试图访问的新类或方法在运行时不存在,要使此工作正常进行,则必须采取适当的操作。适当的操作可能包括使用一些替代方法来完成相同的目标,或者使用简化的功能进行操作。 - -  总之,反射是一种功能强大的工具,对于某些复杂的系统编程任务是必需的,但是它有很多缺点。如果编写的程序必须在编译时处理未知的类,则应该尽可能只使用反射实例化对象,并使用在编译时已知的接口或超类访问对象。 -# 66. 明智审慎地本地方法 - -  Java 本地接口(JNI)允许 Java 程序调用本地方法,这些方法是用 C 或 C++ 等本地编程语言编写的。从历史上看,本地方法主要有三种用途。它们提供对特定于平台的设施(如注册中心)的访问。它们提供对现有本地代码库的访问,包括提供对遗留数据访问。最后,本地方法可以通过本地语言编写应用程序中注重性能的部分,以提高性能。 - -  使用本地方法访问特定于平台的机制是合法的,但是很少有必要:随着 Java 平台的成熟,它提供了对许多以前只能在宿主平台中上找到的特性。例如,Java 9 中添加的流 API 提供了对 OS 流程的访问。在 Java 中没有等效库时,使用本地方法来使用本地库也是合法的。 - -  **为了提高性能,很少建议使用本地方法。** 在早期版本(Java 3 之前),这通常是必要的,但是从那时起 JVM 变得更快了。对于大多数任务,现在可以在 Java 中获得类似的性能。例如,在版本 1.1 中添加了 `java.math`,BigInteger 是在一个用 C 编写的快速多精度运算库的基础上实现的。在当时,为了获得足够的性能这样做是必要的。在 Java 3 中,BigInteger 则完全用 Java 重写了,并且进行了性能调优,新的版本比原来的版本更快。 - -  这个故事的一个可悲的结尾是,除了在 Java 8 中对大数进行更快的乘法运算之外,BigInteger 此后几乎没有发生什么变化。在此期间,对本地库的工作继续快速进行,尤其是 GNU 多精度算术库(GMP)。需要真正高性能多精度算法的 Java 程序员现在可以通过本地方法使用 GMP [Blum14]。 - -  使用本地方法有严重的缺点。由于本地语言不安全(详见第 50 条),使用本地方法的应用程序不再能免受内存毁坏错误的影响。由于本地语言比 Java 更依赖于平台,因此使用本地方法的程序的可移植性较差。它们也更难调试。如果不小心,本地方法可能会降低性能,因为垃圾收集器无法自动跟踪本地内存使用情况(详见第 8 条),而且进出本地代码会产生相关的成本。最后,本地方法需要「粘合代码」,这很难阅读,而且编写起来很乏味。 - -  总之,在使用本地方法之前要三思。一般很少需要使用它们来提高性能。如果必须使用本地方法来访问底层资源或本地库,请尽可能少地使用本地代码,并对其进行彻底的测试。本地代码中的一个错误就可以破坏整个应用程序。 -# 67. 明智审慎地进行优化 - -  有三条关于优化的格言是每个人都应该知道的: - -> 比起其他任何单一的原因(包括盲目的愚蠢),很多计算上的过失都被归昝于效率(不一定能实现)。 -> -> ​ —William A. Wulf [Wulf72] -> -> 不要去计较效率上的一些小小的得失,在 97% 的情况下,不成熟的优化才是一切问题的根源。 -> -> ​ —Donald E. Knuth [Knuth74] -> -> 在优化方面,我们应该遵守两条规则: -> -> ​ 规则 1:不要进行优化。 -> -> ​ 规则 2 (仅针对专家):还是不要进行优化,也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。 -> -> ​ —M. A. Jackson [Jackson75] - -  所有这些格言都比 Java 编程语言早了 20 年。它们告诉我们关于优化的一个深刻的事实:很容易弊大于利,尤其是如果过早地进行优化。在此过程中,你可能会生成既不快速也不正确且无法轻松修复的软件。 - -  不要为了性能而牺牲合理的架构。努力编写 **好的程序,而不是快速的程序。** 如果一个好的程序不够快,它的架构将允许它被优化。好的程序体现了信息隐藏的原则:在可能的情况下,它们在单个组件中本地化设计决策,因此可以在不影响系统其余部分的情况下更改单个决策(详见第 15 条)。 - -  这并不意味着在程序完成之前可以忽略性能问题。实现上的问题可以通过以后的优化来解决,但是对于架构缺陷,如果不重写系统,就不可能解决限制性能的问题。在系统完成之后再改变设计的某个基本方面可能导致结构不良的系统难以维护和进化。因此,你必须在设计过程中考虑性能。 - -  **尽量避免限制性能的设计决策。** 设计中最难以更改的组件是那些指定组件之间以及与外部世界的交互的组件。这些设计组件中最主要的是 API、线路层协议和持久数据格式。这些设计组件不仅难以或不可能在事后更改,而且所有这些组件都可能对系统能够达到的性能造成重大限制。 - -  **考虑API设计决策的性能结果。** 使公共类型转化为可变,可能需要大量不必要的防御性复制(详见第 50 条)。类似地,在一个公共类中使用继承(在这个类中组合将是合适的)将该类永远绑定到它的超类,这会人为地限制子类的性能(详见第 18 条)。最后一个例子是,在 API 中使用实现类而不是接口将你绑定到特定的实现,即使将来可能会编写更快的实现也无法使用(详见第 64 条)。 - -  API 设计对性能的影响是非常实际的。考虑 `java.awt.Component` 中的 getSize 方法。该性能很关键方法返回 Dimension 实例的决定,加上维度实例是可变的决定,强制该方法的任何实现在每次调用时分配一个新的 Dimension 实例。尽管在现代 VM 上分配小对象并不昂贵,但不必要地分配数百万个对象也会对性能造成实际损害。 - -  存在几种 API 设计替代方案。理想情况下,Dimension 应该是不可变的(详见第 17 条);或者,getSize 可以被返回 Dimension 对象的原始组件的两个方法所替代。事实上,出于性能原因,在 Java 2 的组件中添加了两个这样的方法。然而,现有的客户端代码仍然使用 getSize 方法,并且仍然受到原始 API 设计决策的性能影响。 - -  幸运的是,通常情况下,好的 API 设计与好的性能是一致的。**为了获得良好的性能而改变 API 是一个非常糟糕的想法。** 导致你改变 API 的性能问题,可能在平台或其他底层软件的未来版本中消失,但是改变的 API 和随之而来的问题将永远伴随着你。 - -  一旦你仔细地设计了你的程序,成了一个清晰、简洁、结构良好的实现,那么可能是时候考虑优化了,假设此时你还不满意程序的性能。 - -  记得 Jackson 的两条优化规则是「不要做」和「(只针对专家)」。先别这么做。他本可以再加一个:**在每次尝试优化之前和之后测量性能。** 你可能会对你的发现感到惊讶。通常,试图做的优化通常对于性能并没有明显的影响;有时候,还让事情变得更糟。主要原因是很难猜测程序将时间花费在哪里。程序中你认为很慢的部分可能并没有问题,在这种情况下,你是在浪费时间来优化它。一般认为,程序将 90% 的时间花费在了 10% 的代码上。 - -  分析工具可以帮助你决定将优化工作的重点放在哪里。这些工具提供了运行时信息,比如每个方法大约花费多少时间以及调用了多少次。除了关注你的调优工作之外,这还可以提醒你是否需要改变算法。如果程序中潜伏着平方级(或更差)的算法,那么再多的调优也无法解决这个问题。你必须用一个更有效的算法来代替这个算法。系统中的代码越多,使用分析器就越重要。这就像大海捞针:大海越大,金属探测器就越有用。另一个值得特别提及的工具是 jmh,它不是一个分析器,而是一个微基准测试框架,提供了对 Java 代码性能无与伦比的预测性。 - -  与 C 和 C++ 等更传统的语言相比,Java 甚至更需要度量尝试优化的效果,因为 Java 的性能模型更弱:各种基本操作的相对成本没有得到很好的定义。程序员编写的内容和 CPU 执行的内容之间的「抽象鸿沟」更大,这使得可靠地预测优化的性能结果变得更加困难。有很多关于性能的传说流传开来,但最终被证明是半真半假或彻头彻尾的谎言。 - -  Java 的性能模型不仅定义不清,而且在不同的实现、不同的发布版本、不同的处理器之间都有所不同。如果你要在多个实现或多个硬件平台上运行程序,那么度量优化对每个平台的效果是很重要的。有时候,你可能会被迫在不同实现或硬件平台上的性能之间进行权衡。 - -  自本条目首次编写以来的近 20 年里,Java 软件栈的每个组件都变得越来越复杂,从处理器到 vm 再到库,Java 运行的各种硬件都有了极大的增长。所有这些加在一起,使得 Java 程序的性能比 2001 年更难以预测,而对它进行度量的需求也相应增加。 - -  总而言之,不要努力写快的程序,要努力写好程序;速度自然会提高。但是在设计系统时一定要考虑性能,特别是在设计API、线路层协议和持久数据格式时。当你完成了系统的构建之后,请度量它的性能。如果足够快,就完成了。如果没有,利用分析器找到问题的根源,并对系统的相关部分进行优化。第一步是检查算法的选择:再多的底层优化也不能弥补算法选择的不足。根据需要重复这个过程,在每次更改之后测量性能,直到你满意为止。 -# 68. 遵守被广泛认可的命名约定 - -  Java 平台有一组完善的命名约定,其中许多约定包含在《The Java Language Specification》[JLS, 6.1]。不严格地讲,命名约定分为两类:排版和语法。 - -  有少量的与排版有关的命名约定,包括包、类、接口、方法、字段和类型变量。如果没有很好的理由,你不应该违反它们。如果 API 违反了这些约定,那么它可能很难使用。如果实现违反了这些规则,可能很难维护。在这两种情况下,违规都有可能使其他使用代码的程序员感到困惑和恼怒,并使他们做出错误的假设,从而导致错误。本条目概述了各项约定。 - -  包名和模块名应该是分层的,组件之间用句点分隔。组件应该由小写字母组成,很少使用数字。任何在你的组织外部使用的包,名称都应该以你的组织的 Internet 域名开头,并将组件颠倒过来,例如,edu.cmu、com.google、org.eff。以 java 和 javax 开头的标准库和可选包是这个规则的例外。用户不能创建名称以 java 或 javax 开头的包或模块。将 Internet 域名转换为包名前缀的详细规则可以在《The Java Language Specification》[JLS, 6.1] 中找到。 - -  包名的其余部分应该由描述包的一个或多个组件组成。组件应该很短,通常为 8 个或更少的字符。鼓励使用有意义的缩写,例如 util 而不是 utilities。缩写词是可以接受的,例如 awt。组件通常应该由一个单词或缩写组成。 - -  除了 Internet 域名之外,许多包的名称只有一个组件。附加组件适用于大型工具包,这些工具包的大小要求将其分解为非正式的层次结构。例如 `javax.util` 包具有丰富的包层次结构,包的名称如 `java.util.concurrent.atomic`。这样的包称为子包,尽管 Java 几乎不支持包层次结构。 - -  类和接口名称,包括枚举和注释类型名称,应该由一个或多个单词组成,每个单词的首字母大写,例如 List 或 FutureTask。除了缩略语和某些常见的缩略语,如 max 和 min,缩略语应该避免使用。缩略语应该全部大写,还是只有首字母大写,存在一些分歧。虽然有些程序员仍然使用大写字母,但支持只将第一个字母大写的理由很充分:即使多个首字母缩写连续出现,你仍然可以知道一个单词从哪里开始,下一个单词从哪里结束。你希望看到哪个类名,HTTPURL 还是 HttpUrl? - -  方法和字段名遵循与类和接口名相同的排版约定,除了方法或字段名的第一个字母应该是小写,例如 remove 或 ensureCapacity。如果方法或字段名的首字母缩写出现在第一个单词中,那么它应该是小写的。 - -  前面规则的唯一例外是「常量字段」,它的名称应该由一个或多个大写单词组成,由下划线分隔,例如 VALUES 或 NEGATIVE_INFINITY。常量字段是一个静态的 final 字段,其值是不可变的。如果静态 final 字段具有基本类型或不可变引用类型(第17项),那么它就是常量字段。例如,枚举常量是常量字段。如果静态 final 字段有一个可变的引用类型,那么如果所引用的对象是不可变的,那么它仍然可以是一个常量字段。注意,常量字段是唯一推荐使用下划线用法的。 - -  局部变量名与成员名具有类似的排版命名约定,但允许使用缩写,也允许使用单个字符和短字符序列,它们的含义取决于它们出现的上下文,例如 i、denom、houseNum。输入参数是一种特殊的局部变量。它们的命名应该比普通的局部变量谨慎得多,因为它们的名称是方法文档的组成部分。 - -  类型参数名通常由单个字母组成。最常见的是以下五种类型之一:T 表示任意类型,E 表示集合的元素类型,K 和 V 表示 Map 的键和值类型,X 表示异常。函数的返回类型通常为 R。任意类型的序列可以是 T、U、V 或 T1、T2、T3。 - -  为了快速参考,下表显示了排版约定的示例。 - -| Identifier Type | Example | -| ------------------ | ---------------------------------------------------- | -| Package or module | `org.junit.jupiter.api`, `com.google.common.collect` | -| Class or Interface | Stream, FutureTask, LinkedHashMap,HttpClient | -| Method or Field | remove, groupingBy, getCrc | -| Constant Field | MIN_VALUE, NEGATIVE_INFINITY | -| Local Variable | i, denom, houseNum | -| Type Parameter | T, E, K, V, X, R, U, V, T1, T2 | - -  语法命名约定比排版约定更灵活,也更有争议。包没有语法命名约定。可实例化的类,包括枚举类型,通常使用一个或多个名词短语来命名,例如 Thread、PriorityQueue 或 ChessPiece。不可实例化的实用程序类(详见第 4 条)通常使用复数名词来命名,例如 collector 或 Collections。接口的名称类似于类,例如集合或比较器,或者以 able 或 ible 结尾的形容词,例如 Runnable、Iterable 或 Accessible。因为注解类型有很多的用途,所以没有哪部分占主导地位。名词、动词、介词和形容词都很常见,例如,BindingAnnotation、Inject、ImplementedBy 或 Singleton。 - -  执行某些操作的方法通常用动词或动词短语(包括对象)命名,例如,append 或 drawImage。返回布尔值的方法的名称通常以单词 is 或 has(通常很少用)开头,后面跟一个名词、一个名词短语,或者任何用作形容词的单词或短语,例如 isDigit、isProbablePrime、isEmpty、isEnabled 或 hasSiblings。 - -  返回被调用对象的非布尔函数或属性的方法通常使用以 get 开头的名词、名词短语或动词短语来命名,例如 size、hashCode 或 getTime。有一种说法是,只有第三种形式(以 get 开头)才是可接受的,但这种说法几乎没有根据。前两种形式的代码通常可读性更强,例如: - -```java -if (car.speed() > 2 * SPEED_LIMIT) - generateAudibleAlert("Watch out for cops!"); -``` - -  以 get 开头的表单起源于基本过时的 Java bean 规范,该规范构成了早期可重用组件体系结构的基础。有一些现代工具仍然依赖于 bean 命名约定,你应该可以在任何与这些工具一起使用的代码中随意使用它。如果类同时包含相同属性的 setter 和 getter,则遵循这种命名约定也有很好的先例。在本例中,这两个方法通常被命名为 getAttribute 和 setAttribute。 - -  一些方法名称值得特别注意。转换对象类型(返回不同类型的独立对象)的实例方法通常称为 toType,例如 toString 或 toArray。返回与接收对象类型不同的视图(详见第 6 条)的方法通常称为 asType,例如 asList。返回与调用它们的对象具有相同值的基本类型的方法通常称为类型值,例如 intValue。静态工厂的常见名称包括 from、of、valueOf、instance、getInstance、newInstance、getType 和 newType(详见第 1 条,第 9 页)。 - -  字段名的语法约定没有类、接口和方法名的语法约定建立得好,也不那么重要,因为设计良好的 API 包含很少的公开字段。类型为 boolean 的字段的名称通常类似于 boolean 访问器方法,省略了初始值「is」,例如 initialized、composite。其他类型的字段通常用名词或名词短语来命名,如 height、digits 和 bodyStyle。局部变量的语法约定类似于字段的语法约定,但要求更少。 - -  总之,将标准命名约定内在化,并将其作为第二性征来使用。排版习惯是直接的,而且在很大程度上是明确的;语法惯例更加复杂和松散。引用《The Java Language Specification》[JLS, 6.1] 中的话说,「如果长期以来的传统用法要求不遵循这些约定,就不应该盲目地遵循这些约定。」,应使用常识判断。 -# 69. 只针对异常的情况下才使用异常 - -  假如你某一天不走运的话,可能遇到如下代码: - -```java -/* Horrible abuse of exceptions. Don't ever do this! */ -try { - int i = 0; - while ( true ) - range[i++].climb(); -} catch ( ArrayIndexOutOfBoundsException e ) { -} -``` - -  这段代码有什用,看起来根本不明显,这正是它没有真正被使用的原因(详见 67 条)。事实证明,作为一个要对数组元素进行遍历的实现方式,它的构想是十分拙劣的。当这个循环企图访问数组边界之外的第一个数组元素的时候,使用 try-catch 并且忽略 ArrayIndexOutOfBoundsException 异常的手段来达到终止无限循环的目的。假定它与数组循环的标准模式是等价的,对于它的标准模式每个 Java 程序员都可以一眼辨认出来: - -```java -for ( Mountain m : range ) - m.climb(); -``` - -  那么为什么有人会企图使用基于异常的循环,而不是使用行之有效的模式呢?这是他们误以为可以使用 Java 的错误判断机制来提高程序性能,因为 VM 对每次数组访问都要检查越界情况,所以他们认为正常的循环终止测试被编译器隐藏了,但是在 for-each 中仍然可见,这是多余的并且应当避免。这种想法有三个错误: - - - 因为异常设计的初衷适用于不正常的情形,所有几乎没有 JVM 实现试图对他们进行优化,使它们与显式的测试一样快。 - - 把代码放在 try-catch 块中反而阻止了现代 JVM 实现本可能执行的某些特定优化。 - - 对数据进行遍历的标准模式并不会导致冗余的检查。有些 JVM 实现会将它们优化掉。 - -  实际上基于异常的模式比标准模式要慢得多。在我本地的机器上,对于一个有 100 个元素的数组进行遍历,标准模式比基于异常的模式快了 2 倍。 - -  基于异常的循环模式不仅模糊了代码的意图,降低了它的性能,而且它还不能保证正常工作!如果出现了不相关的 bug,这个模式会悄悄的消失从而掩盖了这个 Bug,极大地增加了调试过程的复杂性。假设循环体的计算过程中调用了一个方法,这个方法执行了对某个不相关数组的越界访问。如果使用合理的循环模式,这个 Bug 会产生未被捕捉的异常,从而导致线程立即结束,并产生完整的堆栈轨迹。如果使用这个被误导的基于异常的循环模式,与这个 Bug 相关的异常将会被捕捉到,并且被错误的解释为正常的循环终止条件。 - -  这个例子的教训很简单:顾名思义,**异常应该只用于异常的情况下;他们永远不应该用于正常的程序控制流程。** 一般的,应该优先使用标准的、容易理解的模式,而不是那些声称可以提供更好性能的、弄巧成拙的方法。即使真的能够改进性能,面对平台的不断改进,这种模型的性能优势也不可能一直保持。然而这种过度聪明的模式带来的微妙 Bug 和维护的痛苦将依旧存在。 - -  这条原则对于 API 设计也有启发**。设计良好的 API 不应该强迫它的客户端为了正常的控制流程而使用异常。**如果类中具有“状态相关”(state-dependent)的方法,即只有在特定的不可预知的条件下才可以被调用的方法,这个类往往也应该具有一个单独的“状态测试”(state-testing)方法,即表明是否可以调用这个状态相关的方法。比如 Iterator 接口含有状态相关的 next 方法,以及相应的状态测试方法 hasNext。这使得利用传统的 for 循环(以及 for-each 循环,在内部使用了 hasNext 方法)对集合进行迭代的标准模式成为可能。 - -```java -for ( Iterator i = collection.iterator(); i.hasNext(); ){ - Foo foo = i.next(); - ... -} -``` - -  如果 Iterator 缺少 hasNext 方法,客户端将被迫改用下面的做法: - -```java -/* Do not use this hideous code for iteration over a collection! */ -try { - Iterator i = collection.iterator(); - while ( true ) - { - Foo foo = i.next(); - ... - } -} catch ( NoSuchElementException e ) { -} -``` - -  这应该非常类似于本条目刚开始时对数据进行迭代的例子。除了代码繁琐令人误解之外,这个基于异常的模式可能执行起来也比标准模式更差,并且还可能掩盖系统中其他不相关部分的 Bug。 - -  另外一种提供单独状态测试的做法是,如果“状态相关”方法无法执行想要的计算,就可以让它返回一个零长度的 optional 值(详见第 55 条),或者返回一个可被识别的返回值,比如 null。 - -  对于“状态测试方法”和“optional 返回值或者可识别的返回值”这两种做法,有些指导原则可以帮助你在两者之间做出选择。如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,就必须使用“optional 返回值或者可识别的返回值”,因为在调用“状态测试”方法和调用对应的“状态相关”方法的时间间隔之中,对象的状态有可能发生变化。如果单独的“状态测试”方法必须重复“状态相关”方法的工作,从性能的角度考虑,就必须使用可被识别的返回值。如果其他方面都是等同的,那么“状态测试”方法则优于可被识别的返回值。他提供了相对更高的可读性,对于使用不当的情形可能更加易于检测和改正:如果忘了去调用状态测试方法,状态相关的方法就会抛出异常,使得这个 Bug 变得很明显;如果忘了去检查可识别的返回值,这个 Bug 就很难被发现。optional 返回值不会有这方面的问题。 - -  总而言之,异常是为了在异常情况下被设计和使用的。不要将它们勇于普通的控制流程,也不要编写迫使它们这么做的 API。 -# 70. 对可恢复的情况使用受检异常,对编程错误使用运行时异常 - -  Java 程序设计语言提供了三种 throwable:受检异常(checked exceptions)、运行时异常(runtime exceptions)和错误(errors)。程序员中存在着什么情况适合使用哪种 throwable 的困惑。虽然这种决定不总是那么清晰,但还是有一些一般性的原则提出了强有力的指导。 - -  在决定使用受检异常还是非受检异常时,主要的原则是: **如果期望调用者能够合理的恢复程序运行,对于这种情况就应该使用受检异常。** 通过抛出受检异常,强迫调用者在一个 catch 子句中处理该异常,或者把它传播出去。因此,方法中声明要抛出的每个受检异常都是对 API 用户的一个潜在提示:与异常相关联的条件是调用这个方法一种可能结果。 - -  API 的设计者让 API 用户面对受检异常,以此强制用户从这个异常条件条件中恢复。用户这可以忽视这样的强制要求,只需要捕获异常即可,但这往往不是个好办法(详见第 77 条)。 - -  有两种非受检的 throwable:运行时异常和错误。在行为上两者是等同的:它们都是不需要也不应该被捕获的 throwable。如果程序抛出非受检异常或者错误,往往属于不可恢复的情形,程序继续执行下去有害无益。如果程序没有捕捉到这样的 throwable,将会导致当前线程中断(halt),并且出现适当的错误消息。 - -  **用运行时异常来表明编程错误**。大多数运行时异常都表示前提违例(precondition violations)。所谓前提违例是指 API 的客户没有遵守 API 规范建立的约定。例如,数组访问的预定指明了数组的下标值必须在 0 和数组长度-1 之间。ArrayIndexOutOfBoundsException 表明违反了这个前提。 - -  这个建议有一个问题:对于要处理可恢复的条件,还是处理编程错误,情况并非总是那么黑白分明。例如,考虑资源枯竭的情形,这可能是由程序错误引起的,比如分配了一块不合理的过大数组,也可能确实是由于资源不足而引起的。如果资源枯竭是由于临时的短缺,或是临时需求太大造成的,这种情况可能是可恢复的。API 设计者需要判断这样的资源枯竭是否允许恢复。如果你相信一种情况可能允许回复,就使用受检异常;如果不是,则使用运行时异常。如果不清楚是否有可能恢复,最好使用非受检异常,原因参见 71 条。 - -  虽然 JLS(Java 语言规范)并没有要求,但是按照惯例,错误(Error)往往被 JVM 保留下来使用,以表明资源不足、约束失败,或者其他使程序无法继续执行的条件。由于这已经是个几乎被普遍接受的管理,因此最好不需要在实现任何新的 Error 的子类。因此,**你实现的所有非受检的 throwable 都应该是 RuntimeExceptiond 子类**(直接或者间接的)。不仅不应该定义 Error 的子类,也不应该抛出 AssertionError 异常。 - -  要想定义一个不是 Exception、RuntimeException 或者 Error 子类的 throwable,这也是有可能的。JLS 并没有直接规定这样的 throwable,而是隐式的指定了:从行为意义上讲,他们等同于普通的受检异常(即 Exception 的子类,但不是 RuntimeException 的子类)。那么什么时候应该使用这样的 throwable?一句话,永远也不会用到。它与普通的受检异常相比没有任何益处,还会困扰 API 的使用者。 - -  API 的设计者往往会忘记,异常也是一个完全意义上的对象,可是在它上面定义任何的方法。这些方法的主要用途是捕获异常的代码提供额外信息,特别是关于引发这个异常条件的信息。如果没有这样的方法,程序员必须要懂的如何解析“该异常的字符串表示法”,以便获得这些额外信息。这是极为不好的做法(详见 12 条)。类很少会指定它们的字符串表示法中的细节,因此对于不同的实现及不同的版本,字符串表示法也会大相径庭。由此可见,“解析异常的字符串表示法”的代码可能是不可移植的,也是非常脆弱的。 - -  因为受检异常往往指明了可恢复的条件,所以对于这样的异常,提供一些辅助方法尤其重要,通过这种方法调用者可以获得一些有助于程序恢复的信息。例如,假设因为用户资金不足,当他企图购买一张礼品卡时导致失败,于是抛出受检异常。这个异常应该提供一个访问方法,以便允许客户查询所缺的费用金额,使得使用者可以将这个数值传递给用户。关于这个主题的更多详情,参见 75 条。 - -  总而言之,对于可恢复的情况,要抛出受检异常;对于程序错误,就要抛出运行时异常。不确定是否可恢复,就跑出为受检异常。不要定义任何既不是受检异常也不是运行异常的抛出类型。要在受检异常上提供方法,以便协助程序恢复。 - -# 71. 避免不必要的使用受检异常 - -  Java 程序员不喜欢受检异常,但是如果使用得当,它们可以改善 API 和程序。不返回码和未受检异常的是,它们强迫程序员处理异常的条件,大大增强了可靠性。也就是说,过分使用受检异常会使 API 使用起来非常不方便。如果方法抛出受检异常,调用该方法代码就必须在一个或者多个 catch 块中处理这些异常,或者它必须声明抛出这些异常,并让它们传播出去。无论使用哪一种方法,都给程序员增添了不可忽视的负担。这种负担在 Java 8 中更重了,因为抛出受检异常的方法不能直接在 Stream 中使用(详见第 45 条至第 48 条)。 - -  如果正确地使用 API 并不能阻止这种异常条件的产生,并且一旦产生异常,使用 API 的程序员可以立即采取有用的动作,这种负担就被认为是正当的。除非这两个条件都成立,否则更适合于使用未受检异常。作为一个石蕊测试(石蕊测试是指简单而具有决定性的测试),你可以试着问自己:程序员将如何处理该异常。下面的做法是最好的吗? - -```java -} catch ( TheCheckedException e ) { - throw new AssertionError(); /* Can't happen! */ -} -``` - -  下面这种做法又如何? - -```java -} catch ( TheCheckedException e ) { - e.printStackTrace(); /* Oh well, we lose. */ - System.exit( 1 ); -} -``` - -  如果使用 API 的程序员无法做得比这更好那么未受检的异常可能更为合适。 - -  如果方法抛出的受检异常是唯一的,它给程序员带来的额外负担就会非常高。如果这个方法还有其他的受检异常,该方法被调用的时候,必须已经出现在一个 try 块中,所以这个异常只需要另外一个 catch 块。如果方法只抛出一个受检异常,单独这一个异常就表示:该方法必须放置于一个 try 块中,并且不能在 Stream 中直接使用。这种情况下,应该问问自己,是否还有别的途径可以避免使用受检异常。 - -  除受检异常最容易的方法是,返回所要的结果类型的一个 optional(详见第 55 条)。这个方法不抛出受检异常,而只是返回一个零长度的 optional。这种方法的缺点是,方法无法返回任何额外的信息,来详细说明它无法执行你想要的计算。相反,异常则具有描述性的类型,并且能够导出方法,以提供额外的信息(详见第 70 条)。 - -  “把受检异常变成未受检异常”的一种方法是,把这个抛出异常的方法分成两个方法,其中第一个方法返回一个 boolean 值,表明是否应该抛出异常。这种 API 重构,把下面的调用序列: - -```java -/* Invocation with checked exception */ -try { - obj.action( args ); -} catch ( TheCheckedException e ) { - ... /* Handle exceptional condition */ -} -``` - -  重构为: - -```java -/* Invocation with state-testing method and unchecked exception */ -if ( obj.actionPermitted( args ) ) { - obj.action( args ); -} else { - ... /* Handle exceptional condition */ -} -``` - -  这种重构并非总是恰当的,但是,凡是在恰当的地方,它都会使 API 用起来更加舒服。虽然后者的调用序列没有前者漂亮,但是这样得到的 API 更加灵活。如果程序员知道调用将会成功,或者不介意由于调用失败而导致的线程终止,这种重构还允许以下这个更为简单的调用形式: - -```java -obj.action(args); -``` - -  如果你怀疑这个简单的调用序列是否符合要求,这个 API 重构可能就是恰当的。这样重构之后的 API 在本质上等同于第 69 条中的“状态测试方法”,并且同样的告诫依然适用:如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,这种重构就是不恰当的,因为在 actionPermitted 和 action 这两个调用的时间间隔之中,对象的状态有可能会发生变化。如果单独的 actionPermitted 方法必须重复 action 方法的工作,出于性能的考虑,这种 API 重构就不值得去做。 - -  总而言之,在谨慎使用的前提之下,受检异常可以提升程序的可读性;如果过度使用,将会使 API 使用起来非常痛苦。如果调用者无法恢复失败,就应该抛出未受检异常。如果可以恢复,并且想要迫使调用者处理异常的条件,首选应该返回一个 optional 值。当且仅当万一失败时,这些无法提供足够的信息,才应该抛出受检异常。 -# 72. 优先使用标准的异常 - -  专家级程序员与缺乏经验的程序员一个最主要的区别在于,专家追求并且通常也能够实现高度的代码重用。代码重用是值得提倡的,这是一条通用的规则,异常也不例外。Java 平台类库提供了一组基本的未受检异常,它们满足了绝大多数 API 的异常抛出需求。 - -  重用标准的常有多个好处。其中最主要的好处是,它使 API 更易于学习和使用,因为它与程序员已经熟悉的习惯用法一致。第二个好处是,对于用到这些 API 程序而言,它们的可读性会更好,因为它们不会出现很多程序员不熟悉的异常。最后(也是最不重要的)一点是,异常类越少,意味着内存占用(footprint)就越小,装载这些类的时间开销也越少。 - -  最经常被重用的异常类型是 IllegalArgumentException(详见第 49 条)。当调用者传递的参数值不合适的时候,往往就会抛出这个异常。比如,假设某一个参数代表了“某个动作的重复次数”,如果程序员给这个参数传递了一个负数,就会抛出这个异常。 - -  另一个经常被重用的异常是 llegalStateException。如果因为接收对象的状态而使调用非法,通常就会抛出这个异常。例如,如果在某个对象被正确地初始化之前,调用者就企图使用这个对象,就会抛出这个异常。 - -  可以这么说,所有错误的方法调用都可以被归结为非法参数或者非法状态,但是,还有一些其他的标准异常也被用于某些特定情况下的非法参数和非法状态。如果调用者在某个不允许 null 值的参数中传递了 null,习惯的做法就是抛出 NullPointerException 异常,而不是 IllegalArgumentException。同样地,如果调用者在表示序列下标的参数中传递了越界的值,应该抛出的就是 IndexOutOfBoundsException 异常,而不是 IllegalArgumentException。 - -  另一个值得了解的通用异常是 ConcurrentModificationException。如果检测到一个专门设计用于单线程的对象,或者与外部同步机制配合使用的对象正在(或已经)被并发地修改,就应该抛出这个异常。这个异常顶多就是一个提示,因为不可能可靠地侦测到并发的修改。 - -  最后一个值得注意的标准异常是 UnsupportedOperationException。如果对象不支持所请求的操作,就会抛出这个异常。很少用到它,因为绝大多数对象都会支持它们实现的所有方法。如果类没有实现由它们实现的接口所定义的一个或者多个可选操作(optional operation),它就可以使用这个异常。例如,对于只支持追加操作的 List 实现,如果有人试图从列表中删除元素,它就会抛出这个异常。 - -  **不要直接重用 Exception、RuntimeException、Throwable 或者 Error。** 对待这些类要像对待抽象类一样。你无法可靠地测试这些异常,因为它们是一个方法可能抛出的其他异常的超类。 - -  下表概括了最常见的可重用异常: - - -| 异常 | 使用场合 | -| :-----------------------------: | :------------------------------------------: | -| IllegalArgumentException | 非 null 的参数值不正确 | -| IllegalStateException | 不适合方法调用的对象状态 | -| NullPointerException | 在禁止使用 null 的情况下参数值为 null | -| IndexOutOfBoundsExecption | 下标参数值越界 | -| ConcurrentModificationException | 在禁止并发修改的情况下,检测到对象的并发修改 | -| UnsupportedOperationException | 对象不支持用户请求的方法 | - -  然这些都是 Java 平台类库中迄今为止最常被重用的异常,但是,在条件许可的情况下,其他的异常也可以被重用。例如,如果要实现诸如复数或者有理数之类的算术对象,也可以重用 ArithmeticException 和 NumberFormatException。如果某个异常能够满足你的需要,就不要犹豫,使用就是,不过一定要确保抛出异常的条件与该异常的文档中描述的条件一致。这种重用必须建立在语义的基础上,而不是建立在名称的基础之上。而且,如果希望稍微增加更多的失败一捕获(failure-capture)信息(详见第 75 条),可以放心地子类化标准异常,但要记住异常是可序列化的(详见第 12 章)。这也正是“如果没有非常正当的理由,千万不要自己编写异常类”的原因。 - -  选择重用哪一种异常并非总是那么精确,因为上表中的“使用场合”并不是相互排斥的比如,以表示一副纸牌的对象为例。假设有一个处理发牌操作的方法,它的参数是发一手牌的纸牌张数。假设调用者在这个参数中传递的值大于整副纸牌的剩余张数。这种情形既可以被解释为 IllegalArgumentException( handSize 参数的值太大),也可以被解释为 IllegalStateException(纸牌对象包含的纸牌太少)。在这种情况下,**如果没有可用的参数值,就抛出 llegalStateException,否则就抛出工 llegalArgumentException。** -# 73. 抛出与抽象对应的异常 - -  如果方法抛出的异常与它所执行的任务没有明显的联系,这种情形将会使人不知所措。当方法传递由低层抽象抛出的异常时,往往会发生这种情况。除了使人感到困惑之外,这也“污染”了具有实现细节的更高层的 API 。如果高层的实现在后续的发行版本中发生了变化,它所抛出的异常也可能会跟着发生变化,从而潜在地破坏现有的客户端程序。 - -  为了避免这个问题, **更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。**这种做法称为异常转译 (exception translation),如下代码所示: - -```java -/* Exception Translation */ -try { - ... /* Use lower-level abstraction to do our bidding */ -} catch ( LowerLevelException e ) { - throw new HigherLevelException(...); -} -``` - -  下面的异常转译例子取自于 AbstractSequentialList 类,该类是 List 接口的一个骨架实现 (skeletal implementation),详见第 20 条。在这个例子中,按照`List`接口中 get 方法的规范要求,异常转译是必需的: - -```java -/** - * Returns the element at the specified position in this list. - * @throws IndexOutOfBoundsException if the index is out of range - * ({@code index < 0 || index >= size()}). - */ -public E get( int index ) { - ListIterator i = listIterator( index ); - try { - return(i.next() ); - } catch ( NoSuchElementException e ) { - throw new IndexOutOfBoundsException( "Index: " + index ); - } -} -``` - -  一种特殊的异常转译形式称为异常链 (exception chaining),如果低层的异常对于调试导致高层异常的问题非常有帮助,使用异常链就很合适。低层的异常(原因)被传到高层的异常,高层的异常提供访问方法 (Throwable 的 getCause 方法)来获得低层的异常: - -```java -// Exception Chaining -try { -... // Use lower-level abstraction to do our bidding -} catch (LowerLevelException cause) { - throw new HigherLevelException(cause); -} -``` - -  高层异常的构造器将原因传到支持链 (chaining-aware) 的超级构造器,因此它最终将被传给 Throw able 的其中一个运行异常链的构造器,例如 Throwable(Throwable) : - -```java -/* Exception with chaining-aware constructor */ -class HigherLevelException extends Exception { - HigherLevelException( Throwable cause ) { - super(cause); - } -} -``` - -  大多数标准的异常都有支持链的构造器。对于没有支持链的异常,可以利用 Throwable 的 initCause 方法设置原因。异常链不仅让你可以通过程序(用 getCause) 访问原因,还可以将原因的堆战轨迹集成到更高层的异常中。 - -  **尽管异常转译与不加选择地从低层传递异常的做法相比有所改进,但是也不能滥用它。** 如有可能,处理来自低层异常的最好做法是,在调用低层方法之前确保它们会成功执行,从而避免它们抛出异常。有时候,可以在给低层传递参数之前,检查更高层方法的参数的有效性,从而避免低层方法抛出异常。 - -  如果无法阻止来自低层的异常,其次的做法是,让更高层来悄悄地处理这些异常,从而将高层方法的调用者与低层的问题隔离开来。在这种情况下,可以用某种适当的记录机制(如 java.util.logging) 将异常记录下来。这样有助于管理员调查问题,同时又将客户端代码和最终用户与问题隔离开来。 - -  总而言之,如果不能阻止或者处理来自更低层的异常,一般的做法是使用异常转译,只有在低层方法的规范碰巧可以保证“它所抛出的所有异常对于更高层也是合适的”情况下,才可以将异常从低层传播到高层。异常链对高层和低层异常都提供了最佳的功能:它允许抛出适当的高层异常,同时又能捕获低层的原因进行失败分析(详见第 75 条) 。 -# 74. 每个方法抛出的异常都需要创建文档 - -  描述一个方法所抛出的异常,是正确使用这个方法时所需文档的重要组成部分。因此,花点时间仔细地为每个方法抛出的异常建立文档是特别重要的。 - -  **始终要单独地声明受检异常,** 并且利用 Javadoc 的@ throws 标签, **准确地记录下抛出每个异常的条件。** 如果一个公有方法可能抛出多个异常类,则不要使用“快捷方式”声明它会抛出这些异常类的某个超类。永远不要声明一个公有方法直接“ throws Exception”,或者更糟糕的是声明它直接“ throws Throwable ”,这是非常极端的例子。这样的声明不仅没有为程序员提供关于“这个方法能够抛出哪些异常”的任何指导信息,而且大大地妨碍了该方法的使用,因为它实际上掩盖了该方法在同样的执行环境下可能抛出的任何其他异常。这条建议有一个例外,就是 main 方法它可以被安全地声明抛出 Exception ,因为它只通过虚拟机调用。 - -  虽然 Java 语言本身并没有要求程序员为一个方法声明它可能会抛出的未受检异常,但是,如同受检异常一样,仔细地为它们建立文档是非常明智的。未受检异常通常代表编程上的错误(详见第 70 条),让程序员了解所有这些错误都有助于帮助他们避免犯同样的错误。对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件。每个方法的文档应该描述它的前提条件(详见第 56 条),这是很重要的,在文档中记录下未受检异常是满足前提条件的最佳做法。 - -  对于接口中的方法,在文档中记录下它可能抛出的未受检异常显得尤为重要。这份文档构成了该接口的通用约定( general contract )的一部分,它指定了该接口的多个实现必须遵循的公共行为。 - -  **使用 Javadoc 的 @throws 标签记录下一个方法可能抛出的每个未受检异常,但是不要使用 throws 关键字将未受检的异常包含在方法的声明中。** 使用 API 的程序员必须知道哪些异常是需要受检的,哪些是不需要受检的,因为他们有责任区分这两种情形。当缺少由 throws 声明产生的方法标头时,由 Javadoc 的 @throws 标签所产生的文档就会提供明显的提示信息,以帮助程序员区分受检异常和未受检异常。 - -  应该注意的是,为每个方法可能抛出的所有未受检异常建立文档是很理想的,但是在实践中并非总能做到这一点。当类被修订之后,如果有个导出方法被修改了,它将会抛出额外的未受检异常,这不算违反源代码或者二进制兼容性。假设一个类调用了另一个独立编写的类中的方法。第一个类的编写者可能会为每个方法抛出的未受检异常仔细地建立文档,但是,如果第二个类被修订了,抛出了额外的未受检异常,很有可能第一个类(它并没有被修订)就会把新的未受检异常传播出去,尽管它并没有声明这些异常。 - -  **如果一个类中的许多方法出于同样的原因而抛出同一个异常,在该类的文档注释中对这个异常建立文档,这是可以接受的,** 而不是为每个方法单独建立文档。一个常见的例子是 NullPointerException。 若类的文档注释中有这样的描述:"All methods in this class throw aNullPointerException if a null object reference is passed in any parameter ”(如果 null 对象引用被传递到任何一个参数中,这个类中的所有方法都会抛出 NullPointerException ),或者有其他类似的语句,这是可以的。 - -  总而言之,要为你编写的每个方法所能抛出的每个异常建立文档。对于未受检异常和受检异常,以及抽象的方法和具体的方法一概如此。这个文档在文档注释中应当采用@throws 标签的形式。要在方法的 throws 子句中为每个受检异常提供单独的声明,但是不要声明未受检的异常。如果没有为可以抛出的异常建立文档,其他人就很难或者根本不可能有效地使用你的类和接口。 -# 75. 在细节消息中包含失败一捕获信息 - -  当程序由于未被捕获的异常而失败的时’候,系统会自动地打印出该异常的堆栈轨迹。在堆栈轨迹中包含该异常的字符串表示法 (string representation),即它的 toString 方法的调用结果。它通常包含该异常的类名,紧随其后的是细节消息 (detail message)。通常,这只是程序员或者网站可靠性工程师在调查软件失败原因时必须检查的信息。如果失败的情形不容易重现,要想获得更多的信息会非常困难,甚至是不可能的。因此,异常类型的 toString 方法应该尽可能多地返回有关失败原因的信息,这一点特别重要。换句话说,异常的字符串表示法应该捕获失败,以便于后续进行分析。 - -  **为了捕获失败,异常的细节信息应该包含“对该异常有贡献”的所有参数和字段的值。** 例如, IndexOutOfBoundsException 异常的细节消息应该包含下界、上界以及没有落在界内的下标值。该细节消息提供了许多关于失败的信息。这三个值中任何一个或者全部都有可能是错的。实际的下标值可能小于下界或等于上界(“越界错误”),或者它可能是个无效值,太小或太大。下界也有可能大于上界(严重违反内部约束条件的一种情况) 。每一种情形都代表了不同的问题,如果程序员知道应该去查找哪种错误,就可以极大地加速诊断过程。 - -  对安全敏感的信息有一条忠告。由于在诊断和修正软件问题的过程中,许多人都可以看见堆栈轨迹, **因此千万不要在细节消息中包含密码、密钥以及类似的信息!** - -  虽然在异常的细节消息中包含所有相关的数据是非常重要的,但是包含大量的描述信息往往没有什么意义。堆栈轨迹的用途是与源文件结合起来进行分析,它通常包含抛出该异常的确切文件和行数,以及堆校中所有其他方法调用所在的文件和行数。关于失败的冗长描述信息通常是不必要的,这些信息可以通过阅读源代码而获得。 - -  异常的细节消息不应该与“用户层次的错误消息”混为一谈,后者对于最终用户而言必须是可理解的。与用户层次的错误消息不同,异常的字符串表示法主要是让程序员或者网站可靠性工程师用来分析失败的原因。因此,信息的内容比可读性要重要得多。用户层次的错误消息经常被本地化,而异常的细节消息则几乎没有被本地化。 - -  为了确保在异常的细节消息中包含足够的失败- 捕捉信息, 一种办法是在异常的构造器而不是字符串细节消息中引人这些信息。然后,有了这些信息,只要把它们放到消息描述中,就可以自动产生细节消息。例如 IndexOutOfBoundsException 使用如下构造器代替 String 构造器: - -```java -/** - * Constructs an IndexOutOfBoundsException. - * - * @param lowerBound the lowest legal index value - * @param upperBound the highest legal index value plus one - * @param index the actual index value - */ -public IndexOutOfBoundsException( int lowerBound, int upperBound, - int index ) { - // Generate a detail message that captures the failure - super(String.format( - "Lower bound: %d, Upper bound: %d, Index: %d", - lowerBound, upperBound, index ) ); - // Save failure information for programmatic access - this.lowerBound = lowerBound; - this.upperBound = upperBound; - this.index = index; -} -``` - -  从 Java 9 开始, IndexOutOfBoundsException 终于获得了一个构造器,它可以带一个类型为 int 的 index 参数值,但遗憾的是,它删去了 lowerBound 和 upperBound 参数。更通俗地说, Java 平台类库并没有广泛地使用这种做法,但是,这种做法仍然值得大力推荐。它使程序员更加易于抛出异常以捕获失败。实际上,这种做法使程序员不想捕获失败都难! 这种做法可以有效地把代码集中起来放在异常类中,由这些代码对异常类自身中的异常产生高质量的细节消息,而不是要求类的每个用户都多余地产生细节消息。 - -  正如第 70 条中所建议的, 为异常的失败- 捕获信息 (在上述例子中为 lowerBound 、upperBound 和 index ) 提供一些访问方法是合适的。提供这样的访问方法对受检的异常,比对未受检异常更为重要,因为失败一捕获信息对于从失败中恢复是非常有用的。程序员希望通过程序的手段来访问未受检异常的细节,这很少见 (尽管也是可以想象的) 。然而,即使对于未受检异常,作为一般原则提供这些访问方法也是明智的 (详见第 12 条) 。 -# 76. 保持失败原子性 - -  当对象抛出异常之后,通常我们期望这个对象仍然保持在一种定义良好的可用状态之中, 即使失败是发生在执行某个操作的过程中间。对于受检异常而言,这尤为重要,因为调用者期望能从这种异常中进行恢复。**一般而言,失败的方法调用应该使对象保持在被调用之前的状态。** 具有这种属性的方法被称为具有失败原子性 (failure atomic) 。 - -  有几种途径可以实现这种效果。最简单的办法莫过于设计一个不可变的对象 (详见第 17 条) 。如果对象是不可变的,失败原子性就是显然的。如果一个操作失败了,它可能会阻止创建新的对象,但是永远也不会使已有的对象保持在不一致的状态之中,因为当每个对象被创建之后它就处于一致的状态之中,以后也不会再发生变化。 - -  对于在可变对象上执行操作的方法,获得失败原子性最常见的办法是,在执行操作之前检查参数的有效性 (详见第 49 条) 。这可以使得在对象的状态被修改之前,先抛出适当的异常。比如,以第 7 条中的 Stack.pop 方法为例: - -```java -public Object pop() { - if ( size == 0 ) - throw new EmptyStackException(); - Object result = elements[--size]; - elements[size] = null; /* Eliminate obsolete reference */ - return(result); -} -``` - -  如果取消对初始大小 ( size ) 的检查,当这个方法企图从一个空栈中弹出元素时,它仍然会抛出异常。然而,这将会导致 size 字段保持在不一致的状态 (负数) 之中,从而导致将来对该对象的任何方法调用都会失败。此外,那时, pop 方法抛出的 ArrayIndexOutOfBoundsException 异常对于该抽象来说也是不恰当的 (详见第 73 条) 。 - -  一种类似的获得失败原子性的办法是,调整计算处理过程的顺序,使得任何可能会失败的计算部分都在对象状态被修改之前发生。如果对参数的检查只有在执行了部分计算之后才能进行,这种办法实际上就是上一种办法的自然扩展。比如,以 TreeMap 的情形为例,它的元素被按照某种特定的顺序做了排序。为了向 TreeMap 中添加元素,该元素的类型就必须是可以利用 TreeMap 的排序准则与其他元素进行比较的。如果企图增加类型不正确的元素,在 tree 以任何方式被修改之前,自然会导致 ClassCastException 异常。 - -  第三种获得失败原子性的办法是,在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内容。如果数据保存在临时的数据结构中,计算过程会更加迅速,使用这种办法就是件很自然的事。例如,有些排序函数会在执行排序之前,先把它的输入列表备份到一个数组中,以便降低在排序的内循环中访问元素所需要的开销。这是出于性能考虑的做法,但是,它增加了一项优势:即使排序失败,它也能保证输入列表保持原样。 - -  最后一种获得失败原子’性的办法远远没有那么常用,做法是编写一段恢复代码 (recoverycode ),由它来拦截操作过程中发生的失败,以及便对象回滚到操作开始之前的状态上。这种办法主要用于永久性的 (基于磁盘的) 数据结构。 - -  虽然一般情况下都希望实现失败原子性,但并非总是可以做到。举个例子,如果两个线程企图在没有适当的同步机制的情况下,并发地修改同一个对象,这个对象就有可能被留在不一致的状态之中。因此,在捕获了 ConcurrentModificationException 异常之后再假设对象仍然是可用的,这就是不正确的。错误通常是不可恢复的,因此,当方法抛出 AssertionError 时,不需要努力去保持失败原子性。 - -  即使在可以实现失败原子性的场合,它也并不总是人们所期望的。对于某些操作,它会显著地增加开销或者复杂性。也就是说, 一旦了解了这个问题,获得失败原子性往往既简单又容易。 - -  总而言之,作为方法规范的一部分,它产生的任何异常都应该让对象保持在调用该方法之前的状态。如果违反这条规则, API 文档就应该清楚地指明对象将会处于什么样的状态。遗憾的是,大量现有的 API 文档都未能做到这一点。 -# 77. 不要忽略异常 - -  尽管这条建议看上去是显而易见的,但是它却常常被违反,因而值得再次提出来。当 API 的设计者声明一个方法将抛出某个异常的时候,他们等于正在试图说明某些事情。所以,请不要忽略它!要忽略一个异常非常容易,只需将方法调用通过 try 语句包围起来,并包含一个空的 catch 块: - -```java -// Empty catch block ignores exception - Highly suspect! -try { - ... -} catch ( SomeException e ) { -} -``` - -  **空的 catch 块会使异常达不到应有的目的,** 即强迫你处理异常的情况。忽略异常就如同忽略火警信号一样——如果把火警信号器关掉了,当真正有火灾发生时,就没有人能看到火警信号了。或许你会侥幸逃过劫难,或许结果将是灾难性的。每当见到空的 catch 块时,应该让警钟长鸣。 - -  有些情形可以忽略异常。比如,关闭 FileinputStream 的时候。因为你还没有改变文件的状态,因此不必执行任何恢复动作,并且已经从文件中读取到所需要的信息,因此不必终止正在进行的操作。即使在这种情况下,把异常记录下来还是明智的做法,因为如果这些异常经常发生,你就可以调查异常的原因。 **如果选择忽略异常, catch 块中应该包含一条注释,说明为什么可以这么做,并且变量应该命名为 ignored:** - -```java -Future f = exec.submit(planarMap::chromaticNumber); -int numColors = 4; // Default: guaranteed sufficient for any map -try { - numColors = f.get( 1L, TimeUnit.SECONDS ); -} catch ( TimeoutException | ExecutionException ignored ) { - // Use default: minimal coloring is desirable, not required -} -``` - -  本条目中的建议同样适用于受检异常和未受检异常。不管异常代表了可预见的异常条件,还是编程错误,用空的 catch 块忽略它,都将导致程序在遇到错误的情况下悄然地执行下去。然后,有可能在将来的某个点上,当程序不能再容忍与错误源明显相关的问题时,它就会失败。正确地处理异常能够彻底避免失败。只要将异常传播给外界,至少会导致程序迅速失败,从而保留了有助于调试该失败条件的信息。 -# 78. 同步访问共享的可变数据 - -  关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某一个方法,或者某一个代码块。许多程序员把同步的概念仅仅理解为一种互斥( mutual exclusion )的方式,即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象内部不一致的状态。按照这种观点,对象被创建的时候处于一致的状态(详见第 17 条),当有方法访问它的时候,它就被锁定了。这些方法观察到对象的状态,并且可能会引起状态转变( statetransition ),即把对象从一种一致的状态转换到另一种一致的状态。正确地使用同步可以保证没有任何方法会看到对象处于不一致的状态中。 - -  这种观点是正确的,但是它并没有说明同步的全部意义。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态之中,它还可以保证进入同步方法或者同步代码块的每个线程,都能看到由同一个锁保护的之前所有的修改效果。 - -  Java 语言规范保证读或者写一个变量是原子的( atomic ),除非这个变量的类型为 long 或者 double [JLS , 17.4, 17.7] 。换句话说,读取一个非 long 或 double 类型的变量,可以保证返回值是某个线程保存在该变量中的, 即使多个线程在没有同步的情况下并发地修改这个变量也是如此。 - -  你可能昕说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程将是可见的。 **为了在线程之间进行可靠的通信,也为了互斥访问,同步是必要的。** 这归因于 Java 语言规范中的内存模型(memory model),它规定了一个线程所做的变化何时以及如何变成对其他线程可见[JLS ,17.4; Goetz06, 16]。 - -  如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子可读写的。以下面这个阻止一个线程妨碍,另一个线程的任务为例。Java 的类库中提供了 Thread . stop 方法,但是在很久以前就不提倡使用该方法了,因为它本质上是不安全的一一使用它会导致数据遭到破坏。 **千万不要使用 Thread.stop 方法。** 要阻止一个线程妨碍另一个线程,建议的做法是让第一个线程轮询( poll ) 一个 boolean 字段,这个字段一开始为 false ,但是可以通过第二个线程设置为 true ,以表示第一个线程将终止自己。由于 boolean 字段的读和写操作都是原子的,程序员在访问这个字段的时候不再需要使用同步: - -```java -// Broken! - How long would you expect this program to run? -public class StopThread { - private static Boolean stopRequested; - - public static void main(String[] args) - throws InterruptedException { - Thread backgroundThread = new Thread(() -> { - int i = 0; - while (!stopRequested) - i++; - }); - backgroundThread.start(); - TimeUnit.SECONDS.sleep(1); - stopRequested = true; - } -} -``` - -  你可能期待这个程序运行大约一秒钟左右,之后主线程将 stopRequested 设置为 true ,致使后台线程的循环终止。但是在我的机器上,这个程序永远不会终止:因为后台线程永远在循环! - -  问题在于,由于没有同步,就不能保证后台线程何时‘看到’主线程对 stopRequested 的值所做的改变。没有同步,虚拟机将以下代码: - -```java -while (!stopRequested) - i++; -``` - -  转变成这样: - -```java -if (!stopRequested) - while (true) - i++; -``` - -  这种优化称作提升( hoisting ),正是 OpenJDK Server VM 的工作。结果是一个活性失败 -(liveness failure):这个程序并没有得到提升。修正这个问题的一种方式是同步访问 stopRequested -字段。这个程序会如预期般在大约一秒之内终止: - -```java -// Properly synchronized cooperative thread termination -public class StopThread { - private static Boolean stopRequested; - private static synchronized void requestStop() { - stopRequested = true; - } - private static synchronized Boolean stopRequested() { - return stopRequested; - } - public static void main(String[] args) - throws InterruptedException { - Thread backgroundThread = new Thread(() -> { - int i = 0; - while (!stopRequested()) - i++; - }); - backgroundThread.start(); - TimeUnit.SECONDS.sleep(1); - requestStop(); - } -} -``` - -  注意写方法( requestStop )和读方法( stopRequested )都被同步了。只同步写方法还不够! **除非读和写操作都被同步,否则无法保证同步能起作用。** 有时候,会在某些机器上看到只同步了写(或读)操作的程序看起来也能正常工作,但是在这种情况下,表象具有很大的欺骗性。 - -  StopThread 中被同步方法的动作即使没有同步也是原子的。换句话说,这些方法的同步只是为了它的通信效果,而不是为了互斥访问。虽然循环的每个迭代中的同步开销很小,还是有其他更正确的替代方法,它更加简洁,性能也可能更好。如果 stopRequested 被声明为 volatile ,第二种版本的 StopThread 中的锁就可以省略。虽然 volatile 修饰符不执行互斥访问,但它可以保证任何一个线程在读取该字段的时候都将看到最近刚刚被写入的值: - -```java -// Cooperative thread termination with a volatile field -public class StopThread { - private static volatile Boolean stopRequested; - public static void main(String[] args) - throws InterruptedException { - Thread backgroundThread = new Thread(() -> { - int i = 0; - while (!stopRequested) - i++; - }); - backgroundThread.start(); - TimeUnit.SECONDS.sleep(1); - stopRequested = true; - } -} -``` - -  在使用 volatile 的时候务必要小心。以下面的方法为例,假设它要产生序列号: - -```java -// Broken - requires synchronization! -private static volatile int nextSerialNumber = 0; - -public static int generateSerialNumber() { - return nextSerialNumber++; -} -``` - -  这个方法的目的是要确保每个调用都返回不同的值(只要不超过 232 个调用) 。这个方法的状态只包含一个可原子访问的字段: nextSerialNumber ,这个字段的所有可能的值都是合法的。因此,不需要任何同步来保护它的约束条件。然而,如果没有同步,这个方法仍然无法正确地工作。 - -  问题在于,增量操作符(++)不是原子的。它在 nextSerialNumber 字段中执行两项操作:首先它读取值,然后写回一个新值,相当于原来的值再加上 1。如果第二个线程在第一个线程读取旧值和写回新值期间读取这个字段第二个线程就会与第一个线程一起看到同一个值,并返回相同的序列号。这就是安全性失败( safety failure ):这个程序会计算出错误的结果。 - -  修正 generateSerialNumber 方法的一种方法是在它的声明中增加 synchronized 修饰符。这样可以确保多个调用不会交叉存取,确保每个调用都会看到之前所有调用的效果。一旦这么做,就可以且应该从 nextSerialNumber 中删除 volatile 修饰符。为了保护这个方法,要用 long 代替 int ,或者在 nextSerialNumber 要进行包装时抛出异常。 - -  最好还是遵循第 59 条中的建议,使用 AtomicLong 类,它是 java.util.concurrent.atomic 的组成部分。这个包为在单个变量上进行免锁定、线程安全的编程提供了基本类型。虽然 volatile 只提供了同步的通信效果,但这个包还提供了原子性。这正是你想让 generateSerialNumber 完成的工作,并且它可能比同步版本完成得更好: - -```java -// Lock-free synchronization with java.util.concurrent.atomic -private static final Atomiclong nextSerialNum = new Atomiclong(); - -public static long generateSerialNumber() { - return nextSerialNum.getAndIncrement(); -} -``` - -  避免本条目中所讨论到的问题的最佳办法是不共享可变的数据。要么共享不可变的数据(详见第 17 条),要么压根不共享。换句话说, **将可变数据限制在单个线程中。** 如果采用这一策略,对它建立文档就很重要,以便它可以随着程序的发展而得到维护。深刻地理解正在使用的框架和类库也很重要,因为它们引入了你不知道的线程。 - -  让一个线程在短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,它只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作高效不可变( effectively immutable ) [Goetz06, 3.5.4] 。将这种对象引用从一个线程传递到其他的线程被称作安全发布( safe publication) [Goetz06, 3.5.3] 。安全发布对象引用有许多种方法:可以将它保存在静态字段巾,作为类初始化的一部分;可以将它保存在 volatile 字段、final 字段或者通过正常锁定访问的字段中;或者可以将它放到并发的集合中(详见第 81 条)。 - -  总而言之, **当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。** 如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活性失败( liveness failure )和安全性失败( safety failure ) 。这样的失败是最难调试的。它们可能是间歇性的,且与时间相关,程序的行为在不同的虚拟机上可能根本不同。如果只需要线程之间的交互通信,而不需要互斥, vo latile 修饰符就是一种可以接受的同步形式,但要正确地使用它可能需要一些技巧。 -# 79. 避免过度同步 - -  第 78 条告诫过我们缺少同步的危险性。本条目则关注相反的问题。依据情况的不同,过度同步则可能导致性能降低、死锁,甚至不确定的行为。 - -  **为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要放弃对客户端的控制。** 换句话说,在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法(详见第 24 条) 。从包含该同步区域的类的角度来看,这样的方法是外来的( alien ) 。这个类不知道该方法会做什么事情,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。 - -  为了对这个过程进行更具体的说明,以下面的类为例,它实现了一个可以观察到的集合包装( set wrapper ) 。该类允许客户端在将元素添加到集合中时预订通知。这就是观察者(Observer )模式[Gamma95 ] 。为了简洁起见,类在从集合中删除元素时没有提供通知,但要提供通知也是一件很容易的事情。这个类是在第 18 条中可重用的 ForwardingSet 上实现的: - -```java -// Broken - invokes alien method from synchronized block! -public class ObservableSet extends ForwardingSet { - public ObservableSet(Set set) { super(set); } - - private final List> observers= new ArrayList<>(); - - public void addObserver(SetObserver observer) { - synchronized(observers) { - observers.add(observer); - } - } - - public Boolean removeObserver(SetObserver observer) { - synchronized(observers) { - return observers.remove(observer); - } - } - - private void notifyElementAdded(E element) { - synchronized(observers) { - for (SetObserver observer : observers) - observer.added(this, element); - } - } - - @Override - public Boolean add(E element) { - Boolean added = super.add(element); - if (added) - notifyElementAdded(element); - return added; - } - - @Override - public Boolean addAll(Collection c) { - Boolean result = false; - for (E element : c) - result |= add(element); - // Calls notifyElementAdded - return result; - } -} -``` - -  观察者通过调用 addObserver 方法预订通知,通过调用 removeObserver 方法取消预订。在这两种情况下,这个回调( callback )接口的实例都会被传递给方法: - -```java -@FunctionalInterface -public interface SetObserver { - // Invoked when an element is added to the observable set - void added(ObservableSet set, E element); -} -``` - -  这个接口的结构与 `BiConsumer ,E>` 一样。我们选择定义一个定制的函数接口,因为该接口和方法名称可以提升代码的可读性,且该接口可以发展整合多个回调。也就是说,还可以设置合理的参数来使用 BiConsumer (详见第 44 条)。 - -  如果只是粗略地检验一下, ObservableSet 会显得很正常。例如,下面的程序打印出 0 ~ 99 的数字: - -```java -public static void main(String[] args) { - ObservableSet set = new ObservableSet<>(new HashSet<>()); - set.addObserver((s, e) -> System.out.println(e)); - for (int i = 0; i < 100; i++) - set.add(i); -} -``` - -  现在我们来尝试一些更复杂点的例子。假设我们用一个 addObserver 调用来代替这个调用,用来替换的那个 addObserver 调用传递了一个打印 Integer 值的观察者,这个值被添加到该集合中,如果值为 23 ,这个观察者要将自身删除: - -```java -set.addObserver(new SetObserver<>() { - public void added(ObservableSet s, Integer e) { - System.out.println(e); - if (e == 23) - s.removeObserver(this); - } -}); -``` - -  注意,这个调用以一个匿名类 SetObserver 实例代替了前一个调用中使用的 lambda 。这是因为函数对象需要将自身传给 s.removeObserver ,而 lambda 则无法访问它们自己(详见第 42 条) 。 - -  你可能以为这个程序会打印数字 0 ~ 23 ,之后观察者会取消预订,程序会悄悄地完成它的工作。实际上却是打印出数字 0 ~ 23 ,然后抛出 ConcurrentModificationException 。问题在于,当 notifyElementAdded 调用观察者的 added 方法时,它正处于遍历 observers 列表的过程中。added 方法调用可观察集合的 removeObserver 方法,从而调用 observers.remove 。现在我们有麻烦了。我们正企图在遍历列表的过程中,将一个元素从列表中删除,这是非法的。notifyElementAdded 方法中的迭代是在一个同步的块中,可以防止并发的修改,但是无法防止迭代线程本身回调到可观察的集合中,也无法防止修改它的 observers 列表。 - -  现在我们要尝试一些比较奇特的例子: 我们来编写一个试图取消预订的观察者,但是不直接调用 removeObserver ,它用另一个线程的服务来完成。这个观察者使用了一个 executor service (详见第 80 条): - -```java -// Observer that uses a background thread needlessly -set.addObserver(new SetObserver<>() { - public void added(ObservableSet s, Integer e) { - System.out.println(e); - if (e == 23) { - ExecutorService exec = - Executors.newSingleThreadExecutor(); - try { - exec.submit(() -> s.removeObserver(this)).get(); - } - catch (ExecutionException | InterruptedException ex) { - throw new AssertionError (ex); - } - finally { - exec.shutdown(); - } - } - } -}); -``` - -  顺便提一句,注意看这个程序在一个 catch 子句中捕获了两个不同的异常类型。这个机制是在 Java 7 中增加的,不太正式地称之为多重捕获( multi-catch ) 。它可以极大地提升代码的清晰度,行为与多异常类型相同的程序,其篇幅可以大幅减少。 - -  运行这个程序时,没有遇到异常,而是遭遇了死锁。后台线程调用 s .removeObserver,它企图锁定 observers,但它无法获得该锁,因为主线程已经有锁了。在这期间,主线程一直在等待后台线程来完成对观察者的删除,这正是造成死锁的原因。 - -  这个例子是刻意编写用来示范的,因为观察者实际上没理由使用后台线程,但这个问题却是真实的。从同步区域中调用外来方法,在真实的系统中已经造成了许多死锁,例如 GUI 工具箱。 - -  在前面这两个例子中(异常和死锁),我们都还算幸运。调用外来方法( added )时,同步区域( observers )所保护的资源处于一致的状态。假设当同步区域所保护的约束条件暂时无效时,你要从同步区域中调用一个外来方法。由于 Java 程序设计语言中的锁是可重入的( reentrant ),这种调用不会死锁。就像在第一个例子中一样,它会产生一个异常,因为调用线程已经有这个锁了,因此当该线程试图再次获得该锁时会成功,尽管概念上不相关的另一项操作正在该锁所保护的数据上进行着。这种失败的后果可能是灾难性的。从本质上来说,这个锁没有尽到它的职责。可重入的锁简化了多线程的面向对象程序的构造,但是它们可能会将活性失败变成安全性失败。 - -  幸运的是,通过将外来方法的调用移出同步的代码块来解决这个问题通常并不太困难。对于 notifyElementAdded 方法,这还涉及给 observers 列表拍张“快照”,然后没有锁也可以安全地遍历这个列表了。经过这一修改,前两个例子运行起来便再也不会出现异常或者死锁了: - -```java -// Alien method moved outside of synchronized block - open calls -private void notifyElementAdded(E element) { - List> snapshot = null; - synchronized(observers) { - snapshot = new ArrayList<>(observers); - } - for (SetObserver observer : snapshot) - observer.added(this, element); -} -``` - -  事实上,要将外来方法的调用移出同步的代码块,还有一种更好的方法。Java 类库提供了一个并发集合( concurrent collection ),详见第 81 条,称作 CopyOnWriteArrayList,这是专门为此定制的。这个 CopyOnWriteArrayList 是 ArrayList 的一种变体,它通过重新拷贝整个底层数组,在这里实现所有的写操作。由于内部数组永远不改动,因此迭代不需要锁定,速度也非常快。如果大量使用, CopyOnWriteArrayList 的性能将大受影响,但是对于观察者列表来说却是很好的,因为它们几乎不改动,并且经常被遍历。 - -  如果将这个列表改成使用 CopyOnWriteArrayList ,就不必改动 ObservableSet 的 add 和 addAll 方法。下面是这个类的其余代码。注意其中并没有任何显式的同步: - -```java -// Thread-safe observable set with CopyOnWriteArrayList -private final List> observers = new CopyOnWriteArrayList<>(); - -public void addObserver(SetObserver observer) { - observers.add(observer); -} - -public Boolean removeObserver(SetObserver observer) { - return observers.remove(observer); -} - -private void notifyElementAdded(E element) { - for (SetObserver observer : observers) - observer.added(this, element); -} -``` - -  在同步区域之外被调用的外来方法被称作“开放调用”(open call)[Goetz06, 10 .1.4]。除了可以避免失败之外,开放调用还可以极大地增加并发性。外来方法的运行时间可能为任意时长。如果在同步区域内调用外来方法,其他线程对受保护资源的访问就会遭到不必要的拒绝。 - -  **通常来说,应该在同步区域内做尽可能少的工作。** 获得锁,检查共享数据,根据需要转换数据,然后释放锁。如果你必须要执行某个很耗时的动作,则应该设法把这个动作移到同步区域的外面,而不违背第 78 条中的指导方针。 - -  本条目的第一部分是关于正确性的。接下来,我们要简单地讨论一下性能。虽然自 Java 平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过度同步。在这个多核的时代,过度同步的实际成本并不是指获取锁所花费的 CPU 时间;而是指失去了并行的机会,以及因为需要确保每个核都有一个-致的内存视图而导致的延迟。过度同步的另一项潜在开销在于,它会限制虚拟机优化代码执行的能力。 - -  如果正在编写一个可变的类,有两种选择:省略所有的同步,如果想要并发使用,就允许客户端在必要的时候从外部同步,或者通过内部同步,使这个类变成是线程安全的(详见第 82 条),你还可以因此获得明显比从外部锁定整个对象更高的并发性。java.util 中的集合(除了已经废弃的 Vector 和 Hashtable 之外)采用了前一种方法,而 java.util.concurrent 中的集合则采用了后一种方法(详见第 81 条)。 - -  在 Java 平台出现的早期,许多类都违背了这些指导方针。例如, StringBuffer 实例几乎总是被用于单个线程之中,而它们执行的却是内部同步。为此, StringBuffer 基本上都由 StringBuilder 代替,它是一个非同步的 StringBuf fer 。同样地,java.util.Random 中线程安全的伪随机数生成器,被 java.util.concurrent.ThreadLocalRandom 中非同步的实现取代,主要也是出于上述原因。当你不确定的时候,就不要同步类,而应该建立文档,注明它不是线程安全的。 - -  如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如分拆锁、分离锁和非阻塞并发控制。这些方法都超出了本书的讨论范围,但有其他著作对此进行了阐述[Goetz06, Herlihy12] 。 - -  如果方法修改了静态字段,并且该方法很可能要被多个线程调用,那么也必须在内部同步对这个字段的访问(除非这个类能够容忍不确定的行为) 。多线程的客户端要在这种方法上执行外部同步是不可能的,因为其他不相关的客户端不需要同步也能调用该方法。字段本质上就是一个全局变量,即使是私有的也一样,因为它可以被不相关的客户端读取和修改。第 78 条中的 generateSerialNumber 方法使用的 nextSerialNumber 字段就是这样的一个例子。 - -  总而言之,为了避免死锁和数据破坏,千万不要从同步区字段内部调用外来方法。更通俗地讲,要尽量将同步区字段内部的工作量限制到最少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在如今这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这么做,同时还应该将这个决定清楚地写到文档中(详见第 82 条) 。 -# 80. executor 、task 和 stream 优先于线程 - -  本书第 1 版中阐述了简单的工作队列( work queue)[BlochOl ,详见第 49 条]的代码。这个类允许客户端按队列等待由后台线程异步处理的工作项目。当不再需要这个工作队列时,客户端可以调用一个方法,让后台线程在完成了已经在队列中的所有工作之后,优雅地终止自己。这个实现几乎就像一件玩具,但即使如此,它还是需要一整页精细的代码,一不小心,字就容易出现安全问题或者导致活性失败。幸运的是,你再也不需要编写这样的代码了。 - -  到本书第二版出版的时候, Java 平台中已经增加了 java.util.concurrent 。这个字包中包含了一个 Executor Framework 它是一个很灵活的基于接口的任务执行工具。它创建字了一个在各方面都比本书第一版更好的工作队列,却只需要这一行代码: - -```java -ExecutorService exec = Executors.newSingleThreadExecutor(); -``` - -  下面是为执行而提交一个 runnable 的方法: - -```java -exec.execute(runnable); -``` - -  下面是告诉 executor 如何优雅地终止(如果你没有这么做,虚拟机可能不会退出) : - -```java -exec.shutdown(); -``` - -  你可以利用 executor service 完成更多的工作。例如,可以等待完成一项特殊的任务(就如第 79 条中的 get 方法一样),你可以等待一个任务集合中的任何任务或者所有任务完成(利用 invokeAny 或者 invokeAll 方法),可以等待 executor service 优雅地完成终止(利用 awaitTermination 方法),可以在任务完成时逐个地获取这些任务的结果(利用 ExecutorCompletionService ),可以调度在某个特殊的时间段定时运行或者阶段性地运行的任务(利用 ScheduledThreadPoolExecutor ),等等。 - -  如果想让不止一个线程来处理来自这个队列的请求,只要调用一个不同的静态工厂,这个工厂创建了一种不同的 executor service ,称作线程池( thread pool ) 。你可以用固定或者可变数目的线程创建一个线程池。java.util.concurrent.Executors 类包含了静态工厂,能为你提供所需的大多数以 excutor 。然而,如果你想来点特别的,可以直接使用 ThreadPoolExecutor 类。这个类允许你控制线程池操作的几乎每个方面。 - -  为特殊的应用程序选择 executor service 是很有技巧的。如果编写的是小程序,或者是轻量负载的服务器,使用 Executors.newCachedThreadPool 通常是个不错的选择,因为它不需要配置,并且一般情况下能够正确地完成工作。但是对于大负载的服务器来说,缓存的线程池就不是很好的选择了!在缓存的线程池中,被提交的任务没有排成队列,而是直接交给线程执行。如果没有线程可用,就创建一个新的线程。如果服务器负载得太重,以致它所有的 CPU 都完全被占用了,当有更多的任务时,就会创建更多的线程,这样只会使情况变得更糟。因此,在大负载的产品服务器中,最好使用 Executors.newFixedThreadPool ,它为你提供了一个包含固定线程数目的线程池,或者为了最大限度地控制它,就直接使用 ThreadPoolExecutor 类。 - -  不仅应该尽量不要编写自己的工作队列,而且还应该尽量不直接使用线程。当直接使用线程时, Thread 是既充当工作单元,又是执行机制。在 Executor Framework 中,工作单元和执行机制是分开的。现在关键的抽象是工作单元,称作任务( task ) 。任务有两种:Runnable 及其近亲 Callable (它与 Runnable 类似,但它会返回值,并且能够抛出任意的异常) 。执行任务的通用机制是 executor service 。如果你从任务的角度来看问题,并让一个 executor service 替你执行任务,在选择适当的执行策略方面就获得了极大的灵活性。从本质上讲, Executor Framework 所做的工作是执行, Collections Framework 所做的工作是聚合(aggregation)。 - -  在 Java 7 中, Executor Framework 得到了扩展,它可以支持 fork-join 任务了,这些任务是通过一种称作 fork-join 池的特殊 executor 服务运行的。fork-join 任务用 ForkJoinTask 实例表示,可以被分成更小的子任务,包含 ForkJoinPool 的线程不仅要处理这些任务,还要从另一个线程中“偷”任务,以确保所有的线程保持忙碌,从而提高 CPU 使用率、提高吞吐量,并降低延迟。fork-join 任务的编写和调优是很有技巧的。并发的 stream (详见第 48 条)是在 fork join 池上编写的,我们不费什么力气就能享受到它们的性能优势,前提是假设它们正好适用于我们手边的任务。 - -  Executor Framework 的完整处理方法超出了本书的讨论范围,但是有兴趣的读者可以参阅《Java Concurrency in Practice 》一书[Goetz06 ] 。 - -# 81. 相比 wait 和 notify 优先使用并发工具 - -  本书第 1 版中专门用了一个条目来说明如何正确地使用 wait 和 notify ( Bloch01,详见第 50 条) 。它提出的建议仍然有效,并且在本条目的最后也对此做了概述,但是这条建议现在远远没有之前那么重要了。这是因为几乎没有理由再使用 wait 和 notify 了。自从 Java 5 发行版本开始, Java 平台就提供了更高级的并发工具,它们可以完成以前必须在 wait 和 notify 上手写代码来完成的各项工作。 **既然正确地使用 wait 和 notify 比较困难,就应该用更高级的并发工具来代替。** - -  java.util.concurrent 中更高级的工具分成三类: Executor Framework 、并发集合( Concurrent Collection )以及同步器( Synchronizer),Executor Framework 只在第 80 条中简单地提到过,并发集合和同步器将在本条目中进行简单的阐述。 - -  并发集合为标准的集合接口(如 List 、Queue 和 Map )提供了高性能的并发实现。为了提供高并发性,这些实现在内部自己管理同步(详见第 79 条) 。因此, **并发集合中不可能排除并发活动;将它锁定没有什么作用,只会使程序的速度变慢。** - -  因为无法排除并发集合中的并发活动,这意味着也无法自动地在并发集合中组成方法调用。因此,有些并发集合接口已经通过依赖状态的修改操作(state-dependent modify operation)进行了扩展,它将几个基本操作合并到了单个原子操作中。事实证明,这些操作在并发集合中已经够用,它们通过缺省方法(详见第 21 条)被加到了 Java 8 对应的集合接口中。 - -  例如, Map 的 putIfAbsent(key, value) 方法,当键没有映射时会替它插入一个映射,并返回与键关联的前一个值,如果没有这样的值,则返回 null 。这样就能很容易地实现线程安全的标准 Map 了。例如,下面这个方法模拟了 String . intern 的行为: - -```java -// Concurrent canonicalizing map atop ConcurrentMap - not optimal -private static final ConcurrentMap map = new ConcurrentHashMap<>(); -public static String intern(String s) { - String previousValue = map.putIfAbsent(s, s); - return previousValue == null ? s : previousValue; -} -``` - -  事实上,你还可以做得更好。ConcurrentHashMap 对获取操作(如 get )进行了优化。因此,只有当 get 表明有必要的时候,才值得先调用 get ,再调用 putIfAbsent : - -```java -// Concurrent canonicalizing map atop ConcurrentMap - faster! -public static String intern(String s) { - String result = map.get(s); - if (result == null) { - result = map.putIfAbsent(s, s); - if (result == null) - result = s; - } - return result; -} -``` - -  ConcurrentHashMap 除了提供卓越的并发性之外,速度也非常快。在我的机器上,上面这个优化过的 intern 方法比 String.intern 快了不止 6 倍(但是记住, String.intern 必须使用某种弱引用,避免随着时间的推移而发生内存泄漏)。并发集合导致同步的集合大多被废弃了。**比如, 应该优先使用 ConcurrentHashMap ,而不是使用 Collections.synchronizedMap 。** 只要用并发 Map 替换同步 Map ,就可以极大地提升并发应用程序的性能。 - -  有些集合接口已经通过阻塞操作( blocking operation )进行了扩展,它们会一直等待(或者阻塞)到可以成功执行为止。例如, BlockingQueue 扩展了 Queue 接口,并添加了包括 take 在内的几个方法,它从队列中删除并返回了头元素,如果队列为空,就等待。这样就允许将阻塞队列用于工作队列( work queue ),也称作生产者一消费者队列 (producer-consumer queue ),一个或者多个生产者线程( producer thread )在工作队列中添加工作项目,并且当工作项目可用时,一个或者多个消费者线程( consumer thread )则从工作队列中取出队列并处理工作项目。不出所料,大多数 ExecutorService 实现(包括 ThreadPoolExecutor )都使用了一个 BlockingQueue (详见第 80 条) 。 - -  同步器( Synchronizer )是使线程能够等待另一个线程的对象,允许它们协调动作。最常用的同步器是 CountDownLatch 和 Semaphore 。较不常用的是 CyclicBarrier 和 Exchanger 。功能最强大的同步器是 Phaser 。 - -  倒计数锁存器( Countdown Latch )是一次性的障碍,允许一个或者多个线程等待一个或者多个其他线程来做某些事情。Count DownLatch 的唯一构造器带有一个 int 类型的参数,这个 int 参数是指允许所有在等待的线程被处理之前,必须在锁存器上调用 countDown 方法的次数。 - -  要在这个简单的基本类型之上构建一些有用的东西,做起来是相当容易。例如,假设想要构建一个简单的框架,用来给一个动作的并发执行定时。这个框架中只包含单个方法,该方法带有一个执行该动作的 executor ,一个并发级别(表示要并发执行该动作的次数),以及表示该动作的 runnable 。所有的工作线程( worker thread )自身都准备好,要在 timer 线程启动时钟之前运行该动作。当最后一个工作线程准备好运行该动作时, timer 线程就“发起头炮”,同时允许工作线程执行该动作。一旦最后一个工作线程执行完该动作,timer 线程就立即停止计时。直接在 wait 和 notify 之上实现这个逻辑会很棍乱,而在 CountDownLatch 之上实现则相当简单: - -```java -// Simple framework for timing concurrent execution -public static long time(Executor executor, int concurrency, - Runnable action) throws InterruptedException { - CountDownLatch ready = new CountDownLatch(concurrency); - CountDownLatch start = new CountDownLatch(1); - CountDownLatch done = new CountDownLatch(concurrency); - for (int i = 0; i < concurrency; i++) { - executor.execute(() -> { - ready.countDown(); - // Tell timer we're ready - try { - start.await(); - // Wait till peers are ready - action.run(); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - finally { - done.countDown(); - // Tell timer we're done - } - }); - } - ready.await(); - // Wait for all workers to be ready - long startNanos = System.nanoTime(); - start.countDown(); - // And they're off! - done.await(); - // Wait for all workers to finish - return System.nanoTime() - startNanos; -} -``` - -  注意这个方法使用了三个倒计数锁存器。第一个是 ready ,工作线程用它来告诉 timer 线程它们已经准备好了。然后工作线程在第二个锁存器 start 上等待。当最后一个工作线程调用 ready.countDowη 时, timer 线程记录下起始时间,并调用 start.countDown 允许所有的工作线程继续进行。然后 timer 线程在第三个锁存器 done 上等待,直到最后一个工作线程运行完该动作,并调用 done.countDown 。一旦调用这个, timer 线程就会苏醒过来,并记录下结束的时间。 - -  还有一些细节值得注意。传递给 time 方法的 executor 必须允许创建至少与指定并发级别一样多的线程,否则这个测试就永远不会结束。这就是线程饥饿死锁( thread starvationdeadlock) [Goetz06 8.1.1 ] 。如果工作线程捕捉到 InterruptedException ,就会利用习惯用法 Thread.currentThread().interrupt() 重新断言中断,并从它的 run 方法中返回。这样就允许 executor 在必要的时候处理中断,事实上也理应如此。注意,我们利用了 System.nanoTime 来给活动定时。对于间歇式的定时,始终应该优先使用 System.nanoTime ,而不是使用 System.currentTimeMillis 。因为 System.nanoTime 更准确,也更精确,它不受系统的实时时钟的调整所影响。最后,注意本例中的代码并不能进行准确的定时,除非 action 能完成一定量的工作,比如一秒或者一秒以上。众所周知,准确的微基准测试十分困难,最好在专门的框架如 jmh 的协助下进行[JMH] 。 - -  本条目仅仅触及了并发工具的一些皮毛。例如,前一个例子中的那三个倒计数锁存器其实可以用一个 CyclicBarrier 或者 Phaser 实例代替。这样得到的代码更加简洁,但是理解起来比较困难。虽然你始终应该优先使用并发工具,而不是使用 wait 方法和 notify 方法,但可能必须维护使用了 wait 方法和 notify 方法的遗留代码。wait 方法被用来使线程等待某个条件。它必须在同步区域内部被调用,这个同步区域将对象锁定在了调用 wait 方法的对象上。下面是使用 wait 方法的标准模式: - -```java -// The standard idiom for using the wait method -synchronized (obj) { - while () - obj.wait(); - // (Releases lock, and reacquires on wakeup) - ... // Perform action appropriate to condition -} -``` - -  始终应该使用 wait 循环模式来调用 wait 方法;永远不要在循环之外调用 wait 方法。循环会在等待之前和之后对条件进行测试。 - -  在等待之前测试条件,当条件已经成立时就跳过等待,这对于确保活性是必要的。如果条件已经成立,并且在线程等待之前, notify (或者 notifyAll )方法已经被调用, 则无法保证该线程总会从等待中苏醒过来。 - -  在等待之前测试条件,如果条件不成立的话继续等待,这对于确保安全性是必要的。当条件不成立的时候,如果线程继续执行,则可能会破坏被锁保护的约束关系。当条件不成立时,有以下理由可使一个线程苏醒过来: - -- 另一个线程可能已经得到了锁,并且从一个线程调用 notify 方法那一刻起,到等待线程苏醒过来的这段时间中,得到锁的线程已经改变了受保护的状态。 -- 条件并不成立,但是另一个线程可能意外地或恶意地调用了 notify 方法。在公有可访问的对象上等待,这些类实际上把自己暴露在了这种危险的境地中。公有可访问对象的同步方法中包含的 wait 方法都会出现这样的问题。 -- 通知线程( notifying thread )在唤醒等待线程时可能会过度“大方” 。例如,即使只有某些等待线程的条件已经被满足,但是通知线程可能仍然调用 notifyAll 方法。 -- 在没有通知的情况下,等待线程也可能(但很少)会苏醒过来。这被称为“伪唤醒”(spurious wakeup) [POSIX, 11.4.3.6.1; Java9-api] 。 - -  一个相关的话题是,为了唤醒正在等待的线程,你应该使用 notify 方法还是 notifyAll 方法(回忆一下, notify 方法唤醒的是单个正在等待的线程,假设有这样的线程存在,而 notifyAll 方法唤醒的则是所有正在等待的线程) 。一种常见的说法是,应该始终使用 notifyAll 方法。这是合理而保守的建议。它总会产生正确的结果,因为它可以保证你将会唤醒所有需要被唤醒的线程。你可能也会唤醒其他一些线程,但是这不会影响程序的正确性。这些线程醒来之后,会检查它们正在等待的条件,如果发现条件并不满足,就会继续等待。 - -  从优化的角度来看,如果处于等待状态的所有线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么你就应该选择调用 notify 方法,而不是 notifyAll 方法。 - -  即使这些前提条件都满足,也许还是有理由使用 notifyAll 方法而不是 notify 方法。就好像把 wait 方法调用放在一个循环中,以避免在公有可访问对象上的意外或恶意的通知一样,与此类似,使用 notifyAll 方法代替 notify 方法可以避免来自不相关线程的意外或恶意的等待。否则,这样的等待会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。 - -  简而言之,直接使用 wait 方法和 notify 方法就像用“并发汇编语言”进行编程一样,而 java.util.concurrent 则提供了更高级的语言。 **没有理由在新代码中使用 wait 方法和 notify 方法,即使有,也是极少的。** 如果你在维护使用 wait 方法和 notify 方法的代码,务必确保始终是利用标准的模式从 while 循环内部调用 wait 方法。一般情况下,应该优先使用 notifyAll 方法,而不是使用 notify 方法。如果使用 notify 方法,请一定要小心,以确保程序的活性。 -# 82. 文档应包含线程安全属性 - -  类在其方法并发使用时的行为是其与客户端约定的重要组成部分。如果你没有记录类在这一方面的行为,那么它的用户将被迫做出假设。如果这些假设是错误的,生成的程序可能缺少足够的同步(详见 78 条)或过度的同步(详见 79 条)。无论哪种情况,都可能导致严重的错误。 - -  你可能听说过,可以通过在方法的文档中查找 synchronized 修饰符来判断方法是否线程安全。这个观点有好些方面是错误的。在正常操作中,Javadoc 的输出中没有包含同步修饰符,这是有原因的。方法声明中 synchronized 修饰符的存在是实现细节,而不是其 API 的一部分。**它不能可靠地表明方法是线程安全的。** - -  此外,声称 synchronized 修饰符的存在就足以记录线程安全性,这个观点是对线程安全性属性的误解,认为要么全有要么全无。实际上,线程安全有几个级别。**要启用安全的并发使用,类必须清楚地记录它支持的线程安全级别。** 下面的列表总结了线程安全级别。它并非详尽无遗,但涵盖以下常见情况: - -- **不可变的** — 这个类的实例看起来是常量。不需要外部同步。示例包括 String、Long 和 BigInteger(详见第 17 条)。 -- **无条件线程安全** — 该类的实例是可变的,但是该类具有足够的内部同步,因此无需任何外部同步即可并发地使用该类的实例。例如 AtomicLong 和 ConcurrentHashMap。 -- **有条件的线程安全** — 与无条件线程安全类似,只是有些方法需要外部同步才能安全并发使用。示例包括 Collections.synchronized 包装器返回的集合,其迭代器需要外部同步。 -- **非线程安全** — 该类的实例是可变的。要并发地使用它们,客户端必须使用外部同步来包围每个方法调用(或调用序列)。这样的例子包括通用的集合实现,例如 ArrayList 和 HashMap。 -- **线程对立** — 即使每个方法调用都被外部同步包围,该类对于并发使用也是不安全的。线程对立通常是由于在不同步的情况下修改静态数据而导致的。没有人故意编写线程对立类;此类通常是由于没有考虑并发性而导致的。当发现类或方法与线程不相容时,通常将其修复或弃用。第 78 条中的 generateSerialNumber 方法在没有内部同步的情况下是线程对立的,如第 322 页所述。 - -  这些类别(不包括线程对立类)大致对应于《Java Concurrency in Practice》中的线程安全注解,分别为 Immutable、ThreadSafe 和 NotThreadSafe [Goetz06, Appendix A]。上面分类中的无条件线程安全和有条件的线程安全都包含在 ThreadSafe 注解中。 - -  在文档中记录一个有条件的线程安全类需要小心。你必须指出哪些调用序列需要外部同步,以及执行这些序列必须获得哪些锁(在极少数情况下是锁)。通常是实例本身的锁,但也有例外。例如,`Collections.synchronizedMap` 的文档提到: - -  It is imperative that the user manually synchronize on the returned map when iterating over any of its collection views: - -  当用户遍历其集合视图时,必须手动同步返回的 Map: - -```java -Map m = Collections.synchronizedMap(new HashMap<>()); -Set s = m.keySet(); // Needn't be in synchronized block -... -synchronized(m) { // Synchronizing on m, not s! - for (K key : s) - key.f(); -} -``` - -  不遵循这个建议可能会导致不确定的行为。 - -  类的线程安全的描述通常属于该类的文档注释,但是具有特殊线程安全属性的方法应该在它们自己的文档注释中描述这些属性。没有必要记录枚举类型的不变性。除非从返回类型可以明显看出,否则静态工厂必须记录返回对象的线程安全性,正如 `Collections.synchronizedMap` 所演示的那样。 - -  当一个类使用公共可访问锁时,它允许客户端自动执行一系列方法调用,但是这种灵活性是有代价的。它与诸如 ConcurrentHashMap 之类的并发集合所使用的高性能内部并发控制不兼容。此外,客户端可以通过长时间持有可公开访问的锁来发起拒绝服务攻击。这可以是无意的,也可以是有意的。 - -  为了防止这种拒绝服务攻击,你可以使用一个私有锁对象,而不是使用同步方法(隐含一个公共可访问的锁): - -  因为私有锁对象在类之外是不可访问的,所以客户端不可能干扰对象的同步。实际上,我们通过将锁对象封装在它同步的对象中,是在应用条目 15 的建议。 - -  注意,lock 字段被声明为 final。这可以防止你无意中更改它的内容,这可能导致灾难性的非同步访问(详见 78 条)。我们正在应用条目 17 的建议,最小化锁字段的可变性。**Lock 字段应该始终声明为 final。** 无论使用普通的监视器锁(如上所示)还是 `java.util.concurrent` 包中的锁,都是这样。 - -  私有锁对象用法只能在无条件的线程安全类上使用。有条件的线程安全类不能使用这种用法,因为它们必须在文档中记录,在执行某些方法调用序列时要获取哪些锁。 - -  私有锁对象用法特别适合为继承而设计的类(详见第 19 条)。如果这样一个类要使用它的实例进行锁定,那么子类很容易在无意中干扰基类的操作,反之亦然。通过为不同的目的使用相同的锁,子类和基类最终可能「踩到对方的脚趾头」。这不仅仅是一个理论问题,它就发生在 Thread 类中 [Bloch05, Puzzle 77]。 - -  总之,每个类都应该措辞严谨的描述或使用线程安全注解清楚地记录其线程安全属性。synchronized 修饰符在文档中没有任何作用。有条件的线程安全类必须记录哪些方法调用序列需要外部同步,以及在执行这些序列时需要获取哪些锁。如果你编写一个无条件线程安全的类,请考虑使用一个私有锁对象来代替同步方法。这将保护你免受客户端和子类的同步干扰,并为你提供更大的灵活性,以便在后续的版本中采用复杂的并发控制方式。 -# 83. 明智审慎的使用延迟初始化 - -  延迟初始化是延迟字段的初始化,直到需要它的值。如果不需要该值,则不会初始化字段。这种技术既适用于静态字段,也适用于实例字段。虽然延迟初始化主要是一种优化,但是它也可以用于破坏类中的有害循环和实例初始化 [Bloch05, Puzzle 51]。 - -  与大多数优化一样,延迟初始化的最佳建议是「除非需要,否则不要这样做」(详见第 67 条)。延迟初始化是一把双刃剑。它降低了初始化类或创建实例的成本,代价是增加了访问延迟初始化字段的成本。根据这些字段中最终需要初始化的部分、初始化它们的开销以及初始化后访问每个字段的频率,延迟初始化实际上会损害性能(就像许多「优化」一样)。 - -  延迟初始化也有它的用途。如果一个字段只在类的一小部分实例上访问,并且初始化该字段的代价很高,那么延迟初始化可能是值得的。唯一确定的方法是以使用和不使用延迟初始化的效果对比来度量类的性能。 - -  在存在多个线程的情况下,使用延迟初始化很棘手。如果两个或多个线程共享一个延迟初始化的字段,那么必须使用某种形式的同步,否则会导致严重的错误(详见第 78 条)。本条目讨论的所有初始化技术都是线程安全的。 - -  **在大多数情况下,常规初始化优于延迟初始化。** 下面是一个使用常规初始化的实例字段的典型声明。注意 final 修饰符的使用(详见第 17 条): - -  **如果您使用延迟初始化来取代初始化的循环(circularity),请使用同步访问器**,因为它是最简单、最清晰的替代方法: - -```java -// Lazy initialization of instance field - synchronized accessor -private FieldType field; -private synchronized FieldType getField() { - if (field == null) - field = computeFieldValue(); - return field; -} -``` - -  这两种习惯用法(使用同步访问器进行常规初始化和延迟初始化)在应用于静态字段时都没有改变,只是在字段和访问器声明中添加了 static 修饰符。 - -  **如果需要在静态字段上使用延迟初始化来提高性能,使用 lazy initialization holder class 模式。** 这个用法可保证一个类在使用之前不会被初始化 [JLS, 12.4.1]。它是这样的: - -```java -// Lazy initialization holder class idiom for static fields -private static class FieldHolder { - static final FieldType field = computeFieldValue(); -} -private static FieldType getField() { return FieldHolder.field; } -``` - -  第一次调用 getField 时,它执行 FieldHolder.field,导致初始化 FieldHolder 类。这个习惯用法的优点是 getField 方法不是同步的,只执行字段访问,所以延迟初始化实际上不会增加访问成本。典型的 VM 只会同步字段访问来初始化类。初始化类之后,VM 会对代码进行修补,这样对字段的后续访问就不会涉及任何测试或同步。 - -  如果需要使用延迟初始化来提高实例字段的性能,请使用双重检查模式。这个模式避免了初始化后访问字段时的锁定成本(详见第 79 条)。这个模式背后的思想是两次检查字段的值(因此得名 double check):一次没有锁定,然后,如果字段没有初始化,第二次使用锁定。只有当第二次检查指示字段未初始化时,调用才初始化字段。由于初始化字段后没有锁定,因此将字段声明为 volatile 非常重要(详见第 78 条)。下面是这个模式的示例: - -```java -// Double-check idiom for lazy initialization of instance fields -private volatile FieldType field; -private FieldType getField() { - FieldType result = field; - if (result == null) { // First check (no locking) - synchronized(this) { - if (field == null) // Second check (with locking) - field = result = computeFieldValue(); - } - } - return result; -} -``` - -  这段代码可能看起来有点复杂。特别是不清楚是否需要局部变量(result)。该变量的作用是确保 field 在已经初始化的情况下只读取一次。 - -  虽然不是严格必需的,但这可能会提高性能,而且与低级并发编程相比,这更优雅。在我的机器上,上述方法的速度大约是没有局部变量版本的 1.4 倍。虽然您也可以将双重检查模式应用于静态字段,但是没有理由这样做:the lazy initialization holder class idiom 是更好的选择。 - -  双重检查模式的两个变体值得注意。有时候,您可能需要延迟初始化一个实例字段,该字段可以容忍重复初始化。如果您发现自己处于这种情况,您可以使用双重检查模式的变体来避免第二个检查。毫无疑问,这就是所谓的「单检查」模式。它是这样的。注意,field 仍然声明为 volatile: - -```java -// Single-check idiom - can cause repeated initialization! -private volatile FieldType field; -private FieldType getField() { - FieldType result = field; - if (result == null) - field = result = computeFieldValue(); - return result; -} -``` - -  本条目中讨论的所有初始化技术都适用于基本字段和对象引用字段。当双检查或单检查模式应用于数值基本类型字段时,将根据 0(数值基本类型变量的默认值)而不是 null 检查字段的值。 - -  如果您不关心每个线程是否都会重新计算字段的值,并且字段的类型是 long 或 double 之外的基本类型,那么您可以选择在单检查模式中从字段声明中删除 volatile 修饰符。这种变体称为原生单检查模式。它加快了某些架构上的字段访问速度,代价是需要额外的初始化(每个访问该字段的线程最多需要一个初始化)。这绝对是一种奇特的技术,不是日常使用的。 - -  总之,您应该正常初始化大多数字段,而不是延迟初始化。如果必须延迟初始化字段以实现性能目标或为了破坏友有害的初始化循环,则使用适当的延迟初始化技术。对于字段,使用双重检查模式;对于静态字段,则应该使用the lazy initialization holder class idiom。例如,可以容忍重复初始化的实例字段,您还可以考虑单检查模式。 -# 84. 不要依赖线程调度器 - -  当许多线程可以运行时,线程调度器决定哪些线程可以运行以及运行多长时间。任何合理的操作系统都会尝试公平地做出这个决定,但是策略可能会有所不同。因此,编写良好的程序不应该依赖于此策略的细节。**任何依赖线程调度器来保证正确性或性能的程序都可能是不可移植的。** - -  编写健壮、响应快、可移植程序的最佳方法是确保可运行线程的平均数量不显著大于处理器的数量。这使得线程调度器几乎没有选择:它只运行可运行线程,直到它们不再可运行为止。即使在完全不同的线程调度策略下,程序的行为也没有太大的变化。注意,可运行线程的数量与线程总数不相同,后者可能更高。正在等待的线程不可运行。 - -  保持可运行线程数量低的主要技术是让每个线程做一些有用的工作,然后等待更多的工作。**如果线程没有做有用的工作,它们就不应该运行。** 对于 Executor 框架(详见第 80 条),这意味着适当调整线程池的大小 [Goetz06, 8.2],并保持任务短小(但不要太短),否则分派开销依然会损害性能。 - -  线程不应该处于 busy 到 wait 的循环,而应该反复检查一个共享对象,等待它的状态发生变化。除了使程序容易受到线程调度器变化无常的影响之外,繁忙等待还大大增加了处理器的负载,还影响其他人完成工作。作为反面的极端例子,考虑一下 CountDownLatch 的不正确的重构实现: - -```java -// Awful CountDownLatch implementation - busy-waits incessantly! -public class SlowCountDownLatch { - - private int count; - - public SlowCountDownLatch(int count) { - if (count < 0) - throw new IllegalArgumentException(count + " < 0"); - this.count = count; - } - - public void await() { - while (true) { - synchronized(this) { - if (count == 0) - return; - } - } - } - - public synchronized void countDown() { - if (count != 0) - count--; - } -} -``` - -  在我的机器上,当 1000 个线程等待一个锁存器时,SlowCountDownLatch 的速度大约是 Java 的 CountDownLatch 的 10 倍。虽然这个例子看起来有点牵强,但是具有一个或多个不必要运行的线程的系统并不少见。性能和可移植性可能会受到影响。 - -  当面对一个几乎不能工作的程序时,而原因是由于某些线程相对于其他线程没有获得足够的 CPU 时间,那么 **通过调用 Thread.yield 来「修复」程序** 你也许能勉强让程序运行起来,但它是不可移植的。在一个 JVM 实现上提高性能的相同的 yield 调用,在第二个 JVM 实现上可能会使性能变差,而在第三个 JVM 实现上可能没有任何影响。**Thread.yield 没有可测试的语义。** 更好的做法是重构应用程序,以减少并发运行线程的数量。 - -  一个相关的技术是调整线程优先级,类似的警告也适用于此技术,即,线程优先级是 Java 中最不可移植的特性之一。通过调整线程优先级来调优应用程序的响应性并非不合理,但很少情况下是必要的,而且不可移植。试图通过调整线程优先级来解决严重的活性问题是不合理的。在找到并修复潜在原因之前,问题很可能会再次出现。 - -  总之,不要依赖线程调度器来判断程序的正确性。生成的程序既不健壮也不可移植。因此,不要依赖 `Thread.yield` 或线程优先级。这些工具只是对调度器的提示。线程优先级可以少量地用于提高已经工作的程序的服务质量,但绝不应该用于「修复」几乎不能工作的程序。 -# 85. 优先选择 Java 序列化的替代方案 - -  当序列化在 1997 年添加到 Java 中时,它被认为有一定的风险。这种方法曾在研究语言(Modula-3)中尝试过,但从未在生产语言中使用过。虽然程序员不费什么力气就能实现分布式对象,这一点很吸引人,但代价也不小,如:不可见的构造函数、API 与实现之间模糊的界线,还可能会出现正确性、性能、安全性和维护方面的问题。支持者认为收益大于风险,但历史证明并非如此。 - -  在本书之前的版本中描述的安全问题,和人们担心的一样严重。21 世纪初仅停留在讨论的漏洞在接下来的 10 年间变成了真实严重的漏洞,其中最著名的包括 2016 年 11 月对旧金山大都会运输署市政铁路(SFMTA Muni)的勒索软件攻击,导致整个收费系统关闭了两天 [Gallagher16]。 - -  序列化的一个根本问题是它的可攻击范围太大,且难以保护,而且问题还在不断增多:通过调用 ObjectInputStream 上的 readObject 方法反序列化对象图。这个方法本质上是一个神奇的构造函数,可以用来实例化类路径上几乎任何类型的对象,只要该类型实现 Serializable 接口。在反序列化字节流的过程中,此方法可以执行来自任何这些类型的代码,因此所有这些类型的代码都在攻击范围内。 - -  攻击可涉及 Java 平台库、第三方库(如 Apache Commons collection)和应用程序本身中的类。即使坚持履行实践了所有相关的最佳建议,并成功地编写了不受攻击的可序列化类,应用程序仍然可能是脆弱的。引用 CERT 协调中心技术经理 Robert Seacord 的话: - -> Java 反序列化是一个明显且真实的危险源,因为它被应用程序直接和间接地广泛使用,比如 RMI(远程方法调用)、JMX(Java 管理扩展)和 JMS(Java 消息传递系统)。不可信流的反序列化可能导致远程代码执行(RCE)、拒绝服务(DoS)和一系列其他攻击。应用程序很容易受到这些攻击,即使它们本身没有错误[Seacord17]。 - -  攻击者和安全研究人员研究 Java 库和常用的第三方库中的可序列化类型,寻找在反序列化过程中调用的潜在危险活动的方法称为 gadget。多个小工具可以同时使用,形成一个小工具链。偶尔会发现一个小部件链,它的功能足够强大,允许攻击者在底层硬件上执行任意的本机代码,允许提交精心设计的字节流进行反序列化。这正是 SFMTA Muni 袭击中发生的事情。这次袭击并不是孤立的。不仅已经存在,而且还会有更多。 - -  不使用任何 gadget,你都可以通过对需要很长时间才能反序列化的短流进行反序列化,轻松地发起拒绝服务攻击。这种流被称为反序列化炸弹 [Svoboda16]。下面是 Wouter Coekaerts 的一个例子,它只使用哈希集和字符串 [Coekaerts15]: - -```java -static byte[] bomb() { - Set root = new HashSet<>(); - Set s1 = root; - Set s2 = new HashSet<>(); - for (int i = 0; i < 100; i++) { - Set t1 = new HashSet<>(); - Set 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 实例组成,每个实例包含 3 个或更少的对象引用。整个流的长度为 5744 字节,但是在你对其进行反序列化之前,资源就已经耗尽了。问题在于,反序列化 HashSet 实例需要计算其元素的哈希码。根哈希集的 2 个元素本身就是包含 2 个哈希集元素的哈希集,每个哈希集元素包含 2 个哈希集元素,以此类推,深度为 100。因此,反序列化 Set 会导致 hashCode 方法被调用超过 2100 次。除了反序列化会持续很长时间之外,反序列化器没有任何错误的迹象。生成的对象很少,并且堆栈深度是有界的。 - -  那么你能做些什么来抵御这些问题呢?当你反序列化一个你不信任的字节流时,你就会受到攻击。**避免序列化利用的最好方法是永远不要反序列化任何东西。** 用 1983 年电影《战争游戏》(WarGames)中名为约书亚(Joshua)的电脑的话来说,「唯一的制胜绝招就是不玩。」**没有理由在你编写的任何新系统中使用 Java 序列化。** 还有其他一些机制可以在对象和字节序列之间进行转换,从而避免了 Java 序列化的许多危险,同时还提供了许多优势,比如跨平台支持、高性能、大量工具和广泛的专家社区。在本书中,我们将这些机制称为跨平台结构数据表示。虽然其他人有时将它们称为序列化系统,但本书避免使用这种说法,以免与 Java 序列化混淆。 - -  以上所述技术的共同点是它们比 Java 序列化简单得多。它们不支持任意对象图的自动序列化和反序列化。相反,它们支持简单的结构化数据对象,由一组「属性-值」对组成。只有少数基本数据类型和数组数据类型得到支持。事实证明,这个简单的抽象足以构建功能极其强大的分布式系统,而且足够简单,可以避免 Java 序列化从一开始就存在的严重问题。 - -  领先的跨平台结构化数据表示是 JSON 和 Protocol Buffers,也称为 protobuf。JSON 由 Douglas Crockford 设计用于浏览器与服务器通信,Protocol Buffers 由谷歌设计用于在其服务器之间存储和交换结构化数据。尽管这些技术有时被称为「中性语言」,但 JSON 最初是为 JavaScript 开发的,而 protobuf 是为 c++ 开发的;这两种技术都保留了其起源的痕迹。 - -  JSON 和 protobuf 之间最显著的区别是 JSON 是基于文本的,并且是人类可读的,而 protobuf 是二进制的,但效率更高;JSON 是一种专门的数据表示,而 protobuf 提供模式(类型)来记录和执行适当的用法。虽然 protobuf 比 JSON 更有效,但是 JSON 对于基于文本的表示非常有效。虽然 protobuf 是一种二进制表示,但它确实提供了另一种文本表示,可用于需要具备人类可读性的场景(pbtxt)。 - -  如果你不能完全避免 Java 序列化,可能是因为你需要在遗留系统环境中工作,那么你的下一个最佳选择是 **永远不要反序列化不可信的数据。** 特别要注意,你不应该接受来自不可信来源的 RMI 流量。Java 的官方安全编码指南说:「反序列化不可信的数据本质上是危险的,应该避免。」这句话是用大号、粗体、斜体和红色字体设置的,它是整个文档中唯一得到这种格式处理的文本。[Java-secure] - -  如果无法避免序列化,并且不能绝对确定反序列化数据的安全性,那么可以使用 Java 9 中添加的对象反序列化筛选,并将其移植到早期版本(java.io.ObjectInputFilter)。该工具允许你指定一个过滤器,该过滤器在反序列化数据流之前应用于数据流。它在类粒度上运行,允许你接受或拒绝某些类。默认接受所有类,并拒绝已知潜在危险类的列表称为黑名单;在默认情况下拒绝其他类,并接受假定安全的类的列表称为白名单。**优先选择白名单而不是黑名单,** 因为黑名单只保护你免受已知的威胁。一个名为 Serial Whitelist Application Trainer(SWAT)的工具可用于为你的应用程序自动准备一个白名单 [Schneider16]。过滤工具还将保护你免受过度内存使用和过于深入的对象图的影响,但它不能保护你免受如上面所示的序列化炸弹的影响。 - -  不幸的是,序列化在 Java 生态系统中仍然很普遍。如果你正在维护一个基于 Java 序列化的系统,请认真考虑迁移到跨平台的结构化数据,尽管这可能是一项耗时的工作。实际上,你可能仍然需要编写或维护一个可序列化的类。编写一个正确、安全、高效的可序列化类需要非常小心。本章的其余部分将提供何时以及如何进行此操作的建议。 - -  总之,序列化是危险的,应该避免。如果你从头开始设计一个系统,可以使用跨平台的结构化数据,如 JSON 或 protobuf。不要反序列化不可信的数据。如果必须这样做,请使用对象反序列化过滤,但要注意,它不能保证阻止所有攻击。避免编写可序列化的类。如果你必须这样做,一定要非常小心。 - - - -# 86. 非常谨慎地实现 Serializable - -  使类的实例可序列化非常简单,只需实现 Serializable 接口即可。因为这很容易做到,所以有一个普遍的误解,认为序列化只需要程序员付出很少的努力。而事实上要复杂得多。虽然使类可序列化的即时代价可以忽略不计,但长期代价通常是巨大的。 - -  **实现 Serializable 接口的一个主要代价是,一旦类的实现被发布,它就会降低更改该类实现的灵活性。** 当类实现 Serializable 时,其字节流编码(或序列化形式)成为其导出 API 的一部分。一旦广泛分发了一个类,通常就需要永远支持序列化的形式,就像需要支持导出 API 的所有其他部分一样。如果你不努力设计自定义序列化形式,而只是接受默认形式,则序列化形式将永远绑定在类的原始内部实现上。换句话说,如果你接受默认的序列化形式,类中私有的包以及私有实例字段将成为其导出 API 的一部分,此时最小化字段作用域(详见第 15 条)作为信息隐藏的工具,将失去其有效性。 - -  如果你接受默认的序列化形式,然后更改了类的内部实现,则会导致与序列化形式不兼容。试图使用类的旧版本序列化实例,再使用新版本反序列化实例的客户端(反之亦然)程序将会失败。当然,可以在维护原始序列化形式的同时更改内部实现(使用 ObjectOutputStream.putFields 或 ObjectInputStream.readFields),但这可能会很困难,并在源代码中留下明显的缺陷。如果你选择使类可序列化,你应该仔细设计一个高质量的序列化形式,以便长期使用(详见第 87 和 90 条)。这样做会增加开发的初始成本,但是这样做是值得的。即使是设计良好的序列化形式,也会限制类的演化;而设计不良的序列化形式,则可能会造成严重后果。 - -  可序列化会使类的演变受到限制,施加这种约束的一个简单示例涉及流的唯一标识符,通常称其为串行版本 UID。每个可序列化的类都有一个与之关联的唯一标识符。如果你没有通过声明一个名为 serialVersionUID 的静态 final long 字段来指定这个标识符,那么系统将在运行时对类应用加密散列函数(SHA-1)自动生成它。这个值受到类的名称、实现的接口及其大多数成员(包括编译器生成的合成成员)的影响。如果你更改了其中任何一项,例如,通过添加一个临时的方法,生成的序列版本 UID 就会更改。如果你未能声明序列版本 UID,兼容性将被破坏,从而在运行时导致 InvalidClassException。 - -  **实现 Serializable 接口的第二个代价是,增加了出现 bug 和安全漏洞的可能性(第85项)。** 通常,对象是用构造函数创建的;序列化是一种用于创建对象的超语言机制。无论你接受默认行为还是无视它,反序列化都是一个「隐藏构造函数」,其他构造函数具有的所有问题它都有。由于没有与反序列化关联的显式构造函数,因此很容易忘记必须让它能够保证所有的不变量都是由构造函数建立的,并且不允许攻击者访问正在构造的对象内部。依赖于默认的反序列化机制,会让对象轻易地遭受不变性破坏和非法访问(详见第 88 条)。 - -  **实现 Serializable 接口的第三个代价是,它增加了与发布类的新版本相关的测试负担。** 当一个可序列化的类被修改时,重要的是检查是否可以在新版本中序列化一个实例,并在旧版本中反序列化它,反之亦然。因此,所需的测试量与可序列化类的数量及版本的数量成正比,工作量可能很大。你必须确保「序列化-反序列化」过程成功,并确保它生成原始对象的无差错副本。如果在第一次编写类时精心设计了自定义序列化形式,那么测试的工作量就会减少(详见第 87 和 90 条)。 - -  **实现 Serializable 接口并不是一个轻松的决定。** 如果一个类要参与一个框架,该框架依赖于 Java 序列化来进行对象传输或持久化,这对于类来说实现 Serializable 接口就是非常重要的。此外,如果类 A 要成为另一个类 B 的一个组件,类 B 必须实现 Serializable 接口,若类 A 可序列化,它就会更易于被使用。然而,与实现 Serializable 相关的代价很多。每次设计一个类时,都要权衡利弊。历史上,像 BigInteger 和 Instant 这样的值类实现了 Serializable 接口,集合类也实现了 Serializable 接口。表示活动实体(如线程池)的类很少情况适合实现 Serializable 接口。 - -  **为继承而设计的类(详见第 19 条)很少情况适合实现 Serializable 接口,接口也很少情况适合扩展它。** 违反此规则会给扩展类或实现接口的任何人带来很大的负担。有时,违反规则是恰当的。例如,如果一个类或接口的存在主要是为了参与一个要求所有参与者都实现 Serializable 接口的框架,那么类或接口实现或扩展 Serializable 可能是有意义的。 - -  在为了继承而设计的类中,Throwable 类和 Component 类都实现了 Serializable 接口。正是因为 Throwable 实现了 Serializable 接口,RMI 可以将异常从服务器发送到客户端;Component 类实现了 Serializable 接口,因此可以发送、保存和恢复 GUI,但即使在 Swing 和 AWT 的鼎盛时期,这个工具在实践中也很少使用。 - -  如果你实现了一个带有实例字段的类,它同时是可序列化和可扩展的,那么需要注意几个风险。如果实例字段值上有任何不变量,关键是要防止子类覆盖 finalize 方法,可以通过覆盖 finalize 并声明它为 final 来做到。最后,如果类的实例字段初始化为默认值(整数类型为 0,布尔值为 false,对象引用类型为 null),那么必须添加 readObjectNoData 方法: - -```java -// readObjectNoData for stateful extendable serializable classes -private void readObjectNoData() throws InvalidObjectException { - throw new InvalidObjectException("Stream data required"); -} -``` - -  这个方法是在 Java 4 中添加的,涉及将可序列化超类添加到现有可序列化类 [Serialization, 3.5] 的特殊情况。 - -  关于不实现 Serializable 的决定,有一个警告。如果为继承而设计的类不可序列化,则可能需要额外的工作来编写可序列化的子类。子类的常规反序列化,要求超类具有可访问的无参数构造函数 [Serialization, 1.10]。如果不提供这样的构造函数,子类将被迫使用序列化代理模式(详见第 90 条)。 - -  **内部类(详见第 24 条)不应该实现 Serializable。** 它们使用编译器生成的合成字段存储对外围实例的引用,并存储来自外围的局部变量的值。这些字段与类定义的对应关系,就和没有指定匿名类和局部类的名称一样。因此,内部类的默认序列化形式是不确定的。但是,静态成员类可以实现 Serializable 接口。 -# 87. 考虑使用自定义的序列化形式 - -  当你在时间紧迫的情况下编写类时,通常应该将精力集中在设计最佳的 API 上。有时,这意味着发布一个「一次性」实现,你也知道在将来的版本中会替换它。通常这不是一个问题,但是如果类实现 Serializable 接口并使用默认的序列化形式,你将永远无法完全摆脱这个「一次性」的实现。它将永远影响序列化的形式。这不仅仅是一个理论问题。这种情况发生在 Java 库中的几个类上,包括 BigInteger。 - -  **在没有考虑默认序列化形式是否合适之前,不要接受它。** 接受默认的序列化形式应该是一个三思而后行的决定,即从灵活性、性能和正确性的角度综合来看,这种编码是合理的。一般来说,设计自定义序列化形式时,只有与默认序列化形式所选择的编码在很大程度上相同时,才应该接受默认的序列化形式。 - -  对象的默认序列化形式,相对于它的物理表示法而言是一种比较有效的编码形式。换句话说,它描述了对象中包含的数据以及从该对象可以访问的每个对象的数据。它还描述了所有这些对象相互关联的拓扑结构。理想的对象序列化形式只包含对象所表示的逻辑数据。它独立于物理表征。 - -  **如果对象的物理表示与其逻辑内容相同,则默认的序列化形式可能是合适的。** 例如,默认的序列化形式对于下面的类来说是合理的,它简单地表示一个人的名字: - -```java -// Good candidate for default serialized form -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; - ... // Remainder omitted -} -``` - -  从逻辑上讲,名字由三个字符串组成,分别表示姓、名和中间名。Name 的实例字段精确地反映了这个逻辑内容。 - -  **即使你认为默认的序列化形式是合适的,你通常也必须提供 readObject 方法来确保不变性和安全性。** 对于 Name 类而言, readObject 方法必须确保字段 lastName 和 firstName 是非空的。第 88 条和第 90 条详细讨论了这个问题。 - -  注意,虽然 lastName、firstName 和 middleName 字段是私有的,但是它们都有文档注释。这是因为这些私有字段定义了一个公共 API,它是类的序列化形式,并且必须对这个公共 API 进行文档化。`@serial` 标记的存在告诉 Javadoc 将此文档放在一个特殊的页面上,该页面记录序列化的形式。 - -  与 Name 类不同,考虑下面的类,它是另一个极端。它表示一个字符串列表(使用标准 List 实现可能更好,但此时暂不这么做): - -```java -// Awful candidate for default serialized form -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 -} -``` - -  从逻辑上讲,这个类表示字符串序列。在物理上,它将序列表示为双向链表。如果接受默认的序列化形式,该序列化形式将不遗余力地镜像出链表中的所有项,以及这些项之间的所有双向链接。 - -  **当对象的物理表示与其逻辑数据内容有很大差异时,使用默认的序列化形式有四个缺点:** - -- **它将导出的 API 永久地绑定到当前的内部实现。** 在上面的例子中,私有 `StringList.Entry` 类成为公共 API 的一部分。如果在将来的版本中更改了实现,StringList 类仍然需要接受链表形式的输出,并产生链表形式的输出。这个类永远也摆脱不掉处理链表项所需要的所有代码,即使不再使用链表作为内部数据结构。 -- **它会占用过多的空间。** 在上面的示例中,序列化的形式不必要地表示链表中的每个条目和所有链接关系。这些链表项以及链接只不过是实现细节,不值得记录在序列化形式中。因为这样的序列化形式过于庞大,将其写入磁盘或通过网络发送将非常慢。 -- **它会消耗过多的时间。** 序列化逻辑不知道对象图的拓扑结构,因此必须遍历开销很大的图。在上面的例子中,只要遵循 next 的引用就足够了。 -- **它可能导致堆栈溢出。** 默认的序列化过程执行对象图的递归遍历,即使对于中等规模的对象图,这也可能导致堆栈溢出。用 1000-1800 个元素序列化 StringList 实例会在我的机器上生成一个 StackOverflowError。令人惊讶的是,序列化导致堆栈溢出的最小列表大小因运行而异(在我的机器上)。显示此问题的最小列表大小可能取决于平台实现和命令行标志;有些实现可能根本没有这个问题。 - -  StringList 的合理序列化形式就是列表中的字符串数量,然后是字符串本身。这构成了由 StringList 表示的逻辑数据,去掉了其物理表示的细节。下面是修改后的 StringList 版本,带有实现此序列化形式的 writeObject 和 readObject 方法。提醒一下,transient 修饰符表示要从类的默认序列化表单中省略该实例字段: - -```java -// StringList with a reasonable custom serialized form -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 -} -``` - -  writeObject 做的第一件事是调用 defaultWriteObject, readObject 做的第一件事是调用 defaultReadObject,即使 StringList 的所有字段都是 transient 的。你可能听说过,如果一个类的所有实例字段都是 transient 的,那么你可以不调用 defaultWriteObject 和 defaultReadObject,但是序列化规范要求你无论如何都要调用它们。这些调用的存在使得在以后的版本中添加非瞬态实例字段成为可能,同时保留了向后和向前兼容性。如果实例在较晚的版本中序列化,在较早的版本中反序列化,则会忽略添加的字段。如果早期版本的 readObject 方法调用 defaultReadObject 失败,反序列化将失败,并出现 StreamCorruptedException。 - -  注意,writeObject 方法有一个文档注释,即使它是私有的。这类似于 Name 类中私有字段的文档注释。这个私有方法定义了一个公共 API,它是序列化的形式,并且应该对该公共 API 进行文档化。与字段的 `@serial` 标记一样,方法的 `@serialData`标记告诉 Javadoc 实用工具将此文档放在序列化形式页面上。 - -  为了给前面的性能讨论提供一定的伸缩性,如果平均字符串长度是 10 个字符,那么经过修改的 StringList 的序列化形式占用的空间大约是原始字符串序列化形式的一半。在我的机器上,序列化修订后的 StringList 的速度是序列化原始版本的两倍多,列表长度为 10。最后,在修改后的形式中没有堆栈溢出问题,因此对于可序列化的 StringList 的大小没有实际的上限。 - -  虽然默认的序列化形式对 StringList 不好,但是对于某些类来说,情况会更糟。对于 StringList,默认的序列化形式是不灵活的,并且执行得很糟糕,但是它是正确的,因为序列化和反序列化 StringList 实例会生成原始对象的无差错副本,而所有不变量都是完整的。对于任何不变量绑定到特定于实现的细节的对象,情况并非如此。 - -  例如,考虑哈希表的情况。物理表示是包含「键-值」项的哈希桶序列。一个项所在的桶是其键的散列代码的函数,通常情况下,不能保证从一个实现到另一个实现是相同的。事实上,它甚至不能保证每次运行都是相同的。因此,接受哈希表的默认序列化形式将构成严重的 bug。对哈希表进行序列化和反序列化可能会产生一个不变量严重损坏的对象。 - -  无论你是否接受默认的序列化形式,当调用 defaultWriteObject 方法时,没有标记为 transient 的每个实例字段都会被序列化。因此,可以声明为 transient 的每个实例字段都应该做这个声明。这包括派生字段,其值可以从主数据字段(如缓存的哈希值)计算。它还包括一些字段,这些字段的值与 JVM 的一个特定运行相关联,比如表示指向本机数据结构指针的 long 字段。**在决定使字段非 transient 之前,请确信它的值是对象逻辑状态的一部分。** 如果使用自定义序列化表单,大多数或所有实例字段都应该标记为 transient,如上面的 StringList 示例所示。 - -  如果使用默认的序列化形式,并且标记了一个或多个字段为 transient,请记住,当反序列化实例时,这些字段将初始化为默认值:对象引用字段为 null,数字基本类型字段为 0,布尔字段为 false [JLS, 4.12.5]。如果这些值对于任何 transient 字段都是不可接受的,则必须提供一个 readObject 方法,该方法调用 defaultReadObject 方法,然后将 transient 字段恢复为可接受的值(详见第 88 条)。或者,可以采用延迟初始化(详见第 83 条),在第一次使用这些字段时初始化它们。 - -  无论你是否使用默认的序列化形式,**必须对对象序列化强制执行任何同步操作,就像对读取对象的整个状态的任何其他方法强制执行的那样。** 例如,如果你有一个线程安全的对象(详见第 82 条),它通过同步每个方法来实现线程安全,并且你选择使用默认的序列化形式,那么使用以下 write-Object 方法: - -```java -// writeObject for synchronized class with default serialized form -private synchronized void writeObject(ObjectOutputStream s) throws IOException { - s.defaultWriteObject(); -} -``` - -  如果将同步放在 writeObject 方法中,则必须确保它遵守与其他活动相同的锁排序约束,否则将面临资源排序死锁的风险 [Goetz06, 10.1.5]。 - -  **无论选择哪种序列化形式,都要在编写的每个可序列化类中声明显式的序列版本 UID。** 这消除了序列版本 UID 成为不兼容性的潜在来源(详见第 86 条)。这么做还能获得一个小的性能优势。如果没有提供序列版本 UID,则需要执行高开销的计算在运行时生成一个 UID。 - -  声明序列版本 UID 很简单,只要在你的类中增加这一行: - -```java -private static final long serialVersionUID = randomLongValue; -``` - -  如果你编写一个新类,为 randomLongValue 选择什么值并不重要。你可以通过在类上运行 serialver 实用工具来生成该值,但是也可以凭空选择一个数字。串行版本 UID 不需要是唯一的。如果修改缺少串行版本 UID 的现有类,并且希望新版本接受现有的序列化实例,则必须使用为旧版本自动生成的值。你可以通过在类的旧版本上运行 serialver 实用工具(序列化实例存在于旧版本上)来获得这个数字。 - -  如果你希望创建一个新版本的类,它与现有版本不兼容,如果更改序列版本 UID 声明中的值,这将导致反序列化旧版本的序列化实例的操作引发 InvalidClassException。**不要更改序列版本 UID,除非你想破坏与现有序列化所有实例的兼容性。** - -  总而言之,如果你已经决定一个类应该是可序列化的(详见第 86 条),那么请仔细考虑一下序列化的形式应该是什么。只有在合理描述对象的逻辑状态时,才使用默认的序列化形式;否则,设计一个适合描述对象的自定义序列化形式。设计类的序列化形式应该和设计导出方法花的时间应该一样多,都应该严谨对待(详见第 51 条)。正如不能从未来版本中删除导出的方法一样,也不能从序列化形式中删除字段;必须永远保存它们,以确保序列化兼容性。选择错误的序列化形式可能会对类的复杂性和性能产生永久性的负面影响。 -# 88. 保护性的编写 readObject 方法 - -  第 50 条介绍了一个不可变的日期范围类,它包含可变的私有变量 Date。该类通过在其构造器和访问方法(accessor)中保护性的拷贝 Date 对象,极力维护其约束条件和不可变性。该类代码如下所示: - -```java -// 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 产生的对象会违反它所属类的约束条件,这时问题就产生了。这种字节流可以用来创建一个不可能的对象(impossible object),这时利用普通构造器无法创建的。 - -  假设我们仅仅在 Period 类的声明加上了 implements Serializable 字样。那么这个丑陋的程序代码将会产生一个 Period 实例,他的结束时间比起始时间还早。对于高位 byte 值进行强制类型转换是 Java 缺少 byte 并且做出 byte 类型签名的不幸决定的后果: - -```java -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 的 byte 常量数组是这样产生的:首先对一个正常的 Period 实例进行序列化,然后对得到的字节流进行手工编辑。对于这个例子而言,字节流的细节并不重要,如果你对此十分好奇,可以在《Java Object Serialization Specification》[Serialization, 6] 中查到有关序列化字节流格式的描述信息。如果运行这个程序,它会打印出“ Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984”。主要把 Period 类声明成为可序列化的,这会使我们创建出其违反类约束条件的对象。 - -  为了修整这个问题,可以为 Period 提供一个 readObject 方法,该方法首先调用 defaultReadObject,然后检查被反序列化之后的对象有效性。如果有效性检查失败,readObject 方法就会抛出一个 InvalidObjectException 异常,这使得反序列化过程不能成功的完成: - -```java -// 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 实例。如下的类演示了这种攻击方式: -  -```java -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); - } - } -} -``` - -  要查看正在进行的攻击,请运行以下程序: - -```java -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); -} -``` - -  在我本地机器上运行这个程序产生的输出结果如下: - -```java -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 类的约束条件不会遭到破坏,以保持它的不可变性: - -```java -// 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 方法,并且取消了 start 和 end 的 final 修饰符之后,MutablePeriod 类将不再有效。此时,上面的攻击程序会产生如下输出: - -```java -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 -``` - -  有一个简单的“石蕊”测试,可以用来确定默认的 readObject 方法是否可以被接受。测试方法:增加一个公有的构造器,其参数对应于该对象中每个非 transient 的字段,并且无论参数的值是什么,都是不进行检查就可以保存到相应的字段中。对于这样的做法,你是否会感到很舒适?如果你对这个问题的回答是否定的,就必须提供一个显式的 readObject 方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝。另一种方法是,可以使用序列化代理模式(serialization proxy pattern),详见第 90 条。强烈建议使用这个模式,因为它分担了安全反序列化的部门工作。 - -  对于非 final 的可序列化的类,在 readObject 方法和构造器之间还有其他类似的地方。与构造器一样,readObject 方法不可以调用可被覆盖的方法,无论是直接调用还是间接调用都不可以(详见 19 条)。如果违反了这条规则,并且覆盖了该方法,被覆盖的方法将在子类的状态被反序列化之前先运行。这个程序很可能会失败[Bloch05, Puzzle 91]。 - -  总而言之,在编写 readObject 方法的时候,都要这样想:你正在编写一个公有的构造器,无论给它传递什么样的字节流,它都必须产生一个有效的实例。不要假设这个字节流一定代表着一个真正被序列化的实例。虽然在本条目的例子中,类使用了默认的序列化形式,但是所有讨论到的有可能发生的问题也同样适用于自定义序列化形式的类。下面以摘要的形式给出一些指导方针,有助于编写出更健壮的 readObject 方法。 - - - 类中的对象引用字段必须保持为私有属性,要保护性的拷贝这些字段中的每个对象。不可变类中的可变组件就属于这一类别 - - 对于任何约束条件,如果检查失败就抛出一个 InvalidObjectException 异常。这些检查动作应该跟在所有的保护性拷贝之后。 - - 如果整个对象图在被反序列化之后必须进行验证,就应该使用 ObjectInputValidation 接口(本书没有讨论)。 - - 无论是直接方法还是间接方法,都不要调用类中任何可被覆盖的方法。 -# 89. 对于实例控制,枚举类型优于 readResolve - -  第 3 条讲述了 Singletion(单例)模式,并且给出了以下这个 Singletion 示例。这个类限制了对其构造器的访问,以确保永远只创建一个实例。 - -```java -public class Elvis { - public static final Elvis INSTANCE = new Elvis(); - - private Elvis() { ... } - public void leaveTheBuilding() { ... } -} -``` - -  正如在第 3 条中所提到的,如果在这个类上面增加 `implements Serializable` 的字样,它就不是一个单例。无论该类使用了默认的序列化形式,还是自定义的序列化形式(详见 87 条),都没有关系;也跟它是否使用了显式的 `readObject`(详见 88 条)无关。任何一个 `readObject` 方法,不管是显式的还是默认的,都会返回一个新建的实例,这个新建的实例不同于类初始化时创建的实例。 - -  `readResolve` 特性允许你用 `readObject` 创建的实例代替另外一个实例[Serialization, 3.7]。对于一个正在被反序列化的对象,如果它的类定义了一个 `readResolve` 方法,并且具备正确的声明,那么在反序列化之后,新建对象上的 `readResolve` 方法就会被调用。然后,该方法返回的对象引用将会被回收,取代新建的对象。这个特性在绝大多数用法中,指向新建对象的引用不会再被保留,因此成为垃圾回收的对象。 - -  如果 Elvis 类要实现 `Serializable` 接口,下面的 `readResolve` 方法就足以保证它的单例属性: - -```java -// 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` 实例。因此 `Elvis` 实例的序列化形式不应该包含任何实际的数据;所有的实例字段都应该被声明为 `transient`。事实上,**如果依赖 `readResolve` 进行实例控制,带有对象引用类型的所有实例字段都必须声明为 `transient`。** 否则,那种破釜沉舟式的攻击者,就有可能在 `readResolve` 方法运行之前,保护指向反序列化对象的引用,采用的方式类似于在第 88 条中提到的 `MutablePeriod` 攻击。 - -  这种攻击有点复杂,但是背后的思想十分简单。如果单例包含一个非 `transient` 的对象引用字段,这个字段的内容就可以在单例的 `readResolve` 方法之前被反序列化。当对象引用字段的内容被反序列化时,它就允许一个精心制作的流“盗用”指向最初被反序列化的单例对象引用。 - -  以下是它更详细的工作原理。首先编写一个“盗用者”类,它既有 `readResolve` 方法,又有实例字段,实例字段指向被序列化的单例的引用,“盗用者”就“潜伏”在其中。在序列化流中,用“盗用者”的 `readResolve` 方法运行时,它的实例字段仍然引用部分反序列化(并且还没有被解析)的 Singletion。 - -  “盗用者”的 `readResolve` 方法从它的实例字段中将引用复制到静态字段中,以便该引用可以在 `readResolve` 方法运行之后被访问到。然后这个方法为它所藏身的那个域返回一个正确的类型值。如果没有这么做,当序列化系统试着将“盗用者”引用保存到这个字段时,虚拟机就会抛出 `ClassCastException`。 - -  为了更具体的说明这一点,我们以如下这个单例模式为例: - -```java -// Broken singleton - has nontransient object reference field! -public class Elvis implements Serializable { - public static final Elvis INSTANCE = new Elvis(); - private Elvis() { } - - private String[] favoriteSongs = - { "Hound Dog", "Heartbreak Hotel" }; - public void printFavorites() { - System.out.println(Arrays.toString(favoriteSongs)); - } - - private Object readResolve() { - return INSTANCE; - } -} -``` - -  如下“盗用者”类,是根据以上描述构造的: - -```java -public class ElvisStealer implements Serializable { - static Elvis impersonator; - private static final long serialVersionUID = 0; - private Elvis payload; - - private Object readResolve() { - // Save a reference to the "unresolved" Elvis instance - impersonator = payload; - - // Return object of correct type for favoriteSongs field - return new String[] { "A Fool Such as I" }; - } -} -``` - -  下面是一个不完整的程序,它反序列化一个手工制作的流,为那个有缺陷的单例产生两个截然不同的实例。这个程序省略了反序列化方法,因为它与第 88 条一样。 - -```java -public class ElvisImpersonator { - // Byte stream couldn't have come from a real Elvis instance! - private static final byte[] serializedForm = { - (byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05, 0x45, - 0x6c, 0x76, 0x69, 0x73, (byte) 0x84, (byte) 0xe6, (byte) 0x93, 0x33, - (byte) 0xc3, (byte) 0xf4, (byte) 0x8b, 0x32, 0x02, 0x00, 0x01, 0x4c, - 0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x53, - 0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61, 0x76, - 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45, 0x6c, - 0x76, 0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x4c, - 0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74, 0x00, - 0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b, 0x78, 0x70, 0x71, - 0x00, 0x7e, 0x00, 0x02 - }; - - public static void main(String[] args) { - // Initializes ElvisStealer.impersonator and returns - // the real Elvis (which is Elvis.INSTANCE) - Elvis elvis = (Elvis) deserialize(serializedForm); - Elvis impersonator = ElvisStealer.impersonator; - elvis.printFavorites(); - impersonator.printFavorites(); - } -} -``` - -  这个程序会产生如下的输出,最终证明可以创建两个截然不同的 `Elvis` 实例(包含两种不同的音乐品味): - -``` -[Hound Dog, Heartbreak Hotel] -[A Fool Such as I] -``` - -  通过将 `favoriteSongs` 字段声明为 `transient`,可以修复这个问题,但是最好把 `Elvis` 做成一个单元素的枚举类型(详见第 3 条)。就如 `ElvisStealer` 攻击所示范的,用 `readResolve` 方法防止“临时”被反序列化的实例收到攻击者的访问,这种方法十分脆弱需要万分谨慎。 - -  如果将一个可序列化的实例受控的类编写为枚举,Java 就可以绝对保证出了所声明的常量之外,不会有其他实例,除非攻击者恶意的使用了享受特权的方法。如 `AccessibleObject.setAccessible`。能够做到这一点的任何一位攻击者,已经拥有了足够的特权来执行任意的本地代码,后果不堪设想。将 Elvis 写成枚举的例子如下所示: -```java -// 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 进行实例控制并不过时。如果必须编写可序列化的实力受控的类,在编译时还不知道它的实例,你就无法将类表示成为一个枚举类型。 - -  **readResolve 的可访问性(accessibility)十分重要。** 如果把 `readResolve` 方法放在一个 `final` 类上面,它应该是私有的。如果把 `readResolve` 方法放在一个非 `final` 类上,就必须认真考虑它的的访问性。如果它是私有的,就不适用于任何一个子类。如果它是包级私有的,就适用于同一个包内的子类。如果它是受保护的或者是公开的,并且子类没有覆盖它,对序列化的子类进行反序列化,就会产生一个超类实例,这样可能会导致 `ClassCastException` 异常。 - -  总而言之,应该尽可能的使用枚举类型来实施实例控制的约束条件。如果做不到,同时又需要一个即可序列化又可以实例受控的类,就必须提供一个 `readResolve` 方法,并确保该类的所有实例化字段都被基本类型,或者是 -`transient` 的。 -# 90. 考虑用序列化代理代替序列化实例 - -  正如 85 条和第 86 条提到的,以及本章一直在讨论的,决定实现 Serializable 接口,会增加出错和出现安全问题的可能性,因为它允许利用语言之外的机制来创建实例,而不是使用普通的构造器。然而,有一只方法可以极大的减少这些风险。就是序列化代理模式(seralization proxy pattern)。 - -  序列化代理模式相当简单。首先,为可序列化的类设计一个私有的静态嵌套类,精确地表示外围类的逻辑状态。这个嵌套类被称为序列化代理(seralization proxy),它应该有一个单独的构造器,其参数类型就是那个外围类。这个构造器只是从它的参数中复制数据:它不需要进行任何一致性检验或者保护性拷贝。从设计的角度看,序列化代理的默认序列化形式是外围类最好的序列化形式。外围类及其序列代理都必须声明实现 Serializable 接口。 - -  例如,以第 50 条中编写不可变的 Period 类为例,它在第 88 条中被作为可序列化的。以下是一个类的序列化代理。Period 类是如此简单,以致于它的序列化代理有着与类完全相同的字段。 - -```java -// Serialization proxy for Period class -private static class SerializationProxy implements Serializable { - private final Date start; - private final Date end; - SerializationProxy(Period p) { - this.start = p.start; - this.end = p.end; - } - private static final long serialVersionUID = - 234098243823485285L; // Any number will do (Item 87) -} -``` - -  接下来,将下面的 writeReplace 方法添加到外围类中。通过序列化代理,这个方法可以被逐字的复制到任何类中。 - -```java -// writeReplace method for the serialization proxy pattern -private Object writeReplace() { - return new SerializationProxy(this); -} -``` - -  这个方法的存在就是导致系统产生一个 SerializationProxy 实例,代替外围类的实例。换句话说 writeReplace 方法在序列化之前,将外围类的实例转变成了它的序列化代理。 - -  有了 writeReplace 方法之后,序列化系统永远不会产生外围类的序列化实例,但是攻击者有可能伪造企图违反该类约束条件的示例。为了防止此类攻击,只需要在外围类中添加如下 readObject 方法。 - -```java -// readObject method for the serialization proxy pattern -private void readObject(ObjectInputStream stream) - throws InvalidObjectException { - throw new InvalidObjectException("Proxy required"); -} -``` - -  最后在 SerializationProxy 类中提供一个 readResolve 方法,他返回一个逻辑上等价的外围类的实例。这个方法的出现,导致序列化系统在反序列化的时候将序列化代理转为外围类的实例。 - -  这个 readResolve 方法仅仅利用它的公有 API 创建外围类的一个实例,这正是该模式的魅力所在它极大的消除了序列化机制中语言之外的特征,因为反序列化实例是利用与任何其他实例相同的构造器、静态工厂和方法而创建的。这样你就不必单独确保被反序列化的实例一定要遵守类的约束条件。如果该类的静态工厂或者构造器建立了这些约束条件,并且它的实例方法保持着这些约束条件,你就可以确信序列化也确保着这些约束条件。 - -  以下是上述的 Period.SerializationProxy 的 readResolve 方法: - -```java -// readResolve method for Period.SerializationProxy -private Object readResolve() { - return new Period(start, end); // Uses public constructor -} -``` - -  正如保护性拷贝方法一样(详见 88 条),序列化代理方式可以阻止伪字节流的攻击(详见 88 条)以及内部字段的盗用攻击(详见 88 条)。与前两种方法不同,这种方法允许 Period 类的字段为 final,为了确保 Period 类是真正不可变的(详见 17 条),这一点非常重要。与前两种方法不同的还有,这种方法不需要太费心思。你不必知道哪些字段可能受到狡猾的序列化攻击的威胁,你也不必显式的执行有效性检查,作为反序列化的一部分。 - -  还有另外一种方法,使用这种方法时,序列化代理模式的功能比保护性拷贝的更加强大。序列化代理模式允许反序列化实例有着与原始序列化实例不同的类。你可能认为这在实际应用中没有什么作用,其实不然。 - -  以 EnumSet 的情况为例(详见 36 条)。这个类没有公有的构造器,只有静态工厂。从客户端的角度来看,他们返回 EnumSet 实例,但是在 OpenJDK 的实现,它们返回的是两种子类之一,具体取决于底层枚举类型的大小。如果底层的枚举类型有 64 个或者少于 64 个的元素,静态工厂就返回一个 RegularEnumSet;它们就返回一个 JunmboEnumSet。 - -  现在考虑这种情况:如果序列化一个枚举类型,它的枚举有 60 个元素,然后给这个枚举类型再增加 5 个元素,之后反序列化这个枚举集合。当它被序列化的时候,是一个 RegularEnumSet 实例,但是它一旦被反序列化,他就变成了 JunmboEnumSet 实例。实际发生的情况也正是如此,因为 EnumSet 使用序列化代理模式如果你感兴趣,可以看看如下的 EnumSet 序列化代理,它实际上就是这么简单: - -```java -// EnumSet's serialization proxy -private static class SerializationProxy> - implements Serializable { - private static final long serialVersionUID = 362491234563181265L; - - // The element type of this enum set. - private final Class elementType; - - // The elements contained in this enum set. - private final Enum[] elements; - - SerializationProxy(EnumSet set) { - elementType = set.elementType; - elements = set.toArray(new Enum[0]); - } - - private Object readResolve() { - EnumSet result = EnumSet.noneOf(elementType); - - for (Enum e : elements) - result.add((E) e); - - return result; - } -} -``` - -  序列化代理模式有两个局限性。它不能与可以被客户端拓展的类兼容(详见 19 条)。它也不能与对象图中包含循环的某些类兼容:如果你企图从一个对象的序列化代理的 readResovle 方法内部调用这个对象的方法,就会得到一个 ClassCastException 异常,因为你还没有这个对象,只有它的序列化代理。 - -  最后一点,序列化代理模式所增强的功能和安全性不是没有代价。在我的机器上,通过序列化代理来序列化和反序列化 Period 实例的开销,比使用保护性拷贝增加了 14%。 - -  总而言之,当你发现必须在一个不能被客户端拓展的类上面编写 readObject 或者 writeObject 方法时,就应该考虑使用序列化代理模式。想要稳健的将带有重要约束条件的对象序列化时,这种模式是最容易的方法。 +  你会立刻意识到你做错了什么,在额头上狠