[Java併發-6]「管程」-java管程初探

併發編程這個技術領域已經發展了半個世紀了。有沒有一種核心技術能夠很方便地解決咱們的併發問題呢?這個問題, 我會選擇 Monitor(管程)技術。
Java 語言在 1.5 以前,提供的惟一的併發原語就是管程,並且 1.5 以後提供的 SDK 併發包,也是以管程技術爲基礎的。除此以外,C/C++、C# 等高級語言也都支持管程。編程

什麼是管程

操做系統原理課程告訴咱們,用信號量能解決全部併發問題。可是爲何 Java 在 1.5 以前僅僅提供了 synchronized 關鍵字及 wait()、notify()、notifyAll() 這三個看似從天而降的方法?固然這裏由於 Java 採用的是管程技術,synchronized 關鍵字及 wait()、notify()、notifyAll() 這三個方法都是管程的組成部分。而且安全

管程和信號量是等價的,所謂等價指的是用管程可以實現信號量,也能用信號量實現管程。

可是管程更容易使用,因此 Java 選擇了管程。數據結構

管程,對應的英文是 Monitor,不少 Java 領域的同窗都喜歡將其翻譯成「監視器」,這是直譯。操做系統領域通常都翻譯成「管程」,這個是意譯,在這裏我更傾向於使用「管程」。併發

管程,指的是管理共享變量以及對共享變量的操做過程,讓他們支持併發。

翻譯爲 Java 領域的語言,就是管理類的成員變量和成員方法,讓這個類是線程安全的。那管程是怎麼管的呢?工具

MESA 模型

在管程的發展史上,前後出現過三種不一樣的管程模型,分別是:Hasen 模型、Hoare 模型和 MESA 模型。其中,如今普遍應用的是 MESA 模型,而且 Java 管程的實現參考的也是 MESA 模型。因此咱們重點介紹一下 MESA 模型。spa

在併發編程領域,有兩大核心問題:
一個是互斥,即同一時刻只容許一個線程訪問共享資源;
另外一個是同步,即線程之間如何通訊、協做。這兩大問題,管程都是可以解決的。操作系統

咱們先來看看管程是如何解決互斥問題的。線程

管程解決互斥問題的思路很簡單,就是將共享變量及其對共享變量的操做統一封裝起來。在下圖中,管程 X 將共享變量 queue 這個隊列和相關的操做入隊 enq()、出隊 deq() 都封裝起來了;線程 A 和線程 B 若是想訪問共享變量 queue,只能經過調用管程提供的 enq()、deq() 方法來實現;enq()、deq() 保證互斥性,只容許一個線程進入管程。從中能夠看出,管程模型和麪向對象高度契合的。而我在前面章節介紹的互斥鎖用法,其背後的模型其實就是它。翻譯

圖片描述
管程模型的代碼化語義3d

那管程如何解決線程間的同步問題的。

這個就比較複雜了,咱們來看下 MESA 管程模型示意圖,它詳細描述了 MESA 模型的主要組成部分。

在管程模型裏,共享變量和對共享變量的操做是被封裝起來的,圖中最外層的框就表明封裝的意思。框的上面只有一個入口,而且在入口旁邊還有一個入口等待隊列。當多個線程同時試圖進入管程內部時,只容許一個線程進入,其餘線程則在入口等待隊列中等待。

管程裏還引入了條件變量的概念,並且每一個條件變量都對應有一個等待隊列,以下圖,條件變量 A 和條件變量 B 分別都有本身的等待隊列。

圖片描述
MESA 管程模型圖

那條件變量和等待隊列的做用是什麼呢?其實就是解決線程同步問題。你也能夠結合上面提到的入隊出隊例子加深一下理解。

其餘關於管程的定義,加深咱們的理解

管程是定義了一個數據結構和能爲併發所執行的一組操做,這組操做可以進行同步和改變管程中的數據。這至關於對臨界資源的同步操做都集中進行管理,凡是要訪問臨界資源的進程或線程,都必須先經過管程,由管程的這套機制來實現多進程或線程對同一個臨界資源的互斥訪問和使用。管程的同步主要經過condition類型的變量(條件變量),條件變量可執行操做wait()和signal()。管程通常是由語言編譯器進行封裝,體現出OOP中的封裝思想,也如老師所講的,管程模型和麪向對象高度契合的。

假設有個線程 T1 執行出隊操做,不過須要注意的是執行出隊操做,有個前提條件,就是隊列不能是空的,而隊列不空這個前提條件就是管程裏的條件變量。 若是線程 T1 進入管程後剛好發現隊列是空的,那怎麼辦呢?等待啊,去哪裏等呢?就去條件變量對應的等待隊列裏面等。此時線程 T1 就去「隊列不空」這個條件變量的等待隊列中等待。線程 T1 進入條件變量的等待隊列後,是容許其餘線程進入管程的。

再假設以後另一個線程 T2 執行入隊操做,入隊操做執行成功以後,「隊列不空」這個條件對於線程 T1 來講已經知足了,此時線程 T2 要通知 T1,告訴它須要的條件已經知足了。當線程 T1 獲得通知後,會從等待隊列裏面出來,可是出來以後不是立刻執行,而是從新進入到入口等待隊列裏面。

