深入了解Java并发——《Java Concurrency in Practice》14.构建自定义的同步工具

时间:2021-10-26 17:34:22

虽然章节的目的是介绍如何基于AQS等基类来构建自定义的同步工具,但详细的介绍了AQS的原理,并且详细的讲解了java.util.concurrent类库中许多基于AQS的常用同步工具对AQS的实现及原理。了解AQS之后对ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWritLock、FutureTask都会有一个全新的认识和豁然开朗的感觉。

14.1 状态依赖性的管理

对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的条件下不会失败,但通常有一种更好的选择:等待前提条件变为真。

依赖状态的操作可以一直阻塞到可以继续执行,这比使它们先失败再实现起来要更为方便且不更不易出错。内置的条件队列可以使线程一直阻塞,知道对象进入某个进程可以继续执行的状态,并且当被阻塞的线程可以执行时再唤醒它们。

14.1.3 条件队列

条件队列使得一组线程(等待线程集合)能够通过某种方式来等待特定的条件变成真。条件队列中的元素时一个个正在等待相关条件的线程。

每个对象都可以作为一个条件队列,并且Object中的wait、notify和notifyAll方法构成了内部条件队列的API。对象的内置锁与其内部条件队列是相互关联的,要调用对象X中条件队列的任何一个方法,必须持有对象X上的锁。因为“等待由状态构成的条件”与“维护状态一致性”这两种机制必须被紧密的绑定在一起:只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程

Object.wat会自动释放锁,并请求操作系统挂起当前线程。

14.2 使用条件队列

条件队列很容易被错误使用,编译器或系统平台并没有对其正确使用进行约束。

14.2.1 条件谓词

正确使用条件队列的关键是找出对象在哪个条件谓词上等待。条件谓词是使某个操作成为状态依赖操作的前提条件

将与条件队列相关联的条件谓词以及在这些条件谓词上等待的操作都写入文档

条件等待中存在一种重要的三元关系,包括加锁、wait方法和一个条件谓词。在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前必须现持有这个锁。锁对象与条件队列对象(即调用wait和notify等方法所在的对象)必须是同一个对象。

每一次wait调用都会隐式的域特定的条件谓词关联起来。当调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量

14.2.2 过早唤醒

内置条件队列可以与多个条件谓词一起使用。当一个线程由于调用notifyAll而醒来时,并不意味该线程正在等待的条件谓词已经变成真了。

每当线程从wait中唤醒时,都必须再次测试条件谓词,如果条件谓词不为真,那么就继续等待(或者失败)。由于线程在条件谓词不为真的情况下也可以反复的醒来,因此必须在一个循环中调用wait,并在每次迭代中都测试条件谓词。

void stateDependentMethod() throws InterruptedException {
    synchronized(lock) {
        while(!conditionPredicate()) {
            lock.wait();
        }
    }
}

当使用条件等待时 (如Object.wait或Condition.await)
- 通常都有一个条件谓词——包括一些对象状态的测试,线程在执行前必须首先通过这些测试
- 在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试
- 在一个循环中调用wait
- 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量
- 当调用wait、notify或notifyAll等方法时,一定要持有与条件队列相关的锁
- 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁

14.2.3 丢失的信号

在调用wait之前检查条件谓词,就不会发生信号丢失的问题。

14.2.4 通知

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

条件队列API中有notify和notifyAll两个发出通知的方法,都必须持有与条件队列对象相关联的锁。

调用notify时,JVM会从这个条件队列上等待的多个线程中选择一个来唤醒,而调用notifyAll会唤醒所有在这个条件队列上等待的线程。由于在调用notify或notifyAll时必须持有条件队列对象的锁,而如果这些等待中线程此时不能重新获得锁,那么无法从wait返回,因此发出通知的线程应该尽快的释放锁,从而确保正在等待的线程尽可能快的接触阻塞

