并发基石:AQS源码分析与图解

学习并发,理解并发,掌握并发是Java程序员迈不过去的一道坎。现实业务中很多情况下都会涉及到并发操作,知己知彼百战不殆。学好并发知识非常且极其有必要。这篇文章我们就来分析下并发知识中 非常核心 的一个知识点:AbstractQueuedSynchronizer 简称(AQS)。

在开篇前,我们先看下本文的结构:

说明:

  1. 本文jdk版本为:11
  2. 为了方便下面我们提到AbstractQueuedSynchronizer类时 一般都用AQS简称。
  3. AQS类本质上是模板方法模式,所以在学习AQS时,最好要知道什么是模板方法模式知道其结构。
  4. 由于AQS内部使用state来表示锁资源,所以本文提到资源时 也大多使用state表示。
  5. 在学习AQS之前,希望对锁知识以及Java中的并发工具有所了解和使用,这样可以达到事半功倍的效果。
  6. 在AQS中有挺多CAS操作,希望你对CAS有所了解知道他是怎么做的以及使用场景。
  7. 由于源码分析比较枯燥,所以本文在每一个源码小节后都会配一张图解(因为一图胜千言,我个人比较喜欢画图来解释一些比较重要或者难懂的知识点
  8. AQS是JDK层面的锁实现,如果想了解jvm层面的锁实现则移步我的另一篇文章:万字长文分析synchroized:,两者对比下,你会发现似乎有些地方是同样的设计逻辑
  9. 本文依赖jdk11源码中有部分和VarHandle相关(比如CLH的入队操作),不了解的最好去看一下VarHandle是个啥。

1、AQS概述

所谓AQS(AbstractQueuedSynchronizer)中文直译抽象队列同步器,他定义了一套多线程访问共享资源的同步器框架,提供了SDK层面的锁机制,很多类都是基于这个大拿 开发的比如: ReentrantLock/Semaphore/CountDownLatch/ReentrantReadWriteLock/ThreadPoolExecutor中的Worker/以及jdk之外的很多开源项目 ......等都是基于它。我们简单看下我本地的AQS使用情况:

通过查阅作者的对于该类的文档注释可以得到如下核心信息:

我们来大体概括下上图这段英文信息,就能对AQS有一个基础的认识了。如下:

  1. AQS用一个 volatile int state; 属性表示锁状态(因为锁是存在并发获取的,所以必须要被可见的,即保证a修改后b立即在主内存可见!),1表示锁被持有,0表示未被持有AQS类提供了修改该属性的三个方法: getState() , setState(int newState) , compareAndSetState(int expect, int update) 。
  2. 框架内部维护了一个FIFO的等待队列,是用双向链表实现的,我们称之为CLH队列。
  3. 框架内部也实现了条件变量 Condition ,用它来实现等待唤醒机制,并且支持多个条件变量(本文我们不做分析留到下篇文章)。
  4. AQS支持两种资源共享的模式: 独占模式(Exclusive)和共享模式(Share),所谓独占模式就是任 意时刻只允许一个线程访问共享资源,譬如ReentrantLock;而共享模式指的就是允许多个线程同时访 问共享资源,譬如Semaphore/CountDownLatch
  5. 使用者只需继承 AQS 并重写指定的方法,在方法内完成对共享资源 state 的获取和释放,至于具体线程等待队列的维护,AQS已经在顶层实现好了,在那些模板 方法里。

2、AQS原理与结构

2.1、AQS原理简介

宏观上看,AQS其实就是俩东西组成: 一个是资源 state,一个是未获取到资源的线程的 等待队列 CLH

什么是CLH? : CLH锁其实就是当多个线程竞争同一把锁时,获取不到锁的线程,会排队进入CLH队列(FIFO)的队尾,然后自旋等待,直到其前驱线程释放锁。由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁。

CLH队列中存放的是?: 存放的是一个个的Node对象(而Node中存放的东西我们后边再说,总之我们知道Node中一定会有对应的线程信息)

我们先看下AQS大体的原理图(更多细节的东西在下边会讲到)。

我们简单举个例子,在排他锁模式下流程如下:

  1. 假设t1时刻,有线程a持有资源state(持有资源的线程一定是在head节点这个我们一定要清楚
  2. t1时刻,线程b试图调用获取锁的方法来获取锁资源,发现获取锁失败,则将线程b的相关数据封装为Node并插入CLH队列的队尾。
  3. 挂起线程b,并告知线程a(通过将head节点的waitStatus设置为SIGNAL),资源释放了记得通知我啊!
  4. t2时刻,线程a释放资源(并将对应Node赋值为null,利于GC)state后通知线程b
  5. t3时刻 线程b 尝试获取锁(此时如果是公平锁则大概率可以获取成功,如果是非公平,则不一定)

以上这个只是个大概的流程,期间有很多优化和细节操作。后边源码我们逐一分析在这里我们只需要知道他的主题逻辑就行了。

2.2、AQS结构认识

在分析AQS源码前,我们首先要和AQS内部的兄弟 混个脸熟,所以有了本小节。

首先我们看下 AQS 的 继承关系图 ,如下: 可以看到 我们常见的并发工具(ReentrantLock/Semaphore/CountDownLatch/ThreadPoolExecutor/ReentrantReadWriteLock),都是直接或间接的继承自AQS类。其实到最后我们会发现,搞懂了AQS这个知识点,上边括号中的那几个并发工具类 原理也就豁然开朗了。

AQS中相当重要的三个成员变量(头/尾节点+state):

java 复制代码
//头节点(独占锁模式下,持有资源的永远都是头节点!这个要知道哦)
private transient volatile Node head;
//尾节点
private transient volatile Node tail;
//锁资源(无锁状态是0,每次加锁成功后,通过cas进行+1,在重入场景下,重入几次就是几)
private volatile int state;

AQS中的两个内部类:ConditionObjectNode

Node类:

下边是Node类的源码,先简单看一下:

java 复制代码
static final class Node {
       //当前节点处于共享模式的标记
       static final Node SHARED = new Node();
       
        //当前节点处于独占模式的标记
        static final Node EXCLUSIVE = null;

        //线程被取消
        static final int CANCELLED =  1;
        //head持有锁线程释放资源后需唤醒后继节点
        static final int SIGNAL    = -1;
        //等待condition唤醒
        static final int CONDITION = -2;
        //工作于共享锁状态,需要向后传播,
        static final int PROPAGATE = -3;

        //等待状态,有1,0,-1,-2,-3五个值。分别对应上面的值
        volatile int waitStatus;

        //前驱节点
        volatile Node prev;

        //后继节点
        volatile Node next;

        //等待锁的线程
        volatile Thread thread;

        //等待条件的下一个节点,ConditonObject中用到
        Node nextWaiter;
  }

因为Node类的等待状态和节点模式对于后续分析经常见到属于比较重要的一个点,所以这里我们说一下

Node节点模式(SHARED/EXCLUSIVE):

  • 节点处于排他模式表明节点持有的线程等待获取排他资源,处于共享模式则表明节点所持有的线程等待共享资源。

Node类的 waitStatus等待状态枚举值说明:

  • int waitStatus(默认=0): =0这种情况一般有两种,一是后继节点持有的线程还没被挂起,还没来得及将前继节点状态改成 SIGNAL(比如Node刚创建时),二是后继节点持有的线程已经被唤醒。
  • CANCELLED=1: 作废状态,该节点的线程由于超时,中断等原因而处于作废状态。是不可逆的,一旦处于这个状态,说明应该将该节点移除出队列并且对应的线程放弃锁竞争行为。
  • SIGNAL=-1: 待唤醒后继节点,当前节点的线程处于此状态,后继节点可以安心的进行线程挂起操作,当前节点释放锁或取消之后必然会唤醒它的后继节点。
  • CONDITION=-2: 等待条件状态,当node处于该状态表示节点进入了 Condition 队列(注意和等待队列是两码事),当节点持有的线程获取到资源,但在执行过程中又主动放弃了资源(类似于在 synchronized 中调用 Object.wait 方法),这类节点会进入到 Condition 队列中,等待被唤醒。
  • PROPAGATE=-3: 传播状态,只有 head 节点会处于这个状态,这个值的作用是:连续唤醒队列中处于共享模式的节点,让他们并发获取共享资源(用于共享锁下的唤醒操作,排他锁没有此操作,排他锁只需要唤醒头节点的后继节点(非CANCELED的)即可)。

AQS留给子类的钩子方法(由子类来定义锁的释放和获取逻辑):

java 复制代码
//尝试获取排他锁
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
//尝试释放排他锁
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}
//尝试获取共享锁
protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}
//尝试释放共享锁
protected boolean tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}
//判定当前线程获得的资源是否是排他资源
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

