Java多執行緒之記憶體模型

bmilk發表於2020-06-21

目錄

  • 多執行緒需要解決的問題
    • 執行緒之間的通訊
    • 執行緒之間的同步
  • Java記憶體模型
    • 記憶體間的互動操作
    • 指令屏障
    • happens-before規則
  • 指令重排序
    • 從源程式到位元組指令的重排序
    • as-if-serial語義
    • 程式順序規則
  • 順序一致性模型
    • 順序一致性模型特性
    • 順序一致性模型特性
    • 當程式未正確同步會發生什麼
  • 參考資料

多執行緒需要解決的問題

在多執行緒程式設計中,執行緒之間如何通訊和同步是一個必須解決的問題:

執行緒之間的通訊:

執行緒之間有兩種通訊的方式:訊息傳遞和共享記憶體

  • 共享記憶體:執行緒之間共享程式的公共狀態,通過讀——寫修改公共狀態進行隱式通訊。如上面程式碼中的numLock可以被理解為公共狀態
  • 訊息傳遞:執行緒之間沒有公共狀態,必須通過傳送訊息來進行顯示通訊
    在java中,執行緒是通過共享記憶體來完成執行緒之間的通訊

執行緒之間的同步:

同步指程式中永固空值不同執行緒間的操作發生的相對順序的機制

  • 共享記憶體:同步是顯示進行的,程式設計師需要指定某個方法或者某段程式碼需要線上程之間互斥執行。如上面程式碼中的Lock加鎖和解鎖之間的程式碼塊,或者被synchronized包圍的程式碼塊
  • 訊息傳遞:同步是隱式執行的,因為訊息的傳送必然發生在訊息的接收之前,例如使用Objetc#notify(),喚醒的執行緒接收訊號一定在傳送喚醒訊號的傳送之後。

Java記憶體模型

在java中,所有的例項域,靜態域、陣列都被儲存在堆空間當中,堆記憶體線上程之間共享。

所有的區域性變數,方法定義引數和異常處理器引數不會被執行緒共享,在每個執行緒棧中獨享,他們不會存在可見性和執行緒安全問題。

從Java執行緒模型(JMM)的角度來看,執行緒之間的共享變數儲存在主記憶體當中,每個執行緒擁有一個私有的本地記憶體(工作記憶體)本地記憶體儲存了該執行緒讀——寫共享的變數的副本。
JMM只是一個抽象的概念,在現實中並不存在,其中所有的儲存區域都在堆記憶體當中。JMM的模型圖如下圖所示:

而java執行緒對於共享變數的操作都是對於本地記憶體(工作記憶體)中的副本的操作,並沒有對共享記憶體中原始的共享變數進行操作;

以執行緒1和執行緒2為例,假設執行緒1修改了共享變數,那麼他們之間需要通訊就需要兩個步驟:

  1. 執行緒1本地記憶體中修改過的共享變數的副本同步到共享記憶體中去
  2. 執行緒2從共享記憶體中讀取被執行緒1更新過的共享變數
    這樣才能完成執行緒1的修改對執行緒2的可見。

記憶體間的互動操作

為了完成這一執行緒之間的通訊,JMM為記憶體間的互動操作定義了8個原子操作,如下表:

操作 作用域 說明
lock(鎖定) 共享記憶體中的變數 把一個變數標識為一條執行緒獨佔的狀態
unlock(解鎖) 共享記憶體中的變數 把一個處於鎖定的變數釋放出來,釋放後其他執行緒可以進行訪問
read(讀取) 共享記憶體中的變數 把一個變數的值從共享記憶體傳輸到執行緒的工作記憶體。供隨後的load操作使用
load(載入) 工作記憶體 把read操作從共享記憶體中得到的變數值放入工作記憶體的變數副本當中
use(使用) 工作記憶體 把工作記憶體中的一個變數值傳遞給執行引擎
assign(賦值) 工作記憶體 把一個從執行引擎接受到的值賦值給工作記憶體的變數
store(儲存) 作用於工作記憶體 把一個工作記憶體中的變數傳遞給共享記憶體,供後續的write使用
write(寫入) 共享記憶體中的變數 把store操作從工作記憶體中得到的變數的值放入主記憶體

JMM規定JVM四線時必須保證上述8個原子操作是不可再分割的,同時必須滿足以下的規則:

  1. 不允許readloadstorewrite操作之一單獨出現,即不允許只從共享記憶體讀取但工作記憶體不接受,或者工作捏村發起回寫但是共享記憶體不接收
  2. 不允許一個執行緒捨棄assign操作,即當一個執行緒修改了變數後必須寫回工作記憶體和共享記憶體
  3. 不允許一個執行緒將未修改的變數值寫回共享記憶體
  4. 變數只能從共享記憶體中誕生,不允許執行緒直接使用未初始化的變數
  5. 一個變數同一時刻只能由一個執行緒對其執行lock操作,但是一個變數可以被同一個執行緒重複執行多次lock,但是需要相同次數的unlock
  6. 如果對一個變數執行lock操作,那麼會清空工作記憶體中此變數的值,在執行引擎使用這個變數之前需要重新執行load和assign
  7. 不允許unlock一個沒有被鎖定的變數,也不允許unlock一個其他執行緒lock的變數
  8. 對一個變數unlock之前必須把此變數同步回主存當中。

longdouble的特殊操作
在一些32位的處理器上,如果要求對64位的longdouble的寫具有原子性,會有較大的開銷,為了照固這種情況,
java語言規範鼓勵但不要求虛擬機器對64位的longdouble型變數的寫操作具有原子性,當JVM在這種處理器上執行時,
可能會把64位的long和double拆分成兩次32位的寫

指令屏障

