杨柳亭

杨柳亭

27. 消除非检查警告

27. 消除非检查警告

使用泛型编程时,会看到许多编译器警告:未经检查的强制转换警告,未经检查的方法调用警告,未经检查的参数化可变长度类型警告以及未经检查的转换警告。 你使用泛型获得的经验越多,获得的警告越少,但不要期望新编写的代码能够干净地编译。

许多未经检查的警告很容易消除。 例如,假设你不小心写了以下声明:

1
Set<Lark> exaltation = new HashSet();

编译器会提醒你你做错了什么:

1
2
3
4
5
Venery.java:4: warning: [unchecked] unchecked conversion
Set<Lark> exaltation = new HashSet();
^
required: Set<Lark>
found: HashSet。

然后可以进行指示修正,让警告消失。 请注意,实际上并不需要指定类型参数,只是为了表明它与 Java 7 中引入的钻石运算符(「<>」)一同出现。然后编译器会推断出正确的实际类型参数(在本例中为 Lark):

1
Set<Lark> exaltation = new HashSet<>();

但一些警告更难以消除。 本章充满了这种警告的例子。 当你收到需要进一步思考的警告时,坚持不懈! 尽可能地消除每一个未经检查的警告。 如果你消除所有的警告,你可以放心,你的代码是类型安全的,这是一件非常好的事情。 这意味着在运行时你将不会得到一个 ClassCastException 异常,并且增加了你的程序将按照你的意图行事的信心。

如果你不能消除警告,但你可以证明引发警告的代码是类型安全的,那么(并且只能这样)用 ​​**@SuppressWarnings("unchecked")​ 注解来抑制警告。** 如果你在没有首先证明代码是类型安全的情况下压制警告,那么你给自己一个错误的安全感。 代码可能会在不发出任何警告的情况下进行编译,但是它仍然可以在运行时抛出 ClassCastException 异常。 但是,如果你忽略了你认为是安全的未经检查的警告(而不是抑制它们),那么当一个新的警告出现时,你将不会注意到这是一个真正的问题。 新出现的警告就会淹没在所有的错误警告当中。

SuppressWarnings 注解可用于任何声明,从单个局部变量声明到整个类。 始终在尽可能最小的范围内使用 SuppressWarnings 注解。 通常这是一个变量声明或一个非常短的方法或构造方法。 切勿在整个类上使用 SuppressWarnings 注解。 这样做可能会掩盖重要的警告。

如果你发现自己在长度超过一行的方法或构造方法上使用 SuppressWarnings 注解,则可以将其移到局部变量声明上。 你可能需要声明一个新的局部变量,但这是值得的。 例如,考虑这个来自 ArrayListtoArray 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public <T> T[] toArray(T[] a) {
if (a.length < size)
return (T[]) Arrays.copyOf(elements, size, a.getClass());
System.arraycopy(elements, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}

如果编译 ArrayList 类,则该方法会生成此警告:
ArrayList.java:305: warning: [unchecked] unchecked cast
return (T[]) Arrays.copyOf(elements, size, a.getClass());
^
required: T[]
found: Object[]

在返回语句中设置 SuppressWarnings 注解是非法的,因为它不是一个声明[JLS,9.7]。 你可能会试图把注释放在整个方法上,但是不要这要做。 相反,声明一个局部变量来保存返回值并标注它的声明,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Adding local variable to reduce scope of @SuppressWarnings
public <T> T[] toArray(T[] a) {
if (a.length < size) {
// This cast is correct because the array we're creating
// is of the same type as the one passed in, which is T[].
@SuppressWarnings("unchecked") T[] result =
(T[]) Arrays.copyOf(elements, size, a.getClass());
return result;
}
System.arraycopy(elements, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}

所产生的方法干净地编译,并最小化未经检查的警告被抑制的范围。

每当使用 ​​**@SuppressWarnings(“unchecked”)​ 注解时,请添加注释,说明为什么是安全的。** 这将有助于他人理解代码,更重要的是,这将减少有人修改代码的可能性,从而使计算不安全。 如果你觉得很难写这样的注释,请继续思考。 毕竟,你最终可能会发现未经检查的操作是不安全的。

总之,未经检查的警告是重要的。 不要忽视他们。 每个未经检查的警告代表在运行时出现 ClassCastException 异常的可能性。 尽你所能消除这些警告。 如果无法消除未经检查的警告,并且可以证明引发该警告的代码是安全类型的,则可以在尽可能小的范围内使用 @SuppressWarnings(“unchecked”) 注解来禁止警告。 记录你决定在注释中抑制此警告的理由。

38. 使用接口模拟可扩展的枚举

38. 使用接口模拟可扩展的枚举

在几乎所有方面,枚举类型都优于本书第一版中描述的类型安全模式[Bloch01]。 从表面上看,一个例外涉及可扩展性,这在原始模式下是可能的,但不受语言结构支持。 换句话说,使用该模式,有可能使一个枚举类型扩展为另一个;使用语言功能特性,它不能这样做。 这不是偶然的。 大多数情况下,枚举的可扩展性是一个糟糕的主意。 令人困惑的是,扩展类型的元素是基类型的实例,反之亦然。 枚举基本类型及其扩展的所有元素没有好的方法。 最后,可扩展性会使设计和实现的很多方面复杂化。

也就是说,对于可扩展枚举类型至少有一个有说服力的用例,这就是操作码(operation codes),也称为 opcodes。 操作码是枚举类型,其元素表示某些机器上的操作,例如条目 34 中的 Operation 类型,它表示简单计算器上的功能。 有时需要让 API 的用户提供他们自己的操作,从而有效地扩展 API 提供的操作集。

幸运的是,使用枚举类型有一个很好的方法来实现这种效果。基本思想是利用枚举类型可以通过为 opcode 类型定义一个接口,并实现任意接口。例如,这里是来自条目 34 的 Operation 类型的可扩展版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Emulated extensible enum using an interface
public interface Operation {
double apply(double x, double y);
}

public enum BasicOperation implements Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};
private final String symbol;

BasicOperation(String symbol) {
this.symbol = symbol;
}

@Override public String toString() {
return symbol;
}
}

虽然枚举类型(BasicOperation)不可扩展,但接口类型(Operation)是可以扩展的,并且它是用于表示 API 中的操作的接口类型。 你可以定义另一个实现此接口的枚举类型,并使用此新类型的实例来代替基本类型。 例如,假设想要定义前面所示的操作类型的扩展,包括指数运算和余数运算。 你所要做的就是编写一个实现 Operation 接口的枚举类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Emulated extension enum
public enum ExtendedOperation implements Operation {
EXP("^") {
public double apply(double x, double y) {
return Math.pow(x, y);
}
},
REMAINDER("%") {
public double apply(double x, double y) {
return x % y;
}
};

private final String symbol;

ExtendedOperation(String symbol) {
this.symbol = symbol;
}

@Override public String toString() {
return symbol;
}
}

只要 API 编写为接口类型(Operation),而不是实现(BasicOperation),现在就可以在任何可以使用基本操作的地方使用新操作。请注意,不必在枚举中声明 apply 抽象方法,就像您在具有实例特定方法实现的非扩展枚举中所做的那样(第 162 页)。 这是因为抽象方法(apply)是接口(Operation)的成员。

不仅可以在任何需要「基本枚举」的地方传递「扩展枚举」的单个实例,而且还可以传入整个扩展枚举类型,并使用其元素。 例如,这里是第 163 页上的一个测试程序版本,它执行之前定义的所有扩展操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(ExtendedOperation.class, x, y);
}


private static <T extends Enum<T> & Operation> void test(
Class<T> opEnumType, double x, double y) {
for (Operation op : opEnumType.getEnumConstants())
System.out.printf("%f %s %f = %f%n",
x, op, y, op.apply(x, y));
}

