书山天道-Java并发编程实战

目的:深入钻研 Java concurrency


One step

  1. 研读

  2. 践行


关键点:

1. 有界缓存,直接使用 ArrayBlocingQueue  或 LinkedBlockingQueue。


 Exception

launderThrowable exception handler

  

key point

设计同步策略时考虑的方面:

  1. 将哪些变量声明为 volatile 类型。
  2. 哪些变量必须用锁来保护.
  3. 哪些锁保护哪些变量.
  4. 哪些锁必须是不可变的或者被封闭在线程中的.
  5. 哪些操作必须是原子操作等.

某些方面是严格的实现细节,应该将它们文档化以便于日后的维护。

还有一些方面会影响类中加锁的外在表现。也应该将其作为规范写入文档。

  1. 是否是线程安全的。
  2. 在执行回调时是否持有一个锁?
  3. 是否会有某些特定的锁会影响其行为。
  4. 如果希望客户端代码能够在类中添加原子操作,需要在文档中说明需要获得哪些锁才能实现安全的原子操作。 

1 volatile 的使用场景:

  • 对变量的写入操作不依赖于变量的当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变性条件中。
  • 在访问变量时不需要加锁。

仅当一个变量参与到包含其他状态变量的不变性条件时,才可以声明为volatile类型。

2. 发布 publish  p32

发布一个对象是指,使对象能够在当前作用域之外的外码中使用。

将一个指向对象的引用保存到其他代码可以访问的地方;在某一个非私有的方法中返回该引用; 或者将引用传递到其他类的方法中。

很多情况下,要确保对象及其内部状态不被发布。

在某些情况下,需要发布某个对象,如果在发布时要确保线程安全性,则可能需要同步。

发布内部状态可能会破坏封装,并使得程序难以维持不变性条件。

如果在对象构造完成之前就发布该对象,就会破坏线程安全性。

3. 逸出 escape

某个不该发布的对象被发布时,这种情况被称为逸出。


 研读笔记

2021-01-16

1. 实力封闭是构建线程安全的一种最简单方式 P50

2. 从线程封闭原则及其逻辑推论可以得出 Java 监视器模式 P51

3.使用私有的锁对象而不是对象的内置锁(或热河其他可通过公有方式访问的锁),有许多优点。要向验证某个公有访问

的锁在程序中是否被正确地使用,则需要检查整个程序,而不是单个类。 p51

4. mutablePoint 线程不安全,但追踪器类是线程安全的,深度拷贝。 p53

5. 实时变化,但负载大-深度复制;  不发生变化,浅拷贝。  p55.

6. private final List<KeyListener> keyListeners = new CopyOnWriteArrayList<KeyListener>();   是一个线程安全的链表,

特别适用于管理监听器列表, 不可变条件.

7. 单个线程安全,但组合在一起,需要有约束性的不变性条件才能保证安全性。

8. 将坐标值 x, y 获取到的放在一个数组里,避免在分别获取两个值的时候发生变化。构造一个发布其底层可变状态,还能确保

其线程安全性不被破坏。

9. “判定-编辑-保存” , “若没有则添加” ,“先检查再执行” 线程安全,操作整体为原子操作. 幂等。  p60.

10. Composition 组合 为现有的类添加一个原子操作。通过将List对象的操作委托给底层的List实例来实现List的操作,同时

还添加了一个原子的putIfAbsent方法。 假设把某个链表对象传给构造函数以后,客户代码不会再直接使用这个对象,而只能通过

ImprovedList 来访问它.  P62.


chapter 5 

同步容器类  p66

Vector, Hashtable, 这些同步的封装器类是由 Collections.synchronizedXxx 等工厂方法创建的。

这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有发方法都进行同步,使得每次只有一个线程能够

访问容器的状态.


 1. 迭代等复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发地修改容器时,可能会出现意料之外的情况。

Vector 等,线程A,B 访问修改, 可能会抛出 ArrayIndexOutOfBoundsException 异常

同步容器类要遵守同步策略,支持客户端加锁。

加锁可以防止迭代器抛出 ConcurrentModificationException。 必须要记住在所有对共享容器进行迭代的地方都需要加锁。

如果容器规模很大,或者在每个元素上执行操作的时间很长,这些线程将长时间等待。

可能会产生死锁。

即使不存在饥饿或者死锁等风险,长时间地对容器加锁也会降低程序的可伸缩性,极大地降低吞吐量和CPU利用率。

2. 隐藏迭代器

synchronizedSet 


2021-01-17

Queue 和 BlockingQueue

BlockingQueue 扩展了 Queue, 增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到

队列中出现了一个可用的元素。

如果队列已满(对于有界队列来说),那么插入将一直阻塞,直到队列中出现可用的空间,在“生产者-消费者”这种设计模式中,非常有用。


2021-01-18

FutureTask 实现了 Future 语义,表示一种抽象的可生成结果的计算。

FutureTask 表示的计算是通过 Callable 来实现的.

三种状态: 等待运行(Waiting to run);  正在运行( Running ) 和 运行完成 (Completed ) 。

执行完成 表示计算的所有可能结束方式: 正常结束, 由于取消而结束, 由于异常而结束.

Future.get 的行为取决于任务的状态,如果任务已完成,get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者 抛出异常。

FutureTask 将计算执行的线程传递到 获取这个结果的线程,而 FutureTask 的规范确保了这种传递过程能实现结果的安全发布.

在构造函数或静态初始化方法中启动线程并不是一个好方法,提供一个start方法来启动线程。

Callable 表示的任务可以抛出受检查的或未受检查的异常,并且任何代码都可以抛出一个Error。

封装到 ExecutionException 中,在 Future.get中被重新抛出。

调用get的代码复杂.

当get 方法抛出ExecutionException 时,可能是:

1.Callable 抛出的受检查异常,

2.RuntimeException

3.Error

封装到 launderThrowable 辅助方法。

 在调用 launderThrowable之前,Preloader 首先检查已知的受检查异常,并重新抛出它们。

剩下的时未检查异常,Preloader 将调用 launderThrowable并抛出结果。如果Throwable 传递给 launderThrowable

的是一个 Error, 那么 launderThrowable 将直接再次抛出它:如果不是 RuntimeException, 那么将抛出一个 IllegalStateException

表示这是一个逻辑错误。

剩下的RuntimeException, launderThrowable 将把它们返回给调用者,而调用者通常会重新抛出它们。


2021-01-20

Executor 执行的任务有4个声明周期阶段:创建,提交,开始,完成。

已提交但尚未开始的任务可以取消,

对于已经开始执行的任务,只有当它们能响应中断时,才能取消。

取消一个已经完成的任务不会有任何影响。


Future 表示一个任务的生命周期.

get 方法的行为取决于任务的状态( 尚未开始, 正在运行, 已完成 )。

1. 如果任务已经完成,get会立即返回或者抛出一个 Exception。

2. 如果任务没有完成,那么 get 将阻塞并直到任务完成。

3.  如果任务抛出了异常,get 将异常封装为 ExecutionException 并重新抛出. 可用 getCause 获得初始 异常。

4. 如果任务被取消,那么get将抛出 CancellationException。


将Runnable 或 Callable 提交线程发布到最终执行计算线程,安全发布。

设置结果的过程也包含了安全发布,即将结果从计算它的线程发布到任何通过get 获得它的线程。


只有当大量相互独立同构的 任务可以并发进行处理时,才能体现出将程序的工作负载分配到多个任务中带来的真正性能提升。


2021-01-21 23:10:13

任务并行化, 创建 n 个任务,将其提交到一个线程池,保留n个 Future, 并使用限时的 get 方法通过 Future 串行地获取每一个结果-- invokeAll

下例 支持限时的 invokeAll, 将多个任务提交到一个 ExecutorService 并获得结果.

InvokeAll 方法的参数为一组任务, 并返回一组Future。 这两个集合有着相同的结构.

invokeAll 按照任务集合中迭代器的顺序将所有的Future 添加到返回的集合中,从而使调用者能将各个 Future 与其表示的Callable 关联起来.

