[Java Concurrency in Practice]第十四章 构建自定义的同步工具

构建自定义的同步工具

类库中包含了许多存在状态依赖性的类,例如FutureTask、Semaphore和BlockingQueue等。在这些类的一些操作中有着基于状态的前提条件。例如,不能从一个空的队列中上删除元素,或者获取一个尚未结束的任务的计算结果,在这些操作可以执行之前,必须等到队列进入“非空”状态,或者任务进入“已完成”状态。

创建状态依赖类的最简单方法通常是在类库中现有状态依赖类的基础上进行构造。但如果类库中没有提供你需要的功能,那么还可以使用Java语言和类库提供的底层机制来构造自己的同步机制,包括内置的条件队列、显式的Condition对象以及AbstractQueuedSynchronizer框架。

14.1 状态依赖性管理

在单线程程序中调用一个方法时,如果某个基于状态依赖的前提条件未得到满足(例如“连接池必须非空“),那么这个条件将永远无法成真。因此,在编写顺序程序中的类时,要使得这些类在它们的前提条件未被满足时就失败。但在并发程序中,基于状态的条件可能会由于其他线程的操作而改变:一个资源池可能在几条指令之前还是空的,但线程却变成非空的,因为另一个线程可能会返回一个元素到资源池。对于并发对象上依赖状态的方法,虽然有时候在提前条件不满足的情况下不会失败,但通常有一种更好的选择,即等待前提条件变为真。

依赖状态的操作可以一直阻塞直到可以继续执行,这比使它们先失败再实现起来要更为方便且不易出错。内置的条件队列可以使线程一直阻塞,直到对象进入某个线程可以继续执行的状态,并且当被阻塞的线程可以执行时再唤醒它们。为了突出高效地条件等待机制的价值,将首先介绍如何通过轮询与休眠等方式来(勉强地)解决状态依赖性问题。

可阻塞的状态依赖操作的形式如下所示,这种加锁模式有些不同寻常,因为锁是在操作的执行过程中被释放与重新获取的。构成前提条件的状态变量必须由对象的锁来保护,从而使它们在测试前提条件的同时保持不变。如果前提条件尚未满足,就必须释放锁,以便其他线程可以修改对象的状态,否则,前提条件就永远无法变成真。在再次测试提前条件之前,必须重新获得锁。

acquire lock on object state
while (precondition does not hold) 
{
    release lock
    wait until precondition might hold
    optionally fail if interrupted or timeout expires
    reacquire lock
}
perform action
release lock

当前提条件未满足时,依赖状态的操作可以抛出一个异常或返回一个错误状态(使其成为调用者的一个问题),也可以保持阻塞知道对象进入正确的状态。

接下来介绍有界缓存的实现,其中将采用不同的方法来处理前提条件失败的问题。下面是一个经典的基于队列的循环缓存,后面会有几个版本都是从它继承而来的。该版本没有对放入元素与取出元素进行任务约束,比如队满后不能再放入,队空后不能再取出。

//未加任何约束的缓冲队列
public abstract class BaseBoundedBuffer<V> {
    private final V[] buf;//缓存
    private int tail;//队尾
    private int head;//队首
    private int count;//元素个数

    protected BaseBoundedBuffer(int capacity) {
        this.buf = (V[]) new Object[capacity];
    }

    protected synchronized final void doPut(V v) {//入队
        buf[tail] = v;//在队尾添加
        if (++tail == buf.length)//如果满了,从头开始
            tail = 0;
        ++count;
    }

    protected synchronized final V doTake() {//出队
        V v = buf[head];//从队首取出
        buf[head] = null;//GC
        if (++head == buf.length)//如果到尾了,则从头开始
            head = 0;
        --count;
        return v;
    }

    public synchronized final boolean isFull() {//队列是否满
        return count == buf.length;
    }

    public synchronized final boolean isEmpty() {//队列是否空
        return count == 0;
    }
}

14.1.1 示例:将前提条件的失败传递给调用者

@ThreadSafe
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
    public GrumpyBoundedBuffer(int size) { super(size); }

    public synchronized  void put(V v) throws BufferFullException {
        if (isFull())//如果队列满后再次添加则向调用者抛出异常
            throw new BufferFullException();
        doPut(v);
    }

    public synchronized  V take() throws BufferEmptyException {
        if (isEmpty())//如果队列空后再次取出时则向调用者抛出异常
               throw new BufferEmptyException();
        return doTake();
    }
}

尽管这种方法实现起来很简单,但使用起来却并非如此。异常应该用于发生异常条件的情况中。“缓存已满”并不是有界缓存的一个异常条件,就像“红灯”并不代表交通信号灯出现了异常。在实现缓存时得到的简化(使调用者管理状态依赖性)并不能抵消在使用时存在的复杂性,因为现在调用者必须做好捕获异常的准备,并且在每次缓存操作时都需要重试。

