java并发编程(十二)自定义同步工具

条件队列

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

如每个Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,这就是java内部的条件队列
Object中的wait、notify和notifyAll方法就构成了内部条件队列的API。
Object.wait会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并且修改对象的状态。

条件谓词
条件谓词是使某个操作成为状态依赖的前提条件。在有界缓存中,只有当缓存不为空时,take方法才能执行,否则必须等待。对take方法来说,它的条件谓词就是“缓存不为空”,take方法在执行之前必须首先测试该条件谓词。

过早唤醒
wait方法的返回并不一定意味着线程正在等待的条件谓词已经变成真了。

内置条件队列可以与多个条件谓词一起使用。当一个线程由于条件通过调用notifyAll而醒来时,其他还没有满足条件谓词的线程也醒来了。这就造成了过早唤醒。
一个条件队列可以与多个条件谓词一起使用是非常常见的。

丢失的信号
丢失信号就是线程将等待一个已经发过的事件。
如果线程A通知了一个条件队列,而线程B随后在这个条件队列上等待,那么线程B将不会立即醒来,而是需要另一个通知来唤醒它。
也就是说在一个线程在挂起前,另一个线程修改了条件的值使得这个方法能够正常执行而不阻塞,但是此时这个线程刚刚挂起,如果后续不会再通知的话,这个线程也就永远不会醒来了。
要避免这种情况必须通过一个锁来保护条件谓词并且在每次wait之前都检测条件谓词是否通过

通知
使用条件队列一共分为两部分,一部分是等待也就是上文说的内容,另一部分就是通知。
在条件队列API中有连个发出通知的方法,即notifynotifyAll。无论调用哪个,都必须持有与条件队列对象相关联的锁。
在调用notify时,JVM会从这个条件队列上等待的多个线程中选择一个来唤醒。
调用notifyAll则会唤醒所有在这个条件队列上等待的线程。
由于在调用notifyAll或notify时必须持有条件队列对象的锁,而如果这些等待中线程此时不能重新获得锁,那么无法从wait返回,因此发出通知的线程应该尽快地释放锁,从而确保正在等待的线程尽可能快地解除阻塞。

由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用notify而不是notifyAll,那么将是一种危险的行为,因为单一的通知很容易导致类似于信号丢失的问题。

假设线程A在条件队列上等待条件谓词PA,同时线程B在同一个条件队列上等待条件谓词PB。现在,假设PB变成真,并且线程C执行一个notify:JVM将从它拥有的众多线程中选择一个并唤醒。如果选择了线程A,那么它被唤醒,并且看到PA尚未编程真。因此将继续等待。同时,线程B本可以开始执行,却没有被唤醒。这并不是严格意义上的“丢失信号”,更像一种”被劫持的”信号。

此普遍认为的做法是优先使用notifyAll而不是notify。虽然notifyAll可能比notify更低效,但却更容易确保类的行为是正确的

显式的Condition对象

正如Lock是一种广义的内置锁,Condition也是一种广义的内置条件队列。

内置的条件队列存在一些缺陷。每个内置锁都只能有一个相关联的条件队列,有时候多个线程可能在同一条件队列上等待不同的条件谓词,并且在最常见的加锁模式下公开条件队列对象。这些因素都使得无法满足在调用notifyAll时所有等待线程为同一类型的需求。这种情况下会经常导致线程被过早唤醒

与内置条件队列不同的是,对于每个Lock,可以有任意数量的Condition对象,可以将不用的条件谓词放入不同的Condition对象内,这样通知的时候就可以有针对性的通知各种的队列,避免过早唤醒。

特别注意:在Condition对象中,与wait、notify和notifyAll方法对应的分别是await、signal和signalAll。但是,Condition对Object进行了扩展,因而它也包含了wait和notify方法。一定要确保使用正确的版本——await和signal。

AbstractQueuedSynchronizer(AQS)

AQS是一个用于构建锁和同步器的框架,许多同步器都可以通过AQS很容易并且高效地构建出来。不仅ReentrantLock和Semaphore是基于AQS构建的,还包括CountDownLatch、ReentrantReadWriteLock、SynchronousQueue和FutureTask。

在基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。获取操作是一种依赖状态的操作,并且通常会阻塞。当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或者许可,并且调用者可能会一直等待直到同步器类处于可被获取的状态。在使用CountDownLatch时,“获取”操作意味着“等待并直到闭锁到达结束状态”,而在使用FutureTask时,则意味着“等待并直到任务已经完成”。“释放”并不是一个可阻塞的操作时,当执行“释放”操作时,所有在请求时被阻塞的线程都会开始执行。

AQS负责管理同步器类中的状态,它管理了一个整数状态信息,可以通过getState,setState以及compareAndSetState等protected类型方法来进行操作。这个整数可以用于表示任意状态。例如,ReentrantLock用它来表示所有线程已经重复获取该锁的次数,Semaphore用它来表示剩余的许可数量,FutureTask用它来表示任务的状态(尚未开始、正在运行、已完成以及已取消)。

下面是AQS中的获取操作与释放操作的形式。

boolean acquire() throws InterruptedException
{
    while (当前状态不允许获取操作)
    {
        if (需要阻塞获取请求)
        {
            如果当前线程不在队列中,则将其插入队列
            阻塞当前线程
        }
        else
            返回失败
    }
    可能更新同步器的状态
    如果线程位于队列中,则将其移出队列
    返回成功
}

void release()
{
    更新同步器的状态
    if (新的状态允许某个被阻塞的线程获取成功)
        解除队列中一个或多个线程的阻塞状态
}

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

总结

有些时候类库的同步器不能满足我们的需求时,我们可以通过内置的条件队列,显式的condituon对象或者AbstractQueuedSynchronizer来自定义同步器。

因为java.util.concurrent中的许多可阻塞类,例如ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch、SynchronousQueue和FutureTask等,都是基于AQS构建的。我们也可以根据AQS重写这些同步器的一些方法来扩展他们的功能。

内置条件队列与内置锁是紧密绑定在一起的,这是因为管理状态依赖性的机制必须与确保状态一致性的机制关联起来。同样,显式的Condition与显式地Lock也是紧密地绑定在一起的

发布了56 篇原创文章 · 获赞 4 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/xs925048899/article/details/104698822