Tips
书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code
注意,书中的有些代码里方法是基于Java 9 API中的,所以JDK 最好下载 JDK 9以上的版本。
82. 线程安全文档化
当并发使用一个类的方法时,类的行为方式是其与客户端建立约定的重要部分。如果未能文档化记录某个类行为的这一方面,其用户只能做出做出假设。如果这些假设是错误的,则生成的程序可能执行同步不够(条目 78)或过度同步(条目 79)。 无论哪种情况,都可能导致严重错误。
你可能听说过,可以通过在方法的文档中查找synchronized修饰符来判断该方法是否线程安全的。这在几个方面来讲是错误的。在正常操作中,Javadoc的输出中没有包含synchronized修饰符,这是有原因的。方法声明中synchronized修饰符的存在是实现细节,而不是其API的一部分。它不能可靠地说明方法是是线程安全的。
此外,声称存在synchronized修饰符就足以文档记录线程安全性,这体现了线程安全性是要么全有要么全无属性的误解。实际上,线程安全有几个级别。要启用安全的并发使用,类必须清楚地文档记录它支持的线程安全级别。下面的列表总结了线程安全级别。它并非详尽无遗,但涵盖以下常见情况:
-
不可变的(Immutable) —— 该类的实例看起来是不变的(constant)。不需要外部同步。示例包括String、Long和BigInteger(条目 17)。
-
无条件线程安全(Unconditionally thread-safe) —— 此类的实例是可变的,但该类具有足够的内部同步,以便可以并发使用其实例而无需任何外部同步。 示例包括AtomicLong和ConcurrentHashMap。
-
有条件线程安全(Conditionally thread-safe) —— 与无条件线程安全一样,但某些方法需要外部同步以便安全并发使用。 示例包括
Collections.synchronized
包装器返回的集合,其迭代器需要外部同步。 -
非线程安全(Not thread-safe) —— 这个类的实例是可变的。 要并发使用它们,客户端必须使用其选择的外部同步来包围每个方法调用(或调用序列)。 示例包括通用集合实现,例如ArrayList和HashMap。
-
线程对立(Thread-hostile) —— 即使每个方法调用都被外部同步包围,该类对于并发使用也是不安全的。线程对立通常是由于在不同步的情况下修改静态数据而导致的。没有人故意编写线程对立类;此类通常是由于没有考虑并发性而导致的。当发现类或方法与线程不相容时,通常将其修正或弃用。条目 78中的
generateSerialNumber
方法在没有内部同步的情况下是线程对立的,如第322页所述。
这些分类(除了线程对立)大致对应于《Java Concurrency in Practice》一书中的线程安全注解,分别是Immutable,ThreadSafe和NotThreadSafe [Goetz06,附录A]。 上述分类中的无条件和条件线程安全类别都包含在ThreadSafe注解中。
在文档记录了一个有条件的线程安全类需要小心。 你必须指明哪些调用序列需要外部同步,以及必须获取哪个锁(或在极少数情况下是几把锁)才能执行这些序列。 通常是实例本身的锁,但也有例外。 例如,Collections.synchronizedMap的文档说明了这一点:
It is imperative that the user manually synchronize on the returned map when iterating over any of its collection views:
当迭代任何Map集合的视图时,用户必须手动同步返回的Map:Map<K, V> m = Collections.synchronizedMap(new HashMap<>()); Set<K> s = m.keySet(); // Needn't be in synchronized block ... synchronized(m) { // Synchronizing on m, not s! for (K key : s) key.f(); }
不遵循此建议可能会导致不确定性行为。
类的线程安全性的描述通常属于类的文档注释,但具有特殊线程安全属性的方法应在其自己的文档注释中描述这些属性。 没有必要记录枚举类型的不变性。 除非从返回类型中显而易见,否则静态工厂必须在文档中记录返回对象的线程安全性,如Collections.synchronizedMap(上文)所示。
当类承诺使用可公开访问的锁时,它允许客户端以原子方式执行一系列方法调用,但这种灵活性需要付出代价。 它与并发集合(如ConcurrentHashMap)使用的高性能内部并发控制不兼容。 此外,客户端可以通过长时间保持可公开访问的锁来发起拒绝服务攻击。 这可能是偶然也可能是故意的。
要防止此拒绝服务攻击,可以使用私有锁对象而不是使用synchronized方法(这隐含着可公开访问的锁):
// Private lock object idiom - thwarts denial-of-service attack
private final Object lock = new Object();
public void foo() {
synchronized(lock) {
...
}
}
由于私有锁对象在类外是不可访问的,因此客户端不可能干扰对象的同步。 实际上,我们通过将锁定对象封装在它同步的对象中来应用条目 15的建议。
请注意,锁定属性(lock field)被声明为final。 这可以防止无意中更改其内容,从而导致灾难性的非同步访问(条目 78)。 我们通过最小化锁定属性(lock field)的可变性来应用条目 17的建议。 锁定属性(lock field)应始终声明为final。 无论使用普通的监视器锁(如上所示)还是使用java.util.concurrent.locks包中的锁,都是如此。
私有锁对象习惯用法只能用于无条件线程安全类。 有条件线程安全类不能使用这个习惯用法,因为它们必须文档记录在执行某些方法调用序列时客户端要获取的锁。
私有锁对象习惯用法特别适合用于为继承设计的类(条目 19)。 如果这样的类要使用其实例进行锁定,则子类可能容易且无意地干扰基类的操作,反之亦然。 通过为不同的目的使用相同的锁,子类和基类可能最终“踩到彼此的脚趾。”这不仅仅是一个理论问题;它就发生在Thread类上[Bloch05,Puzzle 77]中。
总之,每个类都应该用措辞严谨的描述或线程安全注解清楚地文档记录其线程安全属性。synchronized修饰符在本文档中没有任何作用。条件线程安全类必须文档记录哪些方法调用序列需要外部同步,以及在执行这些序列时需要获取哪些锁。如果你编写一个无条件线程安全的类,请考虑使用一个私有锁对象来代替同步方法。这将保护免受客户端和子类的同步干扰,并提供更大的灵活性,以便在后续的版本中采用复杂的并发控制方法。