ok,看到这希望对AQS的结构有个初步认识。 我们知道了AQS是很多并发工具的基础,他内部有Node类型的头/尾节点,同时使用state来标记资源,同时AQS中有俩内部类ConditionObject和NodeNode中有等待状态waitStatus锁的模式,另外提供了几个由子类实现的钩子方法用于子类定义加/释放锁的逻辑

有个上边的AQS 概念,原理分析和结构简介,下边我们 正式开始分析源码~~~

3、 AQS源码分析

为了方便加锁和解锁分析以及后续可能存在的debug演示,我们写个加锁的demo

java 复制代码
/**
 * lock():若lock被thread A取得,thread B会进入block状态,直到取得lock。 可能会出现死锁
 *
 * @param args
 * @throws InterruptedException
 */
public static void main(String[] args) throws InterruptedException {
    ReentrantLock lock = new ReentrantLock();
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            lock.lock();
            log.info("我抢到锁了 哈哈我是 :{}",Thread.currentThread().getName());
        }
    };
    Thread threadA = new Thread(runnable, "Thread A");
    Thread threadB = new Thread(runnable, "Thread B");

    threadA.start();
    Thread.sleep(5);
    threadB.start();
    log.info("线程A状态:{}",threadA.getState());
    log.info("线程B状态:{},线程A不释放 没办法 我只能死等了 ",threadB.getState());

}

