关键词:AQS
抽象类AbstractQueuedSynchronizer提供了线程同步的模板方法,其实现了等待队列、入队休眠和唤醒机制等大部分逻辑实现。子类只需重写方法tryAcquire实现获取资源(锁)的逻辑,tryRelease实现释放资源的逻辑,再结合state的值来实现线程同步的相关功能。JDK中的ReentrantLock、ReentrantReadWriteLock、CountDownLatch、ThreadPoolExecutor等功能的实现都借助了AbstractQueuedSynchronizer。
AbstractQueuedSynchronizer提供了独占(每次只能有一个线程获取到资源)和共享(每次可以有多个线程获取到资源)两种模式的线程同步。本文先来介绍在独占模式下的AbstractQueuedSynchronizer。
将从下面三个维度来看看AbstractQueuedSynchronizer的实现:
(1)等待队列
(2)入队策略
(3)线程休眠与唤醒机制
在并发竞争锁的过程中,对于没有获取到锁的线程,不能让其丢失,需要找一块区域将它们记录下来以便在将来继续尝试获取锁。这块区域就叫做等待队列。等待队列的实现数据结构可以有数组、链表。考虑到在等待队列中的元素要不断的进行添加与删除操作,链表无疑是更好的一种选择。对于链表,添加与删除元素只需要将指针指向对应的节点或设置为null,因此时间复杂度都为O(1)。另外链表在分配内存的时候并不要求一定是连续的内存。数组就要求内存空间一定要是连续的一块,并且在增加和删除元素的时候会遇到扩容和缩容以及数组的拷贝等操作。因此通过比较,使用链表实现等待队列无疑是更好的方式(关于数组和链表可以参考ArrayList与LinkedList的区别)
等待队列用于记录在并发竞争的过程中暂时没有获取到锁的线程。
该队列是一个FIFO(先进先出)、双向链表。
(1)队列元素
队列中并不是直接存放的线程对象,而是封装成了一个Node,Node中记录当前线程,以及当前节点的上一个、下一个节点。在队列中,一个线程对象thread有个与之对应的Node对象。
static final class Node {
// 标记此node在共享模式下等待(共享锁)
static final Node SHARED = new Node();
// 标记此node在独占模式下等待(独占、排他锁)
static final Node EXCLUSIVE = null;
// 此node中持有的线程取消等待
static final int CANCELLED = 1;
// 此node中持有的线程需要被休眠(park)
static final int SIGNAL = -1;
// 此node持有的线程正在条件队列中等待
static final int CONDITION = -2;
static final int PROPAGATE = -3;
// 标明node的等待状态,值可以为上面的CANCELLED、SIGNAL、CONDITION、PROPAGATE。
// 因为是int类型的,所以默认值为0
volatile int waitStatus;
// 指向此node的前一个节点
volatile Node prev;
// 指向此node的后一个节点
volatile Node next;
// 此node持有的线程对象
volatile Thread thread;
// 是否在共享模式下等待
final boolean isShared() {
return nextWaiter == SHARED;
}
// 获取此node的前一个节点
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
}
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}
(2)记录队列
定义head、tail属性分别用于记录等待队列中的第一个节点(头节点)和最后一个节点(尾节点)。知道了头节点,可以依次通过节点的next引用找到下一个节点,直到尾节点。通过直到了尾节点,可以依次通过prev引用找到上一个节点,直到头节点。
private transient volatile Node head;
private transient volatile Node tail;
当等待队列还没初始化的时候,即还没有线程请求入队,此时头尾节点是都不存在的,此时head和tail都为null。
有了等待队列,就需要制定一个如何将线程添加到队列中的策略了。最直接的方式是直接将没有竞争到锁的线程添加到队列尾部。如何将新的节点添加到队列中呢?只需要将队列中原先的尾结点的next指向新的节点,然后新的尾结点的prev指向原先的尾结点,这样就将新节点加入到了队列中。
AbstractQueuedSynchronizer的入队策略不是这么直接的,它需要兼顾效率。下面来看看AbstractQueuedSynchronizer是如何将线程添加到等待队列中的。方法acquire提供了独占模式下的入队策略:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
(1)tryAcquire
tryAcquire方式是尝试获取锁,AbstractQueuedSynchronizer提供方法,不提供具体的实现。方法由子类去实现,以定义获取锁的方式。
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
(2)addWaiter
将线程添加到等待队列中。
private Node addWaiter(Node mode) {
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;
}
}
enq(node);
return node;
}
上面的代码主要分为两部分,一个部分逻辑是等待队列已经初始化了,另一个部分逻辑是等待队列还没有初始化。
①等待队列已经初始化。此时,在当前线程入队之前,已经有其他线程执行入队逻辑了,所以队列才会已经初始化。
// 将线程封装成Node对象,mode为独占模式
Node node = new Node(Thread.currentThread(), mode);
// 获取队列的尾节点
Node pred = tail;
// pred不为null,即tail尾结点不为null
// tail尾结点为null的时候是队列还没有初始化,tail不为null,即表示队列已经初始化
// ①等待队列已经初始化
if (pred != null) {
// 将新节点的前驱引用指向现在的尾结点
node.prev = pred;
// 使用CAS操作保证线程安全,在多个新节点并发操作的时候,可以保证只有一个新
// 节点能够被设置为tail,将tail指向新节点
if (compareAndSetTail(pred, node)) {
// 原来的尾结点的next引用指向新节点
// 到这里操作之后,便将新节点加入到了队列的尾部
pred.next = node;
return node;
}
}
代码示意图:
设Thread t = Thread.currentThread();
compareAndSetTail:
private final boolean compareAndSetTail(Node expect, Node update) {
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
借助Unsafe类提供的功能,使用CAS操作将AbstractQueuedSynchronizer对象的tail指向新节点,即将队列的尾结点设置成了新节点。CAS操作保证在并发情况下,只有一个线程能够设值成功。设值成功的线程,会继续执行if语句中的逻辑。设值失败的线程,会走下面的第二种逻辑。
②等待队列还没有初始化,或者在上面逻辑的CAS操作中竞争失败的节点会进入下面的逻辑。
enq(node);
下面的逻辑也可以分为两部分,在第一次循环中,等待队列还没有初始化的话,需要将队列进行初始化,头尾节点为一个new Node()对象。紧接着进入下一次循环,此时tail不为null,将线程对应的节点添加到队列尾部。
private Node enq(final Node node) {
// 自旋,等同于while(true)
for (;;) {
// 获取尾结点
Node t = tail;
// 尾结点为null,表示等待队列还没有初始化,需要进行初始化
if (t == null) {
// 初始化head头节点
if (compareAndSetHead(new Node()))
// 将tail尾结点也指向头结点,此时头节点和尾结点是同一个节点
tail = head;
// 执行完初始化后,进入下一次循环
} else { // 第一次执行完初始化后,进入下一次循环就会进入这里的逻辑
// 将新节点的前驱引用指向尾结点
node.prev = t;
// 使用CAS操作将新节点设置成尾结点
if (compareAndSetTail(t, node)) {
// 原先的尾结点的next引用指向新的尾结点。
t.next = node;
// 跳出自旋
return t;
}
}
}
}
第一次循环初始化队列示意图,即如下代码示意图:
Node t = tail;
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
}
此时的头尾引用指向的新节点并不是线程对应的节点,而是一个空节点new Node()。在AbstractQueuedSynchronizer的等待队列中,头节点永远是一个空的节点,该头节点的next指向的下一个节点才是线程节点。
第二次循环将线程对应的节点入队示意图,即如下代码示意图:
else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
在compareAndSetTail中设值失败的线程会再进入下一个循环。逻辑再执行一次,会将该线程的节点加入到队尾。
在获取锁时,被加入等待队列的线程应该让其阻塞等待(wait),这样CPU将不会对该线程进行调度,避免自旋空转浪费资源。
此时线程自旋,如果该节点为第一个线程节点,即其prev引用指向空的head头节点,那么说明该节点有资格去竞争锁了,如果竞争获得锁了则处理其他逻辑。如果没有竞争获取到锁,则判断是否满足线程休眠条件,满足则休眠,不满足继续自旋。如果该节点不是第一个线程节点,则直接判断是否满足线程休眠条件,满足则休眠,不满足则继续自旋。直到获取到锁退出自旋。
(1)acquireQueued
acquireQueued方法处理对线程进行休眠。它这里的处理并不是直接让线程休眠,而是先让第一个线程节点去尝试获取锁,如果获取成功就不用休眠了。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
// 自旋
for (;;) {
// 获取当前节点的前驱节点,即prev引用指向的节点。
final Node p = node.predecessor();
// 前驱节点是head,表明这个节点是第一个线程节点,此时让它
// 再次去尝试获取锁
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 线程休眠
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
①在入队策略中提到,AbstractQueuedSynchronizer的等待队列,头结点永远是一个空节点,头结点的next指针指向的节点才是持有线程的节点。所以node.predecessor()==head,表示node是第一个线程节点。此时让它调用tryAcquire方法尝试获取锁。
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
setHead:
如果节点中持有的线程获取到锁成功,将该节点设置成头节点,并去掉其持有的线程和前驱引用。
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
代码示意图:
经过上述的逻辑,当前获取到锁的node节点就变成了一个空的头节点,而原先的头节点因为断开了所有的引用将等待GC进行回收。
②当前节点不是第一个线程节点,或者第一个线程节点获取锁没有成功,则进入线程休眠。
shouldParkAfterFailedAcquire
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取node前驱节点的等待状态
// 因为waitStatus是int类型的,所以默认值为0
int ws = pred.waitStatus;
// 如果waitStatus==-1,需要休眠
if (ws == Node.SIGNAL)
return true;
// 如果前驱节点的waitStatus>0,即为CANCELLED = 1
if (ws > 0) {
// 一直向队列的前面找,直到找到waitStatus不大于0的那个节点
// 将当前节点与这个节点连接起来,中间的那些waitStatus>0的节点被断开连接了
// 将来等待GC进行回收
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// waitStatus<=0的状态,统一更改成SIGNAL=-1
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
判断当前节点是否满足线程休眠的条件。若满足则将线程休眠。否则继续进入下一次循环,直到满足跳出循环的逻辑。
满足线程休眠的条件即当前线程的前驱节点waitStatus为SIGNAL=-1,此时方法会返回true,否则返回false。
parkAndCheckInterrupt:
如果shouldParkAfterFailedAcquire返回false,则不会进入parkAndCheckInterrupt方法,会继续执行下一次循环。当满足线程休眠的条件时,执行线程休眠,调用LockSupport.park将当前线程休眠。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
线程什么时候被唤醒,被谁唤醒?线程在什么时候被唤醒才是最佳的时机呢,当线程被唤醒之后要是能够立马获取到锁就好了,即此时锁是被释放,不被其他线程占有的状态。某个线程正在等待区呼呼睡大觉呢,它哪知道锁被释放了可以进行抢锁了呢?当持有锁的线程在释放锁的时候,将某个线程唤醒不就好咯吗。
(1)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;
}
(2)tryRelease
和tryAcquire获取锁一样,tryRelease释放锁也需要子类去自定义实现。
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
(3)unparkSuccessor
找到应该被唤醒的线程,然后唤醒。该方法就是根据空的头节点的next向下找节点,找到第一个节点的waitStatus<=0的,然后将其唤醒。(waitStatus>0的状态只有CANCELLED取消状态,取消排队的线程不再去等待锁了)
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.unpark唤醒线程。唤醒后的线程,会继续acquireQueued方法体中的自旋逻辑。
通过上面的分析,大致的示意图:
(关于AbstractQueuedSynchronizer中的共享模式、Condition、ConditionObject等内容将在后续介绍)