鎖原理 - 信號量 vs 管程:JDK 為什么選擇管程
並發編程之美系列目錄:https://www.cnblogs.com/binarylei/p/9569428.html
管程和信號量都能解決並發問題,它們是等價的。所謂等價指的是用管程能夠實現信號量,也能用信號量實現管程。但是管程在信號量的基礎上提供條件同步,使用更容易,所以 Java 采用的是管程技術。synchronized 關鍵字及 wait()、notify()、notifyAll() 這三個方法都是管程的組成部分。
1. 並發編程解決方案 - 信號量 vs 管程
1.1 相關概念
- 臨界資源:雖然多個進程可以共享系統中的各種資源,但其中許多資源一次只能為一個進程所使用,我們把一次僅允許一個進程使用的資源稱為臨界資源。許多物理設備都屬於臨界資源,如打印機等。此外,還有許多變量、數據等都可以被若干進程共享,也屬於臨界資源。
- 臨界區:對臨界資源的訪問,必須互斥地進行,在每個進程中,訪問臨界資源的那段代碼稱為臨界區。
- 互斥:只有一個線程能訪問臨界區。
1.2 信號量 vs 管程
並發編程這個技術領域已經發展了半個世紀了,相關的理論和技術紛繁復雜。那有沒有一種核心技術可以很方便地解決我們的並發問題呢?事實上,鎖機制的實現方案有兩種:
- 信號量(Semaphere):操作系統提供的一種協調共享資源訪問的方法。和用軟件實現的同步比較,軟件同步是平等線程間的的一種同步協商機制,不能保證原子性。而信號量則由操作系統進行管理,地位高於進程,操作系統保證信號量的原子性。
- 管程(Monitor):解決信號量在臨界區的 PV 操作上的配對的麻煩,把配對的 PV 操作集中在一起,生成的一種並發編程方法。其中使用了條件變量這種同步機制。

說明: 信號量將共享變量 S 封裝起來,對共享變量 S 的所有操作都只能通過 PV 進行,這是不是和面向對象的思想是不是很像呢?事實上,封裝共享變量是並發編程的常用手段。
在信號量中,當 P 操作無法獲取到鎖時,將當前線程添加到同步隊列(syncQueue)中。當其余線程 V 釋放鎖時,從同步隊列中喚醒等待線程。但當有多個線程通過信號量 PV 配對時會異常復雜,所以管程中引入了等待隊列(waitQueue)的概念,進一步封裝這些復雜的操作。
2. 信號量(Semaphere)
2.1 原理
信號中包括一個整形變量,和兩個原子操作 P 和 V。其原子性由操作系統保證,這個整形變量只能通過 P 操作和 V 操作改變。
- 信號量由一個整形變量 S 和兩個原子操作 PV 組成。
- P(Prolaag,荷蘭語嘗試減少):信號量值減 1,如果信號量值小於 0,則說明資源不夠用的,把進程加入等待隊列。
- V (Verhoog,荷蘭語增加):信號量值加 1,如果信號量值小於等於 0,則說明等待隊列里有進程,那么喚醒一個等待進程。

說明: 共享變量 S 只能由 PV 操作,PV 的原子性由操作系統保證。P 相當獲取鎖,可能會阻塞線程,而 V 相當於釋放鎖,不會阻塞線程。根據同步隊列中喚醒線程的先后順序,可以分為公平和非公平兩種。
信號量分類:
- 二進制信號量:資源數目為 0 或 1。
- 資源信號量:資源數目為任何非負值。
2.2 代碼實現
private class Semaphore {
private int sem;
private WaitQueue q;
void P() {
sem--;
if (sem < 0) {
// add this thread t to q;
block(t);
}
}
void V() {
sem++;
if (sem <= 0) {
// remove a thread t from q;
wakeup(t);
}
}
}
說明: Semaphere 的思路很簡單,就是將共享變量 S 及其所有操作 PV 統一封裝起來。事實上,封裝共享變量是並發編程的常用手段。
2.3 使用場景
2.3.1 互斥訪問
Semaphore mutex = new Semaphore(1);
mutex.P();
// do something
mutex.V();
實現臨界區的互斥訪問注意事項: 一是信號量的初始值必須為 1;二是 PV 必須配對使用。
2.3.2 條件訪問
Semaphore condition = new Semaphore(0);
// ThreadA,進行等待隊列中
condition.P();
// ThreadB,喚醒等待線程 ThreadA
condition.V();
實現臨界區的條件訪問注意事項: 初始信號量必須為 0,這樣所有的線程調用 P 操作時都無法獲取到鎖,只能進行等待隊列(相當於管程中的等待隊列),當其余線程 B 調用 V 操作時會喚醒等待線程。
2.3.3 阻塞隊列
阻塞隊列是典型的生產者-消費者模式,任何時刻只能有一個生產者線程或消費都線程訪問緩沖區。並且當緩沖區滿時,生產者線程必須等待,反之消費者線程必須等待。
- 任何時刻只能有一個線程操作緩存區:互斥訪問,使用二進制信號量 mutex,其信號初始值為 1。
- 緩存區空時,消費者必須等待生產者:條件同步,使用資源信號量 notEmpty,其信號初始值為 0。
- 緩存區滿時,生產者必須等待消費者:條件同步,使用資源信號量 notFull,其信號初始值為 n。
private class BoundedBuffer {
private int n = 100;
private Semaphore mutex = new Semaphore(1);
private Semaphore notFull = new Semaphore(n);
private Semaphore notEmpty = new Semaphore(0);
public void product() throws InterruptedException {
notFull.P(); // 緩沖區滿時,生產者線程必須等待
mutex.P();
// ...
mutex.V();
notEmpty.V(); // 喚醒等待的消費者線程
}
public void consume() throws InterruptedException {
notEmpty.P(); // 緩沖區空時,消費都線程等待
mutex.P();
// ...
mutex.V();
notFull.V(); // 喚醒等待的生產者線程
}
}
總結: 直接使用信號量,當多個 Semaphore 條件同步時,PV 配對比較困難而且容易寫錯。為了解決 PV 配對困難的問題,管程登場了。管程實際上是對條件同步的進一步封裝。
2. 管程(Monitor)
Monitor 直譯過來就是 "監視器",操作系統領域一般都翻譯成 "管程"。所謂管程,指的是管理共享變量以及對共享變量的操作過程,讓他們支持並發。翻譯為 Java 領域的語言,就是管理類的成員變量和成員方法,讓這個類是線程安全的。那管程是怎么管的呢?
2.1 MESA 模型
在管程的發展史上,先后出現過三種不同的管程模型,分別是:Hasen 模型、Hoare 模型和 MESA 模型。其中,現在廣泛應用的是 MESA 模型,並且 Java 管程的實現參考的也是 MESA 模型。所以今天我們重點介紹一下 MESA 模型。
在並發編程領域,有兩大核心問題:一個是互斥,即同一時刻只允許一個線程訪問共享資源;另一個是同步,即線程之間如何通信、協作。這兩大問題,管程都是能夠解決的。
2.2 互斥
管程和信號量關於互斥的實現完全一樣,都是將共享變量及其操作統一封裝起來。
2.3 同步
在上述用信號量實現生產者-消費者模式的代碼中,為了實現阻塞隊列的功能,即等待-通知(wait-notify),除了使用互斥鎖 mutex 外,還需要兩個判斷隊滿和隊空的資源信號量 fullBuffers 和 emptyBuffers,使用起來不僅復雜,還容易出錯。
管程在信號量的基礎上,更進一步,增加了條件同步,將上述復雜的操作封起來。

