简介
AbstractQueuedSynchronizer,抽象队列同步器,是一个用于编写锁的框架,由 CLH
变形而来(但其实一点也不像)。本篇为模型理解+源码解读,如果不想看源码的只看模型也会明白这个框架的基本原理和如何使用
理论模型
首先,让我们了解一下 CLH
来感受一下队列是如何在锁这种机制下工作的
CLH
CLH
是实现公平自旋锁的理论模型,它大概是这么工作的:当线程进入时先查看有没有人等待,即队列为不为空,如果没有人等待,则直接拿到锁,所谓的拿到锁就是将头节点的标志设置成占用。而如果发现有人等待则加入队列尾部,然后不断地循环监视前一个节点的标志,一旦前一个节点的标志从占用变成了空闲就说明前一个线程释放了锁,然后自己就会推出循环,成为队列头。大概的过程可以描述为下图
此时蓝色节点就是拿到锁的线程,其他三个线程卡在循环检测前一个节点。当蓝色节点调用方法使得标志位变化为 0 时后一个节点将退出循环,然后使得自己成为头部节点,就像这样
AQS 由 CLH 变化而来,其共同之处在于,队列中的节点总会依次的获取到锁,不过如果它源码中不说确实感觉不到
AQS
AQS同样维护了一个队列,以下简称队列,相对要复杂一些。队列中存在两种节点,分别为共享节点和互斥节点,分别对应两种锁的模式,互斥模式和共享模式,每种模式又对应两种方法,分别是获取锁和释放锁。注意,下面对这四个操作的阐述中,请区分获取
与尝试获取
,释放
与尝试释放
acquire
当一个线程以互斥模式获取锁的时候,如果尝试获取成功,就会立刻拿到锁,否则将进入队列的尾部(此时加入的就是互斥模式的节点),然后给前一个节点作上标记,而后挂起自身。这里的问题时,为什么一定要一个标志位呢?难道不可以当前一个节点释放锁后,直接唤醒下一个节点吗?我认为的答案是,如果后一个节点还没有阻塞,那么此时唤醒不但没有意义,反而会产生异常,而这种标志是一种代价很小的手段,只是告诉前者 “我要睡了,记得叫我起床”release
当一个线程以互斥模式释放锁的时候,其实很简单,首先尝试释放锁,如果成功就查看标志选择唤醒后继节点,然后后继节点就会继续争夺锁,并在拿到锁后成为新的头节点acquireShared
当一个线程以共享模式获取锁的时候,首先尝试获取锁,不过尝试获取的结果是一个整数,如果是负数说明获取失败,如果是 0 则表示获取成功但是仅此而已,如果是正数则说明后续的线程也可以获得锁,而在尝试获取之后,AQS会根据结果选择策略。如果获取失败,线程进入队尾等待,此时进入的节点就是共享节点,然后进入阻塞。如果获取成功,那么他将成为新的头节点,而且如果尝试获取的返回值是整数,则它会在获取成功后继续唤醒下一个节点告诉他可以开始获取锁了,这个过程被称作唤醒的传播,要注意的是,传播会在遇到互斥节点时停止,这是两种节点唯一的区别releaseShared
当一个线程以共享模式释放锁的时候,会先尝试释放,如果释放后返回true
表示可以有新的线程来获取锁,则会去唤醒队列的第一个节点
这四种操作是AQS
最重要的四种操作,互斥模式比较好理解,共享模式中的获取锁可能有些难以理解,其实只要想象唤醒的传播这个过程就大概可以理解与互斥模式的区别了
上述的操作可以理解为 AQS 为你提供的“操作队列的方式”,你需要重写相应的尝试方法
来指导AQS如何执行这四个操作,即重写
- tryAcquire
- tryRelease
- tryAcquireShared
- tryReleaseShared
如何理解这四个方法在 AQS 中的意义呢? tryAcquire
方法就是告诉 AQS 队列中的线程竞争的结果,而 tryRelease
则是告诉 AQS 锁是不是空闲了,当你调用 release
方法的时候 AQS 就通过调用 tryRelease
来询问你是不是有空闲的锁了,如果有,就会唤醒队列中的线程去抢
例如你可以通过一下代码获得一个非常简单的非公平互斥锁
public class MyLock extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
return compareAndSetState(0, 1);
}
@Override
protected boolean tryRelease(int arg) {
return compareAndSetState(1, 0);
}
public void lock() {
acquire(0);
}
public void unlock() {
release(0);
}
}
state
是 AQS 为我们提供的一个表达锁的状态的成员变量,并提供了很多原子操作(就如同上面用到的compareAndSetState
),当然你可以使用自己的没问题。此外,AQS的父类还提供了一个线程成员变量,你也可以使用它来表达当前占有锁的线程
总之,我们只需要定义线程之间是如何竞争一个锁的,而不需要太在意竞争失败的线程怎么管理
此外,上述四种队列管理的操作还有线程可中断版本和定时版本,都只是细微的差别
你也可以同时使用共享和互斥模式,他们并不会冲突,他们只是对队列的一种操作
小结
你可能还是对AQS抱有疑惑,这很正常,如果你不看源码,你可能永远也无法真正明白它实现的细节,和工作流程,以及如何高效的使用它。不过如果你只是想了解一下如何使用这个框架,那你可以看看 CountDownLatch
或者 ReentrantLock
是如何定义上述提到的重写方法的
源码解析
首先,你需要知道以下知识
- 多线程的基本知识
- 原子操作及其与线程间的联系
- 线程中断机制
- VarHandle
队列的节点
队列的本质就是一个双向链表,其中的节点类 Node
是 AQS 的内部类,其有几个比较重要的字段
waitStatus
等待状态,前面提到的“给前一个节点做上标记”的标记,就是该字段一个值prev
前一个节点next
后一个节点thread
代表的线程nextWaiter
代表该节点的模式
每一个字段都由一个变量句柄来提供原子操作,这些句柄的变量名是上述的全大写
nextWaiter
之前提到过,只有共享和互斥两种取值,不过在代码中的取的值很特别,这种写法可以借鉴一下。这个字段取这个名字的含义会体现在后面的Condition
对象中,目前可以将其简单理解为节点的类型
/** Marker to indicate a node is waiting in shared mode */
static final Node SHARED = new Node();
/** Marker to indicate a node is waiting in exclusive mode */
static final Node EXCLUSIVE = null;
waitStatus
是最重要的字段,其由五个固定取值
CANCEL=1
--------------- 代表该节点是一个失效节点SIGNAL=-1
-------------- 当线程决定要阻塞时会给前一个节点的等待状态改变成SIGNAL
,什么用之前提到过CONDITION=-2
----------- 给Condition
对象使用的,不存在于AQS队列中,这个之后会讲PROPAGATE=-3
------------ 只有共享模式的节点用得到,代表当唤醒传播到当前节点的时候一定会继续传播,具体细节和理由在后面细说0
----------------------- 默认状态,代表该节点不会对决策产生任何影响
AQS 中保存着链表的头尾。注意队列头并不是等待节点,而是一个哨兵节点,这个哨兵节点你可以认为它代表着最后一个拿到锁的线程,保存着上一次竞争到锁的线程的信息(因为上一次竞争到的线程会把自己设置为新的头节点),主要是保存 waitStatus
acquire
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这段逻辑用大白话来说就是,如果获取锁失败,就加入队列,如果加入队列期间发现产生了线程中断,就再次对自己中断,addWaiter(Node.EXCLUSIVE)
表示向队列中加入一个互斥模式的节点,并返回被加入的节点,关键看 acquireQueued
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg))