到底什么是AQS?面试时你能说明白吗!

写在开头

上篇文章写到CAS算法时,里面使用AtomicInteger举例说明,这个类在java.unit.concurrent.atomic包中,存储的都是一些原子类,除此之外,"java.unit.concurrent",这个包作为Java中最重要的一个并发工具包,大部分的并发类都在其中,我们今天就来继续学习这个包中的其他并发工具类。

本来今日计划是学习ReentrantLock(可重入锁)的,但打开包后发现还有AbstractOwnableSynchronizer、AbstractQueuedSynchronizer、AbstractQueuedLongSynchronizer这三个类,基于它们在锁中的重要性,我们今天就花一篇的时间,单独来学习一下啦。

AOS、AQS、AQLS

  • AOS(AbstractOwnableSynchronizer) : JDK1.6时发布的,是AQS和AQLS的父类,这个类的主要作用是表示持有者与锁之间的关系。

  • AQS(AbstractQueuedSynchronizer) :JDK1.5时发布,抽象队列同步器,是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的同步器,诸如:ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue等等皆是基于 AQS 的。AQS 内部使用了一个 volatile 的变量 state(int类型) 来作为资源的标识。

  • AQLS(AbstractQueuedLongSynchronizer) :这个类诞生于JDK1.6,原因时上述的int类型的state资源,在当下的业务场景中,资源数量有可能超过int范围,因此,便诞生了这个类,采用Long类型的state。

java 复制代码
//AQS中共享变量,使用volatile修饰保证线程可见性
private volatile int state;

//AQLS中共享变量,采用long类型
private volatile long state;

AQS的底层原理

以上我们大致的介绍了一下AQS的周边,在很多大厂的面试中提及AQS,被问到最多的就是:"麻烦介绍一下AQS的底层原理?",很多同学都浅尝辄止,导致答不出面试官满意的答案,今天我们就花一定的篇幅去一起学习下AQS的底层结构与实现!

AQS的核心思想

AQS的核心思想或者说实现原理是:在多线程访问共享资源时,若标识的共享资源空闲,则将当前获取到共享资源的线程设置为有效工作线程,共享资源设置为锁定状态(独占模式下),其他线程没有获取到资源的线程进入阻塞队列,等待当前线程释放资源后,继续尝试获取。

AQS的数据结构

其实AQS的实现主要基于两个内容,分别是 stateCLH 队列

① state

state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。

java 复制代码
// 共享变量,使用volatile修饰保证线程可见性
private volatile int state;

AQS内部还提供了获取和修改state的方法,注意,这里的方法都是final修饰的,意味着不能被子类重写!

【源码解析1】

java 复制代码
//返回同步状态的当前值
protected final int getState() {
     return state;
}
 // 设置同步状态的值