JUC AQS 也是基於管程實現的,我們基於 ReentrantLock 實現一個阻塞隊列,重點比較和信號量的區別。阻塞隊列有兩個操作分別是入隊和出隊,這兩個方法都是先獲取互斥鎖,類比管程模型中的入口。
- 對於入隊操作,如果隊列已滿,就需要等待直到隊列不滿,即 notFull.await();。
- 對於出隊操作,如果隊列為空,就需要等待直到隊列不空,即 notEmpty.await();。
- 如果入隊成功,那么隊列就不空了,就需要通知條件變量:隊列不空 notEmpty 對應的等待隊列。
- 如果出隊成功,那就隊列就不滿了,就需要通知條件變量:隊列不滿 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();
}
// add x to queue
// 入隊后,通知可出隊
notEmpty.signal();
} finally {
lock.unlock();
}
}
// 出隊
void deq() {
lock.lock();
try {
while (隊列已空) {
// 等待隊列不空
notEmpty.await();
}
// remove the first element from queue
// 出隊后,通知可入隊
notFull.signal();
} finally {
lock.unlock();
}
}
}
總結: 對於用信號量實現阻塞隊列,是不是感覺要簡單些。這里的 notFull 相當於之前的 fullBuffers,notEmpty 相當於之前的 emptyBuffers。
2.4 wait() 的正確姿勢
對於 MESA 管程來說,有一個編程范式,就是需要在一個 while 循環里面調用 wait()。這個是 MESA 管程特有的。所謂范式,就是前人總結的經驗。
while (條件不滿足) {
wait();
}
Hasen 模型、Hoare 模型和 MESA 模型的一個核心區別就是當條件滿足后,如何通知相關線程。管程要求同一時刻只允許一個線程執行,那當線程 T2 的操作使線程 T1 等待的條件滿足時,T1 和 T2 究竟誰可以執行呢?
- Hasen 模型里面,要求 notify() 放在代碼的最后,這樣 T2 通知完 T1 后,T2 就結束了,然后 T1 再執行,這樣就能保證同一時刻只有一個線程執行。
- Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 馬上執行;等 T1 執行完,再喚醒 T2,也能保證同一時刻只有一個線程執行。但是相比 Hasen 模型,T2 多了一次阻塞喚醒操作。
- MESA 管程里面,T2 通知完 T1 后,T2 還是會接着執行,T1 並不立即執行,僅僅是從條件變量的等待隊列進到入口等待隊列里面。這樣做的好處是 notify() 不用放到代碼的最后,T2 也沒有多余的阻塞喚醒操作。但是也有個副作用,就是當 T1 再次執行的時候,可能曾經滿足的條件現在已經不滿足了,所以需要以循環方式檢驗條件變量。
思考1:wait() 方法,在 Hasen 模型和 Hoare 模型里面,都是沒有參數的,而在 MESA 模型里面,增加了超時參數,你覺得這個參數有必要嗎?
有必要。Hasen 是執行完再去喚醒另外一個線程,能夠保證線程的執行。Hoare 是中斷當前線程,喚醒另外一個線程,執行玩再去喚醒,也能夠保證完成。而 MESA 是進入等待隊列,不一定有機會能夠執行,產生飢餓現象。
2.5 notify() 何時可以使用
除非經過深思熟慮,否則盡量使用 notifyAll(),不要使用 notify()。
那什么時候可以使用 notify() 呢?需要滿足以下三個條件:
- 所有等待線程擁有相同的等待條件;
- 所有等待線程被喚醒后,執行相同的操作;
- 只需要喚醒一個線程。
比如上面阻塞隊列的例子中,對於“隊列不滿”這個條件變量,其阻塞隊列里的線程都是在等待“隊列不滿”這個條件,反映在代碼里就是下面這 3 行代碼。對所有等待線程來說,都是執行這 3 行代碼,重點是 while 里面的等待條件是完全相同的。
2.6 AQS 和 synchronized 原理
JUC AQS 就是基於管程實現的,內部包含兩個隊列,一個是同步隊列,一個是等待隊列:
- 同步隊列:鎖被占用時,會將該線程添加到同步隊列中。當鎖釋放后,會從隊列中喚醒一個線程,又分為公平和非公平兩種。
- 等待隊列:當調用 await 是,會將該線程添加到等待隊列中。當其它線程調用 notify 時,會將該線程從等待隊列移動到同步隊列中,重新競爭鎖。
synchronized 也是基於管程實現的,核心的數據結構見 ObjectMonitor。AQS 和 synchronized 都是管程 MESA 模型在 Java 中的應用。一切都套路,有章可循。
參考:
每天用心記錄一點點。內容也許不重要,但習慣很重要!