Java并发编程第11讲——AQS设计思想及核心源码分析

Java并发包(JUC)中提供了很多并发工具,比如前面介绍过的ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore、FutureTask 等锁或者同步部件,它们的实现都用到了一个共同的基类------AbstractQueuedSynchronizer,简称AQS。本篇文章将深入剖析AQS的工作原理和核心概念,以理解多线程同步的关键技术。

一、什么是AQS

AQS全称AbstractQueuedSynchronizer。JDK 1.5之前只有synchronized同步锁,并且效率并不高,因此并发大神Doug Lea在JDK 1.5的时候自己写了一套框架,希望能够成为高效率地实现大部分同步需求的基础,也就是我们现在熟知的AQS(队列同步器)

AQS提供了一个同步器的框架,JUC包下大多数同步器都是围绕着AQS 使用的一组共同的基础行为(如等待队列、条件队列、独占或共享获取等)实现的,比如前边提到的ReentrantLock、CountDownLatch、Semaphore、FutureTask等,当然,我们也可以用AQS来构造出一个符合我们自己需求的同步器。

AQS支持两种同步方式:

  • 独占式(Exclusive):**同一时刻只能有一个线程持有同步资源或锁。**当一个线程成功获取到锁时,其它线程就必须等待,直到持有锁的线程释放资源才能继续执行,比如ReentrantLock。
  • 共享式(Shared):多个线程可以同时获取同一个同步资源或锁,从而实现并发方法。当一个线程获取到共享资源或锁后,其它线程仍然有机会获取资源,而不是被阻塞。比如CountDownLatch、Semaphore和CyclicBarrier就是一种共享锁。

二、AQS的常用方法与示例

AQS的设计是基于模板设计模式的,也就是说,使用者(子类 )需要继承AQS并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用AQS提供的模板方法,而这些模板方法将会调用子类重写的方法。

2.1 可重写的方法

使用AQS的一般方式:

  • 继承AQS并重写指定的方法。(无非是对于共享资源state的获取和释放)
  • 将AQS组合在自定义同步组件中,并调用其模板方法,这些模板方法就会调用子类重写的方法,这是模板方法设计模式一个典型的应用。

需要注意的是,重写AQS指定方法的同时,需要使用同步器提供的下面三个方法来访问和修改同步状态:

  • getState():获取当前同步状态。
  • setState():设置当前同步状态。
  • compareAndSetState(int expect,int update):使用CAS设置当前状态。

下面我们看看AQS定义的可重写的5个方法:

  • **protected boolean tryAcquire(int arg):**独占式获取同步状态,试着获取,成功返回true,失败返回false。
  • **protected boolean tryRelease(int arg):**独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态。
  • **protected int tryAcquireShared(int arg):**共享式获取同步状态,返回小于0的值表示获取失败,反之成功。
  • **protected boolean tryReleaseShared(int arg):**共享式释放同步状态,成功true,失败false。
  • **protected boolean isHeldExclusively():**是否在独占模式下被线程占用。

看过我之前文章的同学,除了最后一个方法,其它的是不是都很熟悉但又有点"模糊",那么今天我们就一探究竟😊,看看到底是怎么个事。

2.2 常用方法

实现自定义同步组件时,将会调用同步器提供的模板方法,如下(部分):

  • **void acquire(int arg):**独占式获取同步状态,如果当前线程获取同步状态成功,则返回,否则将会进入同步队列等待,该方法会调用重写的tryAcquire(int arg)方法。
  • **void acquireInterruptibly(int arg):**与acquire方法相同,但是该方法响应中断,若线程未获取同步状态进入到同步队列,如果当前线程中断,则会抛出InterruptedException。
  • **boolean tryAcquireNanos(int arg,long nanos):**在acquireInterruptibly方法基础上增加了超时限制,如果超时,返回false,获取成功返回true。
  • **void acquireShared(int arg):**共享式获取同步状态,如果为获取,将进入同步列等待,与独占式获取的主要区别在于同一时刻可以又多个线程获取到同步状态。
  • **boolean tryAcquireSharedInterruptibly(int arg):**与acquireShared方法相同,可响应中断。
  • **boolean tryAcquireSharedNanos(int arg,long nanos):**共享模式获取,可中断,并且有超时时间。
  • **boolean release(int arg):**独占式释放同步状态,该方法会在释放同步状态之后,将同步队列中第一个节点包含的线程唤醒。
  • **boolean releaseShare(int arg):**共享式获取同步状态。
  • **Collection<Thread> getQueuedThreads():**获取等待在同步队列上的线程集合。

