项目78警告同步不足的危险。这一项涉及相反的问题。根据不同的情况,过度的同步可能导致性能下降、死锁,甚至不确定性行为。
为了避免活动和安全故障,永远不要在同步方法或块中将控制权交给客户机。换句话说,在同步区域内,不要调用设计为被覆盖的方法,或者由客户机以函数对象的形式提供的方法(item24)。从具有同步区域的类的角度来看,这种方法是不一样的。类不知道该方法做什么,也无法控制它。根据异类方法的作用,从同步区域调用它可能会导致异常、死锁或数据损坏。
要使其具体化,请考虑下面的类,它实现了一个observable集包装器。当元素被添加到集合中时,它允许客户端订阅通知。这是观察者模式[Gamma95]。为了简洁起见,当元素从集合中删除时,类不提供通知,但是提供通知很简单。这个类是在第18项(第90页)的ForwardingSet上实现的:
观察者通过调用addObserver方法订阅通知,通过调用removeObserver方法取消订阅。在这两种情况下,都会将此回调接口的实例传递给方法。
该接口在结构上与BiConsumer<ObservableSet<E>,E>相同。我们选择定义一个自定义函数接口,因为接口和方法名称使代码更具可读性,而且接口可以发展为包含多个回调。也就是说,使用BiConsumer也可以提出合理的理由(item44 )。
粗略地检查一下,ObservableSet似乎工作得很好。例如,下面的程序打印从0到99的数字:
现在让我们尝试一些更奇特的东西。假设我们将addObserver调用替换为一个传递观察者的调用,该观察者打印添加到集合中的整数值,如果该值为23,则该调用将删除自身:
现在让我们尝试一些更奇特的东西。假设我们将addObserver调用替换为一个传递观察者的调用,该观察者打印添加到集合中的整数值,如果该值为23,则该调用将删除自身:
注意,这个调用使用一个匿名类实例来代替前面调用中使用的lambda。这是因为函数对象需要将自己传递给s.removeObserver以及lambdas不能访问它们自己( item42 )。
您可能希望程序打印数字0到23,然后观察者将取消订阅,程序将无声地终止。实际上,它打印这些数字,然后抛出ConcurrentModificationException。问题是notifyElementAdded在调用观察者的添加方法时,正在遍历观察者列表。:添加的方法调用可观察集的removeObserver方法,该方法反过来调用方法observer .remove。现在我们有麻烦了。我们试图在遍历列表的过程中从列表中删除一个元素,这是非法的。notifyElementAdded方法中的迭代位于一个同步块中,以防止并发修改,但它并不阻止迭代线程本身调用回可观察集并修改其观察者列表
现在让我们尝试一些奇怪的事情:让我们编写一个观察者来尝试取消订阅,但是它没有直接调用removeObserver,而是使用另一个线程的服务来执行这个操作。该观察者使用执行器服务(item80 ):
顺便提一下,注意这个程序在一个catch子句中捕获了两种不同的异常类型。在Java 7中添加了这个功能,非正式地称为multi-catch。它可以极大地提高清晰度,并减少在响应多种异常类型时表现相同的程序的大小。
当我们运行这个程序时,我们不会得到异常;我们会得到死锁。后台线程调用s.removeObserver,它试图锁定观察者,但无法获取锁,因为主线程已经拥有锁。一直以来,主线程都在等待后台线程完成删除观察者的操作,这就解释了死锁的原因。
这个例子是人为设计的,因为观察者没有理由使用后台线程来取消订阅本身,但是问题是真实的。在实际系统中,从同步区域内调用外来方法会导致许多死锁,比如GUI工具包。
在前面的两个例子中(异常和死锁),我们都很幸运。调用alien方法(添加)时,由同步区域(观察者)保护的资源处于一致状态。假设您要从同步区域调用一个外来方法,而同步区域保护的不变量暂时无效。因为Java编程语言中的锁是可重入的,所以这样的调用不会死锁。与第一个导致异常的示例一样,调用线程已经持有锁,所以当它试图重新获得锁时,线程将成功,即使另一个概念上不相关的操作正在对锁保护的数据进行中。这种失败的后果可能是灾难性的。从本质上说,这把锁没能发挥它的作用。可重入锁简化了多线程面向对象程序的构造,但它们可以将活动故障转化为安全故障。
幸运的是,通过将异类方法调用移出同步块来解决这类问题通常并不难。对于notifyElementAdded方法,这涉及到获取观察者列表的“快照”,然后可以在没有锁的情况下安全地遍历该列表。有了这个改变,前面的两个例子都可以毫无例外地运行或死锁:
实际上,有一种更好的方法可以将异形方法调用移出同步块。库提供了一个名为CopyOnWriteArrayList的并发集合(item81 ),该集合是为此目的量身定制的。此列表实现是ArrayList的变体,其中所有修改操作都是通过复制整个底层数组来实现的。因为从不修改内部数组,所以迭代不需要锁定,而且速度非常快。对于大多数使用,CopyOnWriteArrayList的性能会很差,但是对于很少修改和经常遍历的观察者列表来说,它是完美的。
如果将ObservableSet的add和addAll方法修改为使用CopyOnWriteArrayList,则不需要更改ObservableSet的add和addAll方法。下面是类的其余部分。请注意,没有任何显式同步:
在同步区域之外调用的外来方法称为open call [Goetz06, 10.1.4]。除了防止失败之外,开放调用还可以极大地提高并发性。一个陌生的方法可以运行任意长的时间。如果从同步区域调用了alien方法,其他线程将被不必要地拒绝访问受保护的资源
作为一个规则,您应该在同步区域内做尽可能少的工作。获取锁,检查共享数据,根据需要进行转换,然后删除锁。如果您必须执行一些耗时的活动,请设法将其移出同步区域,而不违反第78项中的指导原则。
这个项目的第一部分是关于正确性的。现在让我们简要地看一下性能。虽然自Java早期以来,同步的成本已经大幅下降,但比以往任何时候都更重要的是不要过度同步。在多核世界中,过度同步的真正代价不是获得锁所花费的CPU时间;这就是竞争:失去了并行化的机会以及延迟强制要求确保每个核心都有一个一致的内存视图。过度同步的另一个隐藏成本是,它可能限制VM优化代码执行的能力。
:如果您正在编写一个可变类,您有两个选项:您可以省略所有同步,如果需要并发使用,则允许客户机在外部同步,或者可以在内部同步,使类线程安全( item82 )。只有当您能够通过内部同步实现比通过让客户机在外部锁定整个对象要高得多的并发性时,才应该选择后者。 java.util 的集合(废弃的Vector和Hashtable除外)采用前一种方法,而java.util.concurrent采用后者(item81 )。
在Java的早期,许多类违反了这些准则。例如,
StringBuffer实例几乎总是由一个线程使用,但是它们执行内部同步。正是由于这个原因,StringBuffer被StringBuilder取代,而StringBuilder只是一个未同步的StringBuffer。类似地,java.util.Random中的线程安全伪随机数生成器也是一个重要原因。Random被java.util.concurrent.ThreadLocalRandom中的非同步实现取代,如果有疑问,不要同步您的类,但要记录它不是线程安全的。
如果您在内部同步您的类,您可以使用各种技术来实现高并发性,例如锁分割、锁分段和非阻塞并发控制。这些技术超出了本书的范围,但是在其他地方也有讨论[Goetz06, Herlihy08]。
如果一个方法修改了一个静态字段,并且有可能从多个线程调用该方法,则必须在内部同步对该字段的访问(除非该类能够容忍不确定性行为)。多线程客户机不可能对这样的方法执行外部同步,因为不相关的客户机可以在不同步的情况下调用该方法。字段本质上是一个全局变量,即使它是私有的,因为它可以被不相关的客户机读取和修改。项目78中的generateSerialNumber方法使用的nextSerialNumber字段演示了这种情况。
总之,为了避免死锁和数据损坏,永远不要从同步区域内调用外来方法。更一般地,将您在同步区域内所做的工作量保持在最小。在设计可变类时,请考虑它是否应该执行自己的同步。在多核时代,比以往任何时候都更重要的是不要过度同步。只有在有充分理由时,才在内部同步类,并清楚地记录您的决定(item82 )。
本文写于2019.7.23,历时1天