面试官突击一问:你来讲讲AQS是什么吧?都是怎么用的?

Posted Javachichi

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试官突击一问:你来讲讲AQS是什么吧?都是怎么用的?相关的知识,希望对你有一定的参考价值。

前言

在Java面试的时候,多线程相关的知识是躲不掉的,肯定会被问。我就被问到了AQS的知识,就直接了当的问,AQS知道是什么吧,来讲讲它是怎么实现的,以及哪些地方用到了它。当时自己确实没有讲好,所以这次来总结一下这个知识点。

image

此外,这边还整理了包括但不限于:分布式架构、高可扩展、高性能、高并发、Jvm性能调优、Spring,MyBatis,nginx源码分析,Redis,ActiveMQ、Mycat、Netty、Kafka、mysql、Zookeeper、Tomcat、Docker、Dubbo、Nginx等多个知识点高级进阶干货,如若需要学习借鉴,可点击 github资料直达地址

什么是AQS

AQS全称是AbstractQueuedSynchronizer,形如其名,抽象队列同步器。 AQS定义了两种资源共享模式:

  • 独占式,每次只能有一个线程持有锁,例如ReentrantLock实现的就是独占式的锁资源。

  • 共享式,允许多个线程同时获取锁,并发访问共享资源,ReentrantWriteLock和CountDownLatch等就是实现的这种模式。

它维护了一个volatile的state变量和一个FIFO(先进先出)的队列。 其中state变量代表的是竞争资源标识,而队列代表的是竞争资源失败的线程排队时存放的容器。

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
    ...
    /**
     * The synchronization state.
     */
    private volatile int state;
    /**
     * Wait queue node class.
     **/
     static final class Node {
        ...
    }
    ...
}

AQS中提供了操作state的方法

  • getState();

  • setState();

  • compareSetState();

protected final int getState() {
    return state;
}
protected final void setState(int newState) {
    state = newState;
}
protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

因为AbstractQueuedSynchronizer是一个抽象类,他采用模板方法的设计模式,规定了独占和共享模式需要实现的方法,并且将一些通用的功能已经进行了实现,所以不同模式的使用方式,只需要自己定义好实现共享资源的获取与释放即可,至于具体线程在等待队列中的维护(获取资源入队列、唤醒出队列等),AQS已经实现好了。

所以根据共享资源的模式一般实现的方法有如下几个

  • isHeldExclusively();// 是否为独占模式;但是只有使用到了Condition的,才需要去实现它。例如:ReentrantLock。

  • boolean tryAcquire(int arg); // 独占模式;尝试获取资源,成功返回true,失败返回false。

  • boolean tryRelease(int arg) ; // 独占模式;尝试释放资源,成功返回true,失败返回false。

  • int tryAcquireShared(int arg); // 共享模式;尝试获取资源,负数表示失败;0表示成功,但是没有剩余可用资源了;正数表示成功,且有剩余可用资源。

  • boolean tryReleaseShared(int arg) ; // 共享模式;尝试释放资源,若释放资源后允许唤醒后续等待节点返回true,否则返回false。

上面的这几个方法在AbstractQueuedSynchronizer这个抽象类中,都没有被定义为abstract的,说明这些方法都是可以按需实现的,共享模式下可以只实现共享模式的方法(例如CountDownLatch),独占模式下可以只实现独占模式的方法(例如ReentrantLock),也支持两种都实现,两种模式都使用(例如ReentrantReadWriteLock)。

AQS源码分析

我们先简单介绍AQS的两种模式的实现类的代表ReentrantLock(独占模式)和CountDownLatch(共享模式),是如何来共享资源的一个过程,然后再详细通过AQS的源码来分析整个实现过程。

  • ReentrantLock在初始化的时候state=0,表示资源未被锁定。当A线程执行lock()方法时,会调用tryAcquire()方法,将AQS中队列的模式设置为独占,并将独占线程设置为线程A,以及将state+1。 这样在线程A没有释放锁前,其他线程来竞争锁,调用tryAcquire()方法时都会失败,然后竞争锁失败的线程就会进入到队列中。当线程A调用执行unlock()方法将state=0后,其他线程才有机会获取锁(注意ReentrantLock是可重入的,同一线程多次获取锁时state值会进行垒加的,在释放锁时也要释放相应的次数才算完全释放了锁)。

  • CountDownLatch会将任务分成N个子线程去执行,state的初始值也是N(state与子线程数量一致)。N个子线程是并行执行的,每个子线程执行完成后countDown()一次,state会通过CAS方式减1。直到所有子线程执行完成后(state=0),会通过unpark()方法唤醒主线程,然后主线程就会从await()方法返回,继续后续操作。