while (true) {//不停地轮询,会造成忙等
    try {                   
        V item = buffer.take();
        // use item
        break;
    } catch (BufferEmptyException e) {
        Thread.sleep(SLEEP_GRANULARITY);//可能会睡过头
    }
}

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

这种方法的一种变化形式是,当缓存处于某种错误的状态时,返回一个错误值。这是一种改进,因为并没有放弃异常机制,抛出的异常意味着“对不起”,请再试一次,但这种方法并没有解决根本问题:调用者必须自行处理前提条件失败的情况。

上面的客户代码不是实现重试的唯一方式。调用者可以不进入休眠状态,而直接重新调用take方法,这种方法被称为忙等待或自旋等待。如果缓存的状态在很长一段时间内不会发生变化,那么使用这种方法就会消耗大量的CPU时间。但是,调用者也可以进入休眠状态来避免消耗过多的CPU时间,但如果缓存的状态在刚调用完sleep就立即发生变化,那么将不必要地休眠一段时间。因此,客户代码必须要在二者之间进行选择:要么容忍自旋导致的CPU时钟周期浪费,要么容忍由于休眠而导致的低响应性。(除了忙等待与休眠之外,还有一种选择就是调用Thread.yield,这相当于给调度器一个提示:现在需要让出一定的时间使另一个线程运行。假如正在等待另一个线程执行工作,那么如果选择让出处理器而不是消耗完整个调度时间片,那么可以使整体的执行过程变快。)

14.1.2 示例:通过轮询与休眠来实现简单的阻塞

下面程序尝试通过put和take方法来实现一种简单的“轮询与休眠“重试机制,从而使调用者无须在每次调用时都实现重试逻辑。如果缓存为空,那么take将休眠并直到另一个线程从缓存中放入一些数据;如果缓存是满的,那么put将休眠并直到另一个线程从缓存中移除一些数据,以便有空间容纳新的数据。这种方法将前提条件的管理操作封装起来,并简化了对缓存的使用——这正是朝着正确的改进方向迈进了一步。

@ThreadSafe
public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
    public SleepyBoundedBuffer(int size) { super(size); }

    public void put(V v) throws InterruptedException {
        while (true) {
            synchronized (this) {
                if (!isFull()) {
                    doPut(v);
                    return;
                }
            }
            Thread.sleep(SLEEP_GRANULARITY);
        }
    }

    public V take() throws InterruptedException {
        while (true) {
            synchronized (this) {
                if (!isEmpty())
                    return doTake();
            }
            Thread.sleep(SLEEP_GRANULARITY);
        }
    }
}

