在上一篇Java并发(一)之AQS简介提到AQS 内部维护着一个 FIFO 队列,该队列就是 CLH 同步队列。
1. 简介
CLH 同步队列是一个 FIFO 双向队列,AQS 依赖它来完成同步状态的管理:
- 当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程
- 当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。
2. Node
在 CLH 同步队列中,一个节点(Node),表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next)。其定义如下:
Node 是 AbstractQueuedSynchronizer 的内部静态类。
static final class Node {
// 共享
static final Node SHARED = new Node();
// 独占
static final Node EXCLUSIVE = null;
/**
* 因为超时或者中断,节点会被设置为取消状态,被取消的节点时不会参与到竞争中的,他会一直保持取消状态不会转变为其他状态
*/
static final int CANCELLED = 1;
/**
* 后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,将会通知后继节点,使后继节点的线程得以运行
* (说白了就是处于等待被唤醒的线程(或是节点)只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行)
*/
static final int SIGNAL = -1;
/**
* 节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()后,该节点将会从等待队列中转移到同步队列中,加入到同步状态的获取中
*/
static final int CONDITION = -2;
/**
* 表示下一次共享式同步状态获取,将会无条件地传播下去
*/
static final int PROPAGATE = -3;
/** 等待状态 */
volatile int waitStatus;
/** 前驱节点,当节点添加到同步队列时被设置(尾部添加) */
volatile Node prev;
/** 后继节点 */
volatile Node next;
/** 等待队列中的后续节点。如果当前节点是共享的,那么字段将是一个 SHARED 常量,也就是说节点类型(独占和共享)和等待队列中的后续节点共用同一个字段 */
Node nextWaiter;
/** 获取同步状态的线程 */
volatile Thread thread;
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
}
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;
}
}
-
waitStatus 字段,等待状态,用来控制线程的阻塞和唤醒,并且可以避免不必要的调用LockSupport的 #park(…) 和 #unpark(…) 方法。。目前有 4 种:CANCELLED SIGNAL CONDITION PROPAGATE 。
实际上,有第 5 种,INITAL ,值为 0 ,初始状态。 -
撸友请认真看下每个等待状态代表的含义,它不仅仅指的是 Node 自己的线程的等待状态,也可以是下一个节点的线程的等待状态。
CLH 同步队列,结构图如下:
- prev 和 next 字段,是 AbstractQueuedSynchronizer 的字段,分别指向同步队列的头和尾。
- head 和 tail 字段,分别指向 Node 节点的前一个和后一个 Node 节点,从而实现链式双向队列。再配合上 prev 和 next 字段,快速定位到同步队列的头尾。
-
thread 字段,Node 节点对应的线程 Thread 。
-
nextWaiter 字段,Node 节点获取同步状态的模型( Mode )。#tryAcquire(int args) 和 #tryAcquireShared(int args) 方法,分别是独占式和共享式获取同步状态。在获取失败时,它们都会调用 #addWaiter(Node mode) 方法入队。而 nextWaiter 就是用来表示是哪种模式:
- SHARED 静态 + 不可变字段,枚举共享模式。
- EXCLUSIVE 静态 + 不可变字段,枚举独占模式。
- isShared() 方法,判断是否为共享式获取同步状态。
-
predecessor() 方法,获得 Node 节点的前一个 Node 节点。在方法的内部,Node p = prev 的本地拷贝,是为了避免并发情况下,prev 判断完 == null 时,恰好被修改,从而保证线程安全。
-
构造方法有 3 个,分别是:
- Node() 方法:用于 SHARED 的创建。
- Node(Thread thread, Node mode) 方法:用于 #addWaiter(Node mode) 方法。
从 mode 方法参数中,我们也可以看出它代表获取同步状态的模式。
在本文中,我们会看到这个构造方法的使用。 - Node(Thread thread, int waitStatus) 方法,用于 #addConditionWaiter() 方法。
在本文中,不会使用,所以解释暂时省略。
3. 入列
学了数据结构的我们,CLH 队列入列是再简单不过了:
tail 指向新节点。
新节点的 prev 指向当前最后的节点。
当前最后一个节点的 next 指向当前节点。
结构如图:
3.1 acquire(int)
此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。获取到资源后,线程就可以去执行其临界区代码了。下面是acquire()的源码:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
函数流程如下:
- tryAcquire()尝试直接去获取资源,如果成功则直接返回;
- addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
- acquireQueued()使线程在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
- 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
3.1.1 tryAcquire(int)
此方法尝试去获取独占资源。如果获取成功,则直接返回true,否则直接返回false。这也正是tryLock()的语义,还是那句话,当然不仅仅只限于tryLock()。如下是tryAcquire()的源码:
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
什么?直接throw异常?说好的功能呢?好吧,还记得概述里讲的AQS只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现吗?就是这里了!!!AQS这里只定义了一个接口,具体资源的获取交由自定义同步器去实现了(通过state的get/set/CAS)!!!至于能不能重入,能不能加塞,那就看具体的自定义同步器怎么去设计了!!!当然,自定义同步器在进行资源访问时要考虑线程安全的影响。
这里之所以没有定义成abstract,是因为独占模式下只用实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared。如果都定义成abstract,那么每个模式也要去实现另一模式下的接口。说到底,Doug Lea还是站在咱们开发者的角度,尽量减少不必要的工作量。
3.1.2 addWaiter(Node)
此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。(需要考虑并发的情况。它通过 CAS 的方式,来保证正确的添加 Node)还是上源码吧:
private Node addWaiter(Node mode) {
// 新建节点
Node node = new Node(Thread.currentThread(), mode);
// 记录原尾节点
Node pred = tail;
// 快速尝试,添加新节点为尾节点
if (pred != null) {
// 设置新 Node 节点的尾节点为原尾节点
node.prev = pred;
// CAS 设置新的尾节点
if (compareAndSetTail(pred, node)) {
// 成功,原尾节点的下一个节点为新节点
pred.next = node;
return node;
}
}
enq(node);// 失败,多次尝试,直到成功,因为该方法中会自旋
return node;
}
第 3 行:创建新节点 node 。在创建的构造方法,mode 方法参数,传递获取同步状态的模式。
第 5 行:记录原尾节点 tail 。
在下面的代码,会分成 2 部分:
- 第 6 至 16 行:快速尝试,添加新节点为尾节点。
- 第 18 行:添加失败,多次尝试,直到成功添加。
- 第 7 行:当原尾节点非空,才执行快速尝试的逻辑。在下面的 #enq(Node node) 方法中,我们会看到,首节点未初始化的时,head 和 tail 都为空。
- 第 9 行:设置新节点的尾节点为原尾节点。
- 第 11 行:调用 #compareAndSetTail(Node expect, Node update) 方法,使用 Unsafe 来 CAS 设置尾节点 tail 为新节点。代码如下:
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long tailOffset = unsafe.objectFieldOffset (AbstractQueuedSynchronizer.class.getDeclaredField("tail")); // 这块代码,实际在 static 代码块,此处为了方便理解,做了简化。
private final boolean compareAndSetTail(Node expect, Node update) {
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
如果对 Unsafe 不了解,请 Google 之
- 第 13 行:添加成功,最终,将原尾节点的下一个节点为新节点。
- 第 14 行:返回新节点。
- 如果添加失败,因为存在多线程并发的情况,此时需要执行【第 18 行】的代码。
3.1.2.1 enq(Node)
此方法用于将node加入队尾。多次尝试,直到成功添加。源码如下:
private Node enq(final Node node) {
// 多次尝试(cas自旋),直到成功加入队尾
for (;;) {
// 记录原尾节点
Node t = tail;
// 原尾节点不存在,创建首尾节点都为 new Node()
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
// 原尾节点存在,添加新节点为尾节点
} else {
//设置为尾节点
node.prev = t;
// CAS 设置新的尾节点
if (compareAndSetTail(t, node)) {
// 成功,原尾节点的下一个节点为新节点
t.next = node;
return t;
}
}
}
}
- 第 3 行:“死”循环,多次尝试,直到成功添加为止【第 18 行】。
- 第 5 行:记录原尾节点 t 。? 和 #addWaiter(Node node) 方法的【第 5 行】相同。
- 第 10 至 19 行:原尾节点存在,添加新节点为尾节点。? 和 #addWaiter(Node node) 方法的【第 7 至 16 行】相同。
- 第 6 至 9 行:原尾节点不存在,创建首尾节点都为 new Node() 。注意,此时修改的首尾节点是重新创建( new Node() )的,而不是新节点!
这里,笔者的理解是,通过这样的方式,初始化好同步队列的首尾。另外,在 AbstractQueuedSynchronizer 的设计中,head 字段,是一个“占位节点”(暂时没想到特别好的比喻),代表最后一个获得到同步状态的节点(线程),实际它已经出列,所以它的 Node.next 才是真正的队首。当然,同步队列的初始时,new Node() 也是满足这个条件,因为有新的 Node 进队列,目前就已经有线程获得到同步状态。
#compareAndSetHead(Node update) 方法,使用 Unsafe 来 CAS 设置尾节点 head 为新节点。代码如下:
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long headOffset = unsafe.objectFieldOffset (AbstractQueuedSynchronizer.class.getDeclaredField("head")); // 这块代码,实际在 static 代码块,此处为了方便理解,做了简化。
private final boolean compareAndSetHead(Node update) {
return unsafe.compareAndSwapObject(this, headOffset, null, update);
}
注意,第三个方法参数为 null ,代表需要原 head 为空才可以设置。? 和 #compareAndSetTail(Node expect, Node update) 方法,类似。
3.1.3 acquireQueued(Node, int)
OK,通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。聪明的你立刻应该能想到该线程下一部该干什么了吧:进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。没错,就是这样!是不是跟医院排队拿号有点相似~~acquireQueued()就是干这件事:在等待队列中排队拿号(中间没其它事干可以休息),直到拿到号后再返回。这个函数非常关键,还是上源码吧:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;//标记是否成功拿到资源
try {
boolean interrupted = false;//标记等待过程中是否被中断过
//又是一个“自旋”!
for (;;) {
final Node p = node.predecessor();//拿到前驱
//如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
if (p == head && tryAcquire(arg)) {
setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!
failed = false;
return interrupted;//返回等待过程中是否被中断过
}
//如果自己可以休息了,就进入waiting状态,直到被unpark()
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
}
} finally {
if (failed)
cancelAcquire(node);
}
}
看看shouldParkAfterFailedAcquire()和parkAndCheckInterrupt()具体干些什么
3.1.3.1 shouldParkAfterFailedAcquire(Node, Node)
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;//拿到前驱的状态
if (ws == Node.SIGNAL)
//如果已经告诉前驱拿完号后通知自己一下,那就可以安心休息了
return true;
if (ws > 0) {
/*
* 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
* 注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被保安大叔赶走了(GC回收)!
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
//如果前驱正常,那就把前驱的状态设置成SIGNAL,告诉它拿完号后通知自己一下。有可能失败,人家说不定刚刚释放完呢!
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,需要去找个安心的休息点,同时可以再尝试下看有没有机会轮到自己拿号。
3.1.3.2 parkAndCheckInterrupt()
如果线程找好安全休息点后,那就可以安心去休息了。此方法就是让线程去休息,真正进入等待状态
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);//调用park()使线程进入waiting状态
return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
}
park()会让当前线程进入waiting状态。在此状态下,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。(再说一句,如果线程状态转换不熟,可以参考本人写的Thread详解)。需要注意的是,Thread.interrupted()会清除当前线程的中断标记位
再来总结下它的流程吧:
- 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;
- 没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
- acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
- 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
由于此函数是重中之重,我再用流程图总结一下:
4. 出列
CLH 同步队列遵循 FIFO,首节点的线程释放同步状态后,将会唤醒它的下一个节点(Node.next)。而后继节点将会在获取同步状态成功时,将自己设置为首节点( head )。
这个过程非常简单,head 执行该节点并断开原首节点的 next 和当前节点的 prev 即可。注意,在这个过程是不需要使用 CAS 来保证的,因为只有一个线程,能够成功获取到同步状态。如图:
setHead(Node node) 方法,实现上述的出列逻辑。代码如下
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}