條件變量及其等待隊列咱們講清楚了,下面再說說 wait()、notify()、notifyAll() 這三個操做。前面提到線程 T1 發現「隊列不空」這個條件不知足,須要進到對應的等待隊列裏等待。這個過程就是經過調用 wait() 來實現的。若是咱們用對象 A 表明「隊列不空」這個條件,那麼線程 T1 須要調用 A.wait()。同理當「隊列不空」這個條件知足時,線程 T2 須要調用 A.notify() 來通知 A 等待隊列中的一個線程,此時這個隊列裏面只有線程 T1。至於 notifyAll() 這個方法,它能夠通知等待隊列中的全部線程。

下面的代碼實現的是一個阻塞隊列,阻塞隊列有兩個操做分別是入隊和出隊,這兩個方法都是先獲取互斥鎖,類比管程模型中的入口。

  1. 對於入隊操做,若是隊列已滿,就須要等待直到隊列不滿,因此這裏用了notFull.await();
  2. 對於出隊操做,若是隊列爲空,就須要等待直到隊列不空,因此就用了notEmpty.await();
  3. 若是入隊成功,那麼隊列就不空了,就須要通知條件變量:隊列不空notEmpty對應的等待隊列。
  4. 若是出隊成功,那就隊列就不滿了,就須要通知條件變量:隊列不滿notFull對應的等待隊列。
public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 條件變量:隊列不滿  
  final Condition notFull =
    lock.newCondition();
  // 條件變量:隊列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入隊
  void enq(T x) {
    lock.lock();
    try {
      while (隊列已滿){
        // 等待隊列不滿 
        notFull.await();
      }  
      // 省略入隊操做...
      // 入隊後, 通知可出隊
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出隊
  void deq(){
    lock.lock();
    try {
      while (隊列已空){
        // 等待隊列不空
        notEmpty.await();
      }
      // 省略出隊操做...
      // 出隊後,通知可入隊
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

在這段示例代碼中,咱們用了 Java 併發包裏面的 Lock 和 Condition,這個例子只是先讓你明白條件變量及其等待隊列是怎麼回事。

注意這裏只是舉個例子,這裏的行爲只是跟 管程相似,但並非具體實現,這裏拿來舉個例子。

wait() 的正確姿式

可是有一點,須要再次提醒,對於 MESA 管程來講,有一個編程範式,就是須要在一個 while 循環裏面調用 wait()。這個是 MESA 管程特有的

while(條件不知足) {
  wait();
}

Hasen 模型、Hoare 模型和 MESA 模型的一個核心區別就是當條件知足後,如何通知相關線程。管程要求同一時刻只容許一個線程執行,那當線程 T2 的操做使線程 T1 等待的條件知足時,T1 和 T2 究竟誰能夠執行呢?

  1. Hasen 模型裏面,要求 notify() 放在代碼的最後,這樣 T2 通知完 T1 後,T2 就結束了,而後 T1 再執行,這樣就能保證同一時刻只有一個線程執行。
  2. Hoare 模型裏面,T2 通知完 T1 後,T2 阻塞,T1 立刻執行;等 T1 執行完,再喚醒 T2,也能保證同一時刻只有一個線程執行。可是相比 Hasen 模型,T2 多了一次阻塞喚醒操做。
  3. MESA 管程裏面,T2 通知完 T1 後,T2 仍是會接着執行,T1 並不當即執行,僅僅是從條件變量的等待隊列進到入口等待隊列裏面。這樣作的好處是 notify() 不用放到代碼的最後,T2 也沒有多餘的阻塞喚醒操做。可是也有個反作用,就是當 T1 再次執行的時候,可能曾經知足的條件,如今已經不知足了,因此須要以循環方式檢驗條件變量。

notify() 什麼時候可使用

還有一個須要注意的地方,就是 notify() 和 notifyAll() 的使用,前面章節,我曾經介紹過,
除非通過深思熟慮,不然儘可能使用 notifyAll()。那何時可使用 notify() 呢?
須要知足如下三個條件:

  1. 全部等待線程擁有相同的等待條件;
  2. 全部等待線程被喚醒後,執行相同的操做;
  3. 只須要喚醒一個線程。

好比上面阻塞隊列的例子中,對於「隊列不滿」這個條件變量,其阻塞隊列裏的線程都是在等待「隊列不滿」這個條件,反映在代碼裏就是下面這 3 行代碼。對全部等待線程來講,都是執行這 3 行代碼,重點是 while 裏面的等待條件是徹底相同的

while (隊列已滿){
  // 等待隊列不滿
  notFull.await();
}

全部等待線程被喚醒後執行的操做也是相同的,都是下面這幾行:

// 省略入隊操做...
// 入隊後, 通知可出隊
notEmpty.signal();

同時也知足第 3 條,只須要喚醒一個線程。因此上面阻塞隊列的代碼,使用 signal() 是能夠的。

總結

Java 參考了 MESA 模型,語言內置的管程(synchronized)對 MESA 模型進行了精簡。MESA 模型中,條件變量能夠有多個,Java 語言內置的管程裏只有一個條件變量。具體以下圖所示。

圖片描述

Java 內置的管程方案(synchronized)使用簡單,synchronized 關鍵字修飾的代碼塊,在編譯期會自動生成相關加鎖和解鎖的代碼,可是僅支持一個條件變量;而 Java SDK 併發包實現的管程支持多個條件變量,不過併發包裏的鎖,須要開發人員本身進行加鎖和解鎖操做。

併發編程裏兩大核心問題——互斥和同步,均可以由管程來幫你解決。學好管程,理論上全部的併發問題你均可以解決,而且不少併發工具類底層都是管程實現的,因此學好管程,就是至關於掌握了一把併發編程的萬能鑰匙。

相關文章
相關標籤/搜索