AQS的定义

​ 队列同步器 AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。

队列同步器的接口与示例

同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。重写同步器指定的方法时,需要使用同步器提供的如下 3 个方法来访问或修改同步状态。

  • getState():获取当前同步状态。

  • setState(int newState):设置当前同步状态。

  • compareAndSetState(int expect,int update):使用 CAS 设置当前状态,该方法能够保证状态设置的原子性。

同步器可重写的方法与描述如下表所示。

方法名称 描述信息
protected boolean tryAcquire(int arg) 独占式获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后再进行CAS设置同步状态
protected boolean tryAcquire(int arg) 独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态。
protected int tryAcquireShared(int arg) 共享式获取同步状态,返回大于等于0的值,表示获取成功,反之,失败。
protected boolean tryReleaseShared(int arg) 共享式释放锁。
protected boolean isHeldExclusively() 判断当前线程是否在独占模式下被线程占用,一般该方法表示是否被当前线程所独占。

实现自定义同步组件的同时,将会调用同步器提供的模板方法,这些(部分)模板方法与描述下表所示。

方法名称 描述
public final void acquire(int arg) 独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将会进入同步队列等待,该方法将会调用重写的tryAcquire(int arg)方法。
public final void acquireInterruptibly(int arg) 与acquire(int arg) 相同,但是该方法响应中断,当前线程未获取到同步状态而进入同步队列中,如果当前线程被中断,则该方法会抛出InterruptedException 异常。
public final boolean tryAcquireNanos(int arg, long nanosTimeout) 在acquireInterruptibly(int arg) 的基础上增加了超时限制,如果当前线程在超时时间内没有获取同步状态,那么会返回false,如果获取到了返回true。
public final void acquireShared(int arg) 共享式的获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列等待,与独占式获取锁的主要区别是在同一时刻可以有多个线程获取到同步状态。
public final void acquireSharedInterruptibly(int arg) 与acquireInterruptibly(int arg) 方法相同,该方法响应中断。
public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) 与acquireShared(int arg) 相同,增加了超时限制。
public final boolean release(int arg) 独占式的释放同步状态,该方法会在释放同步状态之后,将同步队列中第一个节点包含的线程唤醒。
public final boolean releaseShared(int arg) 共享式释放同步状态。
public final Collection getQueuedThreads() 获得在同步队列上的线程集合。

​ 同步器提供的模板方法基本上分为 3 类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。

自定义独占锁代码示例

通过重写模板模式的钩子方法实现自定义独占锁。

class Mutex implements Lock {
// 内部静态类自定义同步器
private static class Sync extends AbstractQueuedSynchronizer {
// 是否处于占用状态
@Override
protected boolean isHeldExclusively() {
return this.getState() == 1;
}
// 当状态为0时获取到锁
@Override
protected boolean tryAcquire(int arg) {
if (this.compareAndSetState(0,1)){
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
//释放锁,将锁状态设置为0
@Override
protected boolean tryRelease(int arg) {
if (getState() == 0){
throw new IllegalMonitorStateException();
}
this.setExclusiveOwnerThread(null);
this.setState(0);
return true;
}
//返回一个Condition,每个condition都包含了一个condition队列
Condition newCondition(){
return new ConditionObject();
}
}
// 仅需将操作代理到Sync上面。
private Sync sync = new Sync(); @Override
public void lock() {
sync.acquire(1);
} @Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
} @Override
public boolean tryLock() {
return sync.tryAcquire(1);
} @Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1,unit.toNanos(time));
} @Override
public void unlock() {
sync.release(1);
} @Override
public Condition newCondition() {
return sync.newCondition();
}
}

​ 上述示例中,独占锁 Mutex 是一个自定义同步组件,它在同一时刻只允许一个线程占有锁。Mutex 中定义了一个静态内部类,该内部类继承了同步器并实现了独占式获取和释放同步状态。在 tryAcquire(int acquires)方法中,如果经过 CAS 设置成功(同步状态设置为 1),则代表获取了同步状态,而在 tryRelease(int releases)方法中只是将同步状态重置为 0。用户使用 Mutex 时并不会直接和内部同步器的实现打交道,而是调用 Mutex提供的方法,在 Mutex 的实现中,以获取锁的 lock()方法为例,只需要在方法实现中调用同步器的模板方法 acquire(int args)即可,当前线程调用该方法获取同步状态失败后会被加入到同步队列中等待,这样就大大降低了实现一个可靠自定义同步组件的门槛。