注意: 由于锁分为排他锁和共享锁,为了全面,我们在下边两个都说一下,而对于排他锁模式下的公平锁和非公平锁,我们就不细说了,区别挺小的,在排他锁章节,我们以公平锁为例进行加解锁的分析。

3.1、 排他锁

在jdk中,排他锁有synchroizedReentrantLock以及读写锁ReentrantReadWriteLock的写锁也是排他模式,其中synchroized是基于底层实现的和AQS没关系。而ReentrantLock和ReentrantReadWriteLock写锁都是基于AQS的排他模式实现的,从这你可以看出AQS的重要了吗?

3.1.1、AQS【加排他锁】源码分析与图解

ps: 由于上来就放图解不太友好,所以我们先代码分析,再图解总结~

在上边demo中我们使用ReentrantLocklock方法 进行加锁,其内部是这么调用的: ps:(ReentrantLock中的Sync类继承了AQS类)

sync.acquire(1);的调用其实就是AQS的这个acquire方法,注意他是public的也就是说是对外暴露的,如下:

接下来我们就从 AQS的这个acquire方法 来分析加锁逻辑:

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

acquire方法里的逻辑大概如下:

  1. 调用tryAcquire尝试获取锁state: (实现在子类)来获取锁
    • 如果获取成功这个方法(acquire)直接就结束了,如果失败了返回false,进行下边的调用
  2. 调用addWaiter加入等待队列: (这里指定Node为排他锁因为acquire方法的模式就是排他)
    • 如果tail不是空则通过cas添加当前node到队列尾部,如果是空则初始化等待队列,该方法返回当前Node(也即当前获取资源失败的Node对象)。
  3. 调用acquireQueued:
    • 找到当前节点的前驱节点,如果是头节点再次尝试获取锁,成功的话将当前节点置为头节点并将老head节点置为null帮助GC回收
    • 如果前驱节点不是头节点,那就要通过 shouldParkAfterFailedAcquire来判断是否需要将当前节点对应的的线程 park(挂起) ,如需要挂起,则调用LockSupport.park(this)将当前线程挂起,并检测中断标志之后返回。 关于shouldParkAfterFailedAcquire 里边做的事情大概就是:根据当前节点的前驱节点的waitStatus来判断是否需要挂起(只有前驱节点是SIGNAL状态才会挂起当前节点,否则将设置前驱节点为SIGNAL待下一次自旋时挂起)
  4. 调用selfInterrupt:
    • 如果加锁失败且acquireQueued返回中断标识为true,则调用selfInterrupt进行真正的中断操作,至此加锁流程完毕。