所有的任务都执行完毕,或者调用线程被中断时,又或者超过指定时限时,invokeAll将返回。

超过指定时限后,任何还未完成的任务都会取消

当invokeAll返回后,每个任务要么正常地完成要么被取消,而客户端代码可以调用get 或 isCancelled来判断究竟是何种情况。


 取消与关闭

中断 Interruption, 是一种协作机制,能够使一个线程终止另一个线程的工作.

如果某个任务,线程或者服务立即停止,因为这种立即停止会使共享的数据结构处于不一致的状态

在编写任务和服务时,使用一种协作方式:当需要停止时,它们首先会清除当前正在执行的工作然后再结束

任务本身的代码比发出取消请求的代码更清楚如何执行清除工作.

生命周期结束的问题会使 任务,服务以及程序的设计和实现等过程变得复杂,这个在程序设计中非常重要的要素却经常被忽略.

行为良好的软件能很完善地处理失败,关闭和取消等过程。

2021-01-22  20:30:31

任务取消

如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为 可取消的(Cancellable)。取消某个操作的原因:

  1. 用户请求取消:界面“取消”按钮,管理接口发出的取消请求,例如JMX  java management extensions.
  2. 有时间限制的操作: 有限时间内选择最优解,当计时器超时,需要取消所有正在进行的任务。
  3. 应用程序事件: 同2,非最优解,找到可行解,而后取消其他任务。
  4. 错误:例如磁盘空间已满,所有搜索任务取消,记录当前状态,以便稍后重启。
  5. 关闭。当程序或服务关闭时,必须对正在处理或等待处理的工作执行某种操作。平缓的关闭过程中,正在执行的任务继续执行直到完成,而在立即关闭过程中,当前任务则可能取消。

Java中没有一种安全的抢占式方法来停止线程,因此没有安全的抢占式方法来停止任务。

只有 一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。


2021-01-23  09:12:04

一个可取消的任务必须拥有取消策略(Cancellation Policy), 定义了取消 操作的 “How", "When", "What"

即其他代码如何 (How) 请求取消该任务,

任务在何时(When ) 检查是否已经请求了取消,

以及在响应取消请求时应该执行哪些 ( What ) 操作.


注意取消机制 及 是否会因阻塞方法。 BlockingQueue.put 可能会因阻塞一直无法检查到取消标志。  p113

在取消之外的其他操作中使用中断,都是不合适的,很难支撑起更大的应用。

 

阻塞库方法, Thread.sleep 和 Object.wai 都会检查线程何时中断.并且在发现时提前返回.

在响应中断时执行的操作包括:清除中断状态,抛出 InterruptedException 表示阻塞操作由于中断而提前结束。

JVM 并不能保证阻塞方法检测到中断的速度,但在实际情况中还是很快的.


 

但线程在非阻塞状态下中断时,它的中断状态将被设置, 然后根据将被取消的操作来检查中断状态以判断发生了中断。

中断状态将变的”有黏性“,如果不触发 InterruptedException , 那么中断状态将一直保持,直到明确清除中断状态。


中断是实现取消的最合理方式.

对中断操作的正确解释是:它并不会真正地中断一个正在运行的线程,而是发出中断请求,然后由线程在下一个合适的时刻中断自己(取消点)。

有些方法, wait, sleep, join 等,将严格处理这种请求,当它们收到中断请求或者在开始执行时发现某个已经被设置到的中断状态时,将抛出一个异常。

设计良好的方法可以完全忽略这种请求,只要它们能使调用代码对中断请求进行某种处理.

设计糟糕的方法可能会屏蔽中断请求,从而导致调用栈中的其他代码无法对中断请求做出响应.

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

正如任务中应该包含取消策略一样,线程同样应该包含中断策略。

中断策略规定线程如何解释某个中断请求--当发现中断请求时,应该做哪些工作(如果需要),哪些工作单元

对于中断来说是原子操作,以及以多快的速度来响应中断。


最合理的中断策略是某种形式的线程级(Thread - Level ) 取消操作或服务级(Service - Level )取消操作:

尽快退出,在必要时清理,通知某个所有者该线程已经退出。

还可以建立其他中断策略,例如暂停服务或重新开始服务,但对于那么包含非标准中断策略的线程或线程池,只能用于能知道这些策略的任务中。 p116


响应中断

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

1.传递异常(可能在执行某个特定于任务的清除操作之后), 从而使你的方法也成为可中断的阻塞方法.

2.恢复中断状态,从而使调用栈中的上层代码能够对其进行处理.


 

将 InterruptedException 传递给调用者

 


不想或无法传递 InterruptedException(通过 Runnable 来定义任务), 那么需要寻找另一种方式来保存中断请求。

一种标准的方法就是通过调用 interrupt 来恢复中断状态.

除非在你的代码中实现了中断策略,否则不能屏蔽 InterruptedException, 例如在catch 块中捕获到异常却不做任务处理.

 

不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。

它们应该在本地保存中断状态。并在返回前恢复状态而不是在捕获InterruptedException时恢复状态,如果过早地设置

中断状态,可能引起无限循环,因为大多数可中断的阻塞方法都会在入口处检查中断状态,并且当发现该状态已被设置时会立即抛出

InterruptedException 。 p118


处理不可中断的阻塞

并非所有的可阻塞方法或者阻塞机制都能响应中断. 如果一个线程由于执行同步的Socket I/O 或者等待获得内置锁而阻塞,那么中断请求只能

设置线程的中断状态,除此之外没有其他任何作用。

由于执行不可中断操作而被阻塞的线程,可使用类似于中断的手段来停止这些线程,要求我们必须知道线程阻塞的原因。

Java.io 包中的同步 Socket I/O

Java.io 包中的同步 I/O

Selector 的异步 I/O

获取某个锁..  Lock 类中提供了 lockInterruptibly 方法。


停止基于线程的服务

应用程序通常会床架你拥有多个线程的服务,例如线程池,且这些服务的生命周期通常比创建它们的方法

的生命周期要长。如果应用程序准备退出,这些服务所拥有的线程也需要结束。它们需要自行结束。

正确的封装原则:

除非拥有某个线程,否则不能对该线程进行操控。例如,中断线程或修改线程的优先级。

线程由Thread对象表示,且可以被自由共享,然而,线程有一个相应的所有者,即创建该线程的类。

因此线程池是其工作者的所有者,要中断这些线程,要使用线程池。

与其他封装对象一样,线程所有权是不可传递的。

服务应该提供 生命周期方法来关闭它自己及它所拥有的线程。

当应用程序关闭该服务时,服务就可以关闭所有线程了。ExecutorService中的 shutfown 和 shutdownNow 方法。

当取消一个生产者-消费者操作时,需要同时取消生产者和消费者。 


处理非正常的线程终止

并发程序中的某个线程发生故障,通常不会如此明显。当线程发生故障时,应用程序可能看起来仍然在工作,

所以这个失败很可能会被忽略。幸运的是,我们有可以监测并防止在程序中”遗漏“线程的方法。

导致线程提前死亡的最主要原因就是 RuntimeException.

如果在GUI程序中丢失了事件分派线程,那么造成的影响将非常显著--应用程序将停止处理事件且GUI会因此失去响应.

主动方法来解决未检查异常. 在 Thread API 同样提供了 Uncaught-ExceptionHandler, 它能检测出某个线程由于未捕获的异常而终结的

情况。这两种方法是互补的,通过将二者结合在一起,就能有效地防止线程泄露问题。 P134

JVM - 将事件报告给 APP  提供的 UncaughtExceptionHandler 异常处理器. 如果没有,默认的行为是将栈追踪信息输出到 System.err.

异常处理器常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中,还可以尝试重启线程,关闭app,或执行

其他修复或诊断。


 

JVM 关闭

对所有服务使用同一个关闭钩子,而不是每个服务使用一个不同的关闭钩子,并且在该关闭钩子中执行一系列的关闭操作。 p136

守护线程

线程分两种: 普通线程和守护线程。

JVM 启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程(例如GC以及其他执行辅助工作的线程)。

当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此默认情况下,主线程创建的所有线程都是普通线程。

