文章目录
1.Condition的使用
1.1.作用
在使用独占锁的情况下,线程在临界区中运行,如果不做其它的处理,只有线程将临界区中的代码逻辑运行完成之后才会去解锁。
但是我们有时候需要线程循环执行一些特定的代码,直到满足了一定的条件才会暂停下来,当这个条件不满足后又重新开始执行。
比如线程池中的线程都是运行在一个死循环中,只要任务队列中获取了任务,就拿出来运行,任务队列中没有任务,线程就会阻塞。这里的阻塞和唤醒就是通过Condition
的signal()
和await()
方法来实现的。
1.2.使用方式
Condition
是依赖于Lock
存在的,在临界区中的线程可以通过Condition
来释放锁。
下面使用3个线程依次打印的例子来感受一下:
public class ConditionDemo {
private static ReentrantLock lock = new ReentrantLock();
private static Condition condition = lock.newCondition();
public static void test() {
lock.lock();
try {
for (; ; ) {
System.out.println(Thread.currentThread().getName());
Thread.sleep(1000);
// 唤醒等待队列中的下一个节点中的线程
condition.signal();
// 当前线程加入等待队列尾并阻塞
condition.await();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
Thread t1 = new Thread(ConditionDemo::test, "线程1");
Thread t2 = new Thread(ConditionDemo::test, "线程2");
Thread t3 = new Thread(ConditionDemo::test, "线程3");
t1.start();
t2.start();
t3.start();
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
线程1,2,3每隔一秒打印一次自己的线程名,在test()
中是一个死循环,如果不使用Condition
的话,哪个线程先进入循环就会一直打印,阻塞其它两个线程。
此外,一个Lock
可以创建多个Condition
对象,针对不同的条件来做等待和唤醒操作。
2.Condition的原理
在看原理之前,建议先看一下上一篇笔记《(十四)Java可重入互斥锁实现——ReentrantLock详解》,会更容易理解Condition
的原理。
通过上面的例子,我们已经知道了Condition
可以使当前线程释放锁,并将自己挂起让出CPU资源,下面就来看看阻塞和唤醒是如何实现的。
2.1.阻塞如何实现
首先来看一下Condition
的类结构,它是AQS
的一个内部类,定义了两个Node
字段:
public class ConditionObject implements Condition, java.io.Serializable {
// 条件队列的头节点
private transient Node firstWaiter;
// 条件队列的尾结点
private transient Node lastWaiter;
// ......
}
Node
有4种状态分别为CANCELLED
,SIGNAL
,CONDITION
,PROPAGATE
,在条件等待队列中的节点只会用到CANCELLED
和CONDITION
两种状态。
2.1.1.线程加入条件等待队列
调用await()
方法,线程会创建一个CONDITION
状态的节点,然后先将自己加入到条件队列中去。
public final void await() throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
// 创建新的节点,并加入到条件队列中
Node node = addConditionWaiter();
// 省略部分代码
}
加入队列的过程有下面几种可能性。
- 如果条件队列未初始化,则初始化队列,将自己置为头节点。
- 如果条件队列已初始化,则将自己挂接到队列尾之后。
- 如果尾结点是
CANCEL
状态,则从头到尾扫描CANCEL
状态的节点,将他们从队列中清除,然后再将自己挂接到队列尾之后。
上面的动作完成之后,将条件队列中的lastWaiter
指向新创建的节点。
private Node addConditionWaiter() {
Node t = lastWaiter;
// 尾结点是取消状态,将它从队列中清除。
if (t != null && t.waitStatus != Node.CONDITION) {
unlinkCancelledWaiters();
t = lastWaiter;
}
// 创建新节点,并加入到队列中去。
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null)
firstWaiter = node;
else
t.nextWaiter = node;
lastWaiter = node;
return node;
}
可能大家已经注意到了,这里的每一步操作都没有使用CAS
,因为await()
方法是在临界区中调用的,这里同一时间只能有一个线程访问到,不存在线程安全问题。
2.1.1.1.Condition队列的初始化及尾结点添加过程
当队列的尾结点指向null
时,队列会进行初始化:
当新的线程加入到的条件队列时,会挂接掉队列尾:
2.1.1.2.CANCEL节点的清理过程
尾结点是取消状态时,就会进入unlinkCancelledWaiters()
,这个方法中定义了3个指针来从头到尾操作队列,将所有CANCEL
状态的节点都从队列中清理出去,三个指针分别为:
t
:从头到尾依次移动的指针trail
:指向t
经过的上一个Condition
状态的节点。next
:保存t
即将经过的下一个节点。
private void unlinkCancelledWaiters() {
Node t = firstWaiter;
Node trail = null;
while (t != null) {
Node next = t.nextWaiter;
if (t.waitStatus != Node.CONDITION) {
t.nextWaiter = null;
// trail为null表示经过的都是CANCEL状态的节点,此时直接将队列头指向下一个节点
if (trail == null) {
firstWaiter = next;
}
// 此时t指向的是CANCEL状态的节点,这一步将其断开
else {
trail.nextWaiter = next;
}
// next为null表示整个队列已经迭代完了,将队列尾指向最后一个CONDITION状态的节点
if (next == null)
lastWaiter = trail;
}
else {
// 当前节点是CONDITION状态,使用trail保存起来
trail = t;
}
// 指针向右移动
t = next;
}
}
下面通过流程图来演示CANCEL
节点是如何从队列中清除的。
第一步,t
指向头节点,next
指向头节点的后置节点。
此时t
指向的节点是CANCEL
状态,并且trail
还没有指向,t
断开指向后置节点的指针,在操作完成后,firstWaiter
和t
向右移动。
此时t
指向CONDITION
状态的节点,只需要将trail
指向t
指向的节点,然后t
继续向右移动。
现在t
又指向了CANCEL
节点了,并且trail
已经有了指向。接下来t
断开指向后置节点的指针,然后将trail
指向后置节点,完成后同样向右移动。
next
指向null
了,此时会执行最后一个循环,这次循环中会处理尾结点的指针。
清理完成后,得到如下的队列:
2.1.1.3.小结
线程调用await()
方法后,会创建一个节点加入到队列中,如果此时队列没有初始化则先初始化队列。
如果队列尾部的节点为CANCEL
状态,则发起一个清理操作,从头到尾扫描队列将所有CANCEL
节点都从队列中清除出去,然后再将新创建的节点挂接到队列尾。
2.1.2.释放锁
既然当前线程挂起了,那就需要把持有的锁释放掉,让其他的线程可以获取锁。
public final void await() throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
Node node = addConditionWaiter();
// 释放锁
int savedState = fullyRelease(node);
// 省略部分代码
}
这里可能会有重入锁的情况,在释放锁时不再一次将锁状态state
减1,而是直接释放所有重入锁。
final int fullyRelease(Node node) {
boolean failed = true;
try {
// 获取当前的锁状态,使用这个锁状态一次性释放所有重入锁
int savedState = getState();
if (release(savedState)) {
failed = false;
return savedState;
}
// 省略部分代码
}
}
2.1.3.挂起线程
上面两步做完之后,就需要将当前线程挂起
public final void await() throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
// interrupt状态,用于中断自旋
int interruptMode = 0;
// 当前节点没有在CLH队列中,才会挂起
while (!isOnSyncQueue(node)) {
// 当前线程将自己挂起
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
// 省略部分代码
}
这里的代码有两个需要注意的点:
interruptMode
:当它不等于0时,表示有其它的线程调用的当前线程的interrupt()
方法尝试中断等待,这里当前线程会被唤醒然后执行中断等待的操作。isOnSyncQueue(node)
:除了interrupt
中断之外,当前线程被唤醒后也需要中断自旋才能执行接下来的逻辑。所以这里的判断是在查询当前线程所在的Node
是否已经被加入了CLH
队列(如果在CLH
中表示已经从条件队列中移除),在CLH
中的线程直接进入等待抢锁的流程中。
到目前为止,当前线程已经释放了锁,并加入到了条件队列中将自己挂起,阻塞的流程就结束了。接下来只需要等待有其它的线程将当前线程唤醒,我们看看唤醒是如何实现的。
2.2.唤醒如何实现
线程调用signal()
方法时,就会去条件队列中唤醒firstWaiter
中的线程,当然前提是条件队列中还有处于阻塞状态的线程,代码如下:
public final void signal() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
doSignal(first);
}
2.2.1.弹出头节点
只有独占锁可以使用Condition
,所以这里检查到是非独占状态就会抛出异常。然后看doSinal
。
private void doSignal(Node first) {
do {
// 弹出头节点,将firstWaiter指针向右移动
if ((firstWaiter = first.nextWaiter) == null) {
lastWaiter = null;
}
first.nextWaiter = null;
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}
这里为什么会有一个循环?
考虑到first
节点的状态可能是CANCEL
,这时就需要通过迭代了取下一个节点,直到迭代完毕或取到了CONDITION
状态的节点为止。
在transferForSignal(first)
中会尝试修改first
节点的状态,并加入到CLH
队列中,并返回true
中断这里的循环。
2.2.2.头节点加入CLH
final boolean transferForSignal(Node node) {
// CANCEL状态的节点会修改失败
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;
// 头节点加入CLH队列,并返回前置节点p
Node p = enq(node);
int ws = p.waitStatus;
// 如果前置节点p已取消,则唤醒当前线程去处理所有前置的已取消的节点
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}
如果看过上一篇笔记,这里的逻辑就不难理解了。enq(node)
就是将当前节点加入到CLH
队列中排队等待抢锁。
这里并没有判断是否为头节点的后置节点,为什么会去唤醒它呢?
其实在上篇笔记中一直在强调一个点,一个节点中的线程只有在它的前置节点是SIGNAL
状态时才能被前置节点唤醒。
上面的代码中,调用enq
返回的p
就是当前节点的前置节点,这个前置节点如果是CANCEL
状态,就应该将当前线程唤醒去处理掉它前面的取消状态的节点,处理完后再把自己挂起。
2.2.3.线程唤醒
当前线程被唤醒后,会继续执行await()
方法剩下的逻辑,我们先看看完整的方法:
public final void await() throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
Node node = addConditionWaiter();
// 锁状态
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
// 尝试抢锁,如果抢锁成功则将锁状态修改为之前释放锁前的状态
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
// 当前线程抢到锁后尝试执行一次清理条件队列的操作
if (node.nextWaiter != null)
unlinkCancelledWaiters();
// 如果线程被中断,则做中断操作
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
线程被唤醒后会调用acquireQueued()
尝试抢锁,这里的逻辑就和ReentrantLock
一致了。
不同点在于,这里的加锁需要把之前释放的重入锁全部加回去,让线程在临界区中自己去一层层的释放重入锁。
至此,Condition
中的signal()
方法就执行完了,这个流程还是非常简单的,用一句话来总结:将条件队列中的非CANCEL
状态的头节点弹出加入到CLH
队列中等待抢锁。
图示如下: