持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情
本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)
前言
前面一篇文章我们说了Java SDK为什么又要创建Lock来实现管程,原因是区别与synchronized隐式锁的三个特性:能够响应中断、支持超时和非阻塞地获取锁。
那本篇文章就说一下这个Condition,这个Condition实现了管程模型中的条件变量,这也是区别与synchronized的关键点,Java内置的管程synchronized关键字只有一个条件变量,而Lock和Condition实现的管程模型可以有多个条件变量,支持多个条件变量,在业务开发上也更合理。
正文
其实在前面的介绍管程的文章中,我们就说了一个非常好的例子,这里我们还是用这个例子来说一下,就是简单实现阻塞队列。
简单实现阻塞队列
这里我们简单实现一个线程安全的阻塞队列,其实就是管程的模型,代码如下:
class BlockedQueue<T>{
//定义锁
private val lock = ReentrantLock()
//条件变量:队列没有满
private val notFull = lock.newCondition()
//条件变量:队列不是空
private val notEmpty = lock.newCondition()
//入队
fun enq(x: T){
lock.lock()
try {
while (队列已满){
//等待队列不满
notFull.await()
}
//入队
//入队后,通知队列不为空的等待队列
notEmpty.singal()
}finally {
lock.unlock()
}
}
//出队
fun deq(){
lock.lock()
try {
while (队列已空){
//等待队列不为空
notEmpty.await()
}
//出队
//出队后,通知队列不满的等待队列
notFull.singal()
}finally {
lock.unlock()
}
}
}
上面代码的逻辑不过多解释了,其中lock是为了实现互斥,来保证线程安全,即管程模型的入口保证只能有一个线程进入管程模型内部;而notFull和notEmpty就是这个管程的条件变量,根据前面我们学习管程知道,每一个条件变量都有一个等待队列,在等待队列中的线程,在操作系统底层都是属于休眠状态,然后在合适的时机被唤醒。
还有就是和管程和synchronized一样的编写编码范式,即判断添加变量是否为true时,不能使用if,而是使用while,这是因为线程执行到调用await()的时候会进入休眠释放锁,但是当被唤醒时,它要重新去获取锁,而在获取锁的期间,可能条件已经不满足了,所以要重新再判断一遍条件。
这里需要注意的是线程的等待和通知我们使用await()、signal()和signalAll(),这3个方法是Lock和Condition实现的管程模型所使用的方法;而synchronized所实现的管程模型,其使用的是wait()、notify()和notifyAll()这3个方法,这3个方法也只有在synchronized中可以被使用,切记不能使用错了。
同步和异步
说完了Lock和Condition的一个经典案例后,我们再来看一个知名项目Dubbo中,Lock和Condition是如何使用的。
在Dubbo中,有个很著名的地方就是异步转同步,在说这个怎么实现之前,我们想一下什么是同步 什么是异步。
同步和异步的区别到底是什么呢 通俗来说就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。注意这里是调用方是否需要等待,即当前调用线程。
比如下面的代码,有一个计算小数点后100万位的方法pai1M(),这个方法肯定要执行很久:
// 计算圆周率小说点后100万位
String pai1M() {
//省略代码无数
}
pai1M()
printf("hello world")
假如线程一直等待结果,等了很久执行printf("hello world"),这个就是同步;如果调用pai1M()之后,调用方线程不用等待,直接执行printf("hello world"),这就是异步。
而我们平时是如何让代码支持异步的呢,就是通过多线程:
- 调用方直接创建一个子线程,在子线程中调用pai1M(),这种调用就成为异步调用;
- 方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接return,这种就是异步方法;
不论哪种方式,都需要创建子线程去执行耗时操作,这种做法在Android中更为常见,因为在Android中主线程是不能进行耗时操作的。
异步转同步
我们平时最常见的就是使用回调函数来获取子线程运行的结果,这时主线程可以正常运行,而回调中的代码在子线程运行。
但是在Dobbo中,经常发现工作中的RPC调用都是同步的,但是RPC调用在TCP协议层面是异步的,即线程不会等待RPC的响应结果,那这个是如何实现的呢 就是异步转同步。
对于下面一段简单的代码:
DemoService service = 初始化部分省略
String message =
service.sayHello("dubbo");
System.out.println(message);
代码执行到service.sayHello()时,线程会停下来等待结果,直到异步调用结果返回,线程再继续往下执行,当在等待的时候,我们把调用线程dump出来,会是下图:
可以发现线程状态是TIMED_WAITING,本来发送请求是异步的,但是调用线程却阻塞了,说明Dubbo帮我们做了异步转同步的事情,即把当前线程阻塞,等异步结果返回时,再唤醒当前线程。
从调用栈可以发现,线程是阻塞在DefaultFuture.get()方法上,所以这个DefaultFuture就很关键,这时我们就可以思考一下我们的需求:当RPC返回结果之前,阻塞线程调用,让线程等待;当RPC返回结果之后,唤醒调用线程,让调用线程重新执行。
看到这里是不是就感觉非常熟悉了,这就是经典的等待-通知机制,而这个机制就必须要联想到管程模型了,利用Lock和Condition就可以实现这个模型,下面就是DefaultFuture的精简代码:
class DefaultFuture{
//可重入锁
private val lock = ReentrantLock()
//条件变量
private val done = lock.newCondition()
//调用方通过该方法等待结果
fun get(timeout: Int): Any{
val start = System.nanoTime()
lock.lock()
try {
while (!isDone()){
done.await(timeout)
val cur = System.nanoTime()
if (isDone() || cur - start > timeout){
break
}
}
}finally {
lock.unlock()
}
if (!isDone()){
throw TimeoutException()
}
return returnFromResponse()
}
//RPC结果是否已经返回
fun isDone(): Boolean{
return response != null
}
//RPC结果返回时调用该方法
private fun doReceived(res: Any){
lock.lock()
try {
response = res
done?.signal()
}finally {
lock.unlock()
}
}
}
这里可以发现调用线程调用get()方法获取RPC返回结果,在这个方法里,我们能看到许多熟悉的操作:调用lock()获取锁,在finally里面调用unlock()释放说;获取锁后,通过经典的循环范式中,发现条件不满足,调用await()方法来实现等待。
当RPC结果返回时,会调用doReceive()方法,在这个房里,又会调用lock()获取锁,同样在finally中会进行解锁,获取锁后会调用signal()方法来通知线程,结果已经返回,不用再等待了。
其实可以发现这种异步转同步的操作,也就是灵活运行管程模型,熟悉了管程模型就非常好理解。
总结
本篇和上篇文章说了管程除了synchronized这种实现外的另一种实现方式,而区别与synchronized的方式,使用Lock和Condition实现的管程模型,可以有多个条件变量,也让进入管程模型的线程通过Lock的方法不用一直等待,可以解决死锁问题。
欢迎大家点赞、收藏、评论,一起进步。