Java并发编程(Java Concurrency)(15)- 线程信号(Thread Signaling)

时间:2021-06-17 07:00:14

原文链接:http://tutorials.jenkov.com/java-concurrency/thread-signaling.html

摘要:这是翻译自一个大概30个小节的关于Java并发编程的入门级教程,原作者Jakob Jenkov,译者Zhenning Lang,转载请注明出处,thanks and have a good time here~~~(希望自己不要留坑)

线程信号的目的是使不同的线程可以互发信号。此外,线程信号可以让一个线程等待来自其他线程的信号。例如,线程 B 可能会等待来自线程 A 的信号,这个信号表明数据已经准备好被处理。.
(译者理解:Thread Signaling 的概念类似于 Thread Communication,但 Signaling 显然不包含复杂的或大量的数据,更类似于触发的概念,而 Communication 可能会包含更加复杂的数据类型)

1 通过共享对象完成线程信号

线程间发送信号的简单实现方式是将某个共享对象的成员变量设为信号量。例如,线程 A 可能在内部的 synchronized 代码段内将 boolean 型变量 hasDataToProcess 设置为 true,随后线程 B 从其内部的 synchronized 代码段内读取 hasDataToProcess 的数值。如下例所示:

public class MySignal{

protected boolean hasDataToProcess = false;

public synchronized boolean getHasDataToProcess(){
return this.hasDataToProcess;
}

public synchronized void setHasDataToProcess(boolean hasData){
this.hasDataToProcess = hasData;
}

}

线程 A 和 B 为了使得这个信号机制可以正常运作,必须都拥有 MySignal 类的共享实例的引用。如果线程 A 和 B 所拥有的引用指向了不同的 MySignal 实例,那么这两个线程就无法相互通信。这两个线程想要处理的数据可以存放在一个不同于 MySignal 实例的共享的缓冲区内。

2 忙碌等待(Busy Wait)

为了处理某些数据,线程 B 会一直等待这个数据变得可以被处理(例如:可能存在着一些预处理的工作需要其他的线程完成)。换言之,线程 B 在等待来自于线程 A 的信号,该信号可以通过 hasDataToProcess() 方法返回 true 来知道。下面的代码是线程 B 中等待信号的循环语句的代码:

protected MySignal sharedSignal = ...
...
while(!sharedSignal.hasDataToProcess()){
//do nothing... busy waiting
}

请看代码中的循环语句是如何一直运行到 hasDataToProcess() 方法返回 true。这个操作就叫做忙碌等待(Busy Wait),即当这线程等待时它在忙碌于查询工作。

3 wait(), notify() and notifyAll()

对于等待中的线程来说,忙碌等待这种“线程信号”解决方案并没有充分利用 CPU 的资源,除非平均等待时间非常短暂。如果情况并非如此(即线程的等待时间较长),一种更好的解决方案是让等待的线程在收到其他线程的信号之前睡眠或者闲置。

Java 语言内建了使得线程在等待的过程中保持闲置的机制,可以用在 java.lang.Object 类中定义的 wait(),notify() 和 notifyAll() 这三个方法来使用这一机制的。

一个线程调用无论任何对象的 wait() 方法,都会进入闲置(睡眠)状态,直到另一个线程调用之前那个对象的 notify() 方法。此外,一个线程为了调用某个对象的 wait() 或者 notify() 方法,必须首先获得这个对象的锁。换一种说法就是调用 wait() 或者 notify() 方法的线程必须从一个 synchronized 代码块中来调用。下面的例子是修改后的 MySignal 方法,叫做 MyWaitNotify,用于说明如何使用 wait() 和 notify():

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() 的通知线程,都必须从 synchronized 代码块内调用 wait() 或 notify() 方法,并且这是强制的!如果一段代码没持有一个对象的锁,就无法调用这个对象的 wait(),notif() 或 notifyAll() 方法。如果这个线程这样做了,就会抛出 IllegalMonitorStateException 异常。(译者注:注意这里使用的 synchronized 的输入参数必须是想调用 wait(),notif() 或者 notifyAll() 方法的那个对象!!!)

但,这听起来难道不像一个悖论么?那个等待的线程既然是在 synchronized 代码段内睡眠的,那岂不是会一直持有锁?那个调用 notify() 代码的线程不是永远都无法进入 synchronized 代码段内么?答案是否定的。一旦一个线程调用了 wait() 方法,锁就会被释放。这使得其他线程也可以从 synchronized 代码段内调用 wait(),notif() 或者 notifyAll() 方法。

如果调用 notify() 的线程还没有离开 synchronized 代码块,那么睡眠中的线程将一直等到其离开 synchronized 代码块才会被最终唤醒。换言之,被唤醒的线程必须先重新获得监视对象的锁才能终止 wait() 的作用,这是因为 wait() 代码是处在 synchronized 代码块内部的(译者理解:如果一执行完 notify() 方法 wait() 方法的作用就失效的话,就会出现两个线程都进入同一个锁监视的 synchronized 代码块内的情况)。如果多个线程被 notifyAll() 方法所唤醒,那么这些线程实际是按顺序被逐个唤醒的,这是因为每个线程必须轮流获得监视对象的锁。