缓存的代码必须在持有缓存锁的时候才能测试相应的状态条件,因为表示状态条件的变量是由缓存锁保护的。如果测试失败,那么当前执行的线程将首先释放锁并休息一段时间,从而使其他线程能够访问缓存。当线程醒来时,它将重新请求锁并再次尝试执行操作,因而线程将反复地在休眠及测试状态条件等过程之间进行切换,知道可以执行操作为止。(通常,如果线程在休眠或者阻塞时持有一个锁,那么这通常是一种不好的做法,因为只要线程不释放这个锁,有些条件(缓存为满 / 空)就永远无法为真。

从调用者的角度看,这种方法能很好地运行,如果某个操作可以执行,那么就立即执行,否则就阻塞,调用者无须处理失败和重试。要选择合适的休眠时间间隔,就需要在响应性与CPU使用率之间进行权衡。休眠的间隔越小,响应性就越高,但消耗的CPU资源也越高。下图给出了休眠间隔对响应性的影响:在缓存中出现可用空间的时刻与线程醒来并再次检查的时刻之间可能存在延迟。

但是对调用者提出了一个新的需求:处理InterruptedException。当一个方法由于等待某个条件变成真而阻塞时,需要提供一种取消机制。

14.1.3 条件队列

”条件队列“这个名字来源于:它使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。

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

Object.wait会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并且修改对象的状态。当被挂起的线程醒来时,它将在返回之前重新获取锁。从直观上来理解,调用wait意味着”我要去休息了,但当发生特定的事情时唤醒我”,而调用通知方法就意味着“特定事情发生了”。

下面使用wait和nitifyAll来实现了一个有界缓存。这比使用“休眠”的有界缓存更简单,并且更高效(当缓存状态没有发生变化时,线程醒来的次数将更少),响应性也更高(当发生特定状态变化时将立即醒来)。这是一个较大的改进,但要注意:与使用“休眠”的有界缓存相比,条件队列并没有改变原来的语义。它只是在多个方面进行了优化:CPU效率、上下文切换开销和响应性等。如果某个功能无法通过“轮询和休眠”来实现,那么使用条件队列也无法实现,但条件队列使得在表达和管理状态依赖时更加简单和高效。

调用notify的结果是:JVM会从在这个条件队列中等待的众多线程中挑选一个,并把它唤醒;而调用notifyAll会唤醒所有正在这个条件队列中等待的线程。由于你调用notify和notifyAll时必须持有条件队列对象的锁,这导致等待线程此时不能重新获得锁,无法从wait返回,因此该通知线程应该尽快释放锁,以确保等待线程尽可能快地解除阻塞。

@ThreadSafe
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {
    // 条件谓词:not-full(!isFull())
    // 条件谓词:not-empty(!isEmpty())

    public BoundedBuffer(int size) { super(size); }

    // 阻塞并直到: not-full
    public  synchronized  void put(V v) throws InterruptedException {
        while (isFull())//如果满,则等待
            wait();
        doPut(v);
        notifyAll();//并在放入后马上通知其他线程
    }

    // 阻塞并直到: not-empty
    public  synchronized  V take() throws InterruptedException {
        while (isEmpty())//如果为空,则等待
            wait();
        V v = doTake();
        notifyAll();
        return v;
    }
}

用于生产环境的版本还应该包括限时版本的put和take版本,这样如果阻塞操作不能丰预计时间内完成,则超时。版本的put和take可以通过Object.wait的限时版本来实现,这很简单。

14.2 使用条件队列

14.2.1 条件谓词

要想正确地使用条件队列,关键是找出对象在哪个条件谓词上等待。条件谓词将在等待与通知等过程中导致许多困惑,事实上,在Java语言规范或Javadoc中根本就没有直接提到它。但如果没有条件谓词,条件等待机制将无法发挥作用。

条件谓词是使某个操作成为状态依赖的前提条件。在有界缓存中,只有当缓存不为空时,take方法才能执行,否则必须等待。对take方法来说,它的条件谓词就是“缓存不为空”,take方法在执行之前必须首先测试该条件谓词。同样,put方法的条件谓词是“缓存不满”。**条件谓词是由类中各个状态变量构成的表达式。**BaseBoundedBuffer在测试“缓存不为空”时将把count与0进行比较,在测试“缓存不满”时将把count与缓存的大小进行比较。

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

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

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

14.2.2 过早唤醒

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

内置条件队列可以与多个条件谓词一起使用。当一个线程由于调用notifyAll而醒来时,并不意味该线程正在等待的条件谓词已经变成真了。(这就像烤面包机和咖啡机共用一个铃声,当铃响后,你必须查看是哪个设备发出的铃声。)另外,wait方法还可以”假装“返回,而不是由于某个线程调用notify。

当执行控制重新进入调用wait的代码时,它已经重新获取了与条件队列相关联的锁。现在条件谓词是不是变成真了?或许,在发出通知的线程调用notifyAll时,条件谓词可能已经变成真,但在重新获取锁时将再次变为假。在线程被唤醒到wait重新获取锁这段时间里,可能有其他线程已经获取了这个锁,并修改了对象的状态。或者,条件谓词从调用wait起根本就没有变成真。你并不知道另一个线程为什么调用notify或notifyAll,也许是因为同一条件队列相关的另一个条件谓词变成了真。“一个条件队列与多个条件谓词相关”是一个很常见的情况——在BoundedBuffer中使用的条件队列与“非满”和“非空”两个条件谓词相关。

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

void stateDependentMethod() throws InterruptedException
{
    // 必须通过一个锁来保护条件谓词
    synchronized(lock)
    {
        while(!condietionPredicate())
            lock.wait();
        // 现在对象处于合适的状态
    }
}

当使用条件等待时(例如Object.wait或Condition.await):

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

14.2.3 丢失的信号

第10章讨论过活跃性故障,例如死锁和活锁。另一种形式的活跃性故障是丢失的信号。丢失的信号是指:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。现在,线程将等待一个已经发过的事件。就好比在启动了烤面包机后出去拿报纸,但你还在屋外时烤面包机的铃声响了,但你没有听到,因此还会坐在厨房的桌子前面等着烤面包机的铃声。如果线程A通知了一个条件队列,而线程B随后在这个条件队列上等待,那么线程B将不会立即醒来,而是需要另一个通知来唤醒它。像上述程序清单中警告之类的编码错误(例如,没有在调用wait之前检测条件谓词)就会导致信号的丢失。如果按照14.2.2程序清单的方式来设计条件等待,那么就不会发生信号丢失的问题。

14.2.4 通知

在有界缓存中,如果缓存为空,那么在调用take时将阻塞。在缓存变为非空时,为了使take解除阻塞,必须确保在每条使缓存变为非空的代码路径中都发出一个通知。在BoundedBuffer中,只有一条代码路径,即在put方法之后。因此,put在成功地将一个元素添加到缓存后,将调用notifyAll。同样,take在移除一个元素后也将调用notifyAll,向任何正在等待“不为满”条件的线程发出通知:缓存已经不满了。

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

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

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

在BoundedBuffer中很好地说明了为什么在大多数情况下应该优先选择notifyAll而不是单个的notify。这里的条件队列用于两个不同的条件谓词:“非空”和“非满”。假设线程A在条件队列上等待条件谓词PA,同时线程B在同一个条件队列上等待条件谓词PB。现在,假设PB变成真,并且线程C执行一个notify:JVM将从它拥有的众多线程中选择一个并唤醒。如果选择了线程A,那么它被唤醒,并且看到PA尚未编程真。因此将继续等待。同时,线程B本可以开始执行,却没有被唤醒。这并不是严格意义上的“丢失信号”,更像一种”被劫持的”信号,但导致的问题是相同的:线程正在等待一个已经(或者本应该)发生过的信号。

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

由于大多数类并不满足这些需求,因此普遍认为的做法是优先使用notifyAll而不是notify。虽然notifyAll可能比notify更低效,但却更容易确保类的行为是正确的。

当只有一个线程可以执行时,如果使用notifyAll,那么将是低效的,这种低效情况带来的影响有时候很小,但有时候却非常大。如果有10个线程在一个条件队列上等待,那么调用notifyAll将唤醒每一个线程,并使得它们在锁上发生竞争。然后,它们中的大多数或者全部又都回到休眠状态。因而,在每个线程执行一个事件的同时,将出现大量的上下文切换操作以及发生竞争的锁获取操作。(最坏的情况是,在使用notifyAll时将导致O(n2)次唤醒操作,而实际上只需要n次唤醒操作就足够了)。这是“性能考虑因素与安全性因素相互矛盾”的另一种情况。

在BoundedBuffer的put和take方法中采用的通知机制是保守的:每当将一个对象放入缓存或者从缓存中移走一个对象时,就执行一次通知。我们可以对其进行优化:首先,仅当缓存从空变为非空,或者从满转为非满时,才需要释放一个线程。并且,仅当put或take影响到这些状态的转换时,才发出通知。这也被称为“条件通知”。虽然“条件通知”可以提升性能,但却很难实现(而且还会使子类的实现变得复杂),因此在使用时应该谨慎。

public synchronized void put(V v) throws InterruptedException
{
    while(isFull())
        wait();
    boolean wasEmpty = isEmpty();
    doPut(v);
    if(wasEmpty)
        notifyAll();
}

单次通知和条件通知都属于优化措施。通常,在使用这些优化措施时,应该遵循“首选使程序正确地执行,然后才使其运行得更快”这个原则。如果不正确地使用这些优化措施,那么很容易在程序中引入奇怪的活跃性故障。

14.2.5 示例:阀门类

前面章节TestHarness中的“开始阀门闭锁”的构建,是通过将计数器初始化为1,创建了一个二元闭锁:它只有两种状态,初始状态和终止状态。闭锁会阻止线程通过开始阀门,直到阀门被打开,此时所有的线程都可以通过。虽然闭锁机制通常都能够准确地满足我们的需要,但是在闭锁的行为下构建的“阀门”一旦被打开,就不能再重新关闭,有时这会成为一个缺陷。

//可打开可关闭的阀门
public class ThreadGate {
       private boolean isOpen;
       private int generation;

       public synchronized void close() {
              isOpen = false;
       }

       public synchronized void open() {
              ++generation;
              isOpen = true;
              notifyAll();
       }

       public synchronized void await() throws InterruptedException {
              /*
               * 在检测等待条件前先将generation记录下来,以便在唤醒并获取锁后再次
               * 检测条件isOpen不满足时(即关闭),被阻塞的线程还是能继续通过。因为
               * 如果没有定义generation变量,当调用都打开阀门后又立即关闭,这会导致
               * 唤醒过来的线程不能正常通过阀门,所以确保只要是阻塞在该阀门上的所有线
               * 程在获得打开后的通知时就一定能通过,不会再被阻塞
               */
              int arrivalGeneration = generation;
              while (!isOpen && arrivalGeneration == generation)
                     wait();
       }
}

14.2.6 子类的安全问题

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

对于状态依赖的类,要么将其等待和通知等协议完全向子类公开(并且写入正式文档),要么完全阻止子类参与到等待和通知等过程中。(这是对”要么围绕着继承来设计和文档化,要么禁止使用继承“这条规则的一种扩展。)当设计一个可被继承的状态依赖类时,至少需要公开条件队列和锁,并且将条件谓词和同步策略都写入文档。此外,还可能需要公开一些底层的状态变量。(最糟糕的情况是,一个状态依赖的类虽然将其状态向子类公开,但却没有将相应的等待和通知等协议写入文档,这就类似于一个类虽然公开了它的状态变量,但却没有将其不变性条件写入文档。)

另外一种选择就是完全禁止子类化,例如将类声明为final类型,或者将条件队列、锁和状态变量等隐藏起来,使子类看不见它们。否则,如果子类破坏了在基类中使用notify的方式,那么基类需要修复这种破坏。考虑一个无界的可阻塞栈,当栈为空时,pop操作将阻塞,但push操作通常可以执行。这就满足了使用单词通知的需求。如果在这个类中使用了单词通知,并且在其一个子类中添加一个阻塞的”弹出两个连续元素“方法,那么就会出现两种类型的等待线程:等待弹出一个元素的线程和等待弹出两个线程的线程。但如果基类将条件队列公开出来,并且将使用该条件队列的协议也写入文档,那么子类就可以将push方法改写为执行notifyAll,从而重新确保安全性。

14.2.7 封装条件队列

通常,我们应该把条件队列封装起来,因而除了使用条件队列的类,就不能在其他地方访问它。否则,调用者会自以为理解了在等待和通知上使用的协议,并且采用一种违背设计的方式来使用条件队列。(除非条件队列对象对于你无法控制的代码来说是不可访问的,否则就不可能要求在单次通知中的所有等待线程都是同一类型的。如果外部代码错误地在条件队列上等待,那么可能通知协议,并导致一个”被劫持的“的信号。)

不幸的是,这条建议——将条件队列对象封装起来,与线程安全的最常见设计模式并不一致,在这种模式中建议使用对象的内置锁来保护对象自身的状态。在BoundedBuffer中给出了这种常见的设计模式,即缓存对象自身既是锁,又是条件队列。然而,可以很容易将BoundedBuffer重新设计为使用私有的锁对象和条件队列,唯一不同之处在于,新的BoundedBuffer不再支持任何形式的客户端加锁。

14.2.8 入口协议和出口协议

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

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

14.3 显式的Condition对象

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

public interface Condition {
    void await() throws InterruptedException;
    boolean await(long time, TimeUnit unit)
            throws InterruptedException;
    long awaitNanos(long nanosTimeout) throws InterruptedException;
    void awaitUninterruptibly();
    boolean awaitUntil(Date deadline) throws InterruptedException;

    void signal();
    void signalAll();
}

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

一个Condition和一个Lock关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个Condition,可以在相关联的Lock上调用Lock.newCondition方法。正如Lock比内置加锁提供了更为丰富的功能,Condition同样比内置条件队列提供了更丰富的功能:在每个锁上可存在多个等待、条件等待可以是可中断的或不可中断的、基于时限的等待,以及公平的或非公平的队列操作。

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

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

long awaitNanos(long nanosTimeout):参数nanosTimeout为最长等待时间,单位为纳秒;如果超时,则返回一个小于或等于 0 的值,否则返回此方法返回时所剩余时间的估计值,该值绝对小于 nanosTimeout 参数,可以用此值来确定在等待返回但等待条件再次被打破的情况下,再次等待的时间,总共等待的时间绝对不超过nanosTimeout(如果条件再次被打破时再等待nanosTimeout,则不需要使用此方法,直接使用await即可)。此方法的典型用法采用以下形式:

synchronized boolean aMethod(long timeout, TimeUnit unit) {
   long nanosTimeout = unit.toNanos(timeout);//转换成纳秒
   while (!conditionBeingWaitedFor) {
     if (nanosTimeout > 0)
         nanosTimeout = theCondition.awaitNanos(nanosTimeout);//每次等待的时间逐渐减少,这与使用wait或await超时版是不一样的
      else//此种情况属于超时时间用完且等待的条件还不满足时,会直接返回调用者
        return false;
   }
   // ...
}

免中断等待:如果等待的线程被中断,condition.await方法将抛出一个InterruptedException异常。如果你希望在出现这种情况时线程能够继续等待(等待条件满足。似乎不太合理),那么可以使用condition.awaitUninterruptibly方法来代替await。

使用Condition重写前面的循环队列BaseBoundedBuffer:

public class ConditionBoundedBuffer<T> {
    protected final Lock lock = new ReentrantLock();
    private final Condition notFull    = lock.newCondition();//条件:count < items.length
    private final Condition notEmpty  = lock.newCondition();//条件:count > 0
    private final T[] items = (T[]) new Object[100];
    private int tail, head, count;

    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();//等到条件count < items.length满足
            items[tail] = x;
            if (++tail == items.length)
                tail = 0;
            ++count;
            notEmpty.signal();//通知读取等待线程
        } finally {
            lock.unlock();
        }
    }

    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();//等到条件count > 0满足
            T x = items[head];
            items[head] = null;
            if (++head == items.length)
                head = 0;
            --count;
            notFull.signal();//通知写入等待线程
            return x;
        } finally {
            lock.unlock();
        }
    }
}

