@@@ 类库包含了许多存在状态依赖性的类,例如 FutureTask 、Semaphore 和 BlockingQueue
等。在这些类的一些操作中有着基于状态的前提条件。
@@@ 创建状态依赖类的最简单方法通常是在类库中现有状态类的基础上进行构造。但如果类库中
没有你需要的功能,那么还可以使用 Java 语言和类库提供的底层机制来构造自己的同步机制,包括
内置的条件队列 、 显式的 Condition 对象以及 AbstractQueuedSynchronized 框架。
》》状态依赖性的管理
@@@ 在生产者---消费者的设计中经常会使用像 ArrayBlockingQueue 这样的有界缓存。在有界
缓存提供的 put 和 take 操作中都包含有一个前提条件:不能从空缓存中获取元素,也不能将元素放入
已满的缓存中。当前提条件未满足时,状态依赖的操作可以抛出一个异常或返回一个错误状态(使其
成为调用者的一个问题),也可以保持阻塞直到对象进入正确的状态。
### 示例:将前提条件的失败传递给调用者
@@@ 调用者可以不进入休眠状态,而直接重新调用 take 方法,这种方法被称为忙等待或者自旋
等待。
@@@ 客户代码需要进行选择:要么容忍自旋导致的 CPU 时钟周期浪费,要么容忍由于休眠而导致
的低响应性。(除了忙等待与休眠之外,还有另外一种选择就是调用 Thread.yield ,这相当于该调度器
一个提示:现在需要让出一定的时间使另一个线程运行。假如正在等待另一个线程执行工作,那么如果
选择让出处理器而不是消耗完整个 CPU 调度时间片,那么可以使整体的执行过程变快)。
### 示例:通过轮询与休眠来实现简单的阻塞
@@@ 要选择合适的休眠时间间隔,就需要在响应性与 CPU 使用率之间进行权衡。休眠的间隔越小,
响应性就越高,但消耗的 CPU 资源也就越高。
休眠间隔对响应性的影响:在缓存中出现可用空间的时刻与线程醒来并再次检查的时刻之间可能
存在延迟。
@@@ 如果存在某种挂起线程的方法,并且这种方法能够确保当某个条件成真时线程立即醒来,那么将
极大地简化实现工作。这正是条件队列的实现的功能。
### 条件队列(存在某种挂起线程的方法)( 等待 + 通知 )
@@@ “ 条件队列 ” 这个名字来源于:它使得一组线程(称之为等待线程集合)能够通过某种方式来等待
特定的条件变为真。
条件队列中的元素是一个个正在等待相关条件的线程。
@@@ “ 等待由状态构成的条件 ” 与 “ 维护状态一致性 ” 这两种机制必须被紧密地绑定在一起:只有能对
状态进行检查,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。
@@@ Object.wait 会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并
修改对象的状态。当被挂起的线程醒来时,它将在返回之前重新获取锁。
@@@ 每个 Java 对象都可以作为一个锁,每个对象可以作为一个条件队列,并且 Object 中的 wait 、
notify 和 notifyall 方法构成了内部条件队列的 API 。
@@@ 使用 wait 和 notifyAll 来实现一个有界缓存,比使用 “ 休眠 ” 的有界缓存更简单,并且更高效,
响应性也更高。
@@@ 与使用 “ 休眠 ” 的有界缓存相比,条件队列并没有改变原来的语义。它只是在多个方面进行了
优化:CPU 效率 、 上下文切换开销和响应性等。
@@@ 如果某个功能无法通过 “ 轮询和休眠 ” 来实现,那么使用条件队列也无法实现,但条件队列使得
在表达和管理状态依赖性时更加简单和高效。
》》使用条件队列
@@@ 条件队列使构建高效以及高可响应性的状态依赖类变得更容易,但同时也很容易被不正确地使用。
虽然许多规则都能确保正确地使用条件队列,但在编译器或者系统平台上却并没有强制要求遵循这些规则。
(这也是为什么要尽量基于 LinkedBlockingQueue 、 Latch 、 Semaphore 、FutureTask 等类来构造
程序的原因之一,如果能避免使用条件队列,那么实现起来将容易许多)。
### 条件谓词
@@@ 要想正确地使用条件队列,关键是找出对象在哪个条件谓词上等待。
@@@ 事实上,在 Java 语言规范或 Javadoc 以及 JVM 实现中都没有直接提到 “ 条件谓词 ” 。但如果没有
条件谓词,条件等待机制将无法发挥作用。
@@@ 条件谓词是使某个操作成为状态依赖操作的前提条件。
@@@ 将与条件队列相关联的条件谓词以及在这些条件谓词上等待的操作都写入文档。
@@@ 在条件队列中存在一种重要的三元关系,包括加锁 、 wait 方法和一个条件谓词。在条件谓词中包含
多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前必须先持有这个锁。锁对象与条件
队列对象(即调用 wait 和 notify 等方法所在的对象)必须是同一个对象。
@@@ 当线程从 wait 方法中被唤醒时,它在重新请求锁时不具有任何特殊的优先级,而要与任何其他尝试
进入同步代码快的线程一起正常地在锁上进行竞争。
@@@ 每一次 wait 调用都会隐式地与特定的条件谓词关联起来。当调用某个特定条件谓词的 wait 时 ,
调用者必须已经有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。
### 过早唤醒
@@@ 每当线程从 wait 中唤醒时,都必须再次测试条件谓词,如果条件谓词不为真,那么就继续等待
(或者失败)。由于线程在条件谓词不为真的情况下也可以反复地醒来,因此必须在一个循环中调用 wait ,
并在每次迭代中都测试条件谓词。
@@@ 当使用条件等待时(例如 Object.wait 或 Condition.await )
--------- 通常都有一个条件谓词--------包括一些对象状态的测试,线程在执行前必须首先通过这些测试
--------- 在调用 wait 之前测试条件谓词,并且从 wait 中返回时再次进行测试
--------- 在一个循环中调用 wait
--------- 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
--------- 当调用 wait 、 notify 或者 notifyAll 等方法时,一定要持有与条件队列相关的锁。
--------- 在检查条件谓词之后开始执行相应的操作之前,不要释放锁。
### 丢失的信号
@@@ 活跃性故障:死锁 、 活锁 、 丢失的信号 。
@@@ 丢失的信号是指:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。
@@@ 没有在调用 wait 之前检查条件谓词就会导致信号的丢失。
### 通知
@@@ 每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。
@@@ 在条件队列 API 中有两个发出通知的方法,即 notify 和 notifyAll 。无论调用哪一个,都
必须持有与条件队列对象相关联的锁。
@@@ 由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用 notify 而不是
notifyAll ,那么将是一种危险的操作,因为单一的通知很容易导致类似于信号丢失的问题。
@@@ 在大多数情况下应该优先选择 notifyAll 而不是单个的 notify 。虽然 notifyAll 可能比
notify 更低效,但却更容易确保类的行为是正确的。
@@@ 只有同时满足以下两个条件时,才能用单一的 notify 而不是 notifyAll :
------- 所有等待线程的类型都相同。只有一个条件谓词与条件队列相关,并且每个线程在从 wait 返回后
将执行相同的操作。
------- 单进单出。在条件变量上的每次通知,最多只能唤醒一个线程来执行。
@@@ 单次通知和条件通知都属于优化措施。通常,在使用这些优化措施时,应该遵循 “首选使程序
正确地运行,然后才使其运行得更快” 这个原则。如果不正确地使用这些优化措施,那么很容易在程序中
引入奇怪的活跃性故障。
### 示例:阀门类
@@@ 维护状态依赖的类时是非常困难的-------当增加一个新的状态依赖操作时,可能需要对多条
修改对象的代码路径进行改动,才能正确地执行通知。
### 子类的安全问题
@@@ 在使用条件通知和单次通知时,一些约束条件使得子类化过程变得更加复杂。要想支持子类化,
那么在设计类时需要保证:如果在实施子类化时违背了条件通知和单次通知的某个需求,那么在子类
中可以增加合适的通知机制来代表基类。
@@@ 对于状态依赖的类,要么将其等待和通知等协议完全向子类公开(并且写入正式文档),要么
完全阻止子类参与到等待和通知等过程中。(这是对 “ 要么围绕着继承来设计和文档化,要么禁止使用
继承 ” 这一条规则的一种扩展)。
@@@ 当设计一个可被继承的状态依赖类时,至少需要公开条件队列和锁,并且将条件谓词和同步策略
都写入文档。此外,还可能需要公开一些底层的状态变量。
@@@ 可以选择完全禁止子类化,例如将类声明为 final 类型 ,或者将条件队列 、 锁和状态变量等
隐藏起来,使子类看不见它们。否则,如果子类破坏了在基类中使用 notify 的方式,那么基类需要修复
这种破坏。
### 封装条件队列
@@@ 通常我们应该把条件队列封装起来,因而除了使用条件队列的类,就不能在其他地方访问它。
否则,调用者会自以为理解了等待和通知上使用的协议,并且采用一种违背设计的方式来使用条件队列。
@@@ 将条件队列封装起来,这种模式中建议使用对象的内置锁来保护对象自身的状态。(与线程
安全类的最常见设计模式并不一致)。例如:缓存对象自身既是锁,又是条件队列。
### 入口协议和出口协议
@@@ 对于每个状态依赖的操作,以及每个修改其他操作依赖状态的操作,都应该定义一个入口协议
和出口协议。
入口协议就是该操作的条件谓词;
出口协议则包括,检查被该操作修改的所有状态变量,并确认它们是否使某个其他的条件谓词
变为真,如果是,则通知相关的条件队列。
@@@ 在 AbstractQueuedSynchronizer (java.util.concurrent 包中大多数依赖状态的类都是基于这个
类构建的)中使用出口协议。这个类并不是由同步器类执行自己的通知,而是要求同步器方法返回一个
值来表示该类的操作是否已经解除了一个或多个等待线程的阻塞。这种明确的 API 调用需求使得更难以
“ 忘记 ” 在某些状态转换发生时进行通知。
》》显式的 Condition 对象
@@@ 在某些情况下,当内置锁过于灵活时,可以使用显式锁。
@@@ Lock 是一种广义的内置锁,Condition 也是一种广义的内置条件队列。
@@@ 内置条件队列存在一些缺陷。每个内置锁都只能有一个相关联的条件队列。
@@@ 如果想编写一个带有多个条件谓词的并发对象,或者想获得除了条件队列可见性之外的更多
控制权,就可以使用显式的 Lock 和 Condition 而不是内置锁和条件队列,这是一种更灵活的选择。
@@@ 一个 Condition 和一个 Lock 关联在一起,就像一个条件队列和一个内置锁相关联一样。要
创建一个 Condition , 可以在相关联的 Lock 上调用 Lock.newCondition 方法。
在每个 Lock 锁上可存在多个等待 、 条件等待可以是可中断的或不可中断的 、 基于时限的
等待,以及公平的或非公平的队列操作。
对于每个 Lock ,可以有任意数量的 Condition 对象。Condition 对象继承了相关的 Lock
对象的公平性,对于公平的锁,线程会依照 FIFO 顺序从 Condition.await 中释放。
@@@ 特别注意:在 Condition 对象中,与 wait 、 notify 和 notifyAll 方法对应的分别是 await 、
signal 和 signalAll 。但是,Condition 对 Object 进行了扩展,因而它也包含 wait 和 notify 方法。
一定要确保使用正确的版本 ---------- await 和 signal 。
@@@ 在分析使用多个 Condition 的类时,比分析一个单一内部队列加多个条件谓词的类简单得多。
通过将两个条件谓词分开并放到两个等待线程集中,Condition 使其更容易满足单次通知的需求。
signal 比 signalAll 更高效,它能极大地减少每次缓存操作中发生的上下文切换与锁请求的次数。
@@@ 当使用显式的 Lock 和 Condition 时,也必须满足锁 、 条件谓词和条件变量之间的三元关系。
在条件谓词中包含的变量必须由 Lock 来保护,并且在检查条件谓词以及调用 await 和 signal 时,必须
持有 Lock 对象。
@@@ 如果需要一些高级功能,例如使用公平的队列操作或者在每个锁上对应多个等待线程集,那么
应该优先使用 Condition 而不是内置条件队列。
》》Synchronizer 剖析
@@@ 在 ReentrantLock 和 Semaphore 这两个接口之间存在许多共同点。
-------- 这两个接口都可以用做一个 “ 阀门 ” ,即每次只允许一定数量的线程通过,并当线程到达阀门时,
可以通过(在调用 lock 或 acquire 时成功返回),也可以等待(在调用 lock 或 acquire 时阻塞),
还可以取消(在调用 tryLock 或 tryAcquire 时返回 “ 假 ” ,表示在指定的时间内锁是不可用的或者
无法获取许可)。
--------- 这两个接口都支持可中断的 、 不可中断的以及限时的获取操作,并且也都支持等待线程执行公平
或非公平的队列操作。
@@@ 一个很常见的练习就是,证明可以通过锁来实现计数信号量,以及可以通过计数信号量来实现锁。
@@@ AbstractQueuedSynchronizer (AQS) 这个类是其他许多同步类的基类。AQS 是一个用于构建
锁和同步器的框架,许多同步器都可以通过 AQS 很容易并且高效地构造出来。
@@@ 基于 AQS 构建的,包括 ReentrantLock 、 Semaphore 、CountDownLatch 、
ReentrantReadWriteLock 、 SynchronousQueue 和 FutureTask 。
@@@ AQS 解决了在实现同步器时涉及的大量细节问题,例如等待线程采用 FIFO 队列操作顺序。在
不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是需要等待。
@@@ 基于 AQS 来构建同步器能带来许多好处。
---------- 不仅能极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题。
---------- 在基于 AQS 构建的同步器中,只可能在一个时刻发生阻塞,从而降低上下文切换的开销,并
提高吞吐量。
---------- 在设计 AQS 时充分考虑了可伸缩性,因此 java.util.concurrent 中所有基于 AQS 构建的同步器
都能获得这个优势。
》》AbstractQueuedSynchronizer
@@@ 大多数开发人员都不会直接使用 AQS ,标准同步器类的集合能够满足绝大多数情况的需求。但
如果能了解标准同步器类的实现方式,那么对于理解它们的工作原理是非常有帮助的。
@@@ 在基于 AQS 构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。
--------- 获取操作是一种依赖状态的操作,并且通常会阻塞。
### 当使用锁或信号量时,“ 获取 ” 操作的含义很直观,即获取的是锁或者许可,并且调用者
可能会一直等待直到同步器类处于可被获取的状态。
### 在使用 CountDownLatch 时,“ 获取 ” 操作意味着 “ 等待并直到闭锁到达结束状态 ”
### 在使用 FutureTask 时,则意味着 “ 等待并直到任务已经完成 ”
--------- 释放操作并不是一个可阻塞的操作。
### 当执行 “ 释放 ” 操作时,所有在请求时被阻塞的线程都会开始执行。
@@@ 如果一个类想成为状态依赖的类,那么它必须拥有一些状态。 AQS 负责管理同步器类中的
状态,它管理了一个整数状态信息,可以通过 getState , setState 以及 compareAndSetState 等
protected 类型方法来进行操作。这个整数可以用于表示任意状态。
@@@ 根据同步器的不同,获取操作可以是独占操作(例如 ReentrantLock ),也可以是一种非独占
操作(例如 Semaphore 和 CountDownLatch ) 。
------------ 如果某个同步器支持独占的获取操作,那么需要实现一些保护方法,包括 tryAcquire 、
tryRelease 、 isHeldExclusively 等。
------------ 对于支持共享获取的同步器,则应该实现 tryAcquireShared 和 tryReleaseShared 等方法。
补充:AQS 中的 acquire 、 acquireShared 、 release 、 releaseShared 等方法都将调用这些方法在
子类中带有前缀 try 的版本来判断某个操作是否能执行。
》》java.util.concurrent 同步容器类中的 AQS
### ReentrantLock
### Semaphore 与 CountDownLatch
### FutureTask
### ReentrantReadWriteLock
》》小结
@@@ 要实现一个依赖状态的类-------如果没有满足依赖状态的前提条件,那么这个类的方法必须阻塞,
那么最好的方式是基于现有的库类来构建,例如 Semaphore.BlockingQueue 或 CountDownLatch 。
@@@ 有时候现有的库类不能提供足够的功能,在这种情况下,可以使用内置的条件队列 、 显式的
Condition 对象或者 AbstractQueuedSynchronized 来构建自己的同步器。
@@@ 内置条件队列与内置锁是紧密绑定在一起的,这是因为管理状态依赖性的机制必须与确保
状态一致性的机制关联起来。
@@@ 显式的 Condition 与显式的 Lock 也是紧密地绑定到一起的。