Effective Java 第三版——79. 避免过度同步

时间:2021-07-08 17:04:56

Tips

书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code

注意,书中的有些代码里方法是基于Java 9 API中的,所以JDK 最好下载 JDK 9以上的版本。

Effective Java 第三版——79.  避免过度同步

79. 避免过度同步

条目 78警告我们缺乏同步的危险性。这一条目则涉及相反的问题。根据不同的情况,过度的同步可能导致性能下降、死锁甚至不确定性行为。

为了避免活性失败和安全性失败,永远不要在同步方法或代码块中将控制权交给客户端。换句话说,在同步区域内,不要调用设计为被重写的方法,或者由客户端以函数对象的形式提供的方法(条目 24)。从具有同步区域的类的角度来看,这种方法是外外来的(alien)。类不知道该方法做什么,也无法控制它。根据外来方法的作用,从同步区域调用它可能会导致异常、死锁或数据损坏。

要使其具体化说明这个问题,请考虑下面的类,它实现了一个可观察集合包装器(observable set wrapper)。当元素被添加到集合中时,它允许客户端订阅通知。这就是观察者模式(Observer pattern)[Gamma95]。为了简单起见,当元素从集合中删除时,该类不提供通知,但是提供通知也很简单。这个类是在条目 18(第90页)的ForwardingSet类实现的:

// 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方法取消订阅。 在这两种情况下,都会将此回调接口的实例传递给该方法:

@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也可以做出合理的论理由(条目 44)。

如果粗地略检查一下,ObservableSet似乎工作正常。 例如,以下程序打印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表达式不能访问自己(条目 42)。

你可能希望程序打印0到23的数字,之后观察者将取消订阅并且程序将以静默方式终止。 实际上,它打印这些数字然后抛出ConcurrentModificationException异常。 问题是notifyElementAdded在调用观察者的add方法时,正在迭代观察者的列表。 add方法调用observable setremoveObserver方法,该方法又调用方法bservers.remove。 现在我们遇到了麻烦。 我们试图在迭代它的过程中从列表中删除一个元素,这是非法的。 notifyElementAdded方法中的迭代在同步块中,防止并发修改,但它不会阻止迭代线程本身回调到可观察的集合并修改其观察者列表。

现在让我们尝试一些奇怪的事情:让我们编写一个尝试取消订阅的观察者,但不是直接调用removeObserver,而是使用另一个线程的服务来执行操作。 该观察者使用执行者服务(executor service)(条目 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中添加了这种称为multi-catch的工具。它可以极大地提高清晰度并减小程序的大小,这些程序在响应多种异常类型时的行为方式相同。

当运行这个程序时,没有得到异常:而是程序陷入僵局。 后台线程调用s.removeObserver,它试图锁定观察者,但它无法获取锁,因为主线程已经有锁。 一直以来,主线程都在等待后台线程完成删除观察者,这解释了发生死锁的原因。

这个例子是人为设计的,因为观察者没有理由使用后台线程来取消订阅本身,但是问题是真实的。在实际系统中,从同步区域内调用外来方法会导致许多死锁,比如GUI工具包。

在前面的异常和死锁两个例子中,我们都很幸运。调用外来added方法时,由同步区域(观察者)保护的资源处于一致状态。假设要从同步区域调用一个外来方法,而同步区域保护的不变量暂时无效。因为Java编程语言中的锁是可重入的,所以这样的调用不会死锁。与第一个导致异常的示例一样,调用线程已经持有锁,所以当它试图重新获得锁时,线程将成功,即使另一个概念上不相关的操作正在对锁保护的数据进行中。这种失败的后果可能是灾难性的。从本质上说,这把锁没能发挥它的作用。可重入锁简化了多线程面向对象程序的构建,但它们可以将活性失败转化为安全性失败。

幸运的是,通过将外来方法调用移出同步块来解决这类问题通常并不难。对于notifyElementAdded方法,这涉及到获取观察者列表的“快照”,然后可以在没有锁的情况下安全地遍历该列表。通过这样修改,前面的两个例子在运行时不会发生异常或死锁了:

// 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);
}

实际上,有一种更好的方法可以将外来方法调用移出同步代码块。Java类库提供了一个名为CopyOnWriteArrayList的并发集合(条目 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);
}

在同步区域之外调用的外来方法称为开放调用[Goetz06,10.1.4]。 除了防止失败,开放调用可以大大增加并发性。 外来方法可能会持续任意长时间。 如果从同步区域调用外来方法,则将不允许其他线程访问受保护资源。

作为一个规则,应该在同步区域内做尽可能少的工作。获取锁,检查共享数据,根据需要进行转换,然后删除锁。如果必须执行一些耗时的活动,请设法将其移出同步区域,而不违反条目 78 中的指导原则。

这个条目的第一部分是关于正确性的。现在让我们简要地看一下性能。虽然自Java早期以来,同步的成本已经大幅下降,但比以往任何时候都更重要的是,不要过度同步。在多核世界中,过度同步的真正代价不是获得锁花费的CPU时间:这是一种争论,失去了并行化的机会,以及由于需要确保每个核心都有一致的内存视图而造成的延迟。过度同步的另一个隐藏成本是,它可能限制虚拟机优化代码执行的能力。

如果正在编写一个可变类,有两个选项:可以省略所有同步,并允许客户端在需要并发使用时在外部进行同步,或者在内部进行同步,从而使类是线程安全的(条目 82)。 只有通过内部同步实现显着更高的并发性时,才应选择后一个选项,而不是让客户端在外部锁定整个对象。 java.util中的集合(过时的Vector和Hashtable除外)采用前一种方法,而java.util.concurrent中的集合采用后者(条目 81)。

在Java的早期,许多类违反了这些准则。 例如,StringBuffer实例几乎总是由单个线程使用,但它们执行内部同步。 正是由于这个原因,StringBuffer被StringBuilder取代,而StringBuilder只是一个不同步的StringBuffer。 同样,java.util.Random中的线程安全伪随机数生成器被java.util.concurrent.ThreadLocalRandom中的非同步实现取代,也是出于部分上述原因。 如有疑问,请不要同步你的类,但要建立文档,并记录它不是线程安全的。

如果在内部同步类,可以使用各种技术来实现高并发性,例如锁分割( lock splitting)、锁分段(lock striping)和非阻塞并发控制。这些技术超出了本书的范围,但是在其他地方也有讨论[Goetz06, Herlihy12]。

如果一个方法修改了一个静态属性,并且有可能从多个线程调用该方法,则必须在内部同步对该属性的访问(除非该类能够容忍不确定性行为)。多线程客户端不可能对这样的方法执行外部同步,因为不相关的客户端可以在不同步的情况下调用该方法。属性本质上是一个全局变量,即使它是私有的,因为它可以被不相关的客户端读取和修改。条目 78中的generateSerialNumber方法使用的nextSerialNumber属性演示了这种情况。

总之,为了避免死锁和数据损坏,永远不要从同步区域内调用外来方法。更通俗地说,在同步区域内所做的工作量保持在最低水平。在设计可变类时,请考虑它是否应该自己完成同步操作。在多核时代,比以往任何时候都更重要的是不要过度同步。只有在有充分理由时,才在内部同步类,并清楚地在文档中记录你的决定(条目 82)。