在分析使用多个Condition的类时,比分析一个使用单一内部队列加多个条件谓词的类简单得多。通过将两个条件谓词分开并放到两个等待线程集,Condition使其更容易满足单次通知的需求。signal比signalAll更高效,它能极大地减少在每次缓存操作中发生的上下文切换与锁请求的次数。

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

在使用显式地Condition和内置条件队列之间进行选择时,与在ReentrantLock和synchronized之间进行选择是一样的:如果需要一些高级功能,例如使用公平的队列操作或者在每个锁上对应多个等待线程集,那么应该优先使用Condition而不是内置的条件队列。(如果需要ReentrantLock的高级功能,并且已经使用了它,那么就已经做出了选择。)

14.4 Synchronizer剖析

在ReentrantLock和Semaphore这两个接口之间存在许多共同点。这两个类都可以用作一个”阀门“,即每次只允许一定数量的线程通过,并当线程到达阀门时,可以通过(在调用lock或acquire时成功返回),也可以等待(在调用lock或acquire时阻塞),还可以取消(在调用tryLock或tryAcquire时返回”假“,表示在指定的时间内锁是不可用的或者无法获取许可)。而且,这两个接口都支持中断、不可中断的以及限时的获取操作,并且也都支持等待线程执行公平或非公平的队列操作。