大多数情况下应该优先选择notifyAll而不是notify。只有同时满足以下两个条件时,才能使用notify,而不是notifyAll:
1. 所有等待线程的类型都相同。 只有一个条件谓词与条件队列相关,并且每个线程在从wait返回后将执行相同的操作
2. 单进单出 在条件变量上的每次通知,最多只能唤醒一个线程来执行。

14.2.6 子类的安全问题

要想支持子类化,那么在设计类时需要保证:如果在实施子类化时违背了条件通知或单词通知的某个需求,那么在子类中可以增加合适的通知机制来代表基类。

对于状态依赖的类,要么将其等待和通知等协议完全向子类公开并且写入正式文档,要么完全阻止子类参与到等待和通知等过程中。

14.2.7 封装条件队列

通常应该把条件队列封装起来,保证除了使用条件队列的类,就不能在其他地方访问它,避免调用者自以为理解了在等待和通知上使用的协议,从而采用一种违背设计的方式来使用条件队列。

但这与线程安全类最常见的设计模式并不一致,这种模式中建议使用对象的内置锁来保护对象自身的状态。

14.2.8 入口协议与出口协议

对于每个依赖状态的操作,以及每个修改其他操作依赖状态的操作,都应该定义一个入口协议和出口协议。入口协议就是该操作的条件谓词,出口协议则包括,检查该操作修改的所有状态变量,并确认它们是否使某个其他的条件谓词变为真,如果是,则通知相关的条件队列。

在AbstractQueuedSynchronizer中使用出口协议。这个类并不是由同步器类执行自己的通知,而是要求同步器方法返回一个值来表示该类的操作是否已经解除了一个或多个等待线程的阻塞。这种明确的API调用需求使得更难以“忘记”在某些状态转换发生时进行通知。

14.3 显式的Condition对象

Condition是一种广义的内置条件队列。

内置条件队列存在一些缺陷,每个内置锁都只能有一个相关联的条件队列,所以多个线程可能在同一个条件队列上等待不同的条件谓词,并且在最常见的加锁模式下公开条件队列对象。如果想编写一个带有多个条件谓词的并发对象,或者想获得除了条件队列可见性之外的更多控制权,就可以使用显式的Lock和Condition而不是内置锁和条件队列,这是一种更灵活的选择。

一个Condition和一个Lock关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个Condition,可以在相关联的Lock上调用Lock.newCondition方法。

Condition比内置条件队列提供了更丰富的功能:在每个锁上可存在多个等待、条件等待可以使可中断或不可中断的、基于时限的等待、公平的或非公平的队列操作。

对于每个Lock可以有任意数量的Condition对象。Condition对象继承了相关的Lock对象的公平性,对于公平的锁,线程会依照FIFO顺序从Condition.await中释放。

在Condition对象中,与wait、notify、notifyAll对应的是await、signal和signalAll,但Condition作为Object对象同样具有wait和notify方法,注意要正确的使用await和signal。

使用显式的Lock和Condition时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须由Lock来保护,并且在检查条件谓词以及调用wait和signal时,必须持有Lock对象。

如果需要使用一些高级功能,优先选择Condition而不是内置条件队列。

14.4 Synchronizer解析

AQS AbstractQueuedSynchronizer,是许多同步类的基类。是一个用于构建锁和同步器的框架。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWritLock、SynchronousQueue、FutureTask都是它的子类。

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

14.5 AbstractQueuedSynchronizer

在基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。获取操作是一种依赖状态的操作,并且通常会阻塞。释放 并不是一个可阻塞的操作,当执行释放操作时,所有在请求时被阻塞的线程都会开始执行。

如果一个类想成为状态依赖的类,那么它必须拥有一个状态。AQS负责管理同步器类中的状态,它管理了一个整数状态信息,可以通过getState,setState以及compareAndSetState等protected类型方法来进行操作。这个整数可以用于表示任意状态。

根据同步器的不同,获取操作可以使一种独占操作(如ReentrantLock),也可以是一个非独占操作(如Semaphore和CountDownLatch)。一个获取操作包括两部分。首先,同步器判断当前状态是否允许获得操作,如果是则允许线程执行,否则获取操作将阻塞或失败。其次,就是更新同步器的状态,获取同步器的某个县城可能会对其他线程能否也获取该同步器造成影响。

