JAVA并发同步架构 AQS

前言

  在学习并发的时候,不得不说 ReentrantLock;而说到ReentrantLock,就不得不说到 AbstractQueuedSynchronizer(AQS)。

  AQS 是实现同步器的基础组件,并发包中锁的底层就是使用 AQS 实现的,当然在日常的开发中,基本不会直接使用AQS,但是了解其底层原理对于锁的理解和架构的设计是很有帮助的。

 一、框架

  AQS内部维护了一个 volatile int state(共享资源)和一个 FIFO 线程等待队列(多线程争用资源阻塞时会进入队列)。

  AQS内部的 state 是一个单一的状态信息,可以通过 getState()、setState()、compareAndSetState() 函数修改其值。对于ReentrantLock的实现来说,state可以用来标识获取锁的可重入次数。对于AQS来说,线程同步的关键是对状态值 state 进行操作,根据 statue是否属于一个线程,AQS分为两种资源共享方式:Exclusive(独占-ReetrantLock)和 Share(共享-Semaphore/CountDownLatch)。

  不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于线程等待队列的维护(如获取资源失败入队、唤醒出队等),AQS已经在顶层实现好了,自定义同步器主要实现以下方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

 

 

   Node节点是对每个等待获取资源的线程的封装,其中包含了需要同步的线程本身以及等待状态,如阻塞、等待唤醒、取消等。变量waitStatus 表示节点的等待状态,其中包含五种取值: CANCELLED、SIGNAL、CONDITION、PROPAGATE、0

  • CANCELLED(1):表示当前结点已经取消调度。当timeout或被中断,会变更为此状态,进入该状态后结点将不再变化
  • SIGNAL(-1):表示后继结点在等待当前结点的唤醒。后继结点入队时,会将前面结点的状态更新为SIGNAL
  • CONDITION(-2):表示结点等待在 Condition上,当其他线程调用了 Condition的 signal() 方法后,CONDITION状态的结点,将从等待队列转移至同步队列中,等待获取同步锁。
  • PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时会尝试唤醒其他后继的结点。
  • 0:新结点入队时的默认状态。

  waitStatus 负值表示结点处于有效等待状态,正值表示已被取消,源码中有很多使用 >0、<0 来判断结点状态是否正确。

二、源码解析

  源码按照 acquire-release、acquireShared-releaseShared 的次序解读

2.1、acquire(int arg)

   该方法的功能已经在其方法注释上给予了说明,下面具体看下每一步的功能实现

/**
 * 独占模式的获取资源,忽略中断。通过tryAcquired获取资源,如果成功则结束方法。否则,线程进入队列,重复的阻塞和非阻塞,
* 调用tryAcquire直到成功获取到资源,该方法可以用于实现Lock.lock 方法
*/ public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
  • tryAcquire(int) :尝试获取资源,成功直接返回,AQS并没有实现 tryAcquire 而是交由具体的同步器实现该功能
  • addWaiter():将获取资源的线程加入队列尾部,并标记独占模式
  • acquireQueued():线程阻塞在队列中获取资源,一直到成功获取资源才返回。中途如果被中断,则返回true,否则返回false。
  • 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

 2.1.1、tryAcquire(int)

  AQS中的实现,可以看出AQS本身对于资源的获取并没有提供实现,AQS只是一个同步器框架,资源的获取与释放是交给自定义的同步器实现的(通过state的get\set\CAS)。至于能不能重入、加塞由具体的自定义同步器设计。

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

2.1.2、addWaiter(Node)

  将当前的线程按照给定的模式加入队列尾部,并返回当前线程所在节点

private Node addWaiter(Node mode) {
    // 按照给定的模式创建结点
    Node node = new Node(Thread.currentThread(), mode);
    // 尝试将新创建的结点放到队列末尾
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 如果上一步失败,则通过 enq() 方法入队
    enq(node);
    return node;
}


