本文是分析ReentrantLock源码的第三篇博客,介绍Condition的使用和分析源码的实现细节。之前的两篇博客链接如下,有兴趣的读者不妨看看。
通过这篇博客可以学习到Condition当中5种await方法,signal和signalAll方法的使用和源码的实现细节。
在介绍方法的使用和分析源码之前,先来了解一下Condition是什么。
可以把Condition看作是Object监视器的替代品。众所周知,Object有wait()和notify()方法,用于线程间的通信。并且这两个方法只能在synchronized同步块内才可以调用,所有线程的等待和唤醒都需要关联到监视器对象的WaitSet集合。
Condition同样可以实现上面的线程通信。不同点在于,synchronized锁对象关联的监视器对象仅有一个,所以等待队列也只有一个。而一个ReentrantLock可以有多个Condition,这样可以根据不同的业务需求,在使用同一个lock锁对象的基础上使用多个等待队列,让不同性质的线程加入到不同的等待队列当中。
AQS当中Condition的实现类是ConditionObject,它是AQS的内部类,所以无法直接实例化。可以配合ReentrantLock来使用。
ReentrantLock中有newCondition()的方法,来实例化一个ConditionObject对象,因此可以调用多次newCondition()方法来得到多个等待队列。
先来看一个比较简单的例子,一个线程,拿到锁由于某些条件无法满足,调用condition.await()方法
@Slf4j(topic = "s")
public class AwaitTest1 {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) {
new Thread(() -> {
try {
lock.lock();
log.debug("因为某些条件无法满足,进入等待");
condition.await();
log.debug("条件满足了被唤醒,开始工作");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t1").start();
}
}
控制台输出如下,仅仅打印出了一句话,说明调用await方法之后,该线程就不会继续往下执行代码了。这就和Object的wait方法很像,需要另一个线程调用notify来唤醒。不过此处的方法名字不叫做notify,而是signal
修改上面的测试代码如下:
@Slf4j(topic = "s")
public class AwaitTest1 {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
try {
lock.lock();
log.debug("因为某些条件无法满足,进入等待");
condition.await();
log.debug("条件满足了被唤醒,开始工作");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t1").start();
TimeUnit.SECONDS.sleep(4);
lock.lock();
condition.signal();
lock.unlock();
}
}
此时的结果如下,4秒后,主线程将t1线程唤醒,t1线程就继续执行后面的逻辑啦,打印了开始工作。
上面的就是await/signal最基本的使用例子。由两个线程来协作完成,一个线程等待,另一个线程负责唤醒。
这一节来看看awaitUninterruptibly()和await()方法有什么样的不同。
从名字上看,该方法多了Uninterruptibly,不可打断的意思。那么就写一个测试代码,打断一下正在等待的线程看看有什么区别。
先来试一下调用了await()方法的线程被打断的结果,测试代码如下:
@Slf4j(topic = "s")
public class AwaitTest2 {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
lock.lock();
log.debug("因为某些条件无法满足,进入等待");
condition.await();
log.debug("条件满足了被唤醒,开始工作");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t1");
thread.start();
TimeUnit.SECONDS.sleep(4);
thread.interrupt();//打断t1线程
}
}
控制台的输出如下,打断t1线程之后,t1线程会抛出中断异常。
那么调用awaitUninterruptibly()的结果呢?测试代码如下,仅仅将await替换成awaitUninterruptibly。
@Slf4j(topic = "s")
public class AwaitTest2 {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
lock.lock();
log.debug("因为某些条件无法满足,进入等待");
condition.awaitUninterruptibly();//仅修改此处
log.debug("条件满足了被唤醒,开始工作");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t1");
thread.start();
TimeUnit.SECONDS.sleep(4);
thread.interrupt();
}
}
底层是如何实现的呢?第4部分会分析,继续往下看。
上面的两个方法在不发生异常的情况下,会一直在等待被其他线程唤醒。接下来的三个方法,都是带有时间的等待,在一个时间范围内等待,超过这个时间范围,那么就会自己醒来。
测试代码如下:
@Slf4j(topic = "s")
public class AwaitTest3 {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
lock.lock();
log.debug("因为某些条件无法满足,进入等待");
condition.awaitNanos(5000000000l);//5秒
log.debug("条件满足了被唤醒,或超时,开始工作");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t2");
thread.start();
}
}
控制台输出结果如下,等待5秒后,t2线程自己醒来了,继续执行代码。
参数的意思是一个纳秒时间,截止的时间是当前时间+纳秒时间。在截止时间之前,t2线程可以被其他线程叫醒(signal)或者中断(抛出中断异常)。如果超过截止时间,则t2线程自己醒来执行下面的代码。
提问:超过截止时间,t2线程醒来后是立马执行接下来的代码吗?
下面再写一个测试例子看看结果如何:
@Slf4j(topic = "s")
public class AwaitTest3 {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
// t2线程 因为某条件不满足 进入等待队列
Thread thread = new Thread(() -> {
try {
lock.lock();
log.debug("因为某些条件无法满足,进入等待");
condition.awaitNanos(5000000000l);//5秒
log.debug("条件满足了被唤醒,开始工作");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t2");
thread.start();
TimeUnit.MILLISECONDS.sleep(100);
lock.lock();
// 创建5个线程,因为拿不到锁都进入阻塞队列
for (int i = 0; i < 5; i++) {
int finalI = i;
new Thread(() -> {
try {
log.debug("t" + (finalI + 3) + "线程拿不到锁 进入阻塞队列");
lock.lock();
log.debug("t" + (finalI + 3) + "线程拿到锁,开始工作");
TimeUnit.SECONDS.sleep(2);//模拟工作时间2秒
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t" + (i + 3)).start();
}
TimeUnit.MILLISECONDS.sleep(100);//确保t3 - t7 5个线程都进入阻塞队列
lock.unlock();
}
}
先说明一下代码的意图,首先t2线程因为不满足某些条件而调用awaitNanos()方法进入等待队列。之后主线程拿锁,for循环创建5个线程,这5个线程由于拿不到锁会进入阻塞队列,至于为什么,之前的博客已经说明过了,这里不再赘述。
我们来看看结果,看看t2线程是在什么时候执行工作的。控制台输出如下:
可以看到在10秒的时候,它进入了等待队列,但是在20秒的时候,他才继续工作。期间相差的这10秒中,恰好是5个线程,每个线程工作2秒的时间总和。
所以这里可以猜想,t2醒来后它跑到了阻塞队列当中,到底是不是这样的呢?第4部分源码分析的时候再证明。
该方法也是一个规定时间的等待,在截止时间之前,线程可以被其他线程叫醒(signal)或者中断(抛出中断异常)。如果超过截止时间,则线程自己醒来执行下面的代码。
只是这里传入的参数直接是一个截止时间,不再像上面一样需要计算一个截止时间。
测试代码如下:
@Slf4j(topic = "s")
public class AwaitTest4 {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
// t1线程 因为某条件不满足 进入等待队列
Thread thread = new Thread(() -> {
try {
lock.lock();
log.debug("因为某些条件无法满足,进入等待");
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND, 5);
condition.awaitUntil(calendar.getTime());//5秒
log.debug("条件满足了被唤醒,或超时,开始工作");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t1");
thread.start();
}
}
该方法传入等待的时间,和时间单位,相比较于awaitNanos(long nanosTimeout)方法更加的灵活。时间和时间单位进行配合。计算一个截止时间,作用和上面两个方法一样。
测试代码如下:
@Slf4j(topic = "s")
public class AwaitTest5 {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
// t1线程 因为某条件不满足 进入等待队列
Thread thread = new Thread(() -> {
try {
lock.lock();
log.debug("因为某些条件无法满足,进入等待");
condition.await(5, TimeUnit.SECONDS);//5秒
log.debug("条件满足了被唤醒,或超时,开始工作");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t1");
thread.start();
}
}
通过第三部分的方法使用介绍,相信读者已经掌握了这5种方法是如何使用的,以及使用的区别。下面来分析分析底层源码是如何实现的。
首先判断是否中断过,如果发生过中断,那么就会抛出异常。
调用addConditionWaiter方法将当前线程封装成Node结点,并加入到等待队列的末尾。
接着调用fullyRelease释放锁,并记录下状态值。说明await()的线程不再持有锁,这一点和Object中的wait方法是一样的。
调用isOnSyncQueue判断是否在同步队列上,如果当前结点不在同步队列上,说明他在等待队列上,将其阻塞。这里用while循环是为了,等它下次醒来之后再一次的判断是否在同步队列上,如果还是不在同步队列,说明他还在等待队列当中。它就需要继续等待,将其阻塞。
等到他下次被唤醒了,会调用checkInterruptWhileWaiting,判断在阻塞期间是否发生了中断。如果发生了中断,说明取消等待。代码如下:
再接着就是调用acquireQueued方法,此时的结点一定在同步队列上了,所以该方法的执行逻辑,就和之前博客中介绍过的同步阻塞的结点抢锁的逻辑一样。不清楚的读者可以回看之前的博客。
之后就是,判断当前结点的nextWaiter是否为空,如果不为空的话,调用unlinkCancelledWaiters()将取消等待的结点从等待队列中移除。
分析过await方法的源码,再来看接下来的源码就比较容易了。该方法与await的主要区别在于,不可中断。所以如果发生了中断,并不会抛出异常,只有一个措施就是重新中断(中断补偿)。下面来看看代码:
大致逻辑基本相同,仅仅在发生中断的处理上不太一样。此处不需要记录响应中断的模式,无论结点是在同步队列还是等待队列上发生的中断,都采取中断补偿的机制。
该方法让线程在一个时间范围内等待,超过这个时间范围,那么就会自己醒来。源码实现如下:
根据传入的参数,计算出等待的截止时间,逻辑和前面的都差不多。
最主要的区别,在于红色框框的部分,while循环的条件是结点在等待队列上。如果剩余等待时间nanosTimeout小于等于0,那么它就会取消等待,调用transferAfterCanelledWait方法进行队列转移,转移到同步队列上。
如果剩余时间大于spinForTimeoutThreshold,那么该线程会阻塞。这里设置一个阈值,是为了避免时间过短,导致频繁的系统调用(阻塞,唤醒)。
该方法和上面的几乎一样,只是不需要计算截止时间,传入的参数就是截止的时间。
该方法可以说是awaitNanos(long time)的升级版吧,根据时间单位和传入的数字,转换成纳秒时长。之后的逻辑都一样的。
尝试CAS修改结点的状态,如果失败了返回false。说明该结点已经转移了。
如果修改成功。那么就调用enq方法,将结点从等待队列转移到同步队列,enq方法会返回node结点的前驱。
然后判断前驱结点p的ws。此处分2种情况。
第一种:ws大于0,说明这个结点是一个取消状态的结点,那么就可以唤醒当前node结点的线程。
第二种:ws<=0,CAS修改前驱结点的状态为-1。如果修改失败了,说明它本身结点的状态就是-1了。那么此时它有义务唤醒后继结点,也就是唤醒当前node结点的额线程。
以上就是signal方法的源码,比较的简单。
signalAll源码分析
这里和上面的逻辑是一样的。只是内部调用的方法不一样,主要来看看doSignalAll方法的不同。
该方法的逻辑也很简单,从头结点开始,一个一个的将等待队列当中的结点转移到同步队列当中。