差异仅在于当线程退出时的发生的操作,当一个线程退出时,JVM会检查其他正在运行的线程,如果这些线程都是daemon thread.

那么JVM会正常退出操作。JVM停止时,所有仍存在的daemon thread 都将被抛弃-既不会执行finally代码块,也不会执行回卷栈,

而JVM直接退出。

daemon thread 最好执行”内部“任务,例如周期性地从内存的缓存中移除逾期的数据。

终结器

文件句柄或套接字句柄,不再需要它们时,必须显式地交还给操作系统。为实现这个功能,垃圾回收器对那些定义了finalize方法的对象会进行特殊处理:

在回收期释放它们后,调用它们的finalize方法,从而保证一些持久化资源被释放.

大多数情况下,通过使用finally代码块和显式的close方法,能够比使用终结器更好地管理资源,唯一的例外情况在于:当需要管理对象,且该对象持有的

资源是通过本地方法获得的。



线程池的使用

有些类型的任务需要明确地指定执行策略:

  1. 依赖性任务。在线程池中执行独立的任务时,可随意改变线程池的大小和配置,只会对执行性能产生影响,然而,如果提交给线程池的任务需要依赖其他的任务,就隐含地给执行策略带来了约束,必须小心维护以避免产生活跃性问题
  2. 使用线程封闭机制的任务。与线程池相比,单线程的Executor 能够对并发性做出更强的承诺。在任务与执行策略间形成隐式的耦合--任务要求其执行所在的Executor是单线程的,--newSingleThreadExecutor,如果将Executor 从单线程环境改为线程池环境,那么将会失去线程安全性.
  3. 对响应时间敏感的任务。GUI.,如果将多个运行时间较长的任务提交到一个只包含少量线程的线程池中,将降低由该Executor管理的服务的响应性.
  4. 使用ThreadLocal的任务. 只有当线程本地的生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线程池的线程中不应该使用ThreadLocal在任务之间传递值。

 只有当任务都是同类型的并且相互独立时,线程池的性能才能达到最佳。

如果将运行时间较长的与运行时间较短的任务混合在一起,那么除非线程池很大,否则可能造成“阻塞”

如果提交的任务依赖与其他任务,那么除非线程池无限大,否则将可能造成死锁。

基于网络的典型服务器应用中--网页服务器,邮件服务器,以及文件服务器等,它们的请求通常是同类型的并且相互独立的。


 

线程饥饿死锁 Thread Starvation Deadlock

线程池中,如果任务依赖于其他任务,那么可能产生死锁。

在单线程的Executor中,如果一个任务将另一个任务提交到同一个Executor,且等待这个被提交任务的结果,通常会发生死锁

 

运行时间较长的任务

限定任务等待资源的时间,而不要无限制地等待。

如果等待超时,可以把任务标识失败,然后中止任务或者将任务重新放回队列以便随后执行.

这样,无论任务的最终结果是否成功,这种办法都能确保任务总能继续执行下去,并将线程释放出来以执行一些能更快完成的任务.

 

设置线程池的大小

取决于任务的类型以及所部署系统的特性.

如果线程池过大,那么大量的线程将在相对较少的CPU和内存资源上发生竞争,这不仅会导致更高的内存使用量,还可能耗尽资源。

如果线程池过小,将导致许多空闲的处理器无法执行工作,从而降低吞吐率。


设置线程池大小

1. 分析计算环境,资源预算和任务的特性。

有多少个CPU ? 多大的内存?任务是计算密集型, I/O 密集型还是二者皆可? 是否需要像JDBC连接这样的稀缺资源?

如果需要执行不同类别的任务,且它们之间的行为相差很大,应该考虑使用多个线程池,使每个线程池可以根据各自的工作负载来调整。

计算密集型的任务,在拥有 Ncpu 个处理器的系统上,当线程池的大小为 Ncpu + 1 时,通常能实现最优的利用率。

(即使当计算密集型的线程偶尔因为页缺失故障或者其他原因而暂停时,这个“额外” 的线程也能确保 CPU 的时钟周期不会被浪费)

对于包含 I/O 操作或者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。

必须估算出任务的等待时间 与 计算时间 的比值,不需要很准确,根据 分析 或 监控工具获得。

 

在某个基准负载下,分别设置不同大小的线程池来运行应用程序,并观察 CPU 利用率的水平。

Ncpu  = Number of CPUs

Ucpu = target CPU utilization,   Ucpu 大于等于 0 ,小于等于 100%

W/C   等待时间 与 计算时间比率

线程池的最优大小为    N( threads)  =  N cpu  *  Ucpu  * ( 1 +  W / C )

int  N_cpus = Runtime.getRuntime() . availableProcessors();

CPU 周期并不是唯一影响线程池大小的资源, 还包括内存, 文件句柄, 套接字句柄 和 数据库连接等.

计算这些资源对线程池的约束条件是更容易的: 计算每个任务对该资源的需求量,然后用该资源的可用总量

除以每个任务的需求量,所得结果就是线程池大小的上限。

当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程池和资源池的大小将会互相影响。

如果每个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的大小。

当线程池中的任务时数据库连接的唯一使用者时,那么线程池的大小又将限制连接池的大小.



 2021-02-11

chapter 3 避免活跃性危险

最简单的死锁形式:

线程A持有锁L并尝试获取锁M,线程B持有锁并尝试获取锁L,这两个线程将永远的等待下去。抱死 deadly embrace

其中多个线程由于存在环路的锁依赖关系而永远地等待下去。

把每个线程想象为有向图中的一个节点,图中每条边表示的关系是:“线程A等待线程B所占有的资源”,如果在图中形成了一条环路,

那么就存在一个死锁)

 

数据库系统的设计中考虑了检测死锁以及从死锁中恢复。在执行一个事务Transaction时可能需要获取多个锁,并一直持有这些锁直到事务提交。

因此在两个事务之间很可能发生死锁,但事实上这种情况并不多见。

如果没有外部干涉,那么这些事务将永远地等待下去。