2.3 基于AQS实现Mutex锁(示例)

上面大概讲了一下AQS的使用方式和常用的一些方法,接下来就借用JDK 1.8官方文档 在介绍AQS类时,举的一个例子来进一步理解AQS。

java 复制代码
public class Mutex implements Serializable {
    // 静态内部类,自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 是否处于占用状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
        // 当前状态为0的时候获取锁,CAS成功则将state修改为1
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // Otherwise unused
            if (compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }
        // 释放锁,将同步状态设置为0
        protected boolean tryRelease(int releases) {
            assert releases == 1; // Otherwise unused
            if (getState() == 0) {
                throw new IllegalMonitorStateException();
            }
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }
    }
    //同步对象完成一系列复杂的操作,我们仅需指向它即可
    private final Sync sync = new Sync();
    //加锁,代理到acquire(模板方法)上,acquire会调用我们重现的tryAcquire方法
    public void lock() {
        sync.acquire(1);
    }
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }
    //释放锁,代理到release(模板方法上),release会调用我们重写的tryRealease方法
    public void unlock() {
        sync.release(1);
    }
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
}

上述示例中,独占锁Mutex是一个自定义的同步器,它在同一时刻只允许一个线程占有锁。接下来我们就是用常见的i++例子来检验一下Mutex:

java 复制代码
public class TestMutex {
    private static int i = 0;
    private static Mutex mutex = new Mutex();
    //使用自定义的Mutex进行同步处理的a++
    public static void increase() {
        mutex.lock();
        i++;
        mutex.unlock();
    }
    public static void main(String[] args) throws Exception {
        //启动十个线程,每个线程累加10000次
        for (int j = 0; j < 10; j++) {
            new Thread(() -> {
                for (int k = 0; k < 10000; k++) {
                    increase();
                }
            }).start();
        }
        while (Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println(i);//100000
    }
}

每次测试i的结果都是预期的100000,说明我们成功地基于AQS实现了一个简单的Mutex锁。

三、设计思想

AQS的设计思想实际很简单,可以分为三部分:同步状态的原子性管理(state)、队列的管理(CLH变体队列)以及线程的阻塞和释放(LockSupport),下面我们就逐个介绍一下。

3.1 同步状态的管理(state)

每个AQS的子类都依赖于一个volitile修饰的状态变量(state),可以通过getstate、setState以及compareAndSetState等方法进行操作,这个变量可以用于表示任意状态,比如ReentrantLock 用它表示拥有锁的线程重复获取该锁的次数,CountDownLatch 用它表示计数器的数值,Semphore 用它表示剩余的许可数量,FutureTask用它表示任务的状态(尚未开始、正在运行、已完成和已取消)。

3.2 队列的管理

**AQS最核心的就是队列的管理。**AQS内部维护了两个内部类,分别是Node类(构建同步队列)和ConditionObject类(条件队列)。

3.2.1 同步队列(CLH变体队列)

AQS的核心思想就是如果被请求的共享资源(state的状态)空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制就是用CLH变体的虚拟双向队列实现的,即将暂时获取不到锁的线程加入到队列中。

我们先简单介绍下CLH队列:

CLH(Craig,Landin,and Hagersten------三个大佬的人名)队列,是单向链表实现的队列。申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱节点释放了锁就结束自旋。

暂时介绍这么多,今天我们的重点是AQS变体的CLH队列。

该队列由一个个Node节点组成,每个Node节点维护一个prev和next引用,分别指向自己的前驱和后继节点,AQS维护两个指针,指向队列头部head和尾部tail。

当线程获取资源失败时,就会构造成一个Node节点加入CLH变体队列中,同时当前线程会被阻塞在队列中(通过LockSupport.park实现)。当持有同步状态的线程释放同步状态时,会唤醒(通过LockSupport.unpark实现)后继节点,然后此节点线程继续加入到对同步状态的争夺中。

3.2.2 条件队列

AQS内部提供了一个ConditonObject类,给维护独占同步的类以及实现Lock接口的类使用。

但是,有了CLH变种队列为什么还要条件队列呢?

因为CLH变种队列仅能解决线程阻塞和唤醒的问题,并不能提供条件和通知的功能。

因此,AQS引入了ConditionObject条件队列的概念,提供了一种更加高级的线程协作机制,能够更方便地实现特定条件的等待和唤醒。ConditonObject基于CLH变种队列实现,提供了信号通知、重入、公平性等特性,同时在使用时也更加方便和易于维护。

JUC包下的许多同步组件比如ReentrantLock、CyclicBarrier、Semaphore等,都有ConditionObject的身影。总之ConditionObject和CLH变种队列相辅相成,提供了一个完整、高效且灵活的线程协作机制,能够更好地支持更高级的线程同步操作。

3.3 线程的阻塞和释放(LockSupport)

在JSR166之前,阻塞和释放线程都是基于Java内置管程,唯一的选择的是Thread.suspend和Thread.resume,但之前的文章也提到过,由于存在死锁的风险,这两个方法都被声明废弃了。即:如果两个线程同时持有一个线程对象,一个尝试去中断,一个尝试去释放,在并发情况下,无论调用时是否进行了同步,目标线程都存在死锁的风险------如果suspend()中的线程就是即将要执行resume()的那个线程,那肯定就要产生死锁了。

JUC包有一个LockSuport类,它提供了另一种安全和可控的线程挂起和唤醒机制,以避免出现死锁和其它潜在问题:

  • 显式调用:使用LockSupport,线程的挂起和唤醒操作是显式的,需要开发者明确调用park和unpark方法。
  • 无需持有锁:LockSupport的park方法不会持有任何锁对象,因此不会引发死锁。线程在调用park方法挂起时,不会影响其他线程对锁的获取和释放操作。
  • 精确唤醒:LockSupport的unpark方法可以精确地唤醒指定的线程。与Thread的resume方法不同,unpark方法无需等待具体的操作,可以直接唤醒指定的线程。
  • 无状态变更:LockSupport的park和unpark方法不会导致线程状态的不一致性或其他潜在的问题。线程在被唤醒后,可以正常继续执行,遵循同步规则。

四、acquire和release方法源码分析

AQS里面最重要的就是两个操作和一个状态,即获取操作(acquire)、释放操作(release)和同步状态(state)。 获取和释放操作又分为独占式共享式 ,这两种模式大同小异,所以今天就只对独占模式下的获取(acquire) 和**释放(release)**操作进行分析。

4.1 相关属性

再介绍之前我们先看一下相关的属性

java 复制代码
static final class Node {
    //表示共享模式
    static final Node SHARED = new Node();
    //表示独占模式
    static final Node EXCLUSIVE = null;
    //表示线程已取消:由于在同步队列中等待的线程等待超时或中断
    //需要从同步队列中取消等待,节点进入该状态将不会变化(即要移除/跳过的节点)
    static final int CANCELLED =  1;
    //表示后继节点处于park,需要唤醒:后继节点的线程处于park,而当前节点
    //的线程如果进行释放或者被取消,将会通知(signal)后继节点。
    static final int SIGNAL    = -1;
    //表示线程正在等待状态:即节点在等待队列中,节点线程在Condition上,
    //当其他线程对Condition调用signal方法后,该节点会从条件队列中转移到同步队列中
    static final int CONDITION = -2;
     //表示下一次共享模式同步状态会无条件地传播下去
    static final int PROPAGATE = -3;
    //节点的等待状态,即上面的CANCELLED/SIGNAL/CONDITION/PROPAGATE,初始值为0
    volatile int waitStatus;
    //前驱节点
    volatile Node prev;
    //后继节点
    volatile Node next;
    //与当前节点关联的排队中的线程
    volatile Thread thread;
    //同步模式改变时下一个等待节点
    Node nextWaiter;
    //判断是否是共享模式,若是则返回true
    final boolean isShared() {
        return nextWaiter == SHARED;
    }
        //返回节点的前驱节点,如果为null,则抛NullPointerException异常
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }
    //用于创建头节点或SHARED标记
    Node() {    // Used to establish initial head or SHARED marker
    }
        
