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

8. JMM和底層實現原理

8.1 線程間的通訊與同步

線程之間的通訊

線程的通訊是指線程之間以何種機制來交換信息。在編程中,線程之間的通訊機制有兩種,共享內存和消息傳遞。java

共享內存的併發模型裏,線程之間共享程序的公共狀態,線程之間經過寫-讀內存中的公共狀態來隱式進行通訊,典型的共享內存通訊方式就是經過共享對象進行通訊。git

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

線程之間的同步

同步是指程序用於控制不一樣線程之間操做發生相對順序的機制。github

在共享內存併發模型裏,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼須要在線程之間互斥執行。
消息傳遞的併發模型裏,因爲消息的發送必須在消息的接收以前,所以同步是隱式進行的。面試

注意到,Java的併發採用的是共享內存模型,接下來將會主要進行介紹。算法

8.2 Java內存模型(JMM)

JMM

Java的內存模型以下圖所示:數據庫

每一個Java線程擁有對應的工做內存,工做內寸經過Save和Load操做和主內存進行數據交互。編程

Picture1.png

在JVM內部,Java內存模型把內存分紅了兩部分:線程棧區和堆區後端

Picture1.png

JVM中運行的每一個線程都擁有本身的線程棧,線程棧包含了當前線程執行的方法調用相關信息,咱們也把它稱做調用棧。隨着代碼的不斷執行,調用棧會不斷變化。數組

線程棧還包含了當前方法的全部局部變量信息。一個線程只能讀取本身的線程棧,也就是說,線程中的本地變量對其它線程是不可見的。即便兩個線程執行的是同一段代碼,它們也會各自在本身的線程棧中建立局部變量,所以,每一個線程中的局部變量都會有本身的版本。

堆中的對象能夠被多線程共享,若是一個線程得到一個對象的應用,它即可訪問這個對象的成員變量。若是兩個線程同時調用了同一個對象的同一個方法,那麼這兩個線程即可同時訪問這個對象的成員變量,可是對於局部變量,每一個線程都會拷貝一份到本身的線程棧中。

JMM帶來的問題

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

1. 共享對象對各個線程的可見性

在一個線程中修改了共享數據後,如何保證對另一個線程可見?

Picture1.png

2. 共享對象的競爭現象

對於同一個共享數據,如何保證兩個線程正確的修改?

Picture1.png

指令重排

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

Picture111.png

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

單線程下,這種指令重排序會聽從如下兩個規則:

1. 數據依賴性

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

Picture231.png

2. 控制依賴性

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

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

as-if-serial語義:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不會改變。在as-if-serial語義下,編譯器和處理器不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變執行結果。

可是在指令重排並不保證併發執行的正確性,所以可能帶來比較嚴重的問題,好比下面的例子中,use()經過判斷flag是否爲true,來獲取初始化完成的信息。可是因爲指令重排,可能拿到錯誤的a的值。

Picture451.png

Picture4441.png

在併發狀況下,爲了解決重排序帶來的問題,引入了內存屏障來阻止重排序:

Screen Shot 2019-12-14 at 4.08.25 PM.png

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變量的單個讀/寫,當作是使用同一個鎖對這些單個讀/寫操做作了同步。以下面的例子所示:

Pictu23442re1.png

等價於:

Pictur57e1.png

volatile寫與讀

  1. volatile寫的內存語義以下:當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。

Pict45654664re1.png

  1. volatile讀的內存語義以下:當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

Pict4678467ure1.png

volatile內存語義的實現

JMM經過內存屏障插入策略,來實現volatile的讀寫語義。

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

Pictur234e1.png

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

Pictuadfgbre1.png

volatile的底層實現原理:
有volatile變量修飾的共享變量進行寫操做的時候會使用CPU提供的Lock前綴指令。

  • 將當前處理器緩存行的數據寫回到系統內存
  • 這個寫回內存的操做會使在其餘CPU裏緩存了該內存地址的數據無效。

volatile語義進一步:

  • 保證變量對全部線程可見:注意因爲一條字節ma

8.5 鎖的內存語義

  • 當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。

sfrrth.png

  • 當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。

sfgtnms.png

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位虛擬機):

Picture1.png

對象頭信息是與對象自身定義的數據無關的額外存儲成本,可是考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲儘可能多的數據,它會根據對象的狀態複用本身的存儲空間,也就是說,Mark Word會隨着程序的運行發生變化,變化狀態以下(32位虛擬機):

Screen Shot 2019-12-14 at 5.38.08 PM.png

Monitor

Monitor能夠把它理解爲一個同步工具,也能夠描述爲一種同步機制,它一般被描述爲一個對象。
與一切皆對象同樣,全部的Java對象是天生的Monitor,每個Java對象都有成爲Monitor的潛質,由於在Java的設計中 ,每個Java對象自生成後就自帶一種看不見的鎖,它叫作內部鎖或者Monitor鎖。
Monitor 是線程私有的數據結構,每個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每個被鎖住的對象都會和一個monitor關聯(對象頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中有一個Owner字段存放擁有該鎖的線程的惟一標識,表示該鎖被這個線程佔用。其結構以下:

Screen Shot 2019-12-14 at 5.41.55 PM.png

  • 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,這樣反卻是下降了性能,因此通常多寫的場景下用悲觀鎖就比較合適。


參考連接:


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

搜索『後端精進之路』關注公衆號,馬上獲取最新文章和價值2000元的BATJ精品面試課程

後端精進之路.png

相關文章
相關標籤/搜索