注意,扩展的操作类型的类字面文字(ExtendedOperation.class)从 main 方法里传递给了 test 方法,用来描述扩展操作的集合。这个类的字面文字用作限定的类型令牌(详见第 33 条)。opEnumType 参数中复杂的声明(<T extends Enum<T> & Operation> Class<T>)确保了 Class 对象既是枚举又是 Operation 的子类,这正是遍历元素和执行每个元素相关联的操作时所需要的。

第二种方式是传递一个 Collection<? extends Operation>,这是一个限定通配符类型(详见第 31 条),而不是传递了一个 class 对象:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(Arrays.asList(ExtendedOperation.values()), x, y);
}

private static void test(Collection<? extends Operation> opSet,
double x, double y) {
for (Operation op : opSet)
System.out.printf("%f %s %f = %f%n",
x, op, y, op.apply(x, y));
}

生成的代码稍微不那么复杂,test 方法灵活一点:它允许调用者将多个实现类型的操作组合在一起。另一方面,也放弃了在指定操作上使用 EnumSet(详见第 36 条)和 EnumMap(详见第 37 条)的能力。

上面的两个程序在运行命令行输入参数 4 和 2 时生成以下输出:

1
2
4.000000 ^ 2.000000 = 16.000000
4.000000 % 2.000000 = 0.000000

使用接口来模拟可扩展枚举的一个小缺点是,实现不能从一个枚举类型继承到另一个枚举类型。如果实现代码不依赖于任何状态,则可以使用默认实现(详见第 20 条)将其放置在接口中。在我们的 Operation 示例中,存储和检索与操作关联的符号的逻辑必须在 BasicOperationExtendedOperation 中重复。在这种情况下,这并不重要,因为很少的代码是冗余的。如果有更多的共享功能,可以将其封装在辅助类或静态辅助方法中,以消除代码冗余。

该条目中描述的模式在 Java 类库中有所使用。例如,java.nio.file.LinkOption 枚举类型实现了 CopyOptionOpenOption 接口。

总之,虽然不能编写可扩展的枚举类型,但是你可以编写一个接口来配合实现接口的基本的枚举类型,来对它进行模拟。 这允许客户端编写自己的枚举(或其它类型)来实现接口。如果 API 是根据接口编写的,那么在任何使用基本枚举类型实例的地方,都可以使用这些枚举类型实例。

61. 基本数据类型优于包装类

61. 基本数据类型优于包装类

Java 有一个由两部分组成的类型系统,包括基本类型(如 int、double 和 boolean)和引用类型(如 String 和 List)。每个基本类型都有一个对应的引用类型,称为包装类型。与 int、double 和 boolean 对应的包装类是 Integer、Double 和 Boolean。

正如条目 6 中提到的,自动装箱和自动拆箱模糊了基本类型和包装类型之间的区别,但不会消除它们。这两者之间有真正的区别,重要的是你要始终意识到正在使用的是哪一种,并在它们之间仔细选择。

基本类型和包装类型之间有三个主要区别。首先,基本类型只有它们的值,而包装类型具有与其值不同的标识。换句话说,两个包装类型实例可以具有相同的值和不同的标识。第二,基本类型只有全功能值,而每个包装类型除了对应的基本类型的所有功能值外,还有一个非功能值,即 null。最后,基本类型比包装类型更节省时间和空间。如果你不小心的话,这三种差异都会给你带来真正的麻烦。

考虑下面的比较器,它的设计目的是表示 Integer 值上的升序数字排序。(回想一下,比较器的 compare 方法返回一个负数、零或正数,这取决于它的第一个参数是小于、等于还是大于第二个参数。)你不需要在实际使用中编写这个比较器,因为它实现了 Integer 的自然排序,但它提供了一个有趣的例子:

1
2
// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder =(i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);

这个比较器看起来应该可以工作,它将通过许多测试。例如,它可以与 Collections.sort 一起使用,以正确地排序一个百万元素的 List,无论该 List 是否包含重复的元素。但这个比较存在严重缺陷。要使自己相信这一点,只需打印 naturalOrder.compare(new Integer(42), new Integer(42)) 的值。两个 Integer 实例都表示相同的值 (42),所以这个表达式的值应该是 0,但它是 1,这表明第一个 Integer 值大于第二个!

那么问题出在哪里呢?naturalOrder 中的第一个测试工作得很好。计算表达式 i < j 会使 i 和 j 引用的 Integer 实例自动拆箱;也就是说,它提取它们的基本类型值。计算的目的是检查得到的第一个 int 值是否小于第二个 int 值。但假设它不是。然后,下一个测试计算表达式 i==j,该表达式对两个对象引用执行标识比较。如果 i 和 j 引用表示相同 int 值的不同 Integer 实例,这个比较将返回 false,比较器将错误地返回 1,表明第一个整型值大于第二个整型值。将 == 操作符应用于包装类型几乎都是错误的。

在实际使用中,如果你需要一个比较器来描述类型的自然顺序,你应该简单地调用 Comparator.naturalOrder(),如果你自己编写一个比较器,你应该使用比较器构造方法,或者对基本类型使用静态比较方法(详见第 14 条)。也就是说,你可以通过添加两个局部变量来存储基本类型 int 值,并对这些变量执行所有的比较,从而修复损坏的比较器中的问题。这避免了错误的标识比较:

1
2
3
4
Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> {
int i = iBoxed, j = jBoxed; // Auto-unboxing
return i < j ? -1 : (i == j ? 0 : 1);
};

接下来,考虑一下这个有趣的小程序:

1
2
3
4
5
6
7
public class Unbelievable {
static Integer i;
public static void main(String[] args) {
if (i == 42)
System.out.println("Unbelievable");
}
}

不,它不会打印出令人难以置信的东西,但它的行为很奇怪。它在计算表达式 i==42 时抛出 NullPointerException。问题是,i 是 Integer,而不是 int 数,而且像所有非常量对象引用字段一样,它的初值为 null。当程序计算表达式 i==42 时,它是在比较 Integer 与 int。在操作中混合使用基本类型和包装类型时,包装类型就会自动拆箱,这种情况无一例外。如果一个空对象引用自动拆箱,那么你将得到一个 NullPointerException。正如这个程序所演示的,它几乎可以在任何地方发生。修复这个问题非常简单,只需将 i 声明为 int 而不是 Integer。

最后,考虑条目 6 中第 24 页的程序:

1
2
3
4
5
6
7
8
// Hideously slow program! Can you spot the object creation?
public static void main(String[] args) {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println(sum);
}

这个程序比它预期的速度慢得多,因为它意外地声明了一个局部变量 (sum),它是包装类型 Long,而不是基本类型 long。程序在没有错误或警告的情况下编译,变量被反复装箱和拆箱,导致产生明显的性能下降。

在本条目中讨论的所有三个程序中,问题都是一样的:程序员忽略了基本类型和包装类型之间的区别,并承担了恶果。在前两个项目中,结果是彻底的失败;第三个例子还产生了严重的性能问题。

那么,什么时候应该使用包装类型呢?它们有几个合法的用途。第一个是作为集合中的元素、键和值。不能将基本类型放在集合中,因此必须使用包装类型。这是一般情况下的特例。在参数化类型和方法(Chapter 5)中,必须使用包装类型作为类型参数,因为 Java 不允许使用基本类型。例如,不能将变量声明为 ThreadLocal<int> 类型,因此必须使用 ThreadLocal<Integer>。最后,在进行反射方法调用时,必须使用包装类型(详见第 65 条)。

总之,只要有选择,就应该优先使用基本类型,而不是包装类型。基本类型更简单、更快。如果必须使用包装类型,请小心!自动装箱减少了使用包装类型的冗长,但没有减少危险。 当你的程序使用 == 操作符比较两个包装类型时,它会执行标识比较,这几乎肯定不是你想要的。当你的程序执行包含包装类型和基本类型的混合类型计算时,它将进行拆箱,当你的程序执行拆箱时,将抛出 ​​**NullPointerException。** 最后,当你的程序将基本类型装箱时,可能会导致代价高昂且不必要的对象创建。

