【JVM盲點補漏系列】「併發程式設計的難題和挑戰」深入理解JMM及JVM記憶體模型知識體系

洛神灬殤發表於2023-03-26

併發程式設計的難題和挑戰

在併發程式設計的技術領域中,對於我們而言的難題主要有兩個:

  1. 多執行緒之間如何進行通訊和執行緒之間如何同步,通訊是指執行緒之間以何種機制來交換資訊。

多執行緒的執行緒通訊機制

在指令式程式設計中,執行緒之間的通訊機制有兩種:共享記憶體訊息傳遞

  • 共享記憶體的方式,多執行緒之間共享公共的狀態(變數),那麼執行緒之間透過寫/讀記憶體中的公共狀態(變數)來隱式進行通訊。在此模式下,同步實現是隱式進行的,由於訊息的傳送必須在訊息的接收之前。
  • 訊息傳遞的方式,多執行緒之間沒有公共的狀態(變數),那麼執行緒之間必須透過明確的傳遞狀態(變數)來顯式進行通訊。在此模式下,同步實現是顯式進行的,必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。

Java中的同步模式是什麼?

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

Java生態中的併發程式設計模型採用的是共享記憶體模型,因此在Java執行緒之間的通訊總是隱式進行, 整個通訊過程對開發者是黑盒的,如果編寫多執行緒程式的開發者不深入理解這種隱式模式下的執行緒之間通訊機制,就會會出現記憶體可見性和一致性的問題,我們統稱為執行緒不安全問題。

存在記憶體可見問題

Java應用程式中, 所有例項域、靜態域和陣列元素儲存在堆記憶體中, 堆記憶體線上程之間共享。會存在這記憶體可見性問題。

不存在記憶體可見問題

區域性變數(Local variables) , 方法定義引數(java語言規範稱之為formal method parameters) 和異常處理器引數(exception handler parameters) 不會線上程之間共享,它們不會有記憶體可見性問題,也不受記憶體模型的影響。

所以,我們在開發多執行緒場景下的程式的時候主要需要關注的就是記憶體可見問題變數,包含:例項域、靜態域和陣列元素。

而為了降低併發程式設計的難度和門檻,這些執行緒之間的資料同步和通訊控制就交由一個特定的資料模型進行控制和管理,我們稱之為Java記憶體模型(JMM)。

Java記憶體模型(JMM)

JMM決定在程式執行中,一個執行緒對共享變數的寫入何時對另一個執行緒可見。

JMM定義了執行緒和主記憶體之間的抽象關係

執行緒之間的共享變數儲存在主記憶體中,每個執行緒都有一個私有的本地記憶體 , 本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。

本地記憶體是JMM的一個抽象概念, 並不真實存在。它涵蓋了快取, 寫緩衝區, 暫存器以及其他的硬體和編譯器最佳化。

Java 記憶體模型的抽象示意圖如下:

由上圖可見,執行緒A與執行緒B之間如要資料通訊,需要有以下兩個步驟:

  1. 執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去。
  2. 執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數

下面透過示意圖來說明這兩個步驟:

如上圖所示,本地記憶體A和B有主記憶體中共享變數x的副本。假設初始時,這三個記憶體中的x值都為0。

  1. 執行緒A在執行時,把更新後的x值,臨時存放在自己的本地記憶體A中。
  2. 執行緒A和執行緒B需要通訊時,執行緒A首先會把自己本地記憶體中修改後的x值重新整理到主記憶體中,此時主記憶體中的x值變了。
  3. 執行緒B到主記憶體中去讀取執行緒A更新後的x值,此時執行緒B的本地記憶體的x值也變了。

總結一下就是,這兩個步驟資料角度而言是執行緒A在向執行緒B傳送訊息,而且這個通訊過程必須要經過主記憶體。JMM透過控制主記憶體與每個執行緒的本地記憶體之間的互動, 來為程式提供記憶體可見性保證。

執行緒不安全因素之一(指令重排序問題)

