你真的懂wait、notify和notifyAll嗎
生產者消費者模型是我們學習多執行緒知識的一個經典案例,一個典型的生產者消費者模型如下:
public void produce() {
synchronized (this) {
while (mBuf.isFull()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mBuf.add();
notifyAll();
}
}
public void consume() {
synchronized (this) {
while (mBuf.isEmpty()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mBuf.remove();
notifyAll();
}
}
這段程式碼很容易引申出來兩個問題:一個是wait()方法外面為什麼是while迴圈而不是if判斷,另一個是結尾處的為什麼要用notifyAll()方法,用notify()行嗎。
很多人在回答第二個問題的時候會想當然的說notify()是喚醒一個執行緒,notifyAll()是喚醒全部執行緒,但是喚醒然後呢,不管是notify()還是notifyAll(),最終拿到鎖的只會有一個執行緒,那它們到底有什麼區別呢?
其實這是一個物件內部鎖的排程問題,要回答這兩個問題,首先我們要明白java中物件鎖的模型,JVM會為一個使用內部鎖(synchronized)的物件維護兩個集合,Entry Set和Wait Set
對於Entry Set:如果執行緒A已經持有了物件鎖,此時如果有其他執行緒也想獲得該物件鎖的話,它只能進入Entry Set,並且處於執行緒的BLOCKED狀態。
對於Wait Set:如果執行緒A呼叫了wait()方法,那麼執行緒A會釋放該物件的鎖,進入到Wait Set,並且處於執行緒的WAITING狀態。
還有需要注意的是,某個執行緒B想要獲得物件鎖,一般情況下有兩個先決條件,一是物件鎖已經被釋放了(如曾經持有鎖的前任執行緒A執行完了synchronized程式碼塊或者呼叫了wait()方法等等),二是執行緒B已處於RUNNABLE狀態。
那麼這兩類集合中的執行緒都是在什麼條件下可以轉變為RUNNABLE呢?
對於Entry Set中的執行緒,當物件鎖被釋放的時候,JVM會喚醒處於Entry Set中的某一個執行緒,這個執行緒的狀態就從BLOCKED轉變為RUNNABLE。
對於Wait Set中的執行緒,當物件的notify()方法被呼叫時,JVM會喚醒處於Wait Set中的某一個執行緒,這個執行緒的狀態就從WAITING轉變為RUNNABLE;或者當notifyAll()方法被呼叫時,Wait Set中的全部執行緒會轉變為RUNNABLE狀態。所有Wait Set中被喚醒的執行緒會被轉移到Entry Set中。
然後,每當物件的鎖被釋放後,那些所有處於RUNNABLE狀態的執行緒會共同去競爭獲取物件的鎖,最終會有一個執行緒(具體哪一個取決於JVM實現,佇列裡的第一個?隨機的一個?)真正獲取到物件的鎖,而其他競爭失敗的執行緒繼續在Entry Set中等待下一次機會。
有了這些知識點作為基礎,上述的兩個問題就能解釋的清了。
首先來看第一個問題,我們在呼叫wait()方法的時候,心裡想的肯定是因為當前方法不滿足我們指定的條件,因此執行這個方法的執行緒需要等待直到其他執行緒改變了這個條件並且做出了通知。那麼為什麼要把wait()方法放在迴圈而不是if判斷裡呢,其實答案顯而易見,因為wait()的執行緒永遠不能確定其他執行緒會在什麼狀態下notify(),所以必須在被喚醒、搶佔到鎖並且從wait()方法退出的時候再次進行指定條件的判斷,以決定是滿足條件往下執行呢還是不滿足條件再次wait()呢。
就像在本例中,如果只有一個生產者執行緒,一個消費者執行緒,那其實是可以用if代替while的,因為執行緒排程的行為是開發者可以預測的,生產者執行緒只有可能被消費者執行緒喚醒,反之亦然,因此被喚醒時條件始終滿足,程式不會出錯。但是這種情況只是多執行緒情況下極為簡單的一種,更普遍的是多個執行緒生產,多個執行緒消費,那麼就極有可能出現喚醒生產者的是另一個生產者或者喚醒消費者的是另一個消費者,這樣的情況下用if就必然會現類似過度生產或者過度消費的情況了,典型如IndexOutOfBoundsException的異常。所以所有的java書籍都會建議開發者永遠都要把wait()放到迴圈語句裡面。
然後來看第二個問題,既然notify()和notifyAll()最終的結果都是隻有一個執行緒能拿到鎖,那喚醒一個和喚醒多個有什麼區別呢?
耐心看下面這個兩個生產者兩個消費者的場景,如果我們程式碼中使用了notify()而非notifyAll(),假設消費者執行緒1拿到了鎖,判斷buffer為空,那麼wait(),釋放鎖;然後消費者2拿到了鎖,同樣buffer為空,wait(),也就是說此時Wait Set中有兩個執行緒;然後生產者1拿到鎖,生產,buffer滿,notify()了,那麼可能消費者1被喚醒了,但是此時還有另一個執行緒生產者2在Entry Set中盼望著鎖,並且最終搶佔到了鎖,但因為此時buffer是滿的,因此它要wait();然後消費者1拿到了鎖,消費,notify();這時就有問題了,此時生產者2和消費者2都在Wait Set中,buffer為空,如果喚醒生產者2,沒毛病;但如果喚醒了消費者2,因為buffer為空,它會再次wait(),這就尷尬了,萬一生產者1已經退出不再生產了,沒有其他執行緒在競爭鎖了,只有生產者2和消費者2在Wait Set中互相等待,那傳說中的死鎖就發生了。
但如果你把上述例子中的notify()換成notifyAll(),這樣的情況就不會再出現了,因為每次notifyAll()都會使其他等待的執行緒從Wait Set進入Entry Set,從而有機會獲得鎖。
其實說了這麼多,一句話解釋就是之所以我們應該儘量使用notifyAll()的原因就是,notify()非常容易導致死鎖。當然notifyAll並不一定都是優點,畢竟一次性將Wait Set中的執行緒都喚醒是一筆不菲的開銷,如果你能handle你的執行緒排程,那麼使用notify()也是有好處的。
最後我把完整的測試程式碼放出來,供大家參考:
import java.util.ArrayList;
import java.util.List;
public class Something {
private Buffer mBuf = new Buffer();
public void produce() {
synchronized (this) {
while (mBuf.isFull()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mBuf.add();
notifyAll();
}
}
public void consume() {
synchronized (this) {
while (mBuf.isEmpty()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mBuf.remove();
notifyAll();
}
}
private class Buffer {
private static final int MAX_CAPACITY = 1;
private List innerList = new ArrayList<>(MAX_CAPACITY);
void add() {
if (isFull()) {
throw new IndexOutOfBoundsException();
} else {
innerList.add(new Object());
}
System.out.println(Thread.currentThread().toString() + " add");
}
void remove() {
if (isEmpty()) {
throw new IndexOutOfBoundsException();
} else {
innerList.remove(MAX_CAPACITY - 1);
}
System.out.println(Thread.currentThread().toString() + " remove");
}
boolean isEmpty(