线程通讯
线程通讯的目的是让线程之间可以相互发送信号.更多是能够让线程去等待其他线程的信号.如线程B等待线程A的信号用于指示数据已经准备就绪等待处理.
通过共享对象通讯
一个让线程通讯的简单方法是通过共享对象来设置信号量.线程A在同步代码块中设置布尔类型的成员变量hasDataToProcess为true.线程B同样通过同步代码块来读取成员变量hasDataToProcess.下面是一个简单的例子,让一个对象包含一个信号量,并提供相应的设置和检查方法.
public class MySignal {
private boolean hasDataToProcess = false;
public boolean isHasDataToProcess(){
return this.hasDataToProcess;
}
public void setHasDataToProcess(boolean hasDataToProcess){
this.hasDataToProcess = hasDataToProcess;
}
}
线程A和B需要有一个指向同一个对象的引用,才能让信号量正常工作.如果线程A和B引用的不是同一个对象,那么信号量将无法正常工作.待处理数据能够独立于信号量存储在共享缓冲区中.
繁忙的等待
线程B处理数据前需要等待数据进入就绪状态,即等待线程A将信号量hasDataToProcess置换为true.因此线程B需要在循环中等待信号量:
private MySignal sharedSignal= ...
...
while(!sharedSignal.hasDataToProcess()){
// 繁忙的等待
}
需要注意的是,当信号量不为true, 循环会一直进行.我们称这种情况为繁忙的等待.线程一直在循环等待.
wait() notify() 和 notifyAll()
如果平均等待时间相对较长,对于计算机cpu来说,繁忙等待并不是一个高效的方式.因此让线程在等待信号的过程中进入睡眠或闲置状态是一个不错的选择.
Java有一套内建的等待机制,用于让线程在等待信号时进入闲置状态.java.lang.Object声明了三个方法, wait() notify() 和 notifyAll()用于支持这套机制.
一个线程可以调用任一对象的wait()方法来进入闲置状态, 其他对象可以调用相同对象的notify()方法用于唤醒线程.无论调用对象的wait()还是notify()方法都先要取得该对象的对象锁.即需要在同步块中调用wait()和notify().以下是MySignal.class的修改版本,用于示例wait()和notify()的使用.
public class MyWaitNotify {
private class Monitor{
}
private final Monitor monitor = new Monitor();
public void doWait(){
synchronized (monitor){
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void doNotify(){
synchronized (monitor){
monitor.notify();
}
}
}
等待线程可以调用doWait(), 通知线程可以调用doNotify().当有一个线程调用notify(),其他调用同一对象wait()方法的线程将会被唤醒.调用一次notify()仅会唤醒一个线程.若有多个线程调用了同一对象的wait()方法, 可以通过调用notifyAll()来唤醒全部线程.
你能看到等待和通知线程在调用wait()和notify()时是在同步块中进行的, 而这是必须的.若在没有获得任意对象的对象锁前提下, 调用该对象wait() notify()和notifyAll()中的任一方法都会抛出IllegalMonitorStateException异常.
当等待线程在同步代码块中执行过程中, 会不会一直取得对象锁不放, 这样其他通知线程就不能进入同步代码块来调用notify()方法了.答案是否定的, 一旦等待线程调用wait()方法, 会连同对象锁一起释放, 对notify()的调用也是如此.这样就能让其他线程调用wait()和notify()方法了, 前提是他们都在同步代码块中调用.
当一个线程被唤醒时并不能马上离开wait()方法,还需要等待通知线程调用notify()完毕后离开同步代码块释放锁.换句话说,被唤醒的线程需要获得对象锁才能退出wait()方法,因为wait()方法是在同步代码块中调用的.如果多个线程通过notifyAll()方法被唤醒, 那么同一时刻只会有一个线程退出wait()方法,因为其他线程退出wait()方法必须获得对象锁.
信号丢失
若没有任何线程调用相同对象wait()方法前提下, 调用notify()和notifyAll(), 通知信号将不会存储. 此时信号将会丢失. 若在等待线程调用wait()方法之前就调用了notify()方法, 那么对于等待线程来说, 信号已经丢失了.当然这并不是什么大问题, 但有些时候, 这将会导致等待线程无限期的等待下去, 因为唤醒信号已经丢失了.
为了防止这种情况发生, 我们需要将信号量存储起来.我们可以在上文MyWaitNotify.class上进行改进,在该类中置放一个成员变量,用于存储信号量.
public class MyWaitNotify2 {
private class Monitor{
}
boolean wasSignalled = false;
private final Monitor monitor = new Monitor();
public void doWait(){
synchronized (monitor){
if(!wasSignalled){
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
wasSignalled = false;
}
}
public void doNotify(){
synchronized (monitor){
wasSignalled = true;
monitor.notify();
}
}
}
现在可以注意到doNotify()方法中在调用监控对象的notify()方法前, 先置换wasSignalled为true.通常在doWait()方法中, 在调用监控对象的wait()方法前先检查wasSignalled的值是否为false.若不是,则不调用wait()方法,置换wasSignalled为false.这样等待线程只有在上一次调用doWait()和这一次中间没有接收到任何信号量的前提下才会调用监控对象的wait()方法.
虚假唤醒
出于一些未知的原因,线程在没有调用notify()和notifyAll()的前提下也会被唤醒.出于未知原因的唤醒, 我们称之为虚假唤醒.
如果上文MyWaitNotify2.class示例中,等待线程在doWait()方法中发生虚假唤醒,线程将在没有接收到预期信号量的前提下执行, 这将会在你的应用中产生若干问题.
为了防止虚假唤醒的发生, 我们需要用while()循环检查信号量来代替if语句.这种情况我们称之为旋转锁.只要在旋转条件不成立时, 旋转锁才会释放.同样我们在上文MyWaitNotify2.class上进行改进:
public class MyWaitNotify3 {
private class Monitor{
}
boolean wasSignalled = false;
private final Monitor monitor = new Monitor();
public void doWait(){
synchronized (monitor){
while(!wasSignalled){
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
wasSignalled = false;
}
}
public void doNotify(){
synchronized (monitor){
wasSignalled = true;
monitor.notify();
}
}
}
我们能看到, 仅仅是将原先的if语句替换为while. 等待线程如果在没有接收到信号量的前提下唤醒, 将会进入旋转锁中, 重新调用wait()进入等待状态. 等待线程只有在接收到信号量后才会释放旋转锁.
多个线程等待同一信号量
当使用notifyAll()唤醒在等待同一个信号量的多个线程且仅允许它们中一个继续运行时, while循环是一个不错的解决方案.此时只有一个线程能够获得对象锁, 即只有一个线程能够退出wait()方法并清除wasSignalled标记.一旦该线程退出doWait()方法中的同步块,其他线程可以获得对象锁退出wait()方法, 然后进入while()检查wasSignalled成员变量.此时wasSignalled标记已经被上一个唤醒的线程清除, 当前线程只能在片刻唤醒后重新进入等待状态,直到下一个信号量到来.
不要在String常量和全局对象上调用wait()方法
将上文中的MyWaitNotify3.class示例稍作修改,使用一个空String作为监控对象.
done like this:
public class MyWaitNotify4 {
boolean wasSignalled = false;
private final String monitor = "";
public void doWait(){
synchronized (monitor){
while(!wasSignalled){
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
wasSignalled = false;
}
}
public void doNotify(){
synchronized (monitor){
wasSignalled = true;
monitor.notify();
}
}
}
在空String或其他String常量上调用wait()和notify(), JVM编译器内部会将String常量转换为同一个对象.这意味着, 即使你有两个完全不同的MyWaitNotify实例, 它们各自的monitor成员变量都会指向同一个空String对象.这也意味着调用第一个MyWaitNotify实例doWait()方法的线程可能会被调用第二个MyWaitNotify实例doNotify()方法的线程唤醒.
以上情况可以用下图概括:

记住,即使4个线程都是调用同一个共享String对象的wait()和notify()方法,调用doWait()和doNotify()所产生的信号量任然分别存储在不同的MyWaitNotify实例中.一个线程调用MyWaitNotify1的doNotify()方法可能会唤醒等待MyWaitNotify2的线程, 但产生的信号量任然会存储在MyWaitNotify1实例中.
这看起来好像没什么大问题,但如果第二个MyWaitNotify实例的doNotify()被调用, 意外唤醒了线程A和线程B, 此时线程A和B会在旋转锁中检查信号量标记后重新进入等待状态,因为第一个MyWaitNotify实例的doNotify()方法并没有被调用.信号量标记任然是false.这种情况类似于激活了虚假唤醒.线程A或B并没有获得信号量.但旋转锁可以处理这样的情况,因此线程A或B会重新进入等待状态.
问题在于doNotify()中调用的是notify(),四个等待同个空String对象锁的线程仅会有一个能被唤醒,可能被唤醒的线程并不是给定想要传递信号量的目标线程.如果A和B其中一个线程被唤醒,而信号量实际是给C或D的,此时A和B进入while()检查信号量标记, 发现为false,则重新回到等待状态.而此时C和D并没有被唤醒,而信号量却到达了,因此信号量此刻丢失了.这种情况类似于前文提到的信号丢失.C和D收到了信号量却没有正确响应它.
若把doNotify()中的notify()换成notifyAll()则所有的线程都会被唤醒并根据信号量作出响应.线程A和B仍然会进入等待状态,但C和D其中有一个能够正确响应信号量,因为它发现信号量被置换为true则退出wait()方法响应信号量并清除信号量标记.另一个则检查到清除后的信号量重新回到等待状态.
你或许会倾向于使用notifyAll()来代替notify(),但实际这会带来额外的性能损耗,并不是最佳选择.没有理由唤醒所有的线程来响应一个信号量.
所以千万不要调用全局对象或String常量的wait()和notify()方法.而是使用一个独一无二的实例对象.就像MyWaitNotify3.class示例中,使用Monitor实例来代替空String.
该系列博文为笔者复习基础所著译文或理解后的产物,复习原文来自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial