Java并发编程 | 使用"等待-通知"机制让CPU不再无效空转!

1,497 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第21天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

上一篇文章我们说到了死锁的产生条件,以及通过破坏死锁产生的条件来预防死锁,其中破坏"占有且等待"的做法是一次性申请所有资源,而我们的代码是这样写的:

// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
  ;

这个apply方法是通过锁住单例actr来实现多线程互斥

// 一次性申请所有资源 
synchronized boolean apply(Object from, Object to){ 
    //当锁定的资源池中有from或者to的话,则说明没有完全申请成功 
    if(als.contains(from) || als.contains(to)){ 
            return false; 
        } else { 
            //再把form和to加到锁定的资源池中 
            als.add(from); 
            als.add(to); 
        } 
        return true; 
  }

这里返回false表示没有全部申请成功,当N个线程进入转账时,都需要通过while循环来获取其线程需要的资源,如果并发量不大的情况下,这种场景while循环个几次或者几十次就可以获取到资源;但是当并发量极大的时候,这个while循环就可能要循环上万次甚至百万次才能成功获取到所需要的所有资源,而这个就太消耗CPU了。

那这里的最好做法是当线程1去执行A转账B操作时,假如发现A、B账户有被别的线程锁占有时,它就可以先阻塞自己,而不再再去判断,当A、B账户资源被释放时,再通知这个线程1去申请A、B资源,这样就解决了CPU无效空转导致的问题了。

而这个等待-通知机制就是我们今天所说的重点。

正文

在说具体概念之前,我们先看个现实世界中的例子,也就是医院就诊流程

就医流程

在现实世界中,我们去医院就诊的过程大致如下:

  1. 患者先去挂号,然后到就诊门口分诊,等待叫号;
  2. 当大夫叫到自己的号时,患者就可以去就诊了;
  3. 就诊过程中,大夫发现患者需要额外检查,就让患者去做检查,同时叫下一位患者;
  4. 当患者做完检查时,还需要重新分诊,等待叫号;
  5. 当大夫再次叫到自己时,患者再去就诊;

会发现这个流程就有着完美的等待-通知机制,不仅可以保证同一时刻大夫只为一个患者服务,而且还能在患者去做检查时为其他患者服务,能够保证效率,那我们就来类比完善一下编程世界中的等待-通知机制:

  1. 患者去大夫门口分诊,类似于线程要去获取互斥锁;当患者被叫到时,类似线程已经获取到锁了
  2. 大夫让患者去做检查,类似于线程要求的条件没有满足,比如上面转账例子要求都获取A、B2个账户。
  3. 患者去做检查,类似于线程进入等待状态;然后大夫叫下一位患者,这个就注意了,相当于线程释放了互斥锁
  4. 患者做完检查,类似于线程要求的条件满足;患者拿着监测报告需要重新分诊,这就类似于线程需要重新获取互斥锁

综合上面我们可以得出一个完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁

synchronized实现等待-通知机制

这里就用我们前面文章说过的synchronized来实现,需要配合**wait()、notify()、notifyAll()**三个方法。

前面文章我们知道synchronized关键字实现互斥的本质就是锁,我们要明白其锁的对象是什么以及其保护的资源,比如下图,左边有一个等待队列,根据互斥锁的原理,同一时刻只能有一个线程可以进入临界区,其他线程在等待队列中等待;当有一个线程进入临界区后,发现条件不满足,这时会把这个线程阻塞,放入到右边的等待队列中:

image.png

让一个线程等待的方法,使用wait()方法就可以,当调用wait()方法时,线程会释放锁,进入右边的等待队列,这时其他线程可以来获取锁了。

那么当条件满足时,就需要通知刚刚等待的线程,重新去获取锁,在Java中使用notify()和notifyAll()方法可以实现,当条件满足时,会去通知上面右边的等待队列中的线程去抢占锁,重新进入临界区,告诉他们条件曾经满足过

image.png

这里为什么要说是曾经满足过呢 因为notify()只能保证在通知的时间点,条件是满足的,而被通知的线程执行时间点肯定和通知时间点是不一样的,可能到线程执行的时候以及不满足了

通过上面例子,我们可以看出一些锁的底层细节,比如上面synchronized关键字锁的this对象,那这个this就会有一个就绪队列(左边的等待队列)和一个等待队列(因为条件不满足等待的队列,右边的),而在synchronized内部调用notify()、wait()方法时调用的是this对象的notify()和this对象的wait()方法,这个在我们后面学习锁时,就会发现这个底层细节。

优化转账

搞清楚了上面等待-通知机制后,我们就可以优化前面说的转账问题了,在使用这个之前,我们要搞清楚下面4个元素:

  1. 互斥锁,这个必须要明确;
  2. 线程要求的条件:转出账户和转入账户都没有被分配;
  3. 何时等待:线程要求的条件不满足时等待;
  4. 何时通知:当线程释放账户的时候通知。

这里我们可以使用一个范式来表达:

  while(条件不满足) {
    wait();
  }

这个写法,不要轻易修改,那下面就是优化后的转账:

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();
  }
}

这时假如有N多个线程进行转账,都会来调用apply函数来申请资源,当线程X发现条件不满足时,X就会进入等待队列,当其他线程释放了资源,线程X就会被唤醒,会被放入到就绪队列中,当X获取到锁后,它会再次判断条件满不满足,当所需要的账号都有时,便可以进行转账了。

通过上面代码后,Accout中的转账函数便不用再通过while循环写法了,apply函数也不用返回boolean类型了。

这里注意,这里为什么使用while,我们想一下,线程会在调用wait()地方阻塞,当被通知时,获取到锁后,也是在这个地方重新运行,假如是下面写法:

if(条件不满足){
    wait()
}
//后续执行

当线程被通知时,就不会再去判断条件了,就会导致错误。

尽量使用notifyAll()

前面我们知道,通知线程重新去运行有notify()和notifyAll()2个方法,而这2个方法区别是:notify()会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程,这里尽可能使用notifyAll(),因为notify()的随机性唤醒一个线程可能会导致某些线程永远不会被通知到

比如这里有10万个线程去调用apply()方法,有5万条线程都进入了等待队列,有3条线程在就绪队列,这时资源被释放,就通知了其中一条线程,这时就有4条线程去尝试获取锁,而剩下的49999条线程依旧在阻塞;假设4条线程很快执行完了,条件都满足,那等待队列中的线程就G了;如果使用notifyAll()去通知,就是50004条线程都去获取锁,当条件不满足时,这些线程再进入等待队列。

总结

记住每个锁都有线程等待队列和线程就绪队列,等待队列是该锁调用wait()方法而导致阻塞的线程队列,就绪队列是准备去获取锁执行的线程队列,而等待-通知机制就是利用这个来实现的,其中范式写法很重要。