1. 程式人生 > >Java併發程式設計系列-(8) JMM和底層實現原理

Java併發程式設計系列-(8) JMM和底層實現原理

8. JMM和底層實現原理

8.1 執行緒間的通訊與同步

執行緒之間的通訊

執行緒的通訊是指執行緒之間以何種機制來交換資訊。在程式設計中,執行緒之間的通訊機制有兩種,共享記憶體和訊息傳遞。

在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊,典型的共享記憶體通訊方式就是通過共享物件進行通訊。

在訊息傳遞的併發模型裡,執行緒之間沒有公共狀態,執行緒之間必須通過明確的傳送訊息來顯式進行通訊,在java中典型的訊息傳遞方式就是wait()和notify()。

執行緒之間的同步

同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。

在共享記憶體併發模型裡,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。
在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的。

注意到,Java的併發採用的是共享記憶體模型,接下來將會主要進行介紹。

8.2 Java記憶體模型(JMM)

JMM

Java的記憶體模型如下圖所示:

每個Java執行緒擁有對應的工作記憶體,工作內寸通過Save和Load操作和主記憶體進行資料互動。

在JVM內部,Java記憶體模型把記憶體分成了兩部分:執行緒棧區和堆區

JVM中執行的每個執行緒都擁有自己的執行緒棧,執行緒棧包含了當前執行緒執行的方法呼叫相關資訊,我們也把它稱作呼叫棧。隨著程式碼的不斷執行,呼叫棧會不斷變化。

執行緒棧還包含了當前方法的所有區域性變數資訊。一個執行緒只能讀取自己的執行緒棧,也就是說,執行緒中的本地變數對其它執行緒是不可見的。即使兩個執行緒執行的是同一段程式碼,它們也會各自在自己的執行緒棧中建立區域性變數,因此,每個執行緒中的區域性變數都會有自己的版本。

堆中的物件可以被多執行緒共享,如果一個執行緒獲得一個物件的應用,它便可訪問這個物件的成員變數。如果兩個執行緒同時呼叫了同一個物件的同一個方法,那麼這兩個執行緒便可同時訪問這個物件的成員變數,但是對於區域性變數,每個執行緒都會拷貝一份到自己的執行緒棧中。

JMM帶來的問題

上述介紹的JMM也帶來了一些問題:

1. 共享物件對各個執行緒的可見性

在一個執行緒中修改了共享資料後,如何保證對另外一個執行緒可見?

2. 共享物件的競爭現象

對於同一個共享資料,如何保證兩個執行緒正確的修改?

指令重排

除了之前提到的JMM中存在的兩個問題之外,指令重排也可能對程式的正確性產生影響。

如上圖所示,JVM中為了提高指令執行的效率,可能在不改變執行結果的情況下,重排部分指令的順序達到併發執行的效果。

單執行緒下,這種指令重排序會遵從以下兩個規則:

1. 資料依賴性

在下面三種情況,資料存在依賴關係,指令重排必須保證這種情況下的正確性。

2. 控制依賴性

對於控制依賴性,比如下面的例子,b的值依賴於a的狀態,這種情況下,指令重排也會保證這種關係的正確性。

if (a == 1){
    b = 2;
}

as-if-serial語義:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不會改變。在as-if-serial語義下,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。

但是在指令重排並不保證併發執行的正確性,因此可能帶來比較嚴重的問題,比如下面的例子中,use()通過判斷flag是否為true,來獲取初始化完成的資訊。但是由於指令重排,可能拿到錯誤的a的值。

在併發情況下,為了解決重排序帶來的問題,引入了記憶體屏障來阻止重排序:

8.3 Happens-Before

定義

用happens-before的概念來闡述操作之間的記憶體可見性。在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係 。

兩個操作之間具有happens-before關係,並不意味著前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second)。

對於happens-before,可以從下面兩個方面去理解:

  • 對使用者來講:如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

  • 對編譯器和處理器來說:兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序是允許的。

Happens-Before規則

下面幾種規則,無需任何同步手段就可以保證:

1)程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。

2)監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。

3)volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。

4)傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。

5)start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。

6)join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。

7 )執行緒中斷規則:對執行緒interrupt方法的呼叫happens-before於被中斷執行緒的程式碼檢測到中斷事件的發生。

8.4 volatile的記憶體語義

volatile變數自身具有下列特性:

  • 可見性。對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入。

  • 原子性:對任意單個volatile變數的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

具體來看,可以把對volatile變數的單個讀/寫,看成是使用同一個鎖對這些單個讀/寫操作做了同步。如下面的例子所示:

