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

Tips
书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code
注意,书中的有些代码里方法是基于Java 9 API中的,因此JDK 最好下载 JDK 9以上的版本。java

Effective Java, Third Edition

79. 避免过分同步

条目 78警告咱们缺少同步的危险性。这一条目则涉及相反的问题。根据不一样的状况,过分的同步可能致使性能降低、死锁甚至不肯定性行为。git

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

要使其具体化说明这个问题,请考虑下面的类,它实现了一个可观察集合包装器(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方法中的迭代在同步块中,防止并发修改,但它不会阻止迭代线程自己回调到可观察的集合并修改其观察者列表。app

如今让咱们尝试一些奇怪的事情:让咱们编写一个尝试取消订阅的观察者,但不是直接调用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)。

相关文章
相关标签/搜索