private Node enq(final Node node) {
    // CAS自旋,直到成功写入队列末尾
    for (;;) {
        Node t = tail;
        if (t == null) { // 队列为空需要初始化,并将head、tail都指向它
            if (compareAndSetHead(new Node()))
                tail = head;
        } else { // 队列不为空,写入队列末尾
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

   注意:这里有个非常经典的应用:CAS自旋volatile变量

2.1.3、acquireQueued(Node,int)

  通过tryAcquire()和addWaiter() 表明,表明线程获取资源失败,并且已经加入等待队列,那么接下来就是:进入等待状态,等待占用资源的线程释放资源并唤醒,获取到资源之后,就可以执行自己的任务了。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true; // 标记是否成功获取到资源
    try {
        boolean interrupted = false; // 等待过程中是否被中断
        for (;;) {// 自旋
            final Node p = node.predecessor(); // 获取前置结点
            // 如果前置结点是头结点,当前结点就是第二结点,有资格去尝试获取资源
            if (p == head && tryAcquire(arg)) {
                setHead(node); // 获取资源之后,将当前结点设置为头结点
                p.next = null; // setHead中node.prev已经设置为null,此处将原本的head结点的next设置为null,是为了方便GC回收之前的head结点
                failed = false; // 成功获取到了资源
                return interrupted; // 等待过程中没有被中断
            }
            // 如果自己可以休息,就通过park()进入waiting状态,直到unpark()唤醒,如果在不可中断的情况下被中断了,就会从park()中醒来,发现拿不到资源,从而继续进入park()等待
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;// 如果等待过程中被中断,将interrupted标记为true
        }
    } finally {
        if (failed) // 如果等待过程中,没有成功获取到资源,那么取消结点在队列中的等待
            cancelAcquire(node);
    }
}

  有资格获取资源的过程已经了解了,下面来具体看看 shouldParkAfterFailedAcquire(Node,Node) 和 parkAndCheckInterrupt() 做了什么。

/**
 * 检查并更新获取资源失败的结点状态。如果线程需要阻塞,则返回true。这个方法是所有获取资源循环中主要的信号控制方法
 */
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus; // 前驱结点的状态
    // 前驱结点已经被设置为,后面结点等待前驱结点的通知,那么当前结点就可以安心等待park
    if (ws == Node.SIGNAL)
        return true;
    if (ws > 0) { // 前驱结点被取消,沿着队列一直往前遍历,直到找到有效等待状态的结点,并将当前结点的前驱设置为该结点,该结点的后驱结点设置为当前结点(踢除中间无效等待状态的结点)
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 需要将前驱结点的状态赋值为SIGNAL,告诉它获取资源之后通知下自己
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

// 如果线程找到了休息点之后,就可以安心的去休息了,此方法会让线程进入等待状态
private final boolean parkAndCheckInterrupt() { LockSupport.park(this); // 调用park() 是线程进入等待状态 return Thread.interrupted(); // 判断当前线程是不是中断状态,用于在被唤醒时候查看自己是不是被中断了 }

  在整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能够安心去休息(等待),需要找个安心的休息点(前驱结点状态为SIGNAL),同时可以尝试下有没有机会轮到自己获取资源。

  总结一下 acquireQueued 函数的具体流程:

  • 结点进入队列之后,检查结点状态,并寻找安全的休息点
  • 调用LockSupport.park() 方法进入等待状态,等待unpark() 或 interrupt() 唤醒自己
  • 被唤醒之后,查看自己是不是第二顺位,有没有资格获取资源,如果获取到 head 指向当前结点,并返回等待到获取资源的过程中,是否被中断过;如果没有获取到资源,则重复流程。

2.1.4、小结

  重新梳理下来看下acquire(int) 函数的流程:

  1. 调用 tryAcquire(int) 尝试获取资源,如果获取到资源,流程结束
  2. 如果获取资源失败,通过addWaiter加入等待队列队尾,并标记为独占模式
  3. acquireQueued 让线程在等待队列中休息,有机会(轮到自己、unpark())就去尝试获取资源,获取到资源后才会返回。
  4. 如果线程在等待过程中被中断,是不响应的,只是获取资源后才进行自我中断 selfInterrupt,将中断补上。

2.2、release(int)

  release(int) 是独占模式下释放资源的顶层入口,会释放定量的资源,如果彻底释放(state=0),会唤醒等待队列中的其他线程获取资源,这也正是unlock() 的语义

// 独占模式下的资源释放,如果tryRelease返回true,会释放一个或更多的线程(定量)。可以用来实现Lock.unlock
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head; // 获取头结点
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h); // 唤醒等待队列中的下一个线程
        return true;
    }
    return false;
}

  release()调用 tryRelease() 释放资源,根据 tryRelease() 的返回值来判断 tryRelease() 的返回值是否已经完成资源的释放工作,所以自定义同步器在设计的时候要考虑到这一点。

protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}

  和tryAcquire() 一样,tryRelease() 方法是需要独占模式下的自定义同步器自己去实现的。独占模式下的资源释放,只需要减掉响应量的资源即可(state-=arg),并不存在线程安全问题。但是需要注意 tryRelease() 的返回值,在自定义同步器时,如果已经彻底释放资源(state=0),就要返回true,否则返回false。

2.2.1、unparkSuccessor(Node)

