【Java并发编程】之AQS

概述

虽然synchronized提供了便捷性的隐式获取锁释放锁机制(基于JVM机制),但是它却缺少了获取锁与释放锁的可操作性,可中断、超时获取锁,且它为独占式在高并发场景下性能大打折扣。

AQS:AbstractQueuedSynchronizer,即队列同步器。它是构建锁或者其他同步组件的基础框架。它提供了一套完整的同步编程框架,开发人员只需要实现其中几个简单的方法就能自由的使用诸如独占,共享,条件队列等多种同步模式。

ReentrantLock、 ReadWriteLock都是基于AQS来构建.然而这些锁都没有直接来继承AQS,而是定义了一个Sync类去继承AQS.那么为什么要这样呢?because:锁面向的是使用用户,而同步器面向的则是线程控制,那么在锁的实现中聚合同步器而不是直接继承AQS就可以很好的隔离二者所关注的事情.

State和同步队列

AQS有state和node两个比较重要的成员变量。注意,他有一个Head节点。

这是某个线程获取锁时候设置head,也就是说Head中的thread,prev都是null,他只是一个标志节点,并不是一个等待获取锁的线程。

State

AQS使用一个int类型的成员变量state(volatile修饰的)来表示同步状态,当state>0时表示已经获取了锁,当state = 0时表示释放了锁。它提供了三个方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))来对同步状态state进行操作,当然AQS可以确保对state的操作是安全的。

同步队列

AQS通过内置的FIFO同步队列来完成资源获取线程的排队工作,如果当前线程获取同步状态失败(锁)时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。

static final class Node {
    //该等待同步的节点处于共享模式
    static final Node SHARED = new Node();
    //该等待同步的节点处于独占模式
    static final Node EXCLUSIVE = null;

    //等待状态,这个和state是不一样的:有1,0,-1,-2,-3五个值
    volatile int waitStatus;
    static final int CANCELLED =  1;
    static final int SIGNAL    = -1;
    static final int CONDITION = -2;
    static final int PROPAGATE = -3;

    volatile Node prev;//前驱节点
    volatile Node next;//后继节点
    volatile Thread thread;//等待锁的线程
    //和节点是否共享有关
    Node nextWaiter;
    //Returns true if node is waiting in shared mode
    final boolean isShared() {
            return nextWaiter == SHARED;
        }

CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理。

在CLH同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next)。

下面解释下waitStatus五个的得含义:

  • CANCELLED(1):该节点的线程可能由于超时或被中断而处于被取消(作废)状态,一旦处于这个状态,节点状态将一直处于CANCELLED(作废),因此应该从队列中移除.
  • SIGNAL(-1):当前节点为SIGNAL时,后继节点会被挂起,因此在当前节点释放锁或被取消之后必须被唤醒(unparking)其后继结点.
  • CONDITION(-2) 该节点的线程处于等待条件状态,不会被当作是同步队列上的节点,直到被唤醒(signal),设置其值为0,重新进入阻塞状态.
  • 0:新加入的节点

同步状态的获取与释放

在锁的获取时,并不一定只有一个线程才能持有这个锁(或者称为同步状态),所以此时有了独占模式和共享模式的区别,也就是在Node节点中由nextWait来标识。比如ReentrantLock就是一个独占锁,只能有一个线程获得锁,而WriteAndReadLock的读锁则能由多个线程同时获取,但它的写锁则只能由一个线程持有。

这个类使用到了模板方法设计模式:定义一个操作中算法的骨架,而将一些步骤的实现延迟到子类中。

独占式获取锁

独占式,同一时刻仅有一个线程持有同步状态。

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

 

该方法首先尝试获取锁( tryAcquire(arg)的具体实现定义在了子类中),如果获取到,则执行完毕,否则通过addWaiter(Node.EXCLUSIVE), arg)方法把当前节点添加到等待队列末尾,并设置为独占模式,

