前言
在上篇已经了解到,破坏占用且等待条件时如果两个锁不满足同时获取的条件下,就用死循环的方式来循环等待。
while(!actr.apply(this, target))
;
如果apply操作耗时非常短,并发量冲突不大的情况下,是可以这样做的。但是在现在并发量大,耗时时间长的情况下就不适用了,因为可能需要循环上万次才能获取到锁,太消耗CPU了。
为了解决上面循环等待消耗CPU的问题,最好的方案是:如果线程要求的条件不满足,则线程阻塞自己,进入等待状态;当线程要求的条件满足后,通知等待的线程重新执行。这就需要支持等待-通知机制。
完美的就医流程
现实中就医流程大致如下:
挂号——就诊门诊分诊——等待叫号——大夫就诊——去做检查——叫下一位患者——检查完拿检测报告重新分诊——等待叫号——大夫就诊
上述就是一个“等待-通知机制”的典型案例。
把上面示例类比到软件中:申请CPU执行权 – 线程获得CPU执行权 – 等待其他线程释放锁 – 拿到锁 – 检查发现不满足某种条件 – 释放锁 – 等待任务条件完成 – 等待CPU的执行权 – 重新获得锁 – 任务完成。
一个完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
用synchronized实现等待-通知机制
Java语言中,可以通过synchronized配合wait(),notify(),notifyAll()这三个方法实现。
用synchronized实现互斥锁:同一时刻,只允许有一个线程进入到synchronized保护的临界区。
当有一个线程进入临界区后,其他线程 就只能进入图中左边的等待队列里面等待;在并发程序中,如果某些条件不满足需要进入等待状态,Java对象的wait() 方法就能够满足这种需求,当调用了wait()方法后,当前线程就会被阻塞,并且进入到右边的等待队列中。这个等待队列也是互斥锁的等待队列。线程在进入等待队列的同时,会释放持有的互斥锁,其他线程也就可以获得锁进入临界区。
等待队列和互斥锁是一对一的关系,每个互斥锁都有自己的等待队列。
线程要求的条件满足时,Java提供了notify()
和notifyAll()
方法,当条件满足时调用notify()和notifyAll()方法,会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
需要注意的是:
- 条件曾经满足过:因为notify()只能保证在通知时间点条件是满足的。而被通知线程的执行时间点和通知时间点基本上不会重合,所以当线程执行时,很可能条件已经不满足了(保不准有其他线程插队)。
- 重新申请互斥锁:被通知的线程要想重新执行,仍然需要获取到互斥锁。因为曾经获取的锁在调用wait()时已经释放了。
** 我们直到,如果synchronized锁定的是this,那么对应的一定是this.wait(),this.notify(),this.notifyAll()。
如果锁定的是target,那么锁定的就是target.wait(),target.notify(),this.notifyAll()。**
** 而且如果调用wait(),notify(),notifyAll()方法被调用,前提是已经获取到了相应的互斥锁。所以wait(),notify(),notifyAll()必须在synchronized修饰的临界区被调用的,如果在临界区外部调用,或者锁定的是this,而调用target.wait()的话,JVM会抛出一个运行时异常:Java.lang.IllegalMonitorStateException**。
小试牛刀:一个更好地资源分配器
在上篇的转账问题中,由于while(!apply())循环很消耗CPU,那就用等待-通知机制优化一下。
在等待-通知机制中,需要考虑一下四个要素:
- 互斥锁:单例Allocator,可以用this作为互斥锁
- 线程要求的条件:必须转出/转入账户都没有被分配过
- 何时等待:线程不满足条件就等待
- 何时通知:线程满足条件就通知
class Allocator{
private Allocator(){
}
private List<Object> als = new ArrayList<>();
/** 申请资源 */