image.png
链接:《JAVA并发编程实践JavaConcurrencyinPractice-中文-高清-带书签-完整版(Doug Lea)》
https://pan.baidu.com/s/1ShE6XtvkPwJsrqccbjr3LQ
提取码:ism0


书中代码
蓝奏云https://zhuzi51.lanzoui.com/iodqKmdllcj
书中提供的网址 https://jcip.net/


2 线程安全

编写线程安全的代码,本质上是管理对状态的访问,而且通常都是共享的、可变的状态。
通俗地说,一个对象的状态就是它的数据,存储在状态变量中,比如实例域或静态域。对象的状态还包括了其他附属对象的域。例如HashMap的状态一部分存储到对象本身中,但同时也存储到许多Map.Entry对象中。一个对象的状态包含了任何会它外部可见行为产生影响的数据。
所谓共享,是指一个变量可以被多个线程访问;所谓可变,是指变量的值在其生命周期内可以改变。
一个对象是否应该是线程安全的取决于他是否被多个线程访问。线程安全的这个性质,取决于程序中如何使用对象,而不是对象完成了什么。保证对象的线程安全性需要使用同步来协调对其可变状态的访问;若是做不到这一点,就会导致脏数据和其他不可预期的后果。
无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。Java中首要的同步机制时synchronized关键字,它提供了独占锁。除此之外,术语“同步”还包括volatile变量,显示锁和原子变量的使用。

在没有正确同步的情况下,如果多个线程访问了同一个变量,你的程序就存在隐患。有三种方法修复它。

  1. 不要跨线程共享变量
  2. 使用变量为不可变的
  3. 在任何访问状态变量的时候使用同步。

如果没有在类的设计中考虑并发访问的因素,需要使用上面三种方法对类的设计作重大的修改,所以修复的问题并不像听上去那样轻而易举。一开始将一个类设计成线程安全的,比在后期修复它更容易。

2.1 什么是线程安全性

当多个线程访问同一个类时,如果不考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码不必作其他的协调,这个类的行为仍然是正确的,那么称这个类时现场安全的。

3 共享对象

3.1 可见性

为了确保跨线程写入的内存可见性,必须使用同步机制。

  1. public class NoVisibility {
  2. private static boolean ready;
  3. private static int number;
  4. private static class ReaderThread extends Thread {
  5. @Override
  6. public void run() {
  7. System.out.println(ready);
  8. while (!ready) {
  9. Thread.yield();
  10. }
  11. System.out.println(number);
  12. }
  13. }
  14. public static void main(String[] args) {
  15. new ReaderThread().start();
  16. number = 42;
  17. ready = true;
  18. }
  19. }

3.1.1 过期数据

NoVisibility演示了一种没有恰当同步的程序,它能够引起意外的后果:过期数据。当读线程检查ready变量时,他可能看到一个过期的值,除非每一个访问都是同步的,否则很可能得到变量的过期值。更坏的情况是,过期既不会发生在全部变量上,也不会完全不出现:一个线程可能会得到一个变量的最新的值,但是也可能得到另一个变量先前写入的过期值。

3.1.4 volatile变量

Java语言提供了一种同步的弱形式:volatile变量。它确保对一个变量的更新以可预见的方式告知其他的线程。当一个域声明为volatile类型后,编译器运行时会监视这个变量:它是共享的,而且对它的操作不会与其他的内存操作一起别重排序。volatile变量不会缓存在寄存器或者缓存在其他处理器隐藏的地方。所以,读一个volatile类型的变量时,总会返回由某一线程所写入的最新值。

只有当volatile变量能够简化实现和同步策略的验证时,才使用他们。当验证正确性必须推断可见性问题时,应该避免使用volatile变量。正确使用volatile变量的方式包括:用于确保他们所引用的对象的可见性,或者用于标识重要的生命周期事件的发生。

加锁可以保证可见性与原子性,volatile变量只能保证可见性。

**
只有满足了下面所有的标准后,才能使用volatile变量。

  • 写入变量是并不依赖变量的当前值;或者能够确保只有单一的线程修改变量的值;
  • 变量不需要与其他的状态共同参与不变约束;
  • 访问变量时,没有其他的原因需要枷锁。

**

3.2 发布和逸出

发布一个对象的意思是是它能够被当前范围之外的代码所使用。比如将一个引用存储到其他代码可以访问的地方,在一个非私有的方法中返回这个引用,也可以把它传递到其他类的方法中。在很多情况下,我们需要确保对象以及它们内部状态不被暴露。在另外一些情况下,为了正当的使用目的,我们又的确希望发布一个对象,但是用线程安全的方法完成这些工作时,可能需要同步。如果变量发布了内部状态,就可能危机到封装性,并使程序难以维持稳定。如果发布对象是,它还没有完成构造,同样危及线程安全。一个对象在尚未装备好时就将它发布,这种情况称作逸出。

最常见的发布对象的方式是将对象的引用存储到公共静态域中。任何类和现场都能看见这个域,如下,Initialize方法实例化一个新的HashSet实例,并通过将他存储到knowSecrents引用,从而发布了这个实例。

  1. //发布对象
  2. public static Set<Secret> knownSecrets;
  3. public void initialize() {
  4. knownSecrets = new HashSet<>();
  5. }

5 构建块

5.1 同步容器

同步容器包括两部分,一个是Vector和Hashtable,它们是早期的JDK的一部分;另一个是他们的同系容器。这些类是由Collections.synchroniezdXxx工厂方法创建的。这些类通过封装它们的状态,并对每一个公共方法进行同步而实现了线程安全,这样一次只有一个线程能访问容器的状态。

5.1.1 同步容器中出现的问题

同步线程都是线程安全的。但是对于复合操作,有时你可能需要使用额外的客户端加锁进行保护。通常对容器的复合操作包括:迭代、导航(根据一定的顺序寻找下一个元素)。在一个同步的容器中,这些复合操作即使没有客户端加锁的保护,技术上也是线程安全的,但是当其他线程能够同步并发修改容器的时候,他们就可能不按照你期望的方式工作了。

5.1.2 迭代器和ConcurrentModificationExcepation

5.1.3 隐藏迭代器

5.2 并发容器

ConrurrentHashMap来替代同步的哈希Map
CopyOnWriteArraylist 是list相应的同步实现。

使用并发容器替换同步容器,这种做法以很小风险带来了可扩展性显著的提高。

Java5.0 同样添加了两个新的容器类型,Queue和BlockingQueue。Queue用来临时保存正在等待进一步处理的一些列元素。JDK提供了集中实现,包括一个传统的FIFO队列。ConcurrentLinkedQueue:一个(非并发)具有优先级顺序的队列,PriorityQueue。Queue的操作并不会阻塞。

BlockingQueue扩展了Queue,增加了可阻塞的插入和获取操作,如果队列是空的,一个获取操作会一直阻塞直到队列中存在可用元素;如果队列是满的(对于有界队列),插入操作会一直阻塞直到队列中存在可用空间。阻塞队列在生产者,消费者设计中非常有用。

5.2.1 ConcurrentHashMap

同步容器在每个操作的执行期间都持有一个锁。有一些操作,比如HashMap.get或者List.contains,可能会涉及到比预期想更多的工作量:为寻找一个特定对象而遍历访问整个哈希容器或清单,必须调用大量候选对象的equal(equals本身还涉及相当数据的计算)。在一个哈希容器中,如果hashcode没有能很好地分散哈希值,元素很可能不均衡地分不到整个容器中;最极端的情况是,一个不良的哈希函数将会把一个哈希表转化为一个线性链表。遍历一个很长的清单并调用其中部分或者全部元素的equals,这会花费很长时间,并且在这段时间内,其他线程都不能访问这个容器。

ConcurrentHashMap和HashMap一样是一个哈希表,但是它使用完全不同的策略锁,可以提供更好的并发性和可伸缩性,在ConcurrentHashMap以前,程序使用一个公共同步每一个方法,并严格地限制只能有一个线程可以同时访问容器。而ConcurrentHashMap使用一个更加细化的锁机制,名叫分离锁。这个机制允许更深层次的共享访问。任意数量的读线程可以并发访问Map,读者和写者也可以并发访问Map,并且有限数量的写线程还可以并发修改Map。结果是,为并发访问带来更高的吞吐量,同时几乎没有损失单个线程访问的性能。

ConcurrentHashMap与其他的并发容器一起,进一步改进了同步容器:提供不会抛出ConcurrentModificationException的迭代器,因此不需要在容器迭代中加锁。ConcurrentHashMap返回的迭代器具有弱一致性,而非“及时失败”的。弱一致性的迭代器可以容许并发修改。当迭代器被创建时,它会遍历已有的元素,并且可以(但是不保证)感应到在迭代器被创建后,对容器的修改。

同步Map实现提供的一个特性石为独占的访问加锁,这在ConcurrentHashMap中并没有实现。在Hashtable和synchronizedMap中,获得Map的锁就可以防止任何其他线程访问该Map,对于这一个罕见的情况来说是必要的,比如原子化地加入一些映射(mapping),或者对元素进行若干次迭代,在这期间需要去连续修改并发容器中的内存。