数据库服务器会处理:当他检测到一组事务发生了死锁时(通常在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。

作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行.

应用程序可以重新执行被强行中止的事务,而这个事务现在可以成功完成,因为所有跟它竞争资源的事务都已经完成了。


 

JVM 解决死锁问题方面并没有数据库服务那样强大,当一组Java线程发生死锁时,“游戏”将到此结束--这些线程永远不能再使用了,

根据线程完成的工作不同,程序可能完全停止,或某个特定的子系统停止,或是性能降低。

恢复应用程序的唯一方式:中止并重启。

死锁造成的影响很少会立即显现出来,如果一个类可能发生死锁,并不意味着每次都会发生死锁,只是表示可能。

死锁出现,往往在 高负载情况下。


 

1. 锁顺序死锁

A LeftRight

B RightLeft

LeftRightDeadLock 因为两个线程试图以不同的顺序获得相同的锁,交错。

如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

 

2. 上锁得顺序取决于传递给 transferMoney 得参数顺序,而这些参数顺序又取决于外部输入。

必须定义锁得顺序,并在整个应用程序中按照这个顺序来获取锁。

System.identityHashCode 方法,将返回又 Object hashCode 返回得值。

极少数情况下,两个对象可能拥有相同得散列值,此时必须通过某种任意得方法来决定锁得顺序,而这可能又会重新引入死锁。

使用加时赛 Tie-breaking 锁,

考虑 Account中包含一个唯一得,不可变得,且具备可比性得键值,例如账号,则执行锁得顺序就容易,通过键值对对象进行排序,因为不用加时赛 锁。

System.identityHashCode 中出现散列冲突得频率非常低。

 

3. 在协作对象之间发生的死锁

开放调用,从而消除发生死锁的风险。

需要使同步代码块仅被用于保护那些涉及共享状态的操作。

注意收缩同步代码块的范围,可以提高伸缩性。

在程序应尽量使用开放调用,与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。

某些情况下,丢失原子性会引发错误,有些解决方案依赖于构造一些协议(而不通过加锁)来防止其他线程进入代码的临界区。

 

资源死锁

多个线程在相同的资源集合上等待时,也会发生死锁。

数据库的连接池,资源池通常采用信号量来实现当资源池为空时的阻塞行为。

一个任务需要连接两个数据库,在请求这两个资源时不会始终遵循相同的顺序。死锁时,如果每个资源池有N个连接,不仅需要N个循环

等待的线程,且还需要大量不恰当的执行时序。

 

线程饥饿死锁 thread starvation deadlock

如果在调用某个方法时不需要持有锁,那么这种调用被称为 开放调用 ( Open Call )

依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更易于编写。

通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:

 

尽可能地使用开放调用,将更易于找出那些需要获取多个所得代码路径,更容易确保采用一致的顺序来获得锁。

 

一个任务提交另一个任务,并等待提交任务在单线程的Executor 执行完成。

这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor中执行的所有其他任务都停止执行。

如果某些任务需要等待其他任务的结果,这些任务往往是产生线程饥饿死锁的主要来源,

有界线程池/资源池 与 相互依赖的任务不能一起使用。


如果必须获得多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入

正式文档并始终遵循这些协议.

在使用细粒度锁的程序中,可以通过使用一种两阶段策略 two-part strategy 来检查代码中的死锁:

1. 找出在什么地方将获取多个锁(使这个集合尽量小),

2.然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。

如果所有的调用都是开放调用,那么要发现获取多个锁的实例也是非常简单的.

 

支持定时的锁

从检测死锁和从死锁中恢复过来,显式地使用Lock类中的定时 tryLock功能来代替内置锁机制

内置锁,在未获得锁时会一致等待。

而显式锁可以指定超时Timeout, 在超时后 tryLock返回一个失败信息,

如果超时时限内获得锁,则可以在发生某个意外情况后重新获得控制权。

 

当定时锁失败时,并不需要知道失败的原因。

或许时因为发生了死锁,某个线程在持有锁时错误地进入了无限循环,某个操作的执行时间超时。

能记录所发生的失败,以及关于这次操作的其他有用信息。

可以重新启动计算,而不是关闭整个进程。

 

超时后能释放锁,后退并在一段时间后再尝试,消除了死锁发生的条件,使程序恢复过来。


 

线程转储信息来分析死锁

JVM 通过线程转储 (Thread Dump ) 来帮助识别死锁的发生。

包括各个运行中的线程的栈追踪信息。类似于发生异常时的栈最终信息。

线程转储还包含加锁信息,例如每个线程持有哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。

(即使没有死锁,这些信息对于调试来说也是有用的,通过定期触发线程转储,可以观察程序的加锁行为)

生成转储之前,JVM 将在等待关系图中通过搜索循环来找出死锁。

如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中涉及了哪些锁和线程,以及这个锁的获取操作位于程序的哪些位置。

unix -> 向JVM进程发送SIGOUIT信号, 平台 Ctrl - \ 键

windows Ctrl- Break 键


 

饥饿,丢失信号,活锁。

线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿” (Starvation ) 引发饥饿的最常见资源就是 CPU 时钟周期。

在Java app 中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(如无线循环,或者无限制等待某个资源),可能会导致饥饿,

因为其他需要这个锁的线程将无法得到它.

 

糟糕的响应性

GUI 应用程序中使用了后台线程,把运行时间较长的任务放到后台线程中运行,从而不会使用户界面失去响应。

但 CPU 密集型的后台任务仍然可能对响应性造成影响,因为它们会与事件线程共同竞争 CPU 的时钟周期。

这种情况下发挥线程优先级的作用。

计算密集型的后台任务将对响应性造成影响。如果有其他线程完成的工作都是后台任务,那么应该降低它们的优先级,从而提高前台程序的响应性。

 

如果某个线程长时间占有一个锁(或者正在对一个大容器进行迭代,且对每个元素进行计算密集的处理),而其他想要访问这个容器 的线程必须等待很长时间。

 

活锁

Livelock 是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,且总会失败。

通常发生在处理事务消息的应用程序中:如果不能成功处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列开头.

如果消息处理器在吃力某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到错误的处理器时,都会发生事务回滚。

由于这条消息又被放回到队列头,因此处理其将被反复调用,并返回相同的结果 ( Poison Message )

这种形式的活锁由过度的错误恢复代码造成,它错误地将不可修复的错误当作可修复的错误。

 

当多个互相协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。

 

要解决这种活锁问题,需要在重试机制中引入随机性。

 

以太协定定义了在重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生阻塞和反复失败的风险。

在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。 


2021-02-12

性能决策包含多个变量,且非常依赖于运行环境。问题如下

1.”更快“的含义是什么

2.该方法在什么条件下运行的更快?是低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证答案?

3.这些条件在运行环境中的发生频率?能够通过测试来验证答案?

4.在其他不同条件下的环境中能够使用这里的代码?

5.在使用这种性能提升时需要付出哪些隐含的代码,例如增加开发风险或维护开销?这种权衡是否合适?


Amdahl 定律 p186

即使串行部分所占的百分比很小,也会极大的限制当增加计算资源时能够提升的吞吐率。  p189

synchronizedList 封装的 LinkedList 会由于同步开销的增加,吞吐量下降。受到上下文切换的限制。

ConcurrentLinkedQueue 非阻塞队列算法,使用原子引用来更新各个链接指针。只有对指针的更新操作需要串行执行。

要知道串行部分如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分的差异。


 上下文切换 

p190

 内存同步

synchronized 和 volatile 内存栅栏 (Memory Barrier) 

会一致一些编译器优化操作,其内大多数操作都是不能被重排序的.

在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。

无竞争同步开销不为零, Fast-Path  非竞争同步将消耗20 -- 250 个 时钟周期。 对应用程序整体性能的影响微乎其微。


 锁消除优化 Lock Elision 逸出分析 Esacape Analysis 找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。


 锁粒度优化 Lock coarsening 操作,将临近的同步代码块用同一个锁合并起来。


某个线程中的同步可能会影响其他线程的性能。

同步会增加共享内存总线上的通信量,总线的带宽是有限的,所有的处理器都共享这条总线。


 阻塞

非竞争的同步可以完全在 JVM 中处理,而竞争的同步可能需要操作系统的介入,从而增加开销。

JVM 在实现阻塞行为时,可以采用 自旋等待 Sping-waiting, 通过循环不断尝试获取锁,直到成功)

或通过操作系统挂起被阻塞的线程。

当线程无法获取到某个锁,或由于在某个条件等待或在 I/O 操作上阻塞时,需要被挂起,包含两次额外的上下文切换,以及

所有必要的操作系统操作和缓存操作。  P192


减少锁的竞争

串行操作会降低可伸缩性,且上下文切换会降低性能,在锁上发生竞争将同时导致这两种问题.

在并发程序中,可可伸缩性的最主要威胁就是独占方式的资源锁。

有两个因素将影响在锁上发生竞争的可能性:

锁的请求频率。

每次持有该锁的时间。

如果二者的乘机很小,那么大多数获取的锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。

极端情况下,即使仍有大量工作等待完成,处理器也会被闲置.


 有3中方式可以降低锁的竞争程度:

1. 减少锁的持有时间。

2.降低锁得请求频率。

3.使用带有协调机制的独占锁,这些机制允许更高的并发性.


在分解同步代码块时,理想的平衡点将与平台相关,实际情况中,仅当可以将一些”大量“的计算 


 

 

阻塞操作从同步代码块中移除时,才应该考虑同步代码块的大小。


 2021-02-13

锁分解  P196

锁分段:对一组独立对象上的锁进行分解,这种情况被称为锁分段。

concurrencyHashMap 的实现中使用了 一个包含16个锁的数组,每个锁保护所有散列桶的1/16,第N个散列桶由第 N mod 16 把锁保护。

假设散列函数具有合理的分布性,且关键字能够实现均匀分布,大约能把对于锁的请求减少到原来的1/16.

正是这项技术使得 ConcurrencyHashMap 能够支持多达16个并发的写入器。

拥有大量处理器的系统在高访问量的情况下实现更高的并发性,可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈

并需要突破这个限制,才能将锁分段的数量超过默认的 16个。

劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难且开销更高。

通常执行一个操作时只需要获得一个锁,在某些情况下需要加锁整个容器。

 

采用锁分段技术:一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。

Hot field 热点域  p198

concurrentHashMap 中的 size 将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局技术。

为了避免枚举每个元素, ConcuccrentHashMap 为每个分段都维护了一个独立的全局计数。 并通过每个分段的锁来维护这个值。


 

如果 size 方法的调用频率与修改Map操作的执行频率大致相当,那么可以采用这种方式来优化所有已分段的数据结构,

即每当调用size时,将返回值缓存到一个 volatile 变量中,且每当容器被修改时,使这个缓存中的值无效 将其设为-1.如果发现缓存的值非负,

那么表示这个值是正确的,可以直接返回,否则,需要重新计算这个值。


 

替代独占锁

1. 并发容器

2. 读-写锁  ReadWriteLock : 一种在多个读取操作 以及 单个 写入操作情况下的 加锁规则: 如果多个读取操作都不会修改公共资源,那么这些读取操作

可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。

对于只读的数据结构,包含的不可变性可以完全不需要加锁操作。

3 不可变对象

4. 原子变量 : 降低更新“热点域” 时的开销,静态计数器,序列发生器,或者对链表数据结构中头节点的引用, 提供了在整数或对象引用上的细粒度原子操作

因此可伸缩性更高,使用了现代处理器中提供的底层并发原语(比较并交换 compare - and - swap] , 类中只包含少量的热点域,且这些i域不会与其他变量参与到不可变性条件中,用原子变量来替代它们能提高可伸缩性。 降低热点域的开销,但不能消除。


 

检测 CPU 的利用率

测试可伸缩性时,要确保处理器得到充分利用.

UNIX 系统上的 vmstat 和 mpstat 或者 Windows系统的 perfmon, 都能给出处理器的 “忙碌” 状态.

CPU 的利用率并不均匀,有些忙碌,有些不是,

首要目标是进一步找出程序中的并行性,不均匀的利用率表明大多数计算都是有一小组线程完成的,且应用程序没有利用其他的处理器.

CPU 没有充分利用的常见原因:

1. 负载不充分。 增加负载,检查 利用率, 响应时间, 和服务时间等指标的变化。

2. I/O 密集。 通过 iostat 或 perfmon 判断某个应用程序是否是磁盘 I/O 密集型的, 或者通过检测网络的通信流量级别来判断它是否需要高带宽。

3. 外部限制: 如果应用依赖于外部服务,例如数据库或 Web 服务,性能瓶颈可能不在自己的代码中,使用某个分析工具或数据库管理工具来判断

4. 锁竞争: 分析工具, 随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。 ,使用监视工具来判断是否能通过增加额外的 CPU 来提升程序的性能。可能需要调整线程池的大小。

vmstat 命令的输出中,有一栏信息是当前处于可运行状态但并没有运行(由于没有足够的 CPU )的线程数量。


 

同步容器,单线程情况下的性能与 ConcurrentHashMap的性能基本相当,但当负载情况由非竞争性变成竞争性时--同步容器的性能将变得糟糕。

伸缩性受到锁竞争限制,常见的行为。

竞争度不高时,每个操作消耗的时间基本上就是实际执行工作的时间,且吞吐量会因为线程数增加而增加。

当竞争变得激烈时,每个操作消耗的时间大部分都用于上下文切换和调度延迟,再加入更多的线程也不会提高太多的吞吐量。


 

减少上下文切换的开销

许多任务中都包含一些可能被阻塞的操作。

当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。

在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。 


 

异步日志

日志模块将 I/O 操作从发出请求的线程转移到另一个线程,那么通常可以提高性能,但也会引入更多的设计复杂性,例如

1. 中断,当一个在日中操作中阻塞的线程被中断,将出现什么情况)

2. 服务担保, 日志模块能够保证队列中的日志消息都能在服务结束之前记录到日志文件

3. 饱和策略 ,当日志消息的产生速率 比日志模块的处理速度更快时,将出现什么情况

4. 服务生命周期 (如何关闭日志模块, 以及如何将服务状态通知给生产者) 


 

将 I/O  操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。

把一条包含 I/O 操作和锁竞争的复杂且不确定的代码路径 变成简单的。 


 

将工作分散开来,并将 I/O 操作已到了另一个用户感知不到的线程上。

通过把所有记录日志的 I/O 转移另一个线程,消除了输出流上的竞争,又去掉了一个竞争来源。

将提升整体的吞吐量,在调用中消耗的资源更少,上下文切换次数更少,锁得管理更简单。 


2021-02-17

并发程序的测试  海森堡原理   p205  

两类:

1. 安全性测试

采用测试不变性条件的形式,判断某个类的行为是否与其规范保持一致。

链表,修改时把其大小缓存下来,并发测试时,可能由于竞争失败,

将访问计数器的操作 和 同级元素数据的操作合并为 单个原子操作。

实现: 对链表加锁以实现独占访问,然后采用链表中提供的某种“原子快照”功能,

或 在某些“测试点” 上采用原子访问来判断不变性条件 或 执行测试代码。


 

2. 活跃性测试:  进展测试 和 无进展测试。

相关的 性能测试

1. 吞吐量: 一组并发任务中已完成任务所占比例。

2. 响应性: 指请求从发出到完成之间的时间 ( 延迟)

3. 可伸缩性:在增加更多资源的情况下,指 CPU;  吞吐量(缓解短缺) 的提升情况.


1. 基本的单元测试

2.对阻塞操作的测试 : 遵循一个约定:每个测试必须等待它所创建的所有线程结束后才能完成。能否通过测试,以及是否在某个地方报告了失败信息以用于诊断问题.

3.安全性测试。


 

2. 阻塞操作的测试  p207  

 


安全性测试:

发现由于数据竞争而引发的错误,测试一个并发类在不可预测的并发访问情况下能否正确执行.

需要创建多个线程来分别执行 put 和 take 操作,并在执行一段时间后判断在测试中是否会出现问题:

要测试在生产者-消费者模式中使用的类:

1. 检查被放入队列中 和从队列中取出的 各个元素,简单实现: 当元素被插入到队列时,同时将其插入到“影子” 列表,当从队列中删除该元素时,

同时从“影子” 列表中删除,然后在测试程序运行完以后判断“影子”列表是否为空.

该方法可能会干扰测试线程的调度,因为在修改“影子”列表时需要同步,并可能会阻塞。

2. 更好的办法:通过一个对顺序敏感的校验和计算函数 来计算 所有入列元素 和出列元素的 校验和,并进行比较,如果二者相等,测试成功。 

 

 

 



 

跳到chapter16 为与 JVM 衔接 



Java 内存模型

JMM 规则了JVM 必须遵循一组最小保证:对变量的写入操作在何时对于其他线程可见。

不同的处理器架构中提供了不同级别的  缓存一致性 Cache Conherence ,其中一部分只提供最小的保证,

即允许不同的处理器在任意时刻从同一存储位置上看到不同的值。

Java 提供自己的内存模型,JVM通过在适当位置上插入 (处理器架构定义的 内存栅栏)来屏蔽在JMM 与底层平台模型之间的差异.

乐观的模型: 串行一致性。现代多处理器架构都不存在.


Java 不需要指定内存栅栏的位置,只需正确地使用同步,来找出 何时将访问共享状态

使操作延迟 或者 看似 乱序执行 的不同原因,都可以归为 重排序。

内存级的重排序会使程序的行为变得不可预测。

要确保在程序中正确地使用同步很容易: 同步将限制 编译器,运行时, 和硬件对内存操作重排序的方式,从而在

实施重排序时不会破坏 JMM 提供的可见性保证.


Java 内存模型 JMM

通过各种操作来定义 JMM, 对变量的读/写, 监视器的加锁和解锁,线程的启动与合并操作.

JMM为所有操作定义了一个 偏序关系。 集合上的关系,具有反对称, 自反 和传递属性.

Happends-Before.