下边我们来一波源码,对上边几个方法进行详细分析

以下是:ReentrantLock -> FairSync -> tryAcquire(int acquires)方法的实现逻辑

java 复制代码
//ReentrantLock内部类 Sync 继承了 AQS类
abstract static class Sync extends AbstractQueuedSynchronizer {
....
}

//ReentrantLock的公平锁实现类 FairSync 继承了 Sync
static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;
    
    //对AQS的钩子方法tryAcquire 的实现逻辑(看到这里 你会自己使用AQS实现锁了吗??????) 
    @ReservedStackAccess
    protected final boolean tryAcquire(int acquires) {
        //获取当前线程
        final Thread current = Thread.currentThread();
        //获取AQS中的state
        int c = getState();
        //如果state等于0说明此时没有线程占有锁
        if (c == 0) {
            //hasQueuedPredecessors 判断等待队列中是否已经有其他的线程在排队,如果有其他的线程在排队,就需要排队,没有,就不需要排队。 
            //此方法返回true,代表当前线程需要排队,返回false,表示当前线程不用排队

            //不用排队的话 直接cas尝试将state占位己有
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                //设置AQS中的独占线程字段 为当前线程
                setExclusiveOwnerThread(current);
                //返回true 代表获取锁成功
                return true;
            }
        }
        //如果state不是0 则判断当前占有锁的线程是否是当前线程,如果是,
        //表示此线程重入抢锁,对state进行+1 
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            //重入线程,只需将state+1, head信息不需要变,也不许要到等待队列排队,
            //直接返回true加锁成功
            return true;
        }
        //抢锁失败 返回false
        return false;
    }
}

以下是addWaiter方法的实现逻辑:

java 复制代码
private Node addWaiter(Node mode) {
    //将 获取资源失败的线程封装成Node节点
    Node node = new Node(mode);
    //自旋操作
    for (;;) {
        Node oldTail = tail;
        //如果tail不是空,则通过CAS操作将当前Node插入到等待队列尾部
        if (oldTail != null) {
            //设置当前node的前驱是插入前的最后一个节点,即尾节点tail
            node.setPrevRelaxed(oldTail);
            //此时可能有其他线程也在插入,所以使用CAS的方式来插入Node对象到等待队列尾部
            if (compareAndSetTail(oldTail, node)) {
                //关联插入前尾节点和当前节点
                oldTail.next = node;
                //返回当前node
                return node;
            }
        } else {
            //如果tail是空,则初始化等待队列
            initializeSyncQueue();
        }
    }
}

以下是acquireQueued方法的实现逻辑:

java 复制代码
final boolean acquireQueued(final Node node, int arg) {
    //表示当前线程在排队获取资源的过程中是否被 interrupt 过
    boolean interrupted = false;
    try {
        //自旋
        for (;;) {
            //找到当前节点的前驱节点(即前一个节点)
            final Node p = node.predecessor();
            //判断当前节点的前一个节点如果是头结点,则再次尝试获取锁(因为头节点的后一个节点很有可能获取成功,所以这里做了个 再次尝试)
            if (p == head && tryAcquire(arg)) {
                //如果成功则将当前节点设置为head节点
                setHead(node);
                //将原来的头节点置的next引用置位null帮助GC回收
                p.next = null; // help GC
                //返回中断标记 返回false代表当前线程在排队获取资源的过程中未被中断过
                return interrupted;
            }
            //如果前驱节点不是头节点,那就要通过shouldParkAfterFailedAcquire
            //判断是否需要将当前节点对应的的线程 park(挂起) 
            if (shouldParkAfterFailedAcquire(p, node))
                interrupted |= parkAndCheckInterrupt();
        }
    } catch (Throwable t) {
        //如果出现异常,则放弃获取锁
        cancelAcquire(node);
        if (interrupted)
            selfInterrupt();
        throw t;
    }
}