private Node addWaiter(Node mode) {
   //根据传入的模式(独占or共享)创建Node对象;
    Node node = new Node(Thread.currentThread(), mode);
    
    Node pred = tail;
   //如果pred不为空,说明有线程在等待
   //尝试使用CAS入列,如果入列失败,则调用enq采用自旋的方式入列
   //该逻辑在无竞争的情况下才会成功,快速入列
    if (pred != null) {
       //所谓的入列,就是将节点设置为新的tail节点
       //注意:有可能设置node的前节点成功,但是CAS更新失败;
       //这种情况下,由于无法从head或tail找到节点,问题不大;
       //但是对于isOnSyncQueue这种方法,则会造成影响,需要特殊处理
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {//通过CAS更新tail节点,关于CAS,后面会专门写篇文章介绍
           //将原tail节点的后节点设置为新tail节点
           //由于CAS和设置next不是原子操作,因此可能出现更新tail节点成功,但是未执行pred.next = node,导致无法从head遍历节点;
           //但是由于前面已经设置了prev属性,因此可以从尾部遍历;
           //像getSharedQueuedThreads、getExclusiveQueuedThreads都是从尾部开始遍历
            pred.next = node;
            return node;
        }
    }
    enq(node);//通过自旋入列
    return node;
}

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;//记录尾节点
        if (t == null) { //由于采用lazy initialize,当队列为空时,需要进行初始化
            //通过CAS设置head和tail节点
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;//将node的前节点设置为原tail节点
            if (compareAndSetTail(t, node)) {//CAS更新tail节点,更新成功则将原tail节点的后节点设置为node,返回原tail节点,入列成功;
                t.next = node;
                return t;
            }
        }
    }
}

采用CAS+自旋的操循环入队。

这一段代码,跟enq中的一部分重复,为什么要单拿出来呢?

其实这种做法在并发编程中还挺常见,把最有可能成功执行的代码直接写在最常用的调用处,和C++中的inline方法一个意思。具体到本例子,红框中的代码是最有可能一次成功的,因为在线程数不多的情况下,CAS还是很难失败的。因此这种写法可以节省多条指令。因为调用enq需要一次方法调用,进入循环,比较null,然后才到了红框中一样的代码。
总而言之,节省指令,提高效率。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;//未发生中断
        //仍然通过自旋,根据前面的逻辑,此处传入的为新入列的节点
        for (;;) {
            final Node p = node.predecessor();//获取前节点,即prev指向节点
           //如果node的前一节点为head节点,而head节点为空节点,说明node是等待队列里排在最前面的节点
            if (p == head && tryAcquire(arg)) {
              //获取资源成功,将node设置为头节点,setHead清空节点属性thread,prev
                setHead(node);
                p.next = null; // 将原头节点的next设为null,帮助GC
                failed = false;
                return interrupted;//返回是否发生中断
            }
            //如果acquire失败,是否要park,如果是则调用LockSupport.park
            //如果在acquireQueued()中当前线程被中断过,则需要产生一个中断。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;//发生中断
        }
    } finally {
        if (failed)//只有循环中出现异常,才会进入该逻辑
            cancelAcquire(node);
    }
}

在把node插入队列末尾后,它并不立即挂起该节点中线程,因为在插入它的过程中,前面的线程可能已经执行完成,所以它会先进行自旋操作acquireQueued(node, arg),尝试让该线程重新获取锁!当条件满足获取到了锁则可以从自旋过程中退出,否则继续。

AQS的模板方法acquire通过调用子类自定义实现的tryAcquire获取同步状态失败后->将线程构造成Node节点(addWaiter)->将Node节点添加到同步队列对尾(addWaiter)->节点以自旋的方法获取同步状态(acquirQueued)。在节点自旋获取同步状态时,只有其前驱节点是头节点的时候才会尝试获取同步状态,如果该节点的前驱不是头节点或者该节点的前驱节点是头节点但获取同步状态失败,则判断当前线程需要阻塞,如果需要阻塞则需要被唤醒过后才返回。

独占锁的释放

既然是释放,那肯定是持有锁的该线程执行释放操作,即head节点中的线程释放锁.

public final boolean release(int arg) {
if (tryRelease(arg)) {
        Node h = head;
if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
return true;
    }
return false;
}
/**如果node的后继节点不为空且不是作废状态,则唤醒这个后继节点,否则从末尾开始寻找合适的节点,如果找到,则唤醒*/
private void unparkSuccessor(Node node) {
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread);
    }

过程:首先调用子类的tryRelease()方法释放锁,然后唤醒后继节点,在唤醒的过程中,需要判断后继节点是否满足情况,如果后继节点不为且不是作废状态,则唤醒这个后继节点,否则从tail节点向前寻找合适的节点,如果找到,则唤醒. 

共享式获取锁

public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            //获取失败,自旋获取同步状态
            doAcquireShared(arg);
    }
 private void doAcquireShared(int arg) {
        /共享式节点
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                //前驱节点
                final Node p = node.predecessor();
                //如果其前驱节点,获取同步状态
                if (p == head) {
                    //尝试获取同步
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                        parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

 尝试申请锁,如果获取失败则调用doAcquireShared(int arg)自旋方式获取同步状态

参考:

https://zhuanlan.zhihu.com/p/27134110

https://www.jianshu.com/p/c244abd588a8

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值