為了保證記憶體的可見性,JMM的編譯器會禁止特定型別的編譯器重新排序;對於處理器的重新排序,
JMM會要求編譯器在生成指令序列時插入特定型別的的記憶體屏障指令,通過記憶體屏障指令巾紙特定型別的處理器重新排序

JMM規定了四種記憶體屏障,具體如下:

屏障型別 指令示例 說明
LoadLoad Barriers Load1;LoadLoad;Load2 確保Load1的資料先於Load2以及所有後續裝在指令的裝載
StoreStore Barries Store1;StoreStore;Store2 確保Store1資料對於其他處理器可見(重新整理到記憶體)先於Store2及後續儲存指令的儲存
LoadStore Barriers Load1;LoadStore;Store2 確保Load1的裝載先於Store2及後續所有的儲存指令
StoreLoad Barrier Store1;StoreLoad;Load2 確保Store1的儲存指令先於Load1以及後續所所有的載入指令

StoreLoad是一個“萬能”的記憶體屏障,他同時具有其他三個記憶體屏障的效果,現代的處理器大都支援該屏障(其他的記憶體屏障不一定支援),
但是執行這個記憶體屏障的開銷很昂貴,因為需要將處理器緩衝區所有的資料刷回記憶體中。

happens-before規則

在JSR-133種記憶體模型種引入了happens-before規則來闡述操作之間的記憶體可見性。在JVM種如果一個操作的結果過需要對另一個操作可見,
那麼兩個操作之間必然要存在happens-bsfore關係:

  • 程式順序規則:一個執行緒中的個每個操作,happens-before於該執行緒的後續所有操作
  • 監視器鎖規則:對於一個鎖的解鎖,happens-before於隨後對於這個鎖的加鎖
  • volatitle變數規則:對於一個volatile的寫,happens-before於認意後續對這個volatile域的讀
  • 傳遞性:如果A happens-before B B happends-beforeC,那麼A happends-before C

指令重排序

從源程式到位元組指令的重排序

眾所周知,JVM執行的是位元組碼,Java原始碼需要先編譯成位元組碼程式才能在Java虛擬機器中執行,但是考慮下面的程式;

int a = 1;
int b = 1;

在這段程式碼中,ab沒有任何的相互依賴關係,因此完全可以先對b初始化賦值,再對a變數初始化賦值;

事實上,為了提高效能,編譯器和處理器通常會對指令做重新排序。重排序分為3種:

  1. 編譯器優化的重排序。編譯器在不改變單執行緒的程式語義的前提下,可以安排字語句的執行順序。編譯器的物件是語句,不是位元組碼,
    但是反應的結果就是編譯後的位元組碼和寫的語句順序不一致。
  2. 執行級並行的重排序。現代處理器採用了並行技術,來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
  3. 記憶體系統的重排序,由於處理器使用了快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

資料依賴性:如果兩個操作訪問同一個變數,且兩個操作有一個是寫操作,則這兩個操作存在資料依賴性,改變這兩個操作的執行順序,就會改變執行結果。

儘管指令重排序會提高程式碼的執行效率,但是卻為多執行緒程式設計帶來了問題,多執行緒操作共享變數需要一定程度上遵循程式碼的編寫順序,
也需要將修改的共享資料儲存到共享記憶體中,不按照程式碼順序執行可能會導致多執行緒程式出現記憶體可見性的問題,那又如何實現呢?

as-if-serial語義

as-if-serial語義:不論程式怎樣進行重排序,(單執行緒)程式的執行結果不能被改變。編譯器、runtime和處理器都必須支援as-if-serial語義。

程式順序規則

假設存在以下happens-before程式規則:

    1) A happens-before B
    2) B happens-before C
    3) A happens-before C

儘管這裡存在A happens-before B這一關係,但是JMM並不要求A一定要在B之前執行,僅僅要求A的執行結果對B可見。
即JMM僅要求前一個操作的結果對於後一個操作可見,並且前一個操作按照順序排在後一個操作之前。
但是若前一個操作放在後一個操作之後執行並不影響執行結果,則JMM認為這並不違法,JMM允許這種重排序。

順序一致性模型

在一個執行緒中寫一個變數,在另一個執行緒中同時讀取這個變數,讀和寫沒有通過排序來同步來排序,就會引發資料競爭。

資料競爭的核心原因是程式未正確同步。如果一個多執行緒程式是正確同步的,這個程式將是一個沒有資料競爭的程式。

順序一致性模型只是一個參考模型。

順序一致性模型特性

  • 一個執行緒中所有的操作必須按照程式的順序來執行。
  • 不管執行緒是否同步,所有的執行緒都只能看到一個單一的執行順序。

在順序一致性模型中每個曹祖都必須原子執行且立刻對所有執行緒可見。

當程式未正確同步會發生什麼

當執行緒未正確同步時,JMM只提供最小的安全性,當讀取到一個值時,這個值要麼是之前寫入的值,要麼是預設值。
JMM保證執行緒的操作不會無中生有。為了保證這一特點,JMM在分配物件時,首先會對記憶體空間清0,然後才在上面分配物件。

未同步的程式在JMM種執行時,整體上是無序的,執行結果也無法預知。位同步程式子兩個模型中執行特點有如下幾個差異:

  • 順序一致性模型保證單執行緒內的操作會按照程式的順序執行,而JMM不保證單執行緒內的操作會按照程式的順序執行
  • 順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而JMM不保證所有執行緒能看到一致的操作執行順序
  • JMM不保證對64位的longdouble型變數具有寫操作的原子性,而順序一致性模型保證對所有的記憶體的讀/寫操作都具有原子性

參考資料

java併發程式設計的藝術-方騰飛,魏鵬,程曉明著

相關文章