欢迎大家关注 github.com/hsfxuebao/j… ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈
线程允许多个活动同时进行。 并发编程比单线程编程更难,因为更多的事情可能会出错,并且失败很难重现。 你无法避免并发。 它是平台中固有的,也是要从多核处理器获得良好性能的要求,现在无处不在。本章包含的建议可帮助你编写清晰,正确,文档完备的并发程序。
78. 同步访问共享的可变数据
synchronized关键字确保一次只有一个线程可以执行一个方法或代码块。许多程序员认为同步只是一种互斥的方法,以防止一个线程在另一个线程修改对象时看到对象处于不一致的状态。在这个观点中,对象以一致的状态创建(条目 17),并由访问它的方法锁定。这些方法观察状态,并可选地引起状态转换,将对象从一个一致的状态转换为另一个一致的状态。正确使用同步可以保证没有任何方法会观察到处于不一致状态的对象。
这种观点是正确的,但它只说明了一部分意义。如果没有同步,一个线程的更改可能对其他线程不可见。同步不仅阻止线程观察处于不一致状态的对象,而且确保每个进入同步方法或块的线程都能看到由同一锁保护的所有之前修改的效果。
语言规范保证读取或写入变量是原子性(atomic)的,除非变量的类型是long或double [JLS, 17.4, 17.7]。换句话说,读取long或double以外的变量,可以保证返回某个线程存储到该变量中的值,即使多个线程在没有同步的情况下同时修改变量也是如此。
你可能听说过,为了提高性能,在读取或写入原子数据时应该避免同步。这种建议大错特错。虽然语言规范保证线程在读取属性时不会看到任意值,但它不保证由一个线程编写的值对另一个线程可见。同步是线程之间可靠通信以及互斥所必需的。这是语言规范中称之为内存模型(memory model)的一部分,它规定了一个线程所做的更改何时以及如何对其他线程可见[JLS, 17.4;Goetz06, 16)。
即使数据是原子可读和可写的,未能同步对共享可变数据的访问的后果也是可怕的。 考虑从另一个线程停止一个线程的任务。 Java类库提供了Thread.stop方法,但是这个方法很久以前就被弃用了,因为它本质上是不安全的——它的使用会导致数据损坏。 不要使用Thread.stop。 从另一个线程中停止一个线程的推荐方法是让第一个线程轮询一个最初为false的布尔类型的属性,但是第二个线程可以设置为true以指示第一个线程要自行停止。 因为读取和写入布尔属性是原子的,所以一些程序员在访问属性时不需要同步:
// Broken! - How long would you expect this program to run?
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
你可能希望这个程序运行大约一秒钟,之后主线程将stoprequired设置为true,从而导致后台线程的循环终止。然而,在我的机器上,程序永远不会终止:后台线程永远循环!
问题是在没有同步的情况下,无法确保后台线程何时(如果有的话)看到主线程所做的stopRequested值的变化。 在没有同步的情况下,虚拟机将下面代码:
while (!stopRequested)
i++;
转换成这样:
if (!stopRequested)
while (true)
i++;
这种优化称为提升(hoisting,它正是OpenJDK Server VM所做的。 结果是活泼失败( liveness failure):程序无法取得进展。 解决问题的一种方法是同步对stopRequested属性的访问。 正如预期的那样,该程序大约一秒钟终止:
// Properly synchronized cooperative thread termination
public class StopThread {
private static boolean stopRequested;
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized boolean stopRequested() {
return stopRequested;
}
public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested())
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
注意,写方法(requestStop)和读方法(stop- required)都是同步的。仅同步写方法是不够的!除非读和写操作同步,否则不能保证同步工作。有时,只同步写(或读)的程序可能在某些机器上显示有效,但在这种情况下,表面的现象是具有欺骗性的。
即使没有同步,StopThread中同步方法的操作也是原子性的。换句话说,这些方法上的同步仅用于其通信效果,而不是互斥。虽然在循环的每个迭代上同步的成本很小,但是有一种正确的替代方法,它不那么冗长,而且性能可能更好。如果stoprequest声明为volatile,则可以省略StopThread的第二个版本中的锁定。虽然volatile修饰符不执行互斥,但它保证任何读取属性的线程都会看到最近写入的值:
// Cooperative thread termination with a volatile field
public class StopThread {
private static volatile boolean stopRequested;
public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
在使用volatile时一定要小心。考虑下面的方法,该方法应该生成序列号:
// Broken - requires synchronization!
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
该方法的目的是保证每次调用都返回一个唯一值(只要调用次数不超过232次)。 方法的状态由单个可原子访问的属性nextSerialNumber组成,该属性的所有可能值都是合法的。 因此,不需要同步来保护其不变量。 但是,如果没有同步,该方法将无法正常工作。
问题是增量运算符(++)不是原子的。 它对nextSerialNumber属性执行两个操作:首先它读取值,然后它写回一个新值,等于旧值加1。 如果第二个线程在线程读取旧值并写回新值之间读取属性,则第二个线程将看到与第一个线程相同的值并返回相同的序列号。 这是安全性失败(safety failure):程序计算错误的结果。
修复generateSerialNumber的一种方法是将synchronized修饰符添加到其声明中。 这确保了多个调用不会交叉读取,并且每次调用该方法都会看到所有先前调用的效果。 完成后,可以并且应该从nextSerialNumber中删除volatile修饰符。 要保护该方法,请使用long而不是int,或者在nextSerialNumber即将包装时抛出异常。
更好的是,遵循条目 59条中建议并使用AtomicLong类,它是java.util.concurrent.atomic包下的一部分。 这个包为单个变量提供了无锁,线程安全编程的基本类型。 虽然volatile只提供同步的通信效果,但这个包还提供了原子性。 这正是我们想要的generateSerialNumber,它可能强于同步版本的代码:
// Lock-free synchronization with java.util.concurrent.atomic
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}
避免此条目中讨论的问题的最佳方法是不共享可变数据。 共享不可变数据(条目 17)或根本不共享。 换句话说,将可变数据限制在单个线程中。 如果采用此策略,则必须对其进行文档记录,以便在程序发展改进时维护此策略。 深入了解正在使用的框架和类库也很重要,因为它们可能会引入你不知道的线程。
一个线程可以修改一个数据对象一段时间后,然后与其他线程共享它,只同步共享对象引用的操作。然后,其他线程可以在不进一步同步的情况下读取对象,只要不再次修改该对象。这些对象被认为是有效不可变的( effectively immutable)[Goetz06, 3.5.4]。将这样的对象引用从一个线程转移到其他线程称为安全发布(safe publication )[Goetz06, 3.5.3]。安全地发布对象引用的方法有很多:可以将它保存在静态属性中,作为类初始化的一部分;也可以将其保存在volatile属性、final属性或使用正常锁定访问的属性中;或者可以将其放入并发集合中(条目 81)。
总之,当多个线程共享可变数据时,每个读取或写入数据的线程都必须执行同步。 在没有同步的情况下,无法保证一个线程的更改对另一个线程可见。 未能同步共享可变数据的代价是活性失败和安全性失败。 这些失败是最难调试的。 它们可以是间歇性的和时间相关的,并且程序行为可能在不同VM之间发生根本的变化。如果只需要线程间通信,而不需要互斥,那么volatile修饰符是一种可接受的同步形式,但是正确使用它可能会比较棘手。
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的removeObserver方法,该方法又调用方法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)。
80. EXECUTORS, TASKS, STREAMS 优于线程
本书的第一版包含一个简单工作队列的代码[Bloch01,条目 49]。 此类允许客户端将后台线程的异步处理工作排入队列。 当不再需要工作队列时,客户端可以调用一个方法,要求后台线程在完成队列中已有的任何工作后正常终止自身。 实现只不过是个玩具,但即便如此,它还需要一整页精细,细致的代码,如果你没有恰到好处的话,这种代码很容易出现安全和活性失败。 幸运的是,没有理由再编写这种代码了。
到本书第二版出版时,java.util.concurrent包已添加到Java中。 该包包含一个Executor Framework,它是一个灵活的基于接口的任务执行工具。 创建一个比本书第一版更好的工作队列只需要一行代码:
ExecutorService exec = Executors.newSingleThreadExecutor();
下面是如何提交一个可运行的(runnable)执行:
exec.execute(runnable);
下面是如何告诉executor优雅地终止(如果做不到这一点,你的虚拟机很可能不会退出):
exec.shutdown();
可以使用执行器服务(executor service)做更多的事情。例如,可以等待一个特定任务完成(条目 79中使用get方法, 319页),可以等待任何或全部任务完成的集合(使用invokeAny或invokeAll方法),也可以等待执行者服务终止(使用awaitTermination方法),可以在完成任务时逐个检索任务结果(使用ExecutorCompletionService),可以安排任务在特定时间运行或定期运行(使用ScheduledThreadPoolExecutor),等等。
如果希望多个线程处理来自队列的请求,只需调用另一个静态工厂,该工厂创建一种称为线程池的不同类型的执行器服务。 可以创建具有固定或可变数量线程的线程池。 java.util.concurrent.Executors类包含静态工厂,它们提供了你需要的大多数执行程序。 但是,如果想要一些与众不同的东西,可以直接使用ThreadPoolExecutor类。 此类允许你配置线程池操作的几乎每个方面。
为特定应用程序选择执行程序服务可能很棘手。 对于小程序或负载较轻的服务器,Executors.newCachedThreadPool通常是一个不错的选择,因为它不需要配置,通常“做正确的事情”。但是对于负载很重的生产服务器来说,缓存线程池不是一个好的选择! 在缓存线程池中,提交的任务不会排队,而是立即传递给线程执行。 如果没有可用的线程,则创建一个新线程。 如果服务器负载过重以至于所有CPU都被充分利用并且更多任务到达时,则会创建更多线程,这只会使事情变得更糟。 因此,在负载很重的生产服务器中,最好使用Executors.newFixedThreadPool,它提供具有固定线程数的池,或直接使用ThreadPoolExecutor类,以实现最大程度的控制。
不仅应该避免编写自己的工作队列,而且通常应该避免直接使用线程。 当直接使用Thread类时,线程既可以作为工作单元,也可以作为执行它的机制。 在executor framework中,工作单元和执行机制是分开的。 关键的抽象是工作单元,称为任务。 有两种任务:Runnable及其近亲Callable(类似于Runnable,除了它返回一个值并且可以抛出任意异常)。 执行任务的一般机制是executor service。 如果从任务的角度来看,让executor service为你执行它们,可以灵活地选择适当的执行策略以满足你的需求,并在需求发生变化时更改策略。 本质上本质上,Executor Framework执行的功能与Collections Framework聚合(aggregation)功能是相同的。
在Java 7中,Executor Framework被扩展为支持fork-join任务,这些任务由称为fork-join池的特殊executor service运行。 由ForkJoinTask实例表示的fork-join任务可以拆分为较小的子任务,而包含ForkJoinPool的线程不仅处理这些任务,而且还“彼此”窃取“任务”以确保所有线程都保持忙碌,从而导致更高的任务 CPU利用率,更高的吞吐量和更低的延迟。 编写和调优fork-join任务很棘手。 并行流(Parallel streams)(条目 48)是在fork-join池之上编写的,假设它们适合当前的任务,那么你可以轻松地利用它们的性能优势。
对Executor Framework的完整处理超出了本书的范围,但感兴趣的读者可以参考 《Java Concurrency in Practice》一书[Goetz06]。
81. 优先使用并发实用程序替代wait和notify
本书的第一版专门用一个条目来介绍正确使用wait和notify方法[Bloch01,Item 50]。 它的建议仍然有效,并在本条目末尾进行了总结,但这个建议远不如以前那么重要了。 这是因为没有太多理由再使用wait和notify了。 从Java 5开始,该平台提供了更高级别的并发实用程序,可以执行以前必须在wait和notify时手动编写代码的各种操作。 鉴于正确使用wait和notify的困难,应该使用更高级别的并发实用程序。
java.util.concurrent包中的高级实用程序分为三类:Executor Framework,在条目 80中简要介绍了它;并发集合(concurrent collections) 和同步器(synchronizers)。 本条目简要介绍后两者。
并发集合是标准集合接口(如List,Queue和Map)的高性能并发实现。 为了提供高并发性,这些实现在内部管理自己的同步(条目 79)。 因此,不可能从并发集合中排除并发活动; 锁定它只会使程序变慢。
因为不能排除并发集合上的并发活动,所以也不能以原子方式组合对它们的方法调用。 因此,并发集合接口配备了依赖于状态的修改操作,这些操作将几个基本操作组合成单个原子操作。 事实证明,这些操作对并发集合非常有用,它们使用默认方法(条目 21)添加到Java 8中相应的集合接口中。
例如,Map的putIfAbsent(key, value)方法插入键的映射(如果不存在)并返回与键关联的之前的值,如果没有则返回null。
这样可以轻松实现线程安全的规范化Map。 此方法模拟String.intern`方法的行为:
// Concurrent canonicalizing map atop ConcurrentMap - not optimal
private static final ConcurrentMap<String, String> map =
new ConcurrentHashMap<>();
public static String intern(String s) {
String previousValue = map.putIfAbsent(s, s);
return previousValue == null ? s : previousValue;
}
事实上,你可以做得更好。ConcurrentHashMap针对get等检索操作进行了优化。因此,只有在get表明有必要时,才首先调用get并调用putIfAbsent方法:
// Concurrent canonicalizing map atop ConcurrentMap - faster!
public static String intern(String s) {
String result = map.get(s);
if (result == null) {
result = map.putIfAbsent(s, s);
if (result == null)
result = s;
}
return result;
}
除了提供出色的并发性外,ConcurrentHashMap非常快。 在我的机器上,上面的intern方法比String.intern快6倍(但请记住,String.intern必须采用一些策略来防止在长期运行的应用程序中泄漏内存)。 并发集合使基于同步的集合在很大程度上已经过时了。 例如,使用ConcurrentHashMap优先于Collections.synchronizedMap。 简单地用并发Map替换同步Map以显着提高并发应用程序的性能。
一些集合接口使用阻塞操作进行扩展,这些操作等待(或阻塞)直到可以成功执行。 例如,BlockingQueue扩展了Queue并添加了几个方法,包括take,它从队列中删除并返回head元素,等待队列为空。 这允许阻塞队列用于工作队列(也称为生产者——消费者队列),一个或多个生产者线程将工作项入队,并且一个或多个消费者线程从哪个队列变为可用时出队并处理项目。 正如所期望的那样,大多数ExecutorService实现(包括ThreadPoolExecutor)都使用BlockingQueue(条目 80)。
同步器是使线程能够彼此等待的对象,允许它们协调各自的活动。 最常用的同步器是CountDownLatch和Semaphore。 不太常用的是CyclicBarrier和Exchanger。 最强大的同步器是Phaser。
倒计时锁存器(CountDownLatch)是一次性使用的屏障,允许一个或多个线程等待一个或多个其他线程执行某些操作。 CountDownLatch的唯一构造方法接受一个int类型的参数,它是在允许所有等待的线程继续之前,必须在latch上调用countDown方法的次数。
在这个简单的原语上构建有用的东西非常容易。例如,假设想要构建一个简单的框架来为一个操作的并发执行计时。这个框架由一个方法组成,该方法使用一个执行器executor来执行操作,一个表示要并发执行的操作数量并发级别,以及一个表示该操作的runnable组成。所有工作线程都准备在计时器线程启动时钟之前运行操作。当最后一个工作线程准备好运行该操作时,计时器线程“发号施令(fires the starting gun)”,允许工作线程执行该操作。一旦最后一个工作线程完成该操作,计时器线程就停止计时。在wait和notify的基础上直接实现这种逻辑至少会有点麻烦,但是在CountDownLatch的基础上实现起来却非常简单:
// Simple framework for timing concurrent execution
public static long time(Executor executor, int concurrency,
Runnable action) throws InterruptedException {
CountDownLatch ready = new CountDownLatch(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
for (int i = 0; i < concurrency; i++) {
executor.execute(() -> {
ready.countDown(); // Tell timer we're ready
try {
start.await(); // Wait till peers are ready
action.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown(); // Tell timer we're done
}
});
}
ready.await(); // Wait for all workers to be ready
long startNanos = System.nanoTime();
start.countDown(); // And they're off!
done.await(); // Wait for all workers to finish
return System.nanoTime() - startNanos;
}
请注意,该方法使用三个倒计时锁存器。 第一个ready,由工作线程来告诉计时器线程何时准备就绪。 工作线程然后等待第二个锁存器,即start。 当最后一个工作线程调用ready.countDown时,计时器线程记录开始时间并调用start.countDown,允许所有工作线程继续。 然后,计时器线程等待第三个锁存器完成,直到最后一个工作线程完成运行并调用done.countDown。 一旦发生这种情况,计时器线程就会唤醒并记录结束时间。
还有一些细节值得注意。传递给time方法的executor必须允许创建至少与给定并发级别相同数量的线程,否则测试将永远不会结束。这被称为线程饥饿死锁(thread starvation deadlock)[Goetz06, 8.1.1]。如果工作线程捕捉到InterruptedException异常,它使用习惯用法thread.currentthread ().interrupt()重新断言中断,并从它的run方法返回。这允许执行程序按照它认为合适的方式处理中断。System.nanoTime用于计算活动的时间。**对于间隔计时,请始终使用System.nanoTime而不是System.currentTimeMillis。 System.nanoTime更准确,更精确,不受系统实时时钟调整的影响。最后,请注意,本例中的代码不会产生准确的计时,除非action做了相当多的工作,比如一秒钟或更长时间。准确的微基准测试是非常困难的,最好是借助诸如jmh [JMH]这样的专业框架来完成。
这个条目只涉及使用并发实用程序做一些皮毛的事情。 例如,前一个示例中的三个倒计时锁存器可以由单个CyclicBarrier或Phaser实例替换。 结果代码会更简洁,但可能更难理解。
虽然应该始终优先使用并发实用程序来等替换wait和notify方法,但你可能必须维护使用wait和notify的旧代码。 wait方法用于使线程等待某些条件。 必须在同步区域内调用它,该区域锁定调用它的对象。 下面是使用wait方法的标准习惯用法:
// The standard idiom for using the wait method
synchronized (obj) {
while (<condition does not hold>)
obj.wait(); // (Releases lock, and reacquires on wakeup)
... // Perform action appropriate to condition
}
始终要在循环中调用wait方法;永远不要在循环之外调用它。循环用于测试wait前后的条件。
如果条件已经存在,则在wait之前测试条件并跳过等待以确保活性(liveness)。 如果条件已经存在并且在线程等待之前已经调用了notify(或notifyAll)方法,则无法保证线程将从等待中唤醒。
为了确保安全,需要在等待之后再测试条件,如果条件不成立,则再次等待。如果线程在条件不成立的情况下继续执行该操作,它可能会破坏由锁保护的不变式(invariant)。当条件不成立时,以下几个原因可以把线程唤醒:
- 另一个线程可以获得锁并在线程调用notify和等待线程醒来之间改变了保护状态。
- 当条件不成立时,另一个线程可能意外地或恶意地调用notify方法。类通过等待公共可访问的对象来暴露自己。公共可访问对象的同步方法中的任何wait方法都容易受到这个问题的影响。
- 通知线程在唤醒等待线程时可能过于“慷慨”。例如,即使只有一些等待线程的满足条件,通知线程也可能调用notifyAll。
- 在没有通知的情况下,等待的线程可能(很少)被唤醒。这被称为虚假的唤醒(spurious wakeup)[POSIX, 11.4.3.6.1;Java9-api]。
一个相关的问题是,为了唤醒等待的线程,是使用notify还是notifyAll。(回想一下notify唤醒一个等待线程,假设存在这样一个线程,notifyAll唤醒所有等待线程)。有时人们会说,应该始终使用notifyAll。这是合理的、保守的建议。它总是会产生正确的结果,因为它保证唤醒所有需要被唤醒的线程。可能还会唤醒其他一些线程,但这不会影响程序的正确性。这些线程将检查它们正在等待的条件,如果发现为false,将继续等待。
作为一种优化,如果所有线程都在等待相同的条件,并且每次只有一个线程可以从条件变为true中唤醒,那么可以选择调用notify而不是notifyAll。
即使满足了这些先决条件,也可能有理由使用notifyAll来代替notify。正如将wait方法调用放在循环中可以防止公共访问对象上的意外或恶意通知一样,使用notifyAll代替notify可以防止不相关线程的意外或恶意等待。否则,这样的等待可能会“吞下”一个关键通知,让预期的接收者无限期地等待。
总之,与java.util.concurrent提供的高级语言相比,直接使用wait和notify就像在“并发汇编语言”中编程一样。在新代码中基本上不存在使用wait和notify的理由。 如果正在维护使用wait和notify的代码,请确保它始终使用标准惯用法在while循环内调用wait方法。 通常应优先使用notifyAll方法进行通知。 如果使用notify,必须非常小心以确保程序的活性。
82. 线程安全文档化
当并发使用一个类的方法时,类的行为方式是其与客户端建立约定的重要部分。如果未能文档化记录某个类行为的这一方面,其用户只能做出做出假设。如果这些假设是错误的,则生成的程序可能执行同步不够(条目 78)或过度同步(条目 79)。 无论哪种情况,都可能导致严重错误。
你可能听说过,可以通过在方法的文档中查找synchronized修饰符来判断该方法是否线程安全的。这在几个方面来讲是错误的。在正常操作中,Javadoc的输出中没有包含synchronized修饰符,这是有原因的。方法声明中synchronized修饰符的存在是实现细节,而不是其API的一部分。它不能可靠地说明方法是是线程安全的。
此外,声称存在synchronized修饰符就足以文档记录线程安全性,这体现了线程安全性是要么全有要么全无属性的误解。实际上,线程安全有几个级别。要启用安全的并发使用,类必须清楚地文档记录它支持的线程安全级别。下面的列表总结了线程安全级别。它并非详尽无遗,但涵盖以下常见情况:
-
不可变的(Immutable) —— 该类的实例看起来是不变的(constant)。不需要外部同步。示例包括String、Long和BigInteger(条目 17)。
-
无条件线程安全(Unconditionally thread-safe) —— 此类的实例是可变的,但该类具有足够的内部同步,以便可以并发使用其实例而无需任何外部同步。 示例包括AtomicLong和ConcurrentHashMap。
-
有条件线程安全(Conditionally thread-safe) —— 与无条件线程安全一样,但某些方法需要外部同步以便安全并发使用。 示例包括
Collections.synchronized包装器返回的集合,其迭代器需要外部同步。 -
非线程安全(Not thread-safe) —— 这个类的实例是可变的。 要并发使用它们,客户端必须使用其选择的外部同步来包围每个方法调用(或调用序列)。 示例包括通用集合实现,例如ArrayList和HashMap。
-
线程对立(Thread-hostile) —— 即使每个方法调用都被外部同步包围,该类对于并发使用也是不安全的。线程对立通常是由于在不同步的情况下修改静态数据而导致的。没有人故意编写线程对立类;此类通常是由于没有考虑并发性而导致的。当发现类或方法与线程不相容时,通常将其修正或弃用。条目 78中的
generateSerialNumber方法在没有内部同步的情况下是线程对立的,如第322页所述。
这些分类(除了线程对立)大致对应于《Java Concurrency in Practice》一书中的线程安全注解,分别是Immutable,ThreadSafe和NotThreadSafe [Goetz06,附录A]。 上述分类中的无条件和条件线程安全类别都包含在ThreadSafe注解中。
在文档记录了一个有条件的线程安全类需要小心。 你必须指明哪些调用序列需要外部同步,以及必须获取哪个锁(或在极少数情况下是几把锁)才能执行这些序列。 通常是实例本身的锁,但也有例外。 例如,Collections.synchronizedMap的文档说明了这一点:
It is imperative that the user manually synchronize on the returned map when iterating over any of its collection views:
当迭代任何Map集合的视图时,用户必须手动同步返回的Map:Map<K, V> m = Collections.synchronizedMap(new HashMap<>()); Set<K> s = m.keySet(); // Needn't be in synchronized block ... synchronized(m) { // Synchronizing on m, not s! for (K key : s) key.f(); }
不遵循此建议可能会导致不确定性行为。
类的线程安全性的描述通常属于类的文档注释,但具有特殊线程安全属性的方法应在其自己的文档注释中描述这些属性。 没有必要记录枚举类型的不变性。 除非从返回类型中显而易见,否则静态工厂必须在文档中记录返回对象的线程安全性,如Collections.synchronizedMap(上文)所示。
当类承诺使用可公开访问的锁时,它允许客户端以原子方式执行一系列方法调用,但这种灵活性需要付出代价。 它与并发集合(如ConcurrentHashMap)使用的高性能内部并发控制不兼容。 此外,客户端可以通过长时间保持可公开访问的锁来发起拒绝服务攻击。 这可能是偶然也可能是故意的。
要防止此拒绝服务攻击,可以使用私有锁对象而不是使用synchronized方法(这隐含着可公开访问的锁):
// Private lock object idiom - thwarts denial-of-service attack
private final Object lock = new Object();
public void foo() {
synchronized(lock) {
...
}
}
由于私有锁对象在类外是不可访问的,因此客户端不可能干扰对象的同步。 实际上,我们通过将锁定对象封装在它同步的对象中来应用条目 15的建议。
请注意,锁定属性(lock field)被声明为final。 这可以防止无意中更改其内容,从而导致灾难性的非同步访问(条目 78)。 我们通过最小化锁定属性(lock field)的可变性来应用条目 17的建议。 锁定属性(lock field)应始终声明为final。 无论使用普通的监视器锁(如上所示)还是使用java.util.concurrent.locks包中的锁,都是如此。
私有锁对象习惯用法只能用于无条件线程安全类。 有条件线程安全类不能使用这个习惯用法,因为它们必须文档记录在执行某些方法调用序列时客户端要获取的锁。
私有锁对象习惯用法特别适合用于为继承设计的类(条目 19)。 如果这样的类要使用其实例进行锁定,则子类可能容易且无意地干扰基类的操作,反之亦然。 通过为不同的目的使用相同的锁,子类和基类可能最终“踩到彼此的脚趾。”这不仅仅是一个理论问题;它就发生在Thread类上[Bloch05,Puzzle 77]中。
总之,每个类都应该用措辞严谨的描述或线程安全注解清楚地文档记录其线程安全属性。synchronized修饰符在本文档中没有任何作用。条件线程安全类必须文档记录哪些方法调用序列需要外部同步,以及在执行这些序列时需要获取哪些锁。如果你编写一个无条件线程安全的类,请考虑使用一个私有锁对象来代替同步方法。这将保护免受客户端和子类的同步干扰,并提供更大的灵活性,以便在后续的版本中采用复杂的并发控制方法。
83. 明智谨慎地使用延迟初始化
延迟初始化(Lazy initialization)是延迟属性初始化直到需要其值的行为。 如果不需要该值,则永远不会初始化该属性。 此技术适用于静态和实例属性。 虽然延迟初始化主要是一种优化,但它也可以用来打破类和实例初始化中的有害循环[Bloch05,Puzzle 51]。
与大多数优化一样,延迟初始化的最佳建议是“除非需要,否则不要这样做”(条目 67)。延迟初始化是一把双刃剑。它降低了初始化类或创建实例的成本,代价是增加了访问延迟初始化属性的成本。根据这些属性中最终需要初始化的部分、初始化它们的开销以及初始化后访问每个属性的频率,延迟初始化实际上会降低性能(就像许多“优化”一样)。
也就是说,延迟初始化有其用途。 如果仅在类的一小部分实例上访问属性,并且初始化属性的成本很高,则延迟初始化可能是值得的。 确切知道的唯一方法是使用和不使用延迟初始化来测量类的性能。
在存在多个线程的情况下,延迟初始化很棘手。如果两个或多个线程共享一个延迟初始化的属性,那么必须使用某种形式的同步,否则会导致严重的错误(条目 78)。本条目中讨论的所有初始化技术都是线程安全的。
在大多数情况下,正常初始化优于延迟初始化。 以下是通常初始化的实例属性的典型声明。 注意使用final修饰符(条目 17):
// Normal initialization of an instance field
private final FieldType field = computeFieldValue();
如果使用延迟初始化来破坏初始化循环,请使用同步访问器,因为它是最简单,最清晰的替代方法:
// Lazy initialization of instance field - synchronized accessor
private FieldType field;
private synchronized FieldType getField() {
if (field == null)
field = computeFieldValue();
return field;
}
当应用于静态属性时,这两个习惯用法(正常初始化和使用同步访问器的延迟初始化)都不会更改,除了将static修饰符添加到属性和访问器声明。
如果需要在静态属性上使用延迟初始化来提高性能,请使用延迟初始化持有者类(lazy initialization holder class)的习惯用法。这个习惯用法保证了一个类知道被使用时才会被初始化[JLS, 12.4.1]。 如下所示:
// Lazy initialization holder class idiom for static fields
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
private static FieldType getField() { return FieldHolder.field; }
当第一次调用getField方法时,它首次读取FieldHolder.field,导致FieldHolder类的初始化。 这个习惯用法的优点在于getField方法不是同步的,只执行属性访问,因此延迟初始化几乎不会增加访问成本。 典型的虚拟机将仅同步属性访问以初始化类。 初始化类后,虚拟机会对代码进行修补,以便后续访问该属性不涉及任何测试或同步。
如果需要使用延迟初始化来提高实例属性的性能,请使用双重检查(double-check )习惯用法。这个习惯用法避免了初始化后访问属性时的锁定成本(条目 79)。这个习惯用法背后的思想是两次检查属性的值(因此得名double check):第一次没有锁定,然后,如果属性没有初始化,第二次使用锁定。只有当第二次检查指示属性未初始化时,才调用初始化属性。由于初始化属性后没有锁定,因此将属性声明为volatile非常重要(第78项)。下面是这个习惯用用法:
// Double-check idiom for lazy initialization of instance fields
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null) { // First check (no locking)
synchronized(this) {
if (field == null) // Second check (with locking)
field = result = computeFieldValue();
}
}
return result;
}
此代码可能看起来有点复杂。 特别是,可能不清楚是否需要这个result局部变量。 这个变量的作用是确保field属性在已经初始化的常见情况下只读一次。 虽然不是绝对必要,但这可以提高性能,并且通过应用于低级并发编程的标准更加优雅。 在我的机器上,上面的方法大约是没有局部变量的明显版本的1.4倍。
虽然也可以将双重检查用法应用于静态属性,但没有理由这样做:延迟初始化持有者类习惯用法(lazy initialization holder class idiom)是更好的选择。
双重检查习惯用法有两个变体值得注意。有时候,可能需要延迟初始化一个实例属性,该属性可以容忍重复初始化。如果你发现自己处于这种情况,可以使用双重检查的变体来避免第二个检查。毫无疑问,这就是所谓的“单一检查”习惯用法(single-check idiom)。它是这样的。注意,field仍然声明为volatile:
// Single-check idiom - can cause repeated initialization!
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null)
field = result = computeFieldValue();
return result;
本条目中讨论的所有初始化技术都适用于基本类型以及对象引用属性。 当将双重检查或单一检查惯用法应用于数字基本类型时,根据数字0(数字基本类型变量的默认值)而不是用null来检查属性的值。
如果你不关心每个线程是否重新计算属性的值,并且属性的类型是long或double以外的基本类型,那么可以选择从单一检查习惯用法中的属性声明中删除volatile修饰符。 这种变体被称为生动的单一检查习惯用法(racy single-check idiom)。 它加速了某些体系结构上的属性访问,但代价是额外的初始化(直到访问该字段的线程执行一次初始化)。 这绝对是一种奇特的技术,不适合日常使用。
总之,应该正常初始化大多数属性,而不是延迟初始化。 如果必须延迟初始化属性以实现性能目标或打破有害的初始化循环,则使用适当的延迟初始化技术。 例如实例属性,使用双重检查习惯用法; 对于静态属性,使用延迟初始化持有者类习惯用法。 可以容忍重复初始化的属性,也可以考虑单一检查习惯用法。
84. 不要依赖线程调度器
当许多线程可以运行时,线程调度器(thread scheduler)决定哪些线程可以运行以及运行多长时间。任何合理的操作系统都会尝试公平地做出这个决定,但是策略可能会有所不同。因此,编写良好的程序不应该依赖于此策略的细节。任何依赖线程调度器来保证正确性或性能的程序都可能是不可移植的。
编写健壮,响应迅速的可移植程序的最佳方法是确保可运行线程的平均数量不会明显大于处理器数量。 这使得线程调度程序几乎没有多少选择:它只是运行可运行的线程,直到它们不再可运行为止。 即使在完全不同的线程调度策略下,程序的行为也不会有太大变化。 请注意,可运行线程的数量与线程总数不同,后者可能要高得多。 正在等待的线程不可运行。
保持可运行线程数量较少的主要技术是让每个线程做一些有用的工作,然后等待更多的工作。 如果线程没有做有用的工作,它们就不应该运行。 就Executor Framework而言(条目 80),这意味着适当调整线程池的大小[Goetz06, 8.2],并保持任务简短,但不要太短,否则分派的开销会损害性能。
线程不应该处于 busy-wait的状态,反复检查等待其状态改变的共享对象。 除了使程序容易受到线程调度程序的变化无常的影响之外,一直处于 busy-wait的状态大大增加了处理器的负担,减少了其他人可以完成的有用工作量。 作为不该做的极端例子,请考虑CountDownLatch的这种不正当的重新实现:
// Awful CountDownLatch implementation - busy-waits incessantly!
public class SlowCountDownLatch {
private int count;
public SlowCountDownLatch(int count) {
if (count < 0)
throw new IllegalArgumentException(count + " < 0");
this.count = count;
}
public void await() {
while (true) {
synchronized(this) {
if (count == 0)
return;
}
}
}
public synchronized void countDown() {
if (count != 0)
count--;
}
}
在我的机器上,当1000个线程在锁存器(latch)上等待时,SlowCountDownLatch比Java的CountDownLatch慢大约十倍。 虽然这个例子看起来有点牵强,但是看到系统中有一个或多个线程不必要地运行,这种情况并不罕见。 性能和可移植性可能会受到影响。
当一个程序因为某些线程没有获得足够的CPU时间而无法正常工作时,不要试图通过调用Thread.yield方法来“修复”这个程序。你可能会成功地使程序在某种程度上工作,但它不会是可移植的。在一个JVM实现上提高性能的相同的yield方法调用,在第二个JVM实现上可能会使性能变差,而在第三个JVM实现上没有任何影响。Thread.yield没有可测试的语义。更好的做法是重构应用程序,以减少并发运行线程的数量。
类似警告适用的相关技术是调整线程优先级。 线程优先级是Java中最不可移植的功能之一。 通过调整一些线程优先级来调整应用程序的响应性并不是不合理的,但它很少是必需的,并且不可移植。 尝试通过调整线程优先级来解决严重的活跃度问题是不合理的。 在你找到并解决根本原因之前,问题可能会重新出现。
总之,不要依赖线程调度器来确定程序的正确性。 由此产生的程序既不健壮也不可移植。 作为推论,不要依赖Thread.yield方法或线程优先级。 这些机制仅仅是对调度器的提示。 可以谨慎地使用线程优先级来提高已经工作的程序的服务质量,但是它们永远不应该用于“修复”几乎不起作用的程序。