//注意这个方法和Node的waitStatus息息相关,必须理解waitStatus的各个值的含义,才能读懂这个方法
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    //首先获取前驱节点的waitStatus,用这个属性来判断当前节点是否需要挂起
    int ws = pred.waitStatus;
    
    //如果当前node的前驱节点pred状态已经为SIGNAL,表示pred已经知道
    //在释放锁以后要唤醒 下一个node(即当前node)持有的线程,所以当前node持有的线程可以安安心心挂起了
    if (ws == Node.SIGNAL)
        return true;
    
    //如果pred状态为CANCELLED(wautStatus>0只有一种情况就是处于CANCELLED状态时 ),
    //表示pred节点释放了锁或者出现异常,它可以出队列了
    if (ws > 0) {
        //循环将 CANCELLED 状态的节点踢出队列
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
         //进到这个else分支,就意味着pred的waitStatus状态不是 0 就是 PROPAGATE=-3,
         //此时会将pred等待状态改为 SIGNAL,以提醒pred 在释放锁后记得唤醒后继节点(即当前 node)
        pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
    }
    return false;
}


private final boolean parkAndCheckInterrupt() {
    //挂起当前线程
    LockSupport.park(this);

    //interrupted()说明: interrupted方法的作用是检测当前线程是否可以被中断(检查中断标志),
    //返回一个boolean并清除中断状态,第二次再调用时中断状态已经被清除,将返回一个false

    //所以下边的interrupted()并不是中断线程,因此结果可能是true也可能是false
    return Thread.interrupted();
}

ok到这里, AbstractQueuedSynchronizer #acquire(int arg)方法加锁 逻辑也就分析完了,我们画个图来加深下理解:

3.1.2、AQS【解排他锁】源码分析与图解

相比加锁,解锁相对简单点,我们还是debug方式进入解锁入口,如下: 可以清楚看到解锁入口在AQS的release方法(Sync类继承了AQS),所以我们直接看下AQS的release方法源码:

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

可以看到逻辑很清晰,即:如果tryRelease(释放锁)成功,并且头节点的waitStatus!=0,那么将调用unparkSuccessor(head)方法唤醒头节点之后那个节点注意: 排他模式下,唤醒操作 只且只能发生在头节点后继节点之间(因为 排他模式下持有锁的节点只能是头节点head! )。接下来我们就看下tryRelease方法,注意这个和tryAcquire()方法一样,都是AQS类留给子类实现的钩子方法,所以我们需要去 ReentrantLock的内部类SynctryRelease方法中一寻究竟。源码如下:

java 复制代码
// 方法作用:释放锁(通过对state -1)
@ReservedStackAccess
protected final boolean tryRelease(int releases) {
    //获取到AQS的资源变量 state 并减一(注意 加锁和减锁的方法入参  永远是 1 )
    int c = getState() - releases;
    //如果当前线程不是持有锁的线程(直接抛异常,你都没锁 你释放个嘚儿啊 哈哈)
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    //如果state=0了 则说明锁已经真正的释放了,则释放标志位true并且将占有线程置位null
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    //将释放锁之后的state(变量c)赋值给state
    setState(c);
    return free;
}

释放锁成功的话返回true且头节点不是空并且waitStatus!=0,则进入unparkSuccessor方法,开始唤醒头节点的后继节点对应的线程,看下源码:

java 复制代码
// 方法作用:唤醒头节点(head)的后继节点对应的线程
private void unparkSuccessor(Node node) {
    //获取当前线程的等待状态
    int ws = node.waitStatus;
    //如果node节点的等待状态是负数比如(SIGNAL状态),那尝试将waitStatus置为0
    if (ws < 0)
        node.compareAndSetWaitStatus(ws, 0);

    //获取当前节点的后继节点
    Node s = node.next;
    //如果当前节点的后继节点是null或者当前节点的后继节点是>0,(大于0只能是CANCELLED状态),
    //那么将从尾节点tail开始,一直向前找距离当前节点最近的那个需要被唤醒的节点,并赋值给变量s
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node p = tail; p != node && p != null; p = p.prev)
            if (p.waitStatus <= 0)
                s = p;
    }
    //如果找到了当前节点的第一个需要被唤醒的后继节点,则唤醒他!
    if (s != null)
        //唤醒操作,唤醒当前节点后继节点对应的线程。
        LockSupport.unpark(s.thread);
}

