zoukankan      html  css  js  c++  java
  • 并发编程学习笔记之构建自定义的同步工具(十一)

    概述:

    并发编程学习笔记之并发工具类(四)中,为大家介绍了几种同步工具(同步工具就是依靠自己的状态,调节线程是阻塞还是运行用的.),闭锁、FutureTask、信号量、关卡.

    使用以上的同步工具大部分时候可以满足我们的需求,但是如果没能满足我们需要的功能,可以使用语言和类库提供的底层机制,包括内部队列、限制的Condition对象和abstractQueueSynchronizer框架,来构建属于自己的Synchronizer.

    1.管理状态依赖性

    状态依赖: 若一个操作存在基于状态的先验条件,则把它称为是状态依赖的(state-dependent).

    单线程的程序而言,操纵一个集合,如果集合中的元素不为空,就取出一个元素,如果这个先验条件(集合不为空)不满足,就不需要等了,失败就行了.

    但是在多线程的条件下,一个线程走到了检验这个集合是否为空的时候,可以阻塞一段时间,等待结果为真,因为此时可能有另外一个线程往里面添加元素.

    对于并发对象,依赖于状态的方法有时可以在不能满足先验条件的情况下选择失败,不过更好的选择是等待先验条件为真.

    等待先验条件为真的实现方式

    一个可阻塞的状态依赖活动:

    void blockingAction(){
                /*获得锁*/
            while(/*先验条件,例如判断队列不为空*/){
                /*如果不满足先验条件,释放锁*/
                /*等待先验条件为真*/
                /*如果线程被中断或者等待超时,选择失败*/
                /*重新获得锁*/
            }
            /*执行任务*/
            /*再次获得锁*/
        }
    

    锁是在操作过程中被释放与重获的,这也让这种加锁的模式略显与众不同.

    组成先验条件的状态变量必须由对象的锁保护起来,这样它们能在测试先验条件的过程中保持不变.

    如果先验条件尚未满足,就必须释放锁,让其他线程可以修改对象的状态.否则,先验条件就永远无发成真了.

    再一次测试先验条件之前,必须要重新获得锁.

    接下来以一个BaseBoundedBuffer为基类,用不同的子类去继承它,看一下几种处理先验条件的方式.

    基类BaseBoundedBuffer:

    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];
        }
    
        /*
        * 线程安全的put方法,同时因为是final的不能被子类重写
        * */
        protected synchronized final void doPut(V v){
            //将v 放到缓存的 tail位置,从0开始赋值
            buf[tail]  = v;
            // 自增tail,同时与缓存的长度相比较,如果等于缓存的长度
            // 把tail置为0,也就是说下次会从0开始覆盖.
            if(++tail == buf.length){
                tail = 0;
            }
            // 统计的数组容量+1
            ++count;
        }
    
        /*
        * 同上
        * */
        protected  synchronized  final V doTake(){
            /*从head拿取数据*/
            V v = buf[head];
            /*拿出去的数据置为0*/
            buf[head] = null;
            if (++head == buf.length){
                head = 0;
            }
            -- count;
            return v;
        }
    
        //判断是否满了,注意前验条件必须被锁保护,保证不会看到过期数据
        public synchronized  final boolean isFull(){
            //如果当前的容量count == 缓存的长度,那就是满了,返回true
            return count == buf.length;
        }
    
        //判断是否为空
        public synchronized  final boolean isEmpty(){
            return count == 0 ;
        }
    
    }
    
    

    1.1 处理方式1: 将先验条件失败传给调用者

    子类的实现方式,如果不满足先验条件就抛异常:

    public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer {
    
        protected GrumpyBoundedBuffer(int capacity) {
            super(capacity);
        }
    
        /*
        * 添加元素,如果缓存满了抛异常
        * */
        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 (V) doTake();
        }
    }
    
    

    这种方式虽然简单,但是使用起来很麻烦.需要时刻捕获异常.而且还需要调用者重新调用这个方法,重新尝试put/take.

    在客户端调用take方法:

    public static void main(String [] args) throws InterruptedException {
            GrumpyBoundedBuffer grumpyBoundedBuffer = new GrumpyBoundedBuffer();
            //循环调用
            while(true){
                try{
                    //获得对象v,如果成功break,跳出循环
                    //如果失败,捕获异常休息1秒
                    Object v = grumpyBoundedBuffer.take();
                    break;
                } catch (BufferEmptyException e){
                //抛异常以后,休眠一段时间.再尝试
                    Thread.sleep(1000);
                }
            }
    
        }
    

    上面的代码,在调用失败的去情况下会选择休眠一段时间,然后重新尝试.也可以选择不休眠的方式---被称为忙等待自旋等待.

    两种方式各有利弊:

    休眠: 可以避免消耗过多的CPU时间,但是容易睡过头,引发响应慢的问题.

    自旋等待: 短时间比较适合用这种方式,但是长时间会浪费系统的资源.

    所以,客户端代码身处于自旋产生的低CPU使用率和休眠产生的弱响应性之间的两难境地.

    有一种折中的方式是使用Thread.yield方法.让当前线程让出一定的时间给其他线程运行.

    1.2 处理方式2:利用"轮询加休眠"实现拙劣的阻塞

    更好点的方式,"轮询加休眠":

    public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
        protected SleepyBoundedBuffer(int capacity) {
            super(capacity);
        }
    
    
        public void put(V v) throws InterruptedException {
            //无限尝试将v添加入集合
                while(true){
                    //获得锁
                    synchronized (this){
                        //如果不空,就添加进集合,退出循环
                            if(!isFull()){
                                doPut(v);
                                return;
                            }
                    }
                    //否则释放锁,休眠一段时间,给其他线程一些修改的机会.
                    Thread.sleep(1000);
                }
        }
    
        public V take() throws InterruptedException {
            while(true){
                synchronized (this){
                    if(!isEmpty()){
                        return doTake();
                    }
                }
                Thread.sleep(1000);
            }
        }
    }
    

    使用这种方式,调用者不必像之前那样处理失败和重试.

    选择休眠的时间间隔,是在响应性与CPU使用率之间作出的权衡;

    休眠的间隔越小,响应性越好,但是CPU的消耗也越高.

    休眠间隔是如何影响响应性的:
    image
    缓存空间变为可用的时刻与线程被唤醒并在此检查的时刻之间可能有延迟.

    这就是使用这种方式的弊端,现在有一种更好的方式,条件队列(condition queue).

    条件队列可以让线程挂起,并且能够保证当某个条件成为真时,线程可以及时地苏醒过来.

    1.3 让条件队列来解决一切

    条件队列可以让一组线程--称作等待集--以某种方式等待相关条件变成真.它也由此得名.

    不同于传统的队列,条件队列里面放的是等待相关条件的线程. 存放的是线程-画重点

    就像每个Java对象都能当做锁一样,每个对象也能当做条件队列.Object的wait、notify、notifyAll方法构成了内部条件队列的API.

    一个对象的内部锁与它的内部条件队列是相关的: 为了能够调用对象X中的任一个条件队列方法,必须持有对象X的锁(也就是说在synchronized块中调用wait啊,notify啊,notifyAll啊这些方法,不在锁里调用会报错).

    这是因为"等待基于状态的条件"机制必须和"维护状态一致性"机制紧密地绑定在一起:除非你能检查状态,否则你不能等待条件(这里后面,看代码就明白了,说的就是有if/while的条件判断后面才能跟上wait方法),同时,除非你能改变状态,否则你不能从等待(队列)中释放其他的线程(这里说的就是,改变了先验条件的状态,才能调用notify或notifyAll方法);

    先简单的介绍一下wait和notify、notifyAll方法的使用:

    1. wait、notify和notifyAll都是Object类的方法.也就是说每个类都有这三个方法.
    2. 使用这三个方法的时候,一定要在被锁保护的代码块中,否则会报java.lang.IllegalMonitorStateException.
    3. wait和notify、notifyAll是配合使用的. 调用wait方法会挂起线程,释放锁,给其它线程一些机会,让前验条件变为真;notify/notifyAll会唤醒挂起的线程,获取锁,继续执行.

    使用条件队列实现的方式:

    public class BoundedBuffer<V> extends BaseBoundedBuffer <V>{
        protected BoundedBuffer(int capacity) {
            super(capacity);
        }
    
        /*
        * 注意这里的synchronized,
        * 是必须的否则运行会报java.lang.IllegalMonitorStateException
        * 作用是检查前验条件时保护状态的一致性.不会读到过期数据
        * */
        public synchronized void put(V v) throws InterruptedException {
            /*注意这里这里是while循环
            * 不是单单一个简单的if,这么做有两个理由
            *1. 因为从notify/notifyAll通知的这段时间
            * 很有可能前验条件条件又由真变为假.所以循环判断一次是有必要的
            * 2. notify/notifyAll的区别,notify是选取一个条件队列中的线程通知,
            * 而notifyAll则是通知所有的条件队列,当有多个前验条件时,可能有一些没有通过前验条件的也会被通知
            * 所以需要再次判断
            * */
            while(isFull()){
                /*挂起当前线程,释放锁,给其他线程一些机会
                * 使前验条件为真
                * */
                wait();
            }
            /*存入数据*/
            doPut(v);
            /*通知,告诉下面的take方法里面已经有数据了*/
            notifyAll();
        }
    
        public synchronized V take() throws InterruptedException {
            while (isEmpty()){
                wait();
            }
            V v = doTake();
            notifyAll();
            return v;
        }
    
    }
    

    注解说的很详细了,注意两个方法都是被锁保护的,还有使用while循环而不是用if的理由.

    这与之前的"轮询加休眠"方式相比更高效,响应性更佳(不会"睡过头").

    wait方法也有限时的版本,为了避免死锁的问题,可以使用限时版本的wait.

    2.使用条件队列

    使用Java提供的类,比你自己去创建一个类要好,因为它经历了重重考验,证明了自己,而且考虑到方便性、简单性你也应该这么做.

    但是有时候类库没有提供我们需要的同步工具,所以我们必须使用条件队列自己构建一个同步工具,这时一定要小心谨慎,因为它很容易被用错.

    接下来看看使用条件队列的一些注意事项.

    2.1 条件谓词

    正确使用条件队列的关键在于识别出对象可以等待的条件谓词.

    条件谓词是先验条件的第一站,它在一个操作与状态之间建立起依赖关系.

    举个例子,什么是条件谓词:
    在有限缓存中,只有缓存不为空时take才能执行,否则它必须等待.就take而言,它的条件谓词是"缓存不空",
    类似的,put的条件谓词是"缓存不满".

    条件谓词是由类的状态变量构成的表达式.

    看看之前的代码:

        //判断是否满了
        public synchronized  final boolean isFull(){
            //如果当前的容量count == 缓存的长度,那就是满了,返回true
            return count == buf.length;
        }
    
        //判断是否为空
        public synchronized  final boolean isEmpty(){
            return count == 0 ;
        }
    

    count == buf.length; 和 count == 0 ; 就是两个条件谓词

    将条件谓词和与之关联的条件队列,以及在条件队列中等待的操作,都写入文档.
    

    在涉及了加锁、wait方法和条件谓词的条件等待中,存在着一种非常重要的三元关系.条件谓词涉及状态变量,而状态变量是由锁保护的,所以在测试条件谓词之前,我们必须先持有锁.锁对象与条件队列对象(wait和notify方法调用的对象)必须也是同一个对象.

    每次调用wait都会隐式地与特定的条件谓词相关联.
    
    当调用特定条件谓词的wait时,调用者必须已经持有了与条件队列相关的锁,这个锁必须同时还保护着组成条件谓词的状态变量.
    

    2.2 过早地唤醒

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

    一个单独的内部条件队列可以与多个条件谓词共同使用.当有人调用notifyAll,从而唤醒了你的线程时,并不意味着你正在等待的条件谓词现在变成真了.wait甚至可以"假装"返回--不作为对任何线程调用notify的响应.(这就好比烤面包机的线路连接有问题,导致面包尚未烤好,铃声就自己响起来了).

    当控制流重新进入调用wait的代码时,它会重新请求与条件队列相关联的锁.但是这时条件谓词不一定为真,有两种可能:

    1. 在notify/notifyAll通知的这段时间很有可能条件谓词又由真变为假.
    2. notify是选取一个条件队列中的线程通知,而notifyAll则是通知所有的条件队列,所以被notifyAll通知的wait有可能前验条件不为真.

    所以调用wait的地方,要是用while(前验条件)进行循环判断.

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

    • 永远设置一个条件谓词---一些对状态的测试,线程执行前必须满足它;
    • 永远在调用wait前测试条件谓词,并且从wait中返回后再次测试;
    • 永远在循环中调用wait;
    • 确保构成条件谓词的状态变量被锁保护,而这个锁正是与条件队列相关联的;
    • 当调用wait、notify或者notifyAll时,要持有与条件队列相关联的锁;并且,
    • 在检查条件谓词之后、开始执行被保护的逻辑之前,不要释放锁.

    2.3 丢失的信号

    死锁和活锁是活跃度失败的一种形式,另一种活跃度失败的形式是丢失的信号(missed signal).

    当一个线程等待的特定条件为真,但是进入等待前检查条件谓词却返回了假,我们称这样就出现了一个丢失的信号.

    线程在等待一个已经通知过的消息,它有可能永远等不到这个消息.

    例如: 未能在调用wait之前先检测条件谓词,就会导致信号的丢失.

    但是使用while()循环的方式,可以避免这种情况的发生.

    2.4 通知(notify)

    无论何时,当你在等待一个条件,一定要确保有人会在条件谓词变为真时通知你.
    

    在条件队列API中有两个方法--notify和notifyAll.无论调用哪一个,你都必须持有与条件队列对象相关联的锁.

    调用notify的结果是:JVM会从在这个条件队列中等待的众多线程中挑选一个,并把它唤醒;

    调用notifyAll会唤醒所有正在这个条件队列中等待的线程.

    notify/notifyAll应该尽快释放锁,以确保在wait处阻塞的线程尽可能快的解除阻塞.

    由于会有多个线程因为不同的原因在同一个条件队列中等待,因此不用notifyAll而使用notify是危险的.这主要是因为单一的通知容易导致同类的线程丢失全部信号.

    notifyAll在大多数情况下都是由于notify的选择.

    举个例子:
    假设线程A因为谓词PA而在条件队列中等待,同时线程B因为谓词PB也在同一个条件队列中等待.
    现在假设PB变成真,线程C执行一个单一的notify:JVM将从它所拥有的众多线程中选择一个并唤醒,如果A被选中,它随后被唤醒,看到PA尚未变成真,转而继续等待.期间本应该可以执行的B却没有被唤醒.这不是严格意义上的"丢失信号"--它更像一个"被劫持的(hijacked)"信号---不过问题是一样的:线程正在等待一个已经(或者本应该)发生过的信号.

    使用notify取代notifyAll的情况:

    相同的等待者,只有一个条件谓词与条件队列相关,每个线程从wait返回后执行相同的逻辑,并且,一进一出,一个队条件变量的通知,至多只激活一个线程执行.
    

    大多数类都不满足这些条件,因此普遍认可的做法是优先使用notifyAll,而不是单一的notify.尽管使用notifyAll而非notify可能有些低效,但是这样做更容易确保你的类的行为是正确的.

    我们可以将之前的put和take操作进行优化,之前是每次put/take时通知,现在可以先检查是否已经为空/满然后在进行通知:

      public synchronized V take() throws InterruptedException {
            while (isEmpty()){
                wait();
            }
            V v = doTake();
            boolean wasFull = isFull();
            //如果满了,才通知
            if(wasFull){
                notifyAll();
            }
            return v;
        }
    

    尽管"依据条件通知"可以提升性能,但它毕竟只是一种小技巧(而且还让子类的实现变得复杂),应谨慎使用.

    单一的通知(notify)和"依据条件通知"都是优化行为.通常进行优化时应该遵循"先让它跑起来,再让它快起来--如果它还没有足够快"的原则:错误地进行优化很容易给程序带来无法预料的活跃度失败.

    2.5 入口协议和出口协议

    对于每个依赖于状态的操作,以及每个修改了其他状态的操作(对于每一个修改状态的操作,并且其他操作对该状态有状态依赖),都应该为其定义并文档化一个入口协议和出口协议.

    入口协议就是操作的条件谓词;

    出口协议涉及到要检查任何被操作改变的状态变量,确认它们是否引起其他一些条件谓词变为真,如果是,通知相关的条件队列.

    AbstractQueuedSynchronizer采用了出口协议的概念,位于java.util.concurrent包下的大部分状态依赖类都构建于它之上.

    它没有让Synchronizer类自己去执行通知,而是要求同步方法返回一个值,让这个值说明它的动作是否可能已经阻塞了一个或多个线程.这种显示API的要求,可以避免发生在某些状态转换的过程中"忘记"执行通知.

    3. 显示的Condition对象

    Condition是具体的内部条件队列,和显示锁在某种角度上看差不多.

    内部条件队列有一些缺陷.每个内部锁只能有一个与之相关联的条件队列,这意味着多个线程可能为了不同的条件谓词在同一个条件队列中等待,而且大多数常见的锁模式都会暴露条件队列对象.

    如果你想编写一个含有多个条件谓词的并发对象,或者你想获得比条件队列的可见性之外更多的控制权,那么显示的Lock和Condition的实现类提供了一个比内部锁和条件队列更加灵活的选择.

    一个Condition和一个单独的Lock相关联,就像条件队列和单独的内部锁相关联一样;

    调用与Condition相关联的Lock的Lock.newCondition方法,可以创建一个Condition.

    如同Lock提供了比内部加锁要丰富得多的特征集一样,Condition也提供了比内部条件队列要丰富得多的特征集:每个锁可以有多个等待集(因await挂起的线程的集合)、可中断/不可中断的条件等待、基于时限的等待以及公平/非公平队列之间的选择.

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

    注意事项!!!:

    wait、notify和notifyAll在Condition对象中的对等体是await、signal和signalAll.
    
    但是,Condition继承与Object,这意味着它也有wait和notify方法.
    
    一定要确保使用了正确的版本--await和signal!
    

    使用condition的实例:

    public class ConditionBoundedBuffer<T> {
        protected  final Lock lock = new ReentrantLock();
    
        private final Condition notFull = lock.newCondition();
    
        private final Condition notEmpty = lock.newCondition();
    
        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();
                }
                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();
            }
            T x = items[head];
            items[head] = null;
            if( ++head == items.length){
                head = 0;
            }
            -- count;
                notFull.signal();
                return  x;
            }finally {
                lock.unlock();
            }
    
        }
    }
    
    

    使用两个Condition,notFull和notEmpty,明确地表示"非满"与"非空"两个条件谓词.

    使用Condition的方式具有更好的可读性.Condition简化了使用单一通知的条件.使用更有效的signal,而不是signalAll,这就会减少相当数量的上下文切换,而且每次缓存操作都会出发对锁的请求.

    就像内置的锁和条件队列一样,当使用显示的Lock和Condition时,也必须要满足锁、条件谓词和条件变量之间的三元关系:

    涉及条件谓词的变量必须由Lock保护,检查条件谓词时以及调用await和signal时,必须持有Lock对象.
    

    显示的Condition和内部条件队列之间的选择:

    与在ReentrantLcok和Synchronized之间进行选择是一样的:如果你需要使用一些高级特性,比如公平队列或者让每个锁对应多个等待集,这时使用Condition要好于使用内部条件队列.(如果你需要使用ReentrantLock的高级特性,并已在使用它,那么你已经做出来选择.)
    

    4. 剖析Synchronizer

    ReentrantLock和Semaphore 有很多共同点,扮演了"阀门"的角色,每次只允许有限条目的线程通过它;

    线程到达阀门后,可以允许通过(lock或acquire成功返回),可以等待(lock或acquire阻塞),也可以被取消(tryLock或tryAcquire返回false,指明在允许的时间内,锁或者"许可"不可用).

    更进一步,它们都允许可中断的、不可中断的、可限时的请求尝试,它们也都允许选择公平、非公平的等待线程队列.

    之所以有这么多的共同点,是因为它们的实现都用到了一个共同的基类,AbstractQueuedSynchronizer(AQS)

    AQS是一个用来构建锁和Synchronizer的框架.使用AQS能够简单且高效的构造出应用广泛的大量的Synchronizer.

    不仅ReentrantLock和Semaphore是构建于AQS的,其他的还有CountDownLatch、ReentrantReadWriteLock、SynchronousQueue和FutureTask.

    一个使用内部锁,实现semaphore功能的例子:

    public class SemaphoreOnLock {
        private Lock lock = new ReentrantLock();
        private Condition condition = lock.newCondition();
        private  int permit;
    
        public SemaphoreOnLock(int permit) {
            lock.lock();
            try {
                //条件谓词加锁保护
                this.permit = permit;
            }finally {
                lock.unlock();
            }
    
        }
    
        public void acquire() throws InterruptedException {
            lock.lock();
            try {
                //没有许可集就阻塞
                if(permit<=0){
                    condition.await();
                }
                -- permit;
    
            }finally {
                lock.unlock();
            }
        }
    
    
        public void release(){
            lock.lock();
            try {
                if (permit<=0){
                    condition.signal();
                }
                ++permit;
            }finally {
                lock.unlock();
            }
    
        }
    }
    
    

    使用Semaphore也同样可以实现内部锁的功能,把许可集设置为1.

    在SemaphoreOnLock中,请求许可的操作在两个地方可能会阻塞:

    1. 信号量的状态正在被锁保护着
    2. 许可不可用时

    使用AQS构建的Synchronizer只可能在一个点上发生阻塞,这样降低了上下文的开销,并提高了吞吐量.

    5. AbstractQueuedSynchronizer

    一个基于AQS的Synchronizer所执行的基本操作,是一些不同形式的获取(acquire)和释放(release).

    获取操作是状态依赖的操作,总能够阻塞:以synchronized和semaphore举例,获取就是获取锁或者许可,并且调用者可能不得不去等待,直到Synchronizer处于可发生的状态.

    • CountDownLatch的获取意味着"等待,直到闭锁到达它的终止态"
    • FutureTask则意味着"等待,直到任务已经完成".

    "释放"不是一个可阻塞的操作:"释放"可以允许线程在请求执行前阻塞.(被获取阻塞)

    AQS管理同步类中的状态:它管理一个关于状态信息的单一整数(比如返回负数代表错误,0代表独占锁,正数代表非独占锁),状态信息可以通过protected类型的getState,setState和compareAndSetState等方法操作.(compareAndSetState下篇博客会详细介绍)

    不同的同步装置用状态表达不同的意思:

    • ReentrantLock用它来表现拥有它的线程已经请求了多少次锁
    • Semaphore用它来表现剩余的许可数
    • FutureTask用它来表现任务的状态(尚未开始、运行、完成和取消).

    Synchronizer也可以自己管理一些额外的状态变量:

    • ReentrantLock保存了当前锁的所有者的追踪信息,这样它就能区分出是重进入的(reentrant)还是竞争的(contended)条件锁.

    AQS的获取操作可能是独占的,例如ReentrantLock,同一时刻只能有一个线程获取;也可能是非独占的,就像Semaphore和CountDownLatch一样.这取决于不同的Synchronizer.

    一个获取分为两步:

    1. Synchronizer判断当前状态是否被获得;如果是,就让线程执行,如果不是,获取操作阻塞或失败.例如:想获取锁,锁必须是未被占有的;而如果想成功地获取闭锁,闭锁必须未处于终止状态.

    2. 获取同步装置以后,可能需要更新同步装置的状态;一个想获取Synchronizer的线程会影响到其他线程是否能够获取它.例如:

    • 获取锁的操作将锁的状态从"未被占有"改变为"已被占有";
    • 从Semaphore中获取许可的操作会减少剩余许可的数量.
    • 另一方面,一个线程对闭锁的请求操作却不会影响到其它线程是否能够获取他,所以获取闭锁的操作不会改变闭锁的状态(前面两个锁和信号量,都会在消耗完之后阻止其他线程继续获取,但是闭锁可以无限获取,不影响其他线程)

    支持独占获取的Synchronizer应该实现tryAcquire、tryRelease和isHeldExclusively这几个受保护的方法.

    支持共享获取的Synchronizer应该实现tryAcquireShared和tryReleaseShared.

    Synchronizer的子类会根据其acquire和release的语意,使用getState、setState以及compareAndSetState来检查并更新状态,然后通过返回的状态值告诉基类这次"获取"或"释放"的尝试是否成功.

    ReentrantLock的内部类Sync,注意它的nonfairTryAcquire和tryRelease,
    都是根据getState来进行判断:

    abstract static class Sync extends AbstractQueuedSynchronizer {
            private static final long serialVersionUID = -5179523762034025860L;
    
            /**
             * Performs {@link Lock#lock}. The main reason for subclassing
             * is to allow fast path for nonfair version.
             */
            abstract void lock();
    
            /**
             * Performs non-fair tryLock.  tryAcquire is implemented in
             * subclasses, but both need nonfair try for trylock method.
             */
            final boolean nonfairTryAcquire(int acquires) {
                final Thread current = Thread.currentThread();
                int c = getState();
                if (c == 0) {
                    if (compareAndSetState(0, acquires)) {
                        setExclusiveOwnerThread(current);
                        return true;
                    }
                }
                else if (current == getExclusiveOwnerThread()) {
                    int nextc = c + acquires;
                    if (nextc < 0) // overflow
                        throw new Error("Maximum lock count exceeded");
                    setState(nextc);
                    return true;
                }
                return false;
            }
    
            protected final boolean tryRelease(int releases) {
                int c = getState() - releases;
                if (Thread.currentThread() != getExclusiveOwnerThread())
                    throw new IllegalMonitorStateException();
                boolean free = false;
                if (c == 0) {
                    free = true;
                    setExclusiveOwnerThread(null);
                }
                setState(c);
                return free;
            }
    
            protected final boolean isHeldExclusively() {
                // While we must in general read state before owner,
                // we don't need to do so to check if current thread is owner
                return getExclusiveOwnerThread() == Thread.currentThread();
            }
    
            final ConditionObject newCondition() {
                return new ConditionObject();
            }
    
            // Methods relayed from outer class
    
            final Thread getOwner() {
                return getState() == 0 ? null : getExclusiveOwnerThread();
            }
    
            final int getHoldCount() {
                return isHeldExclusively() ? getState() : 0;
            }
    
            final boolean isLocked() {
                return getState() != 0;
            }
    
            /**
             * Reconstitutes the instance from a stream (that is, deserializes it).
             */
            private void readObject(java.io.ObjectInputStream s)
                throws java.io.IOException, ClassNotFoundException {
                s.defaultReadObject();
                setState(0); // reset to unlocked state
            }
        }
    

    下面的代码中的tryAcquireShared返回不同的值,代表不同的结果

    • 从tryAcquireShared返回一个负数,说明获取操作失败;
    • 返回零说明Synchronizer是被独占获取的;
    • 返回正值说明Synchronizer是被非独占获取的.
    static final class FairSync extends Sync {
            private static final long serialVersionUID = 2014338818796000944L;
    
            FairSync(int permits) {
                super(permits);
            }
    
            protected int tryAcquireShared(int acquires) {
                for (;;) {
                    if (hasQueuedPredecessors())
                        return -1;
                    int available = getState();
                    int remaining = available - acquires;
                    if (remaining < 0 ||
                        compareAndSetState(available, remaining))
                        return remaining;
                }
            }
        }
    

    对于tryRelease和tryReleaseShared方法来说,如果能够释放一些正在尝试获取Synchronizer的线程,解除这些线程的阻塞,那么这两个方法会返回true.

    5.1 一个简单的闭锁

    public class OneShotLatch {
        private final Sync sync = new Sync();
    
        public void signal(){
            sync.releaseShared(0);
        }
    
    
        public void await() throws InterruptedException {
            //此方法会请求tryAcquireShared()
            sync.acquireSharedInterruptibly(0);
        }
    
    
        private class Sync extends AbstractQueuedSynchronizer{
    
            @Override
            protected int tryAcquireShared(int ignored){
                // 如果闭锁打开则成功(state == 1),否则失败
                return (getState() == 1) ? 1 : -1;
            }
    
            @Override
            protected boolean tryReleaseShared(int ignored){
                //闭锁现在已打开
                setState(1);
                //现在其他线程可以获得比索
                return true;
            }
        }
    }
    

    最初闭锁是关闭的;任何调用await的线程都会阻塞,直到打开闭锁. 一旦闭锁被一个signal调用打开,等待中的线程就会被释放,而且随后到达闭锁的线程也会被允许执行.

    signal方法详解:

    调用signal方法,调用releaseShare把闭锁的状态打开, 通过返回值表明Synchronizer处于完全被释放的状态.让AQS要求所有等待中的线程尝试去重新请求Synchronizer,并且由于tryAcquireShared会返回成功,所以这次请求会成功.

    通过AQS提供的限时版本的获取方法,可以给OneShotLatch提供显示的请求操作以及检查闭锁状态的能力.

    以上的方法是通过委托实现的,直接扩展AQS也是可以的,但是存在其弊端:

    • 破坏OneShotLatch接口的简洁性(只有两个方法)
    • 虽然AQS的公共方法不允许调用者破坏闭锁的状态,调用者仍然很容易误用它.

    java.util.concurrent中没有一个Synchronizer是直接扩展AQS的,它们都委托了AQS的私有内部子类.

    6. java.util.concurrent的Synchronizer类中的AQS

    简单的看看这些类是如何使用AQS的

    6.1 ReentrantLock

    ReentrantLock使用同步状态持有锁获取操作的计数,还维护一个owner变量来持有当前拥有的线程标识符.

    只有当前线程刚刚获取到锁,或者刚刚释放了锁的时候,才会修改owner.

    非公平的ReentrantLock中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;
            }
            return false ;
        }
    

    在tryRelease中,它检查owner域以确保当前线程在执行一个unlock操作之前,已经拥有了锁;

    在tryAcquire中,它使用这个域来区分重进入的获取操作尝试与竞争的获取操作尝试.

    当一个线程尝试获取锁时,tryAcquire会首先请求锁的状态:

    • 如果锁未被占有,它会尝试更新锁的状态,表明锁被占有.因为状态可能在被观察后的几条指令中被修改,所以tryAcquire使用compareAndSetState来尝试原子地更新状态,表明这个锁已经被占有,并确保状态自最后一次观察后没有被修改过.
    • 如果锁状态表明它已经被占有,如果当前线程是锁的持有者,那么获取操作计数会递增;如果当前线程不是锁的持有者,那么获取操作的尝试会失败.

    6.2 Semaphore和CountDownLatch

    Semaphore使用AQS类型的同步状态持有当前可用许可的数量,tryAcquireShared方法首先计算剩余许可的数量

    • 如果没有足够的许可,会返回一个值,表明获取操作失败.
    • 如果还有充足的许可剩余,tryAcquireShared会使用compareAndSetState,尝试原子地递减许可的计数.

    如果成功会返回一个值,表明获取操作成功.

    返回值同样加入了是否允许其他共享获取尝试能否成功的信息,如果可以的话,其他等待的线程同样会解除阻塞.

    无论是没有足够的许可,还是tryAcquireShared可以原子地更新许可数,以响应获取操作,while循环都会终止.尽管任何给定的compareAndSetState调用,都可能由于与另一个线程的竞争而失败,这使它会重试,在重试过合理的次数后,两个终止条件的一个会变成真.

    类似地,tryReleaseShared会递增许可计数,这会潜在地解除等待中的线程的阻塞,不断地重试直到成功地更新.tryReleaseShared的返回值表明,释放操作是否可以解除其它线程的阻塞.

    Semaphore的tryAcquireShared和tryAcquireShared方法:

    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;
                }
            }
        }
    

    CountDownLatch使用AQS的方式与Semaphore相似:同步状态持有当前的计数.

    countDown方法调用release,后者会导致计数器递减,并且在计数器已经到达零的时候,解除所有等待线程的阻塞,release无法阻塞线程,也就是无论调用多少次countDown方法都不会阻塞线程,只有调用await的时候,并且未消耗点许可集的时候,才会造成阻塞;await调用acquire,如果计数器已经到达零,acquire会立即返回,否则它会被阻塞.

    6.3 FutureTask

    Future.get的语意非常类似于闭锁--如果发生了某些事件(FutureTask表现的任务的完成或取消),线程就可以执行,否则线程会留在队列中,直到有事件发生.

    FutureTask使用AQS类型的同步状态来持有任务的状态--运行、完成或取消.

    FutureTask也维护了一些额外的状态变量,来持有计算的结果或者抛出的异常.它还维护了一个引用,指向正在运行计算任务的线程(如果它当前正处于运行状态),这样如果任务被取消,就可以中断该线程.

    6.4 ReentrantReadWriteLock

    ReadWriteLock的接口要求了两个锁---一个读者锁和一个写者锁.

    但是在基于AQS的ReentrantReadWriteLock实现中,一个单独的AQS子类管理了读和写的加锁.

    ReentrantReadWriteLock使用一个16位的状态为写锁(write-lock)计数,使用另一个16位的状态为读锁(read-lock)计数.

    对读锁的操作使用共享的获取与释放的方法;对写锁的操作使用独占的获取与释放的方法.

    AQS在内部维护一个等待线程的队列,持续追踪一个线程是否被独占请求,或者被共享访问.

    在ReentrantReadWriteLock中,当锁可用时,如果位于队列头部的线程同时也正在准备访问,线程会得到锁;

    如果位于队列头部的线程正在准备访问,那么队列中所有首个线程之前的线程都会得到锁.

    总结

    如果你需要实现一个依赖于状态的类---如果不能满足依赖于状态的前提条件,类的方法必须阻塞.

    最佳的策略通常是将它构建于现有的库类之上,比如Semaphore、BlockingQueue或者CountDownLatch.

    但是,有时现有的库类不能提供足够的功能;在这种情况之下,你可以使用内部条件队列、显式Condition对象或者AbstractQueuedSynchronizer,来构建属于自己的Synchronizer.

    由于"管理状态的独立性"机制必须紧密依赖于"确保状态一致性"机制,所以内部条件队列与内部锁紧密地绑定到了一起.

    类似地,显式的Condition与显示的Lock也是紧密地绑定到一起的,相比于内部条件队列,它还提供了一个可扩展的特征集,包括"多等待集每锁",可中断或不可中断的条件等待,公平或非公平的队列,以及基于最终时限的等待.

  • 相关阅读:
    2020年软件测试需要学什么技术?软件测试人员需要懂代码?软件测试工程师要经常加班吗?
    那些价值数亿的史上最强的【Bug之母】你都知道吗?
    接口自动化测试之-requests模块详解
    linux更新源管理
    Vscode的使用小技巧
    半吊子菜鸟学Web开发 -- PHP学习5-数据库
    半吊子菜鸟学Web开发 -- PHP学习 1-基础语法
    半吊子菜鸟学Web开发 -- PHP学习 4 --异常
    半吊子菜鸟学Web开发 -- PHP学习3-文件
    半吊子菜鸟学Web开发 -- PHP学习2-正则,cookie和session
  • 原文地址:https://www.cnblogs.com/xisuo/p/9950665.html
Copyright © 2011-2022 走看看