关于在生产者与消费者问题中的虚假唤醒问题
一、传统的Synchronized版本的生产者消费者实现
说起虚假唤醒问题就要提起我们的生产者和消费者,我们先看两段代码
1.1、当只有两个线程时,代码运行正常
代码如下
package com.JUC.procon;
/**
* @Author Feng
* @Date 2021/10/19 10:04
* @Version 1.0
* @Description
*
* 线程之间的通信问题:生产者和消费者问题 ! 等待唤醒,通知唤醒
* 线程交替执行:A线程和B线程,操作同一个变量 num = 0
* A:num+1
* B:num-1
*/
public class A {
public static void main(String[] args) {
Data data = new Data();
//A线程加1
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"A").start();
//B线程减一
new Thread(()->{
for (int i = 0; i < 10; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"B").start();
}
}
//生产者消费者的三部曲
// 1.判断等待 2.业务代码 3.通知唤醒
class Data{//数字,资源类
private int num =0;
// +1的方法
public synchronized void increment() throws InterruptedException {
if (num!=0){
this.wait();//不等于0的时候等待
}
//等于0的时候加1
num++;
System.out.println(Thread.currentThread().getName()+"==>"+num);
this.notifyAll();//加完后唤醒
}
// -1的方法
public synchronized void decrement() throws InterruptedException {
if (num==0){
this.wait();//不等于0的时候等待
}
//等于1的时候减1
num--;
System.out.println(Thread.currentThread().getName()+"==>"+num);
this.notifyAll();//减完后唤醒其他线程
}
}
结果如下
- 此时代码的运行是逻辑上是没有问题的,生产者生产一个,消费者就消费一个;当没有库存而消费者线程被唤醒时,则会
wait()
进入等待被挂起。但是这个代码是有严重设计缺陷的,在线程体进行条件判断时应该使用while而非if。为什么呢?请看后面的讲解。这个之所以不出问题是因为生产者和消费者线程都只有一个。此时如果消费者线程被阻塞,则它只有等待生产者线程调用notifyAll()
来唤醒它,而在唤醒它之前,生产者已经完成了生产操作,从而使得没有出现异常。
但是当线程多了之后,就会出现问题
1.2、当有多个线程时,代码执行结果就会有错误
我们为这个资源类新增两个线程C和线程D时。
package com.JUC.procon;
/**
* @Author Feng
* @Date 2021/10/19 10:04
* @Version 1.0
* @Description :传统的synchronized版本
* 线程之间的通信问题:生产者和消费者问题 ! 等待唤醒,通知唤醒
* 线程交替执行:A线程和B线程,操作同一个变量 num = 0
* A:num+1
* B:num-1
*/
public class A {
public static void main(String[] args) {
Data data = new Data();
//A线程加1
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "A").start();
//B线程减一
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "B").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "C").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "D").start();
}
}
//生产者消费者的三部曲
// 1.判断等待 2.业务代码 3.通知唤醒
class Data {//数字,资源类
private int num = 0;
// +1的方法
public synchronized void increment() throws InterruptedException {
if (num != 0) {
this.wait();//不等于0的时候等待
}
//等于0的时候加1
num++;
System.out.println(Thread.currentThread().getName() + "==>" + num);
this.notifyAll();//加完后唤醒
}
// -1的方法
public synchronized void decrement() throws InterruptedException {
if (num == 0) {
this.wait();//不等于0的时候等待
}
//等于1的时候减1
num--;
System.out.println(Thread.currentThread().getName() + "==>" + num);
this.notifyAll();//减完后唤醒其他线程
}
}
运行结果
我们发现出现了2,3
,按照逻辑,应该是只有0和1
,但是出现了2和3,就证明我们出现了问题。有线程多生产了。这是为什么呢?
其实这就是 虚假唤醒问题
二、虚假唤醒
首先需要说明的是,虚假唤醒不是Java语言特有的问题,而是多线程通信特有的问题,在java中就体现在
sychronized-wait-notify
上,最典型的应用场景就是生产者-消费者
模式。
定义
首先我们观看官方的文字定义
A spurious wakeup happens when a thread wakes up from waiting on a condition variable that’s been signaled, only to discover that the condition it was waiting for isn’t satisfied. It’s called spurious because the thread has seemingly been awakened for no reason. But spurious wakeups don’t happen for no reason: they usually happen because, in between the time when the condition variable was signaled and when the waiting thread finally ran, another thread ran and changed the condition. There was a race condition between the threads, with the typical result that sometimes, the thread waking up on the condition variable runs first, winning the race, and sometimes it runs second, losing the race. ----from Wikipedia
当线程从等待状态中被唤醒时,只是发现未满足其正在等待的条件时,就会发生虚假唤醒。 之所以称其为虚假的,是因为该线程似乎无缘无故被唤醒。 虚假唤醒不会无缘无故发生,通常是因为在发起唤醒号和等待线程最终运行之间的临界时间内,线程不再满足竞态条件。
我们再在JDK文档中的Thread类的wait方法中发现虚假唤醒的有关说明
我们发现在这个官方的代码中,它用的是while
来进行条件判断而不是用if
,为什么呢?
在说明两者的区别之前,我们需要明白,当一个线程调用同步对象的wait方法后,当前线程会:
- 释放CPU
- 释放对象锁
- 只有等待该同步对象调用notify/notifyAll该线程才会被唤醒,唤醒后继续从wait处的下一行代码开始执行
这里最关键的就是:线程被唤醒后wait之后的代码执行的,如上面我的代码从num++;
开始执行
这意味着:
如果使用if,条件判断只进行一次,下次被唤醒的时候已经绕过了条件判断,从wait后的语句开始顺序执行;
如果使用while,wait后的语句在循环体内,虽然绕过了上一次的条件判断,但终究会进入下一轮条件判断。
例子
- 消费者D消费了一个产品,此时库存为0。当前线程调用
notifyAll
,从wait
中唤醒线程 - 生产者A被唤醒,拿到CPU和对象锁,生产一个产品,此时库存为1。当前线程调用
notifyAll
,从wait
中唤醒线程 - 问题来了:由于
notifyAll
只能随机唤醒wait
中的线程,它将生产者C唤醒了。由于之前生产者Cwait
阻塞时已经执行过了if
,此处直接向下执行业务代码num++
操作,没有在进行产品的条件判断。将库存从1变为了2.这样就出现了错误的2,然后还是这样出现了3
后续的所有问题就是这样导致的。这也就是为什么我们的条件判断应该用while
的原因。总结起来,导致错误的原因有:
notify/notifyAll
无法指定唤醒线程,只能从wait
的阻塞队列中随机唤醒- 被唤醒的线程从
wait
语句下一行开始执行,导致绕过了if的条件判断
这样的一个过程在wait-notify机制下是无法避免的,因为notify是随机唤醒的。因为wait会下释放锁,线程唤醒之后是从wait的位置获得锁继续执行,而这里是唤醒全部的notifyAll(),所以两个生产者都会被唤醒,假如本应该让旧生产者获取锁生产的,但是被新的生产者获得锁就会重复生产,
再者这里是 if判断 解除阻塞状态时,唤醒后线程会从wait之后的代码开始运行,但是不会重新判断if条件,直接继续运行if代码块之后的代码,他只会判断一次不会再进行判断了(因为wait之前已经进行了判断),它只会继续执行接下来的代码,
而使用while时,在解除阻塞时,会一直进行判断,需要线程满足条件时,才会执行接下来的代码
举例:**
本人的理解就是:就是在等待队列中的线程,被随机cpu调度唤醒了,但是被条件限制了,本来应该被唤醒然后执行的,但是被条件约束,这样就造成了虚假唤醒,通俗讲:就是唤醒了但是没有完全唤醒,哈哈哈!
解决方式:将 if 改为用 while 来判断条件
package com.JUC.procon;
/**
* @Author Feng
* @Date 2021/10/19 10:04
* @Version 1.0
* @Description :传统的synchronized版本
* 线程之间的通信问题:生产者和消费者问题 ! 等待唤醒,通知唤醒
* 线程交替执行:A线程和B线程,操作同一个变量 num = 0
* A:num+1
* B:num-1
*/
public class A {
public static void main(String[] args) {
Data data = new Data();
//A线程加1
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "A").start();
//B线程减一
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "B").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "C").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "D").start();
}
}
//生产者消费者的三部曲
// 1.判断等待 2.业务代码 3.通知唤醒
class Data {//数字,资源类
private int num = 0;
// +1的方法
public synchronized void increment() throws InterruptedException {
while (num != 0) {
this.wait();//不等于0的时候等待
}
//等于0的时候加1
num++;
System.out.println(Thread.currentThread().getName() + "==>" + num);
this.notifyAll();//加完后唤醒
}
// -1的方法
public synchronized void decrement() throws InterruptedException {
while (num == 0) {
this.wait();//不等于0的时候等待
}
//等于1的时候减1
num--;
System.out.println(Thread.currentThread().getName() + "==>" + num);
this.notifyAll();//减完后唤醒其他线程
}
}
结果又正常了
其实还有一个更有意思的理解,我们发现当用while条件限制后,假如notifyAll()
把生产者3给唤醒了,系统需要给其资源,但是又被条件限制了,然后又进入了阻塞。这个让其醒了又睡了,有意思。这个可能才是真正的虚假唤醒。
当线程从等待状态中被唤醒时,只是发现未满足其正在等待的条件时,就会发生虚假唤醒。 之所以称其为虚假的,是因为该线程似乎无缘无故被唤醒。 虚假唤醒不会无缘无故发生,通常是因为在发起唤醒号和等待线程最终运行之间的临界时间内,线程不再满足竞态条件。
好了,这就是虚假唤醒问题,我们需要注意用while
来循环进行条件判断,这样就限制了虚假唤醒的出现。