一文看懂Java鎖機制

zybing發表於2021-09-09

背景知識

指令流水線

CPU的基本工作是執行儲存的指令序列,即程式。程式的執行過程實際上是不斷地取出指令、分析指令、執行指令的過程。

幾乎所有的馮•諾伊曼型計算機的CPU,其工作都可以分為5個階段:取指令、指令譯碼、執行指令、訪存取數和結果寫回。
圖片描述
現代處理器的體系結構中,採用了流水線的處理方式對指令進行處理。指令包含了很多階段,對其進行拆解,每個階段由專門的硬體電路、暫存器來處 理,就可以實現流水線處理。實現更高的CPU吞吐量,但是由於流水線處理本身的額外開銷,可能會增加延遲。

cpu多級快取
圖片描述

在計算機系統中,CPU快取記憶體(CPU Cache,簡稱快取)是用於減少處理器訪問記憶體所需平均時間的部件。在金字塔式儲存體系中它位於自頂向下的第二層,僅次於CPU暫存器。其容量遠小於記憶體,但速度卻可以接近處理器的頻率。

當處理器發出記憶體訪問請求時,會先檢視快取內是否有請求資料。如果存在(命中),則不經訪問記憶體直接返回該資料;如果不存在(失效),則要先把記憶體中的相應資料載入快取,再將其返回處理器。

快取之所以有效,主要是因為程式執行時對記憶體的訪問呈現區域性性(Locality)特徵。這種區域性性既包括空間區域性性(Spatial Locality),也包括時間區域性性(Temporal Locality)。有效利用這種區域性性,快取可以達到極高的命中率。

問題引入

原子性

原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

示例方法:{i++ (i為例項變數)}

這樣一個簡單語句主要由三個操作組成:

  • 讀取變數i的值
  • 進行加一操作
  • 將新的值賦值給變數i

如果對例項變數i的操作不做額外的控制,那麼多個執行緒同時呼叫,就會出現覆蓋現象,丟失部分更新。

另外,如果再考慮上工作記憶體和主存之間的互動,可細分為以下幾個操作:

  • read 從主存讀取到工作記憶體 (非必須)
  • load 賦值給工作記憶體的變數副本(非必須)
  • use 工作記憶體變數的值傳給執行引擎
  • 執行引擎執行加一操作
  • assign 把從執行引擎接收到的值賦給工作記憶體的變數
  • store 把工作記憶體中的一個變數的值傳遞給主記憶體(非必須)
  • write 把工作記憶體中變數的值寫到主記憶體中的變數(非必須)

可見性

可見性:是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值

存在可見性問題的根本原因是由於快取的存在,執行緒持有的是共享變數的副本,無法感知其他執行緒對於共享變數的更改,導致讀取的值不是最新的。

while (flag) {//語句1
   doSomething();//語句2
}
flag = false;//語句3

執行緒1判斷flag標記,滿足條件則執行語句2;執行緒2flag標記置為false,但由於可見性問題,執行緒1無法感知,就會一直迴圈處理語句2。

順序性

順序性:即程式執行的順序按照程式碼的先後順序執行

由於編譯重排序和指令重排序的存在,是的程式真正執行的順序不一定是跟程式碼的順序一致,這種情況在多執行緒情況下會出現問題。

if (inited == false) { 
   context = loadContext();   //語句1
   inited = true;             //語句2
}
doSomethingwithconfig(context); //語句3

由於語句1和語句2沒有依賴性,語句1和語句2可能 並行執行 或者 語句2先於語句1執行,如果這段程式碼兩個執行緒同時執行,執行緒1執行了語句2,而語句1還沒有執行完,這個時候執行緒2判斷inited為true,則執行語句3,但由於context沒有初始化完成,則會導致出現未知的異常。

JMM記憶體模型

Java虛擬機器規範定義了Java記憶體模型(Java Memory Model,JMM)來遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果(C/C++等則直接使用物理機和OS的記憶體模型,使得程式須針對特定平臺編寫),它在多執行緒的情況下尤其重要。

記憶體劃分