相比于Hashtable和synchronizedMap,ConcurrentHashMap有众多的优势,而且几乎不存在什么劣势,因此在大多数情况下用ConcurrentHashMap取代同步Map实现只会带来更好的可伸缩性。只有当你程序需要在独占访问中加锁时,ConcurrentHashMap才会无法胜任。

5.2.2 Map附加的原子操作

因为ConcurrentHashMap不能够在独占访问中被解锁,我们不能使用客户端加锁来创建新的原子操作。如果发现正在已有的同步Map实现中加入这样一个功能,那么这可能标志着你应该考虑使用一个ConcurrentMap替代手头的同步Map。

5.2.3 CopyOnWriteArrayList

CopyOnWriteArrayList是同步List的一个并发替代品。通常情况下它提供了更好的并发性,并避免了在迭代期对容器的加锁和复制。
“写入时复制”容器的线程安全性来源于这样一个事实,只要有效的不可变对象被正确发布,那么访问它将不再需要更多的同步。在每次需要修改时,它们会创建并重新发布一个新的容器拷贝,一次来实现可变性。“写入时复制”容器的迭代器保留一个底层基础数据的引用。这个数据作为迭代器的起点,永远不会别修改,因此对它的同步只不过是为了确保数组内容的可见性。因此,多个线程可以对这个容器进行迭代,并且不会受到另一个或多个想要修改容器的线程带来的干涉。

5.3 阻塞队列和生产者-消费者模式

阻塞队列提供了可阻塞的put和take方法,它们与可定时的offer和poll是等待的。如果Queue已经满了,put方法会阻塞直到有空间可用;如果Queue是空的,那么take方法会被塑造,直到有元素可用,Queue的长度可用有限,也可以无限;无限的Queue永远不会充满,所以它的put方法永远不会阻塞。
阻塞队列支持-生产者-消费者设计模式。一个生产者-消费者设计分离了“识别需要完成的工作”和“执行工作”。该模式不会发现一个工作便立即处理,而是把工作置入一个任务清单中,以备后期处理。生产者-消费者模式简化了开发,因为它解除了生成者和消费者之间相互依赖的代码。生成者和消费者以不同的或者变化的速度生产和消费着数据,生成者-消费者模式将这些活动解耦,因而简化了工作负荷的管理。

类库中包含一些BlockingQueue的实现,其中LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,与LinkedList和ArrayList相似,但是却拥有比同步List更好的并发性。PriorityBlockingQueue 是按优先级顺序排序的队列,当你不希望按照FIFO的顺序处理元素时,这个PriorityBlockingQueue 是非常有用的。正如其他排序的容器一样,PriorityBlockingQueue 可以比较元素本身的额自然顺序(如果它们实现了Comparable),也可以使用一个Comparator进行排序。
最后一个BlockingQueue 的实现是SynchronousQueue,它根本上不是一个真正的队列,因为它不会为队列元素维护任何存储空间。不过,它维护了一个排队的线程清单,这些线程等待把元素加入(enqueue)队列或者移出(dequeue)队列。

6 任务执行

6.1 在线程中执行任务

当围绕“执行任务”来设计应用程序结构时,第一步就是要找出清晰的任务边界。在理想情况下,各个任务是相互独立:任务并不依赖其他任务的状态、结果和边界效应。独立性有助于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执行。为了在调度与负载均衡等过程中实现更高的灵活性,每项任务还应该表示应用程序的一小部分处理能力。

6.1.1 串行地执行任务

在应用程序中可以通过多种策略来调度任务,而其中一些策略能够更好地利用潜在的并发性。最简单的策略就是在单个线程中串行地执行各项任务。

6.2 Executor框架

任务是一组逻辑工作单元,而线程则是使任务异步执行的机制。把所有的任务放在单个线程中串行执行,以及将每个任务放在各自的线程中执行。这两种方式都存在一些严格的限制:串行执行的问题在于其糟糕的响应性和吞吐量,而为每个任务分配一个线程的问题在于资源管理的复杂性。

java.util.concurrent提供一种灵活的线程池实现作为Executor框架的一部分。在Java类库中,执行任务的主要抽象不是thread,而是executor。

6.2.4 Executor的生命周期

Executor的实现通常会创建线程来执行任务。但JVM只有在所有(非守护)线程全部终止后才会退出,因此,如果无法正确地关闭Executor,那么JVM将会无法结束。

