­

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

本書部分摘自《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 線程阻塞,響應時間緩慢 追求吞吐量,同步塊執行速度較長