1. 程式人生 > >深入理解JVM(十一)——Java記憶體模型與執行緒

深入理解JVM(十一)——Java記憶體模型與執行緒

計算機運算的速度,與它的儲存和通訊子系統相差太大,大量的時間花費在磁碟IO,網路通訊和資料庫上。

衡量一個服務效能的高低好壞,每秒事務處理數TPS是最重要的指標。

對於計算量相同的任務,程式執行緒併發協調的越有條不紊,效率越高;反之,執行緒之間頻繁阻塞或是死鎖,將大大降低併發能力。

硬體的效率與一致性

絕大多數的運算任務不能只靠處理器計算就能完成的,至少要與記憶體互動,如讀取執行資料,儲存運算結果等,由於計算機的儲存裝置與計算機的執行有幾個數量級的差別,現在計算機系統不得不加入一種快取記憶體cache用作記憶體和處理器之前的緩衝。將運算的資料複製到快取中,讓執行能快速進行,將計算後的結果從快取會寫到記憶體中。

基於快取記憶體的儲存互動解決了處理器與記憶體的速度矛盾,但是引入了新的問題:快取一致性

在多處理器系統中,每個快取都有自己的高速處理器,他們又共享同一主記憶體。當多處理器的運算任務涉及同一主記憶體區域時,就可能導致快取資料不一致。

除了增加快取外,為了儘量利用處理器的運算單元,處理器會對輸入程式碼進行亂序執行(Out Of Order Execution)優化,處理器會在計算後將亂序執行的結果重組,保證該結果與順序執行的結果一致,但並不保證各個語句計算的先後順序與輸入程式碼的順序一致。
與之相同JVM也有類似的指令重排優化

主記憶體和工作記憶體

Java記憶體模型主要是定義程式中各個變數的訪問規則,即在虛擬機器中變數儲存到記憶體和從記憶體中取出變數 的底層細節。
此處變數與Java程式設計中的變數有區別,包括例項欄位,靜態欄位和構成陣列物件的元素,不包括區域性變數和方法引數,因為後者為執行緒私有不會被共享。

Java記憶體模型規定所有的變數都儲存在主記憶體(可與物理機主記憶體對比)中,每個執行緒還有自己的工作記憶體(可與快取記憶體對比),執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本的拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。

記憶體間互動操作

對於主記憶體和工作記憶體之間的互動協議,及一個變數如何從主記憶體拷貝到工作記憶體,從工作記憶體同步回主記憶體的實現細節,Java定義了8中操作來完成,虛擬機器保證每一種都是原子的,不可再分的(double,long型別的load,store,read,write某些平臺執行有例外)。

lock——鎖定,作用於主記憶體變數,把一個變數標誌為執行緒獨佔狀態
unlock——解鎖
read——讀取,將一個變數從主記憶體讀到工作記憶體
load——載入,把read的結果放入工作記憶體的變數副本中
use——使用,將工作記憶體的值傳遞給執行引擎
assign——賦值,將執行引擎接受到的值賦給工作記憶體的變數
store——儲存,將工作記憶體的變數傳遞給主記憶體
write——寫入,把store的值放在主記憶體變數中

把一個變數從主記憶體賦值到工作記憶體,要順序執行read和load操作;把變數從工作記憶體同步到主記憶體,要順序執行store和Write操作。Java只要求順序執行,但是不保證連續執行。

Java還規定了8中基本操作時必須滿足的規則

  1. 不允許read和load,store和Write操作之一單獨出現
  2. 不允許一個執行緒丟棄它最近的assign操作,即變數在工作記憶體改變後必須同步回主記憶體
  3. 不允許執行緒無原因(未發生assign操作)把資料同步回主記憶體
  4. 一個新的變數只能從主記憶體誕生
  5. 一個變數同一時刻只允許一條執行緒對其lock,但是可以重複執行多次,對應的要執行多次unlock才會解鎖
  6. 如果對一個變數執行lock,那麼就會清空工作記憶體的值,執行引擎使用前,需要重新執行load或者assign操作
  7. 如果一個變數沒有變lock,則不允許對它執行unlock;也不執行去unlock被其它執行緒lock的變數
  8. 對變數unlock之前,必須把變數同步回主記憶體

volatile型變數的特殊規則

關鍵字volatile是Java提供的最輕量級的同步機制