队列同步器的实现分析

接下来将从实现角度分析同步器是如何完成线程同步的,主要包括:

  • 同步队列

  • 独占式同步状态获取与释放、

  • 共享式同步状态获取与释放

  • 超时获取同步状态等同步器的核心数据结构与模板方法。

1 同步队列

​ 同步器依赖内部的同步队列(一个 FIFO 双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,节点的属性类型与名称以及描述如下表所示。

属性类型和名称 描述
int waitStatus 用来表示当前节点在队列中的状态,包含以下状态:
0 当一个Node被初始化的时候的默认值
CANCELLED 为1,表示线程获取锁的请求已经取消了
CONDITION 为-2,表示节点在等待队列中,节点线程等待唤醒
PROPAGATE 为-3,当前线程处在SHARED情况下,该字段才会使用
SIGNAL 为-1,表示线程已经准备好了,就等资源释放了
Node prev 前驱节点,当节点加入同步队列时设置
Node next 后续节点
Thread thread 获取同步状态的线程
Node nextWaiter 等待队列中的后继节点。如果当前节点是共享的,那么这个字段将是一个 SHARED常量,也就是说节点类型(独占和共享)和等待队列中的后继节点共用同一个字段

​ 节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部,同步队列的基本结构如下图所示。

​ 在上图中,同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于 CAS 的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。

​ 同步器将节点加入到同步队列的过程如下图所示。

​ 同步队列遵循 FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,该过程如下图所示。

​ 在上图 中,设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用 CAS 来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的 next 引用即可。

2.通过ReentrantLock理解AQS

ReentrantLock中公平锁和非公平锁在底层是相同的,这里以非公平锁为例进行分析。

在非公平锁中,有一段这样的代码:

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
// 获取失败,调用AQS模板方法acquire(int arg)
acquire(1);
} protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
2.1 acquire(int arg)方法

​ 接下来看看acquire的源码,acquire方法在上面介绍了,他的功能是独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将会进入同步队列等待,该方法将会调用重写的tryAcquire(int arg)方法。这段代码主要完成了同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作,其主要逻辑是:首先调用自定义同步器实现的 tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式 Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node) 方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

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

先来看看ReentrantLock在非公平锁中重写的 tryAcquire(arg)方法

protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
} final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取当前锁的同步状态
int c = getState();
if (c == 0) {
// 如果同步状态为0,尝试获取锁同步状态
if (compareAndSetState(0, acquires)) {
// 获取锁同步状态成功,设置当前锁占用线程为当前线程
setExclusiveOwnerThread(current);
// 返回获取锁成功
return true;
}
}
// 判断当前锁占用线程是否是当前线程,用来实现可重入锁逻辑
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 获取锁失败,调用 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法。
return false;
}

上面这段代码中可以得知如果当前线程调用tryAcquire(arg) 方法失败后会继续调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法。

2.1.2acquireQueued(addWaiter(Node.EXCLUSIVE), arg)

下面我们先看addWaiter(Node.EXCLUSIVE) 源码。