因为Executor是异步地执行任务,所有在任何时间里,所有之前提交的任务的状态都不能立即可见。这些任务中,有些可能已完成了。有些可能正在运行,其他的还可能在队列中等待执行。关闭应用程序是,程序会出现很多种情况;从最平缓的关闭(已经启动的任务全部完成且没有再接到任何新的工作)到最唐突的关闭(拔掉机房的电源),以及介于这两种极端情况之间的各种可能。既然Executor是为应用程序提供服务的,它们里应该可以关闭,无论是平缓地还是唐突地。另外,关闭操作还会影响到记录应用程序任务状态的反馈信息。
为了这个执行服务的生命周期问题。ExecutorService接口扩展了Executor,并且添加了一些用于生命周期管理的方法(同时还有一些用于任务提交的便利方法)。

7 取消和关闭

要做到安全、快速、可靠地停止任务或线程并不容易。Java没有提供任何机制,来安全地强迫线程停止手头的工作。它提供中断——一个协作机制,使一个线程能够要求另一个线程停止当前的工作。

这种协作方法是必须的,因为我们很少需要一个任务、线程或者服务立即停止,立即停止会导致共享的数据结构处于不一致的状态。任务和服务可以这样编码:当要求它们停止时,它们首先会清除当前进程中的工作,然后在终止。这提供了更好的灵活性,因为任务代码本身比发出取消请求的代码更明确应该清除什么。

7.1 任务取消

当外部的代码能够在活动自然完成之前,把它更改为完成状态,那么这个活动被称为可取消的。可能会有很多原因取消一个活动:
用户请求的取消
限时取消
应用程序事件
错误
关闭

  1. @ThreadSafe
  2. public class PrimeGenerator implements Runnable {
  3. //GuardedBy https://blog.csdn.net/chirousha4455/article/details/100858745
  4. @GuardedBy("this")
  5. private final List<BigInteger> primes = new ArrayList<>();
  6. /**
  7. * 使用 volatile 域 保存取消状态
  8. */
  9. private volatile boolean cancelled;
  10. @Override
  11. public void run() {
  12. BigInteger p = BigInteger.ONE;
  13. while (!cancelled) {
  14. p = p.nextProbablePrime();
  15. System.out.println(p);
  16. synchronized (this) {
  17. primes.add(p);
  18. }
  19. }
  20. }
  21. public void cancel() {
  22. cancelled = true;
  23. }
  24. public synchronized List<BigInteger> get() {
  25. return new ArrayList<>(primes);
  26. }
  27. /**
  28. * 生成素数的程序运行一秒钟
  29. */
  30. List<BigInteger> aSecondOfPrimes() throws InterruptedException {
  31. final PrimeGenerator generator = new PrimeGenerator();
  32. new Thread(generator).start();
  33. try {
  34. TimeUnit.SECONDS.sleep(1);
  35. } finally {
  36. generator.cancel();
  37. }
  38. return generator.get();
  39. }
  40. }

7.1.1 中断

PrimeGenerator中的取消机制最终会导致寻找素数的任务退出。但是并不是立刻发生,需要花费一些时间。但是如果一个任务使用这个方案调用一个阻塞方法,比如BlockingQueue.put ,我们可能会遇到一个更严重的问题——任务肯能永远都不检查取消标志,因此永远不会终结。

每一个线程都有一个boolean类型的中断状态。在中断的时候,这个中断状态被设置为true。Thread包含其他用于中断线程的方法,以及请求线程中断状态的方法。interrupt 方法中断目标线程,并且isInterrupted 返回目标线程的中断状态。静态的interrupted 方法名并不理想,它仅仅能够清除当前线程的中断状态,并返回之前的值;这是清除中断转态唯一的方法。

阻塞库函数,比如Thread.sleepObject.wait ,试图 监测线程何时被中断,并提前返回。它们对中断的响应表现为:清除中断状态,抛出InterruptedException;这表示阻塞操作因为中断的缘故提前结束,JVM并没有对阻塞方法中断的速度做出保证,不过在实现中这样的响应速度还是比较迅速的。

调用interrupt并不意味着必然停止目标线程正在进行的工作;它仅仅传递了请求中断的消息。

