- 原文地址:
- 作者: Jakob Jenkov
线程通信就是为了让线程可以彼此发送一些信号。另外,还可以让线程等待其他线程的某个信号。 比如,线程B可以等待线程A数据已经准备好了的信号。
通过共享对象通信
其实,线程间通信的最简单方式就是使用一个共享对象的值来作为信号。 比如,线程A可以通过一个同步块设置一个boolean的成员变量
hasDataToProcess
为true。 然后,线程B也在同一个同步块中读取这个成员变量的值。 这样就可以让两个线程通过一个共享变量,完成某种信号的通知。
public class MySignal{ protected boolean hasDataToProcess = false; public synchronized boolean hasDataToProcess(){ return this.hasDataToProcess; } public synchronized void setHasDataToProcess(boolean hasData){ this.hasDataToProcess = hasData; }}
要注意,线程A和线程B必须使用同一个共享对象的实例,才能完成通信。 如果,线程A和B持有着两个不同的实例引用,那彼此就无法发现对方的信号了。
忙等待(Busy Wait)
线程B如果需要等待数据准备好了才能继续处理。 也就是说,线程B在等待线程A通过
hasDataToProcess()
返回true的信号。 线程B如果通过一个轮询来等待信号,比如这样:
protected MySignal sharedSignal = ......while(!sharedSignal.hasDataToProcess()){ //do nothing... busy waiting}
注意,循环会一直执行,直到
hasDataToProcess()
返回true。 这样就称之为忙等待(busy waiting)。 线程在等待期间,一直处于一个忙碌状态。
wait(), notify() 以及 notifyAll()
忙等待的线程在等待期间对于CPU来说是一种浪费,特别是等待时间较长的情况下。 而如果,线程能够在等待时能够进行休眠或者暂停,直到等待的信号发生,那就比较聪明了。
Java有个自带的等待机制可以让线程暂停直到等待的信号发生。 在
java.lang.Object
类上定义了三个方法来使用这个机制:wait()
,notify()
以及notifyAll()
方法。
当线程在任一对象上调用
wait()
方法,线程就会暂停,直到另一个线程在这个对象上调用notify()
方法。 为了可以调用wait()
和notify()
方法,线程需要先获得对象上的监控锁(monitor)。 也就是说wait()
和notify()
方法,需要在同步块(synchronized block)中调用。 例如:
public class MonitorObject{}public class MyWaitNotify{ MonitorObject myMonitorObject = new MonitorObject(); public void doWait(){ synchronized(myMonitorObject){ try{ myMonitorObject.wait(); } catch(InterruptedException e){...} } } public void doNotify(){ synchronized(myMonitorObject){ myMonitorObject.notify(); } }}
需要等待的线程可以调用
doWait()
方法,需要发送信号(通知)的线程可以调用doNotify()
方法。 当一个线程在某个对象上调用notify()
方法,那在这个对象上等待的某一个线程就会被唤醒,并允许其继续执行。 而notifyAll()
方法将会唤醒对象上所有等待的线程。
正如你所看到的,等待线程和通知线程对于
wait()
和notify()
都是在同步块中调用的。 这个是一个强制性的要求! 如果线程在没有获得对象的监控锁(monitor)的情况下,调用wait()
和notify()
方法,就会得到一个IllegalMonitorStateException
异常。
但是,这怎么可能呢?这样岂不是等待线程持有了对象的监控锁(monitor)然后一直等待? 而如果等待线程不释放监控锁(monitor),那岂不是通知线程就永远无法获得锁,也就永远无法发出通知了吗? 当然,不必担心这个问题,因为答案是,不会发生上述问题。 一旦线程调用了
wait()
方法,就会释放已经持有的监控锁(monitor)。 这就可以让其他线程去调用wait()
或notify()
方法了,当然还是需要再次获得对象的监控锁!
等待线程被唤醒后,还不能退出
wait()
方法,还需要等待通知线程(调用notify()
方法的线程)离开同步块,等待线程才能退出wait()
方法继续执行。 也就是说,被唤醒的线程,必须在退出wait()
方法之前,重新获取等待对象上的监控锁。(wait()
仍然在同步块中) 如果多个线程被notifyAll()
唤醒,那么只会有一个被唤醒的线程能够退出wait()
方法,因为,每个线程还需要重新获得等待对象上的监控锁(monitor)才能退出wait()
方法。
信号丢失(Missed Signals)
notify()
和notifyAll()
并不会在没有等待线程的情况下,保持方法调用的状态。 也即是,通知信号是一次性的,不会保存信号状态,而是直接丢弃。 因此,如果线程调用notify()
方法先于另一个线程调用wait()
方法,那信号对于等待线程就是丢失的。 这是不是一个问题,要看具体的情况。在某些情况下,这会导致等待线程永远等待下去,永远不会被唤醒。
如果需要避免信号丢失的情况,那就需要将信号存储类中。下面这个例子就展示了通过成员变量来存放信号:
public class MyWaitNotify2{ MonitorObject myMonitorObject = new MonitorObject(); boolean wasSignalled = false; public void doWait(){ synchronized(myMonitorObject){ if(!wasSignalled){ try{ myMonitorObject.wait(); } catch(InterruptedException e){...} } //clear signal and continue running. wasSignalled = false; } } public void doNotify(){ synchronized(myMonitorObject){ wasSignalled = true; myMonitorObject.notify(); } }}
doNotify()
方法在调用notify()
之前,会将wasSignalled
变量更新为true。 同样,doWait()
方法,会在wait()
之前,检查wasSignalled
。 这样,如果信号发生于wait()
之前,那doNotify()
就不会在错过信号了。
伪唤醒(Spurious Wakeups)
由于某些原因,会导致即使没有调用
notify()
或notifyAll()
方法,线程也可能被唤醒。 这就是传说中的伪唤醒(Spurious Wakeups),毫无理由的唤醒。
使用了多线程API(POSIX 线程/Windows API)中的条件变量而带来的并发症。JVM实现中使用了条件变量,所以,Java中也伴有伪唤醒的问题。
如果MyWaitNofify2类中的
doWait()
方法发生伪唤醒,就会导致线程在没有收到通知信号的情况下继续执行后续代码!这可能会给你的应用带来一些问题。
为了防止伪唤醒带来的问题,可以使用一个成员变量来记录状态,并使用循环进行检查(或者说,再次确认)。 这样的循环,也可以称之为:自旋锁。被唤醒的线程通过自旋进行等待,直到自旋锁条件达成(while的条件)。示例如下:
public class MyWaitNotify3{ MonitorObject myMonitorObject = new MonitorObject(); boolean wasSignalled = false; public void doWait(){ synchronized(myMonitorObject){ while(!wasSignalled){ try{ myMonitorObject.wait(); } catch(InterruptedException e){...} } //clear signal and continue running. wasSignalled = false; } } public void doNotify(){ synchronized(myMonitorObject){ wasSignalled = true; myMonitorObject.notify(); } }}
使用一个while循环来替代原有的if语句。如果等待线程不是因为收到通知而被唤醒的,那
wasSignalled
变量应该还是false,所以循环会再次进入等待。 这样就避免了伪唤醒带来的非预期中的唤醒。
在同一个信号上等待的多个线程
如果有多个线程在等待,当这些线程被
notifyAll()
方法唤醒时,但是只能有一个线程能够继续执行。这种情况下,while循环也是一个很好的解决方案。
同一个时间点,只能有一个线程能够获得对象的监控锁(monitor),这就意味着,只会有一个线程能够退出
wait()
方法并清理wasSignalled
标识。 而一旦这个线程退出doWait()
中的同步块,其他线程就可以退出wait()
,并且再次检查wasSignalled
变量。 但wasSignalled
已经被第一个唤醒的线程清理掉了,所以,这个线程会随着循环再次进入等待,等待下一个信号到达。
不要在常量字符串或者全局对象上等待
下面这个例子,使用一个常量字符串
""
(不是new出来的String,全局唯一)作为监控对象:
public class MyWaitNotify{ String myMonitorObject = ""; boolean wasSignalled = false; public void doWait(){ synchronized(myMonitorObject){ while(!wasSignalled){ try{ myMonitorObject.wait(); } catch(InterruptedException e){...} } //clear signal and continue running. wasSignalled = false; } } public void doNotify(){ synchronized(myMonitorObject){ wasSignalled = true; myMonitorObject.notify(); } }}
在空字符串(或者其他什么常量字符串都一样)上调用
wait()
,notify()
方法,JVM/编译器就会转换成全局的一个对象(字符串,常量池)。 这就意味着,虽然有两个MyWaitNotify实例对象,但是它们都引用着同一个字符串实例。 这也就是说,线程在其中一个MyWaitNotify上调用doNotify()
,就会导致另一个MyWaitNotify上等待的线程被唤醒。
情况如下图所示:
这个例子中,有4个线程在同一个共享字符串实例上调用
wait()
和notify()
方法,doWait()
和doNotify()
方法会把信号分别存放在两个MyWaitNotify实例中。 这样的情况,会导致MyWaitNotify 1上的doNotify()
方法会唤醒MyWaitNotify 2上等待的线程,而且,信号只会存放于MyWaitNotify 1中。
看上去貌似不是个大问题,毕竟,如果第二个MyWaitNotify 2实例上调用
doNotify()
会导致线程A和B被错误的唤醒。而A,B还会再次检查信号状态(while循环),由于不是MyWaitNotify 1的doNotify()
发出的信号,所以A,B还会再次进入等待。 这就有点类型伪唤醒。 线程A/B不是被正确信号唤醒的。 但是代码可以处理这个情况,让线程A/B再次进入等待。
**(但是不得不否认,带来了额外的处理,以及不必要的上下文切换)**
而真正的问题是,由于
doNotify()
只是调用了notify()
而不是notifyAll()
,这样,就只会有一个线程被唤醒,而不是在同一个对象上等待的4个线程。 如果,被唤醒的是A或B,而型号却是来自C或D。这种情况下,A/B会再次检查状态,重新进入等待。但关键是,C/D就会错过这个信号。 这就类似上述的**信号丢失(Missed Signals)**问题了。C/D发送了信号,但是自己却不能接收到。
如果把
doNotify()
方法,改用notifyAll()
来发送信号,那所有的等待线程就会被唤醒,并检查信号状态。 线程A/B还是再次进入等待,而C或者D就会响应这个信号,他俩中的一个(抢到monitor的)就可以离开doWait()
方法,另一个也会再次进入等待。
这样,你就可能觉得总是使用
notifyAll()
就会比notify()
更好。但其实不是,notifyAll()
同样会引发更多不必要的唤醒,更多的上下文切换。
所以记住,不要使用全局对象(如:常量字符串)来实现wait()/notify()机制。 为每一组需要此机制的任务,分别创建一个对象用于信号会更好!