變數定義為volatile後具備兩種特性

  1. 保證此變數對所有執行緒的可見性,一條執行緒修改了值,新值對其它執行緒來說都是可以立即知道的。
  2. 禁止指令重排序優化。

原子性,可見性與有序性

  • 原子性——Atomicity
    Java記憶體模型保證原子性的操作有read,load,assign,use,store和Write,大致可以認為基本資料型別的訪問讀寫是原子的。
  • 可見性
    當一個執行緒修改了共享變數時,其它執行緒能立即知道這個修改
  • 有序性
    如果在本執行緒內觀察所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。

先行發生原則 happens-before

先行關係是Java記憶體模型定義的兩項操作之間的偏序關係,如果說操作A先行發生與操作B,其實就是說發生操作B之前,操作A產生的影響能被操作B觀察到,影響包括修改了記憶體中共享變數的值,傳送了訊息,呼叫了方法等。

  1. 程式次序規則,一個執行緒內按照程式程式碼執行,指的是控制流順序
  2. 管程鎖定規則,一個unlock操作先行發生於後面對同一個鎖的lock操作,同一個鎖,時間上的先後順序
  3. volatile變數規則,對一個volatile變數的寫操作先行發生與後面對這個變數的讀操作,時間上的先後順序
  4. 執行緒啟動規則,執行緒的start()方法先行發生於執行緒的每一個動作
  5. 執行緒終止規則,執行緒中的所有操作都先行發生於執行緒的終止檢測。
  6. 執行緒中斷規則,對執行緒的interrupt()方法的呼叫優先發生於被中斷執行緒的程式碼檢測到中斷事件的發生。
  7. 物件終結規則,一個物件初始化完成先發生於它的finalize()方法的開始
  8. 傳遞性,操作A先於操作B,操作B先與操作C,則操作A先與操作C

執行緒安全

多多個執行緒訪問一個物件時,如果不考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其它的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件是執行緒安全。

不可變

Java語言中不可變的物件一定是執行緒安全的

絕對執行緒安全

不管執行時環境如何,呼叫者都不需要任何額外的同步措施。通常付出的代價很大。

相對執行緒安全

需要保證這個物件單獨的操作是執行緒安全的,我們在呼叫的時候無須做額外的保障措施,但對於一些特定順序的連續呼叫,可能需要額外的同步手段保障正確性。

執行緒相容

是指物件本身並不是執行緒安全的,但是可以通過在呼叫端使用正確地同步手段保證物件在併發環境中安全使用

執行緒對立

無論呼叫端是否採取同步措施,都無法在多執行緒環境中併發使用的程式碼(執行緒的suspend和resume方法,死鎖的風險)

執行緒安全的實現方法

  • 互斥同步
    同步是指多個執行緒併發訪問共享資料時,保證共享資料在同一時刻只被一個或者一些(使用訊號量的時候)執行緒使用。互斥是實現同步的手段,臨界區,互斥量,訊號量都是主要的互斥實現方法。
    Java中最基本的互斥手段是synchronized關鍵字,編譯後會在同步快前後形成monitorenter和monitorexit這兩個位元組碼指令,這兩個位元組碼都需要一個reference型別的引數來指明要鎖定和解鎖的物件。
    另外concurrent包中提供的可重入鎖ReentrantLock也可以實現互斥,另外提供高階的等待可中斷,可實現公平鎖,鎖繫結多個條件等功能

  • 非阻塞同步
    互斥主要是進行執行緒阻塞和喚醒的操作,也稱阻塞同步,是一種悲觀的併發策略。
    非阻塞同步是一種樂觀的併發策略,基於衝突檢測,就是先進行操作,如果沒有其它執行緒爭用共享資料,那操作就成功;如果共享資料有爭用,產生衝突,則採取其它的補償。這種方式不需要把執行緒掛起,因此稱為非阻塞。
    樂觀鎖需要操作和衝突檢測這兩個步驟具備原子性,也就是需要硬體指令集的支援,這類指令集常有

  • 測試並設定(test and set)

  • 獲取並增加(fetch and increment)
  • 互動(swap)
  • 比較並交換(compare and swap,CAS)
  • 載入連結/條件儲存(load linked/store conditional)