    Node(Thread thread, Node mode) {     // Used by addWaiter
        this.nextWaiter = mode;
       this.thread = thread;
    }
    Node(Thread thread, int waitStatus) { // Used by Condition
       this.waitStatus = waitStatus;
       this.thread = thread;
    }
}
//同步队列的头节点,使用懒加载的方式初始化,仅能通过setHead修改。
private transient volatile Node head;
//同步队列的尾节点,同样是懒加载。仅通过enq方法修改,用于添加新的等待节点
private transient volatile Node tail;
//volatile修饰的状态变量state
private volatile int state;
//返回当前同步状态
protected final int getState() {
   return state;
}
//设置同步状态值
protected final void setState(int newState) {
   state = newState;
}
//使用CAS修改同步状态值
protected final boolean compareAndSetState(int expect, int update) {
   // See below for intrinsics setup to support this
   return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

如上所说,Node类其实就是构成CLH变体队列的一个个节点。

4.2 acquire方法

4.2.1 acquire()

分析:获取同步状态(锁)。

  • tryAcquire():调用的是子类重写的方法,若返回true,则表示获取同步状态成功,后面就不再执行,反之则进入下一步。
  • 此时,获取同步状态失败,构造独占式同步节点,通过addWaiter方法(见下)将此节点添加到同步队列尾部,并调用acquireQueued(见下)方法尝试acquire。
  • 最后,如果acquireQueued返回ture,则调用selfInterrupt方法中断当前线程。

4.2.2 addWaiter()

分析:根据当前线程和入参mode创建一个新Node,如果队列不为空则将Node设置为尾节点,反之则调用enq初始化队列并将node插入队列。

  • 第一个红框:以当前线程和mode为参数,创建一个节点node,将pred赋值为当前尾节点。

  • 第二个红框:pred不为空。

    • 将新创建的节点的前驱节点设置为pre,即将创建的节点放到尾部。

    • 使用CAS将尾节点修改为新节点。

    • 若修改成功,则将pred的后继节点设置为新节点,并返回新节点node。

  • 第三个红框:如果pred为空,则代表此时同步队列为空,调用enq方法(见下)将新节点添加到同步队列,并返回node。

4.2.3 enq()

分析:与上述的addWaiter方法相似,只是多了一个队列为空时,初始化head和tail的操作(懒加载)。

  • 第一个红框:

    • 将t赋值为尾节点。

    • 如果尾节点为空,使用CAS将头节点赋值为一个新创建的无状态节点,并初始化尾节点。

  • 第二个红框:如果尾节点不为空,使用CAS将当前node添加到尾节点。

    • 将node节点的前驱节点设置为t。

    • 使用CAS将尾节点设置为node。

    • 若设置成功,则修改node为t的后继节点,返回t。

4.2.4 acquireQueued()

分析:添加完节点后,立即尝试该节点是否能成功acquire。

  • 第一个红框:判断node节点的前驱节点p是否为头节点head,如果是则尝试acquire,若node成功acquire。则调用setHead方法将node设置为head、将node的Thread设置为null、将node的prev设置为null。将原头节点的next设置为null,也就是断开原head节点与node节点的关联,这就保证了头节点永远是一个不带Thread并且头节点的prev永远为null的空节点。

  • 第二个红框:如果node节点的前驱节点不是head,或者node尝试acquire失败,则会调用shouldParkAfterFailedAcquire方法(见下)检验node是否需要park,如果返回true则调用parkAndCheckInterrupt方法(见下)将node的线程阻塞。

  • 第三个红框:若failed为true,则代表出现了异常 ,调用cancelAcquire方法(见下)取消正在进行acquire的尝试。

4.2.5 shouldParkAfterFailedAcquire()

分析:判断节点是否需要park。

  • 第一个红框:判断前驱节点的等待状态是否为SIGNAL,若是,则表示该node应该park,等待其它前驱节点来唤醒。(此时的pred是原节点的前驱节点)

  • 第二个红框:

    • 如果前驱节点的等待状态大于0,也就是CANCELLED状态,也就是此节点已经无效,则需要从后往前遍历,找到一个非CANCELLED状态的节点,并将自己设置为它的后继节点。

    • 如果前驱节点的等待状态为其它状态,使用CAS尝试将pred节点的等待状态修改为SIGNAL,然后返回false。这就意味着再执行一次acquireQueued方法的第一个if,再次tryAcquire。

4.2.6 parkAndCheckInterrupt()

分析:直接调用LockSupport的park方法将当前线程阻塞,并在被唤醒之后,返回当前线程是否中断。

4.2.7 cancelAcquire()

分析:取消正在等待获取独占同步状态的线程。

  • 第一个红框:首先判断传入的节点是否为空,为空就直接返回,不为空就将node的Thread设置为null。

  • 第二个红框:如果node的前驱节点的等待状态为CANCELLED,则直接断开与该节点的联系。

  • 第三个红框:拿到pred的后继节点predNext(不一定是node了),并将node的等待状态设置为CANCELLED。如果node为尾节点,则CAS将尾节点改为pred节点,也就是把pred后面的节点全部移除(包括node节点和node节点前面等待状态为CANCELLED的节点)。

  • 第四个红框:后继节点的唤醒和更新

    • (判断当前节点是否为头节点)&&((获取node的前驱节点的等待状态赋值给ws并判断其等待状态是否为SIGNAL)||(判断ws是否是除CANCELLED状态之外的状态 && 如果是则将其状态设置为SIGNAL ))&& 判断node的前驱节点的线程是否不为null

    • 如果上述条件都满足,获取当前节点的后继节点next,如果next不为空且等待状态不为CANCELLED,则将前驱节点的后继节点设置为后继节点的后继节点,即跳过当前节点,因为只有pred的等待状态为SIGNAL才能走到这边,因此node的后继节点无需唤醒。

    • 反之,如果pred节点无法提供给node的后继节点信息,则直接唤醒node的后继节点(调用unparkSuccessor方法(见下))。

    • 最后置空当前节点的引用,便于垃圾回收。

4.2.8 unpakSuccessor()

分析:唤醒node节点的后继节点

  • 第一个红框:将node节点的等待状态赋值给ws,如果ws小于0(即等待状态不是CANCELLED),则将ws的等待状态置为0(初始状态因为马上要将node的后继节点唤醒)。

  • 第二个红框:将node节点的后继节点赋值给s,如果s==null或者s的等待状态为CANCELLED,则直接将s置空,并从队列尾部向前遍历,找到等待状态不是CANCELLED的节点t(离node最近的节点),并将其赋值给s。这里的意思就是将node之后的空节点或等待状态为CANCELLED的节点也一并去掉,直接唤醒node之后等待状态不为CANCELLED的节点。

  • 第三个红框:如果s!=null,则执行**LockSupport.unpark(s.thread)**唤醒s节点。

4.3 release()方法

分析:释放同步状态。

  • tryRelease():首先调用子类重写的tryRelease()方法,尝试释放锁。

  • 如果tryRelease()成功即释放锁成功,并且head节点不为空且等待状态不是初始状态,则调用unparkSuccessor方法(见4.2.8)唤醒head节点的后继节点。

4.4 acquire方法总结

release方法简单没什么好总结的,这里就总结一下acquire方法。

  • 首先,acquire方法会调用tryAcquire方法尝试直接获取锁。这个方法是由子类实现的,用于决定是否允许当前线程获取锁。如果tryAcquire方法成功获取了锁,就直接返回。
  • 如果tryAcquire方法无法直接获取锁,当前线程会通过调用addWaiter方法将该线程添加到等待队列中,如果队列不为空则将node放置队列尾部,如果为空则调用enq方法初始化队列,并放置队列尾部。
  • 接下来会调用acquireQueued方法,线程进入自旋状态,期间会不断尝试获取锁。首先会检查该节点的前驱节点是否为head节点,如果是则意味着当前节点是老二节点,可以再次调用tryAcquire方法尝试获取锁,如果获取锁成功,那么它将成为head节点,并将head节点的前驱节点置为null。如果不是头节点或者获取锁失败,则会调用shouldParkAfterFailedAcquire方法判断当前是否需要park。
  • 如果在acquireQueued中发生异常,则会执行cancelAcquire方法取消正在等待获取独占同步状态的线程。
  • 最后如果acquireQueued方法返回true,则调用selfInterrupt方法中断当前线程,这是因为返回ture就代表线程被中断。

End:希望对大家有所帮助,如果有纰漏或者更好的想法,请您一定不要吝啬你的赐教🙋。

相关推荐
鱼跃鹰飞9 小时前
Leetcode面试经典150题-349.两个数组的交集
算法·leetcode·面试
程序猿进阶16 小时前
如何在 Visual Studio Code 中反编译具有正确行号的 Java 类?
java·ide·vscode·算法·面试·职场和发展·架构
无名之逆16 小时前
云原生(Cloud Native)
开发语言·c++·算法·云原生·面试·职场和发展·大学期末
andrew_121920 小时前
腾讯 IEG 游戏前沿技术 一面复盘
java·redis·sql·面试
andrew_121920 小时前
腾讯 IEG 游戏前沿技术 二面复盘
后端·sql·面试
寻求出路的程序媛20 小时前
JVM —— 类加载器的分类,双亲委派机制
java·jvm·面试
kay_54521 小时前
YOLOv8改进 | 模块缝合 | C2f 融合SCConv提升检测性能【CVPR2023】
人工智能·python·深度学习·yolo·目标检测·面试·yolov8改进
gopher95111 天前
qt相关面试题
开发语言·qt·面试
视觉小鸟1 天前
【java面试每日五题之基础篇一】(仅个人理解)
java·笔记·面试
vd_vd1 天前
内存区域-面试与分析
jvm·面试·职场和发展