要保证操作B的线程 看到 操作A 的结果 ( 无论 A 和 B 是否在同一个线程中执行),那么在 A 和 B 之间必须满足 Happends-Before 关系.

如果不满足,JVM可以任意排序。


当一个变量被多个线程读取,和至少一个线程写入时,如果在读操作和写操作之间没有依照 Happens-Before 来排序,就会产生数据竞争问题.

在正确同步的程序中不存在数据竞争,并会表现出串行一致性. 意味着程序中的所有操作都会按照一种固定的 和 全局的 顺序执行.

类库中提供的 Happends-Before 排序

1. 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器获得这个元素的操作之前执行.

2. 在 CountDownLatch 上的倒数操作将在线程从闭锁上的await方法中返回之前执行.

3. 释放 Semaphore 许可的操作将在从该 Semaphore上获得一个许可之前执行。

4. Future 表示的任务的所有操作将在从 Future.get 中返回之前执行.

5. 向Executor 提交一个Runnable 或 Callable 的操作将在任务开始执行之前执行。

6. 一个线程到达 CyclicBarrier 或 Exchanger 的操作将在其他到达该栅栏或交换点的线程被释放之前执行.

如果 CyclicBarrier 使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前进行,

而栅栏操作又会在线程从栅栏释放之前执行。


安全发布技术都来自 JMM 提供的保证,而造成不正确发布的真正原因:

在“发布一个共享对象” 与 “另一个线程访问该对象” 之间缺少一种 Happends-Before 排序。

错误的延迟初始化将导致不正确的发布。

除了不可变对象之外,使用被另一个线程初始化的对象通常都是不安全的,

除非对象的发布操作是在使用该对象的线程开始使用之前执行。


 

安全初始化模式

1. 加 synchronized 方法 获取实例

 

初始器中采用了特殊的方式来处理静态域(或者在静态初始化代码块中初始化的值), 

并提供了额外的线程安全性保证。

静态初始化器是由JVM在类的初始化阶段执行,即在类被加载后且被线程使用之前.

由于JVM将在初始化期间获得一个锁JLS,且每个线程都至少获取一次这个锁以确保这个类已经加载,

因此在静态初始化期间,内存写入操作将自动对所有线程可见。 

无论在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。

该规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步来

确保随后的修改操作是可见的,避免数据破坏.


 

延长初始化占位类模式

通过使用提前初始化 eager initialization, 避免了在每次调用SafeLazyInitialization 中的 getInstance 时所产生的同步开销。

将这项技术和JVM的延迟加载机制结合起来,可以形成一种延迟初始化技术,从而在常见的代码路径中不需同步。

P285


DCL 双重检查加锁  p286

初始化安全性只能保证通过final域可达的值,从构造过程完成时开始的可见性。

对于通过非final域可达的值,或者在构造完成后可能改变的值,必须采用同步来确保可见性。


2021-02-18

根据系统平台的不同,创建线程 与  启动线程等操作 可能需要较大开销。

如果线程的执行时间很短 ,且在循环中启动了大量这样的线程,最坏的情况是,这些线程将串行执行而不是并发执行。

第一个线程仍然比其他线程具有 “领先有时”。

解决方法: 两个 CountDownLatch,  其一为 开始阀门, 其二为 结束阀门。

使用 CyclicBaarrier 获得同样的效果: 在初始化CyclicBarrier 时将计数值指定为工作者线程的数量再加1,并在运行开始和结束时,

使工作者线程和测试线程都在这个栅栏处等待. 


资源管理的 测试

资源泄露。

对于任何持有或管理其他对象的对象,都应该在不需要这些对象时销毁对它们的引用.

这种存储资源泄露不仅会妨碍垃圾回收器回收内存(或者线程,文件句柄,套接字,数据库连接或其他有限资源),

而且还会导致资源耗尽 以及应用程序失败。


 

限制缓存。


PutTakeTest  p211


验证线程池扩展能力的测试方法.  p214


性能测试将衡量典型测试用例中的端到端的性能.

在生产者-消费者设计中通常都会用到有界缓存,因此显然需要测试生产者在向消费者提供数据时的吞吐量。

性能测试的第二个目标是根据经验值来调整各种不同的限值,例如线程数量,缓存容量等。

可能依赖于具体平台的特性(例如:处理器的类型,处理器的步进级别(Stepping Level), CPU 的数量或内存大小等),

因此需要动态地进行配置,通常需要合理地选择这些值.


2021-02-18

p216 使用 TimedPutTakeTest 测试  不同缓存下, 定时,平均数


1个双核超线程机器上测试,使用了一个包含256个元素的缓存.

LinkedBlockingQueue 的可伸缩性要高于 ArrayBlokingQueue 。 链表队列的put和take 等方法支持并发性更高的访问.

因为一些优化后的链接队列算法能将队列头节点的更新操作与尾节点的更新操作分离开来。

由于内存分配操作通常是线程本地的,因此如果算法能通过多执行一些内存分配操作来降低竞争程度,那么这种算法通常

具有更高的可伸缩性。


避免性能测试的陷阱

找出一个典型的使用场景,编写一段程序多次执行这种使用场景,并统计程序的运行时间。

1.垃圾回收

两种策略 p220

2. 动态编译

HotSpot JVM 中将字节码的解释 与 动态编译 结合起来使用。

运行程序时使用命令行选项 -xx : +PrintCompilation 当动态编译运行时将输出一条信息。据此验证动态编译是在测试前后。


在多处理器系统上,无论是正是产品还是测试版本中,都应该选择 -server模式而不是 -client 模式。

避免运算被优化掉而又不会引入过高的开销:

计算某个派生对象中域的散列值,并将它与一个任意值进行比较,例如 System.nanoTime 的当前值,如果二者碰巧相等,

那么就输出一个无用且可被忽略 的消息:

if ( foo.x.hashCode() ==  System.nanoTime()) {

    System.out.print(" ");

}

很少会成功,即使成功了,唯一作用也是在输出中插入一个无害的空字符, print 方法将结果缓存起来,

并直到调用println 才真正地执行输出操作,因此即使 hashCode 与 nanoTime() 值等,也不会真正执行 I/O 操作。


错误模式

  1. 不一致的同步
  2. 调用 Thread.run
  3. 未被释放的锁.
  4. 空的同步块
  5. 双重检查锁,在读取一个共享的可变域时缺少正确的同步。
  6. 在构造函数中启动一个线程
  7. 通知错误
  8. 条件等待中的错误
  9. 对 Lock 和 Condition 的误用。
  10. 在休眠或者等待的同时持有一个锁。
  11. 自旋循环。

JMX ThreadInfo 类 线程竞争监测。


 2021-02-24

可中断的锁获取操作同样能在可取消的操作中使用加锁.

请求内置锁,这些不可中断的阻塞机制将使得实现可取消的任务变得复杂。

lockInterruptibly 方法能够在获的锁 的同时保持对中断的响应,且由于它包含在Lock中, 因此无须创建其他类型的不可中断阻塞机制.

可中断的锁获取操作的标准结构,需要两个 try 块.


 

非块结构的加锁

内置锁,锁的获取和释放等操作都是基于代码块的--释放锁的操作总是与获取锁的操作处于同一个代码块,

而不考虑控制权如何退出代码块.自动的锁 释放简化了对程序的分析,避免了可能的编码错误,有时候需要更灵活的加锁规则.

p231

锁分段 技术在基于散列的容器中实现了不同的散列链,以便使用不同的锁,可以通过采用类似的原则来降低链表中锁的粒度.

即为每个链表节点使用一个独立的锁,使不同的线程能独立地对链表的 不同部分 进行操作。

每个节点的锁将保护链接指针以及在该节点存储的数据,因此当遍历 或 修改链表时,我们必须持有该 节点上的这个锁,

直到获得下一个节点的锁,只有这样,才能释放前一个节点上的锁。

连锁式加锁(Hand-Over-Hand Locking)  或者  锁 耦合 (Lock  Coupling ).


 

ReentrantLock 的构造函数中提供了两种公平性选择:创建一个非公平的锁(默认)或者一个公平的锁。