等價於:

volatile寫與讀

  1. volatile寫的記憶體語義如下:當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體。

  1. volatile讀的記憶體語義如下:當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。

volatile記憶體語義的實現

JMM通過記憶體屏障插入策略,來實現volatile的讀寫語義。

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。在每個volatile寫操作的後面插入一個StoreLoad屏障。

  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。在每個volatile讀操作的後面插入一個LoadStore屏障。

volatile的底層實現原理:
有volatile變數修飾的共享變數進行寫操作的時候會使用CPU提供的Lock字首指令。

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

volatile語義進一步:

  • 保證變數對所有執行緒可見:注意由於一條位元組ma

8.5 鎖的記憶體語義

  • 當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中。

  • 當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數。

8.5 final的記憶體語義

編譯器和處理器要遵守兩個重排序規則:

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

final域為引用型別

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

final語義在處理器中的實現

  • 會要求編譯器在final域的寫之後,建構函式return之前插入一個StoreStore障屏。
  • 讀final域的重排序規則要求編譯器在讀final域的操作前面插入一個LoadLoad屏障。

8.6 Synchronized的實現原理

synchronized底層如何實現?什麼是鎖的升級、降級?

這是一個非常常見的面試題,標準回答如下:

synchronized 程式碼塊是由一對monitorenter/monitorexit 指令實現的,Monitor 物件是同步的基本實現單元。

在 Java 6 之前,Monitor 的實現完全是依靠作業系統內部的互斥鎖,因為需要進行使用者態到核心態的切換,所以同步操作是一個無差別的重量級操作。

現代的(Oracle)JDK 中,JVM進行了大量改進,提供了三種不同的 Monitor 實現,也就是常說的三種不同的鎖:偏斜鎖(Biased Locking)、輕量級鎖和重量級鎖,大大改進了其效能。

所謂鎖的升級、降級,就是 JVM 優化 synchronized 執行的機制,當 JVM 檢測到不同的競爭狀況時,會自動切換到適合的鎖實現,這種切換就是鎖的升級、降級。

當沒有競爭出現時,預設會使用偏斜鎖。JVM 會利用 CAS 操作(compare and swap),在物件頭上的 Mark Word 部分設定執行緒 ID,以表示這個物件偏向於當前執行緒,所以並不涉及真正的互斥鎖。這樣做的假設是基於在很多應用場景中,大部分物件生命週期中最多會被一個執行緒鎖定,使用偏斜鎖可以降低無競爭開銷。

如果有另外的執行緒試圖鎖定某個已經被偏斜過的物件,JVM 就需要撤銷(revoke)偏斜鎖,並切換到輕量級鎖實現。輕量級鎖依賴 CAS 操作 Mark Word 來試圖獲取鎖,如果重試成功,就使用普通的輕量級鎖;否則,進一步升級為重量級鎖。

Synchronized原理詳細總結

1. monitor和物件頭

Java物件頭和monitor是實現synchronized的基礎。

synchronized用的鎖是存在Java物件頭裡的。JVM基於進入和退出Monitor物件來實現方法同步和程式碼塊同步,任何物件都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。在實現時,使用到了monitorenter和monitorexit指令,monitorenter指令是在編譯後插入到同步程式碼塊的開始位置,而monitorexit是插入到方法結束處和異常處。

Java物件頭

synchronized用的鎖是存在Java物件頭裡的,Hotspot虛擬機器的物件頭主要包括兩部分資料:Mark Word(標記欄位)、Klass Pointer(型別指標)。

  • Klass Point是是物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
  • Mark Word用於儲存物件自身的執行時資料,它是實現輕量級鎖和偏向鎖的關鍵,其中儲存的資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等等。

Java物件頭一般佔有兩個機器碼(在32位虛擬機器中,1個機器碼等於4位元組,也就是32bit),但是如果物件是陣列型別,則需要三個機器碼,因為JVM虛擬機器可以通過Java物件的元資料資訊確定Java物件的大小,但是無法從陣列的元資料來確認陣列的大小,所以用一塊來記錄陣列長度。下圖是Java物件頭的儲存結構(32位虛擬機器):

物件頭資訊是與物件自身定義的資料無關的額外儲存成本,但是考慮到虛擬機器的空間效率,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體儲存儘量多的資料,它會根據物件的狀態複用自己的儲存空間,也就是說,Mark Word會隨著程式的執行發生變化,變化狀態如下(32位虛擬機器):

Monitor