从这些共同点看,你或许认为Semaphore是基于ReentrantLock来实现的,或者是ReentrantLock只是一个只有一个许可的Semaphore,这是完全可以的,下面就是使用lock实现一个计数信号量:

public class SemaphoreOnLock {//基于Lock的Semaphore实现
       private final Lock lock = new ReentrantLock();
       //条件:permits > 0
       private final Condition permitsAvailable = lock.newCondition();
       private int permits;//许可数

       SemaphoreOnLock(int initialPermits) {
              lock.lock();
              try {
                     permits = initialPermits;
              } finally {
                     lock.unlock();
              }
       }

       //颁发许可,条件是:permits > 0
       public void acquire() throws InterruptedException {
              lock.lock();
              try {
                     while (permits <= 0)//如果没有许可,则等待
                            permitsAvailable.await();
                     --permits;//用一个少一个
              } finally {
                     lock.unlock();
              }
       }

       //归还许可
       public void release() {
              lock.lock();
              try {
                     ++permits;
                     permitsAvailable.signal();
              } finally {
                     lock.unlock();
              }
       }
}

当然也可以使用计数许可来实现一个Lock:

public class LockOnSemaphore {//基于Semaphore的Lock实现
       //具有一个信号量的Semaphore就相当于Lock
       private final Semaphore s = new Semaphore(1);

