ITEM 79: AVOID EXCESSIVE SYNCHRONIZATION
item 78 警告了同步不足的危险,这一项涉及相反的问题:根据不同的情况,过度的同步可能会导致性能下降、死锁甚至不确定性行为。
为了避免活动性和安全性故障,永远不要在同步方法或同步块中将控制权交给客户端。换句话说,在同步区域内,不要调用被设计为要重写的方法,或者客户端以函数对象的形式提供的方法(item 24)。从具有同步区域的类的角度来看,这些方法是异类。类不知道该方法做什么,也无法控制它。根据异类方法的作用,从同步区域调用它可能会导致异常、死锁或数据损坏。
为了使这个具体化,考虑下面的类 ObservableSet ,它实现了一个包装器。当元素被添加到集合时,它允许客户订阅通知。这是观察者模式[Gamma 95]。为简洁起见,当元素从集合中删除时,类不提供通知,但是提供通知很简单。这个类是在Item 18 的可重用forward set之上实现的:
// Broken - invokes alien method from synchronized block!
public class ObservableSet<E> extends ForwardingSet<E> {
public ObservableSet(Set<E> set) {
super(set);
}
private final List<SetObserver<E>> observers = new ArrayList<>();
public void addObserver(SetObserver<E> observer) {
synchronized(observers) {
observers.add(observer);
}
}
public boolean removeObserver(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 notifyElementAdded
return result;
}
}
观察者通过调用 addObserver 方法订阅通知,通过调用 removeObserver 方法取消订阅。在这两种情况下,实现了 下面这个接口的每个 observer 都将被调用其 added 方法。
@FunctionalInterface
public interface SetObserver<E> {
// Invoked when an element is added to the observable set
void added(ObservableSet<E> set, E element);
}
这个接口在结构上与 BiConsumer<ObservableSet<E>,E> 相同。我们之所以选择定义一个自定义函数接口,是因为接口和方法名称使代码更具可读性,而且接口可以发展为包含多个回调。尽管如此,也可以提出使用BiConsumer(item 44)的合理理由。
粗略地检查,观察器似乎工作良好。例如,下面的程序打印从0到99的数字:
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 调用替换为一个调用,该调用传递一个观察者,该观察者打印添加到集合中的整数值,并在值为 23 时删除自身:
set.addObserver(new SetObserver<>() {
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23)
s.removeObserver(this);
}
});
注意,这个调用使用一个匿名类实例来代替前一个调用中使用的 lambda。这是因为函数对象需要将自身传递给 s.removeObserver 和 lambdas 不能访问它们自己(item 42)。
您可能希望程序打印数字0到23,在此之后观察者将取消订阅,程序将静默终止。实际上,它打印这些数字,然后抛出一个 ConcurrentModificationException 异常。问题在于,当notifyelementAdd 调用观察者的 added 方法时,它正在对观察者列表进行迭代。
added 方法调用 removeObserver 方法,该方法反过来调用 observer.remove 方法。现在我们有麻烦了。我们试图在迭代过程中从列表中删除一个元素,这是不合法的。notifyelementAdd 方法中的迭代位于一个同步块中,以防止并发修改,但它不能阻止迭代线程本身回调可观察集合并修改其观察者列表。
现在让我们尝试一些奇怪的事情:让我们编写一个试图取消订阅的观察者,但是它不是直接调用removeObserver,而是使用另一个线程的服务来执行该操作。此观察者使用一个 executor 服务(item 80):
// Observer that uses a background thread needlessly
set.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 中添加的。它可以极大地增加对多个异常类型做出相同响应的程序的清晰度,并减少程序的大小。
当我们运行这个程序时,我们不会得到异常;我们将陷入死锁。后台线程调用s.removeObserver,它试图锁定观察者,但是它无法获得锁,因为主线程已经有了锁。在此期间,主线程一直在等待后台线程完成删除观察者的操作,这就解释了死锁的原因。
这个例子是人造的,因为观察者没有理由使用后台线程来退订自己,但问题是真实的。从同步区域内调用外来方法会在实际系统中造成许多死锁,例如 GUI 工具包。
在前面的两个例子(异常和死锁)中,我们都很幸运。当调用异类方法(added)时,由同步区域(observers)保护的资源处于一致状态。假设您要从同步区域调用一个异类方法,而同步区域保护的不变式暂时无效。因为 Java 编程语言中的锁是可重入的,所以这样的调用不会死锁。在第一个示例中,调用线程已经持有锁,因此在尝试重新获取锁时,即使另一个概念上不相关的操作正在对由锁保护的数据进行操作,也会成功。这种失败的后果可能是灾难性的。从本质上说,锁没有完成它的工作。可重入锁简化了多线程面向对象程序的构造,但它们可以将活性故障转化为安全故障。
幸运的是,通过将异类方法调用移出同步块来修复这类问题并不太难。对于 notifyelementAdd 方法,这涉及到获取 observers 列表的“快照”,然后可以在不使用锁的情况下安全地遍历该列表。有了这个变化,前面的两个例子都运行没有异常或死锁:
// Alien method moved outside of synchronized block - open calls
private void notifyElementAdded(E element) {
List<SetObserver<E>> snapshot = null;
synchronized(observers) {
snapshot = new ArrayList<>(observers);
}
for (SetObserver<E> observer : snapshot)
observer.added(this, element);
}
实际上,有一种更好的方法可以将异类方法调用移出同步块。这些库提供了一个称为CopyOnWriteArrayList 的并发集合(item 81),它是为此目的而定制的。这个列表实现是 ArrayList 的一个变体,其中所有修改操作都是通过对整个底层数组进行新复制来实现的。因为从来没有修改过内部数组,所以迭代不需要锁,而且非常快。在大多数情况下,CopyOnWriteArrayList 的性能会非常糟糕,但是它对于观察者列表来说非常完美,观察者列表很少被修改,而且经常被遍历。
如果列表被修改为使用 CopyOnWriteArrayList,则不需要更改 ObservableSet 的 add 和 addAll方法。下面是类的其余部分。注意,没有显式同步:
// Thread-safe observable set with CopyOnWriteArrayList
private final List<SetObserver<E>> observers = 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]。除了防止失败之外,open call 还可以极大地提高并发性。一个陌生的方法可能运行任意长的周期。如果从同步区域调用了异类方法,那么其他线程将不必要地拒绝访问受保护的资源。
作为规则,您应该在同步区域内做尽可能少的工作。获取锁,检查共享数据,必要时转换它,并删除锁。如果您必须执行一些耗时的活动,请找到一种方法将其移出同步区域,而不违反 item 78 中的指导原则。
这个项目的第一部分是关于正确性的。现在让我们简单看看性能。虽然自 Java 早期以来,同步的成本已经大幅下降,但更重要的是不要过度同步。在多核世界中,过度同步的真正成本不是获取锁所花费的 CPU 时间;它是争用性:由于需要确保每个核具有一致的内存视图而导致的并行性机会的丧失和延迟。过度同步的另一个潜在代价是,它会限制虚拟机优化代码执行的能力。
如果您正在编写一个可变类,您有两个选择:如果需要并发使用,您可以省略所有同步并允许客户端在外部同步,或者您可以在内部同步,使类线程安全(item 82)。只有当使用内部同步可以实现比让客户端在外部锁定整个对象高得多的并发性时,才应该选择后一个选项。java.util (过时的Vectorand Hashtable 除外) 采用前一种方法,而 java.util.concurrent 的方法则采用第二种方法。
在 Java 的早期,许多类违反了这些准则。例如,StringBuffer 实例几乎总是由单个线程使用,但是它们执行内部同步。正是由于这个原因,StringBuffer 被 StringBuilder 取代,它只是一个非同步的 StringBuffer。类似地,这也是 java.util 中使用线程安全伪随机数生成器的主要原因。Random 被 java.util.concurrent.ThreadLocalRandom 中的非同步实现取代。当有疑问时,不要同步类,而是说明它不是线程安全的。
如果在内部同步类,可以使用各种技术来实现高并发性,比如锁分割、锁分段和非阻塞并发性控制。这些技术超出了本书的范围,但是它们在其他地方被讨论[Goetz06, Herlihy08]。
如果一个方法修改了一个静态字段,并且有可能从多个线程调用该方法,那么您必须在内部同步对该字段的访问(除非类能够容忍非确定性行为)。多线程客户端不可能在这样的方法上执行外部同步,因为不相关的客户端可以在不同步的情况下调用该方法。字段本质上是一个全局变量,即使它是私有的,因为它可以被不相关的客户端读取和修改。Item 78 中的方法generateSerialNumber 使用的 nextSerialNumber 字段演示了这种情况。
总之,为了避免死锁和数据损坏,永远不要从同步区域内调用异类方法。更进一步,将您在同步区域内所做的工作量保持在最小。在设计可变类时,请考虑它是否应该进行自己的同步。在多核时代,不过度同步比以往任何时候都更重要。只有在有很好的理由时才在内部同步类,并清楚地记录您的决定(item 82)。
网友评论