AQS 核心原理


AQS 的全称是 AbstractQueuedSynchronizer(队列同步器),它是 Java 提供的一个抽象类,位于 java.util.concurrent.locks 包下。是各种各样锁的基础,比如说 ReentrantLock、CountDownLatch 等等,这些我们经常用的锁底层实现都依赖于 AQS,由此可见这个类的重要性,所以学好 AQS 对于后面理解锁的实现是非常重要的。
AQS 类定义代码如下:

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable 

一、AQS 核心数据结构

AQS 内部主要维护了一个 FIFO(先进先出)的双向链表。

1、AQS 数据结构原理

AQS 内部维护的双向链表中的各个节点分别指向直接前驱节点和直接的后继节点。所以,在 AQS 内部维护的双向链表可以从其中任意一个节点遍历前驱节点和后继节点。
链表中的每个节点其实都是对线程的封装,在并发场景下,如果某个线程竞争锁失败,就会被封装成一个 Node 节点加入 AQS 队列的末尾。当获取到锁的线程释放锁后,会从 AQS 队列中唤醒一个被阻塞的线程。同时,在 AQS 中维护一个使用 volatile 修饰的变量 state 来标识相应的状态。

// 子类会根据状态字段进行判断是否可以获得锁
// 比如 CAS 成功给 state 赋值 1 算得到锁,赋值失败为得不到锁
// CAS 成功给 state 赋值 0 算释放锁,赋值失败为释放失败
private volatile long state;

AQS 内部数据结构如图所示:
image.png
从图中可以看出,在 AQS 内部的双向链表中,每一个节点都是对线程的封装。同时会存在一个头节点指针指向链表的头部,存在一个尾节点指针指向链表的尾部。头节点指针和尾节点指针会通过 CAS 操作改变链表中节点的指向。另外,头节点指针指向的节点封装的线程会被占用资源。

2、AQS 内部队列模式

同步队列

从本质上讲,AQS 内部实现了两个队列,一个是同步队列,另一个时条件队列。同步队列的结构如下:
image.png
在同步队列中,如果当前线程获取资源失败,就会通过 addWaiter 方法将当前线程放入队列的尾部,并且保持自旋等待的状态,不断判断自己所在的节点是否是队列的头节点。如果自己所在的节点就是头节点,那么此时就会不断尝试获取资源,如果资源获取成功,则通过 acquire 方法退出同步队列。

当多个线程都来请求锁时,某一时刻有且只有一个线程能够获得锁(排它锁),那么剩余获取不到锁的线程,都会到同步队列中去排队并阻塞自己,当有线程主动释放锁时,就会从同步队列头开始释放一个排队的线程,让线程重新去竞争锁。
所以同步队列的主要作用阻塞获取不到锁的线程,并在适当时机释放这些线程。

同步队列属性如下:

// 头节点指针
private transient volatile Node head;

// 尾节点指针
private transient volatile Node tail;

源码中的 Node 是同步队列中的元素,但 Node 被同步队列和条件队列公用,我们在后面再说 Node。

条件队列

条件队列和同步队列的功能一样,管理获取不到锁的线程,底层数据结构也是链表队列,但条件队列不直接和锁打交道,但常常和锁配合使用,是一定的场景下,对锁功能的一种补充。
AQS 条件队列的结构如下:
image.png
AQS 中条件队列就是为 Lock 锁实现的一个基础同步器,只有在使用了 Condition 时才会存在条件队列,并且一个线程可能存在多个条件队列。

public class ConditionObject implements Condition, java.io.Serializable {
        private static final long serialVersionUID = 1173984872572414699L;
        /** First node of condition queue. */
        // 条件队列中第一个 node
        private transient Node firstWaiter;
        /** Last node of condition queue. */
        // 条件队列中最后一个 node
        private transient Node lastWaiter;
        //...//
}        

二、AQS 底层锁的支持

AQS 底层支持独占锁和共享锁两种模式。其中,独占锁同一时刻只能被一个线程占用,共享锁则在同一时刻可以被多个线程占用。

1、核心状态位

在 AQS 中维护了一个 volatile 修饰的核心状态标识 state,用以标识锁的状态

private volatile long state;