在公平的锁上,线程将按照它们发出的请求的顺序来获得锁,但在非公平的锁 上,则允许 “插队”.

非公平的锁上,允许“插队”,当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,

那么这个线程将跳过队列 中 所有的 等待线程并获得这个锁,

Sempaphore 同样可以选择采用公平的或非公平的获取顺序。


 

在执行加锁操作时 ,公平性将由于在挂起线程和恢复线程时存在的开销而极大地降低性能.

在大多数情况下,非公平锁的性能要高于公平锁的性能。


 

激烈竞争的情况下,非公平锁的性能高于公平锁的一个原因是:在恢复一个被挂起的线程与

该线程真正开始运行之间存在着严重的延迟。


当持有锁的时间相对较长, 或者请求锁的平均时间间隔较长,那么应该使用公平锁。

这种情况下,“插队”带来的吞吐量提升(但锁处于可用状态,线程却还处于被唤醒的过程中)则可能不会出现.


与ReentrantLock 一样,内置加锁并不会提供确定的公平性保证,但在大多数情况下,在锁实现上实现统计的公平性保证已经足够了.


只有在内置锁无法满足需求的前提下,ReentrantLock 可以作为一种高级工具。

可定时的,可轮询的 与 可中断的 锁获取操作, 公平队列 以及 非块结构的锁, 才使用 ReentrantLock.


 

内置锁 与 ReentrantLock 相比还有一个优点:在线程转储中能给出在哪些调用帧中获得了哪些锁。

并能够检测和识别发生死锁的线程。

JVM 并不知道哪些线程持有ReentrantLock, 因此在调试使用 ReentrantLock 的线程的问题.

Java6 之后提供了相应的接口.  p234


synchronized 是 JVM 的内置属性,能执行一些优化,例如对线程封闭的锁对象的锁消除优化,通过增加锁的粒度来消除内置锁的同步,

而如果通过基于类库的锁来实现这些功能,则可能性不大。

性能上,应选择 synchronized 而不是 ReentrantLock.


 

读写锁

每次最多只能有一个线程能持有 ReentrantLock, 但对于维护数据的完整性来说,互斥通常是一种过于强硬的加锁规则, 因此就不必要地限制了并发性.

互斥是一种保守的加锁策略,虽然可以避免“写/写” 冲突 和 “ 写/读 ” 冲突,但同样也避免了 “读/读” 冲突。

如果放宽加锁 需求, 允许多个执行 读操作的线程同时访问数据结构,那么将提升程序的性能。

只要每个线程都能确保读取到最新的数据,且在读取数据时不会有其他的线程修改数据,那么就不会发生问题.

读-写 锁 是一种性能优化措施,在一些特定的情况下能实现更高的并发性。

在多处理器系统上被频繁读取的数据结构, 读-写锁能够提高性能。

而在其他情况下,读-写 锁的性能要比独占锁的性能要略差一些。因为它们的复杂性更高。


 

读取锁   和  写入锁 之间的交互 采用的实现 方式:

释放优先: 当一个写入操作释放写入锁时,且队列中同时存在读线程 和 写线程,那么优先选择读线程,写线程,还是最先发出请求的线程。

读线程插队:如果锁是由读线程持有,但有写线程 正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那么将提高并发性,但却可能造成写线程发生饥饿问题.


重入性: 读取锁 和 写入锁是否是可重入的?

降级:如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?可能会使得写入锁被“降级”为读取锁,同时不允许其他写线程修改被保护的资源.

升级:读取锁能够优先于其他正在等待的读线程和写线程而升级为一个写入锁?如果没有显式的升级操作,很容易造成死锁(如果两个读线程试图同时升级为写入锁,

二者都不会释放读取锁)


ReentrantReadWriteLock 为这两种锁都提供了可重入的加锁语义。

该锁中的写入锁只能有唯一的所有者,并且只能由 获得该锁的线程来释放。

记录哪些线程已经获得了读者锁。


当锁的只有时间较长,且大部分操作都不会修改被守护的资源时,那么 读-写锁能提高并发性。


2021-02-25

类库中包含的许多存在状态依赖性的类, FutureTask , Semaphore 和 BlockingQueue 等.

在这些类的一些操作中有着基于状态 的前提条件.

创建状态依赖类的最简单方法通常是在类库中现有状态依赖类的基础上进行构造。

例如,使用了一个  CountDownLatch 来提供所需的阻塞行为.

使用 Java 和 类库提供的底层机制来构造自己的同步机制,包括内置的条件队列显式的 Condition 对象以及

AbstractQueuedSynchronizer 框架


依赖状态的操作可以一直阻塞直到可以继续执行,这比使它们先失败再实现提来要更为方便且更不易出错。

内置的条件队列可以使线程一直阻塞,直到对象进入某个进程可以继续执行的状态,且当被阻塞的线程可以执行时再唤醒它们.

直到对象进入某个进程可以继续执行的状态,且当被阻塞的线程可以执行时再唤醒它们。

轮询与休眠方式 , 低效,勉强地解决 状态依赖性问题..


 

p239

可阻塞的状态依赖操作的结构


在生产者 - 消费者的设计中经常会使用像 ArrayBlockingQueue 这样的有界缓存.

在有界缓存提供的 put 和 take 操作中都包含有一个前提条件 : 不能从空缓存中获取元素,也不能将元素放入已满的缓存中.


有界缓存实现的基类 p239

将前提条件的失败传递给调用者, p240.

如果将状态依赖性交给调用者管理,将导致一些功能无法实现,例如维持 FIFO 顺序,由于迫使调用者重试,因此失去了“谁先到达”的信息.

改进形式,但未解决根本问题:当缓存处于某种错误的状态时返回一个错误值。

Queue 并不适合在 生产者-消费者 设计中使用, Queue ,poll 方法能够在队列为空时返回null, 而 remove 方法则抛出一个异常,

但 Queue 并不适合在生产者 - 消费者 设计中使用.

BlockingQueue 中的操作只有当队列处于正确状态时才会进行处理,否则将阻塞,

因此当生产者 和 消费者 并发执行时, BlockingQueue 才是更好的选择.


轮询和休眠 

要选择合适的休眠时间间隔,需要在响应性与 CPU 使用率之间进行权衡,休眠的间隔越小,响应性就越高,但消耗的 CPU资源也越高。


条件队列 名字来源于: 使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。

传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在正待相关条件的线程。 


 

每个Java 对象都可以作为一个锁,每个对象同样可以作为一个条件队列 ,Object的  wait , notify, 和 notifyAll 方法就构成了

内部条件队列的API 。 

对象的内置锁与其内部条件队列是相互关联的,要调用对象X中条件队列的任何一个方法,必须持有对象X上的锁。

因为“等待由状态构成的条件” 与 “维护状态一致性” 这两种机制必须被紧密地绑定在一起:

只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。


 2021-02-26

尽量基于 LinkedBlockingQueue, Latch, Semaphore 和 FutureTask 等类来构造程序的原因之一。

如果能避免使用条件队列,实现起来容易很多。

使用条件等待时 Object.wait 或 Condition.await 时。


活跃性故障:例如 死锁 和 活锁。

另一种形式的活跃性故障是丢失的信号。丢失的信号是指:线程必须等待一个已经为 真的条件,但在开始等待之前没有

检查条件谓词。现在,线程将等待一个已经发过的事件。

条件等待的前一半内容: 等待,另一半内容:通知。

每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知.

在条件队列API 中有两个发出通知的方法,即 notify  和 notifyAll, 无论调用哪一个,都必须持有与条件队列对象相关联的锁.

在调用 notify (单个) notifyAll,  JVM 会从这个条件队列上等待的多个线程中选择唤醒, 

因为持有条件队列的锁,因此发出通知的线程应该尽快地释放锁,从而确保正在等待的线程尽可能快地解除阻塞.


"丢失信号“ 和  ”被劫持的“ 信号, 导致的问题是相同的:线程 正在等待一个已经 或者本应该 发生过的信号。


只有同时满足以下两个条件时,才能用单一的notify 而不是 notifyAll :