JMM的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。這裡的變數是指共享變數,存在競爭問題的變數,如例項欄位、靜態欄位、陣列物件元素等,不包括執行緒私有的區域性變數、方法引數等,因為私有變數不存在競爭問題。可以認為JMM包括記憶體劃分、變數訪問操作與規則兩部分。
圖片描述
分為主記憶體和工作記憶體,每個執行緒都有自己的工作記憶體,它們共享主記憶體。

  • 主記憶體(Main Memory)儲存所有共享變數的值。
  • 工作記憶體(Working Memory)儲存該執行緒使用到的共享變數在主記憶體的的值的副本複製。

執行緒對共享變數的所有讀寫操作都在自己的工作記憶體中進行,不能直接讀寫主記憶體中的變數。

不同執行緒間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞必須透過主記憶體完成。

這種劃分與Java記憶體區域中堆、棧、方法區等的劃分是不同層次的劃分,兩者基本沒有關係。硬要聯絡的話,大致上主記憶體對應Java堆中物件的例項資料部分、工作記憶體對應棧的部分割槽域;從更低層次上說,主記憶體對應物理硬體記憶體、工作記憶體對應暫存器和快取記憶體。

記憶體間互動規則

關於主記憶體與工作記憶體之間的互動協議,即一個變數如何從主記憶體複製到工作記憶體,如何從工作記憶體同步到主記憶體中的實現細節。Java記憶體模型定義了8種原子操作來完成:
圖片描述

  • lock: 將一個變數標識為被一個執行緒獨佔狀態
  • unclock: 將一個變數從獨佔狀態釋放出來,釋放後的變數才可以被其他執行緒鎖定
  • read: 將一個變數的值從主記憶體傳輸到工作記憶體中,以便隨後的load操作
  • load: 把read操作從主記憶體中得到的變數值放入工作記憶體的變數的副本中
  • use: 把工作記憶體中的一個變數的值傳給執行引擎,每當虛擬機器遇到一個使用到變數的指令時都會使用該指令
  • assign: 把一個從執行引擎接收到的值賦給工作記憶體中的變數,每當虛擬機器遇到一個給變數賦值的指令時,都要使用該操作
  • store: 把工作記憶體中的一個變數的值傳遞給主記憶體,以便隨後的write操作
  • write: 把store操作從工作記憶體中得到的變數的值寫到主記憶體中的變數

定義原子操作的使用規則

1.不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步會主記憶體中

2.一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或者assign)的變數。即就是對一個變數實施use和store操作之前,必須先自行assign和load操作。

3.一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。lock和unlock必須成對出現。

4.如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數之前需要重新執行load或assign操作初始化變數的值。

5.如果一個變數事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他執行緒鎖定的變數。

6.對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作)

從上面可以看出,把變數從主記憶體複製到工作記憶體需要順序執行read、load,從工作記憶體同步回主記憶體則需要順序執行store、write。總結:

  • read、load、use必須成對順序出現,但不要求連續出現。assign、store、write同之;
  • 變數誕生和初始化:變數只能從主記憶體“誕生”,且須先初始化後才能使用,即在use/store前須先load/assign;
  • lock一個變數後會清空工作記憶體中該變數的值,使用前須先初始化;unlock前須將變數同步回主記憶體;
  • 一個變數同一時刻只能被一執行緒lock,lock幾次就須unlock幾次;未被lock的變數不允許被執行unlock,一個執行緒不能去unlock其他執行緒lock的變數。

long和double型變數的特殊規則

Java記憶體模型要求前述8個操作具有原子性,但對於64位的資料型別long和double,在模型中特別定義了一條寬鬆的規定:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行。即未被volatile修飾時執行緒對其的讀取read不是原子操作,可能只讀到“半個變數”值。雖然如此,商用虛擬機器幾乎都把64位資料的讀寫實現為原子操作,因此我們可以忽略這個問題。

先行發生原則

Java記憶體模型具備一些先天的“有序性”,即不需要透過任何同步手段(volatile、synchronized等)就能夠得到保證的有序性,這個通常也稱為happens-before原則。