65. 接口优于反射

65. 接口优于反射

核心反射机制 java.lang.reflect 提供对任意类的编程访问。给定一个 Class 对象,你可以获得 Constructor、Method 和 Field 实例,分别代表了该 Class 实例所表示的类的构造器、方法和字段。这些对象提供对类的成员名、字段类型、方法签名等的编程访问。

此外,Constructor、Method 和 Field 实例允许你反射性地操作它们的底层对应项:你可以通过调用 Constructor、Method 和 Field 实例上的方法,可以构造底层类的实例、调用底层类的方法,并访问底层类中的字段。例如,Method.invoke 允许你在任何类的任何对象上调用任何方法(受默认的安全约束)。反射允许一个类使用另一个类,即使在编译前者时后者并不存在。然而,这种能力是有代价的:

  • 你失去了编译时类型检查的所有好处, 包括异常检查。如果一个程序试图反射性地调用一个不存在的或不可访问的方法,它将在运行时失败,除非你采取了特殊的预防措施。
  • 执行反射访问所需的代码既笨拙又冗长。 写起来很乏味,读起来也很困难。
  • 性能降低。 反射方法调用比普通方法调用慢得多。到底慢了多少还很难说,因为有很多因素在起作用。在我的机器上,调用一个没有输入参数和返回 int 类型的方法时,用反射执行要慢 11 倍。

有一些复杂的应用程序需要反射。包括代码分析工具和依赖注入框架。即使是这样的工具,随着它的缺点变得越来越明显,人们也在逐渐远离并反思这种用法。如果你对应用程序是否需要反射有任何疑问,那么它可能不需要。

通过非常有限的形式使用反射,你可以获得反射的许多好处,同时花费的代价很少。 对于许多程序,它们必须用到在编译时无法获取的类,在编译时存在一个适当的接口或超类来引用该类(详见第 64 条)。如果是这种情况,可以用反射方式创建实例,并通过它们的接口或超类正常地访问它们。

例如,这是一个创建 Set<String> 实例的程序,类由第一个命令行参数指定。程序将剩余的命令行参数插入到集合中并打印出来。不管第一个参数是什么,程序都会打印剩余的参数,并去掉重复项。然而,打印这些参数的顺序取决于第一个参数中指定的类。如果你指定 java.util.HashSet,它们显然是随机排列的;如果你指定 java.util.TreeSet,它们是按字母顺序打印的,因为 TreeSet 中的元素是有序的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Reflective instantiation with interface access
public static void main(String[] args) {

// Translate the class name into a Class object
Class<? extends Set<String>> cl = null;
try {
cl = (Class<? extends Set<String>>) // Unchecked cast!
Class.forName(args[0]);
} catch (ClassNotFoundException e) {
fatalError("Class not found.");
}

// Get the constructor
Constructor<? extends Set<String>> cons = null;
try {
cons = cl.getDeclaredConstructor();
} catch (NoSuchMethodException e) {
fatalError("No parameterless constructor");
}

// Instantiate the set
Set<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");
}

// Exercise the set
s.addAll(Arrays.asList(args).subList(1, args.length));
System.out.println(s);
}

private static void fatalError(String msg) {
System.err.println(msg);
System.exit(1);
}

虽然这个程序只是一个小把戏,但它演示的技术非常强大。这个程序可以很容易地转换成一个通用的集合测试器,通过积极地操作一个或多个实例并检查它们是否遵守 Set 接口约定来验证指定的 Set 实现。类似地,它可以变成一个通用的集合性能分析工具。事实上,该技术足够强大,可以实现一个成熟的服务提供者框架(详见第 1 条)。

这个例子也说明了反射的两个缺点。首先,该示例可以在运行时生成六个不同的异常,如果没有使用反射实例化,所有这些异常都将是编译时错误。(有趣的是,你可以通过传入适当的命令行参数,使程序生成六个异常中的每一个。)第二个缺点是,根据类的名称生成类的实例需要 25 行冗长的代码,而构造函数调用只需要一行。通过捕获 ReflectiveOperationException(Java 7 中引入的各种反射异常的超类),可以减少程序的长度。这两个缺点都只限于实例化对象的程序部分。实例化后,与任何其他 Set 实例将难以区分。在实际的程序中,通过这种限定使用反射的方法,大部分代码可以免受影响。

如果编译此程序,将得到 unchecked 的强制转换警告。这个警告是合法的,即使指定的类不是 Set 实现,Class<? extends Set<String>> 也会成功,在这种情况下,程序在实例化类时抛出 ClassCastException。要了解如何抑制警告,请阅读条目 27。

反射的合法用途(很少)是管理类对运行时可能不存在的其他类、方法或字段的依赖关系。如果你正在编写一个包,并且必须针对其他包的多个版本运行,此时反射将非常有用。该技术是根据支持包所需的最小环境(通常是最老的版本)编译包,并反射性地访问任何较新的类或方法。如果你试图访问的新类或方法在运行时不存在,要使此工作正常进行,则必须采取适当的操作。适当的操作可能包括使用一些替代方法来完成相同的目标,或者使用简化的功能进行操作。

总之,反射是一种功能强大的工具,对于某些复杂的系统编程任务是必需的,但是它有很多缺点。如果编写的程序必须在编译时处理未知的类,则应该尽可能只使用反射实例化对象,并使用在编译时已知的接口或超类访问对象。

85. 优先选择 Java 序列化的替代方案

85. 优先选择 Java 序列化的替代方案

当序列化在 1997 年添加到 Java 中时,它被认为有一定的风险。这种方法曾在研究语言(Modula-3)中尝试过,但从未在生产语言中使用过。虽然程序员不费什么力气就能实现分布式对象,这一点很吸引人,但代价也不小,如:不可见的构造函数、API 与实现之间模糊的界线,还可能会出现正确性、性能、安全性和维护方面的问题。支持者认为收益大于风险,但历史证明并非如此。

在本书之前的版本中描述的安全问题,和人们担心的一样严重。21 世纪初仅停留在讨论的漏洞在接下来的 10 年间变成了真实严重的漏洞,其中最著名的包括 2016 年 11 月对旧金山大都会运输署市政铁路(SFMTA Muni)的勒索软件攻击,导致整个收费系统关闭了两天 [Gallagher16]。

序列化的一个根本问题是它的可攻击范围太大,且难以保护,而且问题还在不断增多:通过调用 ObjectInputStream 上的 readObject 方法反序列化对象图。这个方法本质上是一个神奇的构造函数,可以用来实例化类路径上几乎任何类型的对象,只要该类型实现 Serializable 接口。在反序列化字节流的过程中,此方法可以执行来自任何这些类型的代码,因此所有这些类型的代码都在攻击范围内。

攻击可涉及 Java 平台库、第三方库(如 Apache Commons collection)和应用程序本身中的类。即使坚持履行实践了所有相关的最佳建议,并成功地编写了不受攻击的可序列化类,应用程序仍然可能是脆弱的。引用 CERT 协调中心技术经理 Robert Seacord 的话:

Java 反序列化是一个明显且真实的危险源,因为它被应用程序直接和间接地广泛使用,比如 RMI(远程方法调用)、JMX(Java 管理扩展)和 JMS(Java 消息传递系统)。不可信流的反序列化可能导致远程代码执行(RCE)、拒绝服务(DoS)和一系列其他攻击。应用程序很容易受到这些攻击,即使它们本身没有错误[Seacord17]。

