第79项:避免过度同步

  第78项告诫我们缺少同步的危险性。本项则关注相反的问题。依据情况的不同,过度同步可能会导致性能降低、死锁,甚至不确定的行为。

  为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要放弃对客户端的控制 。换句话说,在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法(第24项)。从包含该同步区域的类的角度来看,这样的方法是外来的(alien)。这个类不知道该方法会做什么事情,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。

  为了对这个过程进行更具体的说明,来考虑下面的类,它实现了一个*可以观察(observable)到的集合包装(set wrapper)。该类允许客户端在将元素添加到集合中时预定通知。这就是观察者(Observer)*模式[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;
    }
}

  Observer通过调用addObserver方法预订通知,通过调用removeObserver方法取消预订。在这两种情况下,这个*回调接口(callback)*的实例都会被传递给方法:

@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>相同。我们选择定义自定义功能接口,因为接口和方法名称使代码更具有可读性,同时也因为接口可以演变为包含多个回调。也就是说,使用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调用来代替这个调用,用来替换的那个addObserver调用传递了一个打印Integer值的观察者,这个值被添加到该集合中,如果值为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不能访问自己【在lambdas里面无法使用this关键字】(第42项)。

  你可能会期望这个程序会打印出023的数字,之后观察者会取消预定。实际上,它打印出023的数字之后抛出了ConcurrentModificationException异常。问题在于,当notifyElementAdded调用观察者的added方法时,它正处于遍历observers列表的过程中。added方法调用可观察集合的removeObserver方法,从而调用observers.remove。现在我们有麻烦了。我们正企图在遍历列表的过程中,将一个元素从列表中删除,这是非法的。notifyElementAdded方法中的迭代是在一个同步块中,可以防止并发的修改,但是无法防止迭代线程本身回调到观察的集合中,也无法防止修改它的observers列表。

  现在我们要尝试一些比较奇特的例子:我们来编写一个试图取消预定的观察者,但是不直接调用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中添加了这种临时【把它】称为*多重catch(multi-catch)*的工具。它可以极大地提高【代码的】清晰度并减小程序的大小,这些程序在响应多种异常类型时的行为是相同。

  当我们运行这个程序的时候,我们没有遇到异常,而是遭遇了死锁。后台线程调用s.removeObserver,它企图锁定observers,但它无法获得该锁,因为主线程已经有锁了。在这期间,主线程一直在等待后台线程来完成对观察者的删除,这正是造成死锁的原因。

  这个例子是可以编写用来示范的,因为观察者实际上没有理由使用后台线程,但这个问题却是真是的。从同步区域中调用外来的方法,在真实的系统中已经造成了许多死锁,例如GUI工具箱。

  在前面的这两个例子中(异常和死锁),我们都还算辛运的。调用外来方法(added)时,同步区域(observers)所保护的资源处于一致的状态。假设当同步区域所保护的约束条件暂时无效时,你要从同步区域中调用一个外来方法。由于Java程序设计语言中的锁是可重入的(reentrant),这种调用不会死锁。就像在第一个例子中一样,它会产生一个异常,因为调用线程已经有这个锁了,因此当该线程试图再次获得该锁时会成功,尽管概念上不相关的另一项操作正在该锁所保护的数据上进行着。这种失败的后果可能是灾难性的。从本质上说,这个锁没有尽到它的职责。可再重入锁简化了多线程的面向对象程序的构造,但是它们可能会将活性失败(liveness failure)变成安全性失败(safety failure)。

  辛运的是,通过将外来方法的调用移出同步的代码块来解决这个问题通常并不太困难。对于notifyElementAdded方法,这还涉及给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);
}

  事实上,要将外来方法的调用移出同步的代码块,还有一种更好的方法。在Java类库中提供了一个并发集合(concurrent collection)(第81项),称为CopyOnWriteArrayList,这是专门为此目的定制的。这是List的一种实现,相当于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]。除了避免失败意外,开放调用还可以极大地增加并发性。外来方法的运行时长是不固定的。如果在同步区域内调用外来方法,其他线程对受保护资源的访问就会遭到不必要的拒绝。

  通常,你应该在同步区域内做尽可能少的工作 。获得锁,检查共享数据,根据需要转换数据,然后释放锁。如果你必须要执行某个很耗时间的动作,则应该设法把这个动作移到同步区域的外面,而不违背第78项中的指导方针。

  本项的第一部分是关于正确性的。接下来,我们要简单地讨论一下性能。虽然自从Java平台早期以来,同步的成本已经急剧下降了,但更重要的是,永远不要过度同步。在这个多核的时代,过度同步的实际成本并不是指获取锁所花费的CPU时间;而是争抢锁:这样就失去了并行的机会,以及因为需要确保每个核都有一个一致的内存视图而导致的延迟。过度同步的另一项潜在开销在于,它会限制VM优化代码执行的能力。

  如果你正在编写一个可变类,你有两个选择:你可以省略所有同步并允许客户端在需要并发使用时从外部进行同步,或者你可以在内部进行同步,使类变成是*线程安全(thread-safe)*的(第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)和非阻塞(nonblocking)并发控制。这些方法都超出了本书的讨论范围,但是它们在其他地方有讨论过[Goetz06, Herlihy08]。

  如果方法修改了静态域,并且有可能从多个线程调用该方法,则必须在内部同步对该域的访问(除非该类可以容忍具有不确定性的行为)。多线程客户端无法在此类方法上执行外部同步,因为不相关的客户端可以在不同步的情况下调用该方法。该字段本质上是一个全局变量,即使它是私有的,因为它可以由不相关的客户端读取和修改。第78项中方法generateSerialNumber使用的nextSerialNumber字段举例说明了这种情况。

  简而言之,为了避免死锁和数据破坏,千万不要从同步区域调用外来方法。更为一般地将,要尽量限制同步区域内部的工作量。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在现在这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这么做,同时还应该将这个决定清楚地写到文档中(第82项)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值