Monitor可以把它理解為一個同步工具,也可以描述為一種同步機制,它通常被描述為一個物件。
與一切皆物件一樣,所有的Java物件是天生的Monitor,每一個Java物件都有成為Monitor的潛質,因為在Java的設計中 ,每一個Java物件自生成後就自帶一種看不見的鎖,它叫做內部鎖或者Monitor鎖。
Monitor 是執行緒私有的資料結構,每一個執行緒都有一個可用monitor record列表,同時還有一個全域性的可用列表。每一個被鎖住的物件都會和一個monitor關聯(物件頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中有一個Owner欄位存放擁有該鎖的執行緒的唯一標識,表示該鎖被這個執行緒佔用。其結構如下:

  • Owner:初始時為NULL表示當前沒有任何執行緒擁有該monitor record,當執行緒成功擁有該鎖後儲存執行緒唯一標識,當鎖被釋放時又設定為NULL;
  • EntryQ:關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的執行緒。
  • RcThis:表示block或waiting在該monitor record上的所有執行緒的個數。
  • Nest:用來實現重入鎖的計數。
  • HashCode:儲存從物件頭拷貝過來的HashCode值(可能還包含GC age)。
  • Candidate:用來避免不必要的阻塞或等待執行緒喚醒,因為每一次只有一個執行緒能夠成功擁有鎖,如果每次前一個釋放鎖的執行緒喚醒所有正在阻塞或等待的執行緒,會引起不必要的上下文切換(從阻塞到就緒然後因為競爭鎖失敗又被阻塞)從而導致效能嚴重下降。Candidate只有兩種可能的值0表示沒有需要喚醒的執行緒1表示要喚醒一個繼任執行緒來競爭鎖。

2. 偏向鎖&輕量級鎖&重量級鎖

Java SE 1.6為了減少獲得鎖和釋放鎖帶來的效能消耗,引入了“偏向鎖”和“輕量級鎖”:鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。

偏向鎖

HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得。偏向鎖是為了在只有一個執行緒執行同步塊時提高效能。

當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧幀中的鎖記錄裡儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下物件頭的Mark Word裡是否儲存著指向當前執行緒的偏向鎖。

引入偏向鎖是為了在無多執行緒競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要檢查是否為偏向鎖、鎖標識為以及ThreadID即可。

獲取鎖的流程:

  1. 檢測Mark Word是否為可偏向狀態,即偏向鎖的標識是否設定成1,鎖標誌位是否為01——確認為可偏向狀態。
  2. 若為可偏向狀態,則測試執行緒ID是否為當前執行緒ID,如果是,則執行步驟(5),否則執行步驟(3);
  3. 如果執行緒ID不為當前執行緒ID,則通過CAS操作競爭鎖,競爭成功,則將Mark Word的執行緒ID替換為當前執行緒ID,否則執行執行緒(4);
  4. 通過CAS競爭鎖失敗,證明當前存在多執行緒競爭情況,當到達全域性安全點(這個時間點是上沒有正在執行的程式碼),獲得偏向鎖的執行緒被掛起,偏向鎖升級為輕量級鎖,然後被阻塞在安全點的執行緒繼續往下執行同步程式碼塊;
  5. 執行同步程式碼塊

釋放鎖

偏向鎖的釋放採用了一種只有競爭才會釋放鎖的機制,執行緒是不會主動去釋放偏向鎖,需要等待其他執行緒來競爭。偏向鎖的撤銷需要等待全域性安全點(這個時間點是上沒有正在執行的程式碼)。其步驟如下:

  1. 暫停擁有偏向鎖的執行緒,判斷鎖物件是否還處於被鎖定狀態;
  2. 撤銷偏向鎖,恢復到無鎖狀態(01)或者輕量級鎖的狀態;

關閉偏向鎖

偏向鎖在Java 6和Java 7裡是預設啟用的。由於偏向鎖是為了在只有一個執行緒執行同步塊時提高效能,如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程式預設會進入輕量級鎖狀態。

輕量級鎖

輕量級鎖是為了線上程近乎交替執行同步塊時提高效能。

加鎖過程

  1. 在程式碼進入同步塊的時候,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為“01”狀態,是否為偏向鎖為“0”),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝,官方稱之為 Displaced Mark Word。這時候執行緒堆疊與物件頭的狀態如下圖所示。

  2. 拷貝物件頭中的Mark Word複製到鎖記錄中。
  3. 拷貝成功後,虛擬機器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標,並將Lock record裡的owner指標指向object mark word。如果更新成功,則執行步驟(4),否則執行步驟(5)。
  4. 如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位設定為“00”,即表示此物件處於輕量級鎖定狀態,這時候執行緒堆疊與物件頭的狀態如下圖所示。

  5. 如果這個更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行。否則說明多個執行緒競爭鎖,若當前只有一個等待執行緒,則可通過自旋稍微等待一下,可能另一個執行緒很快就會釋放鎖。 但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的執行緒以外的執行緒都阻塞,防止CPU空轉,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。