如果兩個操作的執行次序不符合先行原則且無法從happens-before原則推匯出來,那麼它們就不能保證它們的有序性,虛擬機器可以隨意地對它們進行重排序。

  • 程式次序規則(Program Order Rule):一個執行緒內,邏輯上書寫在前面的操作先行發生於書寫在後面的操作。
  • 鎖定規則(Monitor Lock Rule):一個unLock操作先行發生於後面對同一個鎖的lock操作。“後面”指時間上的先後順序。
  • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作。“後面”指時間上的先後順序。
  • 傳遞規則(Transitivity):如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C。
  • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每個一個動作。
  • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生(透過Thread.interrupted()檢測)。
  • 執行緒終止規則(Thread Termination Rule):執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以透過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到執行緒已經終止執行。
  • 物件終結規則(Finaizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於他的finalize()方法的開始。

問題解決

原子性

  • 由JMM直接保證的原子性變數操作包括read、load、use、assign、store、write;
  • 基本資料型別的讀寫(工作記憶體)是原子性的

由JMM的lock、unlock可實現更大範圍的原子性保證,但是這是JVM需要實現支援的功能,對於開發者則是有由synchronized關鍵字 或者 Lock讀寫鎖 來保證原子性。

可見性

volatile 變數值被一個執行緒修改後會立即同步回主記憶體、變數值被其他執行緒讀取前立即從主記憶體重新整理值到工作記憶體。即read、load、use三者連續順序執行,assign、store、write連續順序執行。

synchronized/Lock 由lock和unlock的使用規則保證

  • “對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作)”。
  • “如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數之前需要重新執行load或assign操作初始化變數的值”

final 修飾的欄位在構造器中一旦初始化完成,且構造器沒有把“this”的引用傳遞出去,則其他執行緒可立即看到final欄位的值。

順序性

volatile 禁止指令重排序

synchronized/Lock “一個變數在同一個時刻只允許一條執行緒對其執行lock操作”

開發篇

volatile

被volatile修飾的變數能保證器順序性和可見性

順序性

  • 對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作。“後面”指時間上的先後順序

可見性

  • 當寫一個 volatile 變數時,JMM 會把該執行緒對應的工作記憶體中的共享變數重新整理到主記憶體。
  • 當讀一個 volatile 變數時,JMM 會把該執行緒對應的工作記憶體置為無效,執行緒接下來將從主記憶體中讀取共享變數。

volatile相比於synchronized/Lock是非常輕量級,但是使用場景是有限制的:

  • 對變數的寫入操作不依賴於其當前值,即僅僅是讀取和單純的寫入,比如操作完成、中斷或者狀態之類的標誌
  • 禁止對volatile變數操作指令的重排序

實現原理

volatile底層是透過cpu提供的記憶體屏障指令來實現的。硬體層的記憶體屏障分為兩種:Load Barrier 和 Store Barrier即讀屏障和寫屏障。

記憶體屏障有兩個作用:

  • 阻止屏障兩側的指令重排序
  • 強制把寫緩衝區/快取記憶體中的髒資料等寫回主記憶體,讓快取中相應的資料失效

final

對於final域的記憶體語義,編譯器和處理器要遵守兩個重排序規則(內部實現也是使用記憶體屏障):

  • 寫final域的重排序規則:在建構函式內對一個final域的寫入,與隨後把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。
  • 讀final域的重排序規則:初次讀一個包含final域的物件的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。
public class FinalExample {
       int i;//普通域
       final int j;//final域
       static FinalExample obj;
       
       public FinalExample () {
              i = 1;//寫普通域。對普通域的寫操作【可能會】被重排序到建構函式之外 
              j = 2;//寫final域。對final域的寫操作【不會】被重排序到建構函式之外
       }
       
       // 寫執行緒A執行
       public static void writer () {    
              obj = new FinalExample ();
       }
       
       // 讀執行緒B執行
       public static void reader () {    
              FinalExample object = obj;//讀物件引用
              int a = object.i;//讀普通域。可能會看到結果為0(由於i=1可能被重排序到建構函式外,此時y還沒有被初始化)
              int b = object.j;//讀final域。保證能夠看到結果為2
       }
}

