读《Effective Java》后六章
形而上 Lv5

第八章 Lambda 和 Stream

在Java 8中,增加了函数接口(functional interface)、Lambda 和方法引用(methodreference),使得创建函数对象(function object)变得很容易。与此同时,还增加了StreamAPI,为处理数据元素的序列提供了类库级别的支持。在本章中,将讨论如何最佳地利用这些机制。

第42条:Lambda优先于匿名类

根据以往的经验,是用带有单个抽象方法的接口(或者,几乎都不是抽象类)作为函数类型(function type)。它们的实例称作函数对象(function object),表示函数或者要采取的动作。自从1997年发布 JDK1.1以来,创建函数对象的主要方式是通过匿名类(anonymousclass,详见第 24条)。下面是一个按照字符串的长度对字符串列表进行排序的代码片段,它用一个匿名类创建了排序的比较函数(加强排列顺序):

1
2
3
4
5
6
//Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String sl,String s2) {
return Integer.compare(s1.length(), s2.length());
}
});

匿名类满足了传统的面向对象的设计模式对函数对象的需求,最著名的有策略(Strategy)模式[Gamma95]。Comparator接口代表一种排序的抽象策略(abstract strategy);上述的匿名类则是为字符串排序的一种具体策略(concrete strategy)。但是,匿名类的烦琐使得在Java中进行函数编程的前景变得十分黯淡。

在Java8中,形成了”带有单个抽象方法的接口是特殊的,值得特殊对待”的观念。这些接口现在被称作函数接口(functional interface),Java 允许利用Lambda 表达式(Lambdaexpression,简称Lambda)创建这些接口的实例。Lambda 类似于匿名类的函数,但是比它简洁得多。以下是上述代码用Lambda 代替匿名类之后的样子。样板代码没有了,其行为也十分明确:

1
2
//Lambda expression asfunction object(replaces anonymous class)
Collections.sort(words,(s1,s2) -> Integer.compare(s1.1ength(),s2.1ength()));

注意,Lambda 的类型(Comparator<String>)、其参数的类型(sl 和 s2,两个都是String)及其返回值的类型(int),都没有出现在代码中。编译器利用一个称作类型推导(type inference)的过程,根据上下文推断出这些类型。在某些情况下,编译器无法确定类型,你就必须指定。类型推导的规则很复杂:在JLS[JLS,18]中占了整章的篇幅。几乎没有程序员能够详细了解这些规则,但是没关系。删除所有Lambda 参数的类型吧,除非它们的存在能够使程序变得更加清晰。如果编译器产生一条错误消息,告诉你无法推导出Lambda参数的类型,那么你就指定类型。有时候还需要转换返回值或者整个Lambda表达式,但是这种情况很少见。

关于类型推导应该增加一条警告。第26条告诉你不要使用原生态类型,第29条说过要支持泛型类型,第 30条说过要支持泛型方法。在使用Lambda时,这条建议确实非常重要,因为编译器是从泛型获取到得以执行类型推导的大部分类型信息的。如果你没有提供这些信息,编译器就无法进行类型推导,你就必须在Lambda中手工指定类型,这样极大地增加了它们的烦琐程度。如果上述代码片段中的变量words 声明为原生态类型List,而不是参数化的类型List<String>,它就不会进行编译。

当然,如果用Lambda表达式(详见第14条和第43条)代替比较器构造方法(comparatorconstruction method),有时这个代码片段中的比较器还会更加简练:

1
Collections.sort(words,comparingInt(String::length));

事实上,如果利用Java8在List接口中添加的 sort方法,这个代码片段还可以更加简短一些:

1
words.sort(comparingInt(String::length));