解鎖過程

  1. 通過CAS操作嘗試把執行緒中複製的Displaced Mark Word物件替換當前的Mark Word。
  2. 如果替換成功,整個同步過程就完成了。
  3. 如果替換失敗,說明有其他執行緒嘗試過獲取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被掛起的執行緒。

自旋鎖
1.基於樂觀情況下推薦使用,即鎖競爭不強,鎖等待時間不長的情況下推薦使用
2.單cpu無效,因為基於cas的輪詢會佔用cpu,導致無法做執行緒切換
3.輪詢不產生上下文切換,如果可估計到睡眠的時間很長,用互斥鎖更好

重量級鎖

如上輕量級鎖的加鎖過程見輕量級鎖的步驟(5),輕量級鎖所適應的場景是執行緒近乎交替執行同步塊的情況,如果存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹為重量級鎖。Mark Word的鎖標記位更新為10,Mark Word指向互斥量(重量級鎖)。

Synchronized的重量級鎖是通過物件內部的一個叫做監視器鎖(monitor)來實現的,監視器鎖本質又是依賴於底層的作業系統的Mutex Lock(互斥鎖)來實現的。而作業系統實現執行緒之間的切換需要從使用者態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是為什麼Synchronized效率低的原因。

3. 三種鎖的切換

  • 一個物件剛開始例項化的時候,沒有任何執行緒來訪問它的時候。它是可偏向的,意味著,它現在認為只可能有一個執行緒來訪問它,所以當第一個執行緒來訪問它的時候,它會偏向這個執行緒,此時,物件持有偏向鎖。偏向第一個執行緒,這個執行緒在修改物件頭成為偏向鎖的時候使用CAS操作,並將物件頭中的ThreadID改成自己的ID,之後再次訪問這個物件時,只需要對比ID,不需要再使用CAS在進行操作。
  • 一旦有第二個執行緒訪問這個物件,因為偏向鎖不會主動釋放,所以第二個執行緒可以看到物件時偏向狀態,這時表明在這個物件上已經存在競爭了。檢查原來持有該物件鎖的執行緒是否依然存活,如果掛了,則可以將物件變為無鎖狀態,然後重新偏向新的執行緒。如果原來的執行緒依然存活,則馬上執行那個執行緒的操作棧,檢查該物件的使用情況,如果仍然需要持有偏向鎖,則偏向鎖升級為輕量級鎖,(偏向鎖就是這個時候升級為輕量級鎖的),此時輕量級鎖由原持有偏向鎖的執行緒持有,繼續執行其同步程式碼,而正在競爭的執行緒會進入自旋等待獲得該輕量級鎖。
  • 輕量級鎖認為競爭存在,但是競爭的程度很輕,一般兩個執行緒對於同一個鎖的操作都會錯開,或者說稍微等待一下(自旋),另一個執行緒就會釋放鎖。 但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的執行緒以外的執行緒都阻塞,防止CPU空轉。

偏向所鎖,輕量級鎖都是樂觀鎖,重量級鎖是悲觀鎖。

悲觀鎖
總是假設最壞的情況,每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會阻塞直到它拿到鎖(共享資源每次只給一個執行緒使用,其它執行緒阻塞,用完後再把資源轉讓給其它執行緒)。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。Java中synchronized和ReentrantLock等獨佔鎖就是悲觀鎖思想的實現。
樂觀鎖
總是假設最好的情況,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號機制和CAS演算法實現。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量,像資料庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變數類就是使用了樂觀鎖的一種實現方式CAS實現的。
樂觀鎖適用於寫比較少的情況下(多讀場景),即衝突真的很少發生的時候,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。但如果是多寫的情況,一般會經常產生衝突,這就會導致上層應用會不斷的進行retry,這樣反倒是降低了效能,所以一般多寫的場景下用悲觀鎖就比較合適。


參考連結:

  • https://zhuanlan.zhihu.com/p/29866981
  • https://blog.csdn.net/chenssy/article/details/54883355

本文由『後端精進之路』原創,首發於部落格 http://teckee.github.io/ , 轉載請註明出處

搜尋『後端精進之路』關注公眾號,立刻獲取最新文章和價值2000元的BATJ精品面試課程。

相關推薦

no