初次讀物件引用與初次讀該物件包含的final域,這兩個操作之間存在間接依賴關係。由於編譯器遵守間接依賴關係,因此編譯器不會重排序這兩個操作。大多數處理器也會遵守間接依賴,也不會重排序這兩個操作。但有少數處理器允許對存在間接依賴關係的操作做重排序(比如alpha處理器),這個規則就是專門用來針對這種處理器的。

對於final域是引用型別,寫final域的重排序規則對編譯器和處理器增加了如下約束:

  • 在建構函式內對一個final引用的物件的成員域的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。

synchronized

synchronized用於修飾普通方法、修飾靜態方法、修飾程式碼塊

  • 確保程式碼的同步執行(即不同執行緒間的互斥)(原子性)
  • 確保對共享變數的修改能夠及時可見(可見性)
  • 有效解決指令重排問題(順序性)

實現原理

使用物件的監視器(Monitor,也有叫管程的)進行控制

  • 進入/加鎖時執行位元組碼指令MonitorEnter
  • 退出/解鎖時執行位元組碼指令MonitorExit
  • 當執行程式碼有異常退出方法/程式碼段時,會自動解鎖

使用哪個物件的監視器:

  • 修飾物件方法時,使用當前物件的監視器
  • 修飾靜態方法時,使用類型別(Class 的物件)監視器
  • 修飾程式碼塊時,使用括號中的物件的監視器
  • 必須為 Object 類或其子類的物件

MonitorEnter(加鎖):

  • 每個物件都有一個關聯的監視器。
  • 監視器被鎖住,當且僅當它有屬主(Owner)時。
  • 執行緒執行MonitorEnter就是為了成為Monitor的屬主。
  • 如果 Monitor 物件的記錄數(Entry Count,擁有它的執行緒的重入次數)為 0, 將其置為 1,執行緒將自己置為 Monitor 物件的屬主。
  • 如果Monitor的屬主為當前執行緒,就會重入監視器,將其記錄數增一。
  • 如果Monitor的屬主為其它執行緒,當前執行緒會阻塞,直到記錄數為0,才會 去競爭屬主權。

MonitorExit(解鎖):

  • 執行MonitorExit的執行緒一定是這個物件所關聯的監視器的屬主。
  • 執行緒將Monitor物件的記錄數減一。
  • 如果Monitor物件的記錄數為0,執行緒就會執行退出動作,不再是屬主。
  • 此時其它阻塞的執行緒就被允許競爭屬主。

對於 MonitorEnter、MonitorExit 來說,有兩個基本引數:

  • 執行緒
  • 關聯監視器的物件

關鍵結構

在 JVM 中,物件在記憶體中的佈局分為三塊區域:物件頭、例項資料、對齊填充。如下:

例項變數

  • 存放類的屬性資料資訊,包括父類的屬性資訊
  • 如果是陣列的例項變數,還包括陣列的長度
  • 這部分記憶體按4位元組對齊

填充資料

  • 由於虛擬機器要求物件起始地址必須是8位元組的整數倍
  • 填充資料僅僅是為了位元組對齊
  • 保障下一個物件的起始地址為 8 的整數倍
  • 長度可能為0

物件頭(Object Header)

  • 物件頭由 Mark Word 、Class Metadata Address(類後設資料地址) 和 陣列長度(物件為陣列時)組成

  • 在 32 位和 64 位的虛擬機器中,Mark Word 分別佔用 32 位元組和 64 位元組,因此稱其為 word

Mark Word 儲存的並非物件的 實際業務資料(如物件的欄位值),屬於 額外儲存成本。為了節約儲存空間,Mark Word 被設計為一個 非固定的資料結構,以便在儘量小的空間中儲存儘量多的資料,它會根據物件的狀態,變換自己的資料結構,從而複用自己的儲存空間。
圖片描述
鎖的狀態共有 4 種:無鎖、偏向鎖、輕量級鎖、重量級鎖。隨著競爭的增加,鎖的使用情況如下:

無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖

其中偏向鎖和輕量級鎖是從 JDK 6 時引入的,在 JDK 6 中預設開啟。鎖的升級(鎖膨脹,inflate)是單向的,只能從低到高(從左到右)。不會出現 鎖的降級。

偏向鎖