Java中增加了Lambda之后,使得之前不能使用函数对象的地方现在也能使用了。例如,以第 34 条中的 Operation 枚举类型为例。由于每个枚举的 apply方法都需要不同的行为,我们用了特定于常量的类主体,并覆盖了每个枚举常量中的 apply方法。通过以下代码回顾一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Enumtypewith constant-specificclassbodies&data(Item 34)
public enum Operation{
PLUS("+") {
public double apply(double x, double y) {return × + y;}
},
MINUS("-"){
public double apply(double x,double y) {return x-y;}
},
TIMES("*"){
public double apply(double x,double y) { return × * 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 toStringO { return symbol;}
public abstract double apply(double x,double y);
}

由第 34 条可知,枚举实例域优先于特定于常量的类主体。Lambda 使得利用前者实现特定于常量的行为变得比用后者来得更加容易了。只要给每个枚举常量的构造器传递一个实现其行为的Lambda 即可。构造器将 Lambda 保存在一个实例域中,apply方法再将调用转给Lambda。由此得到的代码比原来的版本更简单,也更加清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Enum withfunction object fields &constant-specificbehavior
public enum Operation {
PLUS("+",(x, y)->x+ y),
MINUS("-",(x,y)->×-y),
TIMES("*",(x,y)->×*y),
DIVIDE("/",(x,y)->×/ 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 ×,double y){
return op.applyAsDouble(x,y);
}
}

注意,这里给Lambda使用了 DoubleBinaryOperator接口,代表枚举常量的行为。这是在java.util.function(详见第 44条)中预定义的众多函数接口之一。它表示一个带有两个 double 参数的函数,并返回一个 double结果。

看看基于Lambda 的Operation 枚举,你可能会想,特定于常量的方法主体已经形同虚设了,但是实际并非如此。与方法和类不同的是,Lambda 没有名称和文档;如果一个计算本身不是自描述的,或者超出了几行,那就不要把它放在一个Lambda中。对于Lambda而言,一行是最理想的,三行是合理的最大极限。如果违背了这个规则,可能对程序的可读性造成严重的危害。如果Lambda很长或者难以阅读,要么找一种方法将它简化,要么重构程序来消除它。而且,传入枚举构造器的参数是在静态的环境中计算的。因而,枚举构造器中的Lambda无法访问枚举的实例成员。如果枚举类型带有难以理解的特定于常量的行为,或者无法在几行之内实现,又或者需要访问实例域或方法,那么特定于常量的类主体仍然是首选。

同样地,你可能会认为,在Lambda时代,匿名类已经过时了。这种想法比较接近事实,但是仍有一些工作用Lambda 无法完成,只能用匿名类才能完成。Lambda限于函数接口。如果想创建抽象类的实例,可以用匿名类来完成,而不是用Lambda。同样地,可以用匿名类为带有多个抽象方法的接口创建实例。最后一点,Lambda 无法获得对自身的引用。在Lambda 中,关键字this 是指外围实例,这个通常正是你想要的。在匿名类中,关键字this 是指匿名类实例。如果需要从函数对象的主体内部访问它,就必须使用匿名类。

Lambda 与匿名类共享你无法可靠地通过实现来序列化和反序列化的属性。因此,尽可能不要(除非迫不得已)序列化一个Lambda(或者匿名类实例)。如果想要可序列化的函数对象,如Comparator,就使用私有静态嵌套类(详见第 24条)的实例。

总而言之,从Java 8开始,Lambda 就成了表示小函数对象的最佳方式。千万不要给函数对象使用匿名类,除非必须创建非函数接口的类型的实例。同时,还要记住,Lambda 使得表示小函数对象变得如此轻松,因此打开了之前从未实践过的在Java 中进行函数编程的大门。

第43条:方法引用优先于Lambda

与匿名类相比,Lambda 的主要优势在于更加简洁。Java 提供了生成比Lambda 更简洁函数对象的方法:方法引用(method reference)。以下代码片段的源程序是用来保持从任意键到Integer 值的一个映射。如果这个值为该键的实例数目,那么这段程序就是一个多集合的实现。这个代码片段的作用是,当这个键不在映射中时,将数字1和键关联起来;或者当这个键已经存在,就负责递增该关联值:

1
# map.merge(key,1,(count, incr) -> count + incr);

注意,这行代码中使用了 merge方法,这是Java 8版本在 Map 接口中添加的。如果指定的键没有映射,该方法就会插人指定值;如果有映射存在,merge方法就会将指定的函数应用到当前值和指定值上,并用结果覆盖当前值。这行代码代表了merge 方法的典型用例。

这样的代码读起来清晰明了,但仍有些样板代码。参数count 和incr 没有添加太多价值,却占用了不少空间。实际上,Lambda要告诉你的就是,该函数返回的是它两个参数的和。从 Java 8开始,Integer(以及所有其他的数字化基本包装类型都)提供了一个名为sum的静态方法,它的作用也同样是求和。我们只要传人一个对该方法的引用,就可以更轻松地得到相同的结果:

1
map.merge(key, 1, Integer::sum) ;

方法带的参数越多能用方法引用消除的样板代码就越多。但在有些Lambda 中,即便它更长,但你所选择的参数名称提供了非常有用的文档信息,也会使得Lambda 的可读性更强,并且比方法引用更易于维护。

只要方法引l用能做的事,就没有Lambda不能完成的(只有一种情况例外,有兴趣的读者请参见 JLS,9.9-2)。也就是说,使用方法引I用通常能够得到更加简短、清晰的代码。如果Lambda太长,或者过于复杂,还有另一种选择:从Lambda 中提取代码,放到一个新的方自己满意的方式编写进人文档。

如果是用 IDE 编程,则可以在任何可能的地方都用方法引用代替Lambda。通常(但并非总是)应该让IDE把握机会好好表现一下。有时候,Lambda 也会比方法引用更加简洁明了。这种情况大多是当方法与Lambda 处在同一个类中的时候。比如下面的代码片段,假定发生在一个名为 GoshThisClas sNameIsHumongous 的类中:

1
service.execute(GoshThisClassNameIsHumongous::action);

Lambda版本的代码如下:

1
service.execute(() -> action();

这个代码片段使用了方法引用,但是它既不比Lambda更简短,也不比它更清晰,因此应该优先考虑Lambda。类似的还有 Function 接口,它用一个静态工厂方法返回 id 函数Function.identity()。如果它不用这个方法,而是在行内编写同等的Lambda 表达式:x->×,一般会比较简洁明了。

许多方法引用都指向静态方法,但其中有 4种没有这么做。其中两个是有限制(bound)和无限制(unbound)的实例方法引用。在有限制的引用中,接收对象是在方法引[用中指定的。有限制的引[用本质上类似于静态引用:函数对象与被引用方法带有相同的参数。在无限制的引用中,接收对象是在运用函数对象时,通过在该方法的声明函数前面额外添加一个参数来指定的。无限制的引[用经常用在流管道(Stream pipeline)(详见第45条)中作为映射和过滤函数。最后,还有两种构造器(constructor)引用,分别针对类和数组。构造器引l用是充当工厂对象。这五种方法引用概括如下:

总而言之,方法引用常常比Lambda表达式更加简洁明了。只要方法引用更加简洁、清晰,就用方法引用;如果方法引用并不简洁,就坚持使用Lambda。

第44条:坚持使用标准的函数接口

在 Java 具有Lambda 表达式之后,编写 API 的最佳实践也做了相应的改变。例如在模板方法(Template Method)模式[Gamma95]中,用一个子类覆盖基本类型方法(primitivemethod),来限定其超类的行为,这是最不讨人喜欢的。现在的替代方法是提供一个接受函数对象的静态工厂或者构造器,便可达到同样的效果。在大多数情况下,需要编写更多的构造器和方法,以函数对象作为参数。需要非常谨慎地选择正确的函数参数类型。

以LinkedHashMap 为例。每当有新的键添加到映射中时,put 就会调用其受保护的removeEldestEntry方法。如果覆盖该方法,便可以用这个类作为缓存。当该方法返回true,映射就会删除最早传人该方法的条目。下列覆盖代码允许映射增长到100个条目,然后每添加一个新的键,就会删除最早的那个条目,始终保持最新的100个条目:

1
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {return size() > 100;

这个方法很好用,但是用Lambda 可以完成得更漂亮。假如现在编写LinkedHash-Map,它会有一个带函数对象的静态工厂或者构造器。看一下 removeEldestEntry的声明,你可能会以为该函数对象应该带一个Map.Entry<K,V>,并且返回一个boolean,但实际并非如此:removeEldestEntry方法会调用 size(),获取映射中的条目数量,这是因为 removeEldestEntrY是映射中的一个实例方法。传到构造器的函数对象则不是映射中的实例方法,无法捕捉到,因为调用其工厂或者构造器时,这个映射还不存在。所以,映射必须将它自身传给函数对象,因此必须传人映射及其最早的条目作为remove 方法的参数。声明一个这样的函数接口的代码如下:

1
//Unnecessary functional interface;usea standard one insteadboolean remove(Map<K,V> map, Map.Entry<K,V> eldest);

这个接口可以正常工作,但是不应该使用,因为没必要为此声明一个新的接口。jaVa.util.function 包已经为此提供了大量标准的函数接口。只要标准的函数接口能够满足需求,通常应该优先考虑,而不是专门再构建一个新的函数接口。这样会使API更加容易学习,通过减少它的概念内容,显著提升互操作性优势,因为许多标准的函数接口都提供了有用的默认方法。如 Predicate 接口提供了合并断言的方法。对于上述LinkedHashMap范例,应该优先使用标准的 BiPredicate<Map<K,V>,Map.Entry<K,V>>接口,而不是定制 EldestEntryRemovalFunction 接口。

java.util.Function 中共有 43 个接口。别指望能够全部记住它们,但是如果能记住其中6个基础接口,必要时就可以推断出其余接口了。基础接口作用于对象引用类型。Operator接口代表其结果与参数类型一致的函数。Predicate接口代表带有一个参数并返回一个boolean 的函数。Function 接口代表其参数与返回的类型不一致的函数。Supplier接口代表没有参数并且返回(或”提供”)一个值的函数。最后,Consumer 代表的是带有一个函数但不返回任何值的函数,相当于消费掉了其参数。这6个基础函数接口概述如下:

这6个基础接口各自还有 3 种变体,分别可以作用于基本类型 int、long 和 double。它们的命名方式是在其基础接口名称前面加上基本类型而得。因此,以带有 int 的 predicate接口为例,其变体名称应该是 IntPredicate,它是一个二进制运算符带有两个 long 值参数并返回一个 long 值 LongBinaryOperator。这些变体接口的类型都不是参数化的,除 Function 变体外,后者是以返回类型作为参数。例如,LongFunction<int[]>表示带有一个long参数,并返回一个int[]数组。

Function 接口还有9种变体,用于结果类型为基本类型的情况。源类型和结果类型始终不一样,因为从类型到自身的函数就是 UnaryOperator。如果源类型和结果类型均为基本类型,就是在 Function 前面添加格式如 ScrToResult,如LongToIntFunction(有6种变体)。如果源类型为基本类型,结果类型是一个对象参数,则要在 Function 前添加ToObj,如 DoubleToObjFunction(有 3 种变体)。

这三种基础函数接口还有带两个参数的版本,如 BiPredicate<T,U>、BiFunction<T,U,R>和 BiConsumer<T,U>。还有 BiFunction 变体用于返回三个相关的基本类型:ToIntBiFunction<T,U>、ToLongBiFunction<T,U> 和 ToDoubleBiFunction<T,U>。Consumer接口也有带两个参数的变体版本,它们带一个对象引[用和一个基本类型:Obj-DoubleConsumer、ObjIntConsumer 和 ObjLongConsumer。总之,这些基础接口有9种带两个参数的版本。

最后,还有 BooleanSupplier 接口,它是 Supplier 接口的一种变体,返回 boolean值。这是在所有的标准函数接口名称中唯一显式提到boolean 类型的,但 boolean 返回值是通过 Predicate 及其4种变体来支持的。BooleanSupplier 接口和上述段落中提及的42个接口,总计43个标准函数接口。显然,这是个大数目,但是它们之间并非纵横交错。另一方面,你需要的函数接口都替你写好了,它们的名称都是循规蹈矩的,需要的时候并不难找到。

现有的大多数标准函数接口都只支持基本类型。千万不要用带包装类型的基础函数接口来代替基本函数接口。虽然可行,但它破坏了第61条的规则”基本类型优于装箱基本类型”。使用装箱基本类型进行批量操作处理,最终会导致致命的性能问题。

现在知道了,通常应该优先使用标准的函数接口,而不是用自己编写的接口。但什么时候应该自己编写接口呢?当然,是在如果没有任何标准的函数接口能够满足你的需求之时,如需要一个带有三个参数的 predicate 接口,或者需要一个抛出受检异常的接口时,当然就需要自己编写啦。但是也有这样的情况:有结构相同的标准函数接口可用,却还是应该自己编写函数接口。

还是以咱们的老朋友Comparator为例吧。它与ToIntBiFunction<T,T>接口在结构上一致,虽然前者被添加到类库中时,后一个接口已经存在,但如果用后者就错了。Comparator 之所以需要有自己的接口,有三个原因。首先,每当在 API 中使用时,其名称提供了良好的文档信息,并且被大量使用。其次,Comparator接口对于如何构成一个有效的实例,有着严格的条件限制,这构成了它的总则(general contract)。实现该接口相当于承诺遵守其契约。第三,这个接口配置了大量很好用的缺省方法,可以对比较器进行转换和合并。

如果你所需要的函数接口与Comparator一样具有一项或者多项以下特征,则必须认真考虑自己编写专用的函数接口,而不是使用标准的函数接口:

口通用,并且将受益于描述性的名称。

口具有与其关联的严格的契约。

口将受益于定制的缺省方法。

如果决定自己编写函数接口,一定要记住,它是一个接口,因而设计时应当万分谨慎(详见第21条)。

注意,EldestEntryRemovalFunction接口(详见第199页)是用@Functional-了程序员设计意图的语句,它有三个目的:告诉这个类及其文档的读者,这个接口是针对Lambda设计的;这个接口不会进行编译,除非它只有一个抽象方法;避免后续维护人员不小心给该接口添加抽象方法。必须始终用@Functionallnterface注解对自己编写的函数接口进行标注。

最后一点是关于函数接口在 API中的使用。不要在相同的参数位置,提供不同的函数接口来进行多次重载的方法,否则可能在客户端导致歧义。这不仅仅是理论上的问题。比如可以编写一个客户端程序,要求进行一次转换,以显示正确的重载(详见第 52条)。避免这个问题的最简单方式是,不要编写在同一个参数位置使用不同函数接口的重载。这是该建议的一个特例,详情请见第52条。

总而言之,既然Java 有了Lambda,就必须时刻谨记用Lambda 来设计API。输人时接受函数接口类型,并在输出时返回之。一般来说,最好使用java.util.function.Function 中提供的标准接口,但是必须警惕在相对罕见的几种情况下,最好还是自己编写专用的函数接口。

第45条:谨慎使用Stream

在 Java 8 中增加了 Stream API,简化了串行或并行的大批量操作。这个 API提供了两个关键抽象:Stream(流)代表数据元素有限或无限的顺序,Stream pipeline(流管道)则代表这些元素的一个多级计算。Stream 中的元素可能来自任何位置。常见的来源包括集合、数组、文件、正则表达式模式匹配器、伪随机数生成器,以及其他 Stream。Stream 中的数据元素可以是对象引l用,或者基本类型值。它支持三种基本类型:int、long 和 double。

一个Stream pipeline中包含一个源Stream,接着是O个或者多个中间操作(intermediateoperation)和一个终止操作(terminal operation)。每个中间操作都会通过某种方式对 Stream进行转换,例如将每个元素映射到该元素的函数,或者过滤掉不满足某些条件的所有元素。所有的中间操作都是将一个Stream 转换成另一个Stream,其元素类型可能与输入的 Stream一样,也可能不同。终止操作会在最后一个中间操作产生的Stream 上执行一个最终的计算,例如将其元素保存到一个集合中,并返回某一个元素,或者打印出所有元素等。

Stream pipeline 通常是lazy的:直到调用终止操作时才会开始计算,对于完成终止操作不需要的数据元素,将永远都不会被计算。正是这种lazy 计算,使无限 Stream 成为可能。注意,没有终止操作的Stream pipeline 将是一个静默的无操作指令,因此千万不能忘记终止操作。

Stream API 是流式(fluent)的:所有包含 pipeline 的调用可以链接成一个表达式。事实上,多个pipeline 也可以链接在一起,成为一个表达式。

在默认情况下,Stream pipeline 是按顺序运行的。要使 pipeline 并发执行,只需在该pipeline 的任何 Stream 上调用 parallel 方法即可,但是通常不建议这么做(详见第 48 条)。

StreamAPI包罗万象,足以用Stream执行任何计算,但是”可以”并不意味着”应该”。如果使用得当,Stream 可以使程序变得更加简洁、清晰;如果使用不当,会使程序变得混乱且难以维护。对于什么时候应该使用Stream,并没有硬性的规定,但是可以有所启发。

以下面的程序为例,它的作用是从词典文件中读取单词,并打印出单词长度符合用户指定的最低值的所有换位词。记住,包含相同的字母,但是字母顺序不同的两个词,称作换位词(anagram)。该程序会从用户指定的词典文件中读取每一个词,并将符合条件的单词放入一个映射中。这个映射键是按字母顺序排列的单词,因此”staple”的键是”aelpst”,”petals”的键也是‘“aelpst”:这两个词就是换位词,所有换位词的字母排列形式是一样的(有时候也叫alphagram)。映射值是包含了字母排列形式一致的所有单词。词典读取完成之后,每一个列表就是一个完整的换位词组。随后,程序会遍历映射的values(),预览并打印出单词长度符合极限值的所有列表。

1
//Prints all large anagram groups in a dictionary iterativelypublic class Anagrams {public static void main(String[] args) throws IOException {File dictionary = new File(args[0]);int minGroupSize = Integer.parseInt(args[1]);Map<String, Set<String>> 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<String> 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);

这个程序中有一个步骤值得注意。被插入到映射中的每一个单词都以粗体显示,这是使用了 Java 8 中新增的computeIfAbsent 方法。这个方法会在映射中查找一个键:如果这个键存在,该方法只会返回与之关联的值。如果键不存在,该方法就会对该键运用指定的函数对象算出一个值,将这个值与键关联起来,并返回计算得到的值。computeIfAbsent方法简化了将多个值与每个键关联起来的映射实现。

下面举个例子,它也能解决上述问题,只不过大量使用了Stream。注意,它的所有程序都是包含在一个表达式中,除了打开词典文件的那部分代码之外。之所以要在另一个表达式中打开词典文件,只是为了使用 try-with-resources 语句,它可以确保关闭词典文件:

1
2
3
// 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<String> words = Files.lines(dictionary)) {words.collect(groupingBy(word -> word.chars().sorted().collect(StringBuilder::new,(sb,c) -> sb.append((char) c),StringBuilder::append).toStringO))values().stream().filter(group -> group.size() >= minGroupSize)

(dnoub +:.+Oaz1sdnou6<-dnoub)dewforEach(System.out::println);

如果你发现这段代码好难懂,别担心,你并不是唯一有此想法的人。它虽然简短,但是难以读懂,对于那些使用 Stream 还不熟练的程序员而言更是如此。滥用 Stream 会使程序代码更难以读懂和维护

好在还有一种舒适的中间方案。下面的程序解决了同样的问题,它使用了Stream,但是没有过度使用。结果,与原来的程序相比,这个版本变得既简短又清晰:

1
//Tastefuluse of streams enhances clarity and concisenesspublicclassAnagrams{public static void main(String[] args) throws IOException {Path dictionary =Paths.get(args[0]);int minGroupSize=Integer.parseInt(args[i]);try (Stream<String> 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 sameas inoriginalversion

即使你之前没怎么接触过Stream,这段程序也不难理解。它在 try-with-resources 块中打开词典文件,获得一个包含了文件中所有代码的Stream。Stream 变量命名为words,是建议 Stream 中的每个元素均为单词。这个 Stream 中的 pipeline 没有中间操作;它的终止操作将所有的单词集合到一个映射中,按照它们的字母排序形式对单词进行分组(详见第46条)。这个映射与前面两个版本中的是完全相同的。随后,在映射的values()视图中打开了一个新的 Stream<List>。当然,这个 Stream 中的元素都是换位词分组。Stream 进行了过滤,把所有单词长度小于 minGroupSize 的单词都去掉了,最后,通过终止操作的 forEach 打印出剩下的分组。

注意,Lambda 参数的名称都是经过精心挑选的。实际上参数应当以 group 命名,只是这样得到的代码行对于书本而言太宽了。在没有显式类型的情况下,仔细命名Lambda参数,这对于Streampipeline的可读性至关重要。

还要注意单词的字母排序是在一个单独的 alphabetize 方法中完成的。给操作命名,并且不要在主程序中保留实现细节,这些都增强了程序的可读性。在 Stream pipeline 中使用 helper 方法,对于可读性而言,比在迭代化代码中使用更为重要,因为 pipeline 缺乏显式的类型信息和具名临时变量。

可以重新实现 alphabetize 方法来使用 Stream,只是基于 Stream 的 alphabetize方法没那么清晰,难以正确编写,速度也可能变慢。这些不足是因为Java 不支持基本类型的charStream(这并不意味着Java应该支持charStream;也不可能支持)。为了证明用Stream 处理char 值的各种危险,请看以下代码:

1
"Hello world!".chars().forEach(System.out::print) ;

或许你以为它会输出 He11oworld!,但是运行之后发现,它输出的是7210110810的元素,并不是 char 值,而是 int 值,因此调用了 print 的 int 覆盖。名为 chars 的方法,却返回 int值的 Stream,这固然会造成困扰。修正方法是利用转换强制调用正确的覆盖:

1
"Hello world!".chars().forEach(x -> System.out.print((char) x));

但是,最好避免利用Stream来处理char值。

刚开始使用 Stream 时,可能会冲动到恨不得将所有的循环都转换成 Stream,但是切记,千万别冲动。这可能会破坏代码的可读性和易维护性。一般来说,即使是相当复杂的任务,最好也结合 Stream 和迭代来一起完成,如上面的 Anagrams 程序范例所示。因此,重构现有代码来使用Stream,并且只在必要的时候才在新代码中使用。

如本条目中的范例程序所示,Stream pipeline利用函数对象(一般是Lambda或者方法引用)来描述重复的计算,而迭代版代码则利用代码块来描述重复的计算。下列工作只能通过代码块,而不能通过函数对象来完成:

口从代码块中,可以读取或者修改范围内的任意局部变量;从Lambda则只能读取final 或者有效的 final 变量[JLS 4.12.4],并且不能修改任何 local 变量。

从代码块中,可以从外围方法中 return、break 或 continue 外围循环,或者抛出该方法声明要抛出的任何受检异常;从Lambda中则完全无法完成这些事情。

如果某个计算最好要利用上述这些方法来描述,它可能并不太适合Stream。反之,Stream可以使得完成这些工作变得易如反掌:

口统一转换元素的序列过滤元素的序列口利用单个操作(如添加、连接或者计算其最小值)合并元素的顺序口将元素的序列存放到一个集合中,比如根据某些公共属性进行分组口搜索满足某些条件的元素的序列

如果某个计算最好是利用这些方法来完成,它就非常适合使用 Stream。

利用 Stream 很难完成的一件事情就是,同时从一个 pipeline 的多个阶段去访问相应的元素:一旦将一个值映射到某个其他值,原来的值就丢失了。一种解决办法是将每个值都映射到包含原始值和新值的一个对象对(pair object),不过这并非万全之策,当pipeline 的多个阶段都需要这些对象对时尤其如此。这样得到的代码将是混乱、繁杂的,违背了Stream的初衷。最好的解决办法是,当需要访问较早阶段的值时,将映射颠倒过来。

例如,编写一个打印出前 20个梅森素数(Mersenne primes)的程序。解释一下,梅森素数是一个形式为 2P-1的数字。如果p是一个素数,相应的梅森数字也是素数;那么它就是一个梅森素数。作为 pipeline 的第一个Stream,我们想要的是所有素数。下面的方法将返回(无限)Stream。假设使用的是静态导人,便于访问 BigInteger 的静态成员:

1
static Stream<BigInteger>primes( {return Stream.iterate(TwO, BigInteger::nextProbablePrime);

方法的名称(primes)是一个复数名词,它描述了Stream 的元素。强烈建议返回Stream 的所有方法都采用这种命名惯例,因为可以增强 Stream pipeline 的可读性。该方法使用静态工厂Stream.iterate,它有两个参数:Stream 中的第一个元素,以及从前一个

1
public static void main(String[] args){.filter(mersenne ->mersenne.isProbablePrime(50)).1imit(20).forEach(System.out::println);

这段程序是对上述内容的简单编码示范:它从素数开始,计算出相应的梅森素数,过滤掉所有不是素数的数字(其中50是个神奇的数字,它控制着这个概率素性测试),限制最终得到的Stream为 20个元素,并打印出来。

现在假设想要在每个梅森素数之前加上其指数(p)。这个值只出现在第一个 Stream 中,因此在负责输出结果的终止操作中是访问不到的。所幸将发生在第一个中间操作中的映射颠倒过来,便可以很容易地计算出梅森数字的指数。该指数只不过是一个以二进制表示的位数,因此终止操作可以产生所要的结果:

1
.forEach(mp -> System.out.println(mp.bitLength() +":"+ mp));

现实中有许多任务并不明确要使用Stream,还是用迭代。例如有个任务是要将一副新纸牌初始化。假设Card 是一个不变值类,用于封装Rank 和 Suit,这两者都是枚举类型。这项任务代表了所有需要计算从两个集合中选择所有元素对的任务。数学上称之为两个集合的笛卡尔积。这是一个迭代化实现,嵌人了一个for-each 循环,大家对此应当都非常熟悉了:

1
//IterativeCartesianproduct computationprivate static List<Card> newDeck(){List<Card> result = new ArrayList<>(;for (Suit suit : Suit.valuesO)for (Rank rank : Rank.values()result.add(new Card(suit, rank));return result;

这是一个基于 Stream 的实现,利用了中间操作 flatMap。这个操作是将 Stream 中的每们扁平化。注意,这个实现中包含了一个嵌人式的Lambda,如以下粗体部分所示:

1
//Stream-based Cartesian product computationprivate static List<Card>newDeck()return Stream.of(Suit.valuesO).flatMap(suit->Stream.of(Rank.values()).map(rank -> new Card(suit,rank))).collect(toListO);

这两种 newDeck版本哪一种更好?这取决于个人偏好,以及编程环境。第一种版本比较简单,可能感觉比较自然,大部分Java 程序员都能够理解和维护,但是有些程序员可能会觉得第二种版本(基于Stream的)更舒服。这个版本可能更简洁一点,如果已经熟练掌握Stream和函数编程,理解起来也不难。如果不确定要用哪个版本,或许选择迭代化版本会更加安全一些。如果更喜欢Stream 版本,并相信后续使用这些代码的其他程序员也会喜欢,就应该使用 Stream 版本。

总之,有些任务最好用 Stream 完成,有些则要用迭代。而有许多任务则最好是结合使用这两种方法来一起完成。具体选择用哪一种方法,并没有硬性、速成的规则,但是可以参考一些有意义的启发。在很多时候,会很清楚应该使用哪一种方法;有些时候,则不太明显。如果实在不确定用Stream还是用迭代比较好,那么就两种都试试,看看哪一种更好用吧

第46条:优先选择Stream中无副作用的函数

如果刚接触 Stream,可能比较难以掌握其中的窍门。就算只是用 Stream pipeline来表达计算就困难重重。当你好不容易成功了,运行程序之后,却可能感到这么做并没有享受到多大益处。Stream 并不只是一个API,它是一种基于函数编程的模型。为了获得Stream 带来的描述性和速度,有时还有并行性,必须采用范型以及API。

Stream 范型最重要的部分是把计算构造成一系列变型,每一级结果都尽可能靠近上一级结果的纯函数(pure function)。纯函数是指其结果只取决于输入的函数:它不依赖任何可变的状态,也不更新任何状态。为了做到这一点,传入Stream 操作的任何函数对象,无论是中间操作还是终止操作,都应该是无副作用的。

有时会看到如下代码片段,它构建了一张表格,显示这些单词在一个文本文件中出现的频率:

1
//Uses the streams API but not the paradigm--Don't do this!Map<String,Long>freq =new HashMap<>O;try (Stream<String> words = new Scanner(file).tokens()){words.forEach(word ->{freq.merge(word.toLowerCase),1l,Long::sum);

以上代码有什么问题吗?它毕竟使用了Stream、Lambda 和方法引用,并且得出了正确的答案。简而言之,这根本不是Stream代码;只不过是伪装成Stream 代码的迭代式代码。它并没有享受到Stream API带来的优势,代码反而更长了点,可读性也差了点,并且比相应的选代化代码更难维护。因为这段代码利用一个改变外部状态(频率表)的Lambda,完成了在终止操作的forEach 中的所有工作。forEach 操作的任务不只展示由 Stream 执行的计算结果,这在代码中并非好事,改变状态的Lambda也是如此。那么这段代码应该是什么样的呢?

1
//Properuseofstreamstoinitializea frequencytableMap<String,Long>freq;try (Stream<String> words = new Scanner(file).tokensO){freq = words.collect(groupingBy(String::toLowerCase, countingO));}

这个代码片段的作用与前一个例子一样,只是正确使用了Stream API,变得更加简洁、清晰。那么为什么有人会以其他的方式编写呢?这是为了使用他们已经熟悉的工具。Java程序员都知道如何使用 for-each 循环,终止操作的 forEach 也与之类似。但 forEach 操作是终止操作中最没有威力的,也是对 Stream 最不友好的。它是显式迭代,因而不适合并行。forEach 操作应该只用于报告Stream 计算的结果,而不是执行计算。有时候,也可以将 forEach 用于其他目的,比如将 Stream 计算的结果添加到之前已经存在的集合中去。

改进过的代码使用了一个收集器(collector),为了使用Stream,这是必须了解的一个新概念。Co1lectors API很吓人:它有 39 种方法,其中有些方法还带有5个类型参数!好消息是,你不必完全搞懂这个API就能享受它带来的好处。对于初学者,可以忽略Collector接口,并把收集器当作封装缩减策略的一个黑盒子对象。在这里,缩减的意思是将Stream的元素合并到单个对象中去。收集器产生的对象一般是一个集合(即名称收集器)。

将 Stream 的元素集中到一个真正的Collection 里去的收集器比较简单。有三个这样的收集器:toList()、toSet()和toCollection(collectionFactory)。它们分别返回一个列表、一个集合和程序员指定的集合类型。了解了这些,就可以编写Streampipeline,从频率表中提取排名前十的单词列表了:

1
//Pipelinetogetatop-tenlistofwordsfromafrequencytableList<String> topTen = freq.keySet().streamO. sorted(comparing(freq: :get) .reversed())1imit(10)collect(toList());

注意,这里没有给toList方法配上它的 Collectors 类。静态导入 collectors 的所有成员是惯例也是明智的,因为这样可以提升Streampipeline的可读性。

reversed()。comparing 方法是一个比较器构造方法(详见第14条),它带有一个键提取函数。函数读取一个单词,”提取”实际上是一个表查找:有限制的方法引用 freq::get在频率表中查找单词,并返回该单词在文件中出现的次数。最后,在比较器上调用reversed,按频率高低对单词进行排序。后面的事情就简单了,只要限制 Stream 为10个单词,并将它们集中到一个列表中即可。

上一段代码是利用 Scanner的 Stream方法来获得Stream。这个方法是在Java 9中增加的。如果使用的是更早的版本,可以把实现Iterator 的扫描器,翻译成使用了类似于第47条中适配器的Stream(streamOf(Iterable))。

Collectors中的另外 36种方法又是什么样的呢?它们大多数是为了便于将 Stream集合到映射中,这远比集中到真实的集合中要复杂得多。每个Stream 元素都有一个关联的键和值,多个Stream元素可以关联同一个键。

最简单的映射收集器是toMap(keyMapper,ValueMapper),它带有两个函数,其中一个是将 Stream 元素映射到键,另一个是将它映射到值。我们采用第 34 条fromString实现中的收集器,将枚举的字符串形式映射到枚举本身:

1
//UsingatoMapcollectortomakeamapfromstringto enumprivate static final Map<String, Operation> stringToEnum =Stream.of(values()).collect(toMap(Object::toString,e -> e));

如果Stream 中的每个元素都映射到一个唯一的键,那么这个形式简单的toMap是很完美的。如果多个 Stream 元素映射到同一个键,pipeline 就会抛出一个 IllegalState-Exception异常将它终止。

toMap 更复杂的形式,以及 groupingBy方法,提供了更多处理这类冲突的策略。其中一种方式是除了给toMap 方法提供了键和值映射器之外,还提供一个合并函数(mergefunction)。合并函数是一个 BinaryOperator,这里的V是映射的值类型。合并函数将与键关联的任何其他值与现有值合并起来,因此,假如合并函数是乘法,得到的值就是与该值映射的键关联的所有值的积。

带有三个参数的toMap 形式,对于完成从键到与键关联的被选元素的映射也是非常有用的。假设有一个Stream,代表不同歌唱家的唱片,我们想得到一个从歌唱家到最畅销唱片之间的映射。下面这个收集器就可以完成这项任务。

1
# Map<Artist, Album> topHits = albums.collect(toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));

注意,这个比较器使用了静态工厂方法 maxBy,这是从 BinaryOperator静态导入的。该方法将 Comparator转换成一个 BinaryOperator,用于计算指定比较器产生的最大值。在这个例子中,比较器是由比较器构造器方法 comparing 返回的,它有一个键提取函数Album::SaleS。这看起来有点绕,但是代码的可读性良好。不严格地说,它的意思是”将唱片的Stream 转换成一个映射,将每个歌唱家映射到销量最佳的唱片”。这就非常接近问题陈述了。

带有三个参数的toMap形式还有另一种用途,即生成一个收集器,当有冲突时强制”保留最后更新”(last-write-wins)。对于许多Stream 而言,结果是不确定的,但如果与映射函数的键关联的所有值都相同,或者都是可接受的,那么下面这个收集器的行为就正是你所要的:

1
# // Collector to impose last-write-wins policytoMap(keyMapper,valueMapper,(oldVal, newVa1) )->new?Va1)

toMap 的第三个也是最后一种形式是,带有第四个参数,这是一个映射工厂,在使用时要指定特殊的映射实现,如 EnumMap 或者 TreeMap。

toMap 的前三种版本还有另外的变换形式,命名为toConcurrentMap,能有效地并行运行,并生成ConcurrentHashMap 实例。

除了 toMap 方法,Collectors API还提供了 groupingBy方法,它返回收集器以生成映射,根据分类函数将元素分门别类。分类函数带有一个元素,并返回其所属的类别。这个类别就是元素的映射键。groupingBy方法最简单的版本是只有一个分类器,并返回一个映射,映射值为每个类别中所有元素的列表。下列代码就是在第 45条的 Anagram 程序中用于生成映射(从按字母排序的单词,映射到字母排序相同的单词列表)的收集器:

1
# words.collect(groupingBy(word -> alphabetize(word)))

如果要让groupingBy返回一个收集器,用它生成一个值而不是列表的映射,除了分类器之外,还可以指定一个下游收集器(downstream collector)。下游收集器从包含某个类别中所有元素的Stream 中生成一个值。这个参数最简单的用法是传人toSet(),结果生成一个映射,这个映射值为元素集合而非列表。

另一种方法是传人toCollection(collectionFactory),允许创建存放各元素类别的集合。这样就可以自由选择自己想要的任何集合类型了。带两个参数的 groupingBy版本的另一种简单用法是,传人counting()作为下游收集器。这样会生成一个映射,它将每个类别与该类别中的元素数量关联起来,而不是包含元素的集合。这正是在本条目开头处频率表范例中见到的:

1
# Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting());

groupingBy的第三个版本,除了下游收集器之外,还可以指定一个映射工厂。注意,这个方法违背了标准的可伸缩参数列表模式:参数mapFactory要在downStream 参数之前,而不是在它之后。groupingBy 的这个版本可以控制所包围的映射,以及所包围的集合,因此,比如可以定义一个收集器,让它返回值为TreeSets 的TreeMap。

groupingByConcurrent 方法提供了 groupingBy 所有三种重载的变体。这些变体可变体叫作 partitioningBy。除了分类方法之外,它还带一个断言(predicate),并返回一个键为 Boolean 的映射。这个方法有两个重载,其中一个除了带有断言之外,还带有下游

收集器。

counting 方法返回的收集器仅用作下游收集器。通过在 Stream 上的count方法,直接就有相同的功能,因此压根没有理由使用collect(counting())。这个属性还有 15种Collectors 方法。其中包含9种方法其名称以 summing、averaging 和 summarizing开头(相应的 Stream 基本类型上就有相同的功能)。它们还包括 reducing、filtering、mapping、flatMapping 和 collectingAndThen 方法。大多数程序员都能安全地避开这里的大多数方法。从设计的角度来看,这些收集器试图部分复制收集器中Stream 的功能,以便下游收集器可以成为”ministream”。

目前已经提到了3个Collectors 方法。虽然它们都在Collectors 中,但是并不包含集合。前两个是 minBy和 maxBy,它们有一个比较器,并返回由比较器确定的 Stream中的最少元素或者最多元素。它们是 Stream接口中 min 和 max方法的粗略概括,也是唱片范例中用过的 BinaryOperator.maxBy方法。

最后一个Collectors方法是joining,它只在CharSequence实例的 Stream 中操作,例如字符串。它以参数的形式返回一个简单地合并元素的收集器。其中一种参数形式在相邻元素之间插入分隔符的收集器。如果传人一个逗号作为分隔符,收集器就会返回一个用逗号隔开的值字符串(但要注意,如果Stream中的任何元素中包含逗号,这个字符串就会引起歧义)。这三种参数形式,除了分隔符之外,还有一个前缀和一个后缀。最终的收集器生成的字符串,会像在打印集合时所得到的那样,如[came,saw,conquered」。

总而言之,编写 Stream pipeline 的本质是无副作用的函数对象。这适用于传人 Stream及相关对象的所有函数对象。终止操作中的forEach 应该只用来报告由 Stream 执行的计算结果,而不是让它执行计算。为了正确地使用Stream,必须了解收集器。最重要的收集器工厂是toList、toSet、toMap、groupingBy和joining。

第47条:Stream要优先用Collection作为返回类型

许多方法都返回元素的序列。在Java 8之前,这类方法明显的返回类型是集合接口Collection、Set 和 List;Iterable;以及数组类型。一般来说,很容易确定要返回这其中哪一种类型。标准是一个集合接口。如果某个方法只为 for-each 循环或者返回序列而存在,无法用它来实现一些 Collection 方法(一般是contains(Object)),那么就用Iterable接口吧。如果返回的元素是基本类型值,或者有严格的性能要求,就使用数组。更复杂了。

或许你曾听说过,现在 Stream 是返回元素序列最明显的选择了,但如第 45条所述,Stream并没有淘汰迭代:要编写出优秀的代码必须巧妙地将Stream与迭代结合起来使用。如果一个API只返回一个Stream,那些想要用for-each 循环遍历返回序列的用户肯定要失望了。因为 Stream 接口只在 Iterable接口中包含了唯一一个抽象方法,Stream 对于该方法的规范也适用于 Iterable 的。唯一可以让程序员避免用 for-each 循环遍历 Stream的是 Stream 无法扩展 Iterable 接口。

遗憾的是,这个问题还没有适当的解决办法。乍看之下,好像给 Stream 的 iterator方法传人一个方法引用可以解决。这样得到的代码可能有点杂乱、不清晰,但也不算难以理解:

1
//Won't compile,due to limitations on Java's type inferencefor (ProcessHandle ph :ProcessHandle.allProcesses(::iterator){//Process theprocess

遗憾的是,如果想要编译这段代码,就会得到一条报错的信息:

1
Test.java:6: error: method reference not expected herefor (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {

为了使代码能够进行编译,必须将方法引用转换成适当参数化的Iterable:

1
// Hideous workaround to iterate over a streamfor (ProcessHandle ph :(Iterable<ProcessHandle>)ProcessHandle.allProcesses()::iterator)

这个客户端代码可行,但是实际使用时过于杂乱、不清晰。更好的解决办法是使用适配器方法。JDK没有提供这样的方法,但是编写起来很容易,使用在上述代码中内嵌的相同方法即可。注意,在适配器方法中没有必要进行转换,因为Java 的类型引用在这里正好派上了用场:

1
//AdapterfromStream<E>toIterable<E>public static<E>Iterable<E>iterableOf(Stream<E> stream){return stream::iterator;有了这个适配器,就可以利用for-each 语句遍历任何 Stream:for(ProcessHandle p:iterableOf(ProcessHandle.allProcesses()){//Process theprocess了

注意,第 34条中 Anagrams 程序的 Stream版本是使用 Files.lines 方法读取词典,而迭代版本则使用了扫描器(scanner)。Files.lines 方法优于扫描器,因为后者默默地吞掉了在读取文件过程中遇到的所有异常。最理想的方式是在迭代版本中也使用 Files.lines。这是程序员在特定情况下所做的一种妥协,比如当 API 只有 Stream 能访问序列,而他们想通过 for-each语句遍历该序列的时候。

反过来说,想要利用 Stream pipeline 处理序列的程序员,也会被只提供 Iterable 的API搞得束手无策。同样地,JDK没有提供适配器,但是编写起来也很容易:

1
//AdapterfromIterable<E>toStream<E>public static <E>Stream<E> streamOf(Iterable<E>iterable){return StreamSupport.stream(iterable.spliteratorO, false);子

如果在编写一个返回对象序列的方法时,就知道它只在Stream pipeline 中使用,当然就可以放心地返回Stream了。同样地,当返回序列的方法只在迭代中使用时,则应该返回Iterable。但如果是用公共的 API返回序列,则应该为那些想要编写 Stream pipeline,以及想要编写for-each语句的用户分别提供,除非有足够的理由相信大多数用户都想要使用相同的机制。

Collection 接口是Iterable的一个子类型,它有一个 stream方法,因此提供了迭代和 stream 访问。对于公共的、返回序列的方法,Collection 或者适当的子类型通常是最佳的返回类型。数组也通过 Arrays.asList 和 Stream.of方法提供了简单的迭代和 stream 访问。如果返回的序列足够小,容易存储,或许最好返回标准的集合实现,如ArrayList 或者HashSet。但是千万别在内存中保存巨大的序列,将它作为集合返回即可。

1
如果返回的序列很大,但是能被准确表述,可以考虑实现一个专用的集合。假设想要返回一个指定集合的幂集(power set),其中包括它所有的子集。a,b,c}的幂集是{{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b,c}}。如果集合中有n个元素,它的幂集就有 2n个。因此,不必考虑将幂集保存在标准的集合实现中。但是,有了AbstractList 的协助,为此实现定制集合就很容易了。

技巧在于,用幂集中每个元素的索引作为位向量,在索引中排第n位,表示源集合中第 n 位元素存在或者不存在。实质上,在二进制数0至 2n-1和有 n 位元素的集合的幂集之间,有一个自然映射。代码如下:

1
//Returns thepower set of aninput set as custom collectionpublic class PowerSet {public static final<E>Collection<Set<E>>of(Set<E>s){List<E> src=new ArrayList<>(s);if(src.size()>30)throw new IllegalArgumentException("Set too big "+ s);return new AbstractList<Set<E>>(){@Override public int size(){return 1<< src.size();//2to the power srcSize@Override public boolean contains(Object o){return o instanceof Set && src.containsAll((Set)o);{@Override public Set<E> get(int index){Set<E> 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 或者 23’-1。如果集合更大,甚至无限大,Collection 规范确实允许 size 方法返回 2²-1,但这并非是最令人满意的解决方案。

为了在 AbstractCollection上编写一个 Collection 实现,除了 Iterable必需的那一个方法之外,只需要再实现两个方法:contains 和 size。这些方法经常很容易编写出高效的实现。如果不可行,或许是因为没有在迭代发生之前先确定序列的内容,返回 Stream 或者Iterable,感觉哪一种更自然即可。如果能选择,可以尝试着分别用两个方法返回。

有时候在选择返回类型时,只需要看是否易于实现即可。例如,要编写一个方法,用它返回一个输入列表的所有(相邻的)子列表。它只用三行代码来生成这些子列表,并将它们放在一个标准的集合中,但存放这个集合所需的内存是源列表大小的平方。这虽然没有幂集那么糟糕,但显然也是无法接受的。像给幂集实现定制的集合那样,确实很烦琐,这个可能还更甚,因为 JDK 没有提供基本的 Iterator 实现来支持。

但是,实现输人列表的所有子列表的 Stream 是很简单的,尽管它确实需要有点洞察力。我们把包含列表第一个元素的子列表称作列表的前缀。例如,(a,b,c)的前缀就是(a)、(a,b)和(a,b,c)。同样地,把包含最后一个元素的子列表称作后缀,因此(a,b,c)的后缀就是(a,b,c)、(b,c)和(c)。考验洞察力的是,列表的子列表不过是前缀的后缀(或者说后缀的前缀)和空列表。这一发现直接带来了一个清晰且相当简洁的实现:

1
//Returnsa stream ofallthesublistsofitsinputlistpublic class SubLists {public static<E> Stream<List<E>>of(List<E> list){return Stream.concat(Stream.of(Collections.emptyList(),prefixes(list).flatMap(SubLists::suffixes));}private static<E> Stream<List<E>>prefixes(List<E>list){return IntStream.rangeClosed(1, list.size().mapToObj(end -> list.subList(0,end));private static <E>Stream<List<E>> suffixes(List<E> list){return IntStream.range(0, list.size().mapToObj(start -> list.subList(start, list.size()));f

注意,它用 Stream.concat 方法将空列表添加到返回的 Stream。另外还用 flatMap方法(详见第 45条)生成了一个包含了所有前缀的所有后缀的Stream。最后,通过映射IntStream.range 和 intStream.rangeClosed 返回的连续 int 值的 Stream,生成了前缀和后缀。通俗地讲,这一术语的意思就是指数为整数的标准 for 循环的 Stream 版本。因此,这个子列表实现本质上与明显的嵌套式 for 循环相类似:

1
for (int start = O; start< src.size(; start++)for (int end = start + 1; end <= src.size(); end++)System.out.println(src.subList(start, end));

这个for循环也可以直接翻译成一个Stream。这样得到的结果比前一个实现更加简洁,但是可读性稍微差了一点。它本质上与第 45条中笛卡尔积的 Stream 代码相类似:

1
//Returnsastreamofallthesublistsofitsinputlistpublic static<E>Stream<List<E>>of(List<E>list){return IntStream.range(0, 1ist.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 实现都很好,但这两者都需要用户在任何更适合迭代的地方,采用 Stream-to-Iterable适配器,或者用 Stream。Stream-to-Iterable适配器不仅打乱了客户端代码,在我的机器上循环的速度还降低了 2.3倍。专门构建的Collection 实现

总而言之,在编写返回一系列元素的方法时,要记住有些用户可能想要当作Stream处果集合中已经有元素,或者序列中的元素数量很少,足以创建一个新的集合,那么就返回一个标准的集合,如 ArrayList。否则,就要考虑实现一个定制的集合,如幂集(power set)范例中所示。如果无法返回集合,就返回 Stream 或者Iterable,感觉哪一种更自然即可。如果在未来的 Java 发行版本中,Stream 接口声明被修改成扩展了 Iterable接口,就可以放心地返回Stream了,因为它们允许进行Stream处理和迭代。

第48条:谨慎使用Stream并行

在主流的编程语言中,Java一直走在简化并发编程任务的最前沿。1996 年 Java 发布时,就通过同步和 wait/notify内置了对线程的支持。Java5引l人了 java.util.concurrent类库,提供了并行集合(concurrent collection)和执行者框架(executor framework)。Java 7引人了 fork-join 包,这是一个处理并行分解的高性能框架。Java 8引人了 Stream,只需要调用一次 parallel 方法就可以实现并行处理。在 Java 中编写并发程序变得越来越容易,但是要编写出正确又快速的并发程序,则一向没那么简单。安全性和活性失败是并发编程中需要面对的问题,Stream pipeline并行也不例外。

请看摘自第45条的这段程序:

1
// Stream-based program to generate the first 20 Mersenne primespublic static void main(String[] args) {primes().map(p -> TwO.pow(p.intValueExact()).subtract(ONE)).filter(mersenne -> mersenne.isProbablePrime(50))1imit(20).forEach(System.out::println);static Stream<BigInteger>primes() {return Stream.iterate(TwO, BigInteger::nextProbablePrime);子

在我的机器上,这段程序会立即开始打印素数,完成运行花了12.5秒。假设我天真地想通过在 Stream pipeline 上添加一个 parallel()调用来提速。你认为这样会对其性能产生什么样的影响呢?运行速度会稍微快一点点吗?还是会慢一点点?遗憾的是,其结果是根本不打印任何内容了,CPU的使用率却定在90%一动不动了(活性失败)。程序最后可能会终止,但是我不想一探究竟,半个小时后就强行把它终止了。

这是怎么回事呢?简单地说,Stream类库不知道如何并行这个pipeline,以及如何探索失败。即便在最佳环境下,如果源头是来自 Stream.iterate,或者使用了中间操作的limit,那么并行 pipeline 也不可能提升性能。这个 pipeline 必须同时满足这两个条件。更糟糕的是,默认的并行策略在处理1imit的不可预知性时,是假设额外多处理几个元素,并放弃任何不需要的结果,这些都不会影响性能。在这种情况下,它查找每个梅森素数时,所花费的时间大概是查找之前元素的两倍。因而,额外多计算一个元素的成本,大概相当于计算所有之前元素总和的时间,这个貌似无伤大雅的 pipeline,却使得自动并行算法濒临崩溃。这个故事的寓意很简单:千万不要任意地并行 Stream pipeline。它造成的性能后果有可能是灾难性的。

总之,在 Stream 上通过并行获得的性能,最好是通过ArrayList、HashMap、HashSet和 ConcurrentHashMap 实例,数组,int 范围和long 范围等。这些数据结构的共性是,都可以被精确、轻松地分成任意大小的子范围,使并行线程中的分工变得更加轻松。Stream类库用来执行这个任务的抽象是分割迭代器(spliterator),它是由 Stream 和Iterable 中的 spliterator 方法返回的。

这些数据结构共有的另一项重要特性是,在进行顺序处理时,它们提供了优异的引用局部性(locality of reference):序列化的元素引用一起保存在内存中。被那些引l用访问到的对象在内存中可能不是一个紧挨着一个,这降低了引用的局部性。事实证明,引用局部性对于并行批处理来说至关重要:没有它,线程就会出现闲置,需要等待数据从内存转移到处理器的缓存。具有最佳引用局部性的数据结构是基本类型数组,因为数据本身是相邻地保存在内存中的。

Stream pipeline 的终止操作本质上也影响了并发执行的效率。如果大量的工作在终止操作中完成,而不是全部工作在 pipeline 中完成,并且这个操作是固有的顺序,那么并行pipeline 的效率就会受到限制。并行的最佳终止操作是做减法(reduction),用一个 Stream 的reduce方法,将所有从 pipeline 产生的元素都合并在一起,或者预先打包像 min、max、count 和 sum 这类方法。骤死式操作(short-circuiting operation)如 anyMatch、allMatch和 noneMatch 也都可以并行。由 Stream 的 collect方法执行的操作,都是可变的减法,不是并行的最好选择,因为合并集合的成本非常高。

如果是自已编写 Stream、Iterable 或者Collection实现,并且想要得到适当的并行性能,就必须覆盖 spliterator 方法,并广泛地测试结果 Stream 的并行性能。编写高质量的分割迭代器很困难,并且超出了本书的讨论范畴。

并行Stream不仅可能降低性能,包括活性失败,还可能导致结果出错,以及难以预计的行为(如安全性失败)。安全性失败可能是因为并行的 pipeline 使用了映射、过滤器或者程序员自己编写的其他函数对象,并且没有遵守它们的规范。Stream 规范对于这些函数对象有着严格的要求条件。例如,传到 Stream 的 reduce 操作的收集器函数和组合器函数,必须是有关联、互不干扰,并且是无状态的。如果不满足这些条件(在第 46条中提到了一些),但是按序列运行 pipeline,可能会得到正确的结果;如果并发运行,则可能会突发性失败。

以上值得注意的是,并行的梅森素数程序虽然运行完成了,但是并没有按正确的顺序(升序)打印出素数。为了保存序列化版本程序显示的顺序,必须用forEachOrdered 代替终止操作的forEach,它可以确保按encounter 顺序遍历并行的 Stream。

假如在使用的是一个可以有效分割的源Stream,一个可并行的或者简单的终止操作,以及互不干扰的函数对象,那么将无法获得通过并行实现的提速,除非 pipeline 完成了足够的实际工作,抵消了与并行相关的成本。据不完全估计,Stream 中的元素数量,是每个元素所执行的代码行数的很多倍,至少是十万倍[Lea 14]。

切记:并行Stream 是一项严格的性能优化。对于任何优化都必须在改变前后对性能进行测试,以确保值得这么做(详见第67条)。最理想的是在现实的系统设置中进行测试。一般来说,程序中所有的并行 Stream pipeline 都是在一个通用的 fork-join 池中运行的。只要有一个 pipeline 运行异常,都会损害到系统中其他不相关部分的性能。

听起来貌似在并行 Stream pipeline 时怪事连连,其实正是如此。我有个朋友,他发现在大量使用Stream 的几百万行代码中,只有少数几个并行 Stream是有效的。这并不意味着应该避免使用并行Stream。在适当的条件下,给Streampipeline添加paralle1调用,确实可以在多处理器核的情况下实现近乎线性的倍增。某些域如机器学习和数据处理,尤其适用于这样的提速。

简单举一个并行 Stream pipeline 有效的例子。假设下面这个函数是用来计算 π(n),素数的数量少于或者等于n:

1
//Prime-counting streampipeline -benefits from parallelizationstatic long pi(long n) {return LongStream.rangeClosed(2, n).mapToObj(BigInteger::valueOf).filter(i -> i.isProbablePrime(50))countO;

在我的机器上,这个函数花31秒完成了计算 π(10”)。只要添加一个parallel()调用,就把调用时间减少到了9.2秒:

1
// Prime-counting stream pipeline - parallel versionstatic long pi(long n) {return LongStream.rangeClosed(2, n).parallel().mapToObj(BigInteger::valueOf).filter(i -> i.isProbablePrime(50)).count();

换句话说,并行计算在我的四核机器上添加了 parallel()调用后,速度加快了3.7倍。值得注意的是,这并不是在实践中计算 n值很大时的 π(n)的方法。还有更加高效的算法,如著名的Lehmer公式。

如果要并行一个随机数的 Stream,应该从 SplittableRandom 实例开始,而不是从ThreadLocalRandom(或实际上已经过时的 Random)开始。SplittableRandom 正是专门为此设计的,还有线性提速的可能。ThreadLocalRandom 则只用于单线程,它将自身当作一个并行的 Stream 源运用到函数中,但是没有 SplittableRandom那么快。Random 在每个操作上都进行同步,因此会导致滥用,扼杀了并行的优势。

总而言之,尽量不要并行 Stream pipeline,除非有足够的理由相信它能保证计算的正确性,并且能加快程序的运行速度。如果对Stream 进行不恰当的并行操作,可能导致程序运行失败,或者造成性能灾难。如果确信并行是可行的,并发运行时一定要确保代码正确,并在真实环境下认真地进行性能测量。如果代码正确,这些实验也证明它有助于提升性能,只有这时候,才可以在编写代码时并行 Stream。

本章要讨论方法设计的几个方面:如何处理参数和返回值,如何设计方法签名,如何为方法编写文档。本章大部分内容既适用于构造器,也适用于普通的方法。与第 4章一样,本章的焦点也集中在可用性、健壮性和灵活性上。

第49条:检查参数的有效性

大多数方法和构造器对于传递给它们的参数值都会有某些限制。例如,索引值必须是非负数,对象引用不能为 null,等等,这些都是很常见的。你应该在文档中清楚地指明这些限制,并且在方法体的开头处检查参数,以强制施加这些限制。它是”发生错误之后应该尽快检测出错误”这一普遍原则的一种特例。如果不能做到这一点,检测到错误的可能性就比较小,即使检测到错误了,也比较难以确定错误的根源。

如果传递无效的参数值给方法,这个方法在执行之前先对参数进行了检查,那么它很快就会失败,并且清楚地出现适当的异常(exception)。如果这个方法没有检查它的参数,就有可能发生几种情形。该方法可能在处理过程中失败,并且产生令人费解的异常。更糟糕的是,该方法可以正常返回,但是会悄悄地计算出错误的结果。最糟糕的是,该方法可以正常返回,但是却使得某个对象处于被破坏的状态,将来在某个不确定的时候,在某个不相关的点上会引发出错误。换句话说,没有验证参数的有效性,可能导致违背失败原子性(failure atomicity),详见第76条。

对于公有的和受保护的方法,要用Javadoc的@throws标签(tag)在文档中说明违反参数值限制时会抛出的异常(详见第74条)。这样的异常通常为IllegalArgumentException、IndexOutOfBoundsException 或 NullPointerException(详见第 72条)。一旦在文档中记录了对于方法参数的限制,并且记录了一旦违反这些限制将要抛出的异常,强加这些限制就是非常简单的事情了。下面是一个典型的例子:

1
*** 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 to0/public BigInteger mod(BigInteger m){if(m.signum() <=0)throw new ArithmeticException("Modulus<=0:"+m);//Dothecomputation

注意,文档注释中并没有说”如果m为 null,mod 就抛出 NullPointerException”,而是作为调用m.signum()的副产物,即使方法正是这么做的。这个异常的文档是建立在数。这样可以很好地避免分别在每个方法中给每个 NullPointerException 建立文档而引起的混乱。它可以结合@Nullable 或者类似的注解一起使用,表示某个特殊的参数可以为 null,不过这个实践不是标准的,有多个注解可以完成这个作用。

在Java7中增加的objects.requireNonNul1方法比较灵活且方便,因此不必再手工进行null检查。只要你愿意,还可以指定自已的异常详情。这个方法会返回其输人,因此可以在使用一个值的同时执行 null检查:

1
# //Inline useof Java's null-checking facilitythis.strategy = Objects.requireNonNull(strategy, "strategy");

也可以忽略返回值,并在必要的地方,用 Objects.requireNonNull作为独立的null 检查。

在 Java 9中增加了检查范围的设施:java.util.Objects。这个设施包含三个方法:checkFromIndexSize、checkFromToIndex 和 checkIndex。这个设施不像检查 null的方法那么灵活。它不允许指定自己的异常详情,而是专门设计用于列表和数组索引的。它不处理关闭的范围(包含其两个端点)。但是如果它所做的正是你需要的,那么就是一个有用的工具。

对于未被导出的方法(unexported method),作为包的创建者,你可以控制这个方法将在哪些情况下被调用,因此你可以,也应该确保只将有效的参数值传递进来。因此,非公有的方法通常应该使用断言(assertion)来检查它们的参数,具体做法如下所示:

1
# assert offset >=0&& offset <=a.length;assert length >= 0 &length <= a.length -offset;...// Do the computation

从本质上讲,这些断言是在声称被断言的条件将会为真,无论外围包的客户端如何使用它。不同于一般的有效性检查,断言如果失败,将会抛出 AssertionError。不同于一般的有效性检查,如果它们没有起到作用,本质上也不会有成本开销,除非通过将-ea(或者-enableassertions)标记(flag)传递给Java 解释器,来启用它们。关于断言的更多信息,请见 Sun 的教程[Asserts]。

对于有些参数,方法本身没有用到,却被保存起来供以后使用,检验这类参数的有效性尤为重要。比如,以第 20条中的静态工厂方法为例,它的参数为一个 int 数组,并返回该数组的List 视图。如果这个方法的客户端要传递 null,该方法将会抛出一个 NullPointer-如果省略了这个条件检查,它就会返回一个指向新建List实例的引用,一旦客户端企图使用这个引l用,立即就会抛出 NullPointerException。到那时,要想找到List实例的来源可能就非常困难了,从而使得调试工作更加复杂。

如前所述,有些参数被方法保存起来供以后使用,构造器正是代表了这种原则的一种特例。检查构造器参数的有效性是非常重要的,这样可以避免构造出来的对象违反了这个类的约束条件。

在方法执行它的计算任务之前,应该先检查它的参数,这一规则也有例外。一个很重要的例外是,在某些情况下,有效性检查工作非常昂贵,或者根本是不切实际的,而且有效性检查已隐含在计算过程中完成。例如,以为对象列表排序的方法 Collections.sort(List)为例,列表中的所有对象都必须是可以相互比较的。在为列表排序的过程中,列表中的每个对象将与其他某个对象进行比较。如果这些对象不能相互比较,其中的某个比较操作就会抛出 ClassCastException,这正是sort方法应该做的事情。因此,提前检查列表中的元素是否可以相互比较,这并没有多大意义。然而,请注意,不加选择地使用这种方法将会导致失去失败原子性(failure atomicity),详见第76条。

有时候,某些计算会隐式地执行必要的有效性检查,但是如果检查不成功,就会抛出错误的异常。换句话说,由于无效的参数值而导致计算过程抛出的异常,与文档中标明这个方法将抛出的异常并不相符。在这种情况下,应该使用第73条中讲述的异常转换(exception translation)技术,将计算过程中抛出的异常转换为正确的异常。

请不要由本条目的内容得出这样的结论:对参数的任何限制都是件好事。相反,在设计方法时,应该使它们尽可能通用,并符合实际的需要。假如方法对于它能接受的所有参数值都能够完成合理的工作,对参数的限制就应该是越少越好。然而,通常情况下,有些限制对于被实现的抽象来说是固有的。

简而言之,每当编写方法或者构造器的时候,应该考虑它的参数有哪些限制。应该把这些限制写到文档中,并且在这个方法体的开头处,通过显式的检查来实施这些限制。养成这样的习惯是非常重要的。只要有效性检查有一次失败,你为必要的有效性检查所付出的努力便都可以连本带利地得到偿还了。

第50条:必要时进行保护性拷贝

Java 用起来如此舒适的一个因素在于,它是一门安全的语言(safe language)。这意味着,它对于缓冲区溢出、数组越界、非法指针以及其他的内存破坏错误都自动免疫,而这些错误却困扰着诸如C和C++这样的不安全语言。在一门安全语言中,在设计类的时候,可以确切地知道,无论系统的其他部分发生什么问题,这些类的约束都可以保持为真。对于那些”把所有内存当作一个巨大的数组来对待”的语言来说,这是不可能的。

即使在安全的语言中,如果不采取一点措施,还是无法与其他的类隔离开来。假设类的客户端会尽其所能来破坏这个类的约束条件,因此你必须保护性地设计程序。实际上,只有当有人试图破坏系统的安全性时,才可能发生这种情形;更有可能的是,对你的API产生误解的程序员,所导致的各种不可预期的行为,只好由类来处理。无论是哪种情况,编写一些面对客户的不良行为时仍能保持健壮性的类,这是非常值得投人时间去做的事情。

如果没有对象的帮助,另一个类不可能修改对象的内部状态,但是对象很容易在无意识的情况下提供这种帮助。例如,以下面的类为例,它声称可以表示一段不可变的时间周期:

1
//Broken "immutable"time period classpublic final class Period {private final Date start;private final Date end;*** @paramstart 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

乍看之下,这个类似乎是不可变的,并且强加了约束条件:周期的起始时间(start)不能在结束时间(end)之后。然而,因为Date类本身是可变的,因此很容易违反这个约束条件:

1
//AttacktheinternalsofaPeriodinstanceDate 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 实例的内部信息避免受到这种攻击,对于构造器的每个可变参数进行保护性拷贝(defensive copy)是必要的,并且使用备份对象作为Period实例的组件,而不使用原始的对象:

1
// Repaired constructor-makes defensive copies of parameterspublic 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攻击[ViegaO1]。

同时也请注意,我们没有用 Date 的 clone 方法来进行保护性拷贝。因为 Date 是非final的,不能保证clone 方法一定返回类为java.util.Date 的对象:它有可能返回专门出于恶意的目的而设计的不可信子类的实例。例如,这样的子类可以在每个实例被创建的时候,把指向该实例的引用记录到一个私有的静态列表中,并且允许攻击者访问这个列表。这将使得攻击者可以自由地控制所有的实例。为了阻止这种攻击,对于参数类型可以被不可信任方子类化的参数,请不要使用clone方法进行保护性拷贝。

虽然替换构造器就可以成功地避免上述的攻击,但是改变 Period 实例仍然是有可能的,因为它的访问方法提供了对其可变内部成员的访问能力:

1
// Second attack on the internals of a Period instanceDate start = new Date();Date end = new Date();Period p = new Period(start, end);p.end().setYear(78); // Modifies internals of p!

为了防御这第二种攻击,只需修改这两个访问方法,使它返回可变内部域的保护性拷贝:

1
// Repaired accessors - make defensive copies of internal fieldspublic Date start() {return new Date(start.getTime();public Date endO){return new Date(end.getTime();

采用了新的构造器和新的访问方法之后,Period真正是不可变的了。不管程序员是多么恶意,或者多么不合格,都绝对不会违反”周期的起始时间不能晚于结束时间”这个约束条件。确实如此,因为除了 Period 类自身之外,其他任何类都无法访问 Period 实例中的任何一个可变域。这些域被真正封装在对象的内部。

访问方法与构造器不同,它们在进行保护性拷贝的时候允许使用clone 方法。之所以如此,是因为我们知道,Period 内部的 Date 对象的类型是java.util.Date,而不可能是其他某个潜在的不可信子类。也就是说,基于第13条中所阐述的原因,一般情况下,最好使用构造器或者静态工厂。

参数的保护性拷贝并不仅仅针对不可变类。每当编写方法或者构造器时,如果它允许客户提供的对象进人到内部数据结构中,则有必要考虑一下,客户提供的对象是否有可能是可变的。如果是,就要考虑你的类是否能够容忍对象进人数据结构之后发生变化。如果答案是否定的,就必须对该对象进行保护性拷贝,并且让拷贝之后的对象而不是原始对象进人到数据结构中。例如,如果你正在考虑使用由客户提供的对象引l用作为内部 Set实例的元素,或者作为内部Map实例的键(key),就应该意识到,如果这个对象在插入之后再被修改,Set 或者 Map 的约束条件就会遭到破坏。

在内部组件被返回给客户端之前,对它们进行保护性拷贝也是同样的道理。不管类是否为不可变的,在把一个指向内部可变组件的引用返回给客户端之前,也应该加倍认真地考虑。解决方案是,应该返回保护性拷贝。记住长度非零的数组总是可变的。因此,在把内部数组返回给客户端之前,总要进行保护性拷贝。另一种解决方案是,给客户端返回该数组的不可变视图(immutableview)。这两种方法在第15条中都已经演示过了。

可以肯定地说,上述的真正启示在于,只要有可能都应该使用不可变的对象作为对象内部的组件,这样就不必再为保护性拷贝(详见第17条)操心。在前面的 Period 例子中,使用了Instant(或LocalDateTime,或者ZonedDateTime),除非使用Java 8之前类型,而不是使用 Date 对象引用。

保护性拷贝可能会带来相关的性能损失,这种说法并不总是正确的。如果类信任它的调用者不会修改内部的组件,可能因为类及其客户端都是同一个包的双方,那么不进行保护必拷贝也是可以的。在这种情况下,类的文档中就必须清楚地说明,调用者绝不能修改受到影响的参数或者返回值。

即使跨越包的作用范围,也并不总是适合在将可变参数整合到对象中之前,对它进行保护性拷贝。有一些方法和构造器的调用,要求参数所引用的对象必须有个显式的交接(handoff)过程。当客户端调用这样的方法时,它承诺以后不再直接修改该对象。如果方法或者构造器期望接管一个由客户端提供的可变对象,它就必须在文档中明确地指明这一点。

如果类所包含的方法或者构造器的调用需要移交对象的控制权,这个类就无法让自身抵御恶意的客户端。只有当类和它的客户端之间有着互相的信任,或者破坏类的约束条件不会伤害到除了客户端之外的其他对象时,这种类才是可以接受的。后一种情形的例子是包装类模式(详见第18条)。根据包装类的本质特征,客户端只需在对象被包装之后直接访问它,就可以破坏包装类的约束条件,但是,这么做往往只会伤害到客户端自己。

简而言之,如果一个类包含有从客户端得到或者返回到客户端的可变组件,这个类就必须保护性地拷贝这些组件。如果拷贝的成本受到限制,并且类信任它的客户端不会不恰当地修改组件,就可以在文档中指明客户端的职责是不得修改受到影响的组件,以此来代替保护性拷贝。

第51条:谨慎设计方法签名

本条目是若干API设计技巧的总结,它们都还不足以单独开设一个条目。综合来说,这些设计技巧将有助于使你的API更易于学习和使用,并且比较不容易出错。

谨慎地选择方法的名称。方法的名称应该始终遵循标准的命名习惯(详见第 68条)。首要目标应该是选择易于理解的,并且与同一个包中的其他名称风格一致的名称。第二个目标应该是选择与大众认可的名称(如果存在的话)相一致的名称。如果还有疑问,请参考Java类库的 API。尽管 Java类库的 API 中也有大量不一致的地方,考虑到这些 Java 类库的规模和范围,这是不可避免的,但它们还是得到了相当程度的认可。

不要过于追求提供便利的方法。每个方法都应该尽其所能。方法太多会使类难以学习、使用、文档化、测试和维护。对于接口而言,这无疑是正确的,方法太多会使接口实现者和接口用户的工作变得复杂起来。对于类和接口所支持的每个动作,都提供一个功能齐全的方法。只有当一项操作被经常用到的时候,才考虑为它提供快捷方式(shorthand)。如果不能确定,最好不要提供快捷方式。

避免过长的参数列表。目标是四个参数或者更少。大多数程序员都无法记住更长的参数列表。如果你编写的许多方法都超过了这个限制,你的 API就不太便于使用,除非用户不停地参考它的文档。现代的IDE通过智能提示会有所帮助,但最好还是使用简短的参数列表。相同类型的长参数序列格外有害。API的用户不仅无法记住参数的顺序,而且,当他们不小心弄错了参数顺序时,程序仍然可以编译和运行,只不过这些程序不会按照作者的意图进行工作。

有三种技巧可以缩短过长的参数列表。第一种是把一个方法分解成多个方法,每个方法只需要这些参数的一个子集。如果不小心,这样做会导致方法过多。但是通过提升它们的正交性(orthogonality),还可以减少(reduce)方法的数目。例如,考虑java.util.List接口。它并没有提供在子列表(sublist)中查找元素的第一个索引和最后一个索引的方法,这两个方法都需要三个参数。相反,它提供了 subList方法,这个方法带有两个参数,并起来,获得期望的功能,而这两个方法都分别只有一个参数。而且,subList方法也可以与其他任何”针对List实例进行操作”的方法结合起来,在子列表上执行任意的计算。这样得到的API就有很高的功能-权重(power-to-weight)比。

缩短长参数列表的第二种技巧是创建辅助类(helper class),用来保存参数的分组。这些辅助类一般为静态成员类(详见第 24条)。如果一个频繁出现的参数序列可以被看作是代表了某个独特的实体,则建议使用这种方法。例如,假设你正在编写一个表示纸牌游戏的类,你会发现经常要传递一个双参数的序列来表示纸牌的点数和花色。如果增加辅助类来表示一张纸牌,并且把每个参数序列都换成这个辅助类的单个参数,那么这个纸牌游戏类的API以及它的内部表示都可能会得到改进。

结合了前两种技巧特征的第三种技巧是,从对象构建到方法调用都采用Builder模式(详见第2条)。如果方法带有多个参数,尤其是当它们中有些是可选的时候,最好定义一个对象来表示所有参数,并允许客户端在这个对象上进行多次”setter”(设置)调用,每次调用都设置一个参数,或者设置一个较小的相关的集合。一旦设置了需要的参数,客户端就调用对象的”执行”(execute)方法,它对参数进行最终的有效性检查,并执行实际的计算。

对于参数类型,要优先使用接口而不是类(详见第64条)。只要有适当的接口可用来定义参数,就优先使用这个接口,而不是使用实现该接口的类。例如,没有理由在编写方法时使用 HashMap 类来作为输人,相反应当使用 Map 接口作为参数。这使你可以传人一个Hashtable、HashMap、TreeMap、TreeMap 的子映射表(submap),或者任何有待于将来编写的Map实现。如果使用的是类而不是接口,则限制了客户端只能传人特定的实现,如果碰巧输入的数据是以其他的形式存在,就会导致不必要的、可能非常昂贵的拷贝操作。

对于boolean参数,要优先使用两个元素的枚举类型。它使代码更易于阅读和编写,尤其是当你在使用支持自动完成功能的IDE时。它也使以后更易于添加其他的选项。例如,你可能会有一个 Thermometer 类型,它带有一个静态工厂方法,而这个静态工厂方法的签名需要带有这个枚举的值:

1
# public enum TemperatureScale { FAHRENHEIT,CELSIUS }

Thermometer.newInstance (TemperatureScale.CELSIUS) 不仅比 Thermometer.newInstance(true)更有用,而且你还可以在未来的发行版本中将 KELVIN 添加到 Tem-依赖重构到枚举常量的方法中(详见第 34条)。例如,每个范围常量都可以有一个方法,它带有一个 double值,并将它规格化成摄氏度。

第52条:慎用重载

下面这个程序的意图是好的,它试图根据一个集合是 Set、List,还是其他的集合类型,来对它进行分类:

1
// 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<String>(),new ArrayList<BigInteger>(),new HashMap<String, String>().values()for (Collection<?> c:collections)System.out.println(classify(c));

你可能期望这个程序会打印出 Set,紧接着是List,以及 Unknown Collection,但实际上不是这样。它打印了三次 Unknown Collection。为什么会这样呢?因为classify方法被重载(overloaded)了,而要调用哪个重载方法是在编译时做出决定的。对于for循环中的全部三次迭代,参数的编译时类型都是相同的:Collection。每次迭代的运行时类型都是不同的,但这并不影响对重载方法的选择。因为该参数的编译时类型为Collection,所以,唯一合适的重载方法是classify(Collection<?>),在循环的每次迭代中,都会调用这个重载方法。

这个程序的行为有悖常理,因为对于重载方法的选择是静态的,而对于被覆盖的方法的选择则是动态的。选择被覆盖的方法的正确版本是在运行时进行的,选择的依据是被调用方法所在对象的运行时类型。这里重新说明一下,当一个子类包含的方法声明与其祖先类中的方法声明具有同样的签名时,方法就被覆盖了。如果实例方法在子类中被覆盖了,并且这个方法是在该子类的实例上被调用的,那么子类中的覆盖方法(overriding method)将会执行,而不管该子类实例的编译时类型到底是什么。为了进行更具体的说明,以下面的程序为例:

1
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<Wine> wineList = List.of(new Wine(, new SparklingWine(), new Champagne();for (Wine wine :wineList)System.out.println(wine.nameO);

name 方法是在类 Wine 中被声明的,但是在类 SparklingWine 和 Champagne 中被覆盖。正如你所预期的那样,这个程序打印出 wine、sparkling wine 和 champagne,尽管在循环的每次迭代中,实例的编译时类型都为Wine。当调用被覆盖的方法时,对象的编译时类型不会影响到哪个方法将被执行;”最为具体的”(most specific)那个覆盖版本总是会得到执行。这与重载的情形相比,对象的运行时类型并不影响”哪个重载版本将被执行”;选择工作是在编译时进行的,完全基于参数的编译时类型。

在 CollectionClassifier 示例中,该程序的意图是:期望编译器根据参数的运行时类型自动将调用分发给适当的重载方法,以此来识别出参数的类型,就好像 Wine 的例子中的 name 方法所做的那样。方法重载机制完全没有提供这样的功能。假设需要有个静态方法,这个程序的最佳修正方案是,用单个方法来替换这三个重载的 classify方法,并在这个方法中做一个显式的instanceof测试:

1
public static String classify(Collection<?>c){return c instanceof Set?"Set":Cinstanceof List ? "List":"Unknown Collection";

因为覆盖机制是标准规范,而重载机制是例外,所以,覆盖机制满足了人们对于方法调用行为的期望。正如 CollectionClassifier 例子所示,重载机制很容易使这些期望落空。如果编写出来的代码的行为可能使程序员感到困惑,那么它就是很糟糕的实践。对于API来说尤其如此。如果API的普通用户根本不知道”对于一组给定的参数,其中的哪个重载方法将会被调用”,那么使用这样的API就很可能导致错误。这些错误要等到运行时发生了怪异的行为之后才会显现出来,导致许多程序员无法诊断出这样的错误。因此,应该避免

胡乱地使用重载机制。

到底是什么造成胡乱使用重载机制呢?这个问题仍有争议。安全而保守的策略是,永远不要导出两个具有相同参数数目的重载方法。如果方法使用可变参数,除第53条中所述的情形之外,保守的策略是根本不要重载它。如果你遵守这些限制,程序员永远也不会陷人”对于任何一组实际的参数,哪个重载方法才是适用的”这样的疑问中。这项限制并不麻烦,因为你始终可以给方法起不同的名称,而不使用重载机制。

例如,以 ObjectOutputStream 类为例。对于每个基本类型,以及几种弓[用类型,它的 write 方法都有一种变形。这些变形方法并不是重载 write 方法,而是具有诸如 write-Boolean(boolean)、writeInt(int)和 writeLong(long)这样的签名。与重载方案相比较,这种命名模式带来的好处是,可以提供相应名称的读方法,比如 readBoolean()、

对于构造器,你没有选择使用不同名称的机会;一个类的多个构造器总是重载的。在许多情况下,可以选择导出静态工厂,而不是构造器(详见第1条)。对于构造器,还不用担心重载和覆盖的相互影响,因为构造器不可能被覆盖。或许你有可能导出多个具有相同参数数目的构造器,所以有必要了解一下如何安全地做到这一点。

如果对于”任何一组给定的实际参数将应用于哪个重载方法上”始终非常清楚,那么导出多个具有相同参数数目的重载方法就不可能使程序员感到混淆。对于每一对重载方型,就属于这种不会感到混淆的情形了。如果显然不可能把一种类型的实例转换为另一种类型,这两种类型就是根本不同的。在这种情况下,一组给定的实际参数应用于哪个重载方法上就完全由参数的运行时类型来决定,不可能受到其编译时类型的影响,所以主要的混淆根源就消除了。例如,ArrayList有一个构造器带一个 int参数,另一个构造器带一个Collection 参数。难以想象在任何情况下,这两个构造器被调用时哪一个会产生混淆。

在Java5发行版本之前,所有的基本类型都根本不同于所有的引用类型,但是当自动装箱出现之后,就不再如此了,它会导致真正的麻烦。以下面这个程序为例:

1
public class SetList {public static void main(String[]  args) {Set<Integer> set = new TreeSet<>();List<Integer> 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之间的整数添加到了排好序的集合和列表中,然后在集合和列表中都进行3次相同的 remove 调用。如果像大多数人一样,希望程序从集合和列表中去除非整数值(0、1 和2),并打印出[-3,-2,-1][-3,-2,-1]。事实上,程序从集合中去除了非整数,还从列表中去除了奇数值,打印出[-3,-2,-1][-2,0,2]。我们将这种行为称之为混乱,已是保守的说法。

实际发生的情况是:set.remove(i)调用选择重载方法 remove(E),这里的E是集合(Integer)的元素类型,将i从 int 自动装箱到Integer 中。这是你所期待的行为,因此程序不会从集合中去除正值。另一方面,list.remove(i)调用选择重载方法remove(int i),它从列表的指定位置上去除元素。如果从列表[-3,-2,-1,0,1,2]开始,去除第零个元素,接着去除第一个、第二个,得到的是[-2,〇,2],这个秘密被揭开了。为了解决这个问题,要将 1ist.remove 的参数转换成Integer,迫使选择正确的重载方法。另一种方法是调用 Integer.valueOf(i),并将结果传给list.remove。这两种方法都如我们所料,打印出[-3,-2,-1][-3,-2,-1]:

1
for(int i= 0;i< 3;i++){set.remove(i);list.remove((Integer) i); // or remove(Integer.value0f(i))remove 方法:remove(E)和remove(int)。当它在Java 5发行版本中被泛型化之前,List接口有一个remove(Object)而不是remove(E),相应的参数类型:Object和int,则根本不同。但是自从有了泛型和自动装箱之后,这两种参数类型就不再根本不同了。换句话说,Java 语言中添加了泛型和自动装箱之后,破坏了List接口。幸运的是,Java类库中几乎再没有 API受到同样的破坏,但是这种情形清楚地说明了,自动装箱和泛型成了Java语言的组成部分之后,谨慎重载显得更加重要了。

在Java 8中增加了lambda 和方法引用之后,进一步增加了重载造成混淆的可能。比如,以下面这两个代码片段为例:

1
2
3
4
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方法也没有被重载,submit方法调用则是合法的。这是被引l用的方法(println)的重载,与被调用方法(submit)的结合,阻止了重载方案算法按你预期的方式完成。

从技术的角度来看,问题在于,System.out::println是一个不精确的方法引用(inexact method reference)[JLS,15.13.1],而且”某些包含隐式类型 lambda 表达式或者不精确方法引用的参数表达式会被可用性测试忽略,因为它们的含义要到选择好目标类型之后才能确定[JLS,15.12.2』”。如果你不理解这段话的意思也没关系,这是针对编译器作者而言的。重点是在同一个参数位置,重载带有不同函数接口的方法或者构造器会造成混淆。因此,不要在相同的参数位置调用带有不同函数接口的方法。按照本条目的说法,不同的函数接口并非根本不同。如果传人命令行参数:-Xlint:overloads,Java 编译器会对这种有问题的重载发出警告。

数组类型和 Object 之外的类截然不同。数组类型和 Serializable 与 Cloneable之外的接口也截然不同。如果两个类都不是对方的后代,这两个独特的类就是不相关的(unrelated)[JLS,5.5]。例如,String 和 Throwable 就是不相关的。任何对象都不可能是两个不相关的类的实例,因此不相关的类也是根本不同的。

还有其他一些”类型对”的例子也是不能相互转换的[JLS,5.1.12],但是,一旦超出了上述这些简单的情形,大多数程序员要想搞清楚”一组实际的参数应用于哪个重载方法上”就会非常困难。确定选择哪个重载方法的规则是非常复杂的,这些规则在每个发行版本中都变得越来越复杂。很少有程序员能够理解其中的所有微妙之处。

有时候,尤其在更新现有类的时候,可能会被迫违反本条目的指导原则。例如,自从Java4 发行版本以来,String类就已经有一个contentEquals(StringBuffer)方法。在 Java 5 发行版本中,新增了一个称作 CharSequence 的接口,用来为 StringBuffer、StringBuilder、String、CharBuffer以及其他类似的类型提供公共接口。在Java平台中增加 CharSequence 的同时,String 也配备了重载的contentEquals 方法,即contentEquals (CharSequence) 方法。

尽管这样的重载显然违反了本条目的指导原则,但是只要当这两个重载方法在同样的参数上被调用时,它们执行的是相同的功能,重载就不会带来危害。程序员可能并不知道哪个重载函数会被调用,但只要这两个方法返回相同的结果就行。确保这种行为的标准做法是,让更具体化的重载方法把调用转发给更一般化的重载方法:

1
//Ensuringthat2methodshaveidenticalbehaviorbyforwardingpublic boolean contentEquals(StringBuffer sb){return contentEquals((CharSequence) sb);子

虽然Java平台类库很大程度上遵循了本条目中的建议,但是也有诸多的类违背了。例如,String 类导出两个重载的静态工厂方法:valueOf(char[])和valueOf(Object),当这两个方法被传递了同样的对象引用时,它们所做的事情完全不同。没有正当的理由可以解释这一点,它应该被看作是一种反常行为,有可能会造成真正的混淆。

简而言之,”能够重载方法”并不意味着就”应该重载方法”。一般情况下,对于多个具有相同参数数目的方法来说,应该尽量避免重载方法。在某些情况下,特别是涉及构造器的时候,要遵循这条建议也许是不可能的。在这种情况下,至少应该避免这样的情形:同一组参数只需经过类型转换就可以被传递给不同的重载方法。如果不能避免这种情形,例如,因为正在改造一个现有的类以实现新的接口,就应该保证:当传递同样的参数时,所有重载方法的行为必须一致。如果不能做到这一点,程序员就很难有效地使用被重载的方法或者构造器,同时也不能理解它为什么不能正常地工作。

第53条:慎用可变参数

可变参数方法一般称作variable aritymethod(可匹配不同长度的变量的方法)[JLS,8.4.1],它接受0个或者多个指定类型的参数。可变参数机制首先会创建一个数组,数组的大小为在调用位置所传递的参数数量,然后将参数值传到数组中,最后将数组传递给方法。

例如,下面就是一个可变参数方法,带有int参数的一个序列,并返回它们的总和。正如你所期望的,sum(1,2,3)的值为6,Sum()的值为0:

1
//Simpleuseofvarargsstatic int sum(int... args) {int sum = 0;for (int arg : args)sum += arg;return sum;

有时候,必须编写需要一个或者多个某种类型参数的方法,而不是需要0个或者多个。个函数的定义就不太好了。你可以在运行时检查数组长度:

1
// The WRoNG way to use varargs to pass one or more arguments!static int min(int... args) {if (args.length == 0)int min = args[0];for(int i= l;i< args.length;i++)if (args[i]<min)min = args[i];return min;了

这种解决方案有几个问题。其中最严重的问题是,如果客户端调用这个方法时,并没有传递参数进去,它就会在运行时而不是编译时发生失败。另一个问题是,这段代码很不美观。你必须在 args 中包含显式的有效性检查,除非将 min 初始化为 Integer.MAX_VALUE,否则将无法使用 for-each 循环,这样的代码也不美观。

幸运的是,有一种更好的方法可以实现想要的效果。声明该方法带有两个参数,一个是指定类型的正常参数,另一个是这种类型的可变参数。这种解决方案解决了前一个示例中的所有不足:

1
//Therightwayto usevarargsto pass one or more argumentsstatic int min(int firstArg, int... remainingArgs) {int min = firstArg;for (int arg : remainingArgs)if (arg < min)min = arg;return min;子

如你所见,当你真正需要让一个方法带有不定数量的参数时,可变参数就非常有效。可变参数是为printf而设计的,该方法是与可变参数同时添加到Java平台中的,为了核心的反射机制(详见第 65条),被改造成利用可变参数。Printf 和反射机制都从可变参数中获得了极大的益处。

在重视性能的情况下,使用可变参数机制要特别小心。每次调用可变参数方法都会导致一次数组分配和初始化。如果凭经验确定无法承受这一成本,但又需要可变参数的灵活性,还有一种模式可以让你如愿以偿。假设确定对某个方法95%的调用会有3个或者更少的参数,就声明该方法的5个重载,每个重载方法带有0至3个普通参数,当参数的数目超过3个时,就使用一个可变参数方法:

1
public void foo() {}public void foo(int al) {}public void foo(int al,int a2){}public void foo(int al,int a2,int a3) { }public void foo(int al,int a2,int a3,int... rest) {}

现在你知道了,当参数的数目超过3个时,所有调用中只有5%需要创建数组。就像大多数的性能优化一样,这种方法通常不太恰当,但是一旦真正需要它时,它可就帮上大忙了。

EnumSet类对它的静态工厂使用了这种方法,最大限度地减少创建枚举集合的成本。当时这么做是有必要的,因为枚举集合为位域提供了在性能方面有竞争力的替代方法,这是很重要的(详见第36条)。

简而言之,在定义参数数目不定的方法时,可变参数方法是一种很方便的方式。在使用可变参数之前,要先包含所有必要的参数,并且要关注使用可变参数所带来的性能影响。

第54条:返回零长度的数组或者集合,而不是null

像下面这样的方法并不少见:

1
// Returns null to indicate an empty collection. Don't do this!private final List<Cheese> cheesesInStock = ...;/*** @return a list containing all of the cheeses in the shop,or null if no cheeses are available for purchase.*public List<Cheese> getCheeses(){return cheesesInStock.isEmpty() ? null : new ArrayList<>(cheesesInStock);

把没有奶酪(cheese)可买的情况当作是一种特例,这是不合常理的。这样做会要求客户端中必须有额外的代码来处理 null返回值,例如:

1
List<Cheese> cheeses = shop.getCheeses();if (cheeses !=null && cheeses.contains(Cheese.STILTON))System.out.println("Jolly good, just the thing.");

对于一个返回 null 而不是零长度数组或者集合的方法,几乎每次用到该方法时都需要这种曲折的处理方式。这样做很容易出错,因为编写客户端程序的程序员可能会忘记写这种专门的代码来处理 null返回值。这样的错误也许几年都不会被注意到,因为这样的方法通常返回一个或者多个对象。返回null而不是零长度的容器,也会使返回该容器的方法实现代码变得更加复杂。

有时候会有人认为:null返回值比零长度集合或者数组更好,因为它避免了分配零长度的容器所需要的开销。这种观点是站不住脚的,原因有两点。第一,在这个级别上担心性能问题是不明智的,除非分析表明这个方法正是造成性能问题的真正源头(详见第 67条)。第二,不需要分配零长度的集合或者数组,也可以返回它们。下面是返回可能的零长度集合的一段典型代码。一般情况下,这些都是必须的:

1
//The right way to return a possibly empty collectionpublic List<Cheese> getCheeses(){return new ArrayList<>(cheesesInStock);了

万一有证据表示分配零长度的集合损害了程序的性能,可以通过重复返回同一个不可变的零长度集合,避免了分配的执行,因为不可变对象可以被自由共享(详见第17条)。下面的代码正是这么做的,它使用了Collections.emptyList方法。如果返回的是集合,最好使用Collections.emptySet;如果返回的是映射,最好使用Collections。emptyMap。但是要记住,这是一个优化,并且几乎用不上。如果你认为确实需要,必须在行动前后分别测试测量性能,确保这么做确实是有帮助的:

1
// Optimization -avoids allocating empty collectionspublic List<Cheese> getCheeses() {return cheesesInStock.isEmpty() ? Collections.emptyList():new ArrayList<>(cheesesInStock);}

说,应该只返回一个正确长度的数组,这个长度可以为零。注意,我们将一个零长度的数组传递给了toArray 方法,以指明所期望的返回类型,即 Cheese[]:

1
//The right way to return a possibly empty arraypublic Cheese[] getCheeses() {return cheesesInStock.toArray(new Cheese[o]);子

如果确信分配零长度的数组会伤害性能,可以重复返回同一个零长度的数组,因为所有零长度的数组都是不可变的:

1
// Optimization - avoids allocating empty arraysprivate static final Cheese[] EMPTY_CHEESE_ARRAY= new Cheese[O];public Cheese[] getCheeses(){return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);}

在优化性能的版本中,我们将同一个零长度的数组传进了每一次的toArraY调用,每当 cheesesInStock 为空时,就会从 getCheese 返回这个数组。千万不要指望通过预先分配传人toArray 的数组来提升性能。研究表明,这样只会适得其反[Shipilevl6]:

1
// Don't do this - preallocating the array harms performance!return cheesesInStock.toArray(new Cheese[cheesesInStock.size(]);

简而言之,永远不要返回 null,而不返回一个零长度的数组或者集合。如果返回 null,那样会使API更难以使用,也更容易出错,而且没有任何性能优势。

第55条:谨慎返回optinal

在Java 8之前,要编写一个在特定环境下无法返回任何值的方法时,有两种方法:要么抛出异常,要么返回nul1(假设返回类型是一个对象引用类型)。但这两种方法都不够完美。异常应该根据异常条件保留起来(详见第69条)。由于创建异常时会捕捉整个堆栈轨迹,因此抛出异常的开销很高。返回 nul1没有这些缺点,但它有自身的不足。如果方法返回 null,客户端就必须包含特殊的代码来处理返回 null的可能性,除非程序员能证明不可能返回 null。如果客户端疏忽了,没有检查 null返回值,并将 null返回值保存在某个数据结构中,那么未来在与这个问题毫不相关的某处代码中,随时有可能发生NullPointerException 异常。

在Java 8中,还有第三种方法可以编写不能返回值的方法。Optinal类代表的是一个不可变的容器,它可以存放单个非null的T引用,或者什么内容都没有。不包含任何内容的 optional 称为空(empty)。非空的optional 中的值称作存在(present)。optional本质上是一个不可变的集合,最多只能存放一个元素。Optional没有实现Collection接口,但原则上是可以的。

理论上能返回T的方法,实践中也可能无法返回,因此在某些特定的条件下,可以改为声明返回 Optional。它允许方法返回空的结果,表明无法返回有效的结果。返回Optional 的方法比抛出异常的方法使用起来更灵活,也更容易,并且比返回 null 的方法更不容易出错。

在第30条展示过下面这个方法,用来根据元素的自然顺序,计算集合中的最大值。

1
//Returnsmaximumvalueincollection-throwsexceptionifemptypublic static <E extends Comparable<E>> E max(Collection<E> c){if (c.isEmptyO)throw new IllegalArgumentException("Empty collection");Eresult=null;for(Ee:c)if(result = null 1l e.compareTo(result)>0)result =Objects.requireNonNull(e);return result;

如果指定的集合为空,这个方法就会抛出 IllegalArgumentException。在第 30条中说过,更好的替代方法是返回Optional。下面就是修改之后的代码:

1
//Returnsmaximum valueincollectionas an Optional<E>public static<E extends Comparable<E>>Optional<E>max(Collection<E>c){if (c.isEmptyO)return Optional.emptyO);Eresult=null;for(Ee:c)if(result ==null 1l e.compareTo(result)>0)result = Objects.requireNonNull(e);return Optional.of(result);

如上所示,返回 optional是很简单的事。只要用适当的静态工厂创建optional 即可。在这个程序中,我们使用了两个optional:Optional.empty()返回一个空的 optional,Optional.of(value)返回一个包含了指定非 null值的optional。将 null 传人Optional.of(value)是一个编程错误。如果这么做,该方法将会抛出 NullPointerException。Optional.ofNullable(value)方法接受可能为 null的值,当传人null值时就返回一个空的optional。永远不要通过返回 Optional 的方法返回 null:因为它彻底违背了 optional 的本意。

Stream 的许多终止操作都返回 optional。如果重新用stream 编写 max方法,让 stream的 max 操作替我们完成产生 optional的工作(虽然还是需要传人一个显式的比较器):

1
// Returns max val in collection as Optional<E>-uses streampublic static <E extends Comparable<E>>Optional<E> max(Collection<E>c){return c.stream(.max(Comparator.natural0rderO);

那么,如何选择是返回 optional,还是返回 null,或是抛出异常呢?Optional 本质上与受检异常(详见第71条)相类似,因为它们强迫 API用户面对没有返回值的现实。抛出未受检的异常,或者返回null,都允许用户忽略这种可能性,从而可能带来灾难性的后果。但是,抛出受检异常需要在客户端添加额外的样板代码。

如果方法返回 optional,客户端必须做出选择:如果该方法不能返回值时应该采取什么动作。你可以指定一个缺省值:

或者抛出任何适当的异常。注意此处传入的是一个异常工厂,而不是真正的异常。这避免了创建异常的开销,除非它真正抛出异常:

1
//Using anoptionalto throwachosenexceptionToy myToy = max(toys).orElseThrow(TemperTantrumException::new);如果你能够证明optional为非空,就不必指定如果optional为空要采取什么动作,直接从optional获得值即可;但是如果你的判断错了,代码就会抛出一个NoSuchElement-Exception:

//Usingoptional whenyouknow there’sareturnvalueElement lastNobleGas = max(Elements.NOBLE_GASES).getO);

有时候,获取缺省值的开销可能很高,除非十分必要,否则还是希望能够避免这一开销。对于这类情况,Optional 提供了一个带有 Supplier的方法,只在必要的时候才调用它。这个方法叫orElseGet,但或许应该叫orElseCompute,因为它与三个名称以 compute 开头的 Map 方法密切相关。有几个Optional方法可以用来处理更加特殊用例的情况:filter、map、flatMap 和 ifPresent。Java 9 又在其中新增了两个方法 or 和ifPresentOrElse。如果上述基本方法不适用,可以查看文档寻找更高级的方法,看看它们是否能够完成你所需的任务。

万一这些方法都无法满足需求,Optional还提供了isPresent()方法,它可以被当作是一个安全阀。当 optional 中包含一个值时,它返回true;当 optional 为空时,返回false。该方法可用于对optional 结果执行任意的处理,但要确保正确使用。isPresent的许多用法都可以用上述任意一种方法取代。这样得到的代码一般会更加简短、清晰,也更符合习惯用法。

例如,以下代码片段用于打印出一个进程的父进程ID,当该进程没有父进程时打印 N/A。这里使用了在Java9中引l人的ProcessHand类:

1
Optional<ProcessHandle> parentProcess = ph.parent(;System.out.println("Parent PID: "+ (parentProcess.isPresentO) ?String.valueOf(parentProcess.get(.pid()) : "N/A"));

上述代码片段可以用以下的代码代替,这里使用了Optional 的 map 函数:

1
System.out.println("Parent PID:"+ph.parent() .map(h -> String.value0f(h.pid())).orElse("N/A"));

当用 Stream 编程时,经常会遇到 Stream<Optional>,为了推动进程还需要一个包含了非空optional中所有元素的 Stream。如果使用的是Java 8版本,可以像这样弥补差距:

1
streamOfOptionals.filter(Optional::isPresent).map(Optional: :get)

在Java 9中,Optional 还配有一个 stream()方法。这个方法是一个适配器,如果optional 中有一个值,它就将 Optional变成包含一个元素的 Stream;如果 optional为空,地取代上述代码片段,如下:

1
streamOfOptionalsflatMap(Optional::stream)

但是并非所有的返回类型都受益于optional的处理方法。容器类型包括集合、映射、Stream、数组和 optional,都不应该被包装在optional 中。不要返回空的 Optional免于处理一个 optional。ProcessHandle 类确实有 arguments 方法,它返回 Optional<String[]>,但是应该把这个方法看作是一个不该被模仿的异常。

那么何时应该声明一个方法来返回 Optional而不是返回 T呢?规则是:如果无法返回结果并且当没有返回结果时客户端必须执行特殊的处理,那么就应该声明该方法返回Optional。也就是说,返回Optional并非不需要任何成本。

Optional是一个必须进行分配和初始化的对象,从 optional读取值时需要额外的开销。这使得optional不适用于一些注重性能的情况。一个特殊的方法是否属于此类,只能通过仔细的测量来确定才行(详见第 67条)。

返回一个包含了基本包装类型的optional,比返回一个基本类型的开销更高,因为optional有两级包装,不是0 级。因此,类库设计师认为必须为基本类型 int、long 和 double 提供类似 Optional的方法。这些 optional 类型为:OptionalInt、OptionalLong 和OptionalDouble。这些包含了Optional中大部分但并非全部的方法。因此,永远不应该返回基本包装类型的 optional,”小型的基本类型”(Boolean、Byte、Character、Short 和Float)除外。

到目前为止,我们已经讨论了返回optional,以及返回之后对它们的处理方法。之所以还没有讨论到其他可能的用途,是因为 optional 的大部分其他用途都还受到质疑。例如,永远不应该用optional作为映射值。如果这么做,有两种方式来表达一个键的逻辑缺失:要么这个键可以不出现在映射中,要么它可以存在,并映射到一个空的 optional。这些既增加了无谓的复杂度,并极有可能造成混淆和出错。更通俗地说,几乎永远都不适合用 optional作为键、值,或者集合或数组中的元素。

这里留下了一个尚未解答的问题:适合将optional保存在实例域中吗?这个答案散发着”恶臭的气息”:它建议使用包含optional域的子类。不过有时候它又是有道理的。以第2条中的 NutritionFacts 类为例,NutritionFacts 实例中包含了许多不必要的域。你不可能给这些域中每一个可能的合并都提供一个子类。而且,这些域有基本类型,导致不方便直接描述这种缺失。NutritionFacts 最好的 API会从 get 方法处为每个 optional 域获得一个 optional,因此将那些 optional 作为域保存在对象中的做法会变得很有意义。

总而言之,如果发现自己在编写的方法始终无法返回值,并且相信该方法的用户每次在调用它时都要考虑到这种可能性,那么或许就应该返回一个optional。但是,应当注意到与返回optional 相关的真实的性能影响;对于注重性能的方法,最好是返回一个 null,或者抛出异常。最后,尽量不要将optional用作返回值以外的任何其他用途。

第56条:为所有导出的API元素编写文档注释

如果要想使一个API真正可用,就必须为其编写文档。传统意义上的API文档是手工生成的,所以保持文档与代码同步是一件很烦琐的事情。Java 编程环境提供了一种被称为 Javadoc 的实用工具,从而使这项任务变得很容易。Javadoc 利用特殊格式的文档注释(documentationcomment,通常被写作doc comment),根据源代码自动产生API文档。

虽然文档注释还没有正式成为Java编程语言的一部分,但它们已经构成了每个程序员都应该知道的事实 API。这些规范的内容在如何编写文档注释(How to Write Doc Comments)的网页上进行了说明[Javadoc-guide]。虽然这个网页在 Java 4 发行版本之后还没有进行更新,但它仍然是个很有价值的资源。在Java 9 中新增了一个重要的文档标签:{@index};在Java 8中增加了一个文档标签:{@implSpec};在Java 5中新增了两个文档标签:{@literal}和{@code}。这些标签在之前提到过的网页上已经没有了,但会在本条目中讨论到。

为了正确地编写API文档,必须在每个被导出的类、接口、构造器、方法和域声明之前增加一个文档注释。如果类是可序列化的,也应该对它的序列化形式编写文档(详见第87条)。如果没有文档注释,Javadoc 所能够做的也就是重新生成该声明,作为受影响的API元素的唯一文档。使用没有文档注释的API是非常痛苦的,也很容易出错。公有的类不能使用缺省构造器,因为无法为它们提供文档注释。为了编写出可维护的代码,还应该为那些没有被导出的类、接口、构造器、方法和域编写文档注释。

方法的文档注释应该简洁地描述出它和客户端之间的约定。除了专门为继承而设计的类中的方法(详见第19条)之外,这个约定应该说明这个方法做了什么,而不是说明它是如何完成这项工作的。文档注释应该列举出这个方法的所有前提条件(precondition)和后置条件(postcondition),所谓前提条件是指为了使客户能够调用这个方法,而必须要满足的条件;所谓后置条件是指在调用成功完成之后,哪些条件必须要满足。一般情况下,前提条件是由@throws 标签针对未受检的异常所隐含描述的;每个未受检的异常都对应一个前提违例(precondition violation)。同样地,也可以在一些受影响的参数的@param 标记中指定前提条件。

除了前提条件和后置条件之外,每个方法还应该在文档中描述它的副作用(sideeffect)。所谓副作用是指系统状态中可以观察到的变化,它不是为了获得后置条件而明确要求的变化。例如,如果方法启动了后台线程,文档中就应该说明这一点。

为了完整地描述方法的约定,方法的文档注释应该让每个参数都有一个@param 标签,以及一个 @return 标签(除非这个方法的返回类型为 void),以及对于该方法抛出的每个异常,无论是受检的还是未受检的都应有一个@throws 标签(详见第 74 条)。如果@return标签中的文本与方法的描述一致,就允许省略,具体取决于你所遵循的编码标准。

按照惯例,跟在@param 标签或者@return 标签后面的文字应该是一个名词短语,描述了这个参数或者返回值所表示的值。在极少数情况下,也会用算术表达式来代替名词短语,详情请参考BigInteger 的例子。跟在@throws 标签之后的文字应该包含单词”if”(如果),紧接着是一个名词短语,它描述了这个异常将在什么样的条件下抛出。按照惯例,@param、@return 或者@throws 标签后面的短语或者子句都不用句点来结束。下面这个简短的文档注释演示了所有这些习惯做法:

1
***Returns the element at the specified position in this list.*<p>This method is <i>not</i>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 benon-negative and less thanthe size of this list* @throws IndexOutOfBoundsException if the index is out of range({@code index<0 1l index >=this.size(})*/E get(int index);

翻译成HTML,文档注释中包含的任意HTML元素都会出现在结果HTML文档中。有时程序员还会把HTML表格嵌人到它们的文档注释中,但是这种做法并不多见。

还要注意,@throws子句的代码片段中到处使用了Javadoc 的{@code}标签。它有两个作用:造成该代码片段以codefont(代码字体)呈现,并限制HTML标记和嵌套的Javadoc标签在代码片段中进行处理。后一种属性正是允许我们在代码片段中使用小于号(<),虽然它是一个HTML元字符。为了将一个多行的代码示例包含在文档注释中,要使用包在HTML 的

标签里面的 Javadoc 标签{@code}。换句话说,是先在多行的代码示例前使用字符
{@code,然后在代码后面加上}
。这样就可以在代码中保留换行,不需要对HTML元字符进行转义,但@ 符号并非如此,如果代码使用了注释就必须进行转义。

最后,要注意这个文档注释中用到了词语”this list”。按惯例,当”this”一词被用在实例方法的文档注释中时,它应该始终是指方法调用所在的对象。

如第 15 条所述,在专门为了继承设计类时,必须在文档中注释它的自用模式(self-use patterm),便于程序员了解覆盖其方法的语义。这些自用模式应该利用Java 8 中增加的@implSpec标签进行文档注释。回顾一下,普通的文档注释是描述方法及其客户端之间的约定;相反,@implSpec 注释则是描述方法及其子类之间的约定,如果子类继承了该方法,或者通过super 调用了方法,则允许子类依赖实现行为。下面是具体的用法范例:

1
* Returns true if this collection is empty.*@imp1Spec*Thisimplementation returns {@code this.size() ==0}* @return true if this collection is emptypublicboolean isEmpty() {...}

从 Java 9 开始,Javadoc 工具仍然忽略 @implSpec 标签,除非传入命令行参数:-tag中得到改进。

不要忘记,为了产生包含HTML元字符的文档,比如小于号(<)、大于号(>)以及”与”号(&),必须采取特殊的动作。让这些字符出现在文档中的最佳办法是用{@literal}标签将它们包围起来,这样就限制了HTML标记和嵌套的Javadoc 标签的处理。除了它不以代码字体渲染文本之外,其他方面都和{@code}标签一样。例如,这个Javadoc 片段:

1
*A geometric seriesconvergesif{@literal|r|<1}.

它产生了文档:”A geometric series converges if|rl<1.”{@literal}标签也可以只是括住小于号,而不是整个不等式,所产生的文档是一样的,但是在源代码中见到的文档注释的可读性就会更差。这说明了一条通则:文档注释在源代码和产生的文档中都应该是易于阅读的。如果无法让两者都易读,产生的文档的可读性要优先于源代码的可读性。

每个文档注释的第一句话(如下所示)成了该注释所在元素的概要描述(summarydescription)。例如,本条目之前的文档注释中的概要描述为”返回这个列表中指定位置上的元素”。概要描述必须独立地描述目标元素的功能。为了避免混淆,同一个类或者接口中的两个成员或者构造器,不应该具有同样的概要描述。特别要注意重载的情形,在这种情况下,往往很自然地在描述中使用同样的第一句话(但在文档注释中这是不可接受的)。

注意所期待的概要描述中是否包括句点,因为句点会过早地终止这个描述。例如,一个以”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}标签将讨厌的句点以及所有关联的文本都包起来,使得源代码中的句点后面不再是空格:

1
***A college degree, such as B.S.,{@literal M.S.} or Ph.D.*/public class Degree { ...}

说概要描述是文档注释中的第一个句子(sentence),这似乎有点误导人。规范指出,概要描述很少是个完整的句子。对于方法和构造器而言,概要描述应该是个完整的动词短语(包含任何对象),它描述了该方法所执行的动作。例如:

1
2
3
ArrayList(intinitialCapacity):用指定的初始容量构造一个空的列表。

Collection.size():返回该集合中元素的数目。

如这些示例所示,使用第三人称时态(returns the number)比使用第二人称(return thenumber)更加确切。

对于类、接口和域,概要描述应该是一个名词短语,它描述了该类或者接口的实例,或者域本身所代表的事物。例如:

口Instant:时间轴上的一个瞬时点。

Math.PI:非常接近于 PI(圆周长度与直径的比值)的 double 值。

Java 9在 Javadoc 生成的 HTML中添加了客户端索引l。这个索引I简化了在大型API文档集中进行搜索的任务,它采用了页面右上角的搜索框的形式。当你在搜索框中输入时,会出现一个下拉菜单,上面显示出相匹配的页面。像类、方法和域这类 API元素,会被自动索引。有时候,会想要索引一些对于 API 比较重要的其他条件。为此,增加了{@index}标签。如果要索引文档注释中出现的某一个条件,只需将它包在这个标签中即可,如下面这个代码片段所示:

1
*Thismethod complies withthe{@index IEEE 754} standard.

需要特别小心文档注释中的泛型、枚举和注解。当为泛型或者方法编写文档时,确保要在文档中说明所有的类型参数。

1
*An object that maps keys to values.A map cannot contain* duplicatekeys;each keycan map to at most one value.*(Remainder omitted)*@param <K>the typeof keys maintained by this map*@param <V> the type of mapped values*/public interface Map<k,V> {...}

当为枚举类型编写文档时,要确保在文档中说明常量,以及类型,还有任何公有的方法。注意,如果文档注释很简短,可以将整个注释放在一行上:

1
*** 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;

为注解类型编写文档时,要确保在文档中说明所有成员,以及类型本身。带有名词短语的文档成员,就当成域一样对待。对于该类型的概要描述,要使用一个动词短语,说明当程序元素具有这种类型的注解时它表示什么意思:

1
***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 {/***Theexception thatthe annotatedtest methodmust throw* in order to pass. (The test is permitted to throw any*subtype ofthe type described bythisclass object.)*Class<? extends Throwable> value();

包级私有的文档注释应该放在一个称作 package-info.java 的文件中。除了这些注释之外,package-info.java 中还必须包含包声明,还可以包含这个声明中的注解。同样地,如果选择使用模块系统(详见第 15条),应该将模块级的注释放在 module-info。java 文件中。

API有两个特征在文档中经常被忽视,即线程安全性和可序列化性。类或者静态方法是否线程安全,应该在文档中对它的线程安全级别进行说明,如第82条所述。如果类是可序列化的,就应该在文档中说明它的序列化形式,如第87条所述。

Javadoc 具有”继承”方法注释的能力。如果一个API元素没有文档注释,Javadoc将会搜索最为适用的文档注释,接口的文档注释将优先于超类的文档注释。搜索算法的细节可以在《TheJavadoc Reference Guide》[ Javadoc-ref ]中找到。也可以利用{@inheritDoc}标签从超类型中继承文档注释的部分内容。这意味着类还可以重用它所实现的接口的文档注释,而不需要拷贝这些注释。这项机制有可能减轻维护多个几乎相同的文档注释的负担,但使用它需要一些小技巧,并具有一些局限性。关于这一点的详情超出了本书的范围,在此不做讨论。

关于文档注释有一点需要特别注意。虽然为所有导出的API元素提供文档注释是必要的,但是这样做并非一劳永逸。对于由多个相互关联的类组成的复杂API,通常有必要用一个外部文档来描述该API的总体结构,对文档注释进行补充。如果有这样的文档,相关的类或者包文档注释就应该包含一个对这个外部文档的链接。

Javadoc 遵循本条目提出的许多建议进行自动检测。在Java 7中,需要用命令行参这样的IDE插件,会进一步根据这些建议完成检测[ Burn01]。通过运行一个 HTML 有效性检查器(HTML validity checker)来检测由 Javadoc 产生的 HTML 文件,也可以降低文档注释中出错的可能性。这样可以检测出 HTML标签的许多不正确用法,以及应该被转义的HTML元字符。Internet上有几个这类检查器可供下载,并且也可以利用W3C MarkupValidationService[W3C-validator]来进行在线检验HTML。在验证产生的HTML时,要记住,从Java 9开始,Javadoc 都可以生成HTML5,以及HTML 4.01,虽然它默认是生成HTML4.01。如果要用Javadoc生成HTML5,可以使用命令行参数-htm15。

本条目中所述的内容涵盖了基本的惯例。虽然到目前为止,已经过去了15年,编写文档注解最权威的指导仍然是《How toWriteDocComments》Javadoc-guide」。

如果遵循本条目中的指导,生成的文档应该能够清晰地描述你的API。但唯一确定了解的方式,就是去阅读由 Javadoc 工具生成的网页。每一个将被其他人使用的 API都值得你这么做。正如测试程序几乎无疑会导致对代码做出修改一样,阅读文档一般至少也会导致对文档注释进行些许的修改。

简而言之,要为API编写文档,文档注释是最好、最有效的途径。对于所有可导出的API元素来说,使用文档注释应该被看作是强制性的要求。要采用一致的风格来遵循标准的约定。记住,在文档注释内部出现任何 HTML标签都是允许的,但是HTML 元字符必须要经过转义。

第九章 通用编程

本章主要讨论Java 语言的细枝末节,包含局部变量的处理、控制结构、类库的用法、各种数据类型的用法,以及两种不是由语言本身提供的机制(反射机制和本地方法)的用法。最后讨论了优化和命名惯例。

第57条:将局部变量的作用域最小化

本条目与第15 条本质上是类似的。将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。

较早的编程语言(如C语言)要求局部变量必须在代码块的开头进行声明,出于习惯,有些程序员目前还是继续这样做。这个习惯应该改正。在此提醒,Java 允许你在任何可以出现语句的地方声明变量。

要使局部变量的作用域最小化,最有力的方法就是在第一次要使用它的地方进行声明。如果变量在使用之前进行声明,这只会造成混乱一对于试图理解程序功能的读者来说,这又多了一种只会分散他们注意力的因素。等要用到该变量时,读者可能已经记不起该变量的类型或者初始值了。

过早地声明局部变量不仅会使它的作用域过早地扩展,而且结束得过晚。局部变量的作用域从它被声明的点开始扩展,一直到外围块的结束处。如果变量是在”使用它的块”之外被声明的,当程序退出该块之后,该变量仍是可见的。如果变量在它的目标使用区域之前或者之后被意外地使用,后果将可能是灾难性的。

几乎每一个局部变量的声明都应该包含一个初始化表达式。如果你还没有足够的信息来对一个变量进行有意义的初始化,就应该推迟这个声明,直到可以初始化为止。这条规则有个例外的情况与trY-Catch 语句有关。如果一个变量被一个方法初始化,而这个方法可能会抛出一个受检异常,该变量就必须在trY块的内部被初始化。如果变量的值必须在try块的外部用到,它就必须在try块之前被声明,但是在try块之前,它还不能被”有意义地初始化”。请参照第65条中的例子。

循环中提供了特殊的机会来将变量的作用域最小化。无论是传统的for循环,还是for-each 形式的 for 循环,都允许声明循环变量(loop variable),它们的作用域被限定在正好需要的范围之内。(这个范围包括循环体,以及循环体之前的初始化、测试、更新部分。)因此,如果在循环终止之后不再需要循环变量的内容,for循环就优先于while 循环。

例如,下面是一种遍历集合的首选做法(详见第58条):

1
//Preferred idiomforiteratingovera collectionor arrayfor(Element e:c){...//DoSomething withe子

如果需要访问迭代器,可能要调用它的 remove 方法,首选做法是利用传统的 for 循环代替 for-each循环:

1
//Idiom for iterating whenyou need the iteratorfor(Iterator<Element> i= c.iteratorO;i.hasNextO;){Element e = i.next();.:// Do something with e and i了

为了弄清楚为什么这些for 循环比 while 循环更好,请参考下面的代码片段,它包含两个 while 循环,以及一个Bug:

1
Iterator<Element>i=c.iterator(;while (i.hasNextO){doSomething(i.next();了Iterator<Element> i2 = c2.iterator(;while (i.hasNextO) { //BUG!doSomethingElse(i2.next());了

第二个循环中包含一个”剪切-粘贴”错误:本来是要初始化一个新的循环变量i2,却使用了旧的循环变量i,遗憾的是,这时 i仍然还在有效范围之内。结果代码仍然可以通过编译,运行的时候也不会抛出异常,但是它所做的事情却是错误的。第二个循环并没有在c2 上迭代,而是立即终止,造成c2 为空的假象。因为这个程序的错误是悄然发生的,所以可能在很长时间内都不会被发现。

如果类似的”剪切-粘贴”错误发生在前面任何一种for循环中,结果代码根本就不能通过编译。在第二个循环开始之前,第一个循环的元素(或者迭代器)变量已经不在它的作用域范围之内了。下面就是一个传统for 循环的例子:

1
for (Iterator<Element> i = c.iteratorO; i.hasNext();){Element e = i.nextO;..// Do something with e and i// Compile-time error - cannot find symbol ifor (Iterator<Element> i2 = c2.iterator(O;i.hasNext();){Element e2 = i2.next();...// Do something with e2 and i2

如果使用for循环,犯这种”剪切-粘贴”错误的可能性就会大大降低,因为通常没有必要在两个循环中使用不同的变量名。循环是完全独立的,所以重用元素(或者迭代器)变量的名称不会有任何危害。实际上,这也是很流行的做法。

使用 for循环与使用 while循环相比还有另外一个优势:更简短,从而增强了可读性。下面是另外一种对局部变量的作用域进行最小化的循环做法:

1
for (int i = 0,n = expensiveComputation();i< n;i++){..// Do something with i;子

关于这种做法要关注的重点是,它具有两个循环变量i和n,二者具有完全相同的作用域。第二个变量n被用来保存第一个变量的极限值,从而避免在每次迭代中执行冗余计算。通常,如果循环测试中涉及方法调用,并且可以保证在每次迭代中都会返回同样的结果,就应该使用这种做法。

最后一种”将局部变量的作用域最小化”的方法是使方法小而集中。如果把两个操作(activity)合并到同一个方法中,与其中一个操作相关的局部变量就有可能会出现在执行另一个操作的代码范围之内。为了防止这种情况发生,只需将这个方法分成两个:每个操作用一个方法来完成。

第58条:for-each循环优先于传统的for循环

如第 45条所述,有些任务最好结合Stream 来完成,有些最好结合迭代完成。下面是用一个传统的 for 循环遍历集合的例子:

1
// Not the best way to iterate over a collection!for (Iterator<Element> i = c.iterator(; i.hasNext(; ){Element e = i.next();...//Dosomethingwithe了

用传统的for 循环遍历数组的做法如下:

1
//Notthebestwaytoiterateoveranarray!for (int i = 0;i < a.length;i++){..// Do something with a[i]

这些做法都比 while 循环(详见第 57条)更好,但是它们并不完美。迭代器和索引l变量都会造成一些混乱一而你需要的只是元素而已。而且,它们也代表着出错的可能。迭代器在每个循环中出现三次,索引变量在每个循环中出现四次,其中有两次让你很容易出错。一旦出错,就无法保证编译器能够发现错误。最后一点是,这两个循环是截然不同的,容器的类型转移了不必要的注意力,并且为修改该类型增加了一些困难。

for-each循环(官方称之为”增强的for语句”)解决了所有问题。通过完全隐藏迭代器或者索引变量,避免了混乱和出错的可能。这种模式同样适用于集合和数组,同时简化了将容器的实现类型从一种转换到另一种的过程:

1
//The preferredidiomfor iterating over collections and arraysfor (Element e:elements){...//Dosomething withe了

当见到冒号(:)时,可以把它读作”在·里面”。因此上面的循环可以读作”对于元素elements 中的每一个元素é”。注意,利用for-each循环不会有性能损失,甚至用于数组也一样:它们产生的代码本质上与手工编写的一样。

对于嵌套式迭代,for-each 循环相对于传统 for 循环的优势还会更加明显。下面就是人们在试图对两个集合进行嵌套迭代时经常会犯的错误:

1
//Can you spot thebug?enum Suit { CLUB,DIAMOND,HEART,SPADE }enum Rank { ACE,DEUCE,THREE,FOUR,FIVE,SIX,SEVEN,EIGHT,NINE, TEN, JACK, QUEEN, KING }static Collection<Suit> suits = Arrays.asList(Suit.values());static Collection<Rank> ranks = Arrays.asList(Rank.values();List<Card> deck = new ArrayList<>();for (Iterator<Suit> i = suits.iteratorO; i.hasNextO;)for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )deck.add(new Card(i.next(), j.next()));

如果之前没有发现这个Bug也不必难过。许多专家级的程序员偶尔也会犯这样的错误。问题在于,在迭代器上对外部的集合(suits)调用了太多次 next方法。它应该从外部的循环进行调用,以便每种花色调用一次,但它却是从内部循环调用,因此每张牌调用一次。在用完所有花色之后,循环就会抛出 NoSuchElementException异常。

如果真的那么不幸,并且外部集合的大小是内部集合大小的几倍(可能因为它们是相同的集合),循环就会正常终止,但是不会完成你想要的工作。例如,下面就是一个考虑不周的尝试,想要打印一对骰子的所有可能的滚法:

1
2
3
4
5
6
// Same bug, different symptom!enum FaCe { ONE,TWO,THREE,FOUR,FIVE,SIX }Collection<Face> faces = EnumSet.allof(Face.class);for (Iterator<Face> i = faces.iterator(); i.hasNext(;)

# for (Iterator<Face> j = faces.iterator();j.hasNext();)

(S XS,,NN,)重9‘而不是预计的那 36 种组合。

为了修正这些示例中的Bug,必须在外部循环的作用域中添加一个变量来保存外部元素:

1
// Fixed, but ugly - you can do better!for (Iterator<Suit> i= suits.iterator(; i.hasNextO;) {Suit suit = i.next();for (Iterator<Rank> j = ranks.iterator(; j.hasNext();)deck.add(new Card(suit, j.next());

如果使用的是嵌套式for-each 循环,这个问题就会完全消失。产生的代码将如你所希望的那样简洁:

1
//Preferred idiom for nested iteration on collections and arraysfor (Suit suit : suits)for (Rank rank :ranks)deck.add(new Card(suit, rank));

遗憾的是,有三种常见的情况无法使用for-each 循环:

口解构过滤一如果需要遍历集合,并删除选定的元素,就需要使用显式的迭代器,以便可以调用它的 remove 方法。使用Java 8中增加的Collection 的 removeIf方法,常常可以避免显式的遍历。

口转换—如果需要遍历列表或者数组,并取代它的部分或者全部元素值,就需要列表迭代器或者数组索引,以便设定元素的值。

口平行迭代一如果需要并行地遍历多个集合,就需要显式地控制迭代器或者索引变量,以便所有迭代器或者索引变量都可以同步前进(就如上述有问题的牌和骰子的示例中无意间所示范的那样)。

如果你发现自己处于以上任何一种情况之下,就要使用普通的for循环,并且要警惕本条目中提到的陷阱。

for-each 循环不仅能遍历集合和数组,还能遍历实现 Iterable接口的任何对象,该接口中只包含单个方法,具体如下:

1
public interface Iterable<E>{//Returnsan iterator over the elements in this iterableIterator<E> iterator();

如果不得不从头开始编写自己的工terator 实现,其中还是有些技巧的,但是如果编写的是表示一组元素的类型,则应该坚决考虑让它实现 Iterable 接口,甚至可以选择让它不要实现Collection 接口。这样,你的用户就可以利用 for-each 循环遍历类型,他们会永远心怀感激的。

总而言之,与传统的 for 循环相比,for-each 循环在简洁性、灵活性以及出错预防性方面都占有绝对优势,并且没有性能惩罚的问题。因此,当可以选择的时候,for-each循环应该优先于 for 循环。

第59条:了解和使用类库

假设你希望产生位于0和某个上界之间的随机整数。面对这个常见的任务,许多程序员会编写出如下所示的方法:

1
//Commonbutdeeplyflawed!static Random rnd = new RandomO;static int random(int n) {return Math.abs(rnd.nextInt()) % n;}

这个方法看起来可能不错,但是却有三个缺点。第一个缺点是,如果n 是一个比较小的2的乘方,经过一段相当短的周期之后,它产生的随机数序列将会重复。第二个缺点是,如果 n 不是2的乘方,那么平均起来,有些数会比其他的数出现得更为频繁。如果 n 比较大,这个缺点就会非常明显。这可以通过下面的程序直观地体现出来,它会产生100万个经过精心指定的范围内的随机数,并打印出有多少个数字落在随机数取值范围的前半部分:

1
public static void main(String[] args) {int n=2*(Integer.MAX_VALUE/ 3);int 1ow=0;for(int i=0;i<1000000;i++)if (random(n) <n/2)low++;System.out.println(low);

如果 random方法工作正常,这个程序打印出来的数将接近于100万的一半,但是如果真正运行这个程序,就会发现它打印出来的数接近于 666666。由 random 方法产生的数字有三分之二落在随机数取值范围的前半部分。

random 方法的第三个缺点是,在极少数情况下,它的失败是灾难性的,因为会返回一个落在指定范围之外的数。之所以如此,是因为这个方法试图通过调用Math.abs,将rnd.nextInt()返回的值映射为一个非负整数int。如果 nextInt()返回Integer.MIN_VALUE,那么Math.abs也会返回Integer.MIN_VALUE,假设n不是2的乘方,那么取模操作符(%)将返回一个负数。这几乎肯定会使程序失败,而且这种失败很难重现。

为了编写能修正这三个缺点的 random方法,有必要了解关于同余伪随机数生成器、数论和2的求补算法的相关知识。幸运的是,你并不需要自己来做这些工作一已经有现成的成果可以为你所用。这一成果被称作Random.nextInt(int)。你无须关心nextInt(int)的实现细节(如果你有强烈的好奇心,可以研究它的文档或者源代码)。具有算法背景的高级工程师已经花了大量的时间来设计、实现和测试这个方法,然后经过这个领域中的专家的审查,以确保它的正确性。之后,标准类库经过了Beta测试并正式发行,几年之间已经有成千上万的程序员在使用它。在这个方法中还没有发现过缺陷,但是,如果将来发现有缺陷,在下一个发行版本中就会修正这些缺陷。通过使用标准类库,可以充分利用这些编写标准类库的专家的知识,以及在你之前的其他人的使用经验。

从Java 7开始,就不应该再使用Random了。现在选择随机数生成器时,大多使用ThreadLocalRandom。它会产生更高质量的随机数,并且速度非常快。在我的机器上,比 Random 快了 3.6 倍。对于 Fork Join Pool 和并行 Stream,则使用 SplittableRandom。

使用标准类库的第二个好处是,不必浪费时间为那些与工作不太相关的问题提供特别的解决方案。就像大多数程序员一样,应该把时间花在应用程序上,而不是底层的细节上。

使用标准类库的第三个好处是,它们的性能往往会随着时间的推移而不断提高,无须你做任何努力。因为许多人在使用它们,并且是当作工业标准在使用,所以提供这些标准类库的组织有强烈的动机要使它们运行得更快。这些年来,许多Java平台类库已经被重新编写了,有时候是重复编写,从而在性能上有了显著的提高。

使用标准类库的第四个好处是,它们会随着时间的推移而增加新的功能。如果类库中漏掉了某些功能,开发者社区就会把这些缺点公示出来,漏掉的功能就会添加到后续的发行版本中。

使用标准类库的最后一个好处是,可以使自己的代码融人主流。这样的代码更易读、更易维护、更易被大多数的开发人员重用。

既然有那么多的优点,使用标准类库机制而不选择专门的实现,这显然是符合逻辑的,然而还是有相当一部分的程序员没有这样做。为什么呢?可能他们并不知道有这些类库机制的存在。在每个重要的发行版本中,都会有许多新的特性被加入到类库中,所以与这些新特性保持同步是值得的。每当Java平台有重要的发行时,都会发布一个网页来说明新的特性。这些网页值得好好读一读「Java8-feat,Java9-feat]。举个例子,假设想要编写一个程序,用它打印出命令行中指定的一条URL 的内容(Linux 中 curl 命令的作用大体如此)。在 Java 9之前,这些代码有点烦琐,但是Java 9在 InputStream 中增加了transferTo方法。下面就是利用这个新方法完成这项任务的完整程序:

1
//Printingthecontentsofa URLwith transferTo,addedinJava9public 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 及其子包中的内容。关于其他类库的知识可以根据需要随时学习。总结类库中的机制超出了本条目的范围,几年来它们已经发展得十分庞大了。

其中有几个类库值得一提。CollectionsFramework(集合框架)和Stream类库(详见第45条至第 48 条)应该成为每一位程序员基本工具箱中的一部分,同样也应该成为java.util.concurrent 中并发机制的组成部分。这个包既包含高级的并发工具来简化多线程的编程任务,还包含低级别的并发基本类型,允许专家们自己编写更高级的并发抽象。关于java.util.concurrent 的高级部分,请参阅第 80条和第 81 条。

在某些情况下,一个类库工具并不能满足你的需要。你的需求越是特殊,这种情形就越有可能发生。虽然你的第一个念头应该是使用标准类库,但是,如果你在观察了它们在某些领域所提供的功能之后,确定它不能满足需要,你就得使用其他的实现。任何一组类库所提供的功能总是难免会有遗漏。如果你在Java类库中找不到所需要的功能,下一个选择应该是在高级的第三方类库中去寻找,比如Google 优秀的开源Guava 类库[Guava]。如果在所有相应的类库中都无法找到你所需的功能,就只能自己实现这些功能了。

总而言之,不要重复发明轮子。如果你要做的事情看起来是十分常见的,有可能类库中已经有某个类完成了这样的工作。如果确实是这样,就使用现成的;如果还不清楚是否存在这样的类,就去查一查。一般而言,类库的代码可能比你自己编写的代码更好一些,并且会随着时间的推移而不断改进。这并不是在质疑你作为一个程序员的能力。从经济角度的分析表明:类库代码受到的关注远远超过大多数普通程序员在同样的功能上所能给予的投人。

第60条:如果需要精确的答案,请避免使用float和double

运算(binary floating-point arithmetic),这是为了在广泛的数值范围上提供较为精确的快速近似计算而精心设计的。然而,它们并没有提供完全精确的结果,所以不应该被用于需要精确结果的场合。float 和 double 类型尤其不适合用于货币计算,因为要让一个float 或

例如,假设你的口袋中有 $1.03,花掉了42之后还剩下多少钱呢?下面这个很简单的程序片段试图回答这个问题:

System.out.print1n(1.03 - 0.42);

遗憾的是,它输出的结果是0.6100000000000001。这并不是个别的例子。假设你的口袋里有$1,你买了9个垫圈,每个为10。那么应该找回多少零头呢?

System.out.println(1.00 - 9 * 0.10);

根据上述程序片段,你得到的是$0.09999999999999998。

你可能会认为,只要在打印之前将结果做一下舍人就可以解决这个问题,但遗憾的是,这种做法并不总是可行的。例如,假设你的口袋里有$1,你看到货架上有一排美味的糖果,标价分别为 10、20、30,等等,一直到$1。你打算从标价为10c的糖果开始,每种买1颗,一直到不能支付货架上下一种价格的糖果为止,那么你可以买多少颗糖果?还会找回多少零头?下面是一个简单的程序,用来解决这个问题:

1
//Broken-usesfloatingpointformonetarycalculation!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);

如果真正运行这个程序,你会发现可以支付3颗糖果,并且还剩下$0.3999999999999999。这个答案是不正确的!解决这个问题的正确方法是使用 BigDecimal、int 或者 1ong 进行货币计算。

下面的程序是上一个程序的简单翻版,它使用BigDecimal类型代替double。注意,它使用了 BigDecimal 的 String 构造器,而不是用 double 构造器。为了避免将不正确的值引人到计算中,这是必需的[Bloch05,Puzzle 2]:

1
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);

如果运行这个修改过的程序,会发现可以支付4颗糖果,还剩下 $0.00。这才是正确的答案。

然而,使用BigDecimal有两个缺点:与使用基本运算类型相比,这样做很不方便,而且速度很慢。对于解决这样一个简单的问题,后一种缺点并不要紧,但是前一种缺点可能会让你很不舒服。

除了使用 BigDecimal 之外,还有一种办法是使用 int 或者long,到底选用 int 还是long 要取决于所涉及数值的大小,同时要自己处理十进制小数点。在这个示例中,最明显的做法是以分为单位进行计算,而不是以元为单位。下面是这个例子的简单翻版,展示了这种做法:

1
2
3
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 还有一些额外的好处,它允许你完全控制舍人,每当一个操作涉及舍人的时候,你都可以从8种舍人模式中选择其一。如果你正通过合法强制的舍入行为进行商务计算,使用 BigDecimal 是非常方便的。如果性能非常关键,并且你又不介意自己处理十进制小数点,而且所涉及的数值又不太大,就可以使用 int 或者 1ong。如果数值范围没有超过9位十进制数字,就可以使用 int;如果不超过18 位数字,就可以使用 long。如果数值可能超过18位数字,就必须使用 BigDecimal。

第61条:基本类型优先于装箱基本类型

Java 有一个类型系统由两部分组成,它包含基本类型(primitive),如 int、double 和boolean,以及引用类型(reference type),如 String 和List。每个基本类型都有一个对应的引l用类型,称作装箱基本类型(boxed primitive)。装箱基本类型中对应于int、double和boolean 的分别是 Integer、Double 和 Boolean。

如第6条中提到的,自动装箱(autoboxing)和自动拆箱(auto-unboxing)模糊了但并没有完全抹去基本类型和装箱基本类型之间的区别。这两种类型之间真正是有差别的,要很清楚在使用的是哪种类型,并且要对这两种类型进行谨慎的选择,这些都非常重要。

在基本类型和装箱基本类型之间有三个主要区别。第一,基本类型只有值,而装箱基了它对应基本类型的所有函数值之外,还有个 null。最后一点区别是,基本类型通常比装箱基本类型更节省时间和空间。如果不小心,这三点区别都会让你陷人麻烦之中。

以下面这个比较器为例,它被设计用来表示 Integer 值的递增数字顺序。(回想一下,比较器的compare 方法返回的数值到底为负数、零还是正数,要取决于它的第一个参数是小于、等于还是大于它的第二个参数。)在实践中并不需要你编写这个在 Integer 中实现自然顺序的比较器,因为这是不需要比较器就可以得到的,但它展示了一个有趣的例子:

这个比较器表面看起来似乎不错,它可以通过许多测试。例如,它可以通过Collections.sort正确地给一个有100万个元素的列表进行排序,无论这个列表中是否包含重复的元素。但是这个比较器有着严重的缺陷。如果你要让自己信服,只要打印 naturalOrder.Compare(newInteger(42),newInteger(42))的值便可以分晓。这两个Integer实例都表示相同的值(42),因此这个表达式的值应该为0,但它输出的却是1,这表明第一个Integer 值大于第二个。

1
问题出在哪呢?naturalOrder 中的第一个测试工作得很好。对表达式i<j执行计算会导致被i和j引l用的Integer 实例被自动拆箱(auto-unboxed);也就是说,它提取了它们的基本类型值。计算动作要检查产生的第一个int值是否小于第二个。但是假设答案是否定的。下一个测试就是执行计算表达式i==j,它在两个对象引用上执行同一性比较(identitycomparison)。如果i和j引用表示同一个int值的不同的Integer实例,这个比较操作就会返回 false,比较器会错误地返回 1,表示第一个Integer 值大于第二个。对装箱基本类型运用==操作符几乎总是错误的。

事实上,如果需要用比较器描述一个类型的自然顺序,只要调用Comparator.natural-Order()即可,如果自已编写比较器,则应该使用比较器构造方法,或者在基本类型上使用静态比较方法(详见第14条)。也就是说,修正这个问题的做法是添加两个局部变量,来保存对应于装箱 Integer 参数的基本类型 int 值,并在这些变量上执行所有的比较操作。这样可以避免大量的同一性比较:

1
Comparator<Integer> natural0rder = (iBoxed, jBoxed) )->int i = iBoxed, j = jBoxed; // Auto-unboxingreturn i< j?-1:(i == j? 0:1);接下来,看看下面这个小程序:public class Unbelievable {static Integer i;public static void main(String[] args) {if (i == 42)System.out.println("Unbelievable") ;

它不会打印出 Unbelievable—但是它的行为也是很奇怪的。它在计算表达式(i == 42)的时候抛出 NullPointerException 异常。问题在于,i是个Integer,而不是 int,就像所有的对象引l用域一样,它的初始值为 null。当程序计算表达式(i== 42)时,它会将 Integer 与 int 进行比较。几乎在任何一种情况下,当在一项操作中混合使用基本类型和装箱基本类型时,装箱基本类型就会自动拆箱,这种情况无一例外。如果null对象引用被自动拆箱,就会抛出一个NullPointerException 异常。就如这个程序所示,它几乎可以在任何位置发生。修正这个问题很简单,声明i是个 int 而不是Integer 即可。

最后,以第6条中的这个程序为例:

1
//Hideously slowprogram!Canyou spot the object creation?public staticvoid main(String[] args){Long sum = OL;for(long i= O;i<Integer.MAX_VALUE;i++){:=+unsSystem.out.println(sum) ;了

这个程序运行起来比预计的要慢一些,因为它不小心将一个局部变量(sum)声明为是装箱基本类型Long,而不是基本类型 1ong。程序编译起来没有错误或者警告,变量被反复地装箱和拆箱,导致明显的性能下降。

在本条目中所讨论的这三个程序中,问题是一样的:程序员忽略了基本类型和装箱基本类型之间的区别,并尝到了苦头。在前两个程序中,其结果是彻底的失败;在第三个程序中,则有严重的性能问题。

那么什么时候应该使用装箱基本类型呢?它们有几个合理的用处。第一个是作为集合中的元素、键和值。你不能将基本类型放在集合中,因此必须使用装箱基本类型。这是一种更通用的特例。在参数化类型和方法(详见第5章)中,必须使用装箱基本类型作为类型参数,因为 Java 不允许使用基本类型。例如,你不能将变量声明为 ThreadLocal类型,因此必须使用 ThreadLocal代替。最后,在进行反射的方法调用(详见第65条)时,必须使用装箱基本类型。

总而言之,当可以选择的时候,基本类型要优先于装箱基本类型。基本类型更加简单,也更加快速。如果必须使用装箱基本类型,要特别小心!自动装箱减少了使用装箱基本类型的烦琐性,但是并没有减少它的风险。当程序用==操作符比较两个装箱基本类型时,它做了个同一性比较,这几乎肯定不是你所希望的。当程序进行涉及装箱和拆箱基本类型的混合类型计算时,它会进行拆箱,当程序进行拆箱时,会抛出 NullPointerException 异常。最后,当程序装箱了基本类型值时,会导致较高的资源消耗和不必要的对象创建。

第62条:如果其他类型更适合,则尽量避免使用字符串

字符串被用来表示文本,它在这方面也确实做得很好。因为字符串很通用,并且Java语言也支持得很好,所以自然就会有这样一种倾向:即使在不适合使用字符串的场合,人们往往也会使用字符串。本条目就是讨论一些不应该使用字符串的情形。

字符串不适合代替其他的值类型。当一段数据从文件、网络,或者键盘设备,进人程序之后,它通常以字符串的形式存在。有一种自然的倾向是让它继续保留这种形式,但是,只有当这段数据本质上确实是文本信息时,这种想法才是合理的。如果它是数值,就应该被转换为适当的数值类型,比如 int、float 或者 BigInteger 类型。如果它是个”是-或-否”这种问题的答案,就应该被转换为 boolean 类型。如果存在适当的值类型,不管是基本类型,还是对象引用,大多应该使用这种类型;如果不存在这样的类型,就应该编写一个类型。虽然这条建议是显而易见的,但通常未能得到遵守。

字符串不适合代替枚举类型。正如第34条中所讨论的,枚举类型比字符串更加适合用来表示枚举类型的常量。

字符串不适合代替聚合类型。如果一个实体有多个组件,用一个字符串来表示这个实体通常是很不恰当的。例如,下面这行代码来自于真实的系统一标识符的名称已经被修改了,以免发生纠纷:

1
# //Inappropriate use of string as aggregate typeString compoundKey = className + "#" + i.next(;

这种方法有许多缺点。如果用来分隔域的字符也出现在某个域中,结果就会出现混乱。为了访问单独的域,必须解析该字符串,这个过程非常慢,也很烦琐,还容易出错。你无法提供equals、toString 或者compareTo 方法,只好被迫接受 String 提供的行为。更好的做法是,简单地编写一个类来描述这个数据集,通常是一个私有的静态成员类(详见第24条)。

字符串也不适合代替能力表(capabilities)。有时候,字符串被用于对某种功能进行授权访问。例如,考虑设计一个提供线程局部变量(thread-local variable)的机制。这个机制提供的变量在每个线程中都有自己的值。自Java 1.2 发行版本以来,Java类库就有提供线程局部变量的机制,但在那之前,程序员必须自己完成。几年前,面对这样的设计任务时,有些人提出了同样的设计方案:利用客户提供的字符串键对每个线程局部变量的内容进行访问授权:

1
// Broken -inappropriate use of string as capability!public class ThreadLocal {private ThreadLocal( { } // Noninstantiable// Sets the current thread's value forthenamed variable.public static void set(String key, Object value);// Returns the current thread'svaluefor the named variable.public static Object get(String key);

这种方法的问题在于,这些字符串键代表了一个共享的全局命名空间。要使这种方法可行,客户端提供的字符串键必须是唯一的:如果两个客户端各自决定为它们的线程局部变量使用同样的名称,它们实际上就无意中共享了这个变量,这样往往会导致两个客户端都失败,而且安全性也很差。恶意的客户端可能有意地使用与另一个客户端相同的键,以便非法地访问其他客户端的数据。

要修正这个API并不难,只要用一个不可伪造的键(有时被称为能力)来代替字符串即可:

1
2
3
public class ThreadLocal{private ThreadLocal() {} // Noninstantiablepublic static class Key {// (Capability)Key){}

// Generates a unique,unforgeablekeypublic static Key getKeyO) {return new KeyO;子public static void set(Key key,Object value);public static Object get(Key key);

这样虽然解决了基于字符串的API的两个问题,但是你还可以做得更好。你实际上不再需要静态方法,它们可以被代之以键(Key)中的实例方法,这样这个键就不再是键,而是线程局部变量了。此时,这个不可被实例化的顶层类也不再做任何实质性的工作,因此可以删除这个顶层类,并将嵌套类命名为 ThreadLocal:

1
public finalclass ThreadLocal {public ThreadLocal(;public void set(Object value);public Object get();

这个API不是类型安全的,因为当你从线程局部变量得到它时,必须将值从Object转换成它实际的值。不可能使原始的基于 String 的 API 为类型安全的,要使基于 Key 的API为类型安全的也很困难,但是通过将 ThreadLoca1 类泛型化(详见第 29 条),使这个API变成类型安全的就是很简单的事情了:

1
public finalclass ThreadLocal<T> {public ThreadLocalO;public void set(T value);public T getO;

粗略地讲,这正是java.lang.ThreadLocal提供的 API。除了解决了基于字符串的API的问题之外,与前面的两个基于键的 API相比,它还更快速、更美观。

总而言之,如果可以使用更加合适的数据类型,或者可以编写更加适当的数据类型,就应该避免用字符串来表示对象。若使用不当,字符串会比其他的类型更加笨拙、更不灵活、速度更慢,也更容易出错。经常被错误地用字符串来代替的类型包括基本类型、枚举类型和聚合类型。

第63条:了解字符串连接的性能

字符串连接操作符(+)是把多个字符串合并为一个字符串的便利途径。要想产生单独一行的输出,或者构造一个字符串来表示一个较小的、大小固定的对象,使用连接操作符是非常合适的,但是它不适合运用在大规模的场景中。为连接n个字符串而重复地使用字符串连接操作符,需要n的平方级的时间。这是由于字符串不可变(详见第17条)而导致的不幸结果。当两个字符串被连接在一起时,它们的内容都要被拷贝。

例如,下面的方法通过重复地为每个项目连接一行,构造出一个代表该账单声明的字符串:

1
// Inappropriate use of string concatenation -Performs poorly!public String statementO{String result ="";for(inti=O;i<numItems(O;i++)result += lineForItem(i);// String concatenationreturn result;

如果项目的数量巨大,这个方法的执行时间就难以估算。为了获得可以接受的性能,请用StringBuilder代替String,来存储构造过程中的账单声明:

1
public String statementO {StringBuilder b = new StringBuilder(numItems( * LINE_WIDTH);for(int i=0;i< numItemsO;i++)b.append(lineForItem(i));return b.toStringO;

从Java 6以来,已经做了大量的工作使字符串连接变得更加快速,但是上述两种做法的性能差别还是很大:如果 numItems 返回 100,并且 1ineForItem 返回一个固定长度为80个字符的字符串,在我的机器上,第二种做法比第一种做法要快6.5倍。因为第一种做法的开销随项目数量而呈平方级增加,项目的数量越大,性能的差别就会越明显。注意,第二种做法预先分配了一个 StringBuilder,使它大到足以容纳整个结果字符串,因此不需要自动扩展。即使使用了默认大小的 StringBuilder,它也仍然比第一种做法快5.5倍。

原则很简单:不要使用字符串连接操作符来合并多个字符串,除非性能无关紧要。否则,应该使用 StringBuilder 的 append 方法。另一种做法是使用字符数组,或者每次只处理一个字符串,而不是将它们组合起来。

第64条:通过接口引1用对象

第 51条建议:应该使用接口而不是类作为参数类型。更通俗来讲,应该优先使用接口而不是类来引用对象。如果有合适的接口类型存在,那么对于参数、返回值、变量和域来说,就都应该使用接口类型进行声明。只有当你利用构造器创建某个对象的时候,才真正需要引l用这个对象的类。为了更具体地说明这一点,以LinkedHashSet的情形为例,它是Set接口的一个实现。在声明变量的时候应该养成这样的习惯:

而不是像这样的声明:

1
//Bad-usesclassastype!LinkedHashSet<Son> SonSet = new LinkedHashSet<>();

如果养成了用接口作为类型的习惯,程序将会更加灵活。当你决定更换实现时,所要做的就只是改变构造器中类的名称(或者使用一个不同的静态工厂)。例如,第一个声明可以被改变为:

SetsonSet =new HashSet<>()

周围的所有代码都可以继续工作。周围的代码并不知道原来的实现类型,所以它们对于这种变化并不在意。

有一点值得注意:如果原来的实现提供了某种特殊的功能,而这种功能并不是这个接口的通用约定所要求的,并且周围的代码又依赖于这种功能,那么很关键的一点是,新的实现也要提供同样的功能。例如,如果第一个声明周围的代码依赖于LinkedHashSet的同步策略,那么在声明中用 HashSet 代替LinkedHashSet 就是不正确的,因为 HashSet 不能保证相关的选代顺序。

为什么要改变实现类型呢?因为第二个实现提供了比第一个更好的性能,或者因为它提供了你所期待的而原来的实现缺乏的功能。比如,假设有一个域中包含了一个HashMap 实例。如果将它改成EnumMap,则可以提供更好的性能,并且迭代顺序与键的自然顺序一致,但是如果键的类型为枚举类型,你就只能使用EnumMap。如果将HashMap 改成Linkded-HashMap,则能提供可以预估的迭代顺序,以及可以与 HashMap 比拟的性能,对于键类型没有任何特殊的要求。

你可能会觉得,用变量的实现类型来声明变量,也是可以接受,因为可以同时改变声明类型和实现类型,但是不能确保修改后的程序可以编译。如果客户端代码使用了没有出现在新实现中的原始实现类型中的方法,或者客户端代码将该实例传到了需要原始实现类型的方法中,那么代码在完成这样的修改之后将不再进行编译。用接口类型声明变量要”保持诚实”。

如果没有合适的接口存在,完全可以用类而不是接口来引用对象。以值类(valueclass)为例,比如 String 和 BigInteger。记住,值类很少会用多个实现编写。它们经常是final的,并且很少有对应的接口。使用这种值类作为参数、变量、域或者返回类型是再合适不过的了。

不存在适当接口类型的第二种情形是,对象属于一个框架,而框架的基本类型是类,不是接口。如果对象属于这种基于类的框架(class-based framework),就应该用相关的基类(base class)(往往是抽象类)来引l用这个对象,而不是用它的实现类。许多java.io类,比如 OutputStream 就属于这种情形。

不存在适当接口类型的最后一种情形是,类实现了接口但它也提供了接口中不存在的法。如果程序依赖于这些额外的方法,这种类就应该只被用来引用它的实例,永远也不应该被用作参数类型。

上,给定的对象是否具有适当的接口应该是很显然的。如果是,用接口引用对象就会使程序更加灵活。如果没有适合的接口,就用类层次结构中提供了必要功能的最小的具体类来引用对象吧。

第65条:接口优先于反射机制

核心反射机制(core reflection facility),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中的元素是排好序的。相应的代码如下:

1
//Reflectiveinstantiationwithinterface accesspublic static void main(String[] args){//Translatetheclassnameintoa Class objectClass<? extends Set<String>> cl = null;try{c1 =(Class<? extends Set<String>>) // Unchecked cast!Class.forName(args[0]);} catch (ClassNotFoundException e){fatalError("Class not found.");//Get the constructorConstructor<? extends Set<String>> cons = null;try{cons = cl.getDeclaredConstructor();} catch (NoSuchMethodException e) {fatalError("No parameterless constructor");//Instantiate the setSet<String> 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");//Exercisethe sets.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实现。同样地,它也可以变成一个通用的集合性能分析工具。实际上,它所演示的这种方法足以实现一个成熟的服务提供者框架(service provider framework),详见第1条。绝大多数情况下,使用反射机制时需要的也正是这种方法。

这个示例演示了反射机制的两个缺点。第一,这个例子会产生6个运行时异常,如果不使用反射方式的实例化,这6个错误都会成为编译时错误。(为了好玩,你也可以通过传人适当的命令行参数,让程序逐个生成这6个异常。)第二,根据类名生成其实例需要 25行冗长的代码,而调用一个构造器则可以非常简洁地只用一行代码。程序的长度可以通过捕捉ReflectiveOperationException 异常来减少,这是在Java 7中引l人的各种反射异常的一个超类。这两个缺点都局限于实例化对象的那部分代码。一旦对象被实例化,它与其他的Set实例就难以区分了。在实际的程序中,通过这种限定使用反射的方法,绝大部分代码可以不受影响。

如果试着编译这个程序,会得到一条未受检的转换警告。这条警告是合法的,因此转情况下,程序在实例化这个类时就会抛出一个ClassCastException 异常。要了解禁止这种警告的最佳方法,请参见第27条。

类对于在运行时可能不存在的其他类、方法或者域的依赖性,用反射法进行管理是合理的,但是很少使用。如果要编写一个包,并且它运行的时候就必须依赖其他某个包的多个版本,这种做法可能就非常有用。具体做法就是,在支持包所需要的最小环境下对它进行编译,通常是最老的版本,然后以反射方式访问任何更加新的类或者方法。如果企图访问的新类或者新方法在运行时不存在,为了使这种方法有效你还必须采取适当的动作。所谓适当的动作,可能包括使用某种其他可替换的办法来达到同样的目的,或者使用简化的功能进行处理。

总而言之,反射机制是一种功能强大的机制,对于特定的复杂系统编程任务,它是非常必要的,但它也有一些缺点。如果你编写的程序必须要与编译时未知的类一起工作,如有可能,就应该仅仅使用反射机制来实例化对象,而访问对象时则使用编译时已知的某个接口或者超类。

第66条:谨慎地使用本地方法

Java NativeInterface(JNI)允许Java 应用程序调用本地方法(native method),所谓本地方法是指用本地编程语言(比如C或者C++)来编写的方法。它们提供了”访问特定于平台的机制”的能力,比如访问注册表(registry)。它们还提供了访问本地遗留代码库的能力,从而可以访问遗留数据(legacy data)。最后,本地方法可以通过本地语言,编写应用程序中注重性能的部分,以提高系统的性能。

使用本地方法来访问特定于平台的机制是合法的,但是几乎没有必要:因为随着Java平台的不断成熟,它提供了越来越多以前只有在宿主平台上才拥有的特性。例如,Java 9增加的进程API,提供了访问操作系统进程的能力。当Java 中没有相当的类库可用时,使用本地方法来使用遗留代码库也是合法的。

使用本地方法来提高性能的做法不值得提倡。在早期的发行版本中(Java 3 发行版本之前),这样做往往是很有必要的,但是从那以后,JVM实现变得越来越快了。对于大多数任务,现在用Java 就可以获得与之相当的性能。举例来说,当Java 1.1 发行版本中增加了java.math 时,BigInteger 是在一个用C编写的快速多精度运算库的基础上实现的。在 Java 3 发行版本中,BigInteger 则完全用Java 重新实现了,并且进行了精心的性能调优,运行得比原来的本地实现更快。

这个故事有一个悲伤的尾声:从那时起,BigInteger几乎没怎么改变,但在Java8中,大整数却以更快的乘积速度在发展。当时,遗留代码库的工作还在持续快速地发展中,著名的有 GNU 高精度算术运算库(GNU Multiple Precision,GMP)。对于需要真正高性能的高精度算术运算的 Java 程序员,现在通过本地方法来使用 GMP 也是无可厚非的[Blum14]。

使用本地方法有一些严重的缺陷。因为本地语言不是安全的(详见第50条),所以使用本地方法的应用程序也不再能免受内存毁坏错误的影响。因为本地语言是与平台相关的,使用本地方法的应用程序也不再是可自由移植的。使用本地方法的应用程序也更难调试。如果不小心,本地方法还可能降低性能,因为回收垃圾器不是自动的,甚至无法追踪本机内存(native memory)使用情况(详见第8条),而且在进人和退出本地代码时,还需要相关的开销。最后一点,需要”胶合代码”的本地方法编写起来单调乏味,并且难以阅读。

总而言之,在使用本地方法之前务必三思。只有在极少数情况下需要使用本地方法来提高性能。如果你必须要使用本地方法来访问底层的资源,或者遗留代码库,也要尽可能少用本地代码,并且要全面进行测试。本地代码中只要有一个Bug都可能破坏整个应用程序。

第67条:谨慎地进行优化

有三条与优化有关的格言是每个人都应该知道的:

很多计算上的过失都被归咎于效率(没有达到必要的效率),而不是任何其他的原因—甚至包括盲目地做傻事。

William A. Wulf [Wulf72]

不要去计较效率上的一些小小的得失,在97%的情况下,不成熟的优化才是一切问题的根源。

Donald E. Knuth [Knuth74]

在优化方面,我们应该遵守两条规则:

规则 1:不要进行优化。

规则2(仅针对专家):还是不要进行优化一也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。

M.A. Jackson [Jackson75]

所有这些格言都比 Java 程序设计语言的出现早了 20 年。它们讲述了一个关于优化的深刻真理:优化的弊大于利,特别是不成熟的优化。在优化过程中,产生的软件可能既不快速,也不正确,而且还不容易修正。

不要为了性能而牺牲合理的结构。要努力编写好的程序而不是快的程序。如果好的程序不够快,它的结构将使它可以得到优化。好的程序体现了信息隐藏(information hiding)的原则:只要有可能,它们就会把设计决策集中在单个模块中,因此可以改变单个决策,而不会影响到系统的其他部分(详见第15条)。

这并不意味着,在完成程序之前就可以忽略性能问题。实现上的问题可以通过后期的优化而得到修正,但是,遍布全局并且限制性能的结构缺陷几乎是不可能被改正的,除非重新编写系统。在系统完成之后再改变设计的某个基本方面,会破坏系统的结构,从而难以维护和改进。因此,必须在设计过程中考虑到性能问题。

要努力避免那些限制性能的设计决策。当一个系统设计完成之后,其中最难以更改的组件是那些指定了模块之间交互关系以及模块与外界交互关系的组件。在这些设计组件之中,最主要的是API、交互层(wire-level)协议以及永久数据格式。这些设计组件不仅在事后难以甚至不可能改变,而且它们都有可能对系统本该达到的性能产生严重的限制。

要考虑API设计决策的性能后果。使公有的类型成为可变的,这可能会导致大量不必要的保护性拷贝(详见第 50条)。同样地,在适合使用复合模式的公有类中使用继承,会把这个类与它的超类永远地束缚在一起,从而人为地限制了子类的性能(详见第18条)。最后一个例子,在API中使用实现类型而不是接口,会把你束缚在一个具体的实现上,即使将来出现更快的实现你也无法使用(详见第64条)。

API设计对于性能的影响是非常实际的。以 Java.awt.Component类中的getSize方法为例。决定就是,这个注重性能的方法将返回 Dimension 实例,与此密切相关的决定是,Dimension 实例是可变的,迫使这个方法的任何实现都必须为每个调用分配一个新的Dimension 实例。尽管在现代 VM上分配小对象的开销并不大,但是分配数百万个不必要的对象仍然会严重地损害性能。

在这种情况下,有几种可供选择的替换方案。理想情况下,Dimension 应该是不可变的(详见第 17条);另一种方案是,用两个方法来替换 getSize 方法,它们分别返回 Dimension的方法被加人到Component API中。然而,原先的客户端代码仍然可以使用 getSize方法,并且仍然要承受原始API设计决策所带来的性能影响。

幸运的是,一般而言,好的API设计也会带来好的性能。为获得好的性能而对API进行包装,这是一种非常不好的想法。导致你对API进行包装的性能因素可能会在平台未来的发行版本中,或者在将来的底层软件中不复存在,但是被包装的API以及由它引起的问题将永远困扰着你。

一旦精心地设计了程序,并且产生了一个清晰、简明、结构良好的实现,那么就到了该考虑优化的时候了,假定此时你对于程序的性能还不满意。

回想一下Jackson 提出的两条优化原则:”不要优化”以及”(仅针对专家)还是不要优化”。他可以再增加一条:在每次试图做优化之前和之后,要对性能进行测量。你可能会惊讶于自己的发现。试图做的优化通常对于性能并没有明显的影响,有时候甚至会使性能变得更差。主要原因在于,要猜出程序把时间花在哪些地方并不容易。你认为程序慢的地方可能并没有问题,这种情况下实际上是在浪费时间去尝试优化。大多数人认为:程序把90%的时间花在10%的代码上了。

的信息,比如每个方法大致上花费了多少时间、它被调用多少次。除了确定优化的重点之,外,它还可以警告你是否需要改变算法。如果一个平方级(或更差)的算法潜藏在程序中,无论怎么调整和优化都很难解决问题。你必须用更有效的算法来替换原来的算法。系统中的代码越多,使用性能剖析器就显得越发重要。这就好像要在一堆干草中寻找一根针:这堆干草越大,使用金属探测器就越有用。值得特别提及的另一种工具是jmh,它不是一个性能部析器,而是微基准测试框架(microbenchmarking framework),它提供了非并行地可见 Java代码性能详情的能力[JMH]。

在Java平台上对优化的结果进行测量,比在其他的传统平台(如C和C++)上更有必要,因为Java程序设计语言没有很强的性能模型(performance model):各种基本操作的相对开销也没有明确定义。程序员所编写的代码与CPU执行的代码之间存在”语义沟”(semantic gap),而且这条语义沟比传统编译语言中的更大,这使得要想可靠地预测出任何优化的性能结果都非常困难。大量流传的关于性能的说法最终都被证明为半真半假,或者根,本就不正确。

不仅Java 的性能模型未得到很好的定义,而且在不同的JVM实现,不同的发行版本,以及不同的处理器中,也都各不相同。如果将要在多个JVM实现和多种硬件平台上运行程序,很重要的一点是,需要在每个Java 实现上测量优化效果。有时候,还必须在从不同JVM实现或者硬件平台上得到的性能结果之中进行权衡。

自从本条目开始编写以来的近二十年,Java软件堆栈的每一个组件都变得更加复杂,从管理器到虚拟机,再到类库,运行Java 的各种硬件也得到了迅猛的发展。这些因素结合起来导致现在 Java 程序的性能比 2001 年时更难以预测了,因此对测量性能的需求也相应地增加了。

总而言之,不要费力去编写快速的程序一一应该努力编写好的程序,速度自然会随之而来。但在设计系统的时候,特别是在设计API、交互层协议和永久数据格式的时候,一定要考虑性能的因素。当构建完系统之后,要测量它的性能。如果它足够快,你的任务就完成了。如果不够快,则可以在性能剖析器的帮助下,找到问题的根源,然后设法优化系统中相关的部分。第一个步骤是检查所选择的算法:再多的低层优化也无法弥补算法的选择不当。必要时重复这个过程,在每一次修改之后都要测量性能,直到满意为止。

第68条:遵守普遍接受的命名惯例

Java平台建立了一整套很好的命名惯例(naming convention),其中有许多命名惯例包含在了《The Java Language Specification》[JLS,6.1]中。不严格地讲,这些命名惯例分为两大类:字面的(typographical)和语法的(grammatical)。

字面的命名惯例比较少,但也涉及包、类、接口、方法、域和类型变量。应该尽量不违反这些惯例,不到万不得已,千万不要违反。如果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包有着非常丰富的包层次,如javax.util.concurrent.atomic。这样的包通常被称为子包(subpackage),尽管Java 语言并没有提供对包层次的支持。

类和接口的名称,包括枚举和注解类型的名称,都应该包括一个或者多个单词,每个单词的首字母大写,例如List 和 FutureTask。应该尽量避免用缩写,除非是一些首字母缩写和一些通用的缩写,比如 max 和min。对于首字母缩写,到底应该全部大写还是只有首字母大写,没有统一的说法。虽然有些程序员仍然采用全部大写的形式,但还是有人强烈支持只首字母大写:即使连续出现多个首字母缩写的形式,你仍然可以区分出一个单词的起始处和结束处。比如类名 HTTPURL 和 HttpUrl 你更愿意看到哪一个?

方法和域的名称与类和接口的名称一样,都遵守相同的字面惯例,只不过方法或者域的名称的第一个字母应该小写,例如 remove、ensureCapacitY。如果由首字母缩写组成的单词是一个方法或者域名称的第一个单词,它就应该是小写形式。

上述规则的唯一例外是”常量域”,它的名称应该包含一个或者多个大写的单词,中间用下划线符号隔开,例如VALUES 或NEGATIVE_INFINITY。常量域是个静态final域,它的值是不可变的。如果静态 final域有基本类型,或者有不可变的引用类型(详见第17条),它就是个常量域。例如,枚举常量是常量域。如果静态 final域有个可变的引用类型,若被引用的对象是不可变的,它也仍然可以是个常量域。注意,常量域是唯一推荐使用下划线的情形。

局部变量名称的字面命名惯例与成员名称类似,只不过它也允许缩写,单个字符和短字符序列的意义取决于局部变量所在的上下文环境,例如i、denom 和houseNum。输人参数是一种特殊的局部变量。它们的命名应该比普通的局部变量更加小心,因为它们的名称是其方法文档的一个组成部分。

类型参数名称通常由单个字母组成。这个字母通常是以下五种类型之一:T表示任意的类型,E 表示集合的元素类型,K和V 表示映射的键和值类型,X表示异常。函数的返回类型通常是R。任何类型的序列可以是T、U、V 或者 T1、T2、T3。

为了快速查阅,下表列出了字面惯例的例子。

语法命名惯例比字面惯例更加灵活,也更有争议。对于包而言,没有语法命名惯例。可被实例化的类(包括枚举类型)通常用一个名词或者名词短语命名,例如 Thread、命名,如 Collectors 或者Collections。接口的命名与类相似,例如Collection 或Comparator,或者用一个以 able 或 ible 结尾的形容词来命名,例如 Runnable、Iterable或者Accessible。由于注解类型有这么多用处,因此没有单独安排词类。名词、动词、介词和形容词都很常用,例如 BindingAnnotation、Inject、ImplementedBy 或者Singleton.

执行某个动作的方法通常用动词或者动词短语(包括对象)来命名,例如append或drawImage。对于返回boolean 值的方法,其名称往往以单词is开头,很少用has,后面跟名词或名词短语,或者任何具有形容词功能的单词或短语,例如 isDigit、isProbablePrime、isEmpty、isEnabled 或者 hasSiblingS。

如果方法返回被调用对象的一个非boolean 的函数或者属性,它通常用名词、名词短语,或者以动词 get 开头的动词短语来命名,例如 size、hashCode 或者 getTime。有一个组织声称只有第三种形式(以 get开头)才可以接受,但是这种声明没有得到支持。前两种形式往往会产生可读性更好的代码,例如:

以 get 开头的形式主要出现在被废弃的 Java Beans 规范中,它形成了早期的可重用组件架构的基础。有些现代工具继续依赖 Beans 命名惯例,你大可放心地在那些需要结合这些工具一起使用的代码中使用。如果类中包含了用于相同属性的 setter 方法和 getter 方法,也强烈建议采用这种命名形式。在这种情况下,这两种方法应该分别被命名为 getAttribute 和setAttribute.

有些方法的名称值得专门提及。转换对象类型的实例方法,它们返回不同类型的独立对象的方法,经常被称为toType,例如 toString 或者toArraY。返回视图(view,详见第6条,视图的类型不同于接收对象的类型)的方法经常被称为 asType,例如 asList。返回一个与被调用对象同值的基本类型的方法,经常被称为 typeValue,例如 intValue。静态工厂的常用名称包括 from、of、valueOf、instance、getInstance、newInstance、getType和newType(详见第1条)。

域名称的语法惯例没有很好地建立起来,它们也没有类、接口和方法名称那么重要,因为设计良好的 API很少会包含暴露出来的域。boolean 类型的域命名与boolean 类型的访问方法(accessor method)很类似,但是省去了初始的 is,例如 initialized 和 composite。其他类型的域通常用名词或者名词短语来命名,比如 height、digits 或bodyStyle。局部变量的语法惯例类似于域的语法惯例,但是更弱一些。

总而言之,把标准的命名惯例当作一种内在的机制来看待,并且学着用它们作为第二特性。字面惯例是非常直接和明确的;语法惯例则更复杂,也更松散。下面这句话引[自盲目遵从这些命名惯例。”请使用大家公认的做法。

第10章

充分发挥异常的优点,可以提高程序的可读性、可靠性和可维护性。如果使用不当,它们也会带来负面的影响。本章提供了一些关于有效使用异常的指导原则。

第69条:只针对异常的情况才使用异常

某一天,如果你不走运的话,可能会碰到下面这样的代码:

1
//Horrible abuseof exceptions.Don'tever do this!try{int i=0;while(true)range[i++].climb();}catch (ArrayIndexOutOfBoundsException e){

这段代码有什么作用?看起来根本不明显,这正是它没有真正被使用的原因(详见第67条)。事实证明,作为一个要对数组元素进行遍历的实现方式,它的构想是非常拙劣的。当这个循环企图访问数组边界之外的第一个数组元素时,用抛出(throw)、捕获(catch)、忽略ArrayIndexOutOfBoundsException 的手段来达到终止无限循环的目的。假定它与数组循环的标准模式是等价的,对于任何一个Java 程序员来说,下面的标准模式一看就会明白:

1
# 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)的方法,即只有在特定的不testing)方法,即指示是否可以调用这个状态相关的方法。例如,Iterator接口有一个”状态相关”的 next方法,及相应的状态测试方法 hasNext。这使得利用传统的 for 循环(以及 for-each 循环,在内部使用了 hasNext方法)对集合进行迭代的标准模式成为可能:

1
for (Iterator<Foo> i = collection.iterator();i.hasNext();) {Foo foo = i.next();·}如果 Iterator 缺少hasNext 方法,客户端将被迫改用下面的做法://Donotusethishideouscodeforiterationoveracollection!try{Iterator<Foo> 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 exception)运行时异常(run-time exception)和错误(error)。关于什么时候适合使用哪种可抛出结构,程序员中间存在一些困惑。虽然这项决定并不总是那么清晰,但还是有些一般性的原则提出了强有力的指导。

在决定使用受检异常或是未受检异常时,主要的原则是:如果期望调用者能够适当地恢复,对于这种情况就应该使用受检异常。通过抛出受检的异常,强迫调用者在一个catch子句中处理该异常,或者将它传播出去。因此,方法中声明要抛出的每个受检异常,都是对API用户的一种潜在指示:与异常相关联的条件是调用这个方法的一种可能的结果。

API的设计者让API用户面对受检异常,以此强制用户从这个异常条件中恢复。用户可以忽视这样的强制要求,只需捕获异常并忽略即可,但这往往不是个好办法(详见第 77条)。

有两种未受检的可抛出结构:运行时异常和错误。在行为上两者是等同的:它们都是不需要也不应该被捕获的可抛出结构。如果程序抛出未受检的异常或者错误,往往就属于不可恢复的情形,继续执行下去有害无益。如果程序没有捕捉到这样的可抛出结构,将会导致当前线程中断(halt),并出现适当的错误消息。

用运行时异常来表明编程错误。大多数的运行时异常都表示前提违例(preconditionviolation)。所谓前提违例是指 API 的客户没有遵守 API规范建立的约定。例如,数组访问的约定指明了数组的下标值必须在零和数组长度减1之间。ArrayIndexOutOfBoundsException表明违反了这个前提。

这条建议有一个问题:对于要处理可恢复的条件,还是处理编程错误,情况并非总是那么黑白分明。例如,考虑资源枯竭的情形,这可能是由于程序错误而引起的,比如分配了一块不合理的过大的数组,也可能确实是由于资源不足而引起的。如果资源枯竭是由于临时的短缺,或是临时需求太大所造成的,这种情况可能就是可恢复的。API设计者需要判断这样的资源枯竭是否允许恢复。如果你相信一种情况可能允许恢复,就使用受检的异常;如果不是,则使用运行时异常。如果不清楚是否有可能恢复,最好使用未受检的异常,原因请参见第71条的讨论。

虽然JLS(Java 语言规范)并没有要求,但是按照惯例,错误往往被JVM 保留下来使用,以表明资源不足、约束失败,或者其他使程序无法继续执行的条件。由于这已经是个几乎被普遍接受的惯例,因此最好不要再实现任何新的 Error 子类。因此,你实现的所有未受检的抛出结构都应该是RuntimeException 的子类(直接的或者间接的)。不仅不应该定义Error子类,甚至也不应该抛出 AssertionError异常。

类,这也是可能的。JLS并没有直接规定这样的抛出结构,而是隐式地指定了:从行为意义上讲,它们等同于普通的受检异常(即 Exception 的子类,但不是 RuntimeException的子类)。那么,什么时候应该使用这样的抛出结构呢?一句话:永远也不会用到。它与普通的受检异常相比没有任何益处,只会困扰API的用户。

API的设计者往往会忘记,异常也是个完全意义上的对象,可以在它上面定义任意的方法。这些方法的主要用途是为捕获异常的代码而提供额外的信息,特别是关于引[发这个异常条件的信息。如果没有这样的方法,程序员必须要懂得如何解析”该异常的字符串表示法”,以便获得这些额外信息。这是极为不好的做法(详见第12条)。类很少会指定它们的字符串表示法中的细节,因此,对于不同的实现及不同的版本,字符串表示法会大相径庭。由此可见,”解析异常的字符串表示法”的代码可能是不可移植的,也是非常脆弱的。

因为受检异常往往指明了可恢复的条件,所以,对于这样的异常,提供一些辅助方法尤其重要,通过这些方法,调用者可以获得一些有助于恢复的信息。例如,假设因为用户资金不足,当他企图购买一张礼品卡时导致失败,于是抛出一个受检的异常。这个异常应该提供一个访问方法,以便允许客户查询所缺的费用金额,使得调用者可以将这个数值传递给用户。关于这个主题的更多详情,请参阅第75条。

总而言之,对于可恢复的情况,要抛出受检异常;对于程序错误,要抛出运行时异常。不确定是否可恢复,则抛出未受检异常。不要定义任何既不是受检异常也不是运行时异常的抛出类型。要在受检异常上提供方法,以便协助恢复。

第71条:避免不必要地使用受检异常

许多Java 程序员不喜欢受检异常,但是如果使用得当,它们可以改善API和程序。不同于返回码和未受检异常的是,它们强迫程序员处理异常的条件,大大增强了可靠性。也就是说,过分使用受检异常会使API使用起来非常不方便。如果方法抛出受检异常,调用该方法的代码就必须在一个或者多个catch 块中处理这些异常,或者它必须声明抛出这些异常,并让它们传播出去。无论使用哪一种方法,都给程序员增添了不可忽视的负担。这种负担在Java8 中更重了,因为抛出受检异常的方法不能直接在Stream 中使用(详见第 45条至第48 条)。

如果正确地使用API并不能阻止这种异常条件的产生,并且一旦产生异常,使用API的程序员可以立即采取有用的动作,这种负担就被认为是正当的。除非这两个条件都成立,否则更适合于使用未受检异常。作为一个石蕊测试(石蕊测试是指简单而具有决定性的测试),你可以试着问自己:程序员将如何处理该异常。下面的做法是最好的吗?

1
} catch (TheCheckedException e) {throw newAssertionErrorO;// Can't happen!}下面这种做法又如何?} catch (TheCheckedException e) {e.printStackTrace(); // Oh well, we loseSystem.exit(1) ;

如果使用API的程序员无法做得比这更好,那么未受检的异常可能更为合适。

如果方法抛出的受检异常是唯一的,它给程序员带来的额外负担就会非常高。如果这个方法还有其他的受检异常,该方法被调用的时候,必须已经出现在一个七rY块中,所以这个异常只需要另外一个catch 块。如果方法只抛出一个受检异常,单独这一个异常就表示:该方法必须放置于一个try块中,并且不能在 Stream 中直接使用。在这种情况下,应该问问自己,是否还有别的途径可以避免使用受检异常。

消除受检异常最容易的方法是,返回所要的结果类型的一个optional(详见第55条)。这个方法不抛出受检异常,而只是返回一个零长度的optional。这种方法的缺点是,方法无法返回任何额外的信息,来详细说明它无法执行你想要的计算。相反,异常则具有描述性的类型,并且能够导出方法,以提供额外的信息(详见第70条)。

“把受检异常变成未受检异常”的一种方法是,把这个抛出异常的方法分成两个方法,其中第一个方法返回一个boolean 值,表明是否应该抛出异常。这种 API重构,把下面的调用序列:

1
// Invocation with checked exceptiontry {obj.action(args) ;}catch(TheCheckedException e){...// Handle exceptional condition

重构为:

1
//Invocation with state-testing method and unchecked exceptionif (obj.actionPermitted(args)) {obj.action(args) ;}else{..// Handle exceptional condition

这种重构并非总是恰当的,但是,凡是在恰当的地方,它都会使API用起来更加舒服。虽然后者的调用序列没有前者漂亮,但是这样得到的API更加灵活。如果程序员知道调用将会成功,或者不介意由于调用失败而导致的线程终止,这种重构还允许以下这个更为简单的调用形式:

obj.action(args) ;

如果你怀疑这个简单的调用序列是否符合要求,这个API重构可能就是恰当的。这样重构之后的API在本质上等同于第 69条中的”状态测试方法”,并且同样的告诫依然适用:如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,这种重构就是不恰当的,因为在 actionPermitted 和 action 这两个调用的时间间隔之中,对象的状态有可能会发生变化。如果单独的 actionPermitted 方法必须重复 action 方法的工作,出于性能的考虑,这种API重构就不值得去做。

总而言之,在谨慎使用的前提之下,受检异常可以提升程序的可读性;如果过度使用,将会使API使用起来非常痛苦。如果调用者无法恢复失败,就应该抛出未受检异常。如果可以恢复,并且想要迫使调用者处理异常的条件,首选应该返回一个optional值。当且仅当万一失败时,这些无法提供足够的信息,才应该抛出受检异常。

第72条:优先使用标准的异常

专家级程序员与缺乏经验的程序员一个最主要的区别在于,专家追求并且通常也能够实现高度的代码重用。代码重用是值得提倡的,这是一条通用的规则,异常也不例外。Java平台类库提供了一组基本的未受检异常,它们满足了绝大多数API的异常抛出需求。

重用标准的异常有多个好处。其中最主要的好处是,它使API更易于学习和使用,因为它与程序员已经熟悉的习惯用法一致。第二个好处是,对于用到这些API的程序而言,它们的可读性会更好,因为它们不会出现很多程序员不熟悉的异常。最后(也是最不重要的)一点是,异常类越少,意味着内存占用(footprint)就越小,装载这些类的时间开销也越少。

最经常被重用的异常类型是IllegalArgumentException(详见第 49条)。当调用者传递的参数值不合适的时候,往往就会抛出这个异常。比如,假设某一个参数代表了”某个动作的重复次数”,如果程序员给这个参数传递了一个负数,就会抛出这个异常。

另一个经常被重用的异常是IllegalStateException。如果因为接收对象的状态而使调用非法,通常就会抛出这个异常。例如,如果在某个对象被正确地初始化之前,调用者就企图使用这个对象,就会抛出这个异常。

可以这么说,所有错误的方法调用都可以被归结为非法参数或者非法状态,但是,还有一些其他的标准异常也被用于某些特定情况下的非法参数和非法状态。如果调用者在某个不允许null值的参数中传递了 null,习惯的做法就是抛出 NullPointerException 异常,而不是 IllegalArgumentException。同样地,如果调用者在表示序列下标的参数中传递了越界的值,应该抛出的就是IndexOutOfBoundsException 异常,而不是Illegal-ArgumentException.

另一个值得了解的通用异常是ConcurrentModificationException。如果检测到一个专门设计用于单线程的对象,或者与外部同步机制配合使用的对象正在(或已经)被并发地修改,就应该抛出这个异常。这个异常顶多就是一个提示,因为不可能可靠地侦测到并发的修改。

最后一个值得注意的标准异常是 UnsupportedOperationException。如果对象不支持所请求的操作,就会抛出这个异常。很少用到它,因为绝大多数对象都会支持它们实现的所有方法。如果类没有实现由它们实现的接口所定义的一个或者多个可选操作(optionaloperation),它就可以使用这个异常。例如,对于只支持追加操作的List 实现,如果有人试图从列表中删除元素,它就会抛出这个异常。

不要直接重用Exception、RuntimeException、Throwable 或者Error。对待这些类要像对待抽象类一样。你无法可靠地测试这些异常,因为它们是一个方法可能抛出的其他异常的超类。

下表概括了最常见的可重用异常:

虽然这些都是Java平台类库中迄今为止最常被重用的异常,但是,在条件许可的情况下,其他的异常也可以被重用。例如,如果要实现诸如复数或者有理数之类的算术对象,也可以重用 ArithmeticException 和 NumberFormatException。如果某个异常能够满足你的需要,就不要犹豫,使用就是,不过一定要确保抛出异常的条件与该异常的文档中描述的条件一致。这种重用必须建立在语义的基础上,而不是建立在名称的基础之上。而且,如果希望稍微增加更多的失败-捕获(failure-capture)信息(详见第75条),可以放心地子类化标准异常,但要记住异常是可序列化的(详见第12章)。这也正是”如果没有非常正当的理由,千万不要自己编写异常类”的原因。

选择重用哪一种异常并非总是那么精确,因为上表中的”使用场合”并不是相互排斥的。比如,以表示一副纸牌的对象为例。假设有一个处理发牌操作的方法,它的参数是发一手牌的纸牌张数。假设调用者在这个参数中传递的值大于整副纸牌的剩余张数。这种情形既可以被解释为 IllegalArgumentException(handSize 参数的值太大),也可以被解释为IllegalStateException(纸牌对象包含的纸牌太少)。在这种情况下,如果没有可用的参数值,就抛出IllegalStateException,否则就抛出IllegalArgumentExceptione

第73条:抛出与抽象对应的异常

如果方法抛出的异常与它所执行的任务没有明显的联系,这种情形将会使人不知所措。当方法传递由低层抽象抛出的异常时,往往会发生这种情况。除了使人感到困惑之外,这也”污染”了具有实现细节的更高层的API。如果高层的实现在后续的发行版本中发生了变化,它所抛出的异常也可能会跟着发生变化,从而潜在地破坏现有的客户端程序。

为了避免这个问题,更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常。这种做法称为异常转译(exception translation),如下代码所示:

1
//ExceptionTranslationtry {..//Use lower-levelabstraction to do our bidding}catch(LowerLevelException e){throw new HigherLevelException(...);子

下面的异常转译例子取自于 AbstractSequentialList类,该类是List接口的一个骨架实现(skeletal implementation),详见第 20条。在这个例子中,按照List接口中get方法的规范要求,异常转译是必需的:

1
*** Returns the element at the specified position in this list.* @throws IndexOutOfBoundsException if the index is out of range({@code index < 0 Il index >= size()}).*/public E get(int index){ListIterator<E> i = listIterator(index);try{return i.next();} catch (NoSuchElementException e) {

一种特殊的异常转译形式称为异常链(exception chaining),如果低层的异常对于调试导致高层异常的问题非常有帮助,使用异常链就很合适。低层的异常(原因)被传到高层的异常,高层的异常提供访问方法(Throwable 的 getCause 方法)来获得低层的异常:

1
// Exception Chainingtry{...// Use lower-level abstraction to do our bidding} catch (LowerLevelException cause){throw new HigherLevelException(cause);

高层异常的构造器将原因传到支持链(chaining-aware)的超级构造器,因此它最终将被传给 Throwable的其中一个运行异常链的构造器,例如 Throwable(Throwable):

1
//Exception with chaining-awareconstructorclass HigherLevelException extends Exception {HigherLevelException(Throwable cause) {super(cause) ;子

大多数标准的异常都有支持链的构造器。对于没有支持链的异常,可以利用 Throwable的 initCause方法设置原因。异常链不仅让你可以通过程序(用 getCause)访问原因,还可以将原因的堆栈轨迹集成到更高层的异常中。

尽管异常转译与不加选择地从低层传递异常的做法相比有所改进,但是也不能滥用它。如有可能,处理来自低层异常的最好做法是,在调用低层方法之前确保它们会成功执行,从而避免它们抛出异常。有时候,可以在给低层传递参数之前,检查更高层方法的参数的有效性,从而避免低层方法抛出异常。

如果无法阻止来自低层的异常,其次的做法是,让更高层来悄悄地处理这些异常,从而将高层方法的调用者与低层的问题隔离开来。在这种情况下,可以用某种适当的记录机制(如java.util.logging)将异常记录下来。这样有助于管理员调查问题,同时又将客户端代码和最终用户与问题隔离开来。

总而言之,如果不能阻止或者处理来自更低层的异常,一般的做法是使用异常转译,只有在低层方法的规范碰巧可以保证”它所抛出的所有异常对于更高层也是合适的”情况下,才可以将异常从低层传播到高层。异常链对高层和低层异常都提供了最佳的功能:它允许抛出适当的高层异常,同时又能捕获低层的原因进行失败分析(详见第75条)。

第74条:每个方法抛出的所有异常都要建立文档

描述一个方法所抛出的异常,是正确使用这个方法时所需文档的重要组成部分。因此,花点时间仔细地为每个方法抛出的异常建立文档是特别重要的。

始终要单独地声明受检异常,并且利用Javadoc的@throws 标签,准确地记录下抛出每个异常的条件。如果一个公有方法可能抛出多个异常类,则不要使用”快捷方式”声明它会抛出这些异常类的某个超类。永远不要声明一个公有方法直接”throws Exception”,或者更糟糕的是声明它直接”throwsThrowable”,这是非常极端的例子。这样的声明不仅没有为程序员提供关于”这个方法能够抛出哪些异常”的任何指导信息,而且大大地妨碍了该方法的使用,因为它实际上掩盖了该方法在同样的执行环境下可能抛出的任何其他异常。这条建议有一个例外,就是 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 构造器:

1
2
3
4
5
* Constructs an IndexOutOfBoundsException.* @param lowerBound the lowest legal index value*@param index the actualindexvaluepublic 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 accessthis.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 方法为例:

1
public Object pop(){if (size == 0)throw new EmptyStackException();Object result = elements[--size];elements[size]=null;// Eliminate obsolete referencereturn result;

如果取消对初始大小(size)的检查,当这个方法企图从一个空栈中弹出元素时,它仍然会抛出异常。然而,这将会导致size 域保持在不一致的状态(负数)之中,从而导致将来对该对象的任何方法调用都会失败。此外,那时,pop 方法抛出的 ArrayIndexOutOfBounds-Exception异常对于该抽象来说也是不恰当的(详见第73条)。

一种类似的获得失败原子性的办法是,调整计算处理过程的顺序,使得任何可能会失败的计算部分都在对象状态被修改之前发生。如果对参数的检查只有在执行了部分计算之后才能进行,这种办法实际上就是上一种办法的自然扩展。比如,以TreeMap 的情形为例,它的元素被按照某种特定的顺序做了排序。为了向TreeMap 中添加元素,该元素的类型就必须是可以利用 TreeMap 的排序准则与其他元素进行比较的。如果企图增加类型不正确的元素,在 tree以任何方式被修改之前,自然会导致ClassCastException异常。

第三种获得失败原子性的办法是,在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内容。如果数据保存在临时的数据结构中,计算过程会更加迅速,使用这种办法就是件很自然的事。例如,有些排序函数会在执行排序之前,先把它的输入列表备份到一个数组中,以便降低在排序的内循环中访问元素所需要的开销。这是出于性能考虑的做法,但是,它增加了一项优势:即使排序失败,它也能保证输入列表保持原样。

最后一种获得失败原子性的办法远远没有那么常用,做法是编写一段恢复代码(recoverycode),由它来拦截操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。这种办法主要用于永久性的(基于磁盘的)数据结构。

虽然一般情况下都希望实现失败原子性,但并非总是可以做到。举个例子,如果两个留在不一致的状态之中。因此,在捕获了ConcurrentModificationException 异常之后再假设对象仍然是可用的,这就是不正确的。错误通常是不可恢复的,因此,当方法抛出AssertionError时,不需要努力去保持失败原子性。

即使在可以实现失败原子性的场合,它也并不总是人们所期望的。对于某些操作,它会显著地增加开销或者复杂性。也就是说,一旦了解了这个问题,获得失败原子性往往既简单又容易。

总而言之,作为方法规范的一部分,它产生的任何异常都应该让对象保持在调用该方法之前的状态。如果违反这条规则,API文档就应该清楚地指明对象将会处于什么样的状态。遗憾的是,大量现有的 API 文档都未能做到这一点。

第77条:不要忽略异常

尽管这条建议看上去是显而易见的,但是它却常常被违反,因而值得再次提出来。当API的设计者声明一个方法将抛出某个异常的时候,他们等于正在试图说明某些事情。所以,请不要忽略它!要忽略一个异常非常容易,只需将方法调用通过try语句包围起来,并包含一个空的catch块:

1
// Empty catch block ignores exception - Highly suspect!try{}catch(SomeException e){

空的catch块会使异常达不到应有的目的,即强迫你处理异常的情况。忽略异常就如同忽略火警信号一样一—如果把火警信号器关掉了,当真正有火灾发生时,就没有人能看到火警信号了。或许你会侥幸逃过劫难,或许结果将是灾难性的。每当见到空的catch 块时,应该让警钟长鸣。

有些情形可以忽略异常。比如,关闭 FileInputStream 的时候。因为你还没有改变文件的状态,因此不必执行任何恢复动作,并且已经从文件中读取到所需要的信息,因此不必终止正在进行的操作。即使在这种情况下,把异常记录下来还是明智的做法,因为如果这些异常经常发生,你就可以调查异常的原因。如果选择忽略异常,catch 块中应该包含一条注释,说明为什么可以这么做,并且变量应该命名为ignored:

1
Future<Integer> f = exec.submit(planarMap::chromaticNumber) ;int numColors = 4; // Default; guaranteed sufficient for any maptry {numColors = f.get(1L, TimeUnit.SECONDS);// Use default: minimal coloring is desirable, not required}

本条目中的建议同样适用于受检异常和未受检异常。不管异常代表了可预见的异常条件,还是编程错误,用空的catch 块忽略它,都将导致程序在遇到错误的情况下悄然地执行下去。然后,有可能在将来的某个点上,当程序不能再容忍与错误源明显相关的问题时,它就会失败。正确地处理异常能够彻底避免失败。只要将异常传播给外界,至少会导致程序迅速失败,从而保留了有助于调试该失败条件的信息。

第十一章 并发

线程机制允许同时进行多个活动。并发程序设计比单线程程序设计要困难得多,因为有更多的东西可能出错,也很难重现失败。但是你无法避免并发,因为我们所做的大部分事情都需要并发,而且并发也是能否从多核的处理器中获得好的性能的一个条件,这些现在都是很平常的事了。本章阐述的建议可以帮助你编写出清晰、正确、文档组织良好的并发程序。

第78条:同步访问共享的可变数据

关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某一个方法,或者某一个代码块。许多程序员把同步的概念仅仅理解为一种互斥(mutualexclusion)的方式,即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象内部不一致的状态。按照这种观点,对象被创建的时候处于一致的状态(详见第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 域的读和写操作都是原子的,程序员在访问这个域的时候不再需要使用同步:

1
// Broken!-How long would you expect thisprogram to run?public class StopThread {private static boolean stopRequested;public static void main(String[] args)throws InterruptedException {Thread backgroundThread = new Thread(O) ->{int i = 0;while (!stopRequested)i++;});backgroundThread. start() ;TimeUnit.SECONDS.sleep(1);stopRequested = true;

你可能期待这个程序运行大约一秒钟左右,之后主线程将 stopRequested设置为true,致使后台线程的循环终止。但是在我的机器上,这个程序永远不会终止:因为后台线程永远在循环!

问题在于,由于没有同步,就不能保证后台线程何时‘看到”主线程对 stopRequested的值所做的改变。没有同步,虚拟机将以下代码:

1
while (!stopRequested)i++;

转变成这样:

1
# if (!stopRequested)while (true)i++;

这种优化称作提升(hoisting),正是OpenJDK Server VM 的工作。结果是一个活性失败(liveness failure):这个程序并没有得到提升。修正这个问题的一种方式是同步访问 stop-Requested域。这个程序会如预期般在大约一秒之内终止:

1
//Properly synchronized cooperative thread terminationpublic 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(() -> {inti=0;while (!stopRequested())i++;);backgroundThread.start();TimeUnit.SECONDS.sleep(1);requestStop();

注意写方法(requestStop)和读方法(stopRequested)都被同步了。只同步写方法还不够!除非读和写操作都被同步,否则无法保证同步能起作用。有时候,会在某些机器上看到只同步了写(或读)操作的程序看起来也能正常工作,但是在这种情况下,表象具有很大的欺骗性。

StopThread 中被同步方法的动作即使没有同步也是原子的。换句话说,这些方法的同步只是为了它的通信效果,而不是为了互斥访问。虽然循环的每个迭代中的同步开销很小,还是有其他更正确的替代方法,它更加简洁,性能也可能更好。如果 stopRequested被声明为 volatile,第二种版本的 StopThread 中的锁就可以省略。虽然volatile 修饰符不执行互斥访问,但它可以保证任何一个线程在读取该域的时候都将看到最近刚刚被写入的值:

1
2
3
//Cooperative threadterminationwithavolatilefieldpublic 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++;H);backgroundThread.start();TimeUnit.SECONDS.sleep(1) ;stopRequested = true;

在使用volatile 的时候务必要小心。以下面的方法为例,假设它要产生序列号::

1
// Broken - requires synchronization!private static volatile int nextSerialNumber = 0;public static int generateSerialNumber() {return nextSerialNumber++;

这个方法的目的是要确保每个调用都返回不同的值(只要不超过2”个调用)。这个方法的状态只包含一个可原子访问的域:nextSerialNumber,这个域的所有可能的值都是合法的。因此,不需要任何同步来保护它的约束条件。然而,如果没有同步,这个方法仍然无法正确地工作。

问题在于,增量操作符(++)不是原子的。它在 nextSerialNumber域中执行两项操作:首先它读取值,然后写回一个新值,相当于原来的值再加上1。如果第二个线程在第一个线程读取旧值和写回新值期间读取这个域,第二个线程就会与第一个线程一起看到同一个值,并返回相同的序列号。这就是安全性失败(safety failure):这个程序会计算出错误的结果。

修正 generateSerialNumber方法的一种方法是在它的声明中增加 synchronized修饰符。这样可以确保多个调用不会交叉存取,确保每个调用都会看到之前所有调用的效果。一旦这么做,就可以且应该从 nextSerialNumber中删除volatile 修饰符。为了保护这个方法,要用long 代替int,或者在nextSerialNumber要进行包装时抛出异常。

最好还是遵循第59 条中的建议,使用AtomicLong 类,它是java.util.concurrent.atomic的组成部分。这个包为在单个变量上进行免锁定、线程安全的编程提供了基本类型。虽然volatile 只提供了同步的通信效果,但这个包还提供了原子性。这正是你想让generateSerialNumber 完成的工作,并且它可能比同步版本完成得更好:

1
//Lock-free synchronizationwith java.util.concurrent.atomicprivate static final AtomicLong nextSerialNum = new AtomicLongO;public static long generateSerialNumber({return nextSerialNum.getAndIncrementO;

了避免本条目中所讨论到的问题的最佳办法是不共享可变的数据。要么共享不可变的数据(详见第17条),要么压根不共享。换句话说,将可变数据限制在单个线程中。如果采用这一策略,对它建立文档就很重要,以便它可以随着程序的发展而得到维护。深刻地理解正在使用的框架和类库也很重要,因为它们引入了你不知道的线程。

让一个线程在短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,它只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作高效不可变(effectivelyimmutable)[Goetz06,3.5.4]。将这种对象引用从一个线程传递到其他的线程被称作安全发布(safe publication)[Goetz06,3.5.3]。安全发布对象引用有许多种方法:可以将它保存在静态域中,作为类初始化的一部分;可以将它保存在volatile域、final域或者通过正常锁定访问的域中;或者可以将它放到并发的集合中(详见第81条)。

总而言之,当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同可变数据会造成程序的活性失败(liveness failure)和安全性失败(safetyfailure)。这样的失败是最难调试的。它们可能是间歇性的,且与时间相关,程序的行为在不同的虚拟机上可能根本不同。如果只需要线程之间的交互通信,而不需要互斥,volatile 修饰符就是一种可以接受的同步形式,但要正确地使用它可能需要一些技巧。

第79条:避免过度同步

第78条告诫过我们缺少同步的危险性。本条目则关注相反的问题。依据情况的不同,过度同步则可能导致性能降低、死锁,甚至不确定的行为。

为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要放弃对客户端的控制。换句话说,在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法(详见第24条)。从包含该同步区域的类的角度来看,这样的方法是外来的(alien)。这个类不知道该方法会做什么事情,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。

为了对这个过程进行更具体的说明,以下面的类为例,它实现了一个可以观察到的集合包装(set wrapper)。该类允许客户端在将元素添加到集合中时预订通知。这就是观察者(Observer)模式[Gamma95]。为了简洁起见,类在从集合中删除元素时没有提供通知,但要提供通知也是一件很容易的事情。这个类是在第 18条中可重用的 ForwardingSet上实现的:

1
2
3
// Broken - invokes alien method from synchronized block!public class ObservableSet<E> extends ForwardingSet<E>{public ObservableSet(Set<E> set) { super(set);}private finalList<Setobserver<E>> observers= new ArrayList<>();public void addobserver(Setobserver<E> observer){

synchronized(observers) {observers.add(observer) ;public boolean remove0bserver(SetObserver<E> observer) {synchronized(observers) {return observers.remove(observer);private void notifyElementAdded(E element) {synchronized(observers) {for (SetObserver<E> 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<? extends E> c){boolean result = false;for (E element : c)result |= add(element); // Calls notifyElementAddedreturn result;

观察者通过调用 addObserver 方法预订通知,通过调用 removeObserver 方法取消预订。在这两种情况下,这个回调(callback)接口的实例都会被传递给方法:

1
@FunctionalInterface public interface SetObserver<E>{// Invoked when an element is addedto theobservable setvoid added(ObservableSet<E> set, E element);

这个接口的结构与BiConsumer<ObservableSet,E>一样。我们选择定义一个定制的函数接口,因为该接口和方法名称可以提升代码的可读性,且该接口可以发展整合多个回调。也就是说,还可以设置合理的参数来使用 BiConsumer(详见第 44 条)。

如果只是粗略地检验一下,ObservableSet会显得很正常。例如,下面的程序打印出0~99的数字:

1
public static void main(String[] args) {ObservableSet<Integer> 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,这个观察者要将自身删除:

1
set.addobserver(new Setobserver<>() {public void added(ObservableSet<Integer> s, Integer e) {System.out.println(e);if(e==23)s.removeObserver(this););

注意,这个调用以一个匿名类 SetObserver 实例代替了前一个调用中使用的lambda。这是因为函数对象需要将自身传给s.removeObserver,而 lambda则无法访问它们自己(详见第42条)。

你可能以为这个程序会打印数字0~23,之后观察者会取消预订,程序会悄悄地完成它问题在于,当 notifyElementAdded 调用观察者的 added 方法时,它正处于遍历 obser-vers列表的过程中。added方法调用可观察集合的 removeObserver方法,从而调用observers.remove。现在我们有麻烦了。我们正企图在遍历列表的过程中,将一个元素从列表中删除,这是非法的。notifyElementAdded方法中的迭代是在一个同步的块中,可以防止并发的修改,但是无法防止迭代线程本身回调到可观察的集合中,也无法防止修改它的observers列表。

现在我们要尝试一些比较奇特的例子:我们来编写一个试图取消预订的观察者,但是不直接调用removeObserver,它用另一个线程的服务来完成。这个观察者使用了一个executor service(详见第 80条):

1
//Observerthatusesabackgroundthreadneedlesslyset.addobserver(new Setobserver<>() {public void added(ObservableSet<Integer> 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 列表拍张”快照”,然后没有锁也可以安全地遍历这个列表了。经过这一修改,前两个例子运行起来便再也不会出现异常或者死锁了:

1
//Alien method moved outside of synchronized block-open callsprivate void notifyElementAdded(E element){List<Setobserver<E>> snapshot = null;synchronized(observers) {snapshot = new ArrayList<>(observers);8for(Setobserver<E> observer : snapshot)observer.added(this, element);子

事实上,要将外来方法的调用移出同步的代码块,还有一种更好的方法。Java类库提供了一个并发集合(concurrent collection),详见第8l条,称作CopyOnWriteArrayList,这是专门为此定制的。这个CopyOnWriteArrayList 是ArrayList 的一种变体,它通过重新拷贝整个底层数组,在这里实现所有的写操作。由于内部数组永远不改动,因此迭代不需要锁定,速度也非常快。如果大量使用,CopyOnWriteArrayList的性能将大受影响,但是对于观察者列表来说却是很好的,因为它们几乎不改动,并且经常被遍历。

如果将这个列表改成使用CopyOnWriteArrayList,就不必改动ObservableSet的 add 和 addAll 方法。下面是这个类的其余代码。注意其中并没有任何显式的同步:

1
new CopyOnWriteArrayList<>(;public void addObserver(SetObserver<E> observer){observers.add(observer) ;public boolean removeObserver(Setobserver<E> observer){return observers.remove(observer);子private void notifyElementAdded(E element){for (Setobserver<E> 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 代替,它是一个非同步的 StringBuffer。同样地,java.util.Random中线程安全的伪随机数生成器,被java.util.concurrent.ThreadLocalRandom 中非同步的实现取代,主要也是出于上述原因。当你不确定的时候,就不要同步类,而应该建立文档,注明它不是线程安全的。

如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如分拆锁、分离锁和非阻塞并发控制。这些方法都超出了本书的讨论范围,但有其他著作对此进行了阐述[Goetz06, Herlihy12 ]。

步对这个域的访问(除非这个类能够容忍不确定的行为)。多线程的客户端要在这种方法上执行外部同步是不可能的,因为其他不相关的客户端不需要同步也能调用该方法。域本质上就是一个全局变量,即使是私有的也一样,因为它可以被不相关的客户端读取和修改。第78条中的 generateSerialNumber方法使用的 nextSerialNumber 域就是这样的一个例子。

总而言之,为了避免死锁和数据破坏,千万不要从同步区域内部调用外来方法。更通俗地讲,要尽量将同步区域内部的工作量限制到最少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在如今这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这么做,同时还应该将这个决定清楚地写到文档中(详见第82条)。

第80条:executor、task和stream优先于线程

本书第1版中阐述了简单的工作队列(work queue)[Bloch01,详见第 49 条]的代码。这个类允许客户端按队列等待由后台线程异步处理的工作项目。当不再需要这个工作队列时,客户端可以调用一个方法,让后台线程在完成了已经在队列中的所有工作之后,优雅地终止自己。这个实现几乎就像一件玩具,但即使如此,它还是需要一整页精细的代码,一不小心,就容易出现安全问题或者导致活性失败。幸运的是,你再也不需要编写这样的代码了。

到本书第二版出版的时候,Java平台中已经增加了java。util.concurrent。这个包中包含了一个Executor Framework,它是一个很灵活的基于接口的任务执行工具。它创建了一个在各方面都比本书第一版更好的工作队列,却只需要这一行代码:

ExecutorService exec = Executors.newSingleThreadExecutor();

下面是为执行而提交一个runnable的方法:

exec.execute(runnable);

下面是告诉executor 如何优雅地终止(如果你没有这么做,虚拟机可能不会退出):

exec.shutdown() ;

你可以利用executor service 完成更多的工作。例如,可以等待完成一项特殊的任务(就如第79条中的get方法一样),你可以等待一个任务集合中的任何任务或者所有任务完成(利用 invokeAny或者 invokeAll方法),可以等待 executor service 优雅地完成终止(利用 awaitTermination 方法),可以在任务完成时逐个地获取这些任务的结果(利用ExecutorCompletionService),可以调度在某个特殊的时间段定时运行或者阶段性地运行的任务(利用 ScheduledThreadPoolExecutor),等等。

如果想让不止一个线程来处理来自这个队列的请求,只要调用一个不同的静态工厂,这个工厂创建了一种不同的 executor service,称作线程池(thread pool)。你可以用固定或者可变数目的线程创建一个线程池。java.util.concurrent.Executors类包含了静态工厂,能为你提供所需的大多数executor。然而,如果你想来点特别的,可以直接使用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 的线程不仅要处理这些任务,高吞吐量,并降低延迟。fork-join 任务的编写和调优是很有技巧的。并发的 stream(详见第48条)是在forkjoin池上编写的,我们不费什么力气就能享受到它们的性能优势,前提是假设它们正好适用于我们手边的任务。

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、并发集合(ConcurrentCollection)以及同步器(Synchronizer),ExecutorFramework只在第80条中简单地提到过,并发集合和同步器将在本条目中进行简单的阐述。

并发集合为标准的集合接口(如List、Queue 和 Map)提供了高性能的并发实现。为了提供高并发性,这些实现在内部自己管理同步(详见第79条)。因此,并发集合中不可能排除并发活动;将它锁定没有什么作用,只会使程序的速度变慢。

因为无法排除并发集合中的并发活动,这意味着也无法自动地在并发集合中组成方法调用。因此,有些并发集合接口已经通过依赖状态的修改操作(state-dependent modify operation)进行了扩展,它将几个基本操作合并到了单个原子操作中。事实证明,这些操作在并发集合中已经够用,它们通过缺省方法(详见第21条)被加到了Java 8对应的集合接口中。

例如,Map 的 putIfAbsent(key,Value)方法,当键没有映射时会替它插人一个映射,并返回与键关联的前一个值,如果没有这样的值,则返回 null。这样就能很容易地实现线程安全的标准 Map 了。例如,下面这个方法模拟了 String.intern 的行为:

1
// Concurrent canonicalizing map atop ConcurrentMap-not optimalprivate static final ConcurrentMap<String, String> 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:

1
// 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 必须使用某种弱引l用,避免随着时间的推移而发生内存泄漏)。并发集合导致同步的集合大多被废弃了。比如,应该优先使用concurrentHashMap,而不是使用collections。synchronizedMap。只要用并发 Map 替换同步 Map,就可以极大地提升并发应用程序的性能。

待(或者阻塞)到可以成功执行为止。例如,BlockingQueue扩展了Queue接口,并添加了包括take在内的几个方法,它从队列中删除并返回了头元素,如果队列为空,就等待。这样就允许将阻塞队列用于工作队列(work queue),也称作生产者-消费者队列(producer-consumerqueue),一个或者多个生产者线程(producer thread)在工作队列中添加工作项目,并且当工作项目可用时,一个或者多个消费者线程(consumer thread)则从工作队列中取出队列并处理工作项目。不出所料,大多数ExecutorService 实现(包括ThreadPoolExecutor)都使用了一个BlockingQueue(详见第80条)。

同步器(Synchronizer)是使线程能够等待另一个线程的对象,允许它们协调动作。最常用的同步器是CountDownLatch 和 Semaphore。较不常用的是CyclicBarrier和Exchanger。功能最强大的同步器是 Phaser。

倒计数锁存器(CountdownLatch)是一次性的障碍,允许一个或者多个线程等待一型的参数,这个int参数是指允许所有在等待的线程被处理之前,必须在锁存器上调用countDown方法的次数。

要在这个简单的基本类型之上构建一些有用的东西,做起来是相当容易。例如,假设想要构建一个简单的框架,用来给一个动作的并发执行定时。这个框架中只包含单个方法,该方法带有一个执行该动作的executor,一个并发级别(表示要并发执行该动作的次数),以及表示该动作的runnable。所有的工作线程(worker thread)自身都准备好,要在timer线程启动时钟之前运行该动作。当最后一个工作线程准备好运行该动作时,timer线程就”发起头炮”,同时允许工作线程执行该动作。一旦最后一个工作线程执行完该动作,timer 线程就立即停止计时。直接在 wait 和 notify之上实现这个逻辑会很混乱,而在CountDownLatch 之上实现则相当简单:

1
2
3
//Simple framework for timing concurrent executionpublic 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= O;i< concurrency;i++){executor.execute(() ->{ready.countDown(); // Tell timer we're readytry {start.await(); // wait till peers are readyaction.run(;}catch (InterruptedException e) {Thread.currentThread() .interrupt() ;}finally {done.countDownO); // Tell timer we're done

});ready.await(); //Waitforallworkerstobereadylong startNanos = System.nanoTime();start.countDownO); // And they're off!done.await(); // Wait for all workers to finishreturn System.nanoTime() - startNanos;子

注意这个方法使用了三个倒计数锁存器。第一个是 ready,工作线程用它来告诉timer线程它们已经准备好了。然后工作线程在第二个锁存器 start上等待。当最后一个工作线程调用ready.countDown 时,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 方法的标准模式:

1
// The standard idiom for using the wait methodsynchronized (obj) {while (<condition does not hold>)obj.waitO; // (Releases lock, and reacquires on wakeup)... // Perform action appropriate to condition

始终应该使用wait循环模式来调用wait方法;永远不要在循环之外调用wait方法。循环会在等待之前和之后对条件进行测试。

在等待之前测试条件,当条件已经成立时就跳过等待,这对于确保活性是必要的。如果条件已经成立,并且在线程等待之前,notify(或者 notifyAll)方法已经被调用,则无法保证该线程总会从等待中苏醒过来。

在等待之后测试条件,如果条件不成立的话继续等待,这对于确保安全性是必要的。当条件不成立的时候,如果线程继续执行,则可能会破坏被锁保护的约束关系。当条件不成立时,有下面一些理由可使一个线程苏醒过来:

口另一个线程可能已经得到了锁,并且从一个线程调用 notify方法那一刻起,到等待线程苏醒过来的这段时间中,得到锁的线程已经改变了受保护的状态。

口条件并不成立,但是另一个线程可能意外地或恶意地调用了 notify方法。在公有可访问的对象上等待,这些类实际上把自己暴露在了这种危险的境地中。公有可访问对象的同步方法中包含的wait方法都会出现这样的问题。

( )有某些等待线程的条件已经被满足,但是通知线程可能仍然调用 notifyAll方法。

口在没有通知的情况下,等待线程也可能(但很少)会苏醒过来。这被称为”伪唤醒”

(spurious wakeup) [POSIX, 11.4.3.6.1; Java9-api]。

一个相关的话题是,为了唤醒正在等待的线程,你应该使用 notify方法还是 notify-All方法(回忆一下,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 修饰符,这是有理由的。因为在-个方法声明中出现synchronized修饰符,这是个实现细节,并不是导出的APl的一部分。它并不一定表明这个方法是线程安全的。

而且”出现了synchronized关键字就足以用文档说明线程安全性”的这种说法隐含了一个错误的观念,即认为线程安全性是一种”要么全有要么全无”的属性。实际上,线程安全性有多种级别。一个类为了可被多个线程安全地使用,必须在文档中清楚地说明它所支持的线程安全性级别。下述分项概括了线程安全性的几种级别。这些分项并没有涵盖所有的可能,只是列出了常见的情形:

口不可变的(immutable)一这个类的实例是不变的。所以,不需要外部的同步。这样的例子包括 String、Long 和 BigInteger(详见第 17条)。

口无条件的线程安全(unconditionally thread-safe)——这个类的实例是可变的,但是这个类有着足够的内部同步,所以它的实例可以被并发使用,无须任何外部同步。其例子包括 AtomicLong 和 ConcurrentHashMap。

口有条件的线程安全(conditionally thread-safe)——除了有些方法为进行安全的并发使用而需要外部同步之外,这种线程安全级别与无条件的线程安全相同。这样的例子包括Collections.synchronized 包装返回的集合,它们的迭代器要求外部同步。

口非线程安全(not thread-safe)一这个类的实例是可变的。为了并发地使用它们,客户端必须利用自己选择的外部同步包围每个方法调用(或者调用序列)。这样的例子包括通用的集合实现,例如 ArrayList 和 HashMap。

口线程对立的(thread-hostile)一这种类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有同步地修改静态数据。没有人会有意编写一个线程对立的类;这种类是因为没有考虑到并发性而产生的后果。当一个类或者方法被发现是线程对立的,一般会得到修正,或者被标注为”不再建议使用”。第78条中的generateSerialNumber方法就是线程对立的,因为没有从内部进行同步,详情请参阅第79条。

这些分类(除了线程对立的之外)粗略对应于《JavaConcurrency inPractice》一书中的线程安全注解(thread safety annotation),分别为 Immutable、ThreadSafe 和 NotThread-Safe [Goetz06,Appendix A]。上述分类中无条件和有条件的线程安全类别都涵盖在 Thread-Safe注解中了。

在文档中描述一个有条件的线程安全类要特别小心。你必须指明哪个调用序列需要外部同步,还要指明为了执行这些序列,必须获得哪一把锁(极少的情况下是指哪几把锁)。通常情况下,这是指作用在实例自身上的那把锁,但也有例外。例如,Collections.synchronizedMap 的文档中有这样的说明:

It isimperative that the user manually synchronize on the returned map wheniterating over any of its collection views:

(当遍历任何被返回 Map 的集合视图时,用户必须手工对它们进行同步:)

1
Map<K, V> m = Co1lections.synchronizedMap(new HashMap<>();Set<K> s = m.keySet;// Needn't be in synchronized blocksynchronized(m) { // Synchronizing on m, not s!for (K key : s)key.fO ;}

如果没有遵循这样的建议,就可能造成不确定的行为。

类的线程安全说明通常放在它的文档注释中,但是带有特殊线程安全属性的方法则应该在它们自己的文档注释中说明它们的属性。没有必要说明枚举类型的不可变性。除非从返回类型来看已经很明显,否则静态工厂必须在文档中说明被返回对象的线程安全性,如Collections.synchronizedMap(上述) 所示。

当一个类承诺”使用一个公有可访问的锁对象”时,就意味着允许客户端以原子的方式执行一个方法调用序列,但是,这种灵活性是要付出代价的。并发集合(如Concurrent-起拒绝服务(denial-of service)攻击,他只需超时地保持公有可访问锁即可。这有可能是无意的,也可能是有意的。

为了避免这种拒绝服务攻击,应该使用一个私有锁对象(private lock object)来代替同步的方法 (隐含着一个公有可访问锁):

1
//Privatelockobjectidiom-thwartsdenial-of-serviceattackprivate final Object lock = new Object();public void foo(){synchronized(lock) {

因为这个私有锁对象不能在这个类之外被访问,也不能被这个类的客户端程序所访问,所以客户端不可能妨碍对象的同步。实际上,我们正是在应用第15条的建议,把锁对象封装在它所同步的对象中。

注意 1ock域被声明为 final 的。这样可以防止不小心改变它的内容,而导致不同步访问包含对象的悲惨后果(详见第78条)。我们这是在应用第 17条的建议,将 1ock域的可变性减到最小。lock 域应该始终声明为 final。这是真的,无论是使用普通的监控锁(如上所述),还是使用来自java.util.concurrent.locks 包中的锁。

私有锁对象模式只能用在无条件的线程安全类上。有条件的线程安全类不能使用这种模式,因为它们必须在文档中说明:在执行某些方法调用序列时,它们的客户端程序必须获得哪把锁。

私有锁对象模式特别适用于那些专门为继承而设计的类(详见第19条)。如果这种类使用它的实例作为锁对象,子类可能很容易在无意中妨碍基类的操作,反之亦然。出于不同的目的而使用相同的锁,子类和基类可能会”相互绊住对方的脚”。这不只是一个理论意义上的问题。例如,这种现象在 Thread 类上就出现过[Bloch05,Puzzle 77]。

简而言之,每个类都应该利用字斟句酌的说明或者线程安全注解,清楚地在文档中说明它的线程安全属性。Synchronized 修饰符与这个文档毫无关系。有条件的线程安全类必须在文档中指明”哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁”。如果你编写的是无条件的线程安全类,就应该考虑使用私有锁对象来代替同步的方法。这样可以防止客户端程序和子类的不同步干扰,让你能够在后续的版本中灵活地对并发控制采用更加复杂的方法。

第83条:慎用延迟初始化

延迟初始化(lazy initialization)是指延迟到需要域的值时才将它初始化的行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既适用于静态域,也适用于实例域。虽然延迟初始化主要是一种优化,但它也可以用来破坏类中的有害循环和实例初始化[Bloch05, Puzzle 51 ]。

就像大多数的优化一样,对于延迟初始化,最好建议”除非绝对必要,否则就不要这么做”(详见第67条)。延迟初始化就像一把双刃剑。它降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化的域的开销。根据延迟初始化的域的哪个部分最终需要初始化、一样)实际上降低了性能。

也就是说,延迟初始化有它的好处。如果域只在类的实例部分被访问,并且初始化这个域的开销很高,可能就值得进行延迟初始化。要确定这一点,唯一的办法就是测量类在用和不用延迟初始化时的性能差别。

始化的域,采用某种形式的同步是很重要的,否则就可能造成严重的Bug(详见第78条)。本条目中讨论的所有初始化方法都是线程安全的。

在大多数情况下,正常的初始化要优先于延迟初始化。下面是正常初始化的实例域的一个典型声明。注意其中使用了final 修饰符(详见第 17 条):

//Normal initialization of an instance fieldprivate final FieldType field = computeFieldValueO;

如果利用延迟优化来破坏初始化的循环,就要使用同步访问方法,因为它是最简单、最清楚的替代方法:

//Lazyinitializationofinstance field-synchronizedaccessorprivate FieldType field;

private synchronized FieldType getFieldO{if (field == nul1)field = computeFieldValue;return field;

这两种习惯模式(正常的初始化和使用了同步访问方法的延迟初始化)应用到静态域上时保持不变,除了给域和访问方法声明添加了 static 修饰符之外。

如果出于性能的考虑而需要对静态域使用延迟初始化,就使用lazyinitializationholderclass 模式。这种模式(也称作initialize-on-demandholder class idiom)保证了类要到被用到的时候才会被初始化[JLS,12.4.1]。如下所示:

1
//Lazyinitializationholderclass idiom forstaticfieldsprivate static class FieldHolder{private staticFieldType getField(){return FieldHolder.field;}

当 getField方法第一次被调用时,它第一次读取FieldHolder.field,导致FieldHolder 类得到初始化。这种模式的魅力在于,getField 方法没有被同步,并且只执行一个域访问,因此延迟初始化实际上并没有增加任何访问成本。现代的VM将在初始化该类的时候,同步域的访问。一旦这个类被初始化,虚拟机将修补代码,以便后续对该域的访问不会导致任何测试或者同步。

如果出于性能的考虑而需要对实例域使用延迟初始化,就使用双重检查模式(double-check idiom)。这种模式避免了在域被初始化之后访问这个域时的锁定开销(详见第 79条)。这种模式背后的思想是:两次检查域的值,因此名字叫双重检查(double-check),第一次检查时没有锁定,看看这个域是否被初始化了;第二次检查时有锁定。只有当第二次检查时表明这个域没有被初始化,才会对这个域进行初始化。因为如果域已经被初始化就不会有锁定,这个域被声明为 volatile 就很重要了(详见第 78 条)。下面就是这种习惯模式:

1
private FieldType getFieldO){FieldType result = field;if(result ==null){// First check(nolocking)synchronized(this) {if (field == null) // Second check (with locking)field = result = computeFieldValue();子return result;

这段代码可能看起来似乎有些费解。尤其对于需要用到局部变量result可能有点不解。这个变量的作用是确保 fielα 只在已经被初始化的情况下读取一次。虽然这不是严格需要,但是可以提升性能,并且因为给低级的并发编程应用了一些标准,因此更加优雅。在我的机器上,上述的方法比没用局部变量的方法快了大约1.4倍。

虽然也可以对静态域应用双重检查模式,但是没有理由这么做,因为lazy initializationholder class idiom是更好的选择。

双重检查模式的两个变量值得一提。有时可能需要延迟初始化一个可以接受重复初始化的实例域。如果处于这种情况,就可以使用双重检查模式的一个变量,它负责分配第二次检查。没错,它就是单重检查模式(single-check idiom)。下面就是这样的一个例子。注意field 仍然被声明为 volatile:

1
//Single-checkidiom-cancauserepeatedinitialization!private volatile FieldType field;private FieldType getFieldO){FieldType result = field;if (result == nul1)field = result = computeFieldValue();return result;了

查模式(double-check idiom)或者单重检查模式(single-check idiom)应用到数值型的基本类型域时,就会用0来检查这个域(这是数值型基本变量的默认值),而不是用 null。

如果你不在意是否每个线程都重新计算域的值,并且域的类型为基本类型,而不是long 或者 double 类型,就可以选择从单重检查模式的域声明中删除 volatile 修饰符。这种变体称之为 racy single-check idiom。它加快了某些架构上的域访问,代价是增加了额外的初始化(直到访问该域的每个线程都进行一次初始化)。这显然是一种特殊的方法,不适合于日常的使用。

总而言之,大多数的域应该正常地进行初始化,而不是延迟初始化。如果为了达到性迟初始化方法。对于实例域,就使用双重检查模式(double-check idiom);对于静态域,则使用lazy initialization holder class idiom。对于可以接受重复初始化的实例域,也可以考虑使用单重检查模式(single-check idiom)。

第84条:不要依赖于线程调度器

当有多个线程可以运行时,由线程调度器(thread scheduler)决定哪些线程将会运行,以及运行多长时间。任何一个合理的操作系统在做出这样的决定时,都会努力做到公正,但是所采用的策略却大相径庭。因此,编写良好的程序不应该依赖于这种策略的细节。任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能都是不可移植的。

要编写出健壮、响应良好、可移植的多线程应用程序,最好的办法是确保可运行线程的平均数量不明显多于处理器的数量。这使得线程调度器没有更多的选择:它只需要运行这些可运行的线程,直到它们不再可运行为止。即使在根本不同的线程调度算法下,这些程序的行为也不会有很大的变化。注意可运行线程的数量并不等于线程的总数量,前者可能更多。在等待的线程并不是可运行的。

保持可运行线程数量尽可能少的主要方法是,让每个线程做些有意义的工作,然后等待更多有意义的工作。如果线程没有在做有意义的工作,就不应该运行。根据Executor务保持适当得小,彼此独立。任务不应该太小,否则分配的开销也会影响到性能。

线程不应该一直处于忙-等(busy-wait)的状态,即反复地检查一个共享对象,以等待某些事情发生。除了使程序易受到调度器的变化影响之外,忙-等这种做法也会极大地增加处理器的负担,降低了同一机器上其他进程可以完成的有用工作量。作为不该做的一个极端的反面例子,考虑下面这个CountDownLatch的不正当的重新实现:

1
//AwfulCountDownLatch implementation-busy-waitsincessantly!public class SlowCountDownLatch{private int count;public SlowCountDownLatch(int count){if(count <0)throw new IllegalArgumentException(count +"<O");this.count = count;publicvoidawaitO{while(true){synchronized(this) {if(count ==0)return;public synchronized void countDownO {if (count != 0)count--;

在我的机器上,当1000个线程在锁存器(latch)中等待的时候,SlowCountDownLatchJava 自带的 CountDownLatch 比人快了大约10倍。虽然这个例子可能显得有点牵强,但是系统中有一个或者多个线程处于不必要的可运行状态,这种现象并不少见。其性能和可移植性都可能受到损害。

如果某一个程序不能工作,是因为某些线程无法像其他线程那样获得足够的CPU时间,那么,不要企图通过调用Thread.yield来”修正”该程序。你可能好不容易成功地让程序能够工作,但这样得到的程序仍然是不可移植的。同一个 yield 调用在一个 JVM实现上能提高性能,而在另一个JVM实现上却有可能会更差,在第三个JVM实现上则可能没有影响。Thread.yield 没有可测试的语义(testable semantic)。更好的解决办法是重新构造应用程序,以减少可并发运行的线程数量。

有一种相关的方法是调整线程优先级(thread priority),也可以算是一条建议。线程优先级是Java平台上最不可移植的特征了。通过调整某些线程的优先级来改善应用程序的响应能力,这样做并非不合理,却是不必要的,也是不可移植的。通过调整线程的优先级来解决严重的活性问题是不合理的。在你找到并修正底层的真正原因之前,这个问题可能会再次出现。

总而言之,不要让应用程序的正确性依赖于线程调度器。否则,得到的应用程序将既不健壮,也不具有可移植性。同样,不要依赖 Thread.yield 或者线程优先级。这些机制都只是影响到调度器。线程优先级可以用来提高一个已经能够正常工作的程序的服务质量,但永远不应该用来”修正”一个原本并不能工作的程序。

第十二章 序列化

本章讨论对象序列化(object serialization),它是Java 的一个框架,用来将对象编码成字节流(序列化),并从字节流编码中重新构建对象(反序列化)。一旦对象被序列化后,它的编码就可以从一台正在运行的虚拟机被传递到另一台虚拟机上,或者被存储到磁盘上,供后续反序列化时使用。本章主要关注序列化的风险,以及如何将风险降到最低。

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

1997年,Java 新增了序列化时,就被认为是有风险的。这种方法已经作为研究语言(Modula-3)做过尝试,但在生产语言中还从未试过。虽然分布式对象使程序员这部分的工作简化了,为之付出的代价却是不可见的构造器,还有API和实现之间模糊的界限,而且在代码的正确性、性能、安全性、维护性方面都有潜在的问题。拥护者们相信利大于弊,但是历史证明事实正好相反。

在本书第2版中谈到的安全性问题,事实证明每一点滴都可能酿成大祸。2000 年之前广泛讨论的安全漏洞,十年之后酿成了严重的攻击事件(exploit),其中著名的有 2016 年11月发生在旧金山市政交通局(SFMTAMuni)的黑客勒索软件攻击事件,导致整个收费系统整整瘫痪了两天[Gallagher16]。

序列化的根本问题在于,其攻击面(attack surface)过于庞大,无法进行防护,并且它还在不断地扩大:对象图是通过在 ObjectInputStream 上调用 readobject 方法进行反序列化的。这个方法其实是个神奇的构造器,它可以将类路径上几乎任何类型的对象都实例化,只要该类型实现了 Serializablé 接口。在反序列化字节流的过程中,该方法可以执行以上任意类型的代码,因此所有这些类型的代码都是攻击面的一部分。

攻击面包括Java平台类库中的类、第三方类库如ApacheCommons Collections中的类,以及应用本身的类。即便遵守所有相关的最佳实践,成功地编写了无可击的可序列化类,这个应用也依然是有漏洞的。引用CERT(计算机安全应急响应组)协调中心技术经理RobertSeacord的话说:

Java反序列化是一个明显存在的风险,它不仅被应用直接广泛使用,也被Java子系统如RMI(远程方法调用)、JMX(Java管理扩展)和JMS(Java消息系统)等大量地间接使用。将不被信任的流进行反序列化,可能导致远程代码执行(Remote Code Execution,RCE)、拒 绝服务(Denial-of-Service,DoS),以及一系列其他的攻击。即使应用本身没有做错任何事情,也可能受到这些攻击[Seacord17].

攻击者和安全研究员都在研究Java类库和常用的第三方类库中可序列化的类型,寻找在进行潜在危险活动的反序列化期间被调用的方法。这些方法被称作指令片段(gadget)。多现指令片段链的功能十分强大,允许攻击者在底层硬件中执行任意的本机代码,唯一的机会就是为反序列化提交精心编写的字节流。这正是发生在 SFMTA Muni 的攻击。这个攻击不是孤立的,还会有其他的攻击,并且会越来越多。

如果不使用任何指令片段,对于需要长时间进行反序列化的简短字节流,只要引发反序列化,就可以轻松地展开一次拒绝服务攻击。这样的字节流被称作反序列化炸弹(deserializationbomb)[Svoboda16]。下面举一个来自WouterCoekaerts 的范例,它只用了散列集和一个字符串

1
2
3
[Coekaerts 15]:

//Deserializationbomb o-deserializing thisstreamtakesforeverstatic byte[] bombO {Set<Object>root =new HashSet<>(;Set<0bject> s1=root;Set<Object> s2 = new HashSet<>();for(int i=0;i<100;i++){Set<Object> t1 = new HashSet<>(;Set<0bject> t2 = new HashSet<>(;tl.add("foo");// Make tlunequaltot2s1.add(t1);s1.add(t2);s2.add(t1); s2.add(t2);sl = tl;s2= t2;return serialize(root);// Method omitted for brevity了

用。整个字节流的长度为5744个字节,但在反序列化之前,总长度会呈爆炸式增长。问题在于,反序列化 HashSet 实例需要计算其元素的散列码。根散列集合中的这 2个元素,就级之深。因此,反序列化集合会导致hashCode 方法被调用超过 210次。反序列化花费的时间是无限的,而且它从不提示是什么东西出了错。它几乎不产生任何对象,堆栈深度也是有限的。

我们怎么做才能预防这些问题呢?每当反序列化一个不信任的字节流时,自己就要试着去攻击它。避免序列化攻击的最佳方式是永远不要反序列化任何东西。引用1983年电影《WarGames》中Joshua的话:”获胜的唯一方法就是压根儿不参与比赛。”在新编写的任何新系统中都没有理由再使用Java 序列化。为了避免Java序列化的诸多风险,还有许多其他机制可以完成对象和字节序列之间的转化,它们同时还能带来很多便利,诸如跨平台支持、高性能、一个大型的工具生态系统,以及一个广阔的专家社区。本书把这些机制作称跨平台会把它们称作序列化系统,但本书不那样用,避免与Java 序列化造成混淆。

这些表示法的共同点是,它们都远比Java 序列化要简单得多。它们不支持任意对象图的自动序列化和反序列化。而是支持包含属性/值对的简单的结构化数据对象。它们只支持的分布式系统,同时又简单得足以避免自Java序列化出现以来就一直造成困扰的那些重大问题。

最前沿的跨平台结构化数据表示法是JSON[JSON]和ProtocolBuffers,也称作protobuf[Protobuf]。JSON 是 Douglas Crockford为浏览器-服务器之间的通信设计的,Protocol Buffers是Google为了在服务器之间保存和交换结构化数据设计的。尽管有时候这些表示法也被称作中性语言(language-neutral),JSON起初却是为 JavaScript 开发的,protobuf是为 C++开发的,这两者都保持着设计初衷的痕迹。

JSON和 protobuf之间最明显的区别在于,JSON是基于文本的,人类可以阅读,而protobuf是二进制的,从根本上来说更有效;JSON 纯粹就是一个数据表示法,而 protobuf则提供模式(类型),建立文档,强制正确的用法。虽然 protobuf比JSON更加有效,但JSON对于基于文本的表示法却非常高效。protobuf虽然是一个二进制表示法,但它提供了可以替代的另一种文本表示法(pbtxt),当人类需要读懂它的时候可以使用。

如果无法完全避免Java 序列化,或许是因为需要在Java 序列化的遗留系统环境中工作,下一步最好永远不要反序列化不被信任的数据。尤其是永远不应该接受来自不信任资源的RMI通信。Java 官方安全编码指导方针中提出:”对不信任数据的反序列化,从本质上来说是危险的,应该予以避免。”这个句子在文中用红色的字体突出显示,在介绍这一点的整个文档中,只有这一句话[Java-secure]。

如果无法避免序列化,又不能绝对确保被反序列化的数据的安全性,就应利用Java9中新增的对象反序列化过滤(object deserialization filtering),这一功能也已经移植到了 Java 较早的版本(java.io.ObjectInputFilter)。它可以在数据流被反序列化之

前,为它们定义一个过滤器。它可以操作类的粒度,允许接受或者拒绝某些类。默认接受类,同时拒绝可能存在危险的黑名单(blacklisting);默认拒绝类,同时接受假定安全的白名单(whitelisting)。白名单优于黑名单,因为黑名单只能抵御已知的攻击。有一个[Schneiderl6]。过滤设施也能帮助你避免过度使用内存,并广泛深人对象图,但无法防御上面提到过的序列化炸弹。

遗憾的是,序列化在 Java 生态系统中仍然十分普遍。如果在维护的系统是基于Java 序列化的,一定要认真考虑将它迁移到跨平台的结构化数据表示法,尽管这项工作费时费力。现实中,可能会发现仍然需要编写或者维护可序列化的类。要编写出正确、安全和高效的序列化类,需要加倍小心。本章剩下的内容将针对何时以及如何做到上述要求提出专业的建议。

总而言之,序列化是很危险的,应该予以避免。如果是重新设计一个系统,一定要用如果必须这么做,就要使用对象的反序列化过滤,但要注意的是,它并不能确保阻止所有的攻击。不要编写可序列化的类。如果必须这么做,一定要倍加小心地进行试验。

第86条:谨慎地实现Serializable接口

要想使一个类的实例可被序列化,非常简单,只要在它的声明中加人implementsSerializable字样即可。正因为太容易了,所以普遍存在这样一种误解,认为程序员毫不费力就可以实现序列化。而实际的情形要复杂得多。虽然使一个类可被序列化的直接开销非常低,甚至可以忽略不计,但是为了序列化而付出的长期开销往往是相当高的。

实现Serializable接口而付出的最大代价是,一旦一个类被发布,就大大降低了”改变这个类的实现”的灵活性。如果一个类实现了Serializable 接口,它的字节流编码(或者说序列化形式)就变成了它的导出的API的一部分。一旦这个类被广泛使用,往往必须永远支持这种序列化形式,就好像你必须要支持导出的API的所有其他部分一样。如果不努力设计一种自定义的序列化形式(custom serialized form),而仅仅接受了默认的序列化形式,这种序列化形式将被永远地束缚在该类最初的内部表示法上。换句话说,如果接受了默认的序列化形式,这个类中私有的和包级私有的实例域将都变成导出的API的一部分,这不符合”最低限度地访问域”的实践准则(详见第15条),从而它就失去了作为信息隐藏工具的有效性。

如果接受了默认的序列化形式,并且以后又要改变这个类的内部表示法,则结果可能导致序列化形式的不兼容。客户端程序企图用这个类的旧版本来序列化一个类,然后用新版本进行反序列化,结果将导致程序失败。反之亦然。在改变内部表示法的同时仍然维持原来的序列化形式(使用 objectOutputStream.putFields 和 ObjectInputStream.readFields),这也是可能的,但是做起来比较困难,并且会在源代码中留下一些明显的隐患。因此,应该仔细地设计一种高质量的序列化形式,并且在很长时间内都愿意使用这种形式(详见第87条和第90条)。这样做将会增加开发的初始成本,但这是值得的。设计良好的序列化形式也许会给类的演变带来限制;但是设计不好的序列化形式则可能会使类根本无法演变。

序列化会使类的演变受到限制,这种限制的一个例子与流的唯一标识符(stream uniqueidentifier)有关,通常称它为序列版本UID(serialversionUID)。每个可序列化的类都有一个唯一标识号与它相关联。如果你没有在一个名为 serialVersionUID 的私有静态 final的long 域中显式地指定该标识号,系统就会对这个类的结构运用一个加密的散列函数(SHA-1),从而在运行时自动产生该标识号。这个自动产生的值会受到类名称、它所实现的接口的名称,以及所有公有的和受保护的成员的名称所影响。如果你通过任何方式改变了这些信息,比如,增加了一个不是很重要的工具方法,自动产生的序列版本UID也会发生变化。因此,如果你没有声明一个显式的序列版本UID,兼容性将会遭到破坏,在运行时导致 InvalidClassException 异常。

实现Serializable的第二个代价是,它增加了出现Bug和安全漏洞的可能性(详见第85条)。通常情况下,对象是利用构造器来创建的;序列化机制是一种语言之外的对象创建机制(extralinguistic mechanism)。无论你是接受了默认的行为,还是覆盖了默认的行为,反序列化机制(deserialization)都是一个”隐藏的构造器”,具备与其他构造器相同的特点。因为反序列化机制中没有显式的构造器,所以你很容易忘记要保证:反序列化过程必须也要保证所有”由真正的构造器建立起来的约束关系”,并且不允许攻击者访问正在构造过程中的对象的内部信息。依靠默认的反序列化机制,很容易使对象的约束关系遭到破坏,以及遭受到非法访问(见第88条)。

实现Serializable的第三个代价是,随着类发行新的版本,相关的测试负担也会增加。当一个可序列化的类被修订的时候,很重要的一点是,要检查是否可以”在新版本中序列化一个实例,然后在旧版本中反序列化”,反之亦然。因此,测试所需要的工作量与”可序列化的类的数量和发行版本号”的乘积成正比,这个乘积可能会非常大。你必须既要确保”序列化-反序列化”过程成功,也要确保结果产生的对象真正是原始对象的复制品。如果在最初编写一个类的时候,就精心设计了自定义的序列化形式,测试的要求就可以有所降低。

实现Serializable接口并不是一个很轻松就可以做出的决定。如果一个类将要加人到某个框架中,并且该框架依赖于序列化来实现对象传输或者持久化,对于这个类来说,一个组件,并且后者必须实现 Serializable接口,若前者也实现了 Serializable接口,它就会更易于被后者使用。然而,有许多实际的开销都与实现 Serializable接口有关。每当你实现一个类的时候,都需要权衡一下所付出的代价和带来的好处。根据经验,如BigInteger 和Instant 等值类应该实现 Serializable接口,大多数的集合类也应该如此。代表活动实体的类,比如线程池(thread pool),一般不应该实现 Serializable接口。

为了继承而设计的类(详见第19条)应该尽可能少地去实现Serializable接口,用户的接口也应该尽可能少继承Serializable接口。如果违反了这条规则,扩展这个类或者实现这个接口的程序员就会背上沉重的负担。然而在某些情况下违反这条规则却是合适的。例如,如果一个类或者接口存在的目的主要是为了参与到某个框架中,该框架要求所有的参与者都必须实现 Serializable 接口,那么,对于这个类或者接口来说,实现或者扩展 Serializable接口就是非常有意义的。

在为了继承而设计的类中,真正实现了 Serializable接口的有 Throwable类和Component 类。因为 Throwable 类实现了 Serializable 接口,所以 RMI 的异常可以从服务器端传到客户端。Component 类实现了 Serializable 接口,因此 GUI可以被发送、保存和恢复,但是即使在 Swing 和 AWT的鼎盛时间,这个机制在实践中也鲜被使用。

如果实现了一个带有实例域的类,它是可序列化和可扩展的,就应该担心以下几个风险。如果类的实例域值有一些约束条件,重要的是防止子类覆盖 finalize 方法,类只要通过覆盖finalize 并把它声明为 final 便可以完成这个任务。否则,类就很容易受到终结器攻击(finalizer attack),详见第 8条。最后,如果类有限制条件,当类的实例域被初始化成它们的默认值(整数类型为O,boolean 为 false,对象引l用类型为 null)时,就会违背这些约束条件,这时候就必须给这个类添加 readObjectNoData 方法:

1
//readobjectNoDataforstatefulextendableserializableclassesprivate void readObjectNoDataO) throws InvalidObjectException {throw new InvalidobjectException("Stream data required");}

括给现有的可序列化类添加可序列化的超类[Serialization,3.5]。

有一条告诫与”不要实现Serializable接口”有关。如果一个专门为了继承而设计的类不是可序列化的,那么想要编写出可序列化的子类就特别费力。这种类正常的反序列化就要求超类得有一个可访问的无参构造器[Serialization,1.10]。如果没有提供这样的无参构造器,子类就会被迫使用序列化代理模式(serialization proxy patten),详见第 90 条。

内部类(详见第 24条)不应该实现Serializable 接口。它们使用编译器产生的合成域(synthetic field)来保存指向外围实例(enclosing instance)的引用,以及保存来自外围作用域的局部变量的值。”这些域如何对应到类定义中”并没有明确的规定,就好像没有指定匿名类和局部类的名称一样。因此,内部类的默认序列化形式是定义不清楚的。然而,静态成员类(static member class)却可以实现Serializable接口。

简而言之,千万不要认为实现 Serializable 接口会很容易。除非一个类只在受保护的环境下使用,在这里版本之间永远不会交互,服务器永远不会暴露给不可信任的数据,否则,实现 Serializable 接口就是个很严肃的承诺,必须认真对待。如果一个类允许继承,则更要加倍小心。

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

当你在时间紧迫的情况下设计一个类时,一般合理的做法是把工作重心集中在设计最佳的API上。有时候,这意味着要发行一个”用完后即丢弃”的实现,因为你知道以后会在新版本中将它替换掉。正常情况下,这不成问题,但是如果这个类实现了 Serializable接口,并且使用了默认的序列化形式,你就永远无法彻底摆脱那个应该丢弃的实现了。它将永远牵制住这个类的序列化形式。这不只是一个纯理论的问题,在Java平台类库中已经有几个类出现了这样的问题,包括BigInteger类。

如果事先没有认真考虑默认的序列化形式是否合适,则不要贸然接受。接受默认的序列化形式是一个非常重要的决定,需要从灵活性、性能和正确性等多个角度对这种编码形式进行考察。一般来讲,只有当自行设计的自定义序列化形式与默认的序列化形式基本相同时,才能接受默认的序列化形式。

考虑以一个对象为根的对象图,相对于它的物理表示法而言,该对象的默认序列化形式是一种相当有效的编码形式。换句话说,默认的序列化形式描述了该对象内部所包含的数据,以及每一个可以从这个对象到达的其他对象的内部数据。它也描述了所有这些对象被链接起来后的拓扑结构。对于一个对象来说,理想的序列化形式应该只包含该对象所表示的逻辑数据,而逻辑数据与物理表示法应该是各自独立的。

如果一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式。例如,对于下面这些仅仅表示人名的类,默认的序列化形式就是合理的:

1
//Good candidate for default serialized formpublic classNameimplements 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*@serialprivate final String middleName;..// Remainder omitted

从逻辑的角度而言,一个名字通常包含三个字符串,分别代表姓、名和中间名。Name中的实例域精确地反映了它的逻辑内容。

即使你确定了默认的序列化形式是合适的,通常还必须提供一个readobject方法以保证约束关系和安全性。对于这个Name类而言,readObject方法必须确保lastName和firstName是非null的。第88条和第90条将详细讨论这个问题。

注意,虽然 lastName、firstName 和 middleName 域是私有的,但是它们仍然有相应的注释文档。这是因为,这些私有域定义了一个公有的API,即这个类的序列化形式,并且该公有的 API 必须建立文档。@serial 标签告诉 Javadoc 工具,把这些文档信息放在有关序列化形式的特殊文档页中。

下面的类与Name 类不同,它是另一个极端,该类表示了一个字符串列表(此刻我们暂时忽略关于”最好使用标准List实现”的建议):

1
//Awfulcandidatefordefaultserializedformpublic 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

从逻辑意义上讲,这个类表示了一个字符串序列。但是从物理意义上讲,它把该序列表示成一个双向链表。如果你接受了默认的序列化形式,该序列化形式将不遗余力地镜像出(mirror)链表中的所有项,以及这些项之间的所有双向链接。

当一个对象的物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下4个缺点:

口它使这个类的导出API永远地束缚在该类的内部表示法上。在上面的例子中,私有的 StringList.Entry类变成了公有 API的一部分。如果在将来的版本中,内部表示法发生了变化,StringList 类仍需要接受链表形式的输人,并产生链表形式的输出。这个类永远也摆脱不掉维护链表项所需要的所有代码,即使它不再使用链表作为内部数据结构了,也仍然需要这些代码。

口它会消耗过多的空间。在上面的例子中,序列化形式既表示了链表中的每个项,也表示了所有的链接关系,这是不必要的。这些链表项以及链接只不过是实现细节,不值得记录在序列化形式中。因为这样的序列化形式过于庞大,所以,把它写到磁盘中,或者在网络上传输都将非常慢。

口它会消耗过多的时间。序列化逻辑并不了解对象图的拓扑关系,所以它必须要经过一个昂贵的图遍历(traversal)过程。在上面的例子中,沿着 next引l用进行遍历是非常简单的。

口它会引起栈溢出。默认的序列化过程要对对象图执行一次递归遍历,即使对于中等规模的对象图,这样的操作也可能会引起栈溢出。在我的机器上,如果 String-List实例包含1000~1800个元素,对它进行序列化就会产生 StackOverflow-Error栈溢出错误。奇怪的是,到底最低多少个元素会引发栈溢出呢?(在我的机器上)每次运行的结果都不一样。引发问题的最少列表元素数量取决于平台实现以及命令行参数,有些实现可能根本不存在这样的问题。

对于 StringList类,合理的序列化形式可以非常简单,只需先包含链表中字符串的数目,然后紧跟着这些字符串即可。这样就构成了 StringList 所表示的逻辑数据,与它的物理表示细节脱离。下面是 StringList的一个修订版本,它包含 writeObject 和readObject 方法,用来实现这样的序列化形式。顺便提醒一下,transient 修饰符表明这个实例域将从一个类的默认序列化形式中省略掉:

1
2
3
//StringListwithareasonablecustomserializedformpublic final class StringList implements Serializable {private transient int size = 0;private transient Entry head = null;// NolongerSerializable!private static class Entry{String data;Entry next;Entry previous;// Appends the specified string to the listpublic 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.defaultWriteObjectO;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 allelementsand insert them in listfor(inti=0;i<numElements;i++)add((String) s.readObject()) ;...// Remainder omitted

尽管 StringList 的所有域都是瞬时的(transient),但 writeObject 方法的首要任务仍是调用defaultWriteObject,readobject方法的首要任务则是调用defaultRead-Object。如果类的所有实例域都是瞬时的,从技术角度而言,不调用 defaultWrite-Object和 defaultReadObject也是允许的,但是序列化规范依然要求你不管怎样都要调用它们。这样得到的序列化形式允许在以后的发行版本中增加非瞬时的实例域,并且还能保持向前或者向后兼容性。如果某一个实例将在未来的版本中被序列化,然后在前一个版本中被反序列化,那么,后增加的域将被忽略掉。如果旧版本的readobject方法没有调用defaultReadObject,反序列化过程将失败,并引发 StreamCorruptedException 异常。

注意,尽管writeObject方法是私有的,它也有文档注释。这与Name 类中私有域的文档注释是同样的道理。该私有方法定义了一个公有的 API,即序列化形式,并且这个公有的 API应该建立文档。如同域的 @serial 标签一样,方法的 @serialData 标签也告知 Javadoc 工具,要把该文档信息放在有关序列化形式的文档页上。

套用以前对性能的讨论形式,如果平均字符串长度为 10个字符,StringList 修订版本的序列化形式就只占用原序列化形式一半的空间。在我的机器上,同样是10个字符长度的情况下,StringList修订版的序列化速度比原版本的快2倍。最终,修订版中不存在栈溢出的问题,因此,对于可被序列化的 StringList 的大小也没有实际的上限。

虽然默认的序列化形式对于 StringList类来说只是不适合而已,对于有些类,情况却变得更加糟糕。对于StringList,默认的序列化形式不够灵活,并且执行效果不佳,但是序列化和反序列化 StringList 实例会产生对原始对象的忠实拷贝,它的约束关系没有被破坏,从这个意义上讲,这个序列化形式是正确的。但是,如果对象的约束关系要依赖于实现的具体细节,对于它们来说,情况就不是这样了。

例如,考虑散列表的情形。它的物理表示法是一系列包含”键-值”(key-value)项的散列桶。到底一个项将被放在哪个桶中,这是该键的散列码的一个函数,一般情况下,不同的 JVM实现不保证会有同样的结果。实际上,即使在同一个JVM 实现中,也无法保证每次运行都会一样。因此,对于散列表而言,接受默认的序列化形式将会构成一个严重的Bug。对散列表对象进行序列化和反序列化操作所产生的对象,其约束关系会遭到严重的破坏。

无论你是否使用默认的序列化形式,当defaultWriteObject方法被调用的时候,每一个未被标记为transient 的实例域都会被序列化。因此,每一个可以被标记为transient的实例域都应该做上这样的标记。这包括那些冗余的域,即它们的值可以根据其他”基本数据域”计算而得到的域,比如缓存起来的散列值。它也包括那些”其值依赖于 JVM 的某一次运行”的域,比如一个1ong 域代表了一个指向本地数据结构的指针。在决定将一个域做成非瞬时的之前,请一定要确信它的值将是该对象逻辑状态的一部分。如果你正在使用一种自定义的序列化形式,大多数实例域,或者所有的实例域都应该被标记为transient,就像上述例子中的 StringList那样。

如果你正在使用默认的序列化形式,并且把一个或者多个域标记为transient,则要记住,当一个实例被反序列化的时候,这些域将被初始化为它们的默认值(defaultvalue):对于对象引用域,默认值为 null;对于数值基本域,默认值为0;对于boolean 域,默认值为 false[JLS,4.12.5]。如果这些值不能被任何 transient 域所接受,你就必须提供一个readObject 方法,它首先调用 defaultReadobject,然后把这些 transient 域恢复为可被初始化(详见第83条)。

无论你是否使用默认的序列化形式,如果在读取整个对象状态的任何其他方法上强制任何同步,则也必须在对象序列化上强制这种同步。因此,如果你有一个线程安全的对象(详见第82条),它通过同步每个方法实现了它的线程安全,并且你选择使用默认的序列化形式,就要使用下列的 writeObject 方法:

1
//writeObjectforsynchronizedclasswithdefaultserialized formprivate synchronized void writeObject(ObjectOutputStream s)throws IoException{s.defaultWriteObjectO;子

如果把同步放在 writeObject方法中,就必须确保它遵守与其他动作相同的锁排[Goetz06, 10.1.5]。

不管你选择了哪种序列化形式,都要为自己编写的每个可序列化的类声明一个显式的这样做也会带来小小的性能好处。如果没有提供显式的序列版本UID,就需要在运行时通过一个高开销的计算过程来产生一个序列版本UID。

要声明一个序列版本UID非常简单,只要在你的类中增加下面这行代码:

private static final long serialVersionUID = randomLongValue;

在编写新的类时,为randomLongValue选择什么值并不重要。通过在该类上运行serialver工具,你就可以得到一个这样的值,你也可以凭空编造一个数值。如果你想修改一个没有序列版本UID的现有的类,并希望新的版本能够接受现有的序列化实例,就必须使用那个自动为旧版本生成的值。通过在旧版的类上运行 serialver 工具,可以得到这个数值(被序列化的实例为之存在的那个数值)。

如果你想为一个类生成一个新的版本,这个类与现有的类不兼容,那么你只需修改序列版本UID声明中的值即可。前一版本的实例经序列化之后,再做反序列化时会引发InvalidClassException 异常而失败。不要修改序列版本 UiD,否则将会破坏类现有的已被序列化实例的兼容性。

总而言之,当你决定要将一个类做成可序列化的时候(详见第 86 条),请仔细考虑应该采用什么样的序列化形式。只有当默认的序列化形式能够合理地描述对象的逻辑状态时,才能使用默认的序列化形式;否则就要设计一个自定义的序列化形式,通过它合理地描述对象的状态。你应该分配足够多的时间来设计类的序列化形式,就好像分配足够多的时间来设计它的导出方法一样(详见第51条)。正如你无法在将来的版本中去掉导出方法一样,你也不能去掉序列化形式中的域;它们必须被永久地保留下去,以确保序列化兼容性。选择错误的序列化形式对于一个类的复杂性和性能都会有永久的负面影响。

第88条:保护性地编写readObject方法

第 50 条介绍了一个不可变的日期范围类,它包含可变的私有 Date 域。该类通过在其构造器和访问方法(accessor)中保护性地拷贝 Date对象,极力地维护其约束条件和不可变性。该类如下代码所示:

1
// Immutable class that uses defensive copyingpublic 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条)。因此,为了使这个类成为可序列化的,似乎你所需要做的也就是在类的声明中增加 implementsSerializable 字样。然而,如果你真的这样做,那么这个类将不再保证它的关键约束了。

问题在于,readobject方法实际上相当于另一个公有的构造器,如同其他的构造器一样,它也要求警惕同样的所有注意事项。构造器必须检查其参数的有效性(详见第49条),并且在必要的时候对参数进行保护性拷贝(详见第 50 条),同样地,readObject 方法也需要这样做。如果 readobject 方法无法做到这两者之一,对于攻击者来说,要违反这个类的约束条件相对就比较简单了。

不严格地说,readobject方法是一个”用字节流作为唯一参数”的构造器。在正常使用的情况下,对一个正常构造的实例进行序列化可以产生字节流。但是,当面对一个人工仿造的字节流时,readobject 产生的对象会违反它所属的类的约束条件,这时问题就产生了。这种字节流可以用来创建一个不可能的对象(impossible object),这是利用普通的构造器无法创建的。

假设我们仅仅在 Period类的声明中加上了 implements Serializable字样。那么这个不完整的程序将产生一个 Period 实例,它的结束时间比起始时间还要早。对于高阶位 byte 值设置的转换,是因为 Java 缺乏 byte 字面量,并且不幸地决定给 byte 类型做标签,这两个因素联合产生的后果:

1
2
3
//Bytestreamcouldn'thavecomefromarealPeriodinstance!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,0×74,0x69, 0x6c, 0x2f,0x44, 0x61, 0×74, 0x65,0x3b, 0x4c,0x00, 0x05, 0×73, 0x74,0x61,0x72,@ 0×74, 0x71,0x00, 0x7e,0x00, 0x01, ,0x78,0x70,0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e,0x75,0x74,0x69, 0x6c, 0x2e,0x44, 0x61, 0×74, 0x65, ,0x68,0x6a,(byte)0x81, 0x01, 0x4b,0x59, 0x74,0x19, ,0x03,0x00,0x00,0x78,0x70, 0×77, 0x08, 0x00, 0x00,0x00,0x66,( (byte)oxdf,0x6e, 0xle, 0x00, 0×78, 0×73, 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 formstatic Object deserialize(byte[]sf){try{return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject(;

}catch (IOException | ClassNotFoundException e){throw new IllegalArgumentException(e);

被用来初始化 serializedForm 的byte 数组常量是这样产生的:首先对一个正常的Period 实例进行序列化,然后对得到的字节流进行手工编辑。对于这个例子而言,字节流的细节并不重要,但是如果你很好奇,可以在《JavaObjectSerializationSpecification》[Serialization,6]中查到有关序列化字节流格式的描述信息。如果运行这个程序,它会打印出”Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984”。只要把 Period声明成可序列化的,就会使我们创建出违反其类约束条件的对象。

为了修正这个问题,可以为 Period提供一个readobject方法,该方法首先调用defaultReadObject,然后检查被反序列化之后的对象的有效性。如果有效性检查失败,readobject方法就抛出一个InvalidobjectException异常,使反序列化过程不能成功地完成:

1
//readobjectmethod withvalidity checking-insufficient!private void readObject(ObjectInputStream s)throws IoException,ClassNotFoundException {s.defaultReadObject();// Check that our invariants are satisfiedif (start.compareTo(end) >0)throw new InvalidobjectException(start +"after "+ end);

尽管这样的修正避免了攻击者创建无效的Period实例,但是,这里仍然隐藏着一个更为微妙的问题。通过伪造字节流,要想创建可变的 Period 实例仍是有可能的,做法是:的两个私有的 Date 域。攻击者从 ObjectInputStream 中读取 Period 实例,然后读取附加在其后面的”恶意编制的对象引用”。这些对象引l用使得攻击者能够访问到Period对象内部的私有 Date 域所引l用的对象。通过改变这些 Date 实例,攻击者可以改变 Period实例。下面的类演示了这种攻击:

1
2
3
public class MutablePeriod {//A period instancepublic final Period period;// period's start field, to which we shouldn't have accesspublic final Date start;//period's end field,to whichwe shouldn't have accesspublic final Date end;public MutablePeriod(){try{ByteArrayOutputStream bos =new ByteArrayOutputStream();

ObjectOutputStream out=new ObjectOutputStream(bos);//Serializeavalid Period instanceout.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,"S Section 6.4.byte[] ref ={0x71,0,0x7e,0,5};// Ref #5bos.write(ref);// The start fieldref[4]=4; Ref #4bos.write(ref); // The end field// Deserialize Period and "stolen"Date referencesObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArrayO));period =(Period) in.readobject(;start = (Date) in.readObject(;end = (Date) in.readObject();catch (IOException I ClassNotFoundException e){throw new AssertionError(e);

要查看正在进行的攻击,请运行以下程序:

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

在我这里的机器上,运行这个程序,产生的输出结果如下:

1
Wed Nov 2200:21:29PST2017-WedNov 22 00:21:29PST 1978Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969

虽然 Period实例被创建之后,它的约束条件没有被破坏,但是要随意地修改它的内部组件仍然是有可能的。一旦攻击者获得了一个可变的 Period实例,就可以将这个实例传递给一个”安全性依赖于 Period 的不可变性”的类,从而造成更大的危害。这种推断并不牵强:实际上,有许多类的安全性就是依赖于 String 的不可变性。

问题的根源在于,Period 的 readObject 方法并没有完成足够的保护性拷贝。当一个对象被反序列化的时候,对于客户端不应该拥有的对象引用,如果哪个域包含了这样的对象引用,就必须要做保护性拷贝,这是非常重要的。因此,对于每个可序列化的不可变类,如果它包含了私有的可变组件,那么在它的 readobject 方法中,必须要对这些组件进行保护性拷贝。下面的 readobject 方法可以确保 Period 类的约束条件不会遭到破坏,以保持它的不可变性:

1
//readobject methodwithdefensivecopyingandvaliditycheckingprivate void readobject(ObjectInputStream s)throws IoException,ClassNotFoundException {s.defaultReadObject();// Defensively copy ourmutablecomponentsstart = new Date(start.getTimeO);end = new Date(end.getTime());//Check that our invariants aresatisfiedif (start.compareTo(end) >0)throw new InvalidobjectException(start +" after "+ end);

注意,保护性拷贝是在有效性检查之前进行的,而且我们没有使用 Date 的clone 方法来执行保护性拷贝。这两个细节对于保护Period类免受攻击是必要的(详见第50条)。同时也要注意到,对于 final域,保护性拷贝是不可能的。为了使用 readObject 方法,我们必须要将 start 和 énd 域做成非 final 的。这是很遗憾的,但是这还算是相对比较好的做法。有了这个新的 readobject方法,并去掉了 start 和 end 域的 final 修饰符之后,MutablePeriod 类将不再有效。此时,上面的攻击程序会产生如下输出:

Wed Nov2200:23:41PST2017-WedNov2200:23:41PST2017WedNov2200:23:41PST2017-WedNov2200:23:41PST2017

有一个简单的”石蕊”测试,可以用来确定默认的readobject方法是否可以被接受。测试方法:增加一个公有的构造器,其参数对应于该对象中每个非瞬时的域,并且无论参数的值是什么,都是不进行检查就可以保存到相应的域中的。对于这样的做法,你是否会感到很舒适?如果你对这个问题的回答是否定的,就必须提供一个显式的 readobject方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝。另一种方法是,可以使用序列化代理模式(serialization proxy pattern),详见第 90条。强烈建议使用这个模式,因为它分担了安全反序列化的部分工作。

对于非 final的可序列化的类,在 readobject方法和构造器之间还有其他类似的地方。与构造器一样,readobject方法不可以调用可被覆盖的方法,无论是直接调用还是间接调用都不可以(详见第19条)。如果违反了这条规则,并且覆盖了该方法,被覆盖的方法将在子类的状态被反序列化之前先运行。程序很可能会失败[Bloch05,Puzzle 91]。

总而言之,在编写 readObject 方法的时候,都要这样想:你正在编写一个公有的构造器,无论给它传递什么样的字节流,它都必须产生一个有效的实例。不要假设这个字节流一定代表着一个真正被序列化过的实例。虽然在本条目的例子中,类使用了默认的序列化形式,但是,所有讨论到的有可能发生的问题也同样适用于使用自定义序列化形式的类。下面以摘要的形式给出一些指导方针,有助于编写出更加健壮的 readobject方法:

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

第89条:对于实例控制,枚举类型优先于readResolve

第3 条讲述了Singleton(单例)模式,并且给出了以下这个 Singleton 类的示例。这个类限制了对其构造器的访问,以确保永远只创建一个实例:

1
public class Elvis{public static final Elvis INSTANCE = new ElvisO);private ElvisO{...}public void leaveTheBuildingO { ... }子

正如在第3条中提到的,如果这个类的声明中加上了 implements Serializable的字样,它就不再是一个单例。无论该类使用了默认的序列化形式,还是自定义的序列化形式(详见第 87条),都没有关系;也跟它是否提供了显式的 readObject方法(见详第 88条)无关。任何一个 readObject 方法,不管是显式的还是默认的,都会返回一个新建的实例,这个新建的实例不同于该类初始化时创建的实例。

readResolve 特性允许你用 readObject 创建的实例代替另一个实例[Serialization,3.7]。对于一个正在被反序列化的对象,如果它的类定义了一个readResolve 方法,并且具备正确的声明,那么在反序列化之后,新建对象上的 readResolve 方法就会被调用。然后,该方法返回的对象引用将被返回,取代新建的对象。在这个特性的绝大多数用法中,指向新建对象的引用不需要再被保留,因此立即成为垃圾回收的对象。

如果 Elvis 类要实现 Serializable 接口,下面的 readResolve 方法就足以保证它的单例属性:

1
//readResolveforinstancecontrol-youcandobetter!private Object readResolve() {//Returntheonetrue Elvis andlet the garbagecollector// take care of the Elvis impersonator.return INSTANCE;子

该方法忽略了被反序列化的对象,只返回该类初始化时创建的那个特殊的Elvis 实例。因此,Elvis 实例的序列化形式并不需要包含任何实际的数据;所有的实例域都应该被声明为瞬时的。事实上,如果依赖readResolve 进行实例控制,带有对象引用类型的所有实例域则都必须声明为transient。否则,那种破釜沉舟式的攻击者,就有可能在readResolve 方法被运行之前,保护指向反序列化对象的引用,采用的方法类似于在第88条中提到过的 MutablePeriod 攻击。

这种攻击有点复杂,但是背后的思想却很简单。如果单例包含一个非瞬时的对象引用域,这个域的内容就可以在单例的 readResolve 方法运行之前被反序列化。当对象引用域的内容被反序列化时,它就允许一个精心制作的流”盗用”指向最初被反序列化的单例的引用。

以下是它更详细的工作原理。首先,编写一个”盗用者”类,它既有readResolver含”盗用者”类,”盗用者”类则引用这个单例。

由于单例包含”盗用者”类,当这个单例被反序列化时,”盗用者”类的readResolve方法先运行。因此,当”盗用者”的 readResolve 方法运行时,它的实例域仍然引用被部分反序列化(并且也还没有被解析)的 Singleton。

“盗用者”的readResolve 方法从它的实例域中将引l用复制到静态域中,以便该引l用可以在 readResolve 方法运行之后被访问到。然后这个方法为它所藏身的那个域返回一个正确的类型值。如果没有这么做,当序列化系统试着将”盗用者”引用保存到这个域中时,虚拟机就会抛出 ClassCastException。

为了更具体地说明这一点,我们以下面这个有问题的单例为例:

1
// Broken singleton - has nontransient object reference field!public class Elvis implements Serializable {public static final Elvis INSTANCE = new ElvisO);private Elvis() {}private String[] favoriteSongs ={"Hound Dog","Heartbreak Hotel"};public void printFavorites( {System.out.println(Arrays.toString(favoriteSongs));private Object readResolve() {return INSTANCE;

如下”盗用者”类,是根据上述的描述构造的:

1
2
3
public class ElvisStealer implements Serializable{static Elvis impersonator;private Elvis payload;private Object readResolve() {

//Saveareference tothe"unresolved"Elvis instanceimpersonator = payload;//Return object of correct type for favoriteSongs fieldreturn new String[] {"A Fool Such as I"};private static final long serialVersionUID=0;

下面是一个不完整的程序,它反序列化一个手工制作的流,为那个有缺陷的单例产生两个截然不同的实例。这个程序中省略了反序列化方法,因为它与第88条中的一样:

1
public class ElvisImpersonator {//Bytestreamcouldn't havecome from arealElvisinstance!private static final byte[] serializedForm = {(byte)oxac, (byte)0xed,0x00,0x05,0x73,0x72,0x00,0x05,0x45,0x6c, 0x76,0x69, 0×73, (byte)0x84, (byte)0xe6,(byte)0x93, 0x33, (byte)0xc3, (byte)0xf4, (byte)0x8b,0x32, 0x02, 0x00, 0x01, 0x4c, 0x00, Ox0d, 0x66, 0x61, 0x76,0x6f, 0x72, 0x69, 0×74, 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, 0×70, 0×73, 0×72, 0x00, OxOc, 0x45, 0x6c, 0x76,0x69, 0×73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0×72, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0×00, 0x01,0x4c,0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,0x74; 0x00, 0×07,0x4c, 0x45,0x6c,0x76, 0x69,0x73,0x3b,0x78,0x70,0x71,0x00,0x7e,0x00,0x02public 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 实例(包含两种不同的音乐品位):

1
# [Hound Dog, Heartbreak Hotel][A Fool Such as I]

通过将favoriteSongs 域声明为transient,可以修正这个问题,但是最好把Elvis 做成是一个单元素的枚举类型(详见第3条)。就如 ElvisStealer攻击所示范的,用readResolve方法防止”临时”被反序列化的实例受到攻击者的访问,这种方法很脆弱,需要万分谨慎。

如果将一个可序列化的实例受控的类编写成枚举,Java 就可以绝对保证除了所声明的常量之外,不会有其他实例,除非攻击者恶意地使用了享受特权的方法,如Accessible-Object.setAccessible。能够做到这一点的任何一位攻击者,已经具备了足够的特权来执行任意的本地代码,后果不堪设想。将 Elvis 写成枚举的例子如下所示:

// Enum singleton - the preferred approachpublic enum Elvis {INSTANCE;private String[] favoriteSongs ={“Hound Dog”,”Heartbreak Hotel”};public void printFavoritesO {System.out.println(Arrays.toString(favoriteSongs));

用readResolve 进行实例控制并不过时。如果必须编写可序列化的实例受控的类,在编译时还不知道它的实例,你就无法将类表示成一个枚举类型。

readResolve 的可访问性(accessibility)很重要。如果把readResolve方法放在一个final类上,它就应该是私有的。如果把 readResolver方法放在一个非 final类上,就必须认真考虑它的可访问性。如果它是私有的,就不适用于任何子类。如果它是包级私有的,就只适用于同一个包中的子类。如果它是受保护的或者公有的,就适用于所有没有覆盖它的子类。如果 readResolve 方法是受保护的或者是公有的,并且子类没有覆盖它,对序列化过的子类实例进行反序列化,就会产生一个超类实例,这样有可能导致ClassCastException 异常。

总而言之,应该尽可能地使用枚举类型来实施实例控制的约束条件。如果做不到,同时又需要一个既可序列化又是实例受控的类,就必须提供一个 readResolver 方法,并确保该类的所有实例域都为基本类型,或者是瞬时的。

第90条:考虑用序列化代理代替序列化实例

正如第 85 条和第 86 条中提到的,以及本章一直在讨论的,决定实现 Serializable接口,会增加出错和出现安全问题的可能性,因为它允许利用语言之外的机制来创建实例,而不是用普通的构造器。然而,有一种方法可以极大地减少这些风险。这种方法就是序列化代理模式(serialization proxy pattern)。

序列化代理模式相当简单。首先,为可序列化的类设计一个私有的静态嵌套类,精确地表示外围类的实例的逻辑状态。这个嵌套类被称作序列化代理(serialization proxy),它应该有一个单独的构造器,其参数类型就是那个外围类。这个构造器只从它的参数中复制数据:它不需要进行任何一致性检查或者保护性拷贝。从设计的角度来看,序列化代理的默认序列化形式是外围类最好的序列化形式。外围类及其序列代理都必须声明实现Serializable接口。

例如,以第50条中编写的不可变的Period类为例,它在第88条中被做成可序列化的。以下是这个类的一个序列化代理。Period 类是如此简单,以致它的序列化代理有着与类完全相同的域:

1
//SerializationproxyforPeriodclassprivate staticclass 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 wi11 do (Item 87)

接下来,将下面的 writeReplace 方法添加到外围类中。通过序列化代理,这个方法可以被逐字地复制到任何类中:

1
2
3
4
//writeReplace method for the serialization proxy patternprivate Object writeReplace() {return new SerializationProxy(this);

}

的实例。换句话说,writeReplace 方法在序列化之前,将外围类的实例转变成了它的序列化代理。

有了 writeReplace 方法之后,序列化系统永远不会产生外围类的序列化实例,但是攻击者有可能伪造,企图违反该类的约束条件。为了防御此类攻击,只要在外围类中添加如下 readobject 方法即可:

1
//readobject method for the serialization proxy patternprivate void readobject(ObjectInputStream stream)throws InvalidobjectException {throw new InvalidobjectException("Proxy required");

最后,在 SerializationProxy类中提供一个 readResolve 方法,它返回一个逻辑上相当的外围类的实例。这个方法的出现,导致序列化系统在反序列化时将序列化代理转变回外围类的实例。

这个 readResolve 方法仅仅利用它的公有 API 创建外围类的一个实例,这正是该模式的魅力所在。它极大地消除了序列化机制中语言本身之外的特征,因为反序列化实例是利用与任何其他实例相同的构造器、静态工厂和方法而创建的。这样你就不必单独确保被反序列化的实例一定要遵守类的约束条件。如果该类的静态工厂或者构造器建立了这些约束条件,并且它的实例方法在维持着这些约束条件,你就可以确信序列化也会维持这些约束条件。

以下是上述 Period.SerializationProxy 的 readResolve 方法:

1
//readResolve method for Period.SerializationProxyprivate Object readResolve() {return new Period(start,end);// Uses public constructon了

正如保护性拷贝方法一样(详见第88条),序列化代理方法可以阻止伪字节流的攻击(详见第88条)以及内部域的盗用攻击(详见第88条)。与前两种方法不同,这种方法允许 Period 类的域为 final 的,为了确保 Period类真正是不可变的(详见第17条),这一点很有必要。与前两种方法不同的还有,这种方法不需要太费心思。你不必知道哪些域可能受到狡猾的序列化攻击的威胁,你也不必显式地执行有效性检查,作为反序列化的一部分。

还有另外一种方法,使用这种方法时,序列化代理模式的功能比保护性拷贝的更加强大。序列化代理模式允许反序列化实例有着与原始序列化实例不同的类。你可能认为这在实际应用中没有什么作用,其实不然。

以EnumSet的情况为例(详见第36条)。这个类没有公有的构造器,只有静态工厂。从客户端的角度来看,它们返回 EnumSet 实例,但是在目前的OpenJDK实现中,它们是返回两种子类之一,具体取决于底层枚举类型的大小。如果底层的枚举类型有64个一一JumboEnumSet.

现在考虑这种情况:如果序列化一个枚举集合,它的枚举类型有60个元素,然后给这个枚举类型再增加5个元素,之后反序列化这个枚举集合。当它被序列化的时候,是一个RegularEnumSet实例,但是一旦它被反序列化,它最好是一个JumboEnumSet实例。实际发生的情况正是如此,因为 EnumSet使用序列化代理模式。如果你有兴趣,可以看看如下的EnumSet序列化代理,它实际上就这么简单:

1
//EnumSet's serializationproxyprivate static class SerializationProxy <E extends Enum<E>>implements Serializable {// The element type ofthis enum set.private final Class<E> elementType;// The elements contained in this enum set.private final Enum<?>[] elements;SerializationProxy(EnumSet<E> set) {elementType = set.elementType;elements = set.toArray(new Enum<?>[0]);private Object readResolve(){EnumSet<E> result = EnumSet.noneOf(elementType);for (Enum<?> e : elements)result.add((E)e);return result;

序列化代理模式有两个局限性。它不能与可以被客户端扩展的类相兼容(详见第19条)。它也不能与对象图中包含循环的某些类相兼容:如果你企图从一个对象的序列化代理的readResolve方法内部调用这个对象中的方法,就会得到一个ClassCast-Exception异常,因为你还没有这个对象,只有它的序列化代理。

最后一点,序列化代理模式所增强的功能和安全性并不是没有代价的。在我的机器上,加了14%

总而言之,当你发现自己必须在一个不能被客户端扩展的类上编写readobject或者writeObject方法时,就应该考虑使用序列化代理模式。要想稳健地将带有重要约束条件的对象序列化时,这种模式可能是最容易的方法。

与第2版中条目的对应关系

参考文献

[Asserts] Programming with Assertions. 2002. Sun Microsystems.http://docs.oracle.com/javase/8/docs/technotes/guides/language /assert.html

[Beck04] Beck, Kent. 2004. JUnit Pocket Guide. Sebastopol, CA: O’ReillyMedia, Inc. ISBN: 0596007434.

[Bloch01] Bloch, Joshua. 2001. Effective Java Programming LanguageGuide. Boston: Addison-Wesley. ISBN: 0201310058.

[Bloch05] Bloch, Joshua, and Neal Gafter. 2005. Java Puzzlers: Traps,Pitfalls, and Corner Cases. Boston: Addison-Wesley.ISBN: 032133678X.

[Blum14] Blum, Scott. 2014. “Faster RSA in Java with GMP? The SquareCorner (blog). Feb. 14, 2014. https://medium.com/square-corner-blog/faster-rsa-in-java-with-gmp-8b13c51c6ec4

[Bracha04] Bracha, Gilad. 2004.”Lesson: Generics”” online supplement to TheJava Tutorial: A Short Course on the Basics, 6th ed. Upper SaddleRiver, NJ: Addison-Wesley, 2014. https://docs.oracle.com/javase/tutorial/extra/generics/

[Burn01] Burn, Oliver. 2001-2017. Checkstyle.http:/checkstyle.sourceforge.net

[Coekaerts 15] Coekaerts, Wouter (@WouterCoekaerts). 2015. “Billion-laughs-style DoS for Java serialization https://gist.github.com/coekie/a27cc406fc9f3dc7a70d … WONTFIX, Twitter, November 9,2015, 9:46 a.m. https://twitter.com/woutercoekaerts/status/663774695381078016

[CompSci17] Brief of Computer Scientists as Amici Curiae for the United StatesCourt of Appeals for the Federal Circuit, Case No. 17-1118, OracleAmerica, Inc. v. Google, Inc. in Support of Defendant-Appellee.(2017)

[Dagger] Dagger. 2013. Square, Inc. http://square.github.io/dagger/[Guava] Guava. 2017. Google Inc. https://github.com/google/guava[Guice] Guice. 2006. Google Inc. https://github.com/google/guice[Javadoc-ref] Javadoc Reference Guide. 2014-2017. Oracle.

[Gallagher16] Gallagher, Sean. 2016. “Muni system hacker hit others by scanningfor year-old Java vulnerability. Ars Technica, November 29, 2016.https://arstechnica.com/information-technology/2016/11/san-francisco-transit-ransomware-attacker-likely-used-year-old-java-exploit/

[Gamma95] Gamma, Erich, Richard Helm, Ralph Johnson, and John Vlissides.1995. Design Patterns: Elements of Reusable Object-OrientedSoftware. Reading, MA: Addison-Wesley. ISBN: 0201633612.

[Goetz06] Goetz, Brian. 2006. Java Concurrency in Practice. With TimPeierls, Joshua Bloch, Joseph Bowbeer, David Holmes, and DougLea. Boston: Addison-Wesley. ISBN: 0321349601.

[Gosling97] Gosling, James. 1997. “The Feel of Java.’ Computer 30 no. 6 (June1997): 53-57. http://dx.doi.org/10.1109/2.587548

[Herlihy12] Herlihy, Maurice, and Nir Shavit. 2012. The Art of MultiprocessorProgramming, Revised Reprint. Waltham, MA: Morgan KaufmannPublishers. ISBN: 0123973376.

[Jackson75] Jackson, M. A. 1975. Principles of Program Design. London:Academic Press. ISBN: 0123790506.

[Java-secure] Secure Coding Guidelines for Java SE. 2017. Oracle. http://www.oracle.com/technetwork/java/seccodeguide-139067.html[Java8-feat] What’s New in JDK 8. 2014. Oracle. http://www.oracle.com /technetwork/java/javase/8-whats-new-2157071.html

[Java9-feat] Java Platform, Standard Edition What’s New in Oracle JDK 9.2017. Oracle. https://docs.oracle.com/javase/9/whatsnew/toc.htm[Java9-api] Java Platform, Standard Edition & Java Development Kit Version9 API Specification. 2017. Oracle. https://docs.oracle.com /javase/9/docs/api/overview-summary.html

[Javadoc-guide] How to Write Doc Comments for the Javadoc Tool. 2000-2004Sun Microsystems. http://www.oracle.com/technetwork/java/javase/documentation/index-137868.html

https://docs.oracle.com/javase/9/javadoc/javadoc.htm[JLS] Gosling, James, Bill Joy, Guy Steele, and Gilad Bracha. 2014. TheJava Language Specification, Java SE 8 Edition. Boston: Addison-Wesley. ISBN: 013390069X.[JMH] Code Tools: jmh. 2014. Oracle.http://openjdk.java.net/projects/code-tools/jmh/[JSON] Introducing JSON. 2013. Ecma International. https://www.json.org[Kahan91] Kahan, William, and J. W. Thomas. 1991. Augmenting aProgramming Language with Complex Arithmetic.UCB/CSD-91-667, University of California, Berkeley.[Knuth74] Knuth, Donald. 1974. Structured Programming with go toStatements. In Computing Surveys 6: 261-301.[Lea14] Lea, Doug. 2014. When to use parallel streams.http://gee.cs.oswego.edu/dl/html/StreamParallelGuidance.html[Lieberman86] Lieberman, Henry. 1986. Using Prototypical Objects to ImplementShared Behavior in Object-Oriented Systems. In Proceedings ofthe First ACM Conference on Object-Oriented ProgrammingSystems, Languages, and Applications, pages 214223, Portland,September 1986. ACM Press.[Liskov87] Liskov, B. 1988. Data Abstraction and Hierarchy. In Addendum tothe Proceedings of OOPSLA ‘87 and SIGPLAN Notices, Vol. 23,No. 5: 17-34, May 1988.[Naftalin07] Naftalin, Maurice, and Philip Wadler. 2007. Java Generics andCollections. Sebastopol, CA: O’Reilly Media, Inc.ISBN: 0596527756.[Parnas72] Parnas, D. L. 1972. On the Criteria to Be Used in DecomposingSystems into Modules. In Communications of the ACM 15: 1053-1058.[POSIX] 9945-1:1996 (ISO/IEC) [IEEE/ANSI Std. 1003.1 1995 Edition]Information Technology—-Portable Operating System Interface(POSIX)—Part 1: System Application: Program Interface (API) CLanguage] (ANSI), IEEE Standards Press, ISBN: 1559375736.[Protobuf] Protocol Buffers. 2017. Google Inc.https://developers.google.com/protocol-buffers[Schneider16] Schneider, Christian. 2016. SWAT (Serial Whitelist Application

[Seacord17] Seacord, Robert. 2017. Combating Java DeserializationVulnerabilities with Look-Ahead Object Input Streams (LAOIS).San Francisco: NCC Group Whitepaper.https://www.nccgroup.trust/globalassets/our-research/us/whitepapers/2017/june/ncc_group_combating_java_deserialization.vulnerabilities_with_look-ahead_object_input_streams1.pdf

[Serialization] Java Object Serialization Specification. March 2005. SunMicrosystems. http://docs.oracle.com/javase/9/docs/specs /serialization/index.html

[Sestoft16] Sestoft, Peter. 2016. Java Precisely, 3rd ed. Cambridge, MA: TheMIT Press. ISBN: 0262529076.

[Shipilev16] Aleksey Shipilev. 2016. Arrays of Wisdom of the Ancients.https://shipilev.net/blog/2016/arrays-wisdom-ancients/

[Smith62] Smith, Robert. 1962. Algorithm 116 Complex Division.In Communications of the ACM 5, no. 8 (August 1962): 435.[Snyder86] Snyder, Alan. 1986. “Encapsulation and Inheritance in Object-Oriented Programming Languages.” In Object-OrientedProgramming Systems, Languages, and Applications ConferenceProceedings, 38-45. New York, NY: ACM Press.

[Spring] Spring Framework. Pivotal Software, Inc. 2017.https://projects.spring.io/spring-framework/

[Stroustrup] Stroustrup, Bjarne. [ca. 2000]. “Is Java the language you wouldhave designed if you didn’t have to be compatible with C? BjarneStroustrup’s FAQ. Updated Ocober 1, 2017.http://www.stroustrup.com/bs_faq.html#Java

[Stroustrup95] Stroustrup, Bjarne. 1995. “”Why C++ is not just an object-orientedprogramming language.” In Addendum to the proceedings of the10th annual conference on Object-oriented programming systems,languages, and applications, edited by Steven Craig Bilow andPatricia S. Bilow New York, NY: ACM.http://dx.doi.org/10.1145/260094.260207

[Svoboda16] Svoboda, David. 2016. Exploiting Java Serialization for Fun andProfit. Software Engineering Institute, Carnegie Mellon University.https://resources.sei.cmu.edu/library/asset-view.cfm?assetid=484347[Thomas94] Thomas, Jim, and Jerome T. Coonen. 1994. “Issues RegardingImaginary Types for C and C++.” In The Journal of C Language

Translation 5, no. 3 (March 1994): 134-138.

[ThreadStop] Why Are Thread.stop, Thread.suspend, Thread. resume andRuntime.runFinalizers0nExit Deprecated? 1999. SunMicrosystems. https://docs.oracle.com/javase/8/docs/technotes/guides/concurrency/threadPrimitiveDeprecation.html

[Viega01] Viega, John, and Gary McGraw. 2001. Building Secure Software:How to Avoid Security Problems the Right Way. Boston: Addison-Wesley. ISBN: 020172152X.

[W3C-validator] W3C MarkupValidation Service. 2007. World Wide WebConsortium. http://validator.w3.org/

[Wulf72] Wulf, W. A Case Against the GOTO. 1972. In Proceedings of the25th ACM National Conference 2: 791-797. New York, NY: ACMPress.

推荐阅读

Effective系列

作者简介

JoshuaBloch美国卡内基-梅隆大学教授,曾是Google公司首席Java架构师、Sun公司杰出工程师和Transarc公司高级系统设计师。他带领团队设计和实现过无数的Java平台特性,包括JDK5.0语言增强版和获奖的JavaCollectionsFramework。他拥有哥伦比亚大学的计算机科学学士学位和卡内基-梅隆大学的计算机科学博士学位。他的著作还包括《JavaPuzzlers》和《Java并发编程实战》(曾获Jolt大奖提名)等。

译者简介

俞黎敏(YuLimin,网名:阿敏总司令)2008年7月加入IBM广州分公司,担任高级技术顾问,主要负责WebSphere系列产品以及云计算、物联网相关的技术支持工作,专注于产品新特性、系统性能调优、疑难问题诊断与解决。开源爱好者,曾参与Spring中文论坛组织的《Spring 2.0Reference》中文翻译的一审和二审,满江红开放技术研究组织启动的《Seam1.2.1Reference》的中文翻译,组织完成了《Seam 2.0Reference》的中文翻译工作。CSDN、CJSDN、Dev2Dev、Matrix、JavaWorldTW、Spring中文等Java论坛的版主,在各大技术社区为推动开源和敏捷开发做出了积极的贡献。翻译、审校过多本图书。

个人网站:http://www.Java2Class.net博客 : http://blog.csdn.net/YuLimin/

Effective Java

本书第2版是在Java6发行之后不久出版的,此后Java又发生了巨大的变化。这本Jolt获奖作品现在已经针对Java7、8、9进行了全面的更新,充分展示了新的Java编程语言及其类库特性。当前的Java编程语言支持多种泛型,这使得Java程序员迫切需要具体的实践建议一—本书正是为此而生的。

提出了具体的建议,揭示了Java平台的精妙之处,并更新了之前的代码范例。每个条目的综合描述和解释都阐明了应该怎么做,不应该怎么做,以及为什么。

本书的内容涵盖了Java7、8和9中新增的语言及类库特性,包括在其面向对象根部添加的函数编程构造。书中

本书新增内容包括:

函数接口、Lambda表达式、方法引用,以及StreamAPI?接口中的缺省方法和静态方法①类型推导,包括泛型的Diamond操作符@SafeVarargs注解try-with-resources语句新增的类库特性,如Optional接口、java.time包,以及集合的便利工厂方法

Pearson

www.pearson.com

投稿热线:(010) 88379604客服热线:(010)88379426 88361066购书热线:(010) 68326294 88379649 68995259华章网站:www.hzbook.com网上购书:www.china-pub.com|数字阅读:www.hzmedia.com.cn

  • 本文标题:读《Effective Java》后六章
  • 本文作者:形而上
  • 创建时间:2021-12-16 08:00:00
  • 本文链接:https://deepter.gitee.io/2021_12_16_effective_java/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!