       //获取锁
       public void lock() throws InterruptedException {
              s.acquire();
       }

       //释放锁
       public void unLock() {
              s.release();
       }
}

事实上,它们在实现时都使用了一个共同的基类,即AbstractQueuedSynchronizer(AQS),这个类也是其他许多同步类的基类。AQS是一个用于构建锁和同步器的框架,许多同步器都可以通过AQS很容易并且高效地构建出来。不仅ReentrantLock和Semaphore是基于AQS构建的,还包括CountDownLatch、ReentrantReadWriteLock、SynchronousQueue和FutureTask。

AQS解决了在实现同步器时涉及的大量细节问题,例如等待线程采用FIFO队列操作顺序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是需要等待。

基于AQS来构建同步器能带来许多好处。它不仅能极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题(这是在没有使用AQS来构建同步器时的情况)。在SemaphoreOnLock中,获取许可的操作可能在两个时刻阻塞——当锁保护信号量状态时,以及当许可不可用时。在基于AQS构建的同步器中,只可能在一个时刻发生阻塞,从而降低上下文切换的开销,并提高吞吐量。在设计AQS时充分考虑了可伸缩性,因此java.util.concurrent中所有基于AQS构建的同步器都能获得这个优势。

14.5 AbstractQueuedSynchronizer

大多数开发者都不会直接使用AQS,标准同步器类的集合能够满足绝大多数的需求,但如果能了解标准同步器类的实现方式,那么对于理解它们的工作原理是非常有帮助的。

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

如果一个类想成为状态依赖的类,那么它必须拥有一些状态。AQS负责管理同步器类中的状态,它管理了一个整数状态信息,可以通过getState,setState以及compareAndSetState等protected类型方法来进行操作。这个整数可以用于表示任意状态。例如,ReentrantLock用它来表示所有线程已经重复获取该锁的次数,Semaphore用它来表示剩余的许可数量,FutureTask用它来表示任务的状态(尚未开始、正在运行、已完成以及已取消)。在同步器类中还可以自行管理一些额外的状态变量,例如,ReentrantLock保存了锁的当前所有者的信息,这样就能区分某个获取操作是重入的还是竞争的。

下面清单给出了AQS中的获取操作与释放操作的形式。根据同步器的不同,获取操作可以是一种独占操作(例如ReentrantLock),也可以是一个非独占操作(例如Semaphore和CountDownLatch)。一个获取操作包括两部分。首先同步器判断当前状态是否允许获得操作,如果是,则允许线程执行,否则获取操作将阻塞或失败。这种判断是由同步器的语义决定的。例如,对于锁来说,如果它没有被某个线程持有,那么他就能被成功地获取,而对于闭锁来说,如果它处于结束状态,那么也能被成功地获取。

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

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