private void unparkSuccessor(Node node) {
    // 如果结点状态是负数,清理状态值,允许失败或者被等待线程更改状态
    int ws = node.waitStatus;
    if (ws < 0) // 置零当前线程所在结点的状态
        compareAndSetWaitStatus(node, ws, 0);

    // 通常来说,待唤醒的线程就在后继结点中。但是如果后继结点被取消或者为null,就从队列尾部遍历知道找到真实的未取消的后继结点(waitState < 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); // 唤醒线程
}

  简单来说,就是使用unpack() 唤醒等待队列中离队列头最近的那个有效线程,下面有s表示。结合acquireQueued() ,线程被唤醒之后,进入if(p == head && tryAcquire(arg)) 的判断,然后s 会把自己设置为head,表示已经获取到了资源,acquire也返回了。

2.3、acquireShared(int)

  该方法是共享模式下的获取资源顶层入口。会尝试获取指定量的资源,如果获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0) // 尝试获取资源
        doAcquireShared(arg); // 获取资源失败,进入等待队列,直到获取到资源为止
}

  tryAcquireShared 依然需要自定义同步器去实现,查看AQS对于tryAcquireShared() 接口的注释,可以看到AQS已经对该方法的返回值做了定义,实现自定义容器的时候需要注意。

  • 负值:表示获取资源失败
  • 0:代表本次获取资源成功,后续的共享模式的获取资源不会成功(没有剩余资源)
  • 正数:表示获取成功,并且后续的共享模式的资源获取也可能会成功(有剩余资源)
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) { // 前驱结点是头结点,当前结点是第二顺位,此时node被唤醒,很可能是head使用完资源之后触发的
                int r = tryAcquireShared(arg);
                if (r >= 0) { // 成功获取到资源,并且可能存在剩余资源
                    setHeadAndPropagate(node, r); // 将head指向自己,如果还有剩余资源可以再唤醒之后的线程
                    p.next = null; // 原来的头结点置为null,用于帮助GC
                    if (interrupted) // 如果等待过程中被中断,此时执行中断
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            // 判断结点状态,寻找安全的休息点,等待unpark() 或 interrupt()
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)  // 如果获取资源失败,取消正在进行的资源获取尝试
            cancelAcquire(node);
    }
}

  整个doAcquireShared() 和 acquireQueued() 非常相似,只不过是将等待中线程中断的处理放到了函数中,而独占模式下,线程的中断处理是放在 acquireQueued() 之外的。

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; 
    setHead(node); // 将head指向当前结点
    
    // 如果还有剩余偏移量,继续唤醒下一个邻居线程,这里使用waitStatus 的符号判断,是因为 waitStatus的状态也许被从 PROPAGATE 转换到了SIGNAL
    if (propagate > 0 || h == null || h. waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared(); // 唤醒
    }
}

  此方法在setHead() 的基础上增加了一步,自己获取资源的同事,如果符合条件,还会去唤醒后继结点。

  还有一点需要注意,只有线程是第二顺位的时候,才会去尝试获取资源,有剩余资源的话还会尝试唤醒之后的队伍。假设1号释放了5个资源位,2号需要6个资源,3号需要1个,4号需要2个。

  1号释放资源唤醒2号,但是2号资源不够,是够会让位给3,4号执行呢。这里是否定的,其实从上面的源码可以看到,如果资源不够,2号获取资源失败,返回值为负数,会寻找安全点,继续park(),只有有充足的资源位才会获取成功,有剩余继续唤醒。

  这样会阻塞在2号位一段时间,这并不算是问题,只是AQS严格按照入队顺序唤醒罢了(保证公平,但是降低了并发)。

2.4、releaseShared(int)

  共享模式下的资源释放,会通过

 public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) { // 尝试释放资源
        doReleaseShared(); // 唤醒后继结点
        return true;
    }
    return false;
}

  改方法和独占模式下的 release() 相似,但有一点需要注意:独占模式下的tryRelease() 在完全释放资源(state=0)之后 ,才会返回true去唤醒其他线程,主要是基于独占锁可重入的考虑;共享模式下的 releaseShared() 没有这个要求,共享模式实际是控制一定量的线程进行并发执行,那么拥有资源的线程在释放部分资源之后就会唤醒后继的等待线程。但是 ReentrantReadWirteLock 读锁的 tryReleaseShared() 只有在完全释放掉资源(state=0)才会返回true,所以自定义同步器需要根据自身的需求决定 tryReleaseShared() 的返回值。

// 正常情况下唤醒head的后继结点是没问题的,但是如果不是,那么状态将被设置为 PROPAGATE 以保证在释放时,继续传播唤醒
private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) { // 头结点状态是SIGNAL,将其设置为0
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h); // 设置成功之后,唤醒后继结点
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

 

2.5、小结

  以上就独占和共享模式下,分别解析了获取-释放资源(acquire-release、acquireShared-releaseShared)的源码,相信对于AQS的实现机制也有了一定的了解。

 

文章摘自:https://www.cnblogs.com/waterystone/p/4920797.html

posted @ 2021-04-25 21:01  跬步-千里  阅读(147)  评论(0编辑  收藏  举报