线程间协作
多线程的协作
线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码一步一步地执行,直到终止。但是,每个运行中的线程,如果仅仅是孤立地运行,那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作,包括数据之间的共享,协同处理事情。这将会带来巨大的价值。
线程之间相互配合,完成某项工作,比如:一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,而最终执行又是另一个线程。前者是生产者,后者就是消费者,这种模式隔离了“做什么”(what)和“怎么做”(How),简单的办法是让消费者线程不断地循环检查变量是否符合预期在while循环中设置不满足的条件,如果条件满足则退出while循环,从而完成消费者的工作
多线程的协作就像人之间的协作一样,配合完成某样事情,但是线程间协作一下几个难点
- 难以确保及时性
- 难以降低开销
如果降低睡眠的时间,比如休眠1毫秒,这样消费者能更加迅速地发现条件变化,但是却可能消耗更多的处理器资源,造成了无端的浪费。
等待/通知机制
是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify/notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
假如有两个线程 一个是为生产者,一个是消费者,生产者生产电池一次一个,消费者使用电池,一次5个,那么这就是两个线程的协作,如果生产多了浪费,生产少了就供应不了消费者了,这个时候可以使用生产者消费者模式,使用一个队列存储生产的电池,生产够了就通知消费者消费,生产者暂停,等消费完了,就通知生产者生产,消费者暂停。
生产者
就是生产电池的线程
消费者
就是使用电池的线程
缓冲区
就是暂时存放电池的地方
等待方法
等待线程忙等待运行时不能有效地利用计算机的CPU,除非平均等待时间非常短。否则,如果等待的线程能以某种方式睡眠或变为非活动状态,直到它收到它正在等待的信号,那将更加智能。
Java有一个内置的等待机制,可以让线程在等待信号时变为非活动状态。java.lang.Object类定义了三个方法,wait()
,notify()
和notifyAll()
,以方便这一点。
wait方法
wait()方法是使当前执行代码的线程进行等待,wait()方法是Object类的方法,该方法用来将当前线程置入“欲执行队列”中,并且在wait()所在的代码处停止执行,直到接到通知或被中断为止。在调用wait()方法之前,线程必须获得该对象的对象级别锁,即只能在同步方法或者同步块中调用wait()方法。
在执行wait()方法后,当前线程释放锁。
notify 方法
方法notify()也要在同步方法或同步块中调用,即在调用前,线程也必须获得该对象的对象级别锁。
notifyAll方法
notify 方法和notifyAll方法类似,notify 方法只是唤醒等待的一个线程notifyAll是唤醒等待的所有线程
notify和notifyAll应该用谁
尽可能用notifyall(),谨慎使用notify(),因为notify()只会唤醒一个线程,我们无法确保被唤醒的这个线程一定就是我们需要唤醒的线程
等待和通知的标准范式
等待方遵循如下原则
1)获取对象的锁。
2)如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
3)条件满足则执行对应的逻辑
synchronized (对象){
while(条件不满足){
对象.wait();
}
对应的处理逻辑
}
通知方遵循如下原则
1)获得对象的锁。
2)改变条件。
3)通知所有等待在对象上的线程
synchronized (对象){
改变条件
对象.notifyAll();
}
package chapter01.demo;
import util.ThreadUtils;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class Battery {
//电池数量
private static int BATTERY_NUM = 0;
//锁对象
private Object less = new Object();
private Object full = new Object();
public static void main(String[] args) {
Battery battery = new Battery();
ExecutorService executorService = Executors.newFixedThreadPool(3);
executorService.execute(() -> {
while (true) {
battery.producer();
}
});
executorService.execute(() -> {
while (true) {
battery.consumer();
}
});
}
/**
* 生产者
*/
public void producer() {
if (BATTERY_NUM >= 5) {
synchronized (full) {
full.notifyAll();
}
}
//持有锁
synchronized (less) {
//数量大于5 需要进行wait等待
while (BATTERY_NUM >= 5) {
try {
System.out.println("生产者阻塞");
less.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//小于5 需要对
ThreadUtils.sleep(1, TimeUnit.SECONDS);
System.out.println("生产一个电池");
BATTERY_NUM++;
}
}
/**
* 消费者
*/
public void consumer() {
if (BATTERY_NUM < 5) {
synchronized (less) {
less.notifyAll();
}
}
//加锁
synchronized (full) {
//数量小于5 需要进行wait等待
while (BATTERY_NUM < 5) {
try {
System.out.println("消费者阻塞");
full.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//条件满足需要对加锁的方法唤醒
ThreadUtils.sleep(1, TimeUnit.SECONDS);
System.out.println("消费一个电池");
BATTERY_NUM = BATTERY_NUM - 5;
}
}
}
在调用wait()、notify()系列方法之前,线程必须要获得该对象的对象级别锁,即只能在同步方法或同步块中调用wait()方法、notify()系列方法,进入wait()方法后,当前线程释放锁,在从wait()返回前,线程与其他线程竞争重新获得锁, 执行notify()系列方法的线程退出调用了notifyAll的synchronized代码块的时候后,他们就会去竞争。如果其中一个线程获得了该对象锁,它就会继续往下执行,在它退出synchronized代码块,释放锁后,其他的已经被唤醒的线程将会继续竞争获取该锁,一直进行下去,直到所有被唤醒的线程都执行完毕。
竞争条件和临界区
一个竞争条件 是一个可能在临界区内出现的特殊情况。临界区 是被多个线程执行的代码区域,并且由于临界区的同步执行会导致线程执行顺序会出现差别。
当多个线程执行临界区产生的结果因为线程执行的顺序而最终不同时,临界区被称为包含竞争条件。竞争条件一词源于线程正在竞争通过临界区的比喻,并且该竞争的结果影响执行临界区的结果。
临界区
在同一个应用程序中运行多个线程不会导致问题,但是当多个线程访问相同的资源时会出现问题。例如,相同的内存(变量,数组或对象),系统(数据库,Web服务等)或文件。
实际上,只有一个或多个线程写入这些资源时才会出现问题。只要资源不改变,让多个线程读取相同的资源是安全的。
这是一个临界区Java代码示例,如果同时由多个线程执行,则可能会失败
public class Counter {
protected long count = 0;
public void add(long value){
this.count = this.count + value;
}
}
想象一下,如果两个线程A和B正在同一个 Counter
类实例上执行add方法,我们无法知道操作系统何时在两个线程之间切换。 add()
方法中的代码不是被Java虚拟机作为单个原子指令执行的,而是将其作为一组较小的指令执行,类似于:
- 从内存中读取this.count到寄存器中。
- 添加值到寄存器。
- 将寄存器写入存储器。
观察以下线程A和B的混合执行会发生什么:
this.count = 0;
A: Reads this.count into a register (0)
B: Reads this.count into a register (0)
B: Adds value 2 to register
B: Writes register value (2) back to memory. this.count now equals 2
A: Adds value 3 to register
A: Writes register value (3) back to memory. this.count now equals 3
两个线程想要将值2和3添加到counter。因此,在两个线程完成执行后,该值应该为5。但是,由于两个线程的执行是交错的,因此结果会不同。
在上面列出的执行序列示例中,两个线程都从内存中读取值0。然后,他们分别将他们自己的值2和3添加到其中,然后将结果写回内存。 this.count
中存储的值将是最后一个线程写入其中的值而不是5。在上面的例子中它是线程A,但如果执行顺序改变它也可以是线程B.
临界区中的竞争条件
前面示例中 add()
方法中的代码包含一个临界区。当多个线程执行此临界区时,会出现竞争条件。
更正式地说,遇到两个线程竞争相同资源的情况,其中访问资源的顺序是重要的,称为竞争条件。导致竞争条件的代码部分称为临界区。
防止竞争条件
为了防止竞争条件发生,您必须确保临界区作为原子指令执行。这意味着一旦一个线程执行它,在第一个线程离开临界区之前,没有其他线程可以执行它。
通过在临界区使用适当的线程同步机制可以避免竞争条件,可以使用同步的Java代码块来实现线程同步。线程间的同步也可以使用其他同步结构来实现,例如锁或原子变量,如java.util.concurrent.atomic.AtomicInteger。
临界区吞吐量
对于较小的临界区,将整个临界区包含在同步块可以起作用。但是,对于较大的临界区,将它分解为几个较小的临界区可能是有益的,这将允许多个线程同时执行多个较小的临界区,可以减少对共享资源的竞争,从而增加总临界区的吞吐量。
这是一个非常简单的Java代码示例,用于表达我的意思:
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
public void add(int val1, int val2){
synchronized(this){
this.sum1 += val1;
this.sum2 += val2;
}
}
}
注意该add()
方法将值添加到两个不同的sum成员变量。为了防止竞争条件,求和在Java同步块内执行。使用此实现,只有一个线程可以执行求和。
但是,由于两个sum变量彼此独立,因此可以将它们的求和分成两个独立的同步块,如下所示:
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
private Integer sum1Lock = new Integer(1);
private Integer sum2Lock = new Integer(2);
public void add(int val1, int val2){
synchronized(this.sum1Lock){
this.sum1 += val1;
}
synchronized(this.sum2Lock){
this.sum2 += val2;
}
}
}
现在两个线程可以同时执行该add()
方法,第一个同步块内有一个线程,第二个同步块内有另一个线程。两个同步块在不同对象上同步,因此两个不同的线程可以独立的分别执行这两个块。这样线程执行该add()
方法就可以彼此等待更少的时间。
当然,这个例子非常简单。在现实生活中的共享资源中,临界区的分解可能要复杂得多,并且需要对执行顺序可能性进行更多分析。
线程安全和共享资源
可以被多个线程同时安全调用的代码称为线程安全。如果一段代码是线程安全的,那么它不包含竞争条件,仅当多个线程更新共享资源时才会出现竞争条件。因此,了解Java线程在执行时需要共享的资源非常重要。
局部变量
局部变量存储在每个线程自己的堆栈中,这意味着线程之间永远不会共享局部变量,这也意味着所有原始类型局部变量(primitive variable,例如int,long等)都是线程安全的。以下是线程安全局部原始类型变量的示例:
public void someMethod(){
long threadSafeInt = 0;
threadSafeInt++;
}
局部对象引用
局部对象引用和原始类型变量有点不同,引用自己本身不共享。但是,引用的对象不存储在每个线程的本地堆栈中,所有对象都存储在共享堆中。
如果一个方法创建的对象永远不会离开创建它的方法,那么它是线程安全的。事实上,您也可以将其传递给其他方法和对象,只要这些方法或对象都不会使此对象能够被其他线程使用。
以下是线程安全局部对象的示例:
public void someMethod(){
LocalObject localObject = new LocalObject();
localObject.callMethod();
method2(localObject);
}
public void method2(LocalObject localObject){
localObject.setValue("value");
}
此示例中LocalObject
的实例不从方法返回,也不会传递给可从someMethod()
方法外部访问的任何其他对象。执行someMethod()
方法的每个线程将创建自己的LocalObject
实例并将其分配给localObject
引用。因此,这里LocalObject
的使用是线程安全的。
实际上,整个方法someMethod()
都是线程安全的。即使LocalObject
实例作为参数传递给同一个类或其他类中的其他方法,它的使用也是线程安全的。
当然,唯一的例外是,如果一个方法使用LocalObject
作为调用参数,并且以允许其他线程访问的方式存储这个LocalObject
实例。
下面这个示例展示了上面描述的例外情况:
import org.junit.Test;
import static java.lang.Thread.sleep;
public class LocalObjectTest {
private LocalObject sharedLocalObject;
private int num;
@Test
public void test() throws Exception {
method1();
for(int i = 0; i < 4; ++i) {
new Thread(() -> sharedLocalObject.setText("" + num++)).start();
}
sleep(500);
System.out.println(sharedLocalObject.text);
}
/**
* 创建一个局部对象引用,并引用一个实例
*/
private void method1() {
LocalObject localObject = new LocalObject();
method2(localObject);
}
/**
* 使一个局部对象逃逸,以此被其他线程访问
* @param object LocalObject
*/
private void method2(LocalObject object) {
sharedLocalObject = object;
}
private static class LocalObject {
private String text;
void setText(String text) {
this.text = text;
}
}
}
sharedLocalObject
即是可从someMethod
方法外部访问的对象,此对象可以被其他线程访问(因为此对象是类的成员变量,而线程和嵌套子类一样,可以在run()中访问此对象)。这个示例解释了上面这句比较抽象的话:
此示例中
LocalObject
的实例不从方法返回,也不会传递给可从someMethod()
方法外部访问的任何其他对象。
对象成员变量
对象成员变量(字段)与对象一起存储在堆上。因此,如果两个线程在同一对象实例上调用方法,并且此方法更新成员变量,则该方法不是线程安全的。以下是非线程安全方法的示例:
public class NotThreadSafe{
StringBuilder builder = new StringBuilder();
public add(String text){
this.builder.append(text);
}
}
如果两个线程在同一个NotThreadSafe实例上同时调用add()
方法,则会导致竞争条件。例如:
NotThreadSafe sharedInstance = new NotThreadSafe();
new Thread(new MyRunnable(sharedInstance)).start();
new Thread(new MyRunnable(sharedInstance)).start();
public class MyRunnable implements Runnable{
NotThreadSafe instance = null;
public MyRunnable(NotThreadSafe instance){
this.instance = instance;
}
public void run(){
this.instance.add("some text");
}
}
注意两个MyRunnable
实例共享同一个NotThreadSafe
实例。因此,当他们在NotThreadSafe
实例上调用add()
方法时,会导致竞争条件。
但是,如果两个线程在不同的实例上同时调用add()
方法 ,那么它不会导致竞争条件。以下是之前的示例,但略有修改:
new Thread(new MyRunnable(new NotThreadSafe())).start();
new Thread(new MyRunnable(new NotThreadSafe())).start();
现在两个线程各有自己的NotThreadSafe
实例,因此它们对add方法的调用不会彼此干扰,代码不再具有竞争条件。因此,即使对象不是线程安全的,它仍然可以以不会导致竞争条件的方式使用。
线程控制逃逸规则
在尝试确定您的代码对某个资源的访问是否是线程安全时,您可以使用线程控制逃逸规则:
If a resource is created, used and disposed within
the control of the same thread,
and never escapes the control of this thread,
the use of that resource is thread safe.
如果资源的创建,使用和回收在同一个线程控制下进行,
并且永远不会从这个线程的控制下逃逸,那么这个资源的使用是线程安全的
资源可以是任何共享资源,如对象,数组,文件,数据库连接,套接字等。在Java中,您并不需要显式地回收对象,因此“回收”意味着丢失对象的引用(引用另一个对象)或将引用置为null。
即使对象的使用是线程安全的,但是如果该对象指向共享资源(如文件或数据库),则整个应用程序可能不是线程安全的。例如,如果线程1和线程2各自创建自己的数据库连接,连接1和连接2,则每个连接本身的使用是线程安全的。但是连接指向的数据库的使用可能不是线程安全的。例如,如果两个线程都执行如下代码:
check if record X exists
if not, insert record X
检查记录X是否存在
如果没有,插入记录X.
如果两个线程同时执行此操作,并且它们正在检查的记录X恰好是相同的记录,则存在两个线程最终都插入它的风险。这是一个示例:
Thread 1 checks if record X exists. Result = no
Thread 2 checks if record X exists. Result = no
Thread 1 inserts record X
Thread 2 inserts record X
线程1检查记录X是否存在。结果为否
线程2检查记录X是否存在。结果为否
线程1插入记录X.
线程2插入记录X.
对于在文件或其他共享资源上进行操作的线程也可能发生这种情况。因此,区分由线程控制的对象是资源还是仅仅引用这个资源(如数据库连接所做的)是很重要的。
线程安全和不变性
仅当多个线程正在访问同一资源,并且一个或多个线程写入资源时,才会出现竞争条件。如果多个线程读取相同的资源, 竞争条件不会发生。
我们可以确保线程之间共享的对象永远不会被任何线程更新,方法是使共享对象不可变,从而保证线程安全。这是一个例子:
public class ImmutableValue{
private int value = 0;
public ImmutableValue(int value){
this.value = value;
}
public int getValue(){
return this.value;
}
}
注意ImmutableValue
实例的值是在构造函数中传递的,另请注意没有setter方法。一旦ImmutableValue
实例被创建,你将不能改变它的值,它是不可变的。但是,您可以使用getValue()
方法读取它。
如果需要对ImmutableValue
实例执行操作,可以通过返回带有该操作产生的值的新实例来执行此操作。以下是添加操作的示例:
public class ImmutableValue{
private int value = 0;
public ImmutableValue(int value){
this.value = value;
}
public int getValue(){
return this.value;
}
public ImmutableValue add(int valueToAdd){
return new ImmutableValue(this.value + valueToAdd);
}
}
注意add()
方法返回了一个带有add操作结果的新ImmutableValue
实例,而不是将值添加到自身。
引用不是线程安全的
要记住,即使对象是不可变的并且因此线程安全,该对象的引用也可能不是线程安全的。看看这个例子:
public class Calculator{
private ImmutableValue currentValue = null;
public ImmutableValue getValue(){
return currentValue;
}
public void setValue(ImmutableValue newValue){
this.currentValue = newValue;
}
public void add(int newValue){
this.currentValue = this.currentValue.add(newValue);
}
}
Calculator
类持有一个对ImmutableValue
实例的引用。注意可以通过setValue()
和add()
方法更改该引用。因此,即使Calculator
类在内部使用不可变对象,它本身也不是不可变的,因此不是线程安全的,这和前面的累加器实现一样,add()和setValue()方法都不是原子操作,而是几个操作组成。换句话说:ImmutableValue
是线程安全的,但使用它不是。在尝试通过不变性实现线程安全时,请记住这一点。
为了使Calculator
类线程安全,你可以使用synchronized关键词声明getValue()
, setValue()
和add()
方法 ,那将可以做到。
线程通信
在前面已经对 wait() , notify() 和 notifyAll() 进行了讲解,并得出了等待/通知机制的基本范式,接下来就对如何得到此范式做一个分析。
线程信令的目的是使线程能够相互发送信号。另外,线程信令使线程能够等待来自其他线程的信号。例如,线程B可能等待来自线程A的信号,指示数据已准备好被处理
通过共享对象发送信号
线程相互发送信号的一种简单方法是在某个共享对象变量中设置信号值。线程A可以从同步块内部将布尔成员变量hasDataToProcess
设置为true,然后线程B可以读取同步块内的hasDataToProcess
成员变量。下面是一个可以保存这种信号的对象的简单示例,并提供了设置和检查它的方法:
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必须都要有对同一个MySignal
实例的引用才能使信号工作。如果线程A和B具有对不同MySignal
实例的引用,则它们将不会检测彼此的信号。要处理的数据可以位于与MySignal
实例分开的共享缓冲区中。
忙等待
处理数据的线程B正在等待可用于处理的数据。换句话说,它正在等待来自线程A的信号,线程A能够让hasDataToProcess()
方法返回true。这是线程B在等待此信号时运行的循环:
protected MySignal sharedSignal = ...
...
while(!sharedSignal.hasDataToProcess()){
//do nothing... busy waiting
}
注意while循环如何一直执行,直到hasDataToProcess()
返回true,这称为忙等待,等待时线程正忙。
线程等待
等待线程忙等待运行时不能有效地利用计算机的CPU,除非平均等待时间非常短。否则,如果等待的线程能以某种方式睡眠或变为非活动状态,直到它收到它正在等待的信号,那将更加智能。
Java有一个内置的等待机制,可以让线程在等待信号时变为非活动状态。java.lang.Object类定义了三个方法,wait()
,notify()
和notifyAll()
,以方便这一点。
在任何对象上调用wait()
的线程将变为非活动状态,直到另一个线程在该对象上调用notify()
。为了调用wait()
或通知调用线程必须首先获取该对象的锁。换句话说,调用线程必须从同步块内部调用wait()
或notify()
。这是一个名为MyWaitNotify的MySignal的修改版本,它使用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() 。这是强制性的!线程在没有持有调用该方法的对象的锁时,不能调用 wait()\, *notify() 或 notifyAll()* 。如果调用的话,则抛出IllegalMonitorStateException
。
但是,这怎么做到呢?只要在同步块内执行,等待线程不会一直持有监视器对象(myMonitorObject)的锁吗?等待线程是否会阻止通知线程进入 doNotify() 中的同步块?答案是不。一旦线程调用 wait() ,它就会释放它在监视器对象上持有的锁。这允许其他线程也调用 wait() 或 notify() ,因为必须从synchronized块内调用这些方法。
一旦线程被唤醒,它不能立刻退出 wait() 调用,直到调用 notify() 的线程离开其synchronized块。换句话说:被唤醒的线程必须重新获取监视器对象上的锁才能退出 wait() 调用,因为等待调用嵌套在同步块中。如果使用 notifyAll() 唤醒多个线程,则一次只有一个被唤醒的线程可以退出 wait() 方法,因为每个线程必须在退出 wait() 之前依次获取监视器对象上的锁。
信号丢失
当在调用 notify() 和 notifyAll() 方法时如果没有线程在等待,notify() 和 notifyAll() 方法不会保持对等待线程的方法调用。然后,通知信号就丢失了。因此,如果线程在被通知的线程调用 wait() 之前调用 notify() ,则等待线程将丢失该信号。这可能是也可能不是问题,但在某些情况下,这可能导致等待线程永远等待,永不醒来,因为错过了唤醒信号。
为避免丢失信号,应将它们存储在信号类中。在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() 调用之前和期间没有收到信号,它只调用 wait() 。
意外唤醒
由于莫名其妙的原因,即使没有调用 notify() 和 notifyAll() ,也可以唤醒线程,这被称为虚假唤醒,线程没有任何理由的醒来。
如果在MyWaitNofity2
类的 doWait() 方法中发生虚假唤醒,则等待线程在没有收到正确的信号时也可以继续处理,这可能会导致应用程序出现严重问题。
为防止虚假唤醒,在while循环内而不是if语句内部检查信号成员变量。这样的while循环也称为自旋锁。被唤醒的线程自旋,直到自旋锁(while循环)中的条件变为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循环而不是if语句中。如果等待的线程在没有收到信号的情况下唤醒,则wasSignalled
值仍将为false,并且while循环将再次执行,导致唤醒的线程返回等待。
多个线程等待同一信号
如果你有多个线程在等待,那么while循环也是一个不错的解决方案,它们是被用notifyAll()
唤醒的,但是只允许其中一个线程继续执行。一次只有一个线程能够获取监视器对象的锁,这意味着只有一个线程可以退出 wait() 调用并清除wasSignalled
标志。一旦该线程退出 doWait() 方法中的synchronized块,其他线程也可以获取锁然后退出 wait() 调用并检查while循环内的wasSignalled
成员变量。但是,这个标志在第一个线程唤醒时被清除,因此其余的唤醒线程返回等待,直到下一个信号到达。
不要在常量String或全局对象上调用wait()
本文的早期版本有一个
MyWaitNotify
示例类,它使用常量字符串(””)作为监视对象。以下是该示例的样子:
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 / Compiler在内部将常量字符串转换为同一对象。这意味着,即使您有两个不同的MyWaitNotify
实例,它们也引用相同的空字符串实例。这也意味着在第一个MyWaitNotify
实例上调用 doWait() 的线程可能会被第二个MyWaitNotify
实例上的 doNotify() 调用唤醒。
情况如下图所示:
请记住,即使4个线程在同一个共享字符串实例上调用 wait() 和 notify() ,来自doWait()和doNotify()调用的信号也会分别存储在两个MyWaitNotify
实例中。MyWaitNotify 1
上的 doNotify() 调用可能会唤醒在MyWaitNotify 2
中等待的线程,但该信号将仅存储在MyWaitNotify 1
中。
这可能不是一个大问题。毕竟,如果在第二个 MyWaitNotify
实例上调用 doNotify() ,那么真正发生的是线程A和B被错误唤醒。这个唤醒的线程(A或B)将在while循环中检查其信号,然后返回等待,因为在第一个MyWaitNotify
实例上没有调用 doNotify() ,所以信号没有被改变,设置为true。这种情况等于主动的虚假唤醒。线程A或B在没有发出信号的情况下唤醒。但是代码可以处理这个问题,所以线程会回来等待。
问题是,由于 doNotify() 调用只调用 notify() 而不调用 notifyAll() ,因此即使4个线程在同一个字符串实例(空字符串)上等待,也只会唤醒一个线程。因此,一个信号真正用于C或D时,但是线程A或B中的一个被唤醒,则被唤醒线程(A或B)将检查其信号,看到没有接收到信号,然后返回等待。C或D不会醒来检查他们实际收到的信号,因此信号丢失了。这种情况等同于前面描述的信号丢失问题,C和D被发送了一个信号但没有响应它。
如果 doNotify() 方法调用了 notifyAll() 而不是 notify() ,则所有等待的线程都被唤醒并依次检查信号。线程A和B将返回等待,但C或D中的一个会发现该信号并离开 doWait() 方法调用。C和D中的另一个将返回等待,因为发现信号的线程在离开 doWait() 时清除了信号。
你可能会被诱惑然后总是调用 notifyAll() 而不是 notify() ,但这是一个糟糕的主意。当只有其中一个线程能够响应信号时,没有理由唤醒所有等待的线程。
所以:不要对 wait() / notify() 机制使用全局对象,字符串常量等。例如,每个MyWaitNotify3
(前面部分的示例)实例都有自己的MonitorObject
实例,而不是使用空字符串进行 wait() / notify() 调用。
下面是一个说明上面问题的示例:
import org.junit.Test;
import static java.lang.Thread.sleep;
public class MyWaitNotify {
private final String myMonitorObject = "";
boolean wasSignalled = false;
public void doWait() {
synchronized(myMonitorObject) {
while(!wasSignalled) {
try{
myMonitorObject.wait();
System.out.println(Thread.currentThread().getName() + " is notified");
} catch(InterruptedException e) {
e.printStackTrace();
}
}
//clear signal and continue running.
wasSignalled = false;
}
}
public void doNotify() {
synchronized(myMonitorObject) {
wasSignalled = true;
myMonitorObject.notify();
}
}
@Test
public void test() throws Exception {
MyWaitNotify myWaitNotify1 = new MyWaitNotify();
MyWaitNotify myWaitNotify2 = new MyWaitNotify();
Thread thread1 = new Thread(() -> {
myWaitNotify1.doWait();
System.out.println(Thread.currentThread().getName() + " is notified successfully");
}, "Thread-1");
Thread thread2 = new Thread(() -> {
myWaitNotify1.doWait();
System.out.println(Thread.currentThread().getName() + " is notified successfully");
}, "Thread-2");
Thread thread3 = new Thread(() -> {
myWaitNotify2.doWait();
System.out.println(Thread.currentThread().getName() + " is notified successfully");
}, "Thread-3");
Thread thread4 = new Thread(() -> {
myWaitNotify2.doNotify();
System.out.println(Thread.currentThread().getName() + " notify");
}, "Thread-4");
thread1.start();
thread2.start();
thread3.start();
//等待三个线程充分运行,即期待他们都已经在等待
sleep(1000);
thread4.start();
sleep(1000);
}
}
输出结果如下:
Thread-4 notify
Thread-1 is notified
可能需要多次运行才会出现线程1或线程2被通知的情况。
经过此章内容的讲解,相信对等待/通知的范式是如何形成的,有了一个充分的认知。