Java并发编程实战(学习笔记 十三 第十四章 构建自定义的同步工具 上)

类库中包含了许多存在状态依赖性的类,如FutureTask,Semaphore和BlockingQueue等。在这些类的一些操作中有基于状态的前提条件,例如,不能从一个空的队列中删除元素,或者获取一个未结束的任务的计算结果。

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

14.1 状态依赖性的管理(Managing State Dependence)

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

可阻塞的状态依赖操作的形式如14-1所示,这种加锁模式有些不同,因为锁是在操作的执行过程中被释放与重新获取的。

构成前提条件的状态变量必须由对象的锁来保护,从而使它们在测试前提条件的同时保持不变。
如果前提 条件尚未满足,比必须释放锁,以便其他线程可以修改对象的状态,否则,前期条件就永远无法变成真。
在再次测试前提条件之前,必须重新获得锁。

//     14-1   可阻塞的状态依赖操作的结构
void blockingAction() throws InterruptedException {
   acquire lock on object state
   while (前提条件尚未满足) {
     release lock
     等待直到前提条件满足
     optionally fail if interrupted or timeout expires
     reacquire lock
    }
    perform action
}

在生产者—消费者的设计中经常会使用像ArrayBlockingQueue这样的有界缓存。在有界缓存提供的put和take操作中都包含一个前提条件:不能从空缓存中获取元素,也不能将元素放入已满的缓存中。
当前提条件未满足时,依赖状态的操作可以抛出一个异常或者返回一个错误状态,也可以保持阻塞直到对象进入正确的状态。

将采用不同的方法来处理前提条件失败的问题。
14-2中的BaseBoundedBuffer,这个类中实现了一个基于数组的循环缓存,其中各个缓存状态变量(buf,head,tail和count)均由缓存的内置锁来保护。它还提供了同步的doPut和doTake方法,并在子类中通过这些方法来实现put和take操作,底层的状态将对子类隐藏。

//        14-2     有界缓存实现的基类
@ThreadSafe
public 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;
        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 示例:将前提条件的失败传递给调用者

put和take都进行了同步以确保实现对缓存状态的独占访问,因为这两个方法在访问缓存时都采用“先检查再运行”的逻辑策略。

//       14-3   当不满足前提条件时,有界缓存不会执行相应的操作
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
    public GrumpyBoundedBuffer() {
            this(100);
    }
    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();
    }
}
class BufferFullException extends RuntimeException {
}

class BufferEmptyException extends RuntimeException {
}

异常应该用于发生异常条件的情况中,“缓存已满”并不是有界缓存的一个异常条件。
调用者必须做好捕获异常的准备,并且在每次缓存操作时都需要重试。
14-4给出了对take的调用——并不是很漂亮。

//      14-4    调用GrumpyBoundedBuffer的代码
class ExampleUsage{
     private GrumpyBoundedBuffer<String> buffer;
     int SLEEP_GRANULARITY = 50;
     void useBuffer() throws InterruptedException{
         while(true){
             try{
                 String item=buffer.take();
                 //对item执行一些操作
                 break;
             }catch (BufferEmptyException e) {
                Thread.sleep(SLEEP_GRANULARITY);
            }
         }
     }
}

当缓存处于某个错误的状态时返回一个错误值,这是一种改进,因为并没有放弃异常机制,但这种方法并没有解决根本问题:调用者必须自行处理前提条件失败的情况(Queue,poll在队列为空时返回null,而remove方法则抛出一个异常,Queue并不适合生产者-消费者模式,BlockingQueue只有当队列处于正确状态时才会进行处理,否则将阻塞,因此才是更好的选择)。

14-4中,调用者可以不进入休眠状态,而直接重新调用take,这种方法被称为忙等待或自旋等待。
忙等待可能导致CPU时钟周期浪费,休眠可能导致低响应性
除了忙等待与休眠,还可以使用Thread.yield,这相当于给调度器一个提示:现在需要让出一定的时间使另一个线程运行。假如正在等待另一个线程执行工作,那么如果选择让出处理器而不是消耗完整个CPU调度时间片,那么可以使整体的执行过程变快。

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

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

//     14-5   使用简单阻塞实现的有界缓存(并不好)
public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
    int SLEEP_GRANULARITY = 60;

    public SleepyBoundedBuffer(){
        super(100);
    }

    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资源也就越大。

下图给出了休眠间隔对响应性的影响:在缓存中出现可用空间的时刻与线程醒来并再次检查的时刻之间可能存在延迟。
这里写图片描述

14.1.3 条件队列(Condition Queues to the Rescue)

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

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

