Java 併發機制底層實現 —— volatile 原理、synchronize 鎖優化機制

低吟不作語發表於2021-02-13

本書部分摘自《Java 併發程式設計的藝術》


概述

相信大家都很熟悉如何使用 Java 編寫處理併發的程式碼,也知道 Java 程式碼在編譯後變成 Class 位元組碼,位元組碼被類載入器載入到 JVM 裡,JVM 執行位元組碼,最終需要轉化為彙編指令在 CPU 上執行。因此,Java 中所使用的併發機制其實是依賴於 JVM 的實現和 CPU 的指令,所以瞭解 Java 併發機制的底層實現原理也是很有必要的


volatile 的應用

volatile 在多處理器開發中保證了共享變數的可見性。可見性的意思是當一個執行緒修改一個共享變數時,另外一個執行緒能立即讀取到修改過後的值

1. volatile 的定義

Java 語言規範第三版對 volatile 的定義如下:Java 程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致地更新,執行緒應該確保通過排它鎖單獨獲得這個變數。排它鎖可以使用 synchronized 實現,但 Java 提供了 volatile,在某些情況下比鎖更加方便。如果一個欄位被宣告成 volatile,Java 執行緒記憶體模型將確保所有執行緒看到這個變數的值是一致的

2. volatile 的實現原理

在 Java 中我們可以直接使用 volatile 關鍵字,但它的底層是怎麼實現的呢?被 volatile 變數修飾的共享變數進行寫操作的時候會多生成一行彙編程式碼,這行程式碼使用了 Lock 指令。Lock 指令在多核處理器下會引發兩件事情:

  • 將當前處理器快取行的資料寫回到系統記憶體
  • 這個寫回記憶體的操作會使在其他 CPU 裡快取了該記憶體地址的資料無效

為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取後再進行操作,但操作完後不知道何時會寫到記憶體。如果對宣告瞭 volatile 的變數進行寫操作,JVM 就會向處理器傳送一條 Lock 字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。但其他處理器的快取還是舊值,為了保證各個處理器的快取是一致的,每個處理器會通過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是過期了。當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效狀態,當處理器對這個資料進行修改操作時,會重新從系統記憶體中把資料讀到處理器快取裡


synchronized 的應用

在多執行緒併發程式設計中 synchronized 一直是元老級角色,很多人稱呼它為重量級鎖。不過,隨著 JavaSE 1.6 對 synchronized 進行了各種優化之後,有些情況下它就並不那麼重了

Java 中的每一個物件都可以作為鎖,具體表現為以下三種形式:

  • 對於普通同步方法,鎖是當前例項物件
  • 對於靜態同步方法,鎖是當前類的 Class 物件
  • 對於同步方法塊,鎖是 Synchronized 括號裡配置的物件

1. synchronized 原理

JVM 基於進入和退出 Monitor 物件來實現方法同步和程式碼塊同步,但兩者的實現細節不一樣。程式碼塊同步是使用 monitorenter 和 monitorexit 指令實現,而方法同步是使用另外一種方式實現,細節在 JVM 規範裡並沒有詳細說明,但方法的同步同樣可以使用這兩個指令來實現

monitorenter 指令是在編譯後插入到同步程式碼塊的開始位置,而 monitorexit 是插入到方法結束處和異常處,JVM 要保證每個 monitorenter 必須有對應的 monitorexit 與之配對。任何物件都有一個 monitor 與之相關聯,當且一個 monitor 被持有後,它將處於鎖定狀態。執行緒執行到 monitorenter 指令時,將會嘗試獲取物件所對應的 monitor 的所有權,即嘗試獲得物件的鎖

2. 鎖的升級

JavaSE 1.6 為了減少獲得鎖和釋放鎖帶來的效能消耗,引入了偏向鎖和輕量級鎖,因此在 JavaSE 1.6 中,鎖一共有四種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。這幾個狀態會隨著競爭情況逐漸升級,鎖可以升級但不能降級,這是為了提高獲得鎖和釋放鎖的效率

3. 偏向鎖

