共享模型之管程(悲觀鎖)
文章目錄
- 共享模型之管程(悲觀鎖)
- 一、常見線程安全的類
- 二、對象頭
- 三、Monitor(監視器 / 管程)
- 四、偏向鎖
- 偏向鎖的實現原理
- 撤銷偏向鎖
- 五、輕量級鎖
- 輕量級鎖的釋放
- 六、重量級鎖
- 七、鎖的升級流程
- 八、sleep / wait / park
- sleep
- wait
- park
- 九、多把鎖相關
- 十、ReentrantLock
一、常見線程安全的類
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的類
他們的每個方法是原子的,但多個方法的組合不是原子的!
二、對象頭
-
普通對象頭
Mark Word 用來存儲對象的 hashCode 或者鎖信息等。
Klass Word 存儲到對象類型數據的指針 -
數組對象頭
Array length 存儲了數組的長度 -
其中32位 Mark Word 的結構為
-
其中64位 Mark Word 的結構為
從上到下對應的是無鎖、偏向鎖、輕量級鎖、重量級鎖以及GC標志。
可以看到,當對象狀態為偏向鎖時,Mark Word 存儲的是偏向的線程 ID;
當狀態為輕量級鎖時,Mark Word 存儲的是指向線程棧中 Lock Record 的指針;
當狀態為重量級鎖時,Mark Word 為指向堆中的 monitor(監視器)對象的指針。
三、Monitor(監視器 / 管程)
在 Java 中,監視器(monitor)是一種同步工具,用于保護共享數據,避免多線程并發訪問導致數據不一致。在 Java 中,每個對象都有一個內置的監視器。
監視器包括兩個重要部分,一個是鎖,一個是等待/通知機制,后者是通過 Object 類中的wait(), notify(), notifyAll()等方法實現的。
剛開始 Monitor 中 Owner 為 null,當 Thread-2 執行 synchronized(obj) 就會將 Monitor 的所有者 Owner 置為 Thread-2,Monitor中只能有一個 Owner。
在 Thread-2 上鎖的過程中,如果 Thread-3,Thread-4,Thread-5 也來執行 synchronized(obj),就會進入EntryList BLOCKED,Thread-2 執行完同步代碼塊的內容,然后喚醒 EntryList 中等待的線程來競爭鎖,競爭的時是非公平的。
Owner 線程發現條件不滿足,調用 wait 方法,即可進入 WaitSet 變為 WAITING 狀態,BLOCKED 和 WAITING 的線程都處于阻塞狀態,不占用 CPU 時間片,BLOCKED 線程會在 Owner 線程釋放鎖時喚醒,WAITING 線程會在 Owner 線程調用 notify 或 notifyAll 時喚醒,但喚醒后并不意味者立刻獲得鎖,仍需進入EntryList 重新競爭。
四、偏向鎖
Hotspot 的作者經過以往的研究發現大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,于是引入了偏向鎖。
偏向鎖會偏向于第一個訪問鎖的線程,如果在接下來的運行過程中,該鎖沒有被其他的線程訪問,則持有偏向鎖的線程將永遠不需要觸發同步。也就是說,偏向鎖在資源無競爭情況下消除了同步語句,連 CAS(后面會細講,戳鏈接直達) 操作都不做了,著極大地提高了程序的運行性能。
大白話就是對鎖設置個變量,如果發現為 true,代表資源無競爭,則無需再走各種加鎖/解鎖流程。如果為 false,代表存在其他線程競爭資源,那么就會走后面的流程。
偏向鎖的實現原理
一個線程在第一次進入同步塊時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程 ID。當下次該線程進入這個同步塊時,會去檢查鎖的 Mark Word 里面是不是放的自己的線程 ID。如果是,表明該線程已經獲得了鎖,以后該線程在進入和退出同步塊時不需要花費 CAS 操作來加鎖和解鎖(對比來說輕量級鎖每次都需要生成鎖記錄,然后用鎖記錄替換 markword );如果不是,就代表有另一個線程來競爭這個偏向鎖。這個時候會嘗試使用 CAS 來替換 Mark Word 里面的線程 ID 為新線程的 ID,這個時候要分兩種情況:
成功,表示之前的線程不存在了, Mark Word 里面的線程 ID 為新線程的 ID,鎖不會升級,仍然為偏向鎖;
失敗,表示之前的線程仍然存在,那么暫停之前的線程,設置偏向鎖標識為 0,并設置鎖標志位為 00,升級為輕量級鎖,會按照輕量級鎖的方式進行競爭鎖。
CAS 是比較并設置的意思,用于在硬件層面上提供原子性操作。在 在某些處理器架構(如x86)中,比較并交換通過指令 CMPXCHG 實現((Compare and Exchange),一種原子指令),通過比較是否和給定的數值一致,如果一致則修改,不一致則不修改。
圖中涉及到了 lock record 指針指向當前堆棧中的最近一個 lock record,是輕量級鎖按照先來先服務的模式進行了輕量級鎖的加鎖。
撤銷偏向鎖
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。
偏向鎖升級成輕量級鎖時,會暫停擁有偏向鎖的線程,重置偏向鎖標識,這個過程看起來容易,實則開銷還是很大的,大概的過程如下:
在一個安全點(在這個時間點上沒有字節碼正在執行)停止擁有鎖的線程。
遍歷線程棧,如果存在鎖記錄的話,需要修復鎖記錄和 Mark Word,使其變成無鎖狀態。
喚醒被停止的線程,將當前鎖升級成輕量級鎖。
所以,如果應用程序里所有的鎖通常處于競爭狀態,那么偏向鎖就會是一種累贅,對于這種情況,我們可以一開始就把偏向鎖這個默認功能給關閉。
調用對象的 hashCode 函數時會生成 hashCode,但對于偏向鎖 Mark Word 里記錄的是線程 id,沒地方放哈希值,所以會撤銷偏向鎖。輕量級鎖在鎖記錄里記錄 hashCode,重量級鎖會在 Monitor 中記錄 hashCode。
調用 wait() 或 notify() 方法會觸發偏向鎖的撤銷,并升級為重量級鎖。這是因為 wait() 和 notify() 引入了線程間的競爭和同步機制,而偏向鎖無法應對這種場景。
五、輕量級鎖
多個線程在不同時段獲取同一把鎖,即不存在鎖競爭的情況,也就沒有線程阻塞。針對這種情況,JVM 采用輕量級鎖來避免線程的阻塞與喚醒。
JVM 會為每個線程在當前線程的棧幀中創建用于存儲鎖記錄的空間,我們稱為 Displaced Mark Word。如果一個線程獲得鎖的時候發現是輕量級鎖,會把鎖的 Mark Word 復制到自己的 Displaced Mark Word 里面。
然后線程嘗試用 CAS 將鎖的 Mark Word 替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示 Mark Word 已經被替換成了其他線程的鎖記錄,說明在與其它線程競爭鎖,當前線程就嘗試使用自旋來獲取鎖。
自旋:不斷嘗試去獲取鎖,一般用循環來實現。
自旋是需要消耗 CPU 的,如果一直獲取不到鎖的話,那該線程就一直處在自旋狀態,白白浪費 CPU 資源。解決這個問題最簡單的辦法就是指定自旋的次數,例如讓其循環 10 次,如果還沒獲取到鎖就進入阻塞狀態。
但是 JDK 采用了更聰明的方式——適應性自旋,簡單來說就是線程如果自旋成功了,則下次自旋的次數會更多,如果自旋失敗了,則自旋的次數就會減少。
自旋也不是一直進行下去的,如果自旋到一定程度(和 JVM、操作系統相關),依然沒有獲取到鎖,稱為自旋失敗,那么這個線程會阻塞。同時這個鎖就會升級成重量級鎖。
輕量級鎖的釋放
在釋放鎖時,當前線程會使用 CAS 操作將 Displaced Mark Word 的內容復制回鎖的 Mark Word 里面。如果沒有發生競爭,那么這個復制的操作會成功。如果有其他線程因為自旋多次導致輕量級鎖升級成了重量級鎖,那么 CAS 操作會失敗,此時會釋放鎖并喚醒被阻塞的線程。
六、重量級鎖
重量級鎖依賴于操作系統的互斥鎖(mutex,用于保證任何給定時間內,只有一個線程可以執行某一段特定的代碼段) 實現,而操作系統中線程間狀態的轉換需要相對較長的時間,所以重量級鎖效率很低,但被阻塞的線程不會消耗 CPU。
每一個對象都可以當做一個鎖,當多個線程同時請求某個對象鎖時,對象鎖會設置幾種狀態用來區分請求的線程:
Contention List:所有請求鎖的線程將被首先放置到該競爭隊列
Entry List:Contention List 中那些有資格成為候選人的線程被移到 Entry List
Wait Set:那些調用 wait 方法被阻塞的線程被放置到 Wait Set
OnDeck:任何時刻最多只能有一個線程正在競爭鎖,該線程稱為 OnDeck
Owner:獲得鎖的線程稱為 Owner
!Owner:釋放鎖的線程
當一個線程嘗試獲得鎖時,如果該鎖已經被占用,則會將該線程封裝成一個ObjectWaiter對象插入到 Contention List 隊列的隊首,然后調用park 方法掛起當前線程。
當線程釋放鎖時,會從 Contention List 或 EntryList 中挑選一個線程喚醒,被選中的線程叫做Heir presumptive即假定繼承人,假定繼承人被喚醒后會嘗試獲得鎖,但synchronized是非公平的,所以假定繼承人不一定能獲得鎖。
這是因為對于重量級鎖,如果線程嘗試獲取鎖失敗,它會直接進入阻塞狀態,等待操作系統的調度。
如果線程獲得鎖后調用Object.wait方法,則會將線程加入到 WaitSet 中,當被Object.notify喚醒后,會將線程從 WaitSet 移動到 Contention List 或 EntryList 中去。需要注意的是,當調用一個鎖對象的wait或notify方法時,如當前鎖的狀態是偏向鎖或輕量級鎖則會先膨脹成重量級鎖。
七、鎖的升級流程
每一個線程在準備獲取共享資源時: 第一步,檢查 MarkWord 里面是不是放的自己的 ThreadId ,如果是,表示當前線程是處于 “偏向鎖” 。
第二步,如果 MarkWord 不是自己的 ThreadId,鎖升級,這時候,用 CAS 來執行切換,新的線程根據 MarkWord 里面現有的 ThreadId,通知之前線程暫停,之前線程將 Markword 的內容置為空。
第三步,兩個線程都把鎖對象的 HashCode 復制到自己新建的用于存儲鎖的記錄空間,接著開始通過 CAS 操作, 把鎖對象的 Markword 的內容修改為自己新建的記錄空間的地址的方式競爭 MarkWord。
第四步,第三步中成功執行 CAS 的獲得資源,失敗的則進入自旋 。
第五步,自旋的線程在自旋過程中,成功獲得資源(即之前獲的資源的線程執行完成并釋放了共享資源),則整個狀態依然處于 輕量級鎖的狀態,如果自旋失敗 。
第六步,進入重量級鎖的狀態,這個時候,自旋的線程進行阻塞,等待之前線程執行完成并喚醒自己。
八、sleep / wait / park
sleep
sleep 是 Thread 類的靜態方法,它的作用是讓當前線程暫停執行一段指定的時間(毫秒或納秒),到時間后自動恢復,無需外部干預,但暫停期間不會釋放持有的鎖。
wait
wait 是Object類的實例方法,它的作用是讓當前線程暫停執行,進入waitSet等待,并釋放持有的鎖,直到其他線程調用 notify() 或 notifyAll() 喚醒它。
與sleep不同的是它會釋放鎖,并且需要 synchronized 配合,需要外部喚醒。
park
park 是 LockSupport 類的靜態方法,它的作用是暫停線程的執行,直到其他線程調用 unpark() 或線程被中斷,暫停期間不會釋放持有的鎖。
它不需要配合 synchronized,也不會釋放鎖資源,unpark() 會提前提供一個許可證,下次 park 時不會進入阻塞。
九、多把鎖相關
多把鎖的優勢是可以增加并發度,但是如果一個線程需要多把鎖就容易發生死鎖,例如哲學家就餐問題。死鎖屬于活躍性問題,除了死鎖還有活鎖和饑餓兩種情況。活鎖是兩個線程互為對方的結束條件而無法結束,饑餓則是一個線程的優先級太低,始終無法得到CPU的調度,拿不到鎖。
十、ReentrantLock
可重入是指同一個線程如果首次獲得了這把鎖,那么因為它是這把鎖的擁有者,因此有權利再次獲取這把鎖。如果是不可重入鎖,那么第二次獲得鎖時,自己也會被鎖擋住。
- 可重入鎖的等待期間可以被 interrupt 打斷。
- 獲取鎖超時會立即失敗。
- ReentrantLock 默認是不公平的,也支持公平模式。
- ReentrantLock 支持多個條件變量(多間休息室)。
- 內部維護持有計數,記錄鎖被同一線程獲取的次數,確保完全釋放。