ok 整个release方法概括就是,释放锁(state-1)并且唤醒头节点之后 waitStatus不是CANCELLED的那个后继节点,但是唤醒后就没了?不是吧,唤醒后,他需要去竞争锁呀!这时候,我们前边分析的那个加锁时候acquireQueued方法的自旋逻辑 就派上用场了,我们简单回顾下: 为了方便理解我们这里举个例子,假设在(独占锁且是公平锁模式下)

  1. t1时刻,线程a获取了锁资源,线程b也尝试获取锁,但是被线程a占用,所以线程b被搞到了等待队列中(此时线程b的前驱节点就是头节点也即线程a),线程b会在acquireQueued的for(;;)中 不断自旋!

  2. 如果t2时刻,线程a释放了锁资源,在unparkSuccessor逻辑中将线程a的后继节点也即线程b唤醒

  3. 紧接着t3时刻,线程b在自旋到if(p==head && tryAcquire(arg))这个条件时,不出意外将会获取到锁 (因为线程b的前驱节点确实是线程a对应的head节点,且在公平模式下tryAcquire不出意外会获取到锁),那么将线程b设置为head节点,此时线程b占有锁(至此完成了一次线程a释放,线程b上位的锁获取逻辑)。 ps: 那么有人说了,假如线程b的前驱不是头节点呢?其实没关系,会进入到这个shouldParkAfterFailedAcquire方法,然后把线程b到线程a之间的CANCELLED状态的Node(是有可能存在取消状态的Node的)都剔除并且将线程b的Node成功与头节点关联,如下逻辑:

    其中node.prev = pred = pred.prev; 这个代码比较有意思,假设有如下节点(node1->node2->node3)首先找到当前node3的前驱node2,将node2赋值给前驱变量pred,然后把node3的前驱指向赋值后的前驱即node1,我擦无情了,简单点就是:假设node2是取消状态,那么就将node3指定为node1的后继,相当于把取消的node2干掉,就这么简单。

    在pred.next=node后(假设线程a与线程b之间只有一个在等待过程中被CANCELLED的线程)此时pred就是线程a对应的head节点, node节点就是线程b对应的Node,pred.next=node;这个代表就成功将线程b搞成了线程a的后继节点,于是在下一次循环时,if(p==head && tryAcquire(arg)) 将成立并在不出意外的情况下线程b成功获取锁!最后整个acquire()方法执行结束。

至此,线程a释放锁->线程b(等待状态)获取锁,形成了一个(自动)闭环。 最后我们还是画个图一览释放锁 的逻辑:

3.2、共享锁

由于SemaphoreCountDownLatch这俩并发工具是基于AQS共享锁实现的而且这俩货在实际开发中,还真有可能用上,所以我们有必要学学AQS共享模式,来彻底理解这些工具的底层原理。

下边我们就以Semaphore为例,来切入AQS共享锁加锁解锁逻辑!

让我们看下下边这个代码,演示了基于AQS共享锁实现的Semaphore并发工具类的使用: Semaphore说白了就是:令牌机制,比如说有3个令牌,在某一时刻。最多只允许3个线程去执行被令牌保护的逻辑(没拿到的线程就等待),每次执行完逻辑后,把令牌归还,好让其他线程去获取并执行(有点一夫当关万夫莫开的意思哈哈!)。

semaphore.acquire();方法是获取令牌,semaphore.release();方法是归还令牌就是这么简单。

共享模式下的state说明: 有个点我们要很清楚,共享模式下的资源state是提前申请的,在获取共享锁后是对AQS的 state -1,而不是排他锁那样获取锁后state+1 ,比如Semaphore semaphore = new Semaphore(3);这个代码就是在向AQS的state变量赋值(将state赋值为3),最终执行代码如下:

3.2.1、 AQS【加共享锁】源码分析与图解

当我们跟进semaphore.accquire()方法后,发现

其Semaphore内部是如下这么调用的: 继续跟进发现AQS中的代码长如下这样: 如果 tryAcquireShared 返回大于等于0,代表获取共享锁成功,但不用立即唤醒后继节点,小于 0 则表示获取失败,如果获取共享资源失败即tryAcquireShared<0成立,就要进入等待队列了(即doAcquireSharedInterruptibly内部的逻辑)。