其次就是更新同步器的状态,获取同步器的某个线程可能会对其他线程能否也获取该同步器造成影响。例如,当获取一个锁后,锁的状态将从“未被持有”变成“已被持有”,而从Semaphore中获取一个许可后,将把剩余许可的数量减1。然而,当一个线程获取闭锁时,并不会影响其他线程能够获取它,因此获取闭锁的操作不会改变闭锁的状态。

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

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

protected boolean tryAcquire(int arg):试图以独占模式获取对象状态。此方法应该检查是否允许它在独占模式下获取对象状态,如果允许,则获取它。此方法由执行 acquire 的线程来调用,如果此方法返回false,则 acquire 方法将线程加入队列(如果还没有将它加入队列),直到获得其他某个线程释放了该线程的信号。如果不支持独占模式则抛出UnsupportedOperationException。参数arg该值总是传递给 acquire 方法的那个值。、

tryAcquireShared基本上与tryAcquire一样,只不过是共享模式获取对象状态。

protected boolean isHeldExclusively():如果对于当前(正调用的)线程,同步是以独占方式进行的,则返回 true。此方法通过AbstractQueuedSynchronizer.ConditionObject 中非阻塞方法来调用的。默认实现将抛出 UnsupportedOperationException。此方法只是 AbstractQueuedSynchronizer.ConditionObject 方法内进行内部调用,因此,如果不使用条件,则不需要定义它。

不可重入互斥锁

以下是一个非再进入的互斥锁定类,它使用值 0 表示未锁定状态,使用 1 表示锁定状态。它还支持一些条件并公开了一个检测方法:

class Mutex implements Lock, java.io.Serializable {

    // Our internal helper class
    private static class Sync extends AbstractQueuedSynchronizer {
      // Report whether in locked state
      protected boolean isHeldExclusively() {
        return getState() == 1;
      }

      // Acquire the lock if state is zero
      public boolean tryAcquire(int acquires) {
        assert acquires == 1; // Otherwise unused
        return compareAndSetState(0, 1);
      }

      // Release the lock by setting state to zero
      protected boolean tryRelease(int releases) {
        assert releases == 1; // Otherwise unused
        if (getState() == 0) throw new IllegalMonitorStateException();
        setState(0);
        return true;
      }

      // Provide a Condition
      Condition newCondition() { return new ConditionObject(); }

      // Deserialize properly
      private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        setState(0); // reset to unlocked state
      }
    }

    // The sync object does all the hard work. We just forward to it.
    private final Sync sync = new Sync();

    public void lock()                { sync.acquire(1); }
    public boolean tryLock()          { return sync.tryAcquire(1); }
    public void unlock()              { sync.release(1); }
    public Condition newCondition()   { return sync.newCondition(); }
    public boolean isLocked()         { return sync.isHeldExclusively(); }
    public boolean hasQueuedThreads() { return sync.hasQueuedThreads(); }
    public void lockInterruptibly() throws InterruptedException {
      sync.acquireInterruptibly(1);
    }
    public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
      return sync.tryAcquireNanos(1, unit.toNanos(timeout));
    }
}

一个简单的闭锁

public class OneShotLatch {
       private final Sync sync = new Sync();

       public void signal() {//释放锁
              sync.releaseShared(0);
       }

       public void await() throws InterruptedException {//获取锁
              sync.acquireSharedInterruptibly(0);
       }

       private class Sync extends AbstractQueuedSynchronizer {
              protected int tryAcquireShared(int ignored) {
                     // 如果 state == 1 则表示闭锁成功打开,否则将调用失败的线程放入阻塞队列中
                     return (getState() == 1) ? 1 : -1;
              }

              protected boolean tryReleaseShared(int ignored) {
                     setState(1); // 闭锁现已被打开
                     return true; // 现在,其他线程可以获得闭锁

              }
       }
}

上面闭锁初始状态是关闭的,刚开始时任何调用await的线程都会阻塞,直到signal被调用。一旦闭锁被一个signal调用打开,待选中的线程就会被释放,而且随后到来的线程也被允许通行。

在OneShotLatch中,AQS状态用来表示闭锁状态——关闭(0)或者打开(1)。await方法调用AQS的acquireSharedInterruptibly,然后接着调用OneShotLatch中的tryAcquireShared方法。在tryAcquireShared的实现中必须返回一个值来表示该获取操作能否执行。如果之前已经打开了闭锁,那么tryAcquireShared将返回成功并允许线程通过,否则就会返回一个表示获取操作失败的值。acquireSharedInterrupted方法在处理失败的方式,是把这个线程放入等待线程队列中。类似地,signal将调用releaseShared,接下来又会调用tryReleaseShared。在tryReleaseShared中将无条件地把闭锁的状态设置为打开,(通过返回值)表示该同步器处于完全被释放的状态。因而AQS让所有等待中的线程都尝试重新请求该同步器,并且由于tryAcquireShared将返回成功,因此现在的请求操作将成功。

