Java多线程(四)——等待,并心怀希望吧。线程的等待/通知机制(一)

Posted by Lain on 10-24,2019

0

等待/通知机制

今天我们来看Java多线程的等待——通知机制。

多线程的环境下,往往有这么一种需求,当某个线程执行到某个地方的时候,需要来自其他线程的数据,但我们又无法确定它是否已经准备就绪,你想让其他线程在准备好数据之后,给你打个招呼,在这之前,你就先等一会,等他招呼来了,你再继续执行。

有的同学肯定第一时间想到了join(),但是join是要等到对方执行完毕的,我们可能需要两个进程交互进行,使用join就不是那么灵活了。

我们可以举一个简单的运用场景。假设你在构建一个分布式的项目,项目与项目之间要保证通讯,因此我们都会起一个心跳线程,来向其他项目定期发送心跳包。如果对方项目掉线了或者出现了网络波动,那么业务线程就应该暂时停止,让心跳线程去检测对方是否上线,并且在对方上线后重新唤醒业务线程执行业务代码。

这里的心跳线程就是一个唤醒线程,唤醒线程会不断的去判断业务线程的执行条件,当发现条件不满足时,业务线程会暂时停止,直到条件满足,并被唤醒线程唤醒后,再去继续执行业务操作。在这个过程中,等待线程被唤醒的条件并不是唤醒线程的结束,而是在唤醒线程执行过程中对等待线程的主动唤醒,唤醒完了心跳还要继续呢,所以在这里,join并不适用。

又有同学可能会问(老实交代,这个同学是不是你自己.jpg),那么,我们可不可以使用一套公用变量,在等待线程中以这套公用变量为条件,用while循环去阻塞住等待线程,直到公用变量被唤醒线程改为false,然后跳出循环,继续执行下面的业务代码呢?当然,这里对于公用变量要有锁去保证它的原子操作等等。。

当然可以啦,但是while循环空转是很耗性能的,虽然我们可以利用Thread.sleep(1)等方法降低对于CPU的使用率,但这么一套下来也很繁琐了。其实这种方式已经很接近我们接下来要说的方法了。

实际上,JDK为我们提供了一套唤醒/通知机制——wait/notify(All),用于在多线程中执行等待与唤醒。

线程的生命周期

在这之前我们先来学习一下线程的生命周期,会有助于对接下来内容的理解:
Thread.state是个枚举类,其中定义了六种线程的状态(以下内容部分整理自《Java多线程实战核心指南-核心篇》):

NEW

线程在被创建出来之后,在启动之前,都处于这个状态。由于一个线程实例只能启动一次,因此一个线程只可能处于这种状态一次。

RUNNABLE

该状态可以被看成两个复合状态:READY和RUNNING,前者表示处于该状态的线程可以被线程调度器进行调度而处于RUNNING状态。后者表示处于该状态的程序正在被处理器执行。执行了Thread.yield()的线程,其状态可能会由RUNNING转换为READY。处于READY子状态的线程也被称为活跃线程。

BLOCKED

一个线程发起一个阻塞式I/O(Blocking I/O)操作后,或者申请一个由其他线程持有的独占资源(比如锁)时,相应的线程会处于该状态。处于BLOCKED状态的线程并不会占用处理器资源。当阻塞式I/O操作完成后,或者线程获得了其申请的资源,该线程的状态又可以转换为RUNNABLE。

WAITING

一个线程执行了某些特定方法之后就会处于这种等待其他线程执行另外一些特性操作的状态。能够使其执行线程变更为WAITING状态的方法包括:Object.wait()、Thread.join()和LockSupport.park(Object)。能够使相应的线程从WAITING变更为RUNNABLE的相应方法包括:Object.notify()/notifyAll()和LockSupport.unpark(Object)

TIMED_WAITING

该状态和WAITING类似,差别在于处于该状态的线程并非无限制地等待其他线程的特定操作,而是处于带有时间限制的等待状态。当其他线程没有在指定时间内执行该线程所期望的特定操作时,该线程的状态自动转换为RUNNABLE

TERMINATED

已经结束的线程处于该状态,由于一个线程实例只能被启动一次,因此一个线程也只可能有一次处于该状态。Thread.run()正常返回或者由于抛出异常而提前终止都会导致相应的线程处于该状态。

wait/notify的作用与用法

wait和notify(All)都是由Object提供的方法,也就是说,任意一个Object对象都可以用来协调线程之间的协作。
其中Object.wait()方法是将执行它的线程暂停(其生命周期被更改为waiting),该方法可以实现等待,Object.notify()的作用是唤醒一个被暂停的线程,调用该方法可以实现通知。调用wait方法的,我们叫它等待线程,而调用notify方法的,我们称之为唤醒进程。还有一个Object.notifyAll(),它可以唤醒等待在该对象上的所有线程。

首先我们明确一点,那就是线程的等待与唤醒,都是依赖的同一个对象,等待线程调用了某个对象的wait方法进入了等待状态,那么要想唤醒这个线程,必须要由唤醒线程调用同一个对象的notify方法。

关于线程的等待有如下模板代码:

//调用wait方法前必须先获得相应对象的内部锁
synchonized(someObject){
	while(保护条件不成立){
		someObject.wait();//暂停当前线程
	}
	//代码能执行到这里,说明条件已到
	//执行目标动作
	doAction();
}

看到这里的while循环了吗?是不是和我们前面说的很像?然而其实是有很大区别的。我们逐步来分析它的执行过程。

首先,按照要求,我们需要获取到相应对象的锁。因此我们用synchonized将代码包裹。
然后我们进入了一个while循环,循环的条件是保护条件不成立,当条件不成立时,调用someObject的wait方法进入暂停状态。也就是说,我们肯定有一个或多个共享的外部变量作为继续执行的判断条件,可以被唤醒线程所更改,当唤醒线程发现满足等待线程的条件的时候,调整保护条件的结果为false,然后调用someObject的notify方法,将守护线程唤醒。
这里要注意:当等待线程调用wait的时候,会先释放掉对someObject的锁,然后进入WAITING状态,这个时候的wait方法其实并没有被返回,当这个线程重新拿到someObject的锁的时候,才会将wait方法返回,继续执行剩余代码,而且还会再循环判断一次保护条件,不符合的话继续给爷锁上。

为什么要这么做呢?首先,等待线程中对于保护条件的判断和目标动作的执行必须要是一个原子操作,为了防止产生竞态——目标动作被执行前的一刻,如果其他线程对共享变量的更新又使得保护条件重新不成立,那接下来的代码执行肯定是有问题的,这也意味着,只有唤醒线程才能对共享变量进行修改,且修改共享变量的代码,也应当被同一个对象,也就是someObject的锁给锁上,在执行等待线程的代码时,任何线程都不能调用更新共享变量相关的代码块,除非等待线程释放了锁。
这就是等待线程在执行wait前必须要获得锁的原因。

而为什么要把wait放在一个while循环里?从刚才的描述来看,wait在返回前会把锁拿回来,这个时候任何人都不可能修改保护条件的共享变量,而只有在保护条件满足的时候,唤醒线程才会调用notify(),这样看来,等待线程在继续执行的时候必然是满足条件的呀?这个while是不是显得有点多余?

然而,这里是有坑的。当守护线程调用notify通知等待线程的时候,等待线程并不一定会第一时间拿到这个锁,有可能其他等待这个锁的线程把这个锁抢走了,并且更新了共享变量!另外,Object.wait()是允许多个线程同时调用的,其内部维护了一个被称为等待集的队列,每当有一个线程调用了这个Object的wait方法,都会将这个线程的引用放进该对象的等待集中,然后每当调用一次notify方法,都会将等待集中的任意一个线程唤醒,并不一定就是唤醒你想要的那个。因此,在wait返回之后,再次进行保护条件的判断是及其有必要的,如果在线程等待锁的过程中有其他线程更新了保护条件,那么即使你wait拿到了锁,代码块依旧是锁定的,但你的保护条件已经不符合要求了,会导致之后的代码出现问题。

我们再来看一下唤醒线程的模板代码:

//必须要持有对象的锁,才能调用notify方法!
synchonized(someObject){
	//更新共享状态
	updateSharedState();
	//唤醒线程
	someObject.notify();
}

要注意的是,notify系列方法并不会像wait那样主动释放锁,影刺我们要将它的调用尽可能的靠近临界区结束的地方,也就是被锁定代码块的尾部。等待线程被唤醒之后占用处理器继续运行的时候,如果其他线程持有了相应对象的内部锁,那么这个等待线程可能又会被再次暂停,以等待再次获得内部锁的机会,而这会导致上下文切换。

小结

可以看出来,wait和notify的设计,实际上使得等待线程和唤醒线程都是基于同一个对象进行同步,一个对象允许多个线程进入其等待池,因此,notify方法并不一定会唤醒我们想要的线程,在实际运用当中,我们常用notifyAll替代该方法。notifyAll会唤醒所有等待在该对象上的线程,但是会照成提早唤醒的问题,由于是唤醒全部的线程,一些我们不想让他唤醒的线程也会被唤醒,这会导致锁被反复争夺,使得上下文频繁切换,影响程序性能。虽然while(条件不符合)的存在,使得即使被提早唤醒,程序也不会出错,但总归不是优雅实现。因此,在设计多线程程序的时候,只有以下两种条件全部满足才可以用notify替代notifyAll:

  1. 确实一次通知仅仅需要唤醒一个线程。
  2. 同步对象的等待集中,仅包含同质线程。

什么是同质线程?即这些线程使用同一个保护条件,并且在Object.wait的调用返回后的处理逻辑一致。比如同一个Runnable接口实例创建的不同线程(实例)或者从同一个Thread子类中new出来的多个实例。

至于使用notifyAll所产生的资源浪费问题,可以利用JDK1.5引入的Condition接口来解决,我们下一章将介绍这个方法。