在上一篇文章并发编程之生产者消费者模型四种实现中,我们引入了并发编程的话题。而并发编程的目的,无非就是合理利用过剩的CPU资源,来达到提高程序性能,从而创造更多业务价值的目的。而要合理的完成并发编程的工作,其中绕不开的两个点就是:锁的运用(jdk自带锁、基于数据结构的锁),以及并发工具类的运用(并发集合、线程池)。而并发工具类以及基于数据结构的锁又离不开一个重要的框架AbstractQueuedSynchronizer(AQS)。本文将会一探AQS的秘密。本文主要包含以下部分:
- 前言
- AQS 基础
2.1 AQS 定义
2.2 AQS 接口
2.3 利用AQS实现自定义独占锁- AQS 原理
3.1 同步队列
3.1.1 Node
3.1.2 同步队列的执行流程
3.2 独占锁的获取和释放
3.2.1 独占锁的获取
3.2.2 独占锁的释放- 由AQS看JUC
- 总结
1. 前言
谈到并发编程就不得不提一个词锁
,按照锁的实现
(而非定义
)来讲,可以分为语言级别的锁(synchronized)
,以及语义级别的锁(基于特殊数据结构实现的锁)
。语义级别的锁常见的如ReentrantLock、ReadWriteLock。这两种锁都有一个共同的核心
即AbstractQueuedSynchronized(AQS)
。同时我们常用的并发工具类 如Condition(基于数据结构实现等待、通知模式。替代ObjectMonitor的语义)、CountDownLatch(同步计数器)、CyclicBarrier(回环栅栏)、Semaphore(信号量)、ThreadPoolExecutor(线程池)
这些都是基于AQS实现或者有用到AQS。可以看到AQS几乎占到了并发编程的半壁江山,是当之无愧的大哥级人物。
我们都知道synchronized是基于ObjectMonitor,最终通过操作系统层面的Mutex(互斥锁)来实现线程同步的
。那么与之对应的AQS呢?思考如下问题:
AQS如何实现独占锁的获取和释放呢?跟Mointor有没有关系呢?
-
AQS如何实现共享锁的获取和释放呢?其处理模式和独占有什么区别呢?
带着这些问题,我们来一探AQS的究竟。
2. AQS 基础
2.1 AQS的定义
-
AQS即AbstractQueuedSynchronizer,即抽象队列同步器又称同步器。是用来构建锁或者其它同步组件的基础框架
。 -
AQS提供了一系列的模板方法来构建获得资源和释放资源的框架,以及预设的通用操作
(如获取资源失失败时的排队操作、释放资源后唤醒等待的后续线程等)。使用的时候,子类需要继承AbstractQueuedSynchronized,并按需重写抽象方法即可
。
简单来说AQS是JUC包的作者,并发编程大师Doug Lea 为开发者提供的实现同步需求的框架,我们可以通过使用它来简便的实现自定义同步组件,而无需实现复杂的队列入队出队及唤醒等复杂操作。
2.2 AQS接口示例
AQS是通过模板方法模式来实现的,而模板方法模式的实现原理为:子类通过继承模板类,并实现模板类的抽象方法。而当调用模板类的模板方法时,会调用子类实现的抽象方法
。那么这里就会涉及到两种方法:需要子类去重写的抽象方法、模板方法。那么AQS中同样也存在这两类方法。
需要子类去重写的抽象方法
名称 | 作用 |
---|---|
protected boolean tryAcquire(int arg) | 独占式的获取同步状态 |
protected boolean tryRelease(int arg) | 独占式的释放同步状态 |
protected int tryAcquireShared(int arg) | 共享式的获取同步状态 |
protected boolean tryReleaseShared(int arg) | 共享式的释放同步状态 |
protected boolean isHeldExclusively() | 是否线程独占 |
AQS提供的模板方法
名称 | 作用 | 说明 |
---|---|---|
public final void acquire(int arg) | 独占式的获取同步状态 | 获取成功方法返回,否则进入队列排队。是否获取成功由重写的tryAcquire(int arg)方法决定。 |
public final void acquireShared | 共享式的获取同步状态 | 获取成功方法返回,否则进入队列排队。是否获取成功,最多有几个线程能获取共享同步状态由tryAcquireShared(int arg)决定。 |
public final boolean release(int arg) | 独占式的释放同步状态 | 释放成功后,会唤醒等待队列的Header节点持有的线程。 |
protected boolean tryReleaseShared(int arg) | 共享式的释放同步状态 | 同样是唤醒等待的header节点。 |
篇幅所限,只列出了部分方法。
2.3 利用AQS实现自定义独占锁
尝试用AQS实现一个独占锁
//自定义独占锁,根据state状态判断独占状态 0:未加锁 1:已加锁
public class Mutex implements Lock {
//1. 自自定义内部类继承AbstractQueuedSynchronizer
private static class Syn extends AbstractQueuedSynchronizer {
//2. 按需重写抽象方法
//是否线程独占
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
//2. 按需抽象方法
//独占式的获取锁
@Override
protected boolean tryAcquire(int arg) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
//2. 按需抽象方法
//独占式的释放锁
@Override
protected boolean tryRelease(int arg) {
if (getState() == 0) {
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
//2. 按需抽象方法
//返回AQS内部类ConditionObject
Condition newCondition() {
return new ConditionObject();
}
}
//3.将内部类组合到同步组件中,以便后面使用内部类从AQS继承的模板方法
private Syn syn = new Syn();
//4. 使用AQS提供的模板方法
//独占式的加锁
@Override
public void lock() {
syn.acquire(1);
}
//4. 使用AQS提供的模板方法
//支持中断的加锁
@Override
public void lockInterruptibly() throws InterruptedException {
syn.acquireInterruptibly(1);
}
//4. 使用AQS提供的模板方法
//非阻塞的情况下拿锁
@Override
public boolean tryLock() {
return syn.tryAcquire(1);
}
//4. 使用AQS提供的模板方法
//非阻塞的情况下拿锁,支持超时时间
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return syn.tryAcquireNanos(1, unit.toNanos(time));
}
//4. 使用AQS提供的模板方法
//独占式的释放锁
@Override
public void unlock() {
syn.release(1);
}
//4. 使用AQS提供的模板方法
//获取与锁相关联的Condition
@Override
public Condition newCondition() {
return syn.newCondition();
}
public static void main(String[] args) {
Lock mutex = new Mutex();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "尝试获得锁[" + mutex + "]");
mutex.lock();
try {
System.out.println(Thread.currentThread().getName() + "持有了锁[" + mutex + "]"+"@ time["+ LocalDateTime.now().toString()+"]");
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
mutex.unlock();
}
}, "t1").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "尝试获得锁[" + mutex + "]");
mutex.lock();
try {
System.out.println(Thread.currentThread().getName() + "持有了锁[" + mutex + "]"+"@ time["+ LocalDateTime.now().toString()+"]");
} finally {
mutex.unlock();
}
}, "t2").start();
}
}
输出结果
t1尝试获得锁[com.moxieliunian.lock.AQSLock.Mutex@5be6cb40]
t1持有了锁[com.moxieliunian.lock.AQSLock.Mutex@5be6cb40]@ time[2020-12-01T14:49:28.161]
t2尝试获得锁[com.moxieliunian.lock.AQSLock.Mutex@5be6cb40]
t2持有了锁[com.moxieliunian.lock.AQSLock.Mutex@5be6cb40]@ time[2020-12-01T14:49:38.161]
可以看到t2线程在t1线程释放锁后才能获得锁,间隔时间刚好是我们设置的t1持有锁时间10s。
从上面的例子中,我们看到了使用AQS自定义同步组件的步骤:
自定义内部类继承AbstractQueuedSynchronizer。
按需重写抽象方法。
将内部类组合到同步组件中,以便后面使用内部类从AQS继承的模板方法。
使用AQS提供的模板方法。
我们只需要简单的实现抽象方法,无需复杂的操作,即可实现自定义同步组件的逻辑。
是不是很神奇?下面让我们一探究竟吧。
3. AQS原理
上面我们说到AQS的关键在于维持了一个FIFO的队列,用于获取同步资源失败时入队等待,以及持有锁的线程释放同步资源时出队唤醒
。所以为了搞清楚AQS的工作原理,先了解其内部的同步队列是很有必要的。因此下面会按照如下顺序介绍:同步队列、独占锁的获取和释放。
在介绍源码之前,我们先要重申下以下几个概念:
同步器:即AQS本身。其持有同步队列的首节点和尾节点引用。
同步队列:即由Node节点组成的双向队列,该队列支持FIFO,每个Node存在指向前驱和后继节点的引用。
3.1 同步队列
3.1.1 Node
整个同步队列对应的类为源码-AbstractQueuedSynchronizer.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;
//节点在等待队列中,处于Condition.await()状态,需等待Condition.signal()方法被调用后,才能从等待队列进入到同步队列中
static final int CONDITION = -2;
//下一次共享式同步状态将会被无条件的传播下去
static final int PROPAGATE = -3;
//等待状态,为以上状态的一种
volatile int waitStatus;
//前驱节点
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() { // Used to establish initial head or SHARED marker
}
//addWaiter时使用的构造方法,设置后继节点和当前线程
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
//配合Condition时使用的构造方法,设置等待状态和当前线程
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}
从上面的源码中,我们可以看到几个关键点
每个Node都维护了一个volatile int 类型的变量waitStatus来标识同步的状态。
每个Node都持有前驱节点的引用 prev,同样被volatile修饰。
每个Node都持有后继节点的引用 next,同样被volatile修饰。
-
每个节点都持有当前线程的引用 thread,V同样被volatile修饰。
我们都知道volatile是为了保持变量修改后对线程立马可见
,那么对volatile变量的修改又是如何进行的呢?我们以设置尾节点为例来看
/**
* CAS tail field. Used only by enq.
*/
private final boolean compareAndSetTail(Node expect, Node update) {
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
我们发现了一个有意思的东西unsafe类
。明显更新Node的属性绝大部分都是通过CAS来进行操作的。
3.1.2 同步队列的执行流程
Node是构成同步队列的基础。那么同步器自身又是如何与Node关联的呢?
同步器(AQS)持有两个Node引用,分别是同步队列的头节点head,以及同步队列的尾节点tail
。
//头节点
private transient volatile Node head;
//尾节点
private transient volatile Node tail;
同步器依赖同步队列来完成同步状态的管理,主要涉及到两方面:
- 当前线程
获取同步状态失败时,将其构造成Node,加入同步队列的尾部,阻塞当前线程等待被唤醒
。 - 当前线程
释放同步状态时,唤醒同步队列的header节点持有的线程,并使其再次尝试获得同步状态
。
以上两个流程分别对应的是同步队列的两个动作:获取资源失败时的入队(尾部插入添加节点),成功释放资源后的出队(头部移除节点)。我们分别按照同步队列的基本结构、同步队列的入队、同步队列的出队来介绍大致流程。
- 同步队列的基本结构
同步队列为一个双向队列,每个Node节点分别持有前驱节点和后继节点的引用。同时同步器持有同步队列的header节点和tail节点的引用
。
值得注意的是头部节点的prev属性为空(首节点不存在前驱节点),尾部节点的next属性为空(尾部节点不存在后继节点)
- 获取资源失败时的入队操作
上面我们说过,在获取资源失败时会将当前线程和waitStatus构建成Node,加入同步队列的尾部进行入队操作。即1.构建Node并将其加入队列尾部。2.将同步器的tail属性指向新的Node。
值得注意的是,之所入队时向尾部插入,是为了FIFO的原则,尾部是最新加入的节点
- 成功释放资源后的出队
当获取同步资源成功的线程释放同步资源时,会唤醒后继节点,后继节点在尝试获取同步资源,获取成功时会将自己设置成头节点。对应同步队列的操作即1.将后继节点设置为新的header。2.断开原来header的next指向(方便GC)。
整个同步队列的执行流程有个有意思的地方:添加尾部节点时为了保证线程安全使用了CAS更新,但是在移除节点的设置头节点的步骤中,并未做线程安全的处理。这是为什么呢?
‘
因为同时有多个线程做加入队列尾部的操作,所以在入队的时候要通过CAS来保证线程安全。而设置头节点的前提时成功获取到了锁,而只能有一个线程能获得锁,故设置头节点的时候不需要额外线程安全处理。
3.2 独占锁的获取和释放
其实说清楚同步队列的工作流程后,整个同步器的工作流程也已经了解大半了。下面我们分别以独占锁的获取和独占锁的释放为例来看看具体的源码。
3.2.1 独占锁的获取
public final void acquire(int arg) {
//获取同步状态
if (!tryAcquire(arg) &&
//获取失败时,构造Node,并将该Node加入到同步队列的尾部
//使同步队列的节点以死循环的方式获取同步状态,获取不到则阻塞,直到被唤醒或者打断
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
该方法主要包含了以下几个关键步骤:
获取同步状态。
如果获取失败则构造Node,并将其加入同步队列尾部。
使该节点以死循环的方式获取同步状态,获取失败则阻塞,直到被头部节点唤醒或者被中断。
下面分别来说明以上过程
- 获取同步状态
获取同步状态是通过tryAcquire(arg)方法来实现的,该方法为抽象方法,需要子类去实现。
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
该方法要保证线程安全的获取同步状态
- 获取失败时构造Node,并将其加入同步队列尾部
这步操作是通过addWaiter(Node.EXCLUSIVE)实现的
private Node addWaiter(Node mode) {
//根据当前Thread和Node.EXCLUSIVE(独占模式的Node常量)来构造当前节点
Node node = new Node(Thread.currentThread(), mode);
// 快速设置尾部,只有同步队列已有数据后才能成功
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//将Node进行入队,未初始的情况下先进行初始化head和tail,并通过死循环的方式CAS设置tail,直到成功
enq(node);
return node;
}
这里主要包括两个关键步骤:
以Thread.currentThread()和Node.EXCLUSIVE作为参数构建Node
将Node进行入队,未初始的情况下先进行初始化head和tail,并通过死循环的方式CAS设置tail,直到成功
构建Node的时候会传入当前线程信息,以便后续通过Node唤醒指定的线程。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // tail为空,需要初始化
//初始化header
if (compareAndSetHead(new Node()))
//将tail临时指向header
tail = head;
} else {
node.prev = t;
//死循环CAS设置tail为当前Node,直至成功
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
可以看到enq(node
主要做了两件事1. 初始化header、2. 设置当前node为tail
。入队的示意图如下:
值得注意的是只有compareAndSetTail(t, node)返回成功后,才会退出当前循环。也就是说通过CAS将并发添加Node变得串行了。
- 以死循环的方式获取同步状态,获取失败则阻塞,直到被头部节点唤醒或者被中断
这步操作是通过acquireQueued(final Node node, int arg)方法实现的
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
//死循环自旋
for (;;) {
//前驱节点
final Node p = node.predecessor();
//前驱节点为头节点,尝试获取同步状态且成功
if (p == head && tryAcquire(arg)) {
//设置当前节点为头节点
setHead(node);
//断开原始头节点的next引用
p.next = null; // help GC
failed = false;
return interrupted;
}
//根据waitStatua检测是否需要挂起
if (shouldParkAfterFailedAcquire(p, node) &&
//挂起线程
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这里主要做了两件事:
- 步骤1:
检查当前节点的前驱节点是否为header节点(即判断当前节点是否为成功获取锁的Node的后继节点),如果是则尝试获取。
- 步骤2:
如果步骤1不满足(前驱节点不是heaer节点,或者获取锁失败),则根据状态进行挂起。
值得注意的是:上诉步骤1和2是以死循环的方式运行的,也就是我们常说的节点自旋的过程。
我们再梳理下整个获取独占锁的获取流程:
- 尝试获取同步状态。
- 获取成功,返回。
- 获取失败,则构造Node,并加入同步队列尾部。
- 同步队列进行自旋。
- 当前节点的前驱节点为头部节点且尝试获取同步状态成功,则退出自旋。
- 上一步不满足,则根据状态进行挂起。直到被唤醒或者中断,然后重复上一步的操作。
3.2.2 独占锁的释放
说完了独占锁的获取,再看独占锁的释放要简单的多。释放同步状态是通过调用模板方法release(int arg)实现的。
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(int arg)方法实现的,该方法同样为抽象方法,需要子类去实现。
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
- 唤醒被挂起的后继线程'
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
//唤醒头部节点的后继节点
if (s != null)
LockSupport.unpark(s.thread);
}
挂起和恢复线程使用到了一个关键类,LockSupport.park(Object blocker)、LockSupport.unpark(Thread thread)
。
public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
UNSAFE.park(false, 0L);
setBlocker(t, null);
}
public static void unpark(Thread thread) {
if (thread != null)
UNSAFE.unpark(thread);
}
4. 由AQS看JUC
上面我们分析了AQS的源码,可以看到两个关键点volatile && CAS
。可以看到AQS做了如下操作
将变量声明为volatile类型(Node的prev,next AQS的tail和head)
利用CAS更新来实现线程之间的同步
配合volatile的读写语义实现线程之间的通信
上面的套路也是整个JUC包的底层实现套路。JUC包下的内容按照依赖层级可以分为两部分:处于上层的:Lock、同步器、阻塞队列、线程池、并发容器
,处于下层的:AQS、原子变量类
。上层类依赖下层类去实现,而下层类又依赖于volatile && CAS(unsafe)
5. 总结
上面我们详细解读了AQS的源码,关键部分
volatile && CAS && unsafe.park && unsafe.unpark
。篇幅所限,并未介绍共享锁的获取与释放。感兴趣的可以自己下面去研究,与独占锁最主要的区别就在于同时不止一个线程可以获取锁。
由于技术水平所限,文章难免有不足之处,欢迎大家指出。希望每位读者都能有新的收获,我们下一篇文章并发编程之synchronized的前世今生见....