一文看懂wait和notify的虛假喚醒(spurious wakeups)
你知道的越多,不知道的就越多,業(yè)余的像一棵小草!
你來,我們一起精進(jìn)!你不來,我和你的競爭對手一起精進(jìn)!
編輯:業(yè)余草
推薦:https://www.xttblog.com/?p=5257
java 多線程 wait 時為什么要用 while 而不是 if?
對于 java 多線程的wait()方法,我們在 jdk1.6 的說明文檔里可以看到這樣一段話:

從上面的截圖,我們可以看出,在使用 wait 方法時,需要使用 while 循環(huán)來判斷條件十分滿足,而不是 if,那么我們思考以下,如果使用 if 會怎么樣?
為方便講解,我們來看一個被廣泛使用的生產(chǎn)消費的例子。demo 代碼如下:
/*
生產(chǎn)和消費
*/
package multiThread;
class SynStack {
private char[] data = new char[6];
private int cnt = 0; //表示數(shù)組有效元素的個數(shù)
public synchronized void push(char ch){
if (cnt >= data.length){
try{
System.out.println("生產(chǎn)線程"+Thread.currentThread().getName()+"準(zhǔn)備休眠");
this.wait();
System.out.println("生產(chǎn)線程"+Thread.currentThread().getName()+"休眠結(jié)束了");
}catch (Exception e){
e.printStackTrace();
}
}
this.notify();
data[cnt] = ch;
++cnt;
System.out.printf("生產(chǎn)線程"+Thread.currentThread().getName()+"正在生產(chǎn)第%d個產(chǎn)品,該產(chǎn)品是: %c\n", cnt, ch);
}
public synchronized char pop(){
char ch;
if (cnt <= 0) {
try{
System.out.println("消費線程"+Thread.currentThread().getName()+"準(zhǔn)備休眠");
this.wait();
System.out.println("消費線程"+Thread.currentThread().getName()+"休眠結(jié)束了");
}catch (Exception e){
e.printStackTrace();
}
}
this.notify();
ch = data[cnt-1];
System.out.printf("消費線程"+Thread.currentThread().getName()+"正在消費第%d個產(chǎn)品,該產(chǎn)品是: %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();*/
}
}
上面的代碼只有一個消費者線程和一個生產(chǎn)者線程,程序運行完美,沒有任何錯誤,那為為什么 jdk 里面強調(diào)要用 while 呢?
這個問題,在我剛?cè)胄械臅r候,我也看不懂。那時也想了很久,然后遇到了一個好的 CTO 點撥了我一下。這個程序如果用到多個生產(chǎn)者和消費者的情況,就會出錯。然后,我將信將疑的試了一下,確實會出錯。但是我不能明白為什么就會出錯,繼續(xù)問他,他看我好學(xué)的勁頭,滿意的笑了笑:“看好你的未來!”。
昨天,微信群里有一個網(wǎng)友在面試時,被問到了 wait 方法為什么必須寫在 while 循環(huán)中?他沒回答出來。
而且,這個問題看了 demo 代碼后,還提問到,不是有 synchronized 關(guān)鍵字加鎖了嗎?哪里還有問題?
如果你也有這樣的疑問,那說明你對 wait 方法原理的實際運行效果不是很了解,或者也存在錯誤的理解。我在群里對他們說,在 wait 方法的前后都加上輸出提示語句,后來的打印結(jié)果出乎他們意料。
一個線程執(zhí)行了 wait 方法以后,它不會再繼續(xù)執(zhí)行了,直到被 notify 喚醒。
那么喚醒以后從何處開始執(zhí)行?
這是解決這里出錯原因的關(guān)鍵。
我們嘗試修改代碼,實現(xiàn)一個生產(chǎn)線程,兩個消費線程。
/*
生產(chǎn)和消費
*/
package multiThread;
class SynStack {
private char[] data = new char[6];
private int cnt = 0; //表示數(shù)組有效元素的個數(shù)
public synchronized void push(char ch) {
if (cnt >= data.length) {
try {
System.out.println("生產(chǎn)線程"+Thread.currentThread().getName()+"準(zhǔn)備休眠");
this.wait();
System.out.println("生產(chǎn)線程"+Thread.currentThread().getName()+"休眠結(jié)束了");
} catch (Exception e) {
e.printStackTrace();
}
}
this.notify();
data[cnt] = ch;
++cnt;
System.out.printf("生產(chǎn)線程"+Thread.currentThread().getName()+"正在生產(chǎn)第%d個產(chǎn)品,該產(chǎn)品是: %c\n", cnt, ch);
}
public synchronized char pop() {
char ch;
if (cnt <= 0) {
try {
System.out.println("消費線程"+Thread.currentThread().getName()+"準(zhǔn)備休眠");
this.wait();
System.out.println("消費線程"+Thread.currentThread().getName()+"休眠結(jié)束了");
} catch (Exception e) {
e.printStackTrace();
}
}
this.notify();
ch = data[cnt-1];
System.out.printf("消費線程"+Thread.currentThread().getName()+"正在消費第%d個產(chǎn)品,該產(chǎn)品是: %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 函數(shù)里增加了一個消費線程。
然后錯誤出現(xiàn)了。

數(shù)組越界,為什么會這樣?
問題的關(guān)鍵就在于7號消費線程喚醒了 6 號消費線程,而 6 號消費線程被喚醒以后,它從哪里開始執(zhí)行是關(guān)鍵?。。。?/p>
它會執(zhí)行
System.out.println("消費線程"+Thread.currentThread().getName()+"休眠結(jié)束了");
這行代碼。
不是從 pop() 方法的開始處執(zhí)行。
那么這跟使用 if 方法有什么關(guān)系?
因為,7 號線程喚醒了 6 號線程,并執(zhí)行了以下 4 行代碼。
ch = data[cnt-1];
System.out.printf("消費線程"+Thread.currentThread().getName()+"正在消費第%d個產(chǎn)品,該產(chǎn)品是: %c\n", cnt, ch);
--cnt;
return ch;
7 號線程執(zhí)行完上面的代碼后,cnt 就 =0 了
又因為 6 號線程被喚醒時已經(jīng)處在 if 方法體內(nèi),它不會再去執(zhí)行 if 條件判斷,所以就順序往下執(zhí)行,這個時候執(zhí)行
ch = data[cnt-1];
// 就會出現(xiàn)越界異常。
// 假如使用 while 就不會,因為當(dāng)喚醒了 6 號線程以后,它依然會去執(zhí)行循環(huán)條件檢測。
// 所以不可能執(zhí)行下去,保證了程序的安全。
結(jié)論:就是用 if 判斷的話,喚醒后線程會從 wait 之后的代碼開始運行,但是不會重新判斷 if 條件,直接繼續(xù)運行 if 代碼塊之后的代碼,而如果使用 while 的話,也會從 wait 之后的代碼運行,但是喚醒后會重新判斷循環(huán)條件,如果不成立再執(zhí)行 while 代碼塊之后的代碼塊,成立的話繼續(xù) wait。
這種現(xiàn)象,也就是 JDK 文檔中提到的虛假喚醒,也有人稱為:異常喚醒,虛擬喚醒、偽喚醒。
虛假喚醒(spurious wakeup),是不想喚醒它或者說不確定是否應(yīng)該喚醒,但是被喚醒了。對程序來說,wait 方法應(yīng)該卡住當(dāng)前程序,不應(yīng)該往后執(zhí)行;但是實際上并沒有被卡住,而是在非預(yù)期的時間程序正常執(zhí)行了,沒有程序沒有被卡住就是被虛假喚醒了。

用 while 而不是 if 來判斷,可以避免虛假喚醒。是因為操作系統(tǒng)的通知不可信,自己再校驗一次,如果是虛假喚醒就再 wait 一次(直到正確為止)。
虛假喚醒是很多語言都存在的問題,也是很多操作系統(tǒng)底層的問題,與具體應(yīng)用無關(guān)。
我列舉的生產(chǎn)者消費者例子,我在用通俗的白話解釋一下。
在單消費者和單生產(chǎn)者的模式中,因為只有兩個線程,消費者 pop 方法 notify 通知到的一定是生產(chǎn)者線程,使其執(zhí)行 push 操作。 在多消費者模式中,消費者 pop 方法 notify 隨機通知一個 SysStack 對象等待池中的線程,使其進(jìn)入 SysStack 對象的鎖池中競爭獲取該對象的鎖。產(chǎn)生錯誤的關(guān)鍵原因在于 notify 通知到的線程既可能是生產(chǎn)者線程有可能是消費者線程。若僅剩一個元素時,某消費者線程執(zhí)行 pop 方法,判斷 if 條件不成立,執(zhí)行 notify 喚醒了另外的消費者線程,并消費了當(dāng)前的最后一個元素。被喚醒的消費者線程由于已經(jīng)在 if 方法中,不需要再判斷剩余的元素數(shù)量,又緊接著執(zhí)行了消費一個元素的操作,此時無元素可消費,程序就異常了。
最后,我再補充下多消費者模式代碼中如果換成 while,且邏輯不正確時很容易發(fā)生程序掛起問題。
因為使用 notify 仍存在導(dǎo)致程序掛起的風(fēng)險。這里先說一下對象的鎖池和等待池。執(zhí)行 wait 方法會使線程釋放鎖進(jìn)入鎖對象的等待池。notify 和 notifyAll 通知等待池中的線程,使其進(jìn)入鎖池競爭鎖資源。notify 僅僅通知等待池中的一個線程,使其進(jìn)入鎖池競爭鎖資源,若競爭到了鎖,線程就 running;notifyAll 會通知鎖對象的等待池中的所有線程進(jìn)入鎖池競爭鎖,盡管最后只能有一個線程得到鎖,剩下的都還等著鎖資源再釋放去競爭。還是舉多消費者的例子,若僅剩一個元素時,某消費者線程執(zhí)行 pop 方法,判斷 if 條件不成立,執(zhí)行 notify 喚醒了另外的消費者線程,并消費了當(dāng)前的最后一個元素。被喚醒的消費者線程由于已經(jīng)使用了 while 進(jìn)行優(yōu)化,會執(zhí)行 wait 操作釋放鎖并加入等待池。此時,若前面全部使用 notify,就會出現(xiàn)鎖池中沒有線程(都在等待池等著 notify/notifyAll),無人競爭已被釋放的鎖的情況,這樣所有線程都無法 running,程序就被掛起了。
以上知識,如有疑問,歡迎加我微信:codedq,進(jìn)群溝通。如果覺得內(nèi)容還可以,歡迎點贊!
