由上一篇文章你應該已經知道,在 破壞佔用且等待條件
的時候,如果轉出賬本和轉入賬本不滿足同時在文件架上這個條件,就用死循環的方式來循環等待,核心代碼如下:
// 一次性申請轉出賬戶和轉入賬戶,直到成功
while(!actr.apply(this, target))
;
如果 apply() 操作耗時非常短,而且併發衝突量也不大時,這個方案還挺不錯的,因爲這種場景下,循環上幾次或者幾十次就能一次性獲取轉出賬戶和轉入賬戶了。但是如果 apply() 操作耗時長,或者併發衝突量大的時候,可能要循環上萬次才能獲取到鎖,太消耗 CPU 了。
其實在這種場景下,最好的方案應該是:如果線程要求的條件(轉出賬本和轉入賬本同在文件架上)不滿足,則線程阻塞自己,進入等待
狀態;當線程要求的條件(轉出賬本和轉入賬本同在文件架上)滿足後, 通知
等待的線程重新執行。其中,使用線程阻塞的方式就能避免循環等待消耗 CPU 的問題。
下面我們就來看看 Java 語言是如何支持 等待 - 通知機制
這裏直接給出 等待 - 通知機制
的相關步驟:
線程首先獲取互斥鎖,當線程要求的條件不滿足時,釋放互斥鎖,進入等待狀態;當要求的條件滿足時,通知其他等待的線程,重新獲取互斥鎖.
用 synchronized 實現等待 - 通知機制
在 Java 語言裏,等待 - 通知機制可以有多種實現方式,比如 Java 語言內置的 synchronized 配合 wait()、notify()、notifyAll() 這三個方法就能輕鬆實現。
先用 synchronized 實現互斥鎖。在下面這個圖裏,左邊有一個等待隊列,同一時刻,只允許一個線程進入 synchronized 保護的臨界區,當有一個線程進入臨界區後,其他線程就只能進入圖中左邊的等待隊列裏等待。 這個等待隊列和互斥鎖是一對一的關係,每個互斥鎖都有自己獨立的等待隊列。
wait() 操作工作原理圖
在併發程序中,當一個線程進入臨界區後,由於某些條件不滿足,需要進入等待狀態,Java 對象的 wait() 方法就能夠滿足這種需求。如上圖所示,當調用 wait() 方法後,當前線程就會被阻塞,並且進入到右邊的等待隊列中,這個等待隊列也是互斥鎖的等待隊列
。 線程在進入等待隊列的同時,會釋放持有的互斥鎖
,線程釋放鎖後,其他線程就有機會獲得鎖,並進入臨界區了。
那線程要求的條件滿足時,該怎麼通知這個等待的線程呢?很簡單,就是 Java 對象的 notify() 和 notifyAll() 方法。我在下面這個圖裏爲你大致描述了這個過程,當條件滿足時調用 notify(),會通知等待隊列(互斥鎖的等待隊列
)中的線程,告訴它條件曾經滿足過
notify() 操作工作原理圖
爲什麼說是曾經滿足過呢?因爲 notify() 只能保證在通知時間點,條件是滿足的
。而被通知線程的執行時間點和通知的時間點
基本上不會重合,所以當線程執行的時候,很可能條件已經不滿足了(可能會有其他線程插隊)。這一點你需要格外注意。除此之外,還有一個需要注意的點,被通知的線程要想重新執行,仍然需要獲取到互斥鎖(因爲曾經獲取的鎖在調用 wait() 時已經釋放了)。
注意 wait()、notify()、notifyAll() 方法操作的等待隊列是互斥鎖的等待隊列,所以方法要使用在鎖
上,synchronized 鎖定的是 this,那麼對應的一定是 this.wait()、this.notify()、this.notifyAll();。而且 wait()、notify()、notifyAll() 這三個方法能夠被調用的前提是已經獲取了相應的互斥鎖,所以我們會發現 wait()、notify()、notifyAll() 都是在 synchronized{}內部被調用的。如果在 synchronized{}外部調用,或者鎖定的 this,而用 target.wait() 調用的話,JVM 會拋出一個運行時異常:java.lang.IllegalMonitorStateException
一個更好地資源分配器
等待 - 通知機制的基本原理搞清楚後,我們來看看它如何解決一次性申請轉出賬戶和轉入賬戶的問題。在這個等待 - 通知機制中,我們需要考慮以下四個要素。
- 互斥鎖:上一篇文章我們提到 Allocator 需要是單例的,所以我們可以用 this 作爲互斥鎖。
- 線程要求的條件:轉出賬戶和轉入賬戶都沒有被分配過。
- 何時等待:線程要求的條件不滿足就等待。
- 何時通知:當有線程釋放賬戶時就通知。
注意下面的判斷方式
while(條件不滿足) {
wait();
}
利用這種範式可以解決上面提到的條件曾經滿足過
的情況。至於爲什麼這麼寫,後面講解 管程
的時候會在詳細解釋。
來看完成後的代碼
class Allocator {
private List<Object> als;
// 一次性申請所有資源
synchronized void apply(
Object from, Object to){
// 經典寫法
while(als.contains(from) ||
als.contains(to)){
try{
wait();
}catch(Exception e){
}
}
als.add(from);
als.add(to);
}
// 歸還資源
synchronized void free(
Object from, Object to){
als.remove(from);
als.remove(to);
notifyAll();
}
}
儘量使用 notifyAll()
在上面的代碼中,我用的是 notifyAll() 來實現通知機制,爲什麼不使用 notify() 呢?這二者是有區別的。
notify() 是會隨機地通知等待隊列中的一個線程,而 notifyAll() 會通知等待隊列中的所有線程。
從感覺上來講,應該是 notify() 更好一些,因爲即便通知所有線程,也只有一個線程能夠進入臨界區。但實際上使用 notify() 也很有風險,它的風險在於可能導致某些線程永遠不會被通知到。
假設我們有資源 A、B、C、D,線程 1 申請到了 AB,線程 2 申請到了 CD,此時線程 3 申請 AB,會進入等待隊列(AB 分配給線程 1,線程 3 要求的條件不滿足),線程 4 申請 CD 也會進入等待隊列。我們再假設之後線程 1 歸還了資源 AB,如果使用 notify() 來通知等待隊列中的線程,有可能被通知的是線程 4,但線程 4 申請的是 CD,所以此時線程 4 還是會繼續等待,而真正該喚醒的線程 3 就再也沒有機會被喚醒了。
所以除非經過深思熟慮,否則儘量使用 notifyAll()。
總結
Java 語言的這種實現,背後的理論模型其實是管程,後面會專門介紹管程。現在你只需要能夠熟練使用就可以了。
思考:wait() 方法和 sleep() 方法都能讓當前線程掛起一段時間,那它們的區別是什麼?