我们对于中断本身最好的理解应该是:它并不会真正中断一个正在运行的线程;它仅仅是发出的中断请求,线程自己会在下一个方便的时刻中断(这些时刻被称为取消点)。有一些方法对这样的请求很重视,比如wait,sleep 和join方法,当它们接到中断请求时会抛出一个异常,或者进入时中断状态就已经被设置了。
静态的interrupted应该小心使用,因为它会清除并发线程的中断状态。如果你调用了interrupted ,并返回了true,你必须对其进行处理,除非你想掩盖这个中断——可以抛出InterruptedException ,或者再次调用interrupt来保存中断状态。

  1. public class BrokenPrimeProducer extends Thread {
  2. private final BlockingQueue<BigInteger> queue;
  3. public BrokenPrimeProducer(BlockingQueue<BigInteger> queue) {
  4. this.queue = queue;
  5. }
  6. @Override
  7. public void run() {
  8. try {
  9. BigInteger p = BigInteger.ONE;
  10. while (!Thread.currentThread().isInterrupted()) {
  11. queue.put(p = p.nextProbablePrime());
  12. }
  13. } catch (Exception e) {
  14. /* 允许线程退出 */
  15. }
  16. }
  17. public void cancel(){
  18. interrupt();
  19. }
  20. }

7.1.2 中断策略

正如需要为任务制定取消策略一样,也应该制定线程中断策略。一个中断策略决定线程如何应对中断请求——当发现中断请求时,它会做什么(如果确实响应中断的话),哪些工作单元对于中断来说是原子操作,以及在多快的时间里响应中断。

大多数可阻塞的库函数,仅仅抛出InterruptedException 作为中断的响应。它们绝不可能自己运行在一个线程中,所以它们为任务或者库代码实现了大多数合理的取消策略:它们会尽可能地位异常信息让路,把它们向后者传给调用者,这样上次栈的代码就可以进一步行动了。

当检查到中断请求时,任务并不需要放弃所有事情——它可以选择推迟,直到更合适的时机。这需要记得它已经被请求过中断了。完成当前正在进行的任务,然后抛出InterruptedException或者指明中断。当更新的过程中发生中断时,这项技术能够保证数据结构不会破坏。

一个任务不应该假设其执行线程中断策略,除非显示地设计用来运行在服务中,并且这些服务有明确的中断策略。无论任务把中断解释为取消,还是其他的一些关于中断的操作,它都应该保存执行线程的中断状态。如果中断的处理不仅仅是把InterruptedException传递给调用者,那么它应该在捕获InterruptedException之后恢复中断状态:

Thread.currentThread().interrupt();

7.1.3 响应中断

当调用可中断阻塞函数时,例如Thread.sleep或BlockingQueue.put等。有两种实用策略可以用于处理。InterruptedException

  • 传递异常(可能在执行某个特定于任务的清除操作之后),从而使你的方法也成为可中断的阻塞方法。
  • 恢复中断状态,从而使调用栈中的上层代码能够对其进行处理。

如果不想或无法传递InterruptedException(获取通过Runnable来定义任务),那么需要寻找另一种方式来保存中断请求。一种标准的方法就是通过再次调用interrupt来恢复中断状态。

7.1.5 通过Future来实现取消

我们已经使用了一种抽象机制来管理任务的生命周期,处理异常,以及实现取消,即Future。通常,使用现有库中的类比自行编写的更好,因此我们将继续使用Future和任务执行框架来构建timedRun。

7.1.6 处理不可终端阻塞

很多可阻塞的库方法通过提前返回和抛出InterruptedException来实现对中断的响应,这使得构建可以响应取消的任务。但是,并不是所有的阻塞方法或阻塞机制都响应中断;如果一个线程是由于进行同步Socket I/O 或者等待获得内部锁而阻塞的。那么中断除了能够设置线程的中断状态以外,什么都不能改变。对应那些不可中断的活动所阻塞的线程,可以使用与中断类似的手段,来确保可以停止这些线程。

java.io中的同步Socket I/O 。在服务器中,阻塞I/O最常见的形式是读取和写入Socket。不幸的是,InputStream和OutputStream中的read和write方法都不响应中断,但是通过关闭底层的Socket。可以让read和write所阻塞的线程抛出SocketException。
java.nio中的同步I/O:中断一个等待InterruptibleChannel的线程,会导致抛出ClosedByInterruptionException。并关闭链路(也会导致其他线程在这条链路的阻塞,抛出CloseByInterruptException) 。关闭一个InterruptibleChannel导致多个阻塞在链路操作上的线程抛出AsynchronousCloseException大多数标准Channels都实现了InterruptibleChannel。
Selector的异步I/O :如果一个线程阻塞与Selector.select方法(在java.nio.channels中) ,close方法会导致它通过抛出ClosedSelectorException提前返回。

7.2 停止基于线程的服务

应用程序通过会创建拥有线程的服务,比如线程池,这些服务的存在时间通车比创建他们的方法存在的时间更长。如果应用程序优雅地退出,这些服务的线程也需要结束,因为没有退出线程惯用的优先方法,他们需要自行结束。

7.2.2 关闭ExecutorService

8 应用线程池

8.1 任务与执行策略间的隐形耦合