AQS 针对 state 变量提供了 getState 方法来读取 state 变量的值,提供了 setState 方法来设置 state 的值。由于 setState 方法无法保证原子性,所以,AQS 中又提供了 compareAndSetState 方法保证修改 state 变量的原子性。

protected final int getState() {
    return state;
}

protected final void setState(int newState) {
	state = newState;
}

protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

2、核心节点类

AQS 实现的独占锁和共享锁模式都是在其静态内部类 Node 中定义的,源码如下:

    static final class Node {
        
        static final Node SHARED = new Node();
      
        static final Node EXCLUSIVE = null;

        static final int CANCELLED =  1;
       
        static final int SIGNAL    = -1;
        
        static final int CONDITION = -2;
    
        static final int PROPAGATE = -3;

        // 当前的状态
        volatile int waitStatus;

	    // 当前节点的前节点
    	// 节点获得成功后就会变成head
    	// head 节点不能被取消
        volatile Node prev;

        // 当前节点的下一个节点
        volatile Node next;

        // 当前节点的线程
        volatile Thread thread;

        Node nextWaiter;

        final boolean isShared() {
            return nextWaiter == SHARED;
        }

        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }

        Node() {   
        }

        Node(Thread thread, Node mode) {     
            this.nextWaiter = mode;
            this.thread = thread;
        }

        Node(Thread thread, int waitStatus) { 
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

通过源码,我们可以看到 Node 是一个双向链表,链表中的每个节点都存在一个指向直接前驱节点的指针 prev 和一个指向后继节点的指针 next,每个节点都保存了当前的状态 waitStatus 和当前线程 thread。并且在 Node 类中通过 SHARED 和 EXCLUSIVE 将其定义成共享和独占模式。

    //node 指示节点在共享模式下等待的标记
    static final Node SHARED = new Node();

    //node 指示节点正在以独占模式等待的标记
    static final Node EXCLUSIVE = null;

在 Node 类中定义了 4 个常量,如下所示:

static final int CANCELLED =  1;

static final int SIGNAL    = -1;

static final int CONDITION = -2;

static final int PROPAGATE = -3;

其中,每个常量的含义如下:

  1. CANCELLED:表示当前节点中的线程已经被取消。
  2. SIGNAL:表示后继节点中的线程处于等待状态,需要被唤醒。
  3. CONDITION:表示当前节点中的线程在等待某个条件,也就是当前节点处于 condition 队列中(条件队列中) 当有节点从同步队列转移到条件队列时,状态就会被更改成 CONDITION。
  4. PROPAGATE:表示当前场景下能够执行后续的 acquireShared 操作(共享模式下,该状态的进程处于可运行状态)
volatile int waitStatus;

waitStatus 的取值就是上面的 4 个常量值,在默认情况下,waitStatus 的取值为 0,表示当前节点等待获取锁。

3、独占锁模式

在 AQS 中,独占锁模式比较常用,使用范围也比较广泛,它的一个典型实现就是 ReentrantLock 锁。独占锁的加锁和解锁都是通过互斥实现的。

3.1 独占锁加锁流程

在 AQS 中,独占模式中加锁的核心入口是 acquire 方法,方法如下所示:

	// 排他模式下,尝试获得锁
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

当某个线程调用 acquire 方法获取独占锁时,在 acquire 方法中会首先调用 tryAcquire 方法尝试获取锁资源,tryAcquire 方法 AQS 中直接抛出一个异常,具体的逻辑由 AQS 的子类实现,如下所示

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

当 tryAcquire 方法返回 false 时,首先会调用 addWaiter 方法将当前线程封装成独占模式的节点,添加到 AQS 队列的尾部。addWriter 方法的源码如下:

	// 方法传入的参数 mode 表示 当前线程的节点
	// return node 表示新增的node
	private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // 当前节点的前置节点为 tail
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            // 以 CAS 的方式去设置尾节点 compareAndSetTail
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
		// 有可能CAS会失败,因为存在竞争,所以进入 enq 环节
        // enq 自旋保证 node 加入队尾
        enq(node);
        return node;
    }

在 addWaiter 方法中,当前线程会被封装成独占模式的 Node 节点,Node 节点被尝试放入队列的尾部,如果放入成功,则通过 CAS 操作修改 Node 节点与前驱节点的指向关系。如果 Node 节点放入队列尾部失败或者 CAS 操作失败,则调用 enq 方法处理 Node 节点。
enq 方法源码如下:

	// 线程加入同步队列队尾的方法
	// 这里注意一下,返回值是添加 node 的前一个节点
	private Node enq(final Node node) {
        // 自旋,直到成功为止,或者直到放弃为止
        for (;;) {
            Node t = tail;
            // 如果队尾为空,则说明当前同步队列没有进行初始化,进行初始化
            if (t == null) { 
                // 创建一个空节点作为 head 节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                // 如果队尾不为空,则将当前节点追加到队尾
                node.prev = t;
                // node追加到队尾
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

在 enq 方法中,Node 节点是通过 CAS 自旋的方式被添加到队列尾部,直到添加成功为止。具体的实现方式是判断队列是否为空,如果队列为空,则创建一个空节点作为 head 节点,同时将 tail 指向 head 节点。在下次自旋时,就会满足队列不为空的条件,通过 CAS 的方式将 Node 节点放入队列尾部。
此时,回到 acquire 方法,当通过调用 addWriter 成功将当前线程封装成独占模式的 Node 节点放入队列后,会调用 acquireQueued 方法在等待队列中排队。
下一步就是要阻塞当前线程了,是 acquireQueued 方法来实现的,即队列中的节点什么时候阻塞,什么时候唤醒由 acquireQueued 去决定,这个方法主要做了如下几件事:

  1. 通过不断的自旋尝试使自己的前一个节点的状态变成 signal 状态,然后阻塞自己;
  2. 获得锁的线程执行完毕之后,释放锁时,会把阻塞的 node 唤醒,node 唤醒之后再次自旋,尝试获得锁;
  3. 返回 false 表示获得锁成功,返回 true 则表示失败。

我们来看下源码实现:

    final boolean acquireQueued(final Node node, int arg) {
        // 标识是否成功获取到锁
        boolean failed = true;
        try {
            // 是否被中断
            boolean interrupted = false;
            // 自旋
            for (;;) {
                // 获取当前节点的前驱节点,p 代表前置结点
                final Node p = node.predecessor();
                // 如果前驱节点是 head 节点,则尝试获取锁
                // 有两种情况会走到 p == head
                // 1. node之前没有获得锁,进入 acquireQueued 方法时,才发现它的前置节点就是头节点,于是尝试获得一次锁
                // 2. node之前一直在阻塞沉睡,然后被唤醒,此时唤醒 node 的节点正是其前一个节点,也能走到if
                // 如果自己 tryAcquire (尝试抢锁) 成功,就立刻讲自己设置成为 head,并且把上一个节点移除
                // 如果自己 tryAcquire 失败,尝试进入同步队列
                if (p == head && tryAcquire(arg)) {
                    // 如果成功获取到资源,则将 head 指向当前节点
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    // 返回是否被中断过的标识
                    return interrupted;
                }
                // shouldParkAfterFailedAcquire 把 node 的前一个节点设置为signal
                // 只要前一个节点的状态是 signal(处于等待状态) 了,那么自己就可以阻塞了
                if (shouldParkAfterFailedAcquire(p, node) &&
                    // 线程是在这个方法中阻塞的,醒来的时候仍在无限 for 循环里面,就能再次自旋尝试获得锁
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            // 如果获得 node 的锁失败,将 node 从队列中移除
            if (failed)
                cancelAcquire(node);
        }
    }

在 acquireQueued 方法中,首先定义了一个 failed 变量来标识获取资源是否失败,默认值为 true,表示获取资源失败。然后,定义一个表示当前线程是否被中断过的标识 interrupted,默认值为 false,表示没有被中断过。
最后,进入一个自旋逻辑,获取当前 Node 节点的前驱节点,如果当前 Node 节点的前驱节点是 head 节点,则表示当前 Node 节点可以尝试获取资源。如果当前节点获取资源成功,则将 head 指向当前节点。也就是说,head 节点指向的 Node 节点就是获取到资源的节点或者为 null。
在 setHead 方法中,当前节点的 prev 指针会被设置为 null,随后当前 Node 节点的前驱节点的 next 指针被设置为 null,表示 head 节点出队列,整个操作成功后会返回等待过程中是否被中断过的标识。

    private void setHead(Node node) {
        head = node;
        node.thread = null;
        node.prev = null;
    }

如果当前节点的前驱节点不是 head,则调用 shouldParkAfterFailedAcquire 方法判断当前线程是否可以进入 waiting 状态,如果可以进入阻塞状态,则进入阻塞状态直到调用 LockSupport 的 unpark 方法唤醒当前线程。
shouldParkAfterFailedAcquire 源码如下:

	// 当前线程可以安心阻塞的标准,就是前一个节点的线程状态是 signal 了
	// 传入的参数 pred 表示前一个节点, node 表示当前节点
	/* 
	 * 关键操作:
	 * 1. 确认前一个节点是否有效。无效的话,一直往前寻找状态不是取消的节点
	 * 2. 把前一个节点的状态设置为 signal
	 * 1、2这两步的操作,有可能一次就成功,也有可能需要外部循环多次才能成功(外面是多个for循环)
     */
	private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // 获取前驱节点的状态
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            // 如果前驱节点的状态为 SIGNAL(-1),则返回 true
            // 直接返回,不需要再自旋了
            return true;
        if (ws > 0) {
            // 找到前一个状态不是取消的节点,因为把当前 node 挂在有效节点的身上
        	// 因为节点的状态是取消的话,是无效的,是不能作为 node 的前置节点的,所以必须找到 node 的有效节点才行
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            // 否则直接把节点状态设置为 signal
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

在 shouldParkAfterFailedAcquire 方法中,先获取当前节点的前驱节点的状态,如果前驱节点的状态为 SIGNAL(-1),则直接返回 true。如果前驱节点的状态大于 0,则当前节点一直向前移动,直到找到一个 waitStatus 状态小于或者等于 0 的节点,排在这个节点的后面。
在 acquireQueued 方法中,如果 shouldParkAfterFailedAcquire 方法返回 true,则调用 parkAndCheckInterrupt 方法阻塞当前线程,源码如下:

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

acquire 整个过程非常长,代码也非常多,但注释很清楚,可以一行一行仔细看看代码。
总结
acquire 方法大致分为三步:

  1. 使用 tryAcquire 方法尝试获得锁,获得锁直接返回,获取不到锁的走 2;
  2. 把当前线程组装成节点(Node),追加到同步队列的尾部(addWaiter);
  3. 自旋,使同步队列中当前节点的前置节点状态为 signal 后,然后阻塞自己。
3.2 独占锁释放锁流程

在独占锁模式中,释放锁的核心入口方法是 release ,如下所示

    public final boolean release(int arg) {
    	// tryRelease 交给实现类去实现,如果返回true则说明成功释放锁
        if (tryRelease(arg)) {
            Node h = head;
            // 头节点不为空 && 非初始化状态
            if (h != null && h.waitStatus != 0)
                // 从头开始唤醒等待锁的节点
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

在 release 方法中,会先调用 tryRelease 方法尝试释放锁,tryRelease 方法会在 AQS 中同样没有具体的实现逻辑,只是简单地抛出了 UnsupportedOperationException 异常,具体的逻辑交由 AQS 的子类实现,如下所示:

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

在 release 方法中,如果 tryRelease 方法返回 true,则会先获取 head 节点,当 head 节点不为空,并且 head 节点的 waitStatus 状态不为 0 时,会调用 unparkSuccessor 方法,并将 head 节点传入方法中。
unparkSuccessor 源码如下:

    // 当线程释放锁成功后,从 node 开始唤醒同步队列中的节点
	// 通过唤醒机制,保证线程不会一直在同步队列中阻塞等待
	private void unparkSuccessor(Node node) {
    	// node节点为当前释放锁的节点,也是同步队列的头结点
        int ws = node.waitStatus;
    	// 如果节点已经被取消了,把节点的状态设置为初始化
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
		// 拿出 node 节点的下一个节点
        Node s = node.next;
		// s 为空,表示 node 的后一个节点为空
        // s.waitStatus > 0 表示s节点已经被取消了
        // 遇到上面两种情况,就从队尾开始,向前遍历,找到第一个 waitStatus 字段不是被取消的节点
        if (s == null || s.waitStatus > 0) {
            s = null;
            // 这个for循环,从尾部开始迭代
            // 主要是因为节点被阻塞的时候,是在acquiredQueued方法里面被阻塞的(上面已经介绍了这个方法)
            // 所以唤醒的时候也一定是在acquiredQueued方法里面被唤醒
         	// 唤醒的条件是,判断判断当前节点的前置节点是否为头结点,这里是判断当前节点的前置节点
         	// 所以这里必须从尾部开始迭代,目的就是过滤无效的前置节点,不然节点被唤醒时,发现前置节点是无效节点的话,就又会陷入阻塞
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
		// 唤醒以上代码找到的线程
        if (s != null)
            LockSupport.unpark(s.thread);
    }

至此,独占模式下的锁释放流程分析完毕。

4、共享锁模式

在 AQS 中,共享锁模式下的加锁和释放锁操作与独占锁不同。

4.1 共享模式加锁流程

在 AQS 中,共享模式下的加锁操作核心入口方法是 acquireShared,如下所示:

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }

acquireShared 整体流程和 acquire 相同,代码也很相似,重复的源码就不贴了,我们就贴出来不一样的代码来,也方便大家进行比较:
第一处尝试获得锁的地方,有所不同,排它锁使用的是 tryAcquire 方法,共享锁使用的是 tryAcquireShared 方法,如下图:
image.png
第二处不同,在于节点获得排它锁时,仅仅把自己设置为同步队列的头节点即可(setHead 方法),但如果是共享锁的话,还会去唤醒自己的后续节点,一起来获得该锁(setHeadAndPropagate 方法),不同之处如下
image.png
doAcquireShared 方法的主要逻辑就是将当前线程放入队列的尾部并阻塞,直到有其他线程释放资源并唤醒当前线程,当前线程在获取到指定量的资源后返回。
在 doAcquireShared 方法中,如果当前节点的前驱节点是 head 节点,则尝试获取资源;如果获取资源成功,则调用 setHeadAndPropagate 方法将 head 指向当前节点;同时如果还有剩余资源,则继续唤醒队列中的后面的线程。
setHeadAndPropagate 源码如下:
这个方法主要做了两件事:

  1. 把当前节点设置成头节点
  2. 看看后续节点有无正在等待,并且也是共享模式的,有的话唤醒这些节点
    private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; 
        // 当前节点设置成头节点
        setHead(node);
        // propagate > 0 表示已经有节点获得共享锁了
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            // 共享模式,还唤醒头节点的后置节点
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }

这个就是共享锁独特的地方,当一个线程获得锁后,它就会去唤醒排在它后面的其它节点,让其它节点也能够获得锁。

4.2 共享模式释放锁流程

在共享模式下,释放锁的核心入口方法是 releaseShared ,如下所示:

    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

doReleaseShared 方法主要用来唤醒队列中后面的线程,源码如下:

	// 释放后置共享节点
	private void doReleaseShared() {
    	for (;;) {
        	Node h = head;
        	// 还没有到队尾,此时队列中至少有两个节点
        	if (h != null && h != tail) {
            	int ws = h.waitStatus;
            	// 如果头节点状态是 SIGNAL ,说明后续节点都需要唤醒
            	if (ws == Node.SIGNAL) {
                	// CAS 保证只有一个节点可以运行唤醒的操作
                	if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    	continue;           
                	// 进行唤醒操作
                	unparkSuccessor(h);
            	}
            	else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                	continue;              
        	}
        	if (h == head)                
            	break;
    	}
    }

在 doReleaseShared 方法中,通过自选的方式获取头节点,当头节点不为空,且队列不为空时,判断头节点的 waitStatus 的值是否为 SIGNAL(-1)。当满足条件时,会通过 CAS 的方式将头节点的 waitStatus 状态值设置为 0,如果 CAS 操作失败,则继续自旋。如果 CAS 操作设置成功,则唤醒队列中的后继节点。
如果头节点的 waitStatus 状态值为 0,并且在通过 CAS 操作将头节点的 waitStatus 状态设置为 PROPAGATE(-3) 时失败,则继续自旋逻辑。
如果在自旋的过程中发现没有后继节点了,则退出自旋逻辑。

三、总结

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SuZhan7710

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值