OneShotLatch是一个功能全面、可用的、性能较好的同步器,并且仅使用了大约20多行代码就实现了。当然,它缺少了一些有用的特性,例如限时的请求操作以及检查闭锁的状态,但这些功能实现起来同样很容易,因为AQS提供了限时版本的获取方法,以及一些在常见检查中使用的辅助方法。

OneShotLatch也可以通过扩展AQS来实现,而不是将一些功能委托给AQS,但这种做法并不合理,原因有很多。这样做将破坏OneShotLatch接口(只有两个方法)的简洁性,并且虽然AQS的公共方法不允许调用者破坏闭锁的状态,但调用者仍可以很容易地误用它们。java.util.concurrent中的所有同步器类都没有直接扩展AQS,而是都将它们的相应功能委托给私有的AQS子类来实现。

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

java.util.concurrent中的许多可阻塞类,例如ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch、SynchronousQueue和FutureTask等,都是基于AQS构建的。我们快速地浏览一下每个类是如何使用AQS的,不需要过于深入地了解细节。

14.6.1 ReentrantLock

ReentrantLock只支持独占方式的获取操作,因此它实现了tryAcquire、tryRelease和isHeldExclusively,下面程序给出了非公平版本的tryAcquire。ReentrantLock将同步状态用于保存锁获取操作的次数,或者正要释放锁的时候,才会修改这个变量。在tryRelease中检查owner域,从而确保当前线程在执行unlock操作之前已经获取了锁:在tryAcquire中将使用这个域来区分获取操作是重入的还是竞争的。

protected boolean tryAcquire(int ignored)
{
    final Thread current = Thread.currentThread();
    int c = getState();
    if(c == 0)
        if(compareAndSetState(0,1))
        {
            owner = current;
            return true;
        }
        else if(current == owner)
        {
            setState(c + 1);
            return true;
        }
}

当一个线程尝试获取锁时,tryAcquire将首先检查锁的状态。如果锁未被持有,那么它将尝试更新锁的状态以表示已经被持有。由于状态可能在检查后被立即修改,因此tryAcquire使用compareAndSetState来原子地更新状态,表示这个锁已经被占有,并确保状态在最后一次检查以后就没有被修改过。如果锁状态表明它已经被持有,并且如果当期那线程是锁的拥有者,那么获取计数会递增,如果当前线程不是锁的拥有者,那么获取操作将失败。
ReentrantLock还利用了AQS对多个条件变量的和多个等待线程集的内置支持。Lock.newCondition将返回一个新的ConditionObject实例,这是AQS的一个内部类。

14.6.2 Semaphore与CountDownLatch

Semaphore将AQS的同步状态用于保存当前可用许可的数量。tryAcquireShared首先计算剩余许可的数量,如果没有足够的许可,那么会返回一个值表示获取操作失败。如果还有剩余的许可,那么tryAcquireShared会通过compareAndSetState以原子方式来降低许可的计数。如果这个操作成功(这意味着许可的计数自从上一次读取后就没有被修改过),那么将返回一个值表示获取操作成功。在返回值中还包含了表示其他共享获取操作能够成功的信息,如果成功,那么其他等待的线程同样会解除阻塞。

protected int tryAcquireShared(int acquires) {
    while (true) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0
            || compareAndSetState(available, remaining)) {
            return remaining;
        }
    }
}

protected boolean tryReleaseShared(int releases) {
    while (true) {
        int p = getState();
        if (compareAndSetState(p, p + releases)) {
            return true;
        }
    }
}

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

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

14.6.3 FutureTask

初看上去,FutureTask甚至不像一个同步器,但Future.get的语义非常类似于闭锁的语义——如果发生了某个事件(有FutureTask表示的任务执行完成或被取消),那么线程就可以恢复执行,否则这些线程将停留在队列中并直到该事件发生。
在FutureTask中,AQS同步状态被用来保存任务的状态,例如,正在运行、已完成或已取消。FutureTask还维护一些额外的状态变量,用来保存计算结果或者抛出的异常。此外,它还维护了一个引用,指向正在执行计算任务的线程(如果它当前处于运行状态),因而如果任务取消,该线程就会中断。

14.6.4 ReentrantReadWriteLock

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

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

小结

要实现一个依赖状态的类——如果没有满足依赖状态的前提条件,那么这个类的方法必须阻塞,那么最好的方式是基于现有类库来构建,例如Semaphore.BlockingQueue或CountDownLatch。然而,有时候现有的类库不能提供足够的功能,在这种情况下,可以使用内置的条件队列、显式的Condition对象或者AbstractQueuedSynchronizer来构建自己的同步器。内置条件队列与内置锁是紧密绑定在一起的,这是因为管理状态依赖性的机制必须与确保状态一致性的机制关联起来。同样,显式的Condition与显式地Lock也是紧密地绑定在一起的,并且与内置条件队列相比,还提供了一个扩展的功能集,包括每个锁对应于多个等待线程集,可中断或不可中断的条件等待,公平或非公平的队列操作,以及基于时限的等待。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值