攻击者和安全研究人员研究 Java 库和常用的第三方库中的可序列化类型,寻找在反序列化过程中调用的潜在危险活动的方法称为 gadget。多个小工具可以同时使用,形成一个小工具链。偶尔会发现一个小部件链,它的功能足够强大,允许攻击者在底层硬件上执行任意的本机代码,允许提交精心设计的字节流进行反序列化。这正是 SFMTA Muni 袭击中发生的事情。这次袭击并不是孤立的。不仅已经存在,而且还会有更多。

不使用任何 gadget,你都可以通过对需要很长时间才能反序列化的短流进行反序列化,轻松地发起拒绝服务攻击。这种流被称为反序列化炸弹 [Svoboda16]。下面是 Wouter Coekaerts 的一个例子,它只使用哈希集和字符串 [Coekaerts15]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static byte[] bomb() {
Set<Object> root = new HashSet<>();
Set<Object> s1 = root;
Set<Object> s2 = new HashSet<>();
for (int i = 0; i < 100; i++) {
Set<Object> t1 = new HashSet<>();
Set<Object> t2 = new HashSet<>();
t1.add("foo"); // Make t1 unequal to t2
s1.add(t1); s1.add(t2);
s2.add(t1); s2.add(t2);
s1 = t1;
s2 = t2;
}
return serialize(root); // Method omitted for brevity
}

对象图由 201 个 HashSet 实例组成,每个实例包含 3 个或更少的对象引用。整个流的长度为 5744 字节,但是在你对其进行反序列化之前,资源就已经耗尽了。问题在于,反序列化 HashSet 实例需要计算其元素的哈希码。根哈希集的 2 个元素本身就是包含 2 个哈希集元素的哈希集,每个哈希集元素包含 2 个哈希集元素,以此类推,深度为 100。因此,反序列化 Set 会导致 hashCode 方法被调用超过 2100 次。除了反序列化会持续很长时间之外,反序列化器没有任何错误的迹象。生成的对象很少,并且堆栈深度是有界的。

那么你能做些什么来抵御这些问题呢?当你反序列化一个你不信任的字节流时,你就会受到攻击。避免序列化利用的最好方法是永远不要反序列化任何东西。 用 1983 年电影《战争游戏》(WarGames)中名为约书亚(Joshua)的电脑的话来说,「唯一的制胜绝招就是不玩。」没有理由在你编写的任何新系统中使用 Java 序列化。 还有其他一些机制可以在对象和字节序列之间进行转换,从而避免了 Java 序列化的许多危险,同时还提供了许多优势,比如跨平台支持、高性能、大量工具和广泛的专家社区。在本书中,我们将这些机制称为跨平台结构数据表示。虽然其他人有时将它们称为序列化系统,但本书避免使用这种说法,以免与 Java 序列化混淆。

以上所述技术的共同点是它们比 Java 序列化简单得多。它们不支持任意对象图的自动序列化和反序列化。相反,它们支持简单的结构化数据对象,由一组「属性-值」对组成。只有少数基本数据类型和数组数据类型得到支持。事实证明,这个简单的抽象足以构建功能极其强大的分布式系统,而且足够简单,可以避免 Java 序列化从一开始就存在的严重问题。

领先的跨平台结构化数据表示是 JSON 和 Protocol Buffers,也称为 protobuf。JSON 由 Douglas Crockford 设计用于浏览器与服务器通信,Protocol Buffers 由谷歌设计用于在其服务器之间存储和交换结构化数据。尽管这些技术有时被称为「中性语言」,但 JSON 最初是为 JavaScript 开发的,而 protobuf 是为 c++ 开发的;这两种技术都保留了其起源的痕迹。

JSON 和 protobuf 之间最显著的区别是 JSON 是基于文本的,并且是人类可读的,而 protobuf 是二进制的,但效率更高;JSON 是一种专门的数据表示,而 protobuf 提供模式(类型)来记录和执行适当的用法。虽然 protobuf 比 JSON 更有效,但是 JSON 对于基于文本的表示非常有效。虽然 protobuf 是一种二进制表示,但它确实提供了另一种文本表示,可用于需要具备人类可读性的场景(pbtxt)。

如果你不能完全避免 Java 序列化,可能是因为你需要在遗留系统环境中工作,那么你的下一个最佳选择是 永远不要反序列化不可信的数据。 特别要注意,你不应该接受来自不可信来源的 RMI 流量。Java 的官方安全编码指南说:「反序列化不可信的数据本质上是危险的,应该避免。」这句话是用大号、粗体、斜体和红色字体设置的,它是整个文档中唯一得到这种格式处理的文本。[Java-secure]

如果无法避免序列化,并且不能绝对确定反序列化数据的安全性,那么可以使用 Java 9 中添加的对象反序列化筛选,并将其移植到早期版本(java.io.ObjectInputFilter)。该工具允许你指定一个过滤器,该过滤器在反序列化数据流之前应用于数据流。它在类粒度上运行,允许你接受或拒绝某些类。默认接受所有类,并拒绝已知潜在危险类的列表称为黑名单;在默认情况下拒绝其他类,并接受假定安全的类的列表称为白名单。优先选择白名单而不是黑名单, 因为黑名单只保护你免受已知的威胁。一个名为 Serial Whitelist Application Trainer(SWAT)的工具可用于为你的应用程序自动准备一个白名单 [Schneider16]。过滤工具还将保护你免受过度内存使用和过于深入的对象图的影响,但它不能保护你免受如上面所示的序列化炸弹的影响。

不幸的是,序列化在 Java 生态系统中仍然很普遍。如果你正在维护一个基于 Java 序列化的系统,请认真考虑迁移到跨平台的结构化数据,尽管这可能是一项耗时的工作。实际上,你可能仍然需要编写或维护一个可序列化的类。编写一个正确、安全、高效的可序列化类需要非常小心。本章的其余部分将提供何时以及如何进行此操作的建议。

总之,序列化是危险的,应该避免。如果你从头开始设计一个系统,可以使用跨平台的结构化数据,如 JSON 或 protobuf。不要反序列化不可信的数据。如果必须这样做,请使用对象反序列化过滤,但要注意,它不能保证阻止所有攻击。避免编写可序列化的类。如果你必须这样做,一定要非常小心。

12. 始终重写 toString 方法

12. 始终重写 toString 方法

虽然 Object 类提供了 toString 方法的实现,但它返回的字符串通常不是你的类的用户想要看到的。 它由类名后跟一个「at」符号(@)和哈希码的无符号十六进制表示组成,例如 PhoneNumber@163b91。 toString 的通用约定要求,返回的字符串应该是「一个简洁但内容丰富的表示,对人们来说是很容易阅读的」。虽然可以认为 PhoneNumber@163b91 简洁易读,但相比于 707-867-5309,但并不是很丰富 。 toString 通用约定「建议所有的子类重写这个方法」。好的建议,的确如此!

虽然它并不像遵守 equals 和 hashCode 约定那样重要 (条目 10 和 11),但是提供一个良好的 toString 实现使你的类更易于使用,并对使用此类的系统更易于调试。当对象被传递到 println、printf、字符串连接操作符或断言,或者由调试器打印时,toString 方法会自动被调用。即使你从不调用对象上的 toString,其他人也可以。例如,有一个引用了某对象的组件,它可能在日志错误信息中包含该对象的字符串描述。如果未能重写 toString,则消息可能是无用的。

如果为 PhoneNumber 提供了一个很好的 toString 方法,那么生成一个有用的诊断消息就像下面这样简单:

1
System.out.println("Failed to connect to " + phoneNumber);

除非你重写 toString 方法,否则程序员以这种方式生成的诊断消息将一无是处。 提供一个良好的 toString 方法不仅惠及类的实例,而且有益于那些包含实例引用的对象,集合尤为明显。 当打印一个 map 时你更愿看到 {Jenny=PhoneNumber@163b91} 还是 {Jenny=707-867-5309}?