當鎖物件第一次被執行緒獲取的時候,虛擬機器將會把物件頭中的標誌位設為“01” (可偏向),即偏向模式。同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的Mark Word之中,如果CAS操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作。

當有另外一個執行緒去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖物件目前是否處於被鎖定的狀態,撤銷偏向(Revoke Bias)後恢復到未鎖定(標誌位為“01”,不可偏向)或 輕量級鎖定(標誌位為“00”)的狀態,後續的同步操作就進入輕量級鎖的流程。

輕量級鎖

進入到輕量級鎖說明不止一個執行緒嘗試獲取鎖,這個階段會透過自適應自旋CAS方式獲取鎖。如果獲取失敗,則進行鎖膨脹,進入重量級鎖流程,執行緒阻塞。

重量級鎖

重量級鎖是透過系統的執行緒互斥鎖來實現的,代價最昂貴
圖片描述
ContentionList,CXQ,存放最近競爭鎖的執行緒

  • LIFO,單向連結串列
  • 很多執行緒都可以把請求鎖的執行緒放入佇列中
  • 但只有一個執行緒能將執行緒出隊
  • EntryLis,表示勝者組

雙向連結串列

  • 只有擁有鎖的執行緒才可以訪問或變更 EntryLis
  • 只有擁有鎖的執行緒在釋放鎖時,並且在 EntryList 為空、ContentionList 不為 空的情況下,才能將ContentionList 中的執行緒全部出隊,放入到EntryList 中

WaitSet,存放處於等待狀態的執行緒

  • 將進行 wait() 呼叫的執行緒放入WaitSet
  • 當進行 notify()、notifyAll()呼叫時,會將執行緒放入到ContentionList或EntryList 佇列中

注意:

  • 對一個執行緒而言,在任何時候最多隻處於三個集合中的一個
  • 處於這三個集合中的執行緒,均為 BLOCKED 狀態,底層使用互斥量來進行阻塞

當一個執行緒成功獲取到鎖時 物件監視器的 owner 欄位從 NULL 變為非空,指向此執行緒 必須將自己從ContentionList或EntryList中出隊

競爭型的鎖傳遞機制 執行緒釋放鎖時,不保證後繼執行緒一定可以獲得到鎖,而是後繼執行緒去競爭鎖

OnDeck,表示準備就緒的執行緒,保證任何時候都只有一個執行緒來直接競爭 鎖

  • 在獲取鎖時,如果發生競爭,則使用自旋鎖來爭用,如果自旋後仍得不 到,再放入上述佇列中。
  • 自旋可以減少ContentionList和EntryList上出隊入隊的操作,也就是減少了內部 維護的這些鎖的爭用。

OS 互斥鎖

重量級鎖是透過作業系統的執行緒互斥鎖來實現的,在 Linux 下,鎖所用的技術是 pthead_mutex_lock / pthead_mutex_unlock,即執行緒間的互斥鎖。

執行緒互斥鎖是基於 futex(Fast Userspace Mutex)機制實現的。常規的作業系統的同步機制(如 IPC 等),呼叫時都需要陷入到核心中執行,即使沒有競爭也要執行一次陷入操作(int 0x80,trap)。而 futex 則是核心態和使用者態的混合,無競爭時,獲取鎖和釋放鎖都不需要陷入核心。

初始分配

首先在記憶體分配 futex 共享變數,對執行緒而言,記憶體是共享的,直接分配(malloc)即可,為整數型別,初始值為1。

獲取鎖

使用CAS對 futex 變數減1,觀察其結果:

  • 如果由1變為0,表示無競爭,繼續執行
  • 如果小於 0,表示有競爭,呼叫 futex(…, FUTEX_WAIT, …) 使當前執行緒休眠

釋放鎖

使用CAS給futex變數加1

  • 如果futex變數由0變為1,表示無競爭,繼續執行
  • 如果 futex 變數變化前為負值,表示有競爭,呼叫 futex(…, FUTEX_WAKE, …) 喚醒一個或多個等待執行緒

作者:VectorJin

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/855/viewspace-2807241/,如需轉載,請註明出處,否則將追究法律責任。

相關文章