根据上述两点,在调用wait(), notify()或notifyAll()的时候,必须先获得锁,且状态变量须由该锁保护,而固有锁对象与固有条件队列对象又是同一个对象。也就是说,要在某个对象上执行wait,notify,先必须锁定该对象,而对应的状态变量也是由该对象锁保护的。

wait(),notify(),notifyAll()不属于Thread类,而是属于Object基础类,也就是说每个对像都有wait(),notify(),notifyAll()的功能。因为都个对像都有锁,锁是每个对像的基础

wait():
等待对象的同步锁,需要获得该对象的同步锁才可以调用这个方法,否则编译可以通过,但运行时会收到一个异常:IllegalMonitorStateException。
调用任意对象的 wait() 方法导致该线程阻塞,该线程不可继续执行,并且该对象上的锁被释放。

notify():
唤醒在等待该对象同步锁的线程(只唤醒一个,如果有多个在等待),注意的是在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由JVM确定唤醒哪个线程,而且不是按优先级。
调用任意对象的notify()方法则导致因调用该对象的 wait()方法而阻塞的线程中随机选择的一个解除阻塞(但要等到获得锁后才真正可执行)

notifyAll():
唤醒所有等待的线程,注意唤醒的是notify之前wait的线程,对于notify之后的wait线程是没有效果的。

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

14-6使用了wait和notifyAll来实现一个有界缓存。
这比使用休眠更简单,更高效,响应性更高。
如果某个功能无法通过“轮询与休眠”来实现,那么使用条件队列也无法实现,但条件队列使得在表达和管理状态依赖性时更简单高效。

//           使用条件队列实现的有界缓存
public class BoundedBuffer<V> extends BaseBoundedBuffer<V>{
    //条件谓词:not-full(!isFull())
    //条件谓词:not-empty(!isEmpty())

    public BoundedBuffer(){
        super(100);
    }

    public BoundedBuffer(int size){
        super(size);
    }
    //阻塞直到:not-full
    public synchronized void put(V v) throws InterruptedException{
        while(isFull())
            wait();
        doPut(v);
        //唤醒所有正在等待该对象的线程 ,这样take操作才能获得锁
        notifyAll();
    }
    //阻塞直到:not-empty
    public synchronized V take() throws InterruptedException{
        while(isEmpty())
            wait();
        V v=doTake();
        //唤醒所有正在等待该对象的线程,这样put才能获得锁
        notifyAll();
        return v;
    }
}

14.2 使用条件队列

条件队列使构建高效以及高可响应性的状态依赖类变得更容易,但同时也容易被不正确地使用。

14.2.1 条件谓词(The Condition Predicate)

要想正确地使用条件队列,关键是要找出在哪个条件谓词上等待。

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

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

在BoundedBuffer中,缓存的状态是由缓存锁保护的,并且缓存对象被用作条件队列。

如果条件谓词不为真,take将在缓存的内置条件队列上调用wait方法。wait方法将释放锁,阻塞当前线程,并等待直到超时,然后线程被中断或通过一个通知被唤醒。在唤醒进程后,wait在返回前还要重新获取锁,当线程从wait方法中被唤醒时,它在重新请求锁时不具有任何特殊的优先级,而要与其他尝试进入同步代码块的线程一起正常地在锁上进行竞争。

14.2.2 过早唤醒

内置条件队列可以与多个条件谓词一起使用。当一个线程由于调用notifyAll而醒来时,并不意味者正在等待的条件谓词已变成真了(像烤面包机和咖啡机共用一个铃声,你必须查看是哪个设备发出的)。

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

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

14-7给出了条件等待的标准形式

//       14-7  状态依赖方法的标准形式
void stateDependentMethod() throws InterruptedException {
   // 必须通过一个锁来保护条件谓词
   synchronized(lock) {
      while (!conditionPredicate())
         lock.wait();
      // 现在对象处于合适的状态
}
}

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

14.2.3 丢失的信号

活跃性故障包括死锁和活锁,还包括丢失的信号。

丢失的信号是指:线程必须等待一个已经成真的条件,但在开始等待之前没有检查条件谓词。(好比你等烤面包机的过程中去拿报纸,而途中铃声响了,你并不知道,还会继续等待)

例如,没有在调用wait之前检查条件信号就会导致信号的丢失。

14.2.4 通知(Notification)

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

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

在条件队列API中有两种发出通知的方法:notify和notifyAll。
这两种方法都必须获得与条件队列对象相关联的锁。
调用notify时,JVM会从这个条件队列上等待的多个线程中选择一个来唤醒。
调用notifyAll时则唤醒所有在这个条件队列上等待的线程。
发出通知的线程应尽快地释放锁,从而确保正在等待的线程尽快地解除阻塞。