tryAcquireShared加的共享锁(注意tryAcquireShared也是AQS留给子类的钩子方法,想找实现必然得去AQS的子类中找)下面我们进入进一步分析:

java 复制代码
public class Semaphore implements java.io.Serializable {
...

    abstract static class Sync extends AbstractQueuedSynchronizer {
    ...

    static final class NonfairSync extends Sync {
        private static final long serialVersionUID = -2694183684443567898L;

        NonfairSync(int permits) {
            super(permits);
        }

        protected int tryAcquireShared(int acquires) {
            return nonfairTryAcquireShared(acquires);
        }
    }

    //此处真正实现了AQS的tryAcquireShared钩子方法。
    final int nonfairTryAcquireShared(int acquires) {
        for (;;) {
            //获取到AQS的资源 state
            int available = getState();
            //获取锁时,将可用值state减一(注意这里可不是排他锁时候的+1)
            int remaining = available - acquires;
            //如果剩余可用资源<0说明已经没有资源可用,直接返回负数,如果cas成功则说明还有资源可用,返回剩余资源数量remaining
            if (remaining < 0 ||
                compareAndSetState(available, remaining))
                return remaining;
        }
    }

if (tryAcquireShared(arg) < 0)成立时(此时也代表没有资源可用了,也即获取锁失败 )则会进入等待队列,具体细节在doAcquireSharedInterruptibly()方法中,我们看下源码:

java 复制代码
private void doAcquireSharedInterruptibly(int arg)
    throws InterruptedException {
    //和排他锁加锁 acquire()方法的逻辑差不多
    final Node node = addWaiter(Node.SHARED);
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        throw t;
    }
}

简单说明一下doAcquireSharedInterruptibly的逻辑:

  1. 通过addWaiter方法(注意传入的锁模式是共享模式)添加当前线程对应Node(共享类型的Node)到等待队列,(addWaiter方法我们在排他锁说过了此处不过多啰嗦)
  2. 自旋,找当前节点的前驱节点,如果前驱是head则尝试再次获取共享锁,如果返回的值>0则说明获取锁成功(有剩余可用资源),调用setHeadAndPropagate方法,咦?这个方法好像第一次见,排他锁加锁没有见过,是啥玩意?一会说
  3. shouldParkAfterFailedAcquire这个方法是老朋友了,排他锁加锁分析中我们唠叨过,不再分析。

其实梳理下来可以发现: doAcquireSharedInterruptibly()方法实现上和排他锁的加锁方法acquire()方法差不多,就是多判断了是否还有剩余资源(r其实就是state-1的值),唤醒后继节点,为啥要唤醒后继节点?排他锁模式下线程a抢锁成功后可没有唤醒后继节点的操作啊?那是因为:既然一个线程刚获得了共享锁,那么很有可能还有剩余的共享锁,可供排队在后面的线程获得,所以需要唤醒后面的线程,让他们也来试试!(真是好兄弟啊,发家了不忘兄弟我😄)

第2步里我们说了setHeadAndPropagate,这里我们看一下他说干啥的?(上源码!)

java 复制代码
//值的注意的 propagate是state-1的值
private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // Record old head for check below
    setHead(node);
    
