Effective Java读书笔记-16
避免过度同步
通常来说,应该在同步区域内做尽可能少的工作。获得锁,检查共享数据,根据需要转换数据,然后释放锁。如果必须要执行某个很耗时的动作,则应该设法把这个动作移到同步区域的外面。
自Java平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过度同步。在这个多核的时代,过度同步的实际成本并不是指获取锁所花费的CPU时间;而是指失去了并行的机会,以及因为需要确保每个核都有一个一致的内存视图而导致的延迟。过度同步的另一项潜在开销在于,它会限制虚拟机优化代码执行的能力。
如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如分拆锁、分离锁和非阻塞并发控制。如果方法修改了静态域,并且该方法很可能要被多个线程调用,那么也必须在内部同步对这个域的访问。
为了避免死锁和数据破坏,千万不要从同步区字段内部调用外来方法更通俗地讲,要尽量将同步区字段内部的工作量限制到最少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在如今这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这么做,同时还应该将这个决定清楚地写到文档中。
线程安全性的文档化
当一个类的方法被并发使用的时候,这个类的行为如何,是该类与其客户端程序建立的约定的重要组成部分。如果没有在一个类的文档中描述其行为的并发性情况,使用这个类的程序员将不得不做出某些假设。如果这些假设是错误的,所得到的程序就可能缺少足够的同步,或者过度同步。无论属于这其中的哪一种情况,都可能会发生严重的错误。
通过查看文档中是否出现synchronized修饰符,可以确定一个方法是否是线程安全的。这种说法从几个方面来说都是错误的。 在正常的操作中,Javadoc并没有在它的输出中包含synchronized修饰符。因为在一个方法声明中出现 synchronized修饰符,这是个实现细节,并不是导出的API的一部分。它并不一定表明这个方法是线程安全的。
而且“出现了synchronized关键字就足以用文档说明线程安全性”的这种说法隐含了一个错误的观念,即认为线程安全性是一种“要么全有要么全无”的属性。实际上,线程安全性有多种级别。一个类为了可被多个线程安全地使用,必须在文档中清楚地说明它所支持的线程安全性级别。下述分项概括了线程安全性的几种级别。这些分项并没有涵盖所有的可能,只是列出了常见的情形:
-
不可变的(immutable)——这个类的实例是不变的。所以,不需要外部的同步。这样的例子包括String、Long和BigInteger。
-
无条件的线程安全(unconditionally
thread-safe)——这个类的实例是可变的,但是这个类有着足够的内部同步,所以它的实例可以被并发使用,无须任何外部同步。其例子包括AtomicLong和ConcurrentHashMap。 -
有条件的线程安全(conditionally
thread-safe)——除了有些方法为进行安全的并发使用而需要外部同步之外,这种线程安全级别与无条件的线程安全相同。这样的例子包括Collections.synchronized包装返回的集合,它们的迭代器要求外部同步。 -
非线程安全(not
thread-safe)——这个类的实例是可变的。为了并发地使用它们,客户端必须利用自己选择的外部同步包围每个方法调用(或者调用序列)。这样的例子包括通用的集合实现,例如ArrayList和HashMap。 -
线程对立的(thread-hostile)——这种类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有同步地修改静态数据。没有人会有意编写一个线程对立的类;这种类是因为没有考虑到并发性而产生的后果。当一个类或者方法被发现是线程对立的,一般会得到修正,或者被标注为"不再建议使用"。
Lock字段应该始终声明为 final。无论使用普通的监视器锁还是 java.util.concurrent 包中的锁。
每个类都应该利用字斟句酌的说明或者线程安全注解,清楚地在文档中说明它的线程安全属性。synchronized修饰符与这个文档毫无关系。有条件的线程安全类必须在文档中指明“哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁”。如果你编写的是无条件的线程安全类,就应该考虑使用私有锁对象来代替同步的方法。这样可以防止客户端程序和子类的不同步干扰,让你能够在后续的版本中灵活地对并发控制采用更加复杂的方法。
慎用延迟初始化
延迟初始化(lazy initialization)是指延迟到需要域的值时才将它初始化的行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既适用于静态域,也适用于实例域。虽然延迟初始化主要是一种优化,但它也可以用来破坏类中的有害循环和实例初始化。
就像大多数的优化一样,对于延迟初始化,最好建议"除非绝对必要,否则就不要这么做"。 延迟初始化就像一把双刃剑。它降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化的域的开销。根据延迟初始化的域的哪个部分最终需要初始化、初始化这些域要多少开销,以及每个域多久被访问一次,延迟初始化(就像其他的许多优化一样)实际上降低了性能。
如果域只在类的实例部分被访问,并且初始化这个域的开销很高,可能就值得进行延迟初始化。要确定这一点,唯一的办法就是测量类在用和不用延迟初始化时的性能差别。
在大多数情况下,正常的初始化要优先于延迟初始化。下面是正常初始化的实例域的一个典型声明。注意其中使用了final修饰符:
// Normal initialization of an instance field
private final FieldType field = computeFieldValue();
如果利用延迟优化来破坏初始化的循环,就要使用同步访问方法,因为它是最简单、最清楚的替代方法:
// Lazy initialization of instance field-synchronized accessor
private FieldType field;
private synchronized FieldType getField() {
if (field == null) {
field = computeFieldValue();
return field;
}
}
如果出于性能的考虑而需要对实例域使用延迟初始化,就使用双重检查模式(double-check idiom)。这种模式避免了在域被初始化之后访问这个域时的锁定开销。 这种模式背后的思想是:两次检查域的值,因此名字叫双重检查(double-check),第一次检查时没有锁定,看看这个域是否被初始化了;第二次检查时有锁定。只有当第二次检查时表明这个域没有被初始化,才会对这个域进行初始化。因为如果域已经被初始化就不会有锁定,这个域被声明为volatile就很重要了。下面就是这种习惯模式:
// 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;
}
双重检查模式的两个变量值得一提。有时可能需要延迟初始化一个可以接受重复初始化的实例域。如果处于这种情况,就可以使用单重检查模式(single-check idiom)。下面就是这样的一个例子。注意field仍然被声明为volatile:
// 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;
}
大多数的域应该正常地进行初始化,而不是延迟初始化。 如果为了达到性能目标,或者为了破坏有害的初始化循环,而必须延迟初始化一个域,就可以使用相应的延迟初始化方法。对于实例域,就使用双重检查模式(double-check idiom);对于静态域,则使用lazy initialization holder class idiom。对于可以接受重复初始化的实例域,也可以考虑使用单重检查模式(single-check idiom)。
不要依赖于线程调度器
当有多个线程可以运行时,由线程调度器(thread scheduler)决定哪些线程将会运行,以及运行多长时间。任何一个合理的操作系统在做出这样的决定时,都会努力做到公正,但是所采用的策略却大相径庭。因此,编写良好的程序不应该依赖于这种策略的细节。任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能都是不可移植的。
要编写出健壮、响应良好、可移植的多线程应用程序,最好的办法是确保可运行线程的平均数量不明显多于处理器的数量。这使得线程调度器没有更多的选择:它只需要运行这些可运行的线程,直到它们不再可运行为止。
保持可运行线程数量尽可能少的主要方法是,让每个线程做些有意义的工作,然后等待更多有意义的工作。如果线程没有在做有意义的工作,就不应该运行。
线程不应该一直处于忙-等(busy-wait)的状态,即反复地检查一个共享对象,以等待某些事情发生。除了使程序易受到调度器的变化影响之外,忙-等这种做法也会极大地增加处理器的负担,降低了同一机器上其他进程可以完成的有用工作量。作为不该做的一个极端的反面例子,考虑下面这个CountDownLatch的不正当的重新实现:
// Awful CountDownLatch implementation-busy-waits incessantly!
public class SlowCountDownLatch {
private int count;
public SlowCountDownLatch(int count) {
if (count < 0) {
throw new IllegalArgumentException(count + "< 0");
this.count = count;
}
public void await() {
while (true) {
synchronized (this) {
if (count == 0) {
return;
}
}
}
}
public synchronized void countDown () {
if (count != 0) {
count--;
}
}
}
}
总而言之,不要让应用程序的正确性依赖于线程调度器。否则,得到的应用程序将既不健壮,也不具有可移植性。 同样,不要依赖Thread.yield或者线程优先级。这些机制都只是影响到调度器。线程优先级可以用来提高一个已经能够正常工作的程序的服务质量,但永远不应该用来"修正"一个原本并不能工作的程序。