上篇文章中介绍了死锁的形成以及破解方式,其中破坏 占用且等待
条件使用了一个死循环的方式去持续申请资源,直到申请到所有资源才继续向下执行:
while(!actr.apply(this,target));
// 继续向下执行业务逻辑
...
如果 apply
申请资源的操作 耗时非常短 ,并且并发量冲突不大时,这个方案是一个比较合适的解决方案。
但是如果申请资源的操作**耗时长,并发量冲突大
,就可能导致循环很久,在这种情况下可能循环上万次才能获取到锁,非常耗费 CPU 资源
**。
在这种情况下比较好的解决方案:
如果线程要求的条件不满足(比如没有申请到需要的资源),则线程进入
等待
状态,当继续执行的条件满足后,通知
等待的线程重新执行。使用线程阻塞的方式就可以避免循环等待消耗 CPU 的问题。
Java 也是支持这种 等待——通知 机制的。
完美的就医流程
下面是一个作者的例子,帮助我们理解 等待——通知 机制,就是我们平时去看病的叫号机制:
- 挂号,找到对应门诊,
等待
叫号 —— 【线程需要获取锁进入临界区执行互斥代码】 - 叫到自己时进屋就诊 —— 【获取到了
锁
】 - 就诊过程中可能需要做检查,于是去做检查,此时大夫会让下一位患者进来就诊 —— 【不满足某些执行条件,该线程进入
等待状态
,释放锁
】 - 做完检查重新拿个号等待叫号 ——【 条件满足后等待线程被唤醒,重新进入
RUNABLE
状态等待调度,重新获取锁
】 - 再次叫到自己号时带着报告去找大夫 —— 【获取
锁
,进入临界区
,继续执行下面的业务】
一个完整的等待通知——机制的机制描述:
线程首先获取互斥锁,当线程要求的条件不满足时,释放锁,进入等待状态;
当要求满足时,**
通知
**等待线程,重新获取锁。
使用 synchronized 实现 等待 — 通知机制
Java中关于 等待——通知 的实现有多种方式,使用 内置的 synchronized
配合 wait()
、notify()
、notifyAll()
就可以实现这个机制。
首先使用 synchronized
定义一个互斥的代码块,想进入其中必须获取对应的锁对象,这个临界区也就是上面例子中的医生诊室。
当一个线程进入后,其他线程只能等待锁被释放后才能获取锁进入临界区,也就是下图中的等待队列。
这个等待队列和互斥锁是一对一的关系,每个锁
都有自己独立
的等待队列
。
在并发程序中,当一个线程
进入临界区
后,由于某些条件不满足,就需要进入等待状态,Object
中的 wait
方法就是提供这个功能的。
如上图所示,当调用 wait
方法后,当前线程会被阻塞,进入到右边的等待队列中,这个等待队列同时也是获取互斥锁的等待队列。当线程进入等待队列后,会释放持有的互斥锁,这样其他线程就有机会获取锁进入临界区了。
当线程要求的条件满足时,需要通知这个处于 WAITING
状态的线程,这需要用到 notify
和 notifyAll
方法。
下面这个图大致描述了该过程:当条件满足时调用 notify
会通知互斥锁的等待队列中的线程,告诉它条件曾经满足过。
这里条件曾经满足过指的是:notify
只能保证在通知的时间点条件是满足的,而被通知线程的执行时间点和通知时间点基本不会重合
,所以可能当线程执行时条件已经不满足了(例如其他线程插队)。这一点需要注意
这里一直强调 wait
、notify
、notifyAll
操作的等待队列指的是 互斥锁等待队列,如果 synchronized
的锁对象是 this
,对应的调用者就是 this.wait()
、this.notify()
、this.notifyAll()
,一定是**锁对象
**来调用这些方法,而这些方法的调用前提也是已经获取了相应的互斥锁。
如果在临界区
外调用的话,就会抛出 java.lang.IllegalMonitorStateException
运行时异常。
实践:修改上一章分配器,实现一个更好的
上一章中针对转账存在的死锁条件分别实现了不同的解决方法,这里将 等待—通知
机制的基本原理搞清楚后,可以使用这个机制来解决资源的一次性申请问题,在这个机制中我们需要考虑4个要素:
互斥锁
:Allocator
是单例的,所以可以用this
作为互斥锁(效果等同于用Class
对象做互斥锁,都是将程序变为串行程序了。)线程要求的条件
:转出账户和转入账户都同时可用。何时等待
:条件不满足时等待。何时通知
:有线程释放账户时通知。
考虑清楚这4个问题就可以使用 等待—通知 机制完成代码了:
while(条件不满足) {
wait()
}
利用这个方法可以解决上面提到的:条件曾经满足过这个问题,当 wait() 返回时,有可能条件已经发生变化,曾经满足但是现在已经不满足了。所以需要重新校验条件。
范式意味着经典写法,所以没有特殊理由不要改变写法。
public class Allocator2 {
private List<Object> als;
// 一次性申请所有资源
synchronized void apply(Object from, Object to) {
// 经典写法
while (als.contains(from) || als.contains(to)) {
try {
wait();
} catch (InterruptedException e) {
}
}
als.add(from);
als.add(to);
}
// 释放资源
synchronized void free(Object from, Object to) {
als.remove(from);
als.remove(to);
notifyAll();
}
}
尽量使用 notifyAll
因为 notify
是**随机
**通知等待队列中的一个线程,可能我们需要通知的线程一直无法被通知到,而 notifyAll
则是通知队列中的所有线程,所以一般情况下使用 notifyAll
更安全。
假设有4个资源:
A
、B
、C
、D
线程1
申请到了 资源AB
线程2
申请到了 资源CD
此时
线程3
申请AB
会进入等待队列(因为资源以及被分配给了线程1)
线程4
申请CD
也会进入等待队列假设
线程1
归还
了资源AB
,如果使用的是notify
来通知等待队列中的线程,因为其通知是随机
性的,可能通知到的是 线程4,但是线程4
申请的资源是CD
,于是线程4继续等待,由于没有通知到3,所以线程3将一直等待下去,没有机会被唤醒。
总结
等待—通知
机制是一种非常普遍的线程间协作的方式。我们平时经常使用轮询的方式来等待某个状态,很多情况下都可以使用 等待—通知来进行优化,因为在等待的时候不会耗费 CPU 的资源。
Java 语言的这套实现背后的理论模型是 管程
。
个人小结:
最近正好在看 Object
和 Thread
类的源码,同时也在梳理 sleep
& wait
¬ify
以及线程之间状态转换的关系。
刚开始觉得这些东西挺简单的,写了几个demo看了看注释,发现程序真正运行起来和自己之前想象的结果还是不一样的。
这篇极客时间的文章主要就是讲了 wait
和 notify
,刚好也对应上了最近正在学的东西,还不错,作者也总是在举现实中的例子帮助理解。
但是这些基础的API好像用的并不是很多,JDK的JUC包中有更强大更方便的类供我们使用,但是一定是要理解其基本的原理的。
精选留言:
1、 wait 与 sleep 的区别:
- wait 释放锁,而 sleep 不释放锁
- wait 只能在临界区内使用, sleep 可以在任何地方调用
- wait 无需捕获异常,sleep 需要。
相同点
:两者在调用后都会出让 CPU 时间片,等待再次调度。
Q.E.D.
Comments | 0 条评论