这本书的第一版专门介绍了wait和notify的正确用法[Bloch01, item 50]。它的建议仍然有效,并在本项目末尾作了总结,但这一建议已远不如从前重要。这是因为使用wait和notify的理由要少得多。自Java 5以来,该平台提供了更高级别的并发实用程序,可以执行以前必须在wait和notify之上手工编写代码的操作。考虑到正确使用wait和notify的困难,您应该使用更高级别的并发实用程序。
java.util.concurrent中的高级实用程序,分为三类: 在item80简要介绍的Executor Framework;concurrent
collections; 以及synchronizers。 Concurrent collections 和synchronizers将在本章简单地介绍。
并发集合是标准集合接口(如List、Queue和Map)的高性能并发实现。为了提供高并发性,这些实现在内部管理它们自己的同步( item79)。因此,不可能将并发活动排除在并发集合之外;锁定它只会使程序变慢。
因为不能排除并发集合上的并发活动,所以也不能原子地组合对它们的方法调用。因此,并发集合接口配备了依赖于状态的修改操作,这些操作将多个原语组合成单个原子操作。这些操作在并发集合上非常有用,因此使用默认方法(item21 )将它们添加到Java 8中相应的集合接口。
例如,Map的putIfAbsent(key, value)方法为一个没有键的键插入一个映射,并返回与键关联的前一个值,如果没有,则返回null。这使得实现线程安全的规范化映射变得很容易。这个方法模拟了String.intern的行为:
image.png
事实上,你可以做得更好。ConcurrentHashMap针对get等检索操作进行了优化。因此,只有在get表明有必要时,才有必要首先调用get并调用putIfAbsent:
image.png
除了提供优秀的并发性,ConcurrentHashMap还非常快。我的机器上,上面的intern方法比String.intern快六倍多(但是请记住,String.intern必须使用一些策略来防止在长时间运行的应用程序中内存泄漏)。并发集合使同步集合在很大程度上过时。例如,使用ConcurrentHashMap比Collections.synchronizedMap更好。简单地用并发映射替换同步映射可以显著提高并发应用程序的性能。
一些集合接口使用阻塞操作进行了扩展,这些操作将等待(或阻塞)成功执行。例如,BlockingQueue扩展了Queue并添加了几个方法,包括take,它从队列中删除并返回head元素,如果队列为空,则等待。这允许将阻塞队列用于工作队列(也称为生产者-消费者队列),一个或多个生产者线程将工作项排队到该工作队列中,一个或多个消费者线程将工作项从该工作队列中取出并在这些工作项可用时处理它们。正如您所期望的,大多数ExecutorService实现,包括ThreadPoolExecutor,都使用BlockingQueue(item80)。
同步器是允许线程彼此等待的对象,允许它们协调各自的活动。最常用的同步器是CountDownLatch和Semaphore。更少使用的是CyclicBarrier和CyclicBarrier.最强大的同步器是Phaser.
Countdown是单次使用的屏障,允许一个或多个线程等待一个或多个其他线程执行某些操作。唯一的构造函数
CountDownLatch接受一个int数,这是在允许所有等待线程继续之前,必须在闩上调用倒计时方法的次数。
在这个简单的原语上构建有用的东西非常容易。例如,假设您想要构建一个简单的框架来为一个操作的并发执行计时。这个框架由一个方法组成,该方法使用一个执行器来执行操作,一个并发级别表示要并发执行的操作的数量,一个可运行的方法表示操作。所有的工作线程准备在计时器线程启动时钟之前运行操作。当最后一个工作线程准备好运行这个动作时,定时器线程“发射发令枪”,允许工作线程执行这个动作。一旦最后一个工作线程完成该操作,计时器线程就停止时钟。在wait和notify之上直接实现这种逻辑至少可以说是很麻烦的,但是在wait和notify之上却出奇地简单
CountDownLatch:
image.png
注意,该方法使用三个倒计时锁。第一个是ready,工作线程使用它来告诉定时器线程它们什么时候准备好了。工作线程然后等待第二个锁存器,即start。当最后一个工作线程调用ready时。倒计时,计时器线程记录开始时间并调用start。倒计时,允许所有工作线程继续。然后计时器线程等待第三个锁存器done,直到最后一个工作线程运行完操作并调用done. countdown。一旦发生这种情况,计时器线程就会唤醒并记录结束时间。
还有一些细节值得注意。传递给time方法的执行器必须允许创建至少与给定并发级别相同数量的线程,否则测试将永远不会完成。这就是所谓的线程饥饿死锁[Goetz06, 8.1.1]。如果工作线程捕捉到InterruptedException,它使用习惯用法thread . currentthread ().interrupt()重申中断,并从它的run方法返回。这允许执行程序按照它认为合适的方式处理中断。请注意, System.nanoTime是用来计时的活动。对于间隔计时,始终使用 System.nanoTime。而不是System.currentTimeMillis。Sysytem.nanoTime不仅更精确,而且不受系统实时时钟调整的影响。最后,请注意,本例中的代码不会产生准确的计时,除非action做了相当多的工作,比如一秒钟或更长时间。准确的微基准测试是出了名的困难,最好借助于诸如jmh [jmh]这样的专门框架。
这个项目只涉及到您可以使用并发实用程序做什么。例如,前面示例中的三个倒计时锁存器可以替换为单个CyclicBarrier或Phaser实例。成的代码可能更简洁,但可能更难于理解。
虽然您应该始终优先使用并发实用程序来等待和通知,但是您可能必须维护使用等待和通知的遗留代码。wait方法用于使线程等待某个条件。它必须在同步区域内调用,该同步区域将锁定调用它的对象。:下面是使用等待方法的标准习语:
总是使用wait循环习惯用法来调用wait方法;永远不要在循环之外调用它。循环用于在等待之前和之后测试条件。
等待之前测试条件,如果条件已经存在,则跳过等待,以确保活性。如果条件已经存在,并且在线程等待之前已经调用了notify(或notifyAll)方法,则不能保证线程将从等待中唤醒。
为了确保安全,需要在等待之后再测试条件,如果条件不成立,则再次等待。如果线程在条件不成立的情况下继续执行该操作,它可能会破坏由锁保护的不变式。当条件不成立时,线程可能会唤醒以下几个原因:
- 另一个线程可以获得锁,并在线程调用notify和等待线程醒来之间更改保护状态。
- 当条件不成立时,另一个线程可能意外地或恶意地调用notify。类通过等待公共可访问的对象而暴露在这类恶作剧中。公共可访问对象的同步方法中的任何等待都容易受到这个问题的影响。
- 通知线程在唤醒等待线程时可能过于“慷慨”。例如,即使只有一些等待线程的条件得到满足,通知线程也可能调用notifyAll。
- 在没有通知的情况下,等待的线程可能(很少)醒来。这被称为虚假的唤醒[POSIX, 11.4.3.6.1;Java9-api]。
一个相关的问题是,是使用notify还是notifyAll来唤醒等待的线程。(回想一下notify唤醒一个等待线程(假设存在这样一个线程),notifyAll唤醒所有等待线程)。有时人们会说,应该始终使用notifyAll。这是合理的、保守的建议。它总是会产生正确的结果,因为它保证您将唤醒需要唤醒的线程。您可能还会唤醒其他一些线程,但这不会影响程序的正确性。这些线程将检查它们正在等待的条件,如果发现为false,将继续等待。
作为一种优化,如果在等待集中的所有线程都在等待相同的条件,并且每次只有一个线程可以从条件变为true中获益,那么您可以选择调用notify而不是notifyAll。
即使满足了这些先决条件,也可能有理由使用notifyAll来代替notify。正如将等待调用放在循环中可以防止公共访问对象上的意外或恶意通知一样,使用notifyAll代替notify可以防止不相关线程的意外或恶意等待。否则,这样的等待可能会“吞下”一个关键通知,让预期的接收者无限期地等待.
总之,与java.util.concurrent提供的高级语言相比,直接使用wait和notify就像使用“并发汇编语言”编程一样.在新代码中很少有理由使用wait和notify。如果维护使用wait和notify的代码,请确保它始终使用标准的习惯用法在while循环中调用wait。notifyAll方法通常应该优先用于notify。如果使用notify,则必须非常小心以确保其活性。
本文写于2019.7.23,历时1天
网友评论