实际上,toString 方法应该返回对象中包含的所有需要关注的信息,如电话号码示例中所示。 如果对象很大或者包含不利于字符串表示的状态,这是不切实际的。 在这种情况下,toString 应该返回一个摘要,如 Manhattan residential phone directory (1487536 listings) 或线程[main,5,main]。 理想情况下,字符串应该是不言自明的(线程示例并没有遵守这点)。 如果未能将所有对象的值得关注的信息包含在字符串表示中,则会导致一个特别烦人的处罚:测试失败报告如下所示:

1
Assertion failure: expected {abc, 123}, but was {abc, 123}.

实现 toString 方法时,必须做出的一个重要决定是:在文档中指定返回值的格式。 建议你对值类进行此操作,例如电话号码或矩阵类。 指定格式的好处是它可以作为标准的,明确的,可读的对象表示。 这种表示形式可以用于输入、输出以及持久化可读性的数据对象,如 CSV 文件。 如果指定了格式,通常提供一个匹配的静态工厂或构造方法,是个好主意,所以程序员可以轻松地在对象和字符串表示之间来回转换。 Java 平台类库中的许多值类都采用了这种方法,包括 BigInteger,BigDecimal 和大部分基本类型包装类。

指定 toString 返回值的格式的缺点是,假设你的类被广泛使用,一旦指定了格式,就会终身使用。程序员将编写代码来解析表达式,生成它,并将其嵌入到持久数据中。如果在将来的版本中更改了格式的表示,那么会破坏他们的代码和数据,并且还会抱怨。但通过选择不指定格式,就可以保留在后续版本中添加信息或改进格式的灵活性。

无论是否决定指定格式,你都应该清楚地在文档中表明你的意图。如果指定了格式,则应该这样做。例如,这里有一个 toString 方法,该方法在条目 11 中使用 PhoneNumber 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Returns the string representation of this phone number.
* The string consists of twelve characters whose format is
* "XXX-YYY-ZZZZ", where XXX is the area code, YYY is the
* prefix, and ZZZZ is the line number. Each of the capital
* letters represents a single decimal digit.
*
* If any of the three parts of this phone number is too small
* to fill up its field, the field is padded with leading zeros.
* For example, if the value of the line number is 123, the last
* four characters of the string representation will be "0123".
*/
@Override
public String toString() {
return String.format("%03d-%03d-%04d",
areaCode, prefix, lineNum);
}

如果你决定不指定格式,那么文档注释应该是这样的:

1
2
3
4
5
6
7
8
9
/**
* Returns a brief description of this potion. The exact details
* of the representation are unspecified and subject to change,
* but the following may be regarded as typical:
*
* "[Potion #9: type=love, smell=turpentine, look=india ink]"
*/
@Override
public String toString() { ... }

在阅读了这条注释之后,那些生成依赖于格式细节的代码或持久化数据的程序员,在这种格式发生改变的时候,只能怪他们自己。

无论是否指定格式,都可以通过编程方式访问 toString 返回的值中包含的信息。 例如,PhoneNumber 类应该包含 areaCode, prefix, lineNum 这三个属性。 如果不这样做,就会强迫程序员需要这些信息来解析字符串。 除了降低性能和程序员做不必要的工作之外,这个过程很容易出错,如果改变格式就会中断,并导致脆弱的系统。 由于未能提供访问器,即使已指定格式可能会更改,也可以将字符串格式转换为事实上的 API。

在静态工具类(详见第 4 条)中编写 toString 方法是没有意义的。 你也不应该在大多数枚举类型(条目 34)中写一个 toString 方法,因为 Java 为你提供了一个非常好的方法。 但是,你应该在任何抽象类中定义 toString 方法,该类的子类共享一个公共字符串表示形式。 例如,大多数集合实现上的 toString 方法都是从抽象集合类继承的。

Google 的开放源代码 AutoValue 工具在条目 10 中讨论过,它为你生成一个 toString 方法,就像大多数 IDE 工具一样。 这些方法非常适合告诉你每个属性的内容,但并不是专门针对类的含义。 因此,例如,为我们的 PhoneNumber 类使用自动生成的 toString 方法是不合适的(因为电话号码具有标准的字符串表示形式),但是对于我们的 Potion 类来说,这是完全可以接受的。 也就是说,自动生成的 toString 方法比从 Object 继承的方法要好得多,它不会告诉你对象的值。

回顾一下,除非父类已经这样做了,否则在每个实例化的类中重写 Object 的 toString 实现。 它使得类更加舒适地使用和协助调试。 toString 方法应该以一种美观的格式返回对象的简明有用的描述。

49. 检查参数有效性

49. 检查参数有效性

本章(第 8 章)讨论了方法设计的几个方面:如何处理参数和返回值,如何设计方法签名以及如何记载方法文档。 本章中的大部分内容适用于构造方法和其他普通方法。 与第 4 章一样,本章重点关注可用性,健壮性和灵活性上。

大多数方法和构造方法对可以将哪些值传递到其对应参数中有一些限制。 例如,索引值必须是非负数,对象引用必须为非 null。 你应该清楚地在文档中记载所有这些限制,并在方法主体的开头用检查来强制执行。 应该尝试在错误发生后尽快检测到错误,这是一般原则的特殊情况。 如果不这样做,则不太可能检测到错误,并且一旦检测到错误就更难确定错误的来源。

如果将无效参数值传递给方法,并且该方法在执行之前检查其参数,则它抛出适当的异常然后快速且清楚地以失败结束。 如果该方法无法检查其参数,可能会发生一些事情。 在处理过程中,该方法可能会出现令人困惑的异常。 更糟糕的是,该方法可以正常返回,但默默地计算错误的结果。 最糟糕的是,该方法可以正常返回但是将某个对象置于受损状态,在将来某个未确定的时间在代码中的某些不相关点处导致错误。 换句话说,验证参数失败可能导致违反故障原子性(failure atomicity)(详见第 76 条)。

对于公共方法和受保护方法,请使用 Java 文档@throws注解来记在在违反参数值限制时将引发的异常(条目 74)。 通常,生成的异常是IllegalArgumentExceptionIndexOutOfBoundsExceptionNullPointerException(条目 72)。 一旦记录了对方法参数的限制,并且记录了违反这些限制时将引发的异常,那么强制执行这些限制就很简单了。 这是一个典型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Returns a BigInteger whose value is (this mod m). This method
* differs from the remainder method in that it always returns a
* non-negative BigInteger.
*
* @param m the modulus, which must be positive
* @return this mod m
* @throws ArithmeticException if m is less than or equal to 0
*/
public BigInteger mod(BigInteger m) {
if (m.signum() <= 0)
throw new ArithmeticException("Modulus <= 0: " + m);

... // Do the computation

}

请注意,文档注释没有说「如果 m 为 null,mod 抛出 NullPointerException」,尽管该方法正是这样做的,这是调用m.sgn()的副产品。这个异常记载在类级别文档注释中,用于包含的BigInteger类。类级别的注释应用于类的所有公共方法中的所有参数。这是避免在每个方法上分别记录每个NullPointerException的好方法。它可以与@Nullable或类似的注释结合使用,以表明某个特定参数可能为空,但这种做法不是标准的,为此使用了多个注解。

在 Java 7 中添加的Objects.requireNonNull 方法灵活方便,因此没有理由再手动执行空值检查。 如果愿意,可以指定自定义异常详细消息。 该方法返回其输入的值,因此可以在使用值的同时执行空检查:

1
2
// Inline use of Java's null-checking facility
this.strategy = Objects.requireNonNull(strategy, "strategy");

你也可以忽略返回值,并使用Objects.requireNonNull作为满足需求的独立空值检查。

