Java并发编程学习——使用等待通知机制优化循环等待

什么是等待通知机制

在前面提到的破坏占用且等待条件的时候使用了死循环来获取资源,当apply()操作历时非常长或者并发量很大的时候,这个死循环是非常占cpu资源的,所以这种场景下可以使用等待通知机制来优化循环等待。

什么是等待通知机制?所谓等待就是当线程获取锁进入临界区想要获取相应资源而条件不满足获取不到的时候,线程自己进入阻塞状态(等待)并且释放锁,等到线程需要的资源都有的时候则通知线程它需要的资源曾经满足过。

为什么说曾经满足过,因为当线程被通知唤醒的时候还需要重新获得互斥锁,在这个阶段是有时差的,所以这个时候可能会出现一些情况导致刚刚满足的资源又被其它线程拿走了。

完美的就医流程

就医流程就可以比作这个等待通知机制。

  1. 患者需要挂号,等待叫号
  2. 等到叫到自己的号的时候就可以找医生就诊了。(获取互斥锁)
  3. 就诊过程医生可能要叫患者去做一些检查。同时叫下一个患者(因为某种条件,资源不符合线程释放锁进入阻塞状态)
  4. 患者做完检查,拿着报告重新分诊(资源满足要重新获得互斥锁)
  5. 医生再次叫到自己号的时候,患者再去找医生就诊(条件资源满足重新获得锁)

使用synchronized实现等待-通知机制

在Java语言中可以通过synchronized结合wait(),notify(),notifyAll()这三个方法来实现等待-通知机制。

在下面这个图里,左边有一个等待队列,同一时刻,只允许一个线程进入 synchronized 保护的临界区(这个临界区可以看作大夫的诊室),当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列

wait原理

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。如上图所示,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。

上面我们一直强调 wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Allocator {
private List<Object> als;
// 一次性申请所有资源
synchronized void apply(
Object from, Object to){
// 经典写法
// 不满足则等待(释放锁并阻塞自己)
while(als.contains(from) ||
als.contains(to)){
try{
wait();
}catch(Exception e){
}
}
als.add(from);
als.add(to);
}
// 归还资源
synchronized void free(
Object from, Object to){
als.remove(from);
als.remove(to);
notifyAll();
}
}

尽量使用notifyAll

notify会随机通知等待队列中的某一个线程,而notifyAll会通知等待队列中的所有线程。

所以使用notify的风险就是有些线程可能永远不会被唤醒,所以除非经过深思熟虑,不然尽量避免使用notify。

-------------本文结束感谢阅读-------------