CAS需要3個運算元,記憶體位置(V),舊的預期值(A),新值(B)。有且僅當V符合舊預期值A時,用B更新V的值,否者它就不執行更新,但是無論是否更新了V都會返回V的舊值,上述處理過程是一個原子操作。
ABA問題,通過版本解決,大部分不影響可以不解決;一定要解決可以通過互斥。

鎖優化

HotSpot團隊一直致力於各種鎖優化的技術,如適應性自旋,鎖消除,鎖粗化,輕量級鎖和偏向鎖等。

  • 自旋鎖和自適應鎖
    上面說到互斥最大的影響是阻塞的實現,掛起執行緒和恢復執行緒的操作都需要轉人核心態去完成,這個操作給系統併發帶來很大的壓力。
    共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起執行緒和恢復執行緒是不值得的,。如果物理機可以讓執行緒並行執行,可以讓後面請求鎖的程序稍等,但不要放棄處理器的執行時間,為了讓執行緒等待需要執行緒執行一個忙迴圈(自旋)。
    如果鎖被佔用的時間很短,自旋等待的效果非常好;如果鎖佔用的時間長,則自旋只會白白消耗處理器資源。
    自適應自旋是隻自旋的時間不在固定(JVM引數),而是由前一次同一個鎖上的自旋時間及鎖持有者的狀態來決定的。

  • 鎖消除
    逃逸分析,變數如果沒有逃逸出執行緒,則共享資料的競爭鎖可以消除。(也許程式設計師顯示沒有加鎖但是JVM優化,比較s1+s2+s3,變成stringBuffer.append()方法)

  • 鎖粗化
    原則上同步塊的作用範圍儘量小,只在共享資料的實際作用域中進行同步。
    如果一系列的連續操作要對同一物件反覆加鎖和解鎖,甚至加鎖操作出現在迴圈體中,就會導致不必要的效能消耗。

  • 輕量級鎖
    輕量級指的是相對作業系統互斥量來實現的傳統鎖而言的。輕量級鎖並沒有要代替重量級的鎖,也沒有在多執行緒的情況下減少傳統的重量級鎖的效能消耗意思。
    HotSpot虛擬機器的物件頭分為兩個部分,第一個部分用於儲存物件自身的執行資料,,如雜湊碼,GC分代年齡等。這部分是實現輕量級鎖和偏向鎖的關鍵。
    32位虛擬機器中,物件未被鎖定的狀態下,Mark Word的32Bit用於存放25Bit的雜湊碼,4Bit的GC分代年齡,2Bit的儲存鎖標誌位,1Bit固定為0;在其它狀態下(輕量級鎖定,重量級鎖定,GC標誌,可偏向)物件的儲存內容是

儲存內容 標誌位 狀態
雜湊碼和物件分代年齡 01 未鎖定
指向鎖記錄的指標 00 輕量級鎖定
指向重量級鎖的指標 10 重量級鎖定
空,不需要記錄資訊 11 GC標誌
偏向執行緒ID,偏向時間戳,物件分代年齡 01 可偏向

程式碼進入同步塊的時候,如果同步物件沒有別鎖定(鎖標誌位01狀態),虛擬機器首先在當前執行緒的棧幀中建立一個鎖記錄的空間,存放鎖物件目前的Mark Word拷貝,然後虛擬機器採用CAS操作嘗試將物件的Mark Word更新為指向鎖記錄的指標。如果操作成功,這個執行緒擁有該物件的鎖,並且物件的鎖標誌位轉變成00。
如果這個更新失敗,虛擬機器會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果只說明當前執行緒已經擁有這個物件的鎖,就可以直接進入同步塊繼續執行,否則說明這個鎖物件被其它執行緒搶佔了。
如果兩條以上執行緒爭用同一個鎖,則輕量級鎖不在有效,膨脹為重量級鎖。鎖標誌位變為10,Mark Word中儲存的是指向重量級鎖(互斥量)的指標。後面等待鎖的執行緒也進入阻塞狀態。
解鎖的過程也是通過CAS操作來進行的,如果物件的Mark Word仍然指向執行緒的鎖記錄,那就用CAS去替換。如果替換成功則同步過程完成,如果失敗,則說明其它執行緒嘗試過獲取該鎖,要在釋放鎖的同時,喚醒被掛起的執行緒。

  • 輕量級鎖
    消除資料在無競爭情況下的同步原語,進一步提供程式的執行效能。如果說輕量級鎖實在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS也不做。
    鎖會偏向與第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其它執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。