Effective Java 第三版——79. 避免过度同步
Tips
书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code
注意,书中的有些代码里方法是基于Java 9 API中的,所以JDK 最好下载 JDK 9以上的版本。
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 set
的removeObserve
r方法,该方法又调用方法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)。
Effective Java 第三版——79. 避免过度同步的更多相关文章
- 《Effective Java 第三版》新条目介绍
版权声明:本文为博主原创文章,可以随意转载,不过请加上原文链接. https://blog.csdn.net/u014717036/article/details/80588806前言 从去年的3月份 ...
- 《Effective Java 第三版》目录汇总
经过反复不断的拖延和坚持,所有条目已经翻译完成,供大家分享学习.时间有限,个别地方翻译得比较仓促,希望有疑虑的地方指出批评改正. 第一章简介 忽略 第二章 创建和销毁对象 1. 考虑使用静态工厂方法替 ...
- Effective Java 第三版——45. 明智审慎地使用Stream
Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将 ...
- Effective Java 第三版——1. 考虑使用静态工厂方法替代构造方法
Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将 ...
- Effective Java 第三版——3. 使用私有构造方法或枚类实现Singleton属性
Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将 ...
- Effective Java 第三版——7. 消除过期的对象引用
Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将 ...
- Effective Java 第三版——9. 使用try-with-resources语句替代try-finally语句
Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将 ...
- Effective Java 第三版——10. 重写equals方法时遵守通用约定
Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将 ...
- Effective Java 第三版——11. 重写equals方法时同时也要重写hashcode方法
Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将 ...
随机推荐
- HashMap 源码阅读
前言 之前读过一些类的源码,近来发现都忘了,再读一遍整理记录一下.这次读的是 JDK 11 的代码,贴上来的源码会去掉大部分的注释, 也会加上一些自己的理解. Map 接口 这里提一下 Map 接口与 ...
- myBatis之Clob & Blob
1. 表结构 1.1 在Mysql中的数据类型,longblob --> blob, longtext --> clob 2. 配置文件, 请参考 myBatis之入门示例 3. L ...
- C# 动态调用WebService 2
using Microsoft.CSharp; using System; using System.CodeDom; using System.CodeDom.Compiler; using Sys ...
- python 3.6 + numpy + matplotlib + opencv + scipy 安装
首先,下载并安装 python3.6: 然后,在网址http://www.lfd.uci.edu/~gohlke/pythonlibs/ 上 分别下载 numpy.scipy.matplotlib.o ...
- 南阳325----zb的生日
//重量太大,用01背包容易超时 //数据只有20组.可以用dfs搜索最优解 #include<stdio.h> ]; int maxs,n,s; void dfs(int,int); i ...
- Shell脚本笔记(二)Shell变量
Shell变量 一)全局环境变量 全局变量对于定义它的shell和其子shell都是可见的,但如果生成它的shell被终止,全局变量也就消失了.另外全局变量会被子shell的同名变量覆盖. #定义一个 ...
- setState()之后使用state的问题
一开始知道setState()是异步执行方法,在使用这个方法改变state之后直接用this.state获取的state不是更新之后的,只有render发生变化的时候才触发this.setState( ...
- BZOJ4133 : Answer的排队
设$f[i][j]$表示考虑前$i$个人,第$i$个人在前$i$个人中排名为$j$的方案数. 对于大小关系相同的一段,转移可以看成求$k$次前/后缀和,任意一项对另一项的贡献仅和其下标差值有关,FFT ...
- 创建触发器(trigger)
创建触发器 DROP TRIGGER IF EXISTS `ins_table_name`; DELIMITER ;; CREATE TRIGGER `ins_table_name` AFTER IN ...
- PAT基础6-6
6-6 求单链表结点的阶乘和 (15 分) 本题要求实现一个函数,求单链表L结点的阶乘和.这里默认所有结点的值非负,且题目保证结果在int范围内. 函数接口定义: int FactorialSum( ...