由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,如果使用notify,将是危险的,因为单一的通知可能导致类似信号丢失的问题:线程正在等待一个已经(或者本应该)发生过的信号。

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

BoundedBuffer满足“单进单出”,但不满足第一点,因为它的条件队列有两个不同的条件谓词:“非空”和“非满”。
而第五章中的TestHarness使用的“开始阀门”闭锁(单个事件释放一组线程)并不满足“单进单出”的需求,因为这个“开始阀门”将使得多个线程开始执行。

在BoundedBuffer的put和take方法中采用的通知机制时保守的:每当将一个对象放入缓存或者从缓存中移走一个对象时,就执行一次通知。
我们可以进行优化:首先,仅当缓存从空变为非空,或者从满转为非满时,才需要释放一个线程(等到状态转换了才唤醒线程,其他新进来线程正常执行)。并且,仅当put或take影响到这些状态转换时,才发出通知,这被称为“条件通知(Conditional Notification)”。

虽然“条件通知”可以提升性能,但却很难实现(还会使子类的实现变得复杂),因此在使用时应该谨慎。

//         14-8   在BoundedBuffer中使用条件通知
public synchronized void put(V v) throws InterruptedException{
    while (isFull())
       wait();
    boolean wasEmpty = isEmpty();
    doPut(v);
    if (wasEmpty)
      notifyAll();
}

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

14.2.5 示例:阀门类

第5章使用的“开始阀门闭锁”在初始化指定的参数为1,从而创建一个二元闭锁:它只有两种状态:即初始状态(1)和结束状态(0)。

闭锁能阻止线程通过开始阀门,并直到阀门被打开,此时所有的线程都可以通过该阀门。

闭锁机制在某些情况下存在一个缺陷:按照这种方式构造的阀门在打开后无法重新关闭。

通过使用条件等待,创建一个可重新关闭的ThreadGate类。它可以打开和关闭阀门,并提供一个await方法,该方法能一直阻塞直到阀门被打开。在open方法中使用了notifyAll,因为这个类的语义不满足“单进单出”

//     14-9   适应wait和notifyAll来实现可重新关闭的阀门
@ThreadSafe
public class ThreadGate {
    //条件谓词:opened-since(n)(isopen||generation>n)
    @GuardedBy("this") private boolean isOpen;
    @GuardedBy("this") private int generation;

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

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

//  阻塞直到:opened-since{generation on entry}
    public synchronized void await() throws InterruptedException {
        int arrivalGeneration = generation;
        //当有线程调用了open,isOpen为true,generation增加,此时才不阻塞
        while (!isOpen && arrivalGeneration == generation)
            wait();
    }
}

在await中使用的条件比测试isOpen复杂得多,这种条件谓词是必须的,因为如果当阀门打开时有N个线程正在等待它,那么这些线程都应该被允许执行。
然而,如果阀门在打开后又非常快速地关闭了,并且await方法只检查isOpen,那么所有线程都可能无法释放:当所有线程收到通知时,将重新请求锁并退出wait,而此时的阀门可能已经再次关闭了。因此,在ThreadGate中使用了一个更复杂的条件谓词:每次阀门关闭后,递增一个“Generation”计数器,如果阀门现在是打开的,或者阀门自从该线程达到后一直是打开的,那么线程就可以通过await。

由于ThreadGate只支持等待打开阀门,因此它直到open中执行通知。要想支持“等待打开”又支持“等待关闭”,必须在open和close中都进行通知。这就说明了在维护状态依赖的类时时非常困难的——当增加一个新的状态依赖操作时,可能需要对多条修改对象的代码路径进行改动。

14.2.6 子类的安全问题

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

另一选择就是完全禁止子类化,例如将类型声明为final,或者将条件队列,锁和状态变量等隐藏起来,使子类看不见它们。

14.2.7 封装条件队列

通常,我们应该把条件队列封装起来,因而除了使用条件队列的类,就不能在其他地方访问它。否则,调用者会采用一种违背设计的方式来使用条件队列。

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

14.2.8 入口协议与出口协议(Entry and Exit Protocols)

对于每个依赖状态的操作,以及每个操作其他操作依赖状态的操作,都应该顶一个入口协议和出口协议。

入口协议就是该操作的条件谓词,出口协议则包括,检查被该操作修改的所有状态变量,并确定它们是否使某个其他的条件谓词成真,如果是,则通知相同的条件队列。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值