protected final void setState(int newState) {
     state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
      return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

② CLH双向队列

我们在上面提到了独占模式下,没有获取资源的线程会被放入队列,然后阻塞、唤醒、锁的重分配机制,就是基于CLH实现的。CLH 锁 (Craig, Landin, and Hagersten locks)是一种自旋锁的改进,是一个虚拟的双向队列,所谓虚拟是指没有队列的实例,内部仅存各结点之间的关联关系。

AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个节点(Node)来实现锁的分配。在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

CLH的原理

AQS资源共享

在AQS的框架中对于资源的获取有两种方式:

  • 独占模式(Exclusive) :资源是独有的,每次只能一个线程获取,如ReentrantLock;
  • 共享模式(Share) :资源可同时被多个线程获取,具体可获取个数可通过参数设定,如CountDownLatch。

①独占模式

以ReentrantLock为例,其内部维护了一个state字段,用来标识锁的占用状态,初始值为0,当线程1调用lock()方法时,会尝试通过tryAcquire()方法(钩子方法)独占该锁,并将state值设置为1,如果方法返回值为true表示成功,false表示失败,失败后线程1被放入等待队列中(CLH队列),直到其他线程释放该锁。

但需要注意的是,在线程1获取到锁后,在释放锁之前,自身可以多次获取该锁,每获取一次state加1,这就是锁的可重入性,这也说明ReentrantLock是可重入锁,在多次获取锁后,释放时要释放相同的次数,这样才能保证最终state为0,让锁恢复到未锁定状态,其他线程去尝试获取!

②共享模式

CountDownLatch(倒计时器)就是基于AQS共享模式实现的同步类,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程开始执行任务,每执行完一个子线程,就调用一次 countDown() 方法。该方法会尝试使用 CAS(Compare and Swap) 操作,让 state 的值减少 1。当所有的子线程都执行完毕后(即 state 的值变为 0),CountDownLatch 会调用 unpark() 方法,唤醒主线程。这时,主线程就可以从 await() 方法(CountDownLatch 中的await() 方法而非 AQS 中的)返回,继续执行后续的操作。

【注意】 一般情况下,子类只需要根据需求实现其中一种模式就可以,当然也有同时实现两种模式的同步类,如 ReadWriteLock。

AQS的Node节点

上述的两种共享模式、线程的引用、前驱节点、后继节点等都存储在Node对象中,我们接下来就走进Node的源码中一探究竟!

【源码解析2】

java 复制代码
static final class Node {
    // 标记一个结点(对应的线程)在共享模式下等待
    static final Node SHARED = new Node();
    // 标记一个结点(对应的线程)在独占模式下等待
    static final Node EXCLUSIVE = null;

    // waitStatus的值,表示该结点(对应的线程)已被取消
    static final int CANCELLED = 1;
    // waitStatus的值,表示后继结点(对应的线程)需要被唤醒
    static final int SIGNAL = -1;
    // waitStatus的值,表示该结点(对应的线程)在等待某一条件
    static final int CONDITION = -2;
    /*waitStatus的值,表示有资源可用,新head结点需要继续唤醒后继结点(共享模式下,多线程并发释放资源,而head唤醒其后继结点后,需要把多出来的资源留给后面的结点;设置新的head结点时,会继续唤醒其后继结点)*/
    static final int PROPAGATE = -3;

    // 等待状态,取值范围,-3,-2,-1,0,1
    volatile int waitStatus;
    volatile Node prev; // 前驱结点
    volatile Node next; // 后继结点
    volatile Thread thread; // 结点对应的线程
    Node nextWaiter; // 等待队列里下一个等待条件的结点


    // 判断共享模式的方法
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    Node(Thread thread, Node mode) {     // Used by addWaiter
        this.nextWaiter = mode;
        this.thread = thread;
    }

    // 其它方法忽略,可以参考具体的源码
}

// AQS里面的addWaiter私有方法
private Node addWaiter(Node mode) {
    // 使用了Node的这个构造函数
    Node node = new Node(Thread.currentThread(), mode);
    // 其它代码省略
}

CANCELLED: 表示当前节点(对应的线程)已被取消。当等待超时或被中断,会触发进入为此状态,进入该状态后节点状态不再变化; SIGNAL: 后面节点等待当前节点唤醒; CONDITION: Condition 中使用,当前线程阻塞在Condition,如果其他线程调用了Condition的signal方法,这个节点将从等待队列转移到同步队列队尾,等待获取同步锁; PROPAGATE: 共享模式,前置节点唤醒后面节点后,唤醒操作无条件传播下去; 0:中间状态,当前节点后面的节点已经唤醒,但是当前节点线程还没有执行完成。

AQS的获取资源与释放资源

有了以上的知识积累后,我们再来看一下AQS中关于获取资源和释放资源的实现吧。

获取资源

在AQS中获取资源的是入口是acquire(int arg)方法,arg 是要获取的资源个数,在独占模式下始终为 1。

【源码解析3】

java 复制代码
public final void accquire(int arg) {
    // tryAcquire 再次尝试获取锁资源,如果尝试成功,返回true,尝试失败返回false
    if (!tryAcquire(arg) &&
        // 走到这,代表获取锁资源失败,需要将当前线程封装成一个Node,追加到AQS的队列中
        //并将节点设置为独占模式下等待
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // 线程中断
        selfInterrupt();
}

tryAcquire()是一个可被子类具体实现的钩子方法,用以在独占模式下获取锁资源,如果获取失败,则把线程封装为Node节点,存入等待队列中,实现方法是addWaiter(),我们继续跟入源码去看看。

【源码解析4】

java 复制代码
private Node addWaiter(Node mode) {
 //创建 Node 类,并且设置 thread 为当前线程,设置为排它锁
 Node node = new Node(Thread.currentThread(), mode);
 // 获取 AQS 中队列的尾部节点
 Node pred = tail;
 // 如果 tail == null,说明是空队列,
 // 不为 null,说明现在队列中有数据,
 if (pred != null) {
  // 将当前节点的 prev 指向刚才的尾部节点,那么当前节点应该设置为尾部节点
  node.prev = pred;
  // CAS 将 tail 节点设置为当前节点
  if (compareAndSetTail(pred, node)) {
   // 将之前尾节点的 next 设置为当前节点
   pred.next = node;
   // 返回当前节点
   return node;
  }
 }
 enq(node);
 return node;
}

// 自旋CAS插入等待队列
private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

在这部分源码中,将获取资源失败的线程封装后的Node节点存入队列尾部,考虑到多线程情况下的节点插入问题,这里提供了自旋CAS的方式保证节点的安全性。

等待队列中的所有线程,依旧从头结点开始,一个个的尝试去获取共享资源,这部分的实现可以看acquireQueued()方法,我们继续跟入。

【源码解析5】

java 复制代码
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        // interrupted用于记录线程是否被中断过
        boolean interrupted = false;
        for (;;) { // 自旋操作
            // 获取当前节点的前驱节点
            final Node p = node.predecessor();
            // 如果前驱节点是head节点,并且尝试获取同步状态成功
            if (p == head && tryAcquire(arg)) {
                // 设置当前节点为head节点
                setHead(node);
                // 前驱节点的next引用设为null,这时节点被独立,垃圾回收器回收该节点
                p.next = null; 
                // 获取同步状态成功,将failed设为false
                failed = false;
                // 返回线程是否被中断过
                return interrupted;
            }
            // 如果应该让当前线程阻塞并且线程在阻塞时被中断,则将interrupted设为true
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        // 如果获取同步状态失败,取消尝试获取同步状态
        if (failed)
            cancelAcquire(node);
    }
}

在这个方法中,从等待队列的head节点开始,循环向后尝试获取资源,获取失败则继续阻塞,头结点若获取资源成功,则将后继结点设置为头结点,原头结点从队列中回收掉。

释放资源

相对于获取资源,AQS中的资源释放就简单多啦,我们直接上源码!

【源码解析6】

java 复制代码
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

private void unparkSuccessor(Node node) {
    // 如果状态是负数,尝试把它设置为0
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // 得到头结点的后继结点head.next
    Node s = node.next;
    // 如果这个后继结点为空或者状态大于0
    // 通过前面的定义我们知道,大于0只有一种可能,就是这个结点已被取消(只有 Node.CANCELLED(=1) 这一种状态大于0)
    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(arg)通过是个钩子方法,需要子类自己去实现,比如在ReentrantLock中的实现,会去做state的减少操作int c = getState() - releases;,毕竟这是一个可重入锁,直到state的值减少为0,表示锁释放完毕!

接下来会检查队列的头结点。如果头结点存在并且waitStatus不为0,这意味着还有线程在等待,那么会调用unparkSuccessor(Node h)方法来唤醒后续等待的线程。

总结

好啦,到这里我们对于AQS的学习就告一段落啦,后面我们准备使用AQS去自定义一个同步类,持续关注唷😊😊😊

结尾彩蛋

如果本篇博客对您有一定的帮助,大家记得留言+点赞+收藏呀。原创不易,转载请联系Build哥!

如果您想与Build哥的关系更近一步,还可以关注"JavaBuild888",在这里除了看到《Java成长计划》系列博文,还有提升工作效率的小笔记、读书心得、大厂面经、人生感悟等等,欢迎您的加入!

相关推荐
徐*红16 分钟前
java 线程池
java·开发语言
尚学教辅学习资料16 分钟前
基于SSM的养老院管理系统+LW示例参考
java·开发语言·java毕设·养老院
2401_8576363916 分钟前
计算机课程管理平台:Spring Boot与工程认证的结合
java·spring boot·后端
1 9 J18 分钟前
Java 上机实践4(类与对象)
java·开发语言·算法
Code apprenticeship19 分钟前
Java面试题(2)
java·开发语言
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
憨子周1 小时前
2M的带宽怎么怎么设置tcp滑动窗口以及连接池
java·网络·网络协议·tcp/ip
霖雨3 小时前
使用Visual Studio Code 快速新建Net项目
java·ide·windows·vscode·编辑器
SRY122404193 小时前
javaSE面试题
java·开发语言·面试
Fiercezm3 小时前
JUC学习
java