一文看懂wait和notify的虚假唤醒(spurious wakeups)
共 17770字,需浏览 36分钟
·
2021-08-13 19:14
你知道的越多,不知道的就越多,业余的像一棵小草!
你来,我们一起精进!你不来,我和你的竞争对手一起精进!
编辑:业余草
推荐:https://www.xttblog.com/?p=5257
java 多线程 wait 时为什么要用 while 而不是 if?
对于 java 多线程的wait()
方法,我们在 jdk1.6 的说明文档里可以看到这样一段话:
从上面的截图,我们可以看出,在使用 wait 方法时,需要使用 while 循环来判断条件十分满足,而不是 if,那么我们思考以下,如果使用 if 会怎么样?
为方便讲解,我们来看一个被广泛使用的生产消费的例子。demo 代码如下:
/*
生产和消费
*/
package multiThread;
class SynStack {
private char[] data = new char[6];
private int cnt = 0; //表示数组有效元素的个数
public synchronized void push(char ch){
if (cnt >= data.length){
try{
System.out.println("生产线程"+Thread.currentThread().getName()+"准备休眠");
this.wait();
System.out.println("生产线程"+Thread.currentThread().getName()+"休眠结束了");
}catch (Exception e){
e.printStackTrace();
}
}
this.notify();
data[cnt] = ch;
++cnt;
System.out.printf("生产线程"+Thread.currentThread().getName()+"正在生产第%d个产品,该产品是: %c\n", cnt, ch);
}
public synchronized char pop(){
char ch;
if (cnt <= 0) {
try{
System.out.println("消费线程"+Thread.currentThread().getName()+"准备休眠");
this.wait();
System.out.println("消费线程"+Thread.currentThread().getName()+"休眠结束了");
}catch (Exception e){
e.printStackTrace();
}
}
this.notify();
ch = data[cnt-1];
System.out.printf("消费线程"+Thread.currentThread().getName()+"正在消费第%d个产品,该产品是: %c\n", cnt, ch);
--cnt;
return ch;
}
}
class Producer implements Runnable{
private SynStack ss = null;
public Producer(SynStack ss){
this.ss = ss;
}
public void run() {
char ch;
for (int i=0; i<10; ++i){
// try{
// Thread.sleep(100);
// }catch (Exception e){}
ch = (char)('a'+i);
ss.push(ch);
}
}
}
class Consumer implements Runnable{
private SynStack ss = null;
public Consumer(SynStack ss){
this.ss = ss;
}
public void run(){
for (int i=0; i<10; ++i){
/*try{
Thread.sleep(100);
}
catch (Exception e){
}*/
//System.out.printf("%c\n", ss.pop());
ss.pop();
}
}
}
public class TestPC2 {
public static void main(String[] args) {
SynStack ss = new SynStack();
Producer p = new Producer(ss);
Consumer c = new Consumer(ss);
Thread t1 = new Thread(p);
t1.setName("1号");
t1.start();
/*Thread t2 = new Thread(p);
t2.setName("2号");
t2.start();*/
Thread t6 = new Thread(c);
t6.setName("6号");
t6.start();
/*Thread t7 = new Thread(c);
t7.setName("7号");
t7.start();*/
}
}
上面的代码只有一个消费者线程和一个生产者线程,程序运行完美,没有任何错误,那为为什么 jdk 里面强调要用 while 呢?
这个问题,在我刚入行的时候,我也看不懂。那时也想了很久,然后遇到了一个好的 CTO 点拨了我一下。这个程序如果用到多个生产者和消费者的情况,就会出错。然后,我将信将疑的试了一下,确实会出错。但是我不能明白为什么就会出错,继续问他,他看我好学的劲头,满意的笑了笑:“看好你的未来!”。
昨天,微信群里有一个网友在面试时,被问到了 wait 方法为什么必须写在 while 循环中?他没回答出来。
而且,这个问题看了 demo 代码后,还提问到,不是有 synchronized 关键字加锁了吗?哪里还有问题?
如果你也有这样的疑问,那说明你对 wait 方法原理的实际运行效果不是很了解,或者也存在错误的理解。我在群里对他们说,在 wait 方法的前后都加上输出提示语句,后来的打印结果出乎他们意料。
一个线程执行了 wait 方法以后,它不会再继续执行了,直到被 notify 唤醒。
那么唤醒以后从何处开始执行?
这是解决这里出错原因的关键。
我们尝试修改代码,实现一个生产线程,两个消费线程。
/*
生产和消费
*/
package multiThread;
class SynStack {
private char[] data = new char[6];
private int cnt = 0; //表示数组有效元素的个数
public synchronized void push(char ch) {
if (cnt >= data.length) {
try {
System.out.println("生产线程"+Thread.currentThread().getName()+"准备休眠");
this.wait();
System.out.println("生产线程"+Thread.currentThread().getName()+"休眠结束了");
} catch (Exception e) {
e.printStackTrace();
}
}
this.notify();
data[cnt] = ch;
++cnt;
System.out.printf("生产线程"+Thread.currentThread().getName()+"正在生产第%d个产品,该产品是: %c\n", cnt, ch);
}
public synchronized char pop() {
char ch;
if (cnt <= 0) {
try {
System.out.println("消费线程"+Thread.currentThread().getName()+"准备休眠");
this.wait();
System.out.println("消费线程"+Thread.currentThread().getName()+"休眠结束了");
} catch (Exception e) {
e.printStackTrace();
}
}
this.notify();
ch = data[cnt-1];
System.out.printf("消费线程"+Thread.currentThread().getName()+"正在消费第%d个产品,该产品是: %c\n", cnt, ch);
--cnt;
return ch;
}
}
class Producer implements Runnable {
private SynStack ss = null;
public Producer(SynStack ss) {
this.ss = ss;
}
public void run() {
char ch;
for (int i=0; i<10; ++i) {
// try{
// Thread.sleep(100);
// }
// catch (Exception e){
// }
ch = (char)('a'+i);
ss.push(ch);
}
}
}
class Consumer implements Runnable {
private SynStack ss = null;
public Consumer(SynStack ss) {
this.ss = ss;
}
public void run() {
for (int i=0; i<10; ++i) {
/*try{
Thread.sleep(100);
}
catch (Exception e){
}*/
//System.out.printf("%c\n", ss.pop());
ss.pop();
}
}
}
public class TestPC2 {
public static void main(String[] args) {
SynStack ss = new SynStack();
Producer p = new Producer(ss);
Consumer c = new Consumer(ss);
Thread t1 = new Thread(p);
t1.setName("1号");
t1.start();
/*Thread t2 = new Thread(p);
t2.setName("2号");
t2.start();*/
Thread t6 = new Thread(c);
t6.setName("6号");
t6.start();
Thread t7 = new Thread(c);
t7.setName("7号");
t7.start();
}
}
上面代码就是在 main 函数里增加了一个消费线程。
然后错误出现了。
数组越界,为什么会这样?
问题的关键就在于7号消费线程唤醒了 6 号消费线程,而 6 号消费线程被唤醒以后,它从哪里开始执行是关键!!!!
它会执行
System.out.println("消费线程"+Thread.currentThread().getName()+"休眠结束了");
这行代码。
不是从 pop() 方法的开始处执行。
那么这跟使用 if 方法有什么关系?
因为,7 号线程唤醒了 6 号线程,并执行了以下 4 行代码。
ch = data[cnt-1];
System.out.printf("消费线程"+Thread.currentThread().getName()+"正在消费第%d个产品,该产品是: %c\n", cnt, ch);
--cnt;
return ch;
7 号线程执行完上面的代码后,cnt 就 =0 了
又因为 6 号线程被唤醒时已经处在 if 方法体内,它不会再去执行 if 条件判断,所以就顺序往下执行,这个时候执行
ch = data[cnt-1];
// 就会出现越界异常。
// 假如使用 while 就不会,因为当唤醒了 6 号线程以后,它依然会去执行循环条件检测。
// 所以不可能执行下去,保证了程序的安全。
结论:就是用 if 判断的话,唤醒后线程会从 wait 之后的代码开始运行,但是不会重新判断 if 条件,直接继续运行 if 代码块之后的代码,而如果使用 while 的话,也会从 wait 之后的代码运行,但是唤醒后会重新判断循环条件,如果不成立再执行 while 代码块之后的代码块,成立的话继续 wait
。
这种现象,也就是 JDK 文档中提到的虚假唤醒,也有人称为:异常唤醒,虚拟唤醒、伪唤醒。
虚假唤醒(spurious wakeup),是不想唤醒它或者说不确定是否应该唤醒,但是被唤醒了。对程序来说,wait 方法应该卡住当前程序,不应该往后执行;但是实际上并没有被卡住,而是在非预期的时间程序正常执行了,没有程序没有被卡住就是被虚假唤醒了
。
用 while 而不是 if 来判断,可以避免虚假唤醒。是因为操作系统的通知不可信,自己再校验一次,如果是虚假唤醒就再 wait 一次(直到正确为止)。
虚假唤醒是很多语言都存在的问题,也是很多操作系统底层的问题,与具体应用无关。
我列举的生产者消费者例子,我在用通俗的白话解释一下。
在单消费者和单生产者的模式中,因为只有两个线程,消费者 pop 方法 notify 通知到的一定是生产者线程,使其执行 push 操作。 在多消费者模式中,消费者 pop 方法 notify 随机通知一个 SysStack 对象等待池中的线程,使其进入 SysStack 对象的锁池中竞争获取该对象的锁。产生错误的关键原因在于 notify 通知到的线程既可能是生产者线程有可能是消费者线程。若仅剩一个元素时,某消费者线程执行 pop 方法,判断 if 条件不成立,执行 notify 唤醒了另外的消费者线程,并消费了当前的最后一个元素。被唤醒的消费者线程由于已经在 if 方法中,不需要再判断剩余的元素数量,又紧接着执行了消费一个元素的操作,此时无元素可消费,程序就异常了。
最后,我再补充下多消费者模式代码中如果换成 while,且逻辑不正确时很容易发生程序挂起问题。
因为使用 notify 仍存在导致程序挂起的风险。这里先说一下对象的锁池和等待池。执行 wait 方法会使线程释放锁进入锁对象的等待池。notify 和 notifyAll 通知等待池中的线程,使其进入锁池竞争锁资源。notify 仅仅通知等待池中的一个线程,使其进入锁池竞争锁资源,若竞争到了锁,线程就 running;notifyAll 会通知锁对象的等待池中的所有线程进入锁池竞争锁,尽管最后只能有一个线程得到锁,剩下的都还等着锁资源再释放去竞争。还是举多消费者的例子,若仅剩一个元素时,某消费者线程执行 pop 方法,判断 if 条件不成立,执行 notify 唤醒了另外的消费者线程,并消费了当前的最后一个元素。被唤醒的消费者线程由于已经使用了 while 进行优化,会执行 wait 操作释放锁并加入等待池。此时,若前面全部使用 notify,就会出现锁池中没有线程(都在等待池等着 notify/notifyAll),无人竞争已被释放的锁的情况,这样所有线程都无法 running,程序就被挂起了。
以上知识,如有疑问,欢迎加我微信:codedq,进群沟通。如果觉得内容还可以,欢迎点赞!