Java記憶體模型的基礎
在并發編程中,需要處理兩個關鍵問題:執行緒之間如何通信及執行緒之間如何同步,通信指執行緒之間以何種機制來交換資訊,在命令式編程中,執行緒之間的通信機制有兩種:共享記憶體和訊息傳遞,
Java語言的并發采用的是共享記憶體模型,Java執行緒之間的通信總是隱式進行,整個通信程序對程式員完全透明,Java執行緒之間的通信由Java記憶體模型簡稱JMM(Java Memory Mode)控制,JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見,從抽象的角度來看,JMM是這樣定義執行緒和主記憶體之間的抽象關系的:執行緒之間的共享變數存盤在主記憶體(Main Memory)中,每個執行緒都有一個私有的本地記憶體(Local Memory),本地記憶體中存盤了該執行緒以讀/寫共享變數的副本,
主記憶體主要對應用于Java堆中的物件實體資料部分,而本地記憶體則對應于虛擬機堆疊中的部磁區域,從更基礎的層面上說,主記憶體直接對應于物理硬體記憶體,而為了獲取更好的運行速度,虛擬機可能會讓本地記憶體優先存盤于暫存器和高速快取中,因為程式運行時主要訪問的是本地記憶體,
本地記憶體是JMM的一個抽象概念,并不是真實存在的,它涵蓋了快取、寫緩沖區、暫存器以及其他硬體和編譯器優化,Java記憶體模型的抽象示意圖如下所示,

從示意圖中來看,如果執行緒A與執行緒B之間要進行通信,必須經歷如下2個步驟,
1. 執行緒A把本地記憶體中更新過的共享變數重繪到主記憶體中,
2. 執行緒B從主記憶體中讀取執行緒A之前更新的共享變數,
重排序
在執行程式的程序中,為了提高性能,編譯器和處理器常常會對指令做重排序,重排序分3中型別,
1. 編譯器優化重排序,
Java虛擬機的即時編譯器中存在指令重排序(Instruction Reorder),編譯器在不改變單執行緒程式語意的前提下,可以重新安排陳述句執行,
2. 指令級并行的重排序,
現代處理器采用了指令級并行技術來將多條指令重疊執行,如果不存在資料依賴性,處理器可以改變陳述句對應機器指令的執行順序,
3. 記憶體系統的重排序,
由于處理器使用快取和讀/寫緩沖區,當多個處理器的運算任務都設計同一塊記憶體區域時,資料的加載和存盤操作看上去可能是亂序執行的,
從Java源代碼到最終實際執行的指令序列,會分別經歷下面3中重排序,

上述1屬于編譯器重排序,2、3屬于處理器重排序,這些重排序可能會導致多執行緒程式出現記憶體可見性問題,對于編譯器,JMM的編譯器重排序規則會禁止特定型別的編譯器重排序(不是所有的編譯器重排序都要禁止),對于處理器重排序,Java編譯器在生成指令序列時,插入特定的記憶體屏障(Memory Barriers)指令,通過記憶體屏障來禁止特定型別的處理器重排序,
編譯器和處理器為了優化程式性能,可能會對指令序列進行重新排序,下表展示了常見處理器允許重排序的型別串列,(Load:裝載 Store:儲存)
| Load-Load | Load-Store | Store-Store | Store-Load | 資料依賴 | |
| SPARC-TSO | N | N | N | Y | N |
| x86 | N | N | N | Y | N |
| IA64 | Y | Y | Y | Y | N |
| PowerPC | Y | Y | Y | Y | N |
表格中“N”表示處理器不允許兩個操作重排序,“Y”表示允許重排序,從表中可以看到,常見處理器都允許Store-Load重排序;常見處理器都不允許對存在資料依賴的操作做重排序,SPARC-TSO和X86處理器擁有相對較強的處理器記憶體模型,它們僅僅允許對寫-讀操作做重排序(因為它們都使用了寫緩沖區),
為了保證記憶體可見性,Java編譯器在生成指令序列時的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序,JMM把記憶體屏障分為4類,如下表所示,
| 屏障型別 | 指令示例 | 說明 |
| LoadLoad Barriers | Load1;LoadLoad;Load2 |
確保Load1資料的裝載先于Load2及所有后續裝載指令的裝載, |
| StoreStore Barriers | Store1; StoreStore;Store2 |
確保Store1資料對其他處理器可見(重繪到記憶體)先于Store2 及后續所有存盤指令, |
| LoadStore Barriers | Load1;LoadStore;Store2 |
確保Load1資料裝載先于Store2及所有后續的存盤指令重繪到記憶體, |
| StoreLoad Barriers |
Store1;StoreLoad;Load2 |
確保Store1資料對其他處理器可見(重繪到記憶體)先于Load2及所有 后續裝載指令的裝載, StoreLoad Barriers會使該記憶體屏障之前的所有記憶體訪問指令(存盤 和裝載)完成之后,才執行該屏障之后的記憶體訪問指令, |
并發編程模型
由于計算機的存盤設備和處理器的運算速度有著幾個量級的差距,所以現代計算機系統加入一層或者多層讀寫速度盡可能接近處理器速度的高速快取(Cache)來作為記憶體與處理器之間的快取,寫緩沖區可以保證指令流水線持續運行,它避免由于處理器停頓下來等待向記憶體寫入資料而產生的延遲,同時,通過批處理的方式重繪寫緩沖區,以及合并寫緩沖區對同一記憶體地址的多次寫,減少對記憶體總線的占用,
高速快取雖然解決了處理器與記憶體速度之間的矛盾,但是引入了新的問題:快取一致性(Cache Chherence),
下面用一個例子來具體說明:
| 處理器A | 處理器B | |
| 代碼 |
a = 1; //A1 x = b; //A2 |
b = 2; //B1 y = a; //B2 |
| 運行結果 |
初始狀態:a = b = 0 處理器允許執行后得到結果: x = y = 0 |
|
假設有處理器A和處理器B按照程式順序并行執行記憶體訪問,最終可能得到 x = y = 0 的結果,具體原因如下圖所示,

這里處理器A和處理器B可以同時把共享變數寫入自己的寫緩沖區(A1,B1),然后從記憶體中讀取另一個共享變數(A2,B2),最后才把自己寫緩沖區里中保存的臟資料重繪到記憶體中(A3,B3),當以這種時序執行時,程式就有可能得到 a = b = 0 的結果,
從記憶體操作的實際發生順序來看,直到處理器A執行A3來重繪自己的寫緩沖區,寫操作A1才算真正執行完成,雖然處理器A執行記憶體操作的順序為A1 → A2,但記憶體操作實際發生順序卻是A2 → A1,此時處理器A的記憶體操作順序被重排序了(處理器B的情況一樣),
as-if-serial 語意
as-if-serial語意的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單執行緒)程式的執行結果不能被改變,編譯器和處理器都必須遵守as-if-serial語意,
為了遵守as-if-serial語意,編譯器和處理器不會對存在資料依賴的操作做重排序,因為這種操作會改變執行結果,但是如果操作之間不存在資料依賴關系,這些操作就有可能被重排序,下面舉例說明,
1 double pi = 3.14; // A 2 double r = 1.0; // B 3 double area = pi * r * r; // C
上面這段代碼所示,A和C之間存在資料依賴關系,B和C之間也存在資料依賴關系,因此操作C不能被重排序到A和B前面(這樣程式的執行結果將被改變),但是A和B之間沒有資料依賴關系,編譯器和處理器可以重排序A和B之間的順序,這段程式可能存在兩種執行順序,如下圖所示,

那哪些操作之間會存在資料依賴呢?如果兩個操作訪問同一個變數,且者兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性,資料依賴分為下列3種型別,
| 名稱 | 代碼示例 | 說明 |
| 寫后讀 |
a = 1; b = a; |
寫一個變數之后,再讀這個變數 |
| 寫后寫 |
a = 1; a = 2; |
寫一個變數之后,再寫這個變數 |
| 讀后寫 |
a = b; b = 1; |
讀一個變數之后,再寫這個變數 |
以上3種情況,只要重排序兩個操作之間的執行順序,程式的執行結果就會被改變,所以編譯器和處理器重排序時,會遵守資料依賴性,不會改變存在資料依賴性的兩個操作的執行順序,但是這里說的資料依賴性僅僅指對單個處理器中的指令序列和單個執行緒中執行的操作,不同處理器和不同執行緒之間的資料依賴性不被編譯器和處理器考慮,
參考資料:《Java并發編程的藝術》、《深入理解Java虛擬機》
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/80798.html
標籤:Java