基於上述所說的場景之下,JVM為了在執行程式時為了提高效能,編譯器和處理器常常會對指令做重排序。在此我們將按照重排序的執行時間前後分為重排序分三種型別,如下圖所示。

  • 第一步屬於編譯器重排序:編譯器最佳化的重排序,編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

  • 第二步屬於處理器重排序:指令級並行的重排序,現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP) 來將多條指令重疊執行。如果不存在資料依賴性, 處理器可以改變語句對應機器指令的執行順序。

  • 第三步屬於處理器重排序:記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行,此處特別是針對與本地記憶體和共享主存之間的更新操作的一致性和可見性

這些重排序都可能會導致多執行緒程式出現記憶體可見性問題。

JMM解決重排序的執行緒不安全問題

解決編譯器級別重排序

  • JMM的編譯器重排序規則會禁止特定型別的編譯器重排序,此處注意:不是所有的編譯器重排序都要禁止

解決處理器級別重排序

  • JMM的處理器重排序規則會要求java編譯器在生成指令序列時, 插入特定型別的記憶體屏障(memory barriers, 也可以稱之為memory fence)指令, 透過 記憶體屏障 指令來禁止特定型別的處理器重排序此處注意:不是所有的處理器重排序都要禁止)

總結一下,針對於JMM屬於語言級的記憶體模型, 它確保在不同的編譯器和不同的處理器平臺之上,透過禁止特定型別的編譯器重排序和處理器重排序,從而實現了記憶體的可見性以及一致性。

處理器重排序與記憶體屏障指令

上面說了其實是透過插入了記憶體屏障指令,從而控制住了對應的處理器級別的指令重排。

執行緒不安全因素之一(寫快取處理模式)

  • 現代的處理器使用寫緩衝區來臨時儲存向記憶體寫入的資料,寫緩衝區可以保證指令流水線持續執行,它可以避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲。

  • 透過以批處理的方式重新整理寫緩衝區,以及合併寫緩衝區中對同一記憶體地址的多次寫,可以減少對記憶體匯流排的佔用。雖然寫緩衝區有這麼多好處,但每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。

這個特性會對記憶體操作的執行順序產生重要的影響,處理器對記憶體的讀/寫操作的執行順序,不一定與記憶體實際發生的讀/寫操作順序一致。

  1. 處理器A處理器B可以同時把共享變數寫入自己的寫緩衝區(A1,B1)
  2. 從記憶體中讀取另一個共享變數(A2,B2)
  3. 最後才把自己寫快取區中儲存的髒資料重新整理到記憶體中(A3,B3)。

從記憶體操作實際發生的順序來看,直到處理器A執行A3來重新整理自己的寫快取區,寫操作A1才算真正執行了。雖然處理器A執行記憶體操作的順序為:A1->A2,但記憶體操作實際發生的順序卻是:A2->A1。此時,處理器A的記憶體操作順序被重排序了(處理器B的情況和處理器A一樣)。

由於現代的處理器都會使用寫緩衝區,因此現代的處理器都會允許對寫-讀操作重排序。常見的處理器都允許Store-Load重排序,常見的處理器都不允許對存在資料依賴的操作做重排序。

記憶體屏障指令

為了保證記憶體可見性, java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。JMM把記憶體屏障指令分為下列四類:

記憶體屏障型別 指令示例 備註
LoadLoad Barries Load1\LoadLoad\Load2 確保Load1資料的裝載,之前於Load2及所有後續裝載指令的裝載
StoreStore Barries Store1\StoreStore\Store2 確保Store1資料對其他處理器可見(重新整理到記憶體),之前於Store2及所有後續儲存指令的儲存。
LoadStore Barriers Load1\ LoadStore\Store2 確保Load1資料裝載, 之前於Store2及所有後續的儲存指令重新整理到記憶體
StoreLoad Barriers Store1\StoreLoad\Load2 確保Storel資料對其他處理器變得可見(指重新整理到記憶體),之前於Load2及所有後續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有記憶體訪問指令(儲存和裝載指令)完成之後,才執行該屏障之後的記憶體訪問指令。

**StoreLoad Barriers是一個“全能型”的屏障, 它同時具有其他三個屏障的效果。現代的多處理器大都支援該屏障(其他型別的屏障不一定被所有處理器支援)。執行該屏障開銷會很昂貴,因為當前處理器通常要把寫緩衝區中的資料全部重新整理到記憶體中(buffer fully flush) **。

未完善待續!

相關文章