独占模式分析

在AbstractQueuedSynchronizer的类里面有一个静态内部类Node。它代表的是队列中的每一个节点。 其中Node节点有如下几个属性:

// 节点的状态
volatile int waitStatus;
// 当前节点的前一个节点
volatile Node prev;
// 当前节点的后一个节点
volatile Node next;
// 当前节点中所包含的线程对象
volatile Thread thread;
// 等待队列中的下一个节点
Node nextWaiter;

每个属性代表的什么,已经写在代码注释中了。其中Node类中还有几个常量,代表了几个节点的状态(waitStatus)值。

/** waitStatus value to indicate thread has cancelled */
    static final int CANCELLED =  1;
    /** waitStatus value to indicate successor's thread needs unparking */
    static final int SIGNAL    = -1;
    /** waitStatus value to indicate thread is waiting on condition */
    static final int CONDITION = -2;
    /**
     * waitStatus value to indicate the next acquireShared should
     * unconditionally propagate
     */
    static final int PROPAGATE = -3;

首先节点的状态值waitStatus默认是0,然后下面几个常量有自己具体的含义。 CANCELLED = 1; 代表的是当前节点从同步队列中取消,当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。 SIGNAL = -1; 代表后继节点处于等待状态。后继结点入队时,会将前继结点的状态更新为SIGNAL。 CONDITION = -2; 节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了 signal()方法后,该节点将会从等待队列中转移到同步队列中,加入到对同步状态的获取中。 PROPAGATE = -3; 表示在共享模式下,前继节点在释放资源后会唤醒后继节点,并将这种共享模式传播下去。

通过上面几个固定的常量值,我们可以看出节点状态中通常负数值通常表示节点处于有效的等待状态,而正数值代表节点已经被取消了。

所以AQS源码中有很多地方都用waitStatus>0或waitStatus<0这种方式来判断队列中节点的是否正常。

独占模式下,只能有一个线程占有锁资源,其他竞争资源的线程,在竞争失败后都会进入到等待队列中,等待占有锁资源的线程释放锁,然后再重新被唤醒竞争资源。

ReentrantLock加锁过程

ReentrantLock默认是非公平锁,就是说,线程在竞争锁的时候并不是按照先来后到的顺序来获取锁的,但是ReentrantLock也是支持公平锁的,在创建的时候传入一个参数值即可。 下面我们以ReentrantLock默认情况下的加锁来分析AQS的源码。 ReentrantLock并没有直接继承AQS类,而是通过内部类来继承AQS类的,这样自己的实现功能,自己用。 我们在用ReentrantLock加锁的时候都是调用的用lock()方法,那么我们来看看默认非公平锁下,lock()方法的源码:

/**
 * Sync object for non-fair locks
 */
static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;

    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

通过源码可以看到,lock()方法,首先是通过CAS的方式抢占锁,如果抢占成功则将state的值设置为1。然后将对象独占线程设置为当前线程。


protected final void setExclusiveOwnerThread(Thread thread) {
    exclusiveOwnerThread = thread;
}

如果抢占锁失败,就会调用acquire()方法,这个acquire()方法的实现就是在AQS类中了,说明具体抢占锁失败后的逻辑,AQS已经规定好了模板。


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

上面已经介绍了,独占模式是需要实现tryAcquire()方法的,这里首先就是通过tryAcquire()方法抢占锁,如果成功返回true,失败返回false。tryAcquire()方法的具体实现,是在ReentrantLock里面的,AQS类中默认是直接抛出异常的。

  • 首先获取state值,如果state值为0,说明无锁,那么通过CAS尝试加锁,成功后,将独占线程设置为当前线程。

  • 如果state值不为0,并且当前的独占线程和当前线程为同一线程,那么state重入次数加1。

  • 如果state值不为0,并且当前线程不是独占线程,直接返回false。

protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
     final Thread current = Thread.currentThread();
     int c = getState();// 获取state值
     if (c == 0) { 
     // 如果state值为0,说明无锁,那么就通过cas方式,尝试加锁,成功后将独占线程设置为当前线程
         if (compareAndSetState(0, acquires)) {
             setExclusiveOwnerThread(current);
             return true;
         }
     }
     else if (current == getExclusiveOwnerThread()) { // 如果是同一个线程再次来获取锁,那么就将state的值进行加1处理(可重入锁的,重入次数)。
         int nextc = c + acquires;
         if (nextc < 0) // overflow
             throw new Error("Maximum lock count exceeded");
         setState(nextc);
         return true;
     }
     return false;
 }

我们继续来看acquire()方法,在执行完tryAcquire()方法后,如果加锁失败那么就会执行addWaiter()方法和acquireQueued(),这两个方法的作用是将竞争锁失败的线程放入到等待队列中。

image

addWaiter()方法的源码如下:

private Node addWaiter(Node mode) {
    // 用参数指定的模式将当前线程封装成队列中的节点(EXCLUSIVE【独占】,SHARED【共享】)
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    // tail是队列的尾部节点,初始时队列为空,尾部节点为null,直接调用enq将节点插入队列
    if (pred != null) {
    // 将当前线程节点的前级节点指向队列的尾部节点。
        node.prev = pred;
        // 通过CAS方式将节点插入到队列中
        if (compareAndSetTail(pred, node)) {
        // 插入成功后,将原先的尾部节点的后级节点指向新的尾部节点
            pred.next = node;
            return node;
        }
    }
    // 如果尾部节点为空或通过CAS插入队列失败则通过enq方法插入节点
    enq(node);
    return node;
}

addWaiter()中主要做了三件事

  • 将当前线程封装成Node。

  • 判断队列中尾部节点是否为空,若不为空,则将当前线程的Node节点通过CAS插入到尾部。

  • 如果尾部节点为空或CAS插入失败则通过enq()方法插入到队列中。

那么enq()方法是又是怎么插入节点的呢?

enq()方法源码如下:

private Node enq(final Node node) {
    // 看到死循环,就明白是通过自旋咯
    for (;;) {
    // 当tail节点为空时直接将当前节点设置成尾部节点,并插入到队列中,以及设置它为head节点。
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
        // 若是因为在addWaiter()方法中插入失败或第二次进入循环,那么将当前线程的前级节点指向尾部节点,并通过CAS方式将尾部节点指向当前线程的节点。
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

其实enq()方法主要就是通过自旋将数据插入到队列中的操作:

  • 当队列为空时,将当前节点设置为头节点和尾节点。

  • 进入二次循环后,将node添加到尾部。

这样addWaiter()方法就构造了一个队列,并将当前线程添加到了队列中了。 我们再回到acquire()方法中。

image

现在就剩下acquireQueued()方法没看了,这个方法中的操作挺多的。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
        // 获取前级节点,如果未null,则抛出异常
            final Node p = node.predecessor();
        // 如果前级节点为head,并且执行抢占锁成功。
            if (p == head && tryAcquire(arg)) {
            // 抢占锁成功,当前节点成功新的head节点
                setHead(node);
                // 然后将原先的head节点指向null,方便垃圾回收进行回收
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 如果当前节点不为head,或者抢占锁失败。就根据节点的状态决定是否需要挂起线程。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed) // 如果获取锁异常,则出取消获取锁操作。
            cancelAcquire(node);
    }
}
final Node predecessor() throws NullPointerException {
    Node p = prev;
    if (p == null)
        throw new NullPointerException();
    else
        return p;
}
  • 首先获取节点的前级节点。

  • 如果当前节点的前级节点是head那么就可以去抢占锁了。

  • 抢占成功后就将新节点设置为head,原先的head置为空。

  • 如果抢占锁失败,则根据waitStatus值决定是否挂起线程。

  • 最后,通过cancelAcquire()取消获取锁操作。

下面看一下shouldParkAfterFailedAcquire()和parkAndCheckInterrupt()这两个方法是如何挂起线程的。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;// 获取前级节点
    if (ws == Node.SIGNAL)// 如果前级节点的waitStatus值为SIGNAL(-1),说明当前节点也已经在等待唤醒了,直接返回true。
        return true;
  // 如果前级节点的waitStatus值大于0说明前级节点已经取消了。
    if (ws > 0) {
   // 如果前级节点已经是CANCEL状态了,那么会继续向前找,直到找到的节点不是CANCEL(waitStatue>0)状态的节点,然后将其设置为当前节点的前级节点。
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
    // 如果前级节点为0或者其他不为-1的小于0的值,则将当前节点的前级节点设置为 SIGNAL(-1)
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

parkAndCheckInterrupt()方法的作用就是挂起线程,如果shouldParkAfterFailedAcquire()方法成功,会执行parkAndCheckInterrupt()方法,它通过LockSupport的park()方法,将当前线程挂起(WAITING),它需要unpark()方法唤醒它,通过这样一种FIFO机制的等待,来实现Lock操作。

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

LockSupport是JDK从1.6开始提供的一个线程同步源语工具类,在这里主要用到了它的两个方法,挂起线程和唤醒线程:

public static void park() {
    UNSAFE.park(false, 0L);
}
public static void unpark(Thread thread) {
    if (thread != null)
        UNSAFE.unpark(thread);
}

LockSupport的挂起和唤醒线程都是不可重入的,它由一个许可标志,当调用park()时就会将许可设置为0,挂起线程,如果再调用一次park(),会阻塞线程。当调用unpark()时才会将许可标志设置成1。

ReentrantLock释放锁过程

ReentrantLock释放锁的过程主要有两个阶段

  • 释放锁。

  • 唤醒挂起的线程。 unlock()方法的源码如下。


public void unlock() {
   sync.release(1);
}

释放锁的方法是写在父类,AbstractQueuedSynchronizer类中的。 源码如下:


// 释放独占模式下的锁资源
public final boolean release(int arg) {
    if (tryRelease(arg)) { // 尝试释放资源
        Node h = head;
  //释放成功后,判断头节点的状态是否为无锁状态,如果不为无锁状态就将头节点中的线程唤醒。
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false; // 释放资源失败,直接返回false
}

我们首先释放资源来看tryRelease()方法的源码,看看释放资源是怎样的过程。


protected final boolean tryRelease(int releases) {
// 从state中减去传入参数的相应值(一般为1)
    int c = getState() - releases;
    // 当释放资源的线程与独占锁现有线程不一致时,非法线程释放,直接抛出异常。
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 这里是处理重入锁的机制,因为可重入机制,所以每次都重入state值都加1,
    //所以在释放的时候也要相应的减1,直到state的值为0才算完全的释放锁资源。
    if (c == 0) {
        free = true;
        // 完全释放资源后,将独占线程设置为null,这样后面的竞争线程才有可能抢占。
        setExclusiveOwnerThread(null);
    }
    // 重新赋值state
    setState(c);
    return free;
}

tryRelease()方法在释放锁资源时,可以单纯的理解为是修改独占模式的状态值和置空占有线程的操作。将state的值减掉相应的参数值(一般是1),如果计算结果为0,就将他的独占线程设置为null,其他线程才有机会抢占成功。 在加锁时,同一线程加一次锁,state状态值就会加1,在解锁的时候没解锁一次就会减1,同一个锁可重入,只有lock次数与unlock次数相同才会释放资源,将独占线程设置为null。

释放了资源后,我们再看唤醒挂起线程时的过程。这个过程就在unparkSuccessor()方法中。


private void unparkSuccessor(Node node) {
    /* 获取当前节点的等待状态,一般是头节点,占有锁的节点是在头节点上。 */
    int ws = node.waitStatus;
    // 将当前节点的线程的状态值设为0,成为无锁状态。
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    /*
     * Thread to unpark is held in successor, which is normally
     * just the next node.  But if cancelled or apparently null,
     * traverse backwards from tail to find the actual
     * non-cancelled successor.
     */
    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);
}

这个释放过程就是将需要释放的线程节点设置成无锁状态,然后去队列中找到可以唤醒的节点,进行唤醒线程。 有一点需要解释一下,就是在寻找可以唤醒的节点时,为什么要从后向前找? 在上面unparkSuccessor()方法的源码里面有一段英文注释(7行~12行),我保留了下来了。

/* * Thread to unpark is held in successor, which is normally * just
the next node. But if cancelled or apparently null, * traverse
backwards from tail to find the actual * non-cancelled successor. */

这段英文注释翻译过来的大概意思就是:线程唤醒的时候,通常是从当前线程的下个节点线程开始寻找,但是下个节点有可能已经取消了或者为null了,所以从后向前找,直到找到一个非 取消状态的节点线程。

由于文章篇幅太长了,我这次就先将独占模式的加锁和解锁的过程总结到这里,以后其他文章将通过CountDownLatch的加锁和解锁过程来总结AQS在共享模式下过程。

AQS共享模式分析

其实AQS的共享模式总结起来感觉比独占模式稍微容易一些,只是说总结起来稍微容易哦。

CountDownLatch的获取共享资源的过程

在使用CountDownLatch的时候,是先创建CountDownLatch对象,然后在每次执行完一个任务后,就执行一次countDown()方法。直到通过getCount()获取到的值为0时才算执行完,如果count值不为0可通过await()方法让主线程进行等待,知道所有任务都执行完成,count的值被设为0。 那么我们先来看创建CountDownLatch的方法。


public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}