4 信号丢失(Missed Signals)

由于 notify() 和 notifyAll() 方法没有保持能力,所以如果他们被调用时没有正在睡眠的线程,那么他们的唤醒信号就会发生丢失。因此,如果一个线程调用 notify() 方法发生在被唤醒的线程调用 wait() 方法之前,信号就会被后者丢失。这种情况有时并不是问题,但有时会引发睡眠线程的永久等待,因为触发信号丢失了。

为了防止“信号丢失”发生,信号应该被储存在发送信号的类中。例如在 MyWaitNotify 例子中触发信号应该存储在 MyWaitNotify 实例的一个成员变量中,下面是修改过的 MyWaitNotify 代码:

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 变量的值。事实上,只有当前一个 doWait() 方法和现在的 doWait() 方法的调用之间不存在触发信号,doWait() 方法才会调用 wait() 函数。

5 虚假的醒来(Spurious Wakeups)

可能会因为一些莫名其妙的原因,在还没有调用 notify() 和 notifyAll() 方法时线程可能就从等待中醒了过来。这被成为虚假的醒来 —— 即无原因的醒来。

如果上面例子中的 MyWaitNofity2 类的 doWait() 方法发生了虚假的醒来,那么等待线程可能在没有接到合适的触发信号时继续运行后面的代码!这可能使你的程序发生一系列问题。

为了防止虚假醒来所带来的不利影响,触发信号变量(即例子中的 wasSignalled )需要在一个循环中被重复检查(而不只是用 if 语句),这样的循环也被称为自旋锁(spin lock)。被唤醒的线程围绕着一个条件旋转(spin),直到自旋锁内的条件变为 false。下面的例子修改了 MyWaitNotify2,并且展示了自旋锁的应用:

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

请注意 wait() 方法的位置 —— 被嵌入到了一个 while 循环中。如果睡眠线程没收到信号就自己醒了过来,由于 wasSignalled 变量还是 flase,所以 while 循环会重新运行,使得该线程再次进入睡眠状态。

6 多个线程等待相同的信号

对于多个睡眠线程等待 notifyAll 的触发信号,上面提到的使用 while 循环的方法会使得其中只有一个线程被唤醒并继续运行。这是因为只有一个线程可以获得监视对象的锁,也就会有一个线程率先被唤醒然后将 wasSignalled 置为 false。这个线程随后退出 synchronized 代码块,此时其他的线程就可以进入 doWait() 函数。然而此时,wasSignalled 已经被置为 false,所以其他的线程就会一直等待下去。

7 不要调用常量字符串或全局对象的 wait() 方法

本文的早先的一个版本使用常量字符串”“作为监视对象,来调用 wait() 方法,如下例所示:

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

通过常量字符串 String 来调用 wait() 或 notify() 方法所导致的问题是,JVM/编译器会在内部自动将内容相同的 String 转变为相同的对象。这意味着,即便你创建了两个不同的 MyWaitNotify 实例,他们内部的 myMonitorObject 变量也会指向相同的 String 对象。这将导致一个非预期的线程中的 notify() 方法会唤醒另睡眠中的线程。如下图所示:

Java并发编程(Java Concurrency)(15)- 线程信号(Thread Signaling)

请记住,即便 4 个线程调用 wait() 和 notify() 方法,但是信号本身只会存储在隔离的类中(即成员变量 wasSignalled )。所以,A 的 doNotify 方法虽然可能会唤醒 C 和 D 由 wait() 导致的睡眠,但不会改变 C 和 D 中的 wasSignalled 的值。

乍一看这可能并不是什么大问题。毕竟,假如第二个 MyWaitNotify 实例的 doNotify() 方法被调用,所发生的无外乎是线程 A 和 B 被错误的唤醒。线程 A 和 B 随后检测 while 循环中的信号量,发现信号量还是 false,所以就再次进入等待状态。这等价于一次虚假的唤醒,即线程 A 和 B 没有被(意图中的)触发而自动醒来。但代码处理了这一问题,所以线程再次陷入睡眠。

问题在于,由于 doNotify() 方法调用的是 notify() 而不是 notifyAll(),只有一个线程会收到这个信号。由于 4 个线程都在等待一个相同 String 实例的激活,如果 A 或 B 收到了信号并被唤醒,实际我们真正想被唤醒的线程,例如 C 或 D,就会丢失了这个信号。所以虽然从线程 A 和 B 的角度来看一切正常,但实际上 C 或 D 丢失了该有的触发信号。这等价于第 4 节所介绍的信号丢失问题。

但是如果 doNotify() 方法调用的是 notifyAll() 而不是 notify(),那么所有睡眠的线程都将被唤醒,并且检查属于自己的本地信号量。线程 A 和 B 就继续进入睡眠,线程 C 和 D 其中之一离开 doWait(),另一个继续等待(因为信号量被再次清空了)

这会使你觉得莫不如每次都使用 notifyAll(),但从性能角度这实际是一个坏点子。因为没有理由在只需要一个线程被唤醒的情况下每次都唤醒全部线程。

所以结论是:别用全局对象或字符串常量等对象的 wait() 或 notify() 机制。