在 Java 9 中,java.util.Objects 类中添加了范围检查工具。 此工具包含三个方法:checkFromIndexSizecheckFromToIndexcheckIndex。 此工具不如空检查方法灵活。 它不允许指定自己的异常详细消息,它仅用于列表和数组索引。 它不处理闭合范围(包含两个端点)。 但如果它能满足你的需要,那就很方便了。

对于未导出的方法,作为包的作者,控制调用方法的环境,这样就可以并且应该确保只传入有效的参数值。因此,非公共方法可以使用断言检查其参数,如下所示:

1
2
3
4
5
6
7
// Private helper function for a recursive sort
private static void sort(long a[], int offset, int length) {
assert a != null;
assert offset >= 0 && offset <= a.length;
assert length >= 0 && length <= a.length - offset;
... // Do the computation
}

本质上,这些断言声称断言条件将成立,无论其客户端如何使用封闭包。与普通的有效性检查不同,断言如果失败会抛出AssertionError。与普通的有效性检查不同的是,除非使用-ea(或者-enableassertions)标记传递给 java 命令来启用它们,否则它们不会产生任何效果,本质上也不会产生任何成本。有关断言的更多信息,请参阅教程assert

检查方法中未使用但存储以供以后使用的参数的有效性尤为重要。例如,考虑第 101 页上的静态工厂方法,它接受一个 int 数组并返回数组的 List 视图。如果客户端传入 null,该方法将抛出 NullPointerException,因为该方法具有显式检查 (调用 Objects.requireNonNull 方法)。如果省略了该检查,则该方法将返回对新创建的 List 实例的引用,该实例将在客户端尝试使用它时立即抛出 NullPointerException。 到那时,List 实例的来源可能很难确定,这可能会使调试任务大大复杂化。

构造方法是这个原则的一个特例,你应该检查要存储起来供以后使用的参数的有效性。检查构造方法参数的有效性对于防止构造对象违反类不变性(class invariants)非常重要。

你应该在执行计算之前显式检查方法的参数,但这一规则也有例外。 一个重要的例外是有效性检查昂贵或不切实际的情况,并且在进行计算的过程中隐式执行检查。 例如,考虑一种对对象列表进行排序的方法,例如Collections.sort(List)。 列表中的所有对象必须是可相互比较的。 在对列表进行排序的过程中,列表中的每个对象都将与其他对象进行比较。 如果对象不可相互比较,则某些比较操作抛出 ClassCastException 异常,这正是sort方法应该执行的操作。 因此,提前检查列表中的元素是否具有可比性是没有意义的。 但请注意,不加选择地依赖隐式有效性检查会导致失败原子性(failure atomicity)的丢失(详见第 76 条)。

有时,计算会隐式执行必需的有效性检查,但如果检查失败则会抛出错误的异常。 换句话说,计算由于无效参数值而自然抛出的异常与文档记录方法抛出的异常不匹配。 在这些情况下,你应该使用条目 73 中描述的异常翻译(exception translation)习惯用法将自然异常转换为正确的异常。

不要从本条目中推断出对参数的任意限制都是一件好事。 相反,你应该设计一些方法,使其尽可能通用。 假设方法可以对它接受的所有参数值做一些合理的操作,那么对参数的限制越少越好。 但是,通常情况下,某些限制是正在实现的抽象所固有的。

总而言之,每次编写方法或构造方法时,都应该考虑对其参数存在哪些限制。 应该记在这些限制,并在方法体的开头使用显式检查来强制执行这些限制。 养成这样做的习惯很重要。 在第一次有效性检查失败时,它所需要的少量工作将会得到对应的回报。

48. 谨慎使用流并行

48. 谨慎使用流并行

在主流语言中,Java 一直处于提供简化并发编程任务的工具的最前沿。 当 Java 于 1996 年发布时,它内置了对线程的支持,包括同步和 wait / notify 机制。 Java 5 引入了 java.util.concurrent 类库,带有并发集合和执行器框架。 Java 7 引入了 fork-join 包,这是一个用于并行分解的高性能框架。 Java 8 引入了流,可以通过对 parallel 方法的单个调用来并行化。 用 Java 编写并发程序变得越来越容易,但编写正确快速的并发程序还像以前一样困难。 安全和活跃度违规(liveness violation)是并发编程中的事实,并行流管道也不例外。

考虑条目 45 中的程序:

1
2
3
4
5
6
7
8
9
10
11
// Stream-based program to generate the first 20 Mersenne primes
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}

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

在我的机器上,这个程序立即开始打印素数,运行到完成需要 12.5 秒。假设我天真地尝试通过向流管道中添加一个到 parallel() 的调用来加快速度。你认为它的表现会怎样?它会快几个百分点吗?慢几个百分点?遗憾的是,它不会打印任何东西,但是 CPU 使用率会飙升到 90%,并且会无限期地停留在那里 (liveness failure:活性失败)。这个程序可能最终会终止,但我不愿意去等待;半小时后我强行阻止了它。

这里发生了什么?简而言之,流类库不知道如何并行化此管道并且启发式失败(heuristics fail)。 即使在最好的情况下,如果源来自 Stream.iterate 方法,或者使用中间操作 limit 方法,并行化管道也不太可能提高其性能。 这个管道必须应对这两个问题。更糟糕的是,默认的并行策略处理不可预测性的 limit 方法,假设在处理一些额外的元素和丢弃任何不必要的结果时没有害处。在这种情况下,找到每个梅森素数的时间大约是找到上一个素数的两倍。因此,计算单个额外元素的成本大致等于计算所有先前元素组合的成本,并且这种无害的管道使自动并行化算法瘫痪。这个故事的寓意很简单:不要无差别地并行化流管道(stream pipelines)。性能后果可能是灾难性的。

通常,并行性带来的性能收益在 ​​**ArrayListHashMapHashSet​ 和 ​ConcurrentHashMap​ 实例、数组、int​ 类型范围和 ​long​ 类型的范围的流上最好。** 这些数据结构的共同之处在于,它们都可以精确而廉价地分割成任意大小的子程序,这使得在并行线程之间划分工作变得很容易。用于执行此任务的流泪库使用的抽象是 spliterator,它由 spliterator 方法在 StreamIterable 上返回。

所有这些数据结构的共同点的另一个重要因素是它们在顺序处理时提供了从良好到极好的引用位置( locality of reference):顺序元素引用在存储器中存储在一块。 这些引用所引用的对象在存储器中可能彼此不接近,这降低了引用局部性。 对于并行化批量操作而言,引用位置非常重要:没有它,线程大部分时间都处于空闲状态,等待数据从内存传输到处理器的缓存中。 具有最佳引用位置的数据结构是基本类型的数组,因为数据本身连续存储在存储器中。

流管道终端操作的性质也会影响并行执行的有效性。 如果与管道的整体工作相比,在终端操作中完成了大量的工作,并且这种操作本质上是连续的,那么并行化管道的有效性将是有限的。 并行性的最佳终操作是缩减(reductions),即使用流的 reduce 方法组合管道中出现的所有元素,或者预先打包的 reduce(如 minmaxcountsum)。短路操作 anyMatchallMatchnoneMatch 也可以支持并行性。由 Streamcollect 方法执行的操作,称为可变缩减(mutable reductions),不适合并行性,因为组合集合的开销非常大。

如果编写自己的 StreamIterableCollection 实现,并且希望获得良好的并行性能,则必须重写 spliterator 方法并广泛测试生成的流的并行性能。 编写高质量的 spliterator 很困难,超出了本书的范围。

并行化一个流不仅会导致糟糕的性能,包括活性失败(liveness failures);它会导致不正确的结果和不可预知的行为 (安全故障)。 使用映射器(mappers),过滤器(filters)和其他程序员提供的不符合其规范的功能对象的管道并行化可能会导致安全故障。 Stream 规范对这些功能对象提出了严格的要求。 例如,传递给 Streamreduce 方法操作的累加器(accumulator)和组合器(combiner)函数必须是关联的,非干扰的和无状态的。 如果违反了这些要求(其中一些在第 46 项中讨论过),但按顺序运行你的管道,则可能会产生正确的结果; 如果将它并行化,它可能会失败,也许是灾难性的。

沿着这些思路,值得注意的是,即使并行的梅森素数程序已经运行完成,它也不会以正确的 (升序的) 顺序打印素数。为了保持顺序版本显示的顺序,必须将 forEach 终端操作替换为 forEachOrdered 操作,它保证以遇出现顺序(encounter order)遍历并行流。

即使假设正在使用一个高效的可拆分的源流、一个可并行化的或廉价的终端操作以及非干扰的函数对象,也无法从并行化中获得良好的加速效果,除非管道做了足够的实际工作来抵消与并行性相关的成本。作为一个非常粗略的估计,流中的元素数量乘以每个元素执行的代码行数应该至少是 100,000 [Lea14]。

重要的是要记住并行化流是严格的性能优化。 与任何优化一样,必须在更改之前和之后测试性能,以确保它值得做(详见第 67 条)。 理想情况下,应该在实际的系统设置中执行测试。 通常,程序中的所有并行流管道都在公共 fork-join 池中运行。 单个行为不当的管道可能会损害系统中不相关部分的其他行为。

如果在并行化流管道时,这种可能性对你不利,那是因为它们确实存在。一个认识的人,他维护一个数百万行代码库,大量使用流,他发现只有少数几个地方并行流是有效的。这并不意味着应该避免并行化流。在适当的情况下,只需向流管道添加一个 ​​**parallel​ 方法调用,就可以实现处理器内核数量的近似线性加速。** 某些领域,如机器学习和数据处理,特别适合这些加速。

1
2
3
4
5
6
7
8
// 作为并行性有效的流管道的简单示例,请考虑此函数来计算π(n),素数小于或等于 n:
// Prime-counting stream pipeline - benefits from parallelization
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}

在我的机器上,使用此功能计算π(108)需要 31 秒。 只需添加 parallel() 方法调用即可将时间缩短为 9.2 秒:

1
2
3
4
5
6
7
8
// Prime-counting stream pipeline - parallel version
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}

换句话说,在我的四核计算机上,并行计算速度提高了 3.7 倍。值得注意的是,这不是你在实践中如何计算π(n) 为 n 的值。还有更有效的算法,特别是 Lehmer’s formula。

如果要并行化随机数流,请从 SplittableRandom 实例开始,而不是 ThreadLocalRandom(或基本上过时的 Random)。 SplittableRandom 专为此用途而设计,具有线性加速的潜力。ThreadLocalRandom 设计用于单个线程,并将自身适应作为并行流源,但不会像 SplittableRandom 一样快。Random 实例在每个操作上进行同步,因此会导致过度的并行杀死争用(parallelism-killing contention)。

总之,甚至不要尝试并行化流管道,除非你有充分的理由相信它将保持计算的正确性并提高其速度。不恰当地并行化流的代价可能是程序失败或性能灾难。如果您认为并行性是合理的,那么请确保您的代码在并行运行时保持正确,并在实际情况下进行仔细的性能度量。如果您的代码是正确的,并且这些实验证实了您对性能提高的怀疑,那么并且只有这样才能在生产代码中并行化流。

68. 遵守被广泛认可的命名约定

68. 遵守被广泛认可的命名约定

Java 平台有一组完善的命名约定,其中许多约定包含在《The Java Language Specification》[JLS, 6.1]。不严格地讲,命名约定分为两类:排版和语法。

有少量的与排版有关的命名约定,包括包、类、接口、方法、字段和类型变量。如果没有很好的理由,你不应该违反它们。如果 API 违反了这些约定,那么它可能很难使用。如果实现违反了这些规则,可能很难维护。在这两种情况下,违规都有可能使其他使用代码的程序员感到困惑和恼怒,并使他们做出错误的假设,从而导致错误。本条目概述了各项约定。

包名和模块名应该是分层的,组件之间用句点分隔。组件应该由小写字母组成,很少使用数字。任何在你的组织外部使用的包,名称都应该以你的组织的 Internet 域名开头,并将组件颠倒过来,例如,edu.cmu、com.google、org.eff。以 java 和 javax 开头的标准库和可选包是这个规则的例外。用户不能创建名称以 java 或 javax 开头的包或模块。将 Internet 域名转换为包名前缀的详细规则可以在《The Java Language Specification》[JLS, 6.1] 中找到。

包名的其余部分应该由描述包的一个或多个组件组成。组件应该很短,通常为 8 个或更少的字符。鼓励使用有意义的缩写,例如 util 而不是 utilities。缩写词是可以接受的,例如 awt。组件通常应该由一个单词或缩写组成。

除了 Internet 域名之外,许多包的名称只有一个组件。附加组件适用于大型工具包,这些工具包的大小要求将其分解为非正式的层次结构。例如 javax.util 包具有丰富的包层次结构,包的名称如 java.util.concurrent.atomic。这样的包称为子包,尽管 Java 几乎不支持包层次结构。

类和接口名称,包括枚举和注释类型名称,应该由一个或多个单词组成,每个单词的首字母大写,例如 List 或 FutureTask。除了缩略语和某些常见的缩略语,如 max 和 min,缩略语应该避免使用。缩略语应该全部大写,还是只有首字母大写,存在一些分歧。虽然有些程序员仍然使用大写字母,但支持只将第一个字母大写的理由很充分:即使多个首字母缩写连续出现,你仍然可以知道一个单词从哪里开始,下一个单词从哪里结束。你希望看到哪个类名,HTTPURL 还是 HttpUrl?

方法和字段名遵循与类和接口名相同的排版约定,除了方法或字段名的第一个字母应该是小写,例如 remove 或 ensureCapacity。如果方法或字段名的首字母缩写出现在第一个单词中,那么它应该是小写的。

前面规则的唯一例外是「常量字段」,它的名称应该由一个或多个大写单词组成,由下划线分隔,例如 VALUES 或 NEGATIVE_INFINITY。常量字段是一个静态的 final 字段,其值是不可变的。如果静态 final 字段具有基本类型或不可变引用类型(第17项),那么它就是常量字段。例如,枚举常量是常量字段。如果静态 final 字段有一个可变的引用类型,那么如果所引用的对象是不可变的,那么它仍然可以是一个常量字段。注意,常量字段是唯一推荐使用下划线用法的。

局部变量名与成员名具有类似的排版命名约定,但允许使用缩写,也允许使用单个字符和短字符序列,它们的含义取决于它们出现的上下文,例如 i、denom、houseNum。输入参数是一种特殊的局部变量。它们的命名应该比普通的局部变量谨慎得多,因为它们的名称是方法文档的组成部分。

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

为了快速参考,下表显示了排版约定的示例。

Identifier Type Example
Package or module org.junit.jupiter.api, com.google.common.collect
Class or Interface Stream, FutureTask, LinkedHashMap,HttpClient
Method or Field remove, groupingBy, getCrc
Constant Field MIN_VALUE, NEGATIVE_INFINITY
Local Variable i, denom, houseNum
Type Parameter T, E, K, V, X, R, U, V, T1, T2

语法命名约定比排版约定更灵活,也更有争议。包没有语法命名约定。可实例化的类,包括枚举类型,通常使用一个或多个名词短语来命名,例如 Thread、PriorityQueue 或 ChessPiece。不可实例化的实用程序类(详见第 4 条)通常使用复数名词来命名,例如 collector 或 Collections。接口的名称类似于类,例如集合或比较器,或者以 able 或 ible 结尾的形容词,例如 Runnable、Iterable 或 Accessible。因为注解类型有很多的用途,所以没有哪部分占主导地位。名词、动词、介词和形容词都很常见,例如,BindingAnnotation、Inject、ImplementedBy 或 Singleton。