private Node addWaiter(Node mode) {
// 通过当前线程和锁模式(这里是Node.EXCLUSIVE 独占模式)封装Node节点
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
// pred 指针指向尾节点
Node pred = tail;
// 判断指针指向的tail节点是否为null
if (pred != null) {
// 如果tail节点不为null,尝试将node放入到同步队列尾部。
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
// 放入成功,放回node节点
return node;
}
}
// 尾节点设置失败或者说tail为null,调用enq方法
enq(node);
return node;
} /**
* 同步器通过“死循环”来保证节点的正确添加,在“死循环”中只有通过 CAS 将节点设置成为尾节点之后,当前线程才能从该方法返回
* 否则,当前线程不断地尝试设置。
*/
private Node enq(final Node node) {
for (;;) {
// t 指向尾节点tail
Node t = tail;
// 判断 tail是否为null
if (t == null) { // Must initialize
// 如果为null尝试创建同步队列第一个节点(虚拟节点)
if (compareAndSetHead(new Node()))
// 创建虚拟节点成功,将AQS的尾节点指针也指向这个虚拟节点
tail = head;
} else {
// 将node节点加入到队列尾部
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}

​ 上述代码通过使用 compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全添加。试想一下:如果使用一个普通的 LinkedList 来维护节点之间的关系,那么当一个线程获取了同步状态,而其他多个线程由于调用 tryAcquire(int arg)方法获取同步状态失败而并发地被添加到 LinkedList 时,LinkedList 将难以保证 Node 的正确添加,最终的结果可能是节点的数量有偏差,而且顺序也是混乱的。

​ 在 enq(final Node node)方法中,同步器通过“死循环”来保证节点的正确添加,在“死循环”中只有通过 CAS 将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置。可以看出,enq(final Node node)方法将并发添加节点的请求通过 CAS 变得“串行化”了。

双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。


下面继续查看方法acquireQueued(addWaiter(Node.EXCLUSIVE), arg)

​ 节点进入同步队列之后,就进入了一个自旋的过程,每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程),代码如下

final boolean acquireQueued(final Node node, int arg) {
// 标记是否成功拿到资源
boolean failed = true;
try {
// 标记等待过程中是否中断过
boolean interrupted = false;
// 开始自旋,要么获取锁,要么中断
for (;;) {
// 获取当前节点的前驱节点
final Node p = node.predecessor();
// 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)
if (p == head && tryAcquire(arg)) {
// 获取锁成功,头指针移动到当前node
setHead(node);
//断开了p节点与后继节点之间的引用关系以便在适当的时候回收内存。
p.next = null; // help GC
failed = false;
return interrupted;
}
// 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

注:setHead方法是把当前节点置为虚节点,但并没有修改waitStatus,因为它是一直需要用的数据。

// java.util.concurrent.locks.AbstractQueuedSynchronizer
// 将当前节点设置为虚节点
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
} // java.util.concurrent.locks.AbstractQueuedSynchronizer // 靠前驱节点判断当前线程是否应该被阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取头结点的节点状态
int ws = pred.waitStatus;
// 说明头结点处于唤醒状态
if (ws == Node.SIGNAL)
return true;
// 通过枚举值我们知道waitStatus>0是取消状态
if (ws > 0) {
do {
// 循环向前查找取消节点,把取消节点从队列中剔除
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 设置前任节点等待状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
} // parkAndCheckInterrupt主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
2.2.3 总结:ReentrantLock非公平锁的lock()的流程如下:

3.如何解锁

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

调用AQS的release(int arg)方法

public final boolean release(int arg) {
// 调用ReentrantLock 重写的tryRelease(arg)方法
if (tryRelease(arg)) {
// 释放锁成功,获取头结点
Node h = head;
if (h != null && h.waitStatus != 0)
//头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态
unparkSuccessor(h);
return true;
}
return false;
} // java.util.concurrent.locks.ReentrantLock.Sync#tryRelease
protected final boolean tryRelease(int releases) {
// 减去可重入次数
int c = getState() - releases;
// 调用该方法线程不去当前获取锁线程抛异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 判断c是否为0
if (c == 0) {
free = true;
//将独占线程设置为null
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}

参考:

书籍 《Java并发编程的艺术》

https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html

结合ReentrantLock来看AQS的原理的更多相关文章

  1. 扒一扒ReentrantLock以及AQS实现原理

    提到JAVA加锁,我们通常会想到synchronized关键字或者是Java Concurrent Util(后面简称JCU)包下面的Lock,今天就来扒一扒Lock是如何实现的,比如我们可以先提出一 ...

  2. ReentrantLock 以及 AQS 实现原理

    什么是可重入锁?       ReentrantLock是可重入锁,什么是可重入锁呢?可重入锁就是当前持有该锁的线程能够多次获取该锁,无需等待.可重入锁是如何实现的呢?这要从ReentrantLock ...

  3. ReentrantLock以及AQS实现原理

    什么是可重入锁? ReentrantLock是可重入锁,什么是可重入锁呢?可重入锁就是当前持有该锁的线程能够多次获取该锁,无需等待.可重入锁是如何实现的呢?这要从ReentrantLock的一个内部类 ...

  4. 透过 ReentrantLock 分析 AQS 的实现原理

    对于 Java 开发者来说,都会碰到多线程访问公共资源的情况,这时候,往往都是通过加锁来保证访问资源结果的正确性.在 java 中通常采用下面两种方式来解决加锁得问题: synchronized 关键 ...

  5. 面经手册 · 第17篇《码农会锁,ReentrantLock之AQS原理分析和实践使用》

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 如果你相信你做什么都能成,你会自信的多! 千万不要总自我否定,尤其是职场的打工人.如 ...

  6. 并发编程学习笔记(5)----AbstractQueuedSynchronizer(AQS)原理及使用

    (一)什么是AQS? 阅读java文档可以知道,AbstractQueuedSynchronizer是实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量.事件,等等)提供一个框架, ...

  7. 从ReentrantLock看AQS (AbstractQueuedSynchronizer) 运行流程

    从ReentrantLock看AQS (AbstractQueuedSynchronizer) 运行流程 概述 本文将以ReentrantLock为例来讲解AbstractQueuedSynchron ...

  8. ReentrantLock 与 AQS 源码分析

    ReentrantLock 与 AQS 源码分析 1. 基本结构    重入锁 ReetrantLock,JDK 1.5新增的类,作用与synchronized关键字相当,但比synchronized ...

  9. 由AbstractQueuedSynchronizer和ReentrantLock来看模版方法模式

    在学完volatile和CAS之后,近几天在撸AbstractQueuedSynchronizer(AQS)的源代码,很多并发工具都是基于AQS来实现的,这也是并发专家Doug Lea的初衷,通过写一 ...

  10. AQS工作原理分析

      AQS工作原理分析 一.大致介绍1.前面章节讲解了一下CAS,简单讲就是cmpxchg+lock的原子操作:2.而在谈到并发操作里面,我们不得不谈到AQS,JDK的源码里面好多并发的类都是通过Sy ...

随机推荐

  1. 如何遍历HashMap集合?

    在Java中,HashMap是一种常用的数据结构,它提供了快速的查找.插入和删除操作.当我们需要遍历HashMap中的所有元素时,可以利用三种不同的方法实现. 方法一:使用键值对遍历 HashMap中 ...

  2. 【故障公告】被放出的 Bing 爬虫,又被爬宕机的园子

    这些巨头爬虫们现在怎么了?记忆中2022年之前的十几年,园子没有遇到过被巨头爬虫们爬宕机的情况,巨头们都懂得爱护,都懂得控制节奏,都懂得在爬网时控制并发连接数以免给目标网站造成过大压力. 从去年开始, ...

  3. 【Redis】-使用Lua脚本解决多线程下的超卖问题以及为什么?

    一.多线程下引起的超卖问题呈现1.1.我先初始化库存数量为1.订单数量为0 1.2.开启3个线程去执行业务 业务为:判断如果说库存数量大于0,则库存减1,订单数量加1 结果为:库存为-2,订单数量为3 ...

  4. pg序列的增删改查

    添加序列. CREATE SEQUENCE IF NOT EXISTS public.data_device_id_seq INCREMENT 1 START 1 MINVALUE 1 MAXVALU ...

  5. 2022-08-29:以下go语言代码输出什么?A:10 10;B:10 19;C:19 10;D:19 19。 package main import ( “fmt“ ) func he

    2022-08-29:以下go语言代码输出什么?A:10 10:B:10 19:C:19 10:D:19 19. package main import ( "fmt" ) fun ...

  6. vue全家桶进阶之路3:Node.js

    Node.js发布于2009年5月,由Ryan Dahl开发,是一个基于Chrome V8引擎的JavaScript运行环境,使用了一个事件驱动.非阻塞式I/O模型, 让JavaScript 运行在服 ...

  7. 配置pip源

    1.使用配置文件配置文件[global]trusted-host=pypi.doubanio.comindex-url=https://pypi.doubanio.com/simple配置文件放置位置 ...

  8. es笔记三之term,match,match_phrase 等查询方法介绍

    本文首发于公众号:Hunter后端 原文链接:es笔记三之term,match,match_phrase 等查询方法介绍 首先介绍一下在 es 里有两种存储字符串的字段类型,一个是 keyword,一 ...

  9. Github疯传!200本计算机经典书籍!

    好书在精不在多,每一本经典书籍都值得反复翻阅,温故而知新! 下面分享几本计算机经典书籍,都是我自己看过的. 重构 改善既有代码的设计 就像豆瓣评论所说的,看后有种醍醐灌顶.欲罢不能的感觉.无论你是初学 ...

  10. postgresql 安装和配置

    ### 安装过程 \1. 下载Postgresql源码包: \# wget http://ftp.postgresql.org/pub/source/v9.4.3/postgresql-9.4.3.t ...