研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得。偏向鎖,顧名思義,它會偏向於第一個訪問鎖的執行緒,如果在執行過程中,只有一個執行緒訪問,不存在多執行緒爭用的情況,就會給執行緒加一個偏向鎖,執行緒不需要觸發同步就能獲得鎖,降低獲得鎖的代價

  • 偏向鎖的獲取

    當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧幀中的鎖記錄裡儲存鎖偏向的執行緒 ID,以後該執行緒在進入和退出同步塊時不需要進行 CAS 操作來加鎖和解鎖,只需測試一下物件頭的 Mark Word 是否儲存著指向當前執行緒的偏向鎖。如果測試成功,表示執行緒已經獲得鎖,否則再測試一下 Mark Work 中偏向鎖的標識是否設定成 1(表示當前是偏向鎖),如果沒有設定,使用 CAS 競爭鎖,否則嘗試使用 CAS 將物件頭的偏向鎖指向當前執行緒

  • 偏向鎖的撤銷

    偏向鎖只有遇到其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖,執行緒不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有位元組碼正在執行)。它首先會暫停擁有偏向鎖的執行緒,判斷持有偏向鎖的執行緒是否活動,如果執行緒不處於活動狀態,則將物件頭設定成無鎖狀態;如果物件仍然活著,撤銷偏向鎖後恢復到未鎖定或輕量級鎖的狀態

  • 關閉偏向鎖

    偏向鎖在 Java6 和 Java7 裡是預設開啟的,但是它在應用程式啟動幾秒之後才啟用,如有必要可以使用 JVM 引數來關閉延遲:-XX:BiasedLockingStartupDelay = 0。如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過 JVM 引數來關閉偏向鎖:-XX:-UseBiasedLocking = false,那麼程式預設會進入輕量級鎖狀態

下圖是偏向鎖的獲得和撤銷流程

4. 輕量級鎖

傳統的重量級鎖效能往往不如人意,因為 monitorenter 與 monitorexit 這兩個控制多執行緒同步的 bytecode 原語,是 JVM 依賴作業系統的互斥量來實現的。互斥是一種會導致執行緒掛起,並在較短的時間內又需要重新排程回原執行緒的,較為消耗資源的操作,為了優化效能,從 Java6 開始引入了輕量級鎖的概念。輕量級鎖本意是為了減少多執行緒進入互斥的機率,並不是要替代互斥,它利用了 CPU 原語 Compare-And-Swap(CAS),嘗試在進入互斥前,進行補救

  • 輕量級鎖加鎖

    執行緒在執行同步塊之前,JVM 先在當前執行緒的棧幀中建立用於儲存鎖記錄的空間,並將物件頭中的 Mark Word 複製到鎖記錄中,官方稱為 Displaced Mark Word

    然後執行緒嘗試使用 CAS 將物件頭中的 Mark Word 替換為指向鎖記錄的指標,如果成功,當前執行緒獲得鎖,否則表示其他執行緒競爭鎖,當前執行緒嘗試使用自旋來獲取鎖

  • 輕量級鎖解鎖

    輕量級鎖解鎖時,執行緒會使用原子的 CAS 操作將 Dispatch Mark Word 替換回到物件頭,如果成功,表示沒有競爭發生;如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖

下圖是輕量級鎖及膨脹流程圖

因為自旋會消耗 CPU,為了避免無用的自旋,一旦鎖升級為重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他執行緒試圖獲取鎖時,都會被阻塞,當持有鎖的執行緒釋放鎖之後會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的奪鎖之爭

5. 鎖的優缺點對比

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 如果執行緒間存在鎖競爭,會帶來額外的鎖賒銷的消耗 適用於只有一個執行緒訪問同步塊場景
輕量級鎖 競爭的執行緒不會阻塞,提高了程式的響應速度 如果始終得不到鎖競爭的執行緒,使用自旋會消耗 CPU 追求響應時間,同步塊執行速度非常快
重量級鎖 執行緒競爭不使用自旋,不會消耗 CPU 執行緒阻塞,響應時間緩慢 追求吞吐量,同步塊執行速度較長

相關文章