执行某些操作的方法通常用动词或动词短语(包括对象)命名,例如,append 或 drawImage。返回布尔值的方法的名称通常以单词 is 或 has(通常很少用)开头,后面跟一个名词、一个名词短语,或者任何用作形容词的单词或短语,例如 isDigit、isProbablePrime、isEmpty、isEnabled 或 hasSiblings。

返回被调用对象的非布尔函数或属性的方法通常使用以 get 开头的名词、名词短语或动词短语来命名,例如 size、hashCode 或 getTime。有一种说法是,只有第三种形式(以 get 开头)才是可接受的,但这种说法几乎没有根据。前两种形式的代码通常可读性更强,例如:

1
2
if (car.speed() > 2 * SPEED_LIMIT)
generateAudibleAlert("Watch out for cops!");

以 get 开头的表单起源于基本过时的 Java bean 规范,该规范构成了早期可重用组件体系结构的基础。有一些现代工具仍然依赖于 bean 命名约定,你应该可以在任何与这些工具一起使用的代码中随意使用它。如果类同时包含相同属性的 setter 和 getter,则遵循这种命名约定也有很好的先例。在本例中,这两个方法通常被命名为 getAttribute 和 setAttribute。

一些方法名称值得特别注意。转换对象类型(返回不同类型的独立对象)的实例方法通常称为 toType,例如 toString 或 toArray。返回与接收对象类型不同的视图(详见第 6 条)的方法通常称为 asType,例如 asList。返回与调用它们的对象具有相同值的基本类型的方法通常称为类型值,例如 intValue。静态工厂的常见名称包括 from、of、valueOf、instance、getInstance、newInstance、getType 和 newType(详见第 1 条,第 9 页)。

字段名的语法约定没有类、接口和方法名的语法约定建立得好,也不那么重要,因为设计良好的 API 包含很少的公开字段。类型为 boolean 的字段的名称通常类似于 boolean 访问器方法,省略了初始值「is」,例如 initialized、composite。其他类型的字段通常用名词或名词短语来命名,如 height、digits 和 bodyStyle。局部变量的语法约定类似于字段的语法约定,但要求更少。

总之,将标准命名约定内在化,并将其作为第二性征来使用。排版习惯是直接的,而且在很大程度上是明确的;语法惯例更加复杂和松散。引用《The Java Language Specification》[JLS, 6.1] 中的话说,「如果长期以来的传统用法要求不遵循这些约定,就不应该盲目地遵循这些约定。」,应使用常识判断。

83. 明智审慎的使用延迟初始化

83. 明智审慎的使用延迟初始化

延迟初始化是延迟字段的初始化,直到需要它的值。如果不需要该值,则不会初始化字段。这种技术既适用于静态字段,也适用于实例字段。虽然延迟初始化主要是一种优化,但是它也可以用于破坏类中的有害循环和实例初始化 [Bloch05, Puzzle 51]。

与大多数优化一样,延迟初始化的最佳建议是「除非需要,否则不要这样做」(详见第 67 条)。延迟初始化是一把双刃剑。它降低了初始化类或创建实例的成本,代价是增加了访问延迟初始化字段的成本。根据这些字段中最终需要初始化的部分、初始化它们的开销以及初始化后访问每个字段的频率,延迟初始化实际上会损害性能(就像许多「优化」一样)。

延迟初始化也有它的用途。如果一个字段只在类的一小部分实例上访问,并且初始化该字段的代价很高,那么延迟初始化可能是值得的。唯一确定的方法是以使用和不使用延迟初始化的效果对比来度量类的性能。

在存在多个线程的情况下,使用延迟初始化很棘手。如果两个或多个线程共享一个延迟初始化的字段,那么必须使用某种形式的同步,否则会导致严重的错误(详见第 78 条)。本条目讨论的所有初始化技术都是线程安全的。

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

如果您使用延迟初始化来取代初始化的循环(circularity),请使用同步访问器,因为它是最简单、最清晰的替代方法:

1
2
3
4
5
6
7
// Lazy initialization of instance field - synchronized accessor
private FieldType field;
private synchronized FieldType getField() {
if (field == null)
field = computeFieldValue();
return field;
}

这两种习惯用法(使用同步访问器进行常规初始化和延迟初始化)在应用于静态字段时都没有改变,只是在字段和访问器声明中添加了 static 修饰符。

如果需要在静态字段上使用延迟初始化来提高性能,使用 lazy initialization holder class 模式。 这个用法可保证一个类在使用之前不会被初始化 [JLS, 12.4.1]。它是这样的:

1
2
3
4
5
// Lazy initialization holder class idiom for static fields
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
private static FieldType getField() { return FieldHolder.field; }

第一次调用 getField 时,它执行 FieldHolder.field,导致初始化 FieldHolder 类。这个习惯用法的优点是 getField 方法不是同步的,只执行字段访问,所以延迟初始化实际上不会增加访问成本。典型的 VM 只会同步字段访问来初始化类。初始化类之后,VM 会对代码进行修补,这样对字段的后续访问就不会涉及任何测试或同步。

如果需要使用延迟初始化来提高实例字段的性能,请使用双重检查模式。这个模式避免了初始化后访问字段时的锁定成本(详见第 79 条)。这个模式背后的思想是两次检查字段的值(因此得名 double check):一次没有锁定,然后,如果字段没有初始化,第二次使用锁定。只有当第二次检查指示字段未初始化时,调用才初始化字段。由于初始化字段后没有锁定,因此将字段声明为 volatile 非常重要(详见第 78 条)。下面是这个模式的示例:

1
2
3
4
5
6
7
8
9
10
11
12
// Double-check idiom for lazy initialization of instance fields
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null) { // First check (no locking)
synchronized(this) {
if (field == null) // Second check (with locking)
field = result = computeFieldValue();
}
}
return result;
}

这段代码可能看起来有点复杂。特别是不清楚是否需要局部变量(result)。该变量的作用是确保 field 在已经初始化的情况下只读取一次。

虽然不是严格必需的,但这可能会提高性能,而且与低级并发编程相比,这更优雅。在我的机器上,上述方法的速度大约是没有局部变量版本的 1.4 倍。虽然您也可以将双重检查模式应用于静态字段,但是没有理由这样做:the lazy initialization holder class idiom 是更好的选择。

双重检查模式的两个变体值得注意。有时候,您可能需要延迟初始化一个实例字段,该字段可以容忍重复初始化。如果您发现自己处于这种情况,您可以使用双重检查模式的变体来避免第二个检查。毫无疑问,这就是所谓的「单检查」模式。它是这样的。注意,field 仍然声明为 volatile:

1
2
3
4
5
6
7
8
// Single-check idiom - can cause repeated initialization!
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null)
field = result = computeFieldValue();
return result;
}

本条目中讨论的所有初始化技术都适用于基本字段和对象引用字段。当双检查或单检查模式应用于数值基本类型字段时,将根据 0(数值基本类型变量的默认值)而不是 null 检查字段的值。

如果您不关心每个线程是否都会重新计算字段的值,并且字段的类型是 long 或 double 之外的基本类型,那么您可以选择在单检查模式中从字段声明中删除 volatile 修饰符。这种变体称为原生单检查模式。它加快了某些架构上的字段访问速度,代价是需要额外的初始化(每个访问该字段的线程最多需要一个初始化)。这绝对是一种奇特的技术,不是日常使用的。

总之,您应该正常初始化大多数字段,而不是延迟初始化。如果必须延迟初始化字段以实现性能目标或为了破坏有害的初始化循环,则使用适当的延迟初始化技术。对于字段,使用双重检查模式;对于静态字段,则应该使用the lazy initialization holder class idiom。例如,可以容忍重复初始化的实例字段,您还可以考虑单检查模式。

0%