    //如果还有剩余资源或者头节点是null或者头节点的状态不是CANCELLED或者   h = head) == null )
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        //注意这里唤醒的是共享模式类型的后继节点!
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

//共享模式下的唤醒
private void doReleaseShared() {
    //自旋
    for (;;) {
        Node h = head;
        //如果头节点不是空并且头节点不等于尾节点
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            //如果头节点的状态是 SIGNAL 说明头节点在释放锁后必须要唤醒后继节点
            if (ws == Node.SIGNAL) {
                //尝试CAS操作,将头节点的SIGNAL状态变为 0 ,此处将compareAndSetWaitStatus(h, Node.SIGNAL, 0)
                //和unparkSuccessor(h)绑定在了一起。说明了只要head成功得
                //从SIGNAL修改为0(返回true不走if),那么head的后继的代表线程肯定会被唤醒了
                if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                //如果成功,则唤醒后继节点(与排他锁模式下的唤醒逻辑一致,不过多解释了)
                unparkSuccessor(h);
            }
            //如果头节点是0 初始状态,则尝试将头节点变为PROPAGATE状态(以便后续的传播)
            else if (ws == 0 &&
                     !h.compareAndSetWaitStatus(0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

什么情况下waitStatus=0? : 一个进入队列的线程挂起前一定会将前置节点置为 SIGNAL 状态,而一个节点一旦进入了 SIGNAL 状态,只有在后继节点被唤起时才会被更改,且改回 0 状态。所以,一个后继节点不为空的节点的状态为 0,那只有两种情况,一是后继节点持有的线程还没被挂起,还没来得及将前继节点状态改成 SIGNAL (比如Node刚创建时),二是后继节点持有的线程已经被唤醒。

上边的doReleaseShared方法逻辑梳理一下大概如此:

  1. 判断头节点等待状态,如果是SIGNAL则唤醒后继节点
  2. 如果头节点状态是0 则将其改为PROPAGATE,明确告知头节点,你释放锁后唤醒的不仅仅是后继节点啊~,而是后继所有等待的共享模式的节点,需要传播下去!(忘记了的回到2.2看下AQS结构认识有说PROPAGATE是干啥的) ps: 这段doReleaseShared的方法,步骤解释很清楚,但是如果你要串联起来还是有点难度的至少我个人这么认为。有些地方没必要细抠,总之我们知道doReleaseShared的核心就是 线程a抢到一个共享资源,唤醒后继那些共享节点,让这些等待资源的兄弟也来试试!

我们还是一贯的画个图,更加通透一些,共享锁加锁示意图如下:

3.2.2、AQS【解共享锁】源码分析与图解

共享锁的解锁,其实源码很简单了,我们过一下

一样的,我们还是以Semaphore为例看下解锁即从release切入debug如下:

tryReleaseShared源码如下(主要逻辑就是归还state,也即对state+1 并CAS赋值给AQS state):

java 复制代码
protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;
        if (next < current) // overflow
            throw new Error("Maximum permit count exceeded");
        if (compareAndSetState(current, next))
            return true;
    }
}

而对于doReleaseShared这个方法,我们上边再说共享锁加锁后,唤醒后继等待的那些共享节点时,已经分析过了,这里不在啰嗦重复。

可以看到最终解锁就是两个逻辑

  1. tryReleaseShared:对state进行+1 ,即释放1个资源,让给其他等待的共享节点
  2. doReleaseShared:唤醒当前节点的后继节点通过unpark操作
  3. 唤醒后的主动抢锁逻辑,就依靠共享锁加锁那里的自旋来实现了,即这个逻辑: 从而形成了一个 释放锁->唤醒后继节点->后继节点通过自旋抢锁的闭环操作(排他锁也是这个主逻辑,我们上边也说过)。

4、AQS总结

在本文,我们先对AQS是啥做了概念介绍,之后对AQS的主要一些成员结构混了个面熟,知道这些东西,在分析源码时我们才能更好的从宏观/整体视角来看待整个流程,之后我们深入AQS源码分析了 排他锁加/解锁共享锁加/解锁的逻辑并给出了图解(注意图解很重要,我还是那句话一图剩前言!)。

我想如果你把AQS啃明白,那么对于 ReentrantLock/Semaphore/CountDownLatch/ReentrantReadWriteLock/ThreadPoolExecutor中的Worker/以及jdk之外的很多开源项目 我想应该会触类旁通的!因为他们都是基于AQS实现,只是加解锁逻辑部分有所不同而已。

本来我们想把AQS的ConditionObject这货也说了,但是现在字数有点多,写文章的页面很卡,索性我们放在下一篇来对ConditionObject进行分析。

相关推荐
沈询-阿里36 分钟前
java-智能识别车牌号_基于spring ai和开源国产大模型_qwen vl
java·开发语言
AaVictory.42 分钟前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
Yaml41 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
LuckyLay1 小时前
Spring学习笔记_27——@EnableLoadTimeWeaving
java·spring boot·spring
向阳12181 小时前
Dubbo负载均衡
java·运维·负载均衡·dubbo
Gu Gu Study2 小时前
【用Java学习数据结构系列】泛型上界与通配符上界
java·开发语言
测试19982 小时前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
小码编匠2 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#
WaaTong2 小时前
《重学Java设计模式》之 原型模式
java·设计模式·原型模式