如果某个同步器支持独占的获取操作,那么需要实现一些保护方法,包括tryAcquire、tryRelease和isHeldExclusively等。对于支持共享获取的同步器,应该实现tryAcquireShared、tryReleaseShared等。AQS中的acquire、acuireShared、release和releaseShared等方法都将调用这些方法在子类中带有前缀的try的版本来判断某个操作是否能执行。在同步器的子类中,可以根据其获取操作和释放操作的语义,使用getState、setState以及compareAndSetState来检查和更新状态,并通过返回的状态值来告知基类获取或释放同步器的操作是否成功。

为了使支持条件队列的锁(如ReentrantLock)实现起来更简单,AQS还提供了一些机制来构造与同步器相关联的条件变量。

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

14.6 java.util.concurrent同步器类中的AQS。

14.6.1 ReentrantLock

ReentrantLock只支持独占方式的获取操作,因此它实现了tryAcquire、tryRelease和isHeldExclusively。ReentrantLock将同步状态用于保存锁获取操作的次数,并且还维护一个owner变量来保存当前所有者线程的标识符,只有在当前线程刚刚获取到锁,或者正要释放锁的时候,才会修改这个变量。在tryRelease中检查owner域,从而确保当前线程在执行unlock操作之前已经获取了锁:在tryAcquire中将使用这个域来区分获取操作是重入的还是竞争的。

14.6.2Semaphore与CountDownLatch

Semaphore将AQS的同步状态用于保存当前可用许可的数量。tryAcquireShared方法首先计算剩余许可的双良,如果没有足够的许可,会返回一个值表示获取操作失败。如果还有剩余的许可,那么tryAcquireShared会通过compareAndSetState以原子方式来降低许可的计数。如果这个操作成功,那么将返回一个值表示获取操作成功。在返回值中还包含了表示其他共享获取操作能否成功的信息,如果成功,那么其他等待的线程同样会接触阻塞。

当没有足够的许可,或者当tryAcquireShared可以通过原子方式来更新许可的计数以响应获取操作时,while循环将终止。虽然对compareAndSetState的调用可能由于与另一个线程发生竞争而失败,并使其重新尝试,但在经过了一定次数的充实操作以后,在这两个结束条件中有一个会变为真。同样,tryReleaseShared将增加许可计数,这可能会解除等待中线程的阻塞状态,并且不断地重试知道更新操作成功。tryReleaseShared的返回值表示在这次释放操作中解除了其他线程的阻塞。

CountDownLatch使用AQS的方式与Semaphore很相似:在同步状态中保存的是当前的技术值。countDown方法调用release,从而导致计数值递减,并且当计数值为零时,解除所有等待线程的阻塞。await调用acquire,当计数器为零时,acquire将立即返回,否则将阻塞。

14.6.3 FutureTask

在FutureTask中,AQS同步状态被用来保存任务的状态。FutureTask还维护一些额外的状态变量,用来保存计算结果或者爆出的异常。此外,它还维护了一个引用,指向正在执行计算任务的进程,因而如果任务取消,该线程就会中断。

ReentrantReadWritLock

ReadWritLock接口表示存在两个锁:读锁和写锁,但在基于AQS实现的ReentrantReadWritLock中,单个AQS子类同时管理读锁和写锁。ReentrantReadWritLock使用了一个16位的状态来表示写入锁的技术,并且使用了另一个16位的状态来表示读取锁的计数。在读取所上的操作将使用共享的获取方法与释放方法,在写如梭上的操作将使用独占的获取方法与释放方法。

AQS在内部维护一个等待线程队列,其中记录了某个线程请求的是独占访问还是共享访问。在ReentrantReadWritLock中,当锁可用时,如果位于队列头部的线程执行写入操作,那么线程会得到这个锁,如果位域队列同步的线程执行读取方位,那么队列中在第一个写入线程之前的所有线程都将获得这个锁。