所有等待线程的类型都相同. 只有一个条件谓词与条件队列相关,且每个线程在从wait 返回后将执行相同的操作.

单进单出. 在条件变量上的每次通知,最多只能唤醒一个线程来执行.


单次通知 和 条件通知 都属于优化措施。

要将 条件队列封装起来,与线程安全类的最常见设计模式并不一致. 在这种模式中建议使用对象内置锁来保护对象自身的状态.



 通过”入口协议 和 出口协议 Entry and  Exit Protocols  来描述 wait 和 notify 方法的正确使用。

对于每个依赖状态的操作,以及每个修改其他操作依赖状态的操作,都应该定义一个入口协议和出口协议.

入口协议就是该操作的条件谓词,出口协议则包括,检查被该操作修改的所有状态变量,并确认它们是否包含使某个其他的条件谓词为真,

如果是, 则通知相关的条件队列.


 在 AbstractQueuedSynchronizer ( java.util.concurrent 包中大多数依赖状态的类都是基于这个类构建的) 中使用出口协议.

这个类并不是由同步器类执行自己的通知,而是要求同步器方法返回一个值来表示该类的操作是否已经解除了一个或多个等待线程的阻塞.

这种明确的API调用需要使得更难以“忘记”在某些状态转换发生时进行通知。


内置队列的缺陷 p251

一个Condtion 和 一个 Lock 关联在一起,一个条件队列和一个内置锁相关联一样。

可以在相关联的Lock上调用 Lock.newCondition方法。

Lock 锁比内置锁提供了更丰富的功能,

Condition 同样比内置条件队列提供了更丰富的功能:在每个锁上存在多个等待,条件等待可以使可中断或不可中断的,

基于时限的等待,以及公平的或非公平的队列操作。

Condition 中 与 wait, notify 和 notifyAll 对应的分别是 await, sinal, singleAll,


显式的 Condition 和 内置条件队列之间进行选择时,与在 RenntrantLock 和 Synchronized 之间的选择是一样的:

如果需要 公平的队列操作 或者 在每个锁上对应多个等待线程集,应该优先使用 Condition 而不是内置条件队列。

ReentrantLock 的高级功能。


 

ReentrantLock 和 Semaphore 这两个接口之间存在许多共同点。这两个类都可以用作一个“阀门”,

即每次只允许一定数量的线程通过,并当线程到达阀门时,(调用 lock 或 acquire 时成功返回),也可以等待

(调用 lock 或 acquire 时阻塞),还可以取消(调用 tryLock 或 tryAcquire 时返回“假”,表示在指定的时间内锁

是不可用的或者无法获得许可)。

两个接口都支持 可中断的, 不可中断的, 以及限时的获取操作,且都支持等待线程执行 公平 或非公平的 队列操作。


 

AQS 基类 AbstractQueueSynchronizer 

是用于构建锁和同步器的框架。

CountDownLatch, ReentrantReadWriteLock, SynchronousQueue 和 FutureTask

基于 AQS来构建同步器能带来许多好处。能极大地减少实现工作,且不必处理在多个位置上发生的竞争问题(这是在没有使用

AQS 来构建 同步器时的情况)。

在 SemaphoreOnLock 中,获取许可的操作可能在两个时刻阻塞--当锁保护 信号量状态时,以及当许可不可用时。

在基于 AQS 构建同步器中,只可能在一个时刻 发生阻塞,从而降低上下文切换的开销,并提高吞吐量。

JUC中所有基于 AQS构建的同步器都能获得这个优势。


p255 AQS 中获取操作和释放操作的标准形式


tryAcquire, tryRelease 和 isHeldExclusively, 对于支持共享获取的同步器,

则应该实现 tryAcquireShared 和 tryReleaseShared 等方法。

AQS 中的 acquire, acquireShared,  release 和 releaseShared 

 

AQS提供了一些机制构造与同步器相关联的条件变量, 支持条件队列的锁 ReentrantLock


JUC 中所有同步器类都没有直接扩展 AQS ,而是都将它们的相应功能委托给私有的 AQS 子类来实现.

避免破坏自身方法的简洁性,避免调用者误用导致  破坏闭锁的状态.


 

AQS  JUC 中的可阻塞类: ReentrantLock , Semaphore, ReentrantReadWriteLock , CountDownLatch, SynchronousQueue

和 FutureTask 等, 都是基于 AQS构建的.

ReentrantLock 只支持独占方法的获取操作,因此它实现了 tryAcquire, tryRelease 和 isHeldExclusively. 

ReentrantLock 将同步状态用于保存 锁获取操作的次数,且还维护一个 owner 变量来保存当前所有者线程的标识符,

只有在当前线程刚刚获取到锁,或者正要释放锁时,才会修改这个变量。

tryRelease 中检查这个 owner 域,从而确保当前线程在执行 unlock 操作之前已经获取了锁:

在 tryAcquire 中将使用这个域来区分获取操作是重入还是竞争的.


 

Future Task

Future.get 的语义非常类似于闭锁的语义 - 如果发生了某个事件 ( 由 FutureTask 表示的任务执行完成或被取消 )

那么线程就可以重复执行,否则这些线程将停留在队列中并直到该事件发生.

Future task中, AQS 同步状态被用来保存任务的状态,例如,正在运行,已完成或已取消。

Future Task 还维护一些额外的状态变量,用来保存计算结果 或者抛出异常.

此外,它还维护了一个引用,指向正在执行计算任务的线程(如果它当前处于运行状态),因而如何任务取消,该线程就会中断.


ReentrantReadWriteLock

在基于AQS 的实现中, 单个AQS 子类 将同时管理读取加锁和写入加锁.

使用了一个 16位的状态来表示写入锁的计数。且使用了另一个16位的状态来表示读取锁的计数。

在读取锁上的操作将使用共享的获取方法与释放方法,在写入锁上的操作将使用独占的方法与释放方法. 


2021-03-01

硬件对并发的支持

比较并交换 CAS

CAS 包含的3个操作数- 需要读写的内存位置 V, 进行比较的值 A, 拟写入的新值 B.

当且仅当 V的值等于A时,CAS才会通过原子方式 用新值B来更新V的值,否则不做任何更改。

不使用锁也能实现原子的 读 - 改 - 写 操作。

如果 CAS 失败,反复重试是一种合理的策略,但在一些竞争很激烈的情况下,

更好的方式是 在重试前 首先等待一段时间或者回退,从而避免造成活锁的问题。

CAS 的优势在于性能好,

劣势在于:使调用者处理竞争问题(通过重试,回退,放弃),而在锁中能自动处理竞争问题(线程在获得锁之前将一直阻塞 ).

大多数 处理器上,在无竞争的锁获取和释放“快速代码 路径”上的开销,大约是CAS  开销的两倍。


原子变量类

使用了底层的JVM 支持为数字类型和引用类型提供了一种高效的 CAS 操作,而在 util.concurrent 中的大多数类在实现

时则直接或间接地使用了这些原子变量类.

原子变量比锁的粒度更细,量级更轻,对于在多处理器系统上实现高性能的并发代码来说是非常关键的。

原子变量将发生竞争的范围缩小到单个变量上,这是你获得的粒度最细的情况。

因为不需要挂起或重新调度线程。

在使用基于原子变量而非锁的算法中,线程在执行时更不易 出现延迟,如果遇到竞争,也很容易恢复过来.


原子变量类 相当于一种泛化的 volatile 变量,能够支持原子的和有条件的 读-改-写 操作.

共有12个原子变量类,可分为4组:标量类 Scalar , 更新器类, 数组类,以及复合变量类.

最常用的原子变量就是标量类。

原子数组类中的元素可以实现原子更新。为数组的元素提供了volatile类型的访问语义,这是普通数组 所不具备的特性。

原子变量类没有重新定义 hashCode  或 equal 方法,每个实例都是不同的,与其他可变对象相同,也不宜用作基于散列的容器的键值。

使用 CAS 来维持包含多个变量的不变性条件。  p267


 

posted @ 2021-01-16 11:52  君子之行  阅读(231)  评论(0)    收藏  举报