private static final class Sync extends AbstractQueuedSynchronizer {
   private static final long serialVersionUID = 4982264981922014374L;
   Sync(int count) {
       setState(count);
   }
}

我们看到创建CountDownLatch的过程,其实就是将count值赋值给state的过程。

再来看await()方法的源码:



public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);// 等待可中断的获取共享资源的方法
}
public final void acquireSharedInterruptibly(int arg)
            throws InterruptedException {
    if (Thread.interrupted()) // 如果线程已经中断,直接抛出异常结束。
        throw new InterruptedException();
    if (tryAcquireShared(arg) < 0)// 尝试获取共享资源,获取失败后,自旋入队列
        doAcquireSharedInterruptibly(arg);// 可中断的入队列过程
}

整个await()的等待过程是,先尝试获取共享资源,获取成功则执行任务,获取失败,则调用方法自旋式进入等待队列。 通过最初在介绍AQS的时候就说过 ,共享模式下是需要自己去实现tryAcquireShared()方法来获取共享资源的,那么我们看看CountDownLatch是如何实现获取共享资源的。



protected int tryAcquireShared(int acquires) {
    return (getState() == 0) ? 1 : -1;
}

简单易懂,就一行代码,直接获取state值,等于0就是成功,不等于0就失败。

如果你觉得自己学习效率低,缺乏正确的指导,可以加入资源丰富,学习氛围浓厚的技术圈一起学习交流吧!
[Java架构群]
群内有许多来自一线的技术大牛,也有在小厂或外包公司奋斗的码农,我们致力打造一个平等,高质量的JAVA交流圈子,不一定能短期就让每个人的技术突飞猛进,但从长远来说,眼光,格局,长远发展的方向才是最重要的。

那么获取资源失败后,doAcquireSharedInterruptibly()方法是如何入执行的呢。 源码如下:



private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
    final Node node = addWaiter(Node.SHARED);// addWaiter()方法已经总结过了,这一步操作的目的就是将当前线程封装成节点加入队尾,并设置成共享模式。
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();// 获取前级节点
            if (p == head) {
            // 如果前级节点是头节点,直接尝试获取共享资源。
                int r = tryAcquireShared(arg);
                if (r >= 0) {// 如果获取共享资源成功,将head节点指向自己
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC 将原head节点指向空,方便垃圾回收。
                    failed = false;
                    return;
                }
            }
            // 如果不是前级节点不是head节点,就根据前级节点状态,判断是否需要挂起线程。
            

以上是关于面试官突击一问:你来讲讲AQS是什么吧?都是怎么用的?的主要内容,如果未能解决你的问题,请参考以下文章

面试中的被问到Spring,我们该怎么去回答

面试官问你多线程你该怎么回答

面试官一问一答的面试题

8年Java面试官:月薪8000和30000的差距是什么?

面试官问:分库分表后,你们是怎么迁移和部署上线的?怎么保证一致性?

面试突击Mysql:Mysql都有哪些特性?分别适用于哪些场景?