一、Java內存模型
硬件處理
電腦硬件,我們知道有用於計算的cpu、輔助運算的內存、以及硬盤還有進行數據傳輸的數據總線。在程序執行中很多都是內存計算,cpu爲了更快的進行計算會有高速緩存,最後同步至主內存,大概的交互如下圖
爲了使處理器內部的運算單元能夠被充分的利用,處理器可能會對輸入代碼進行亂序執行優化,然後將計算後的結果進行重組,保證該結果和順序執行的結果是一致的(單位時間內,一個core只能執行一個線程,所以結果的一致僅限一個線程內)。
Java內存模型
Java內存模型是語言級別的模型,它的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取數變量這樣的底層細節。
在內存裏,java內存模型規定了所有的變量都存儲在主內存(物理內存)中,每條線程還有自己的工作內存,線程對變量的所有操作都必須在工作內存中進行。不同的線程無法訪問別線程的工作內存裏的內容。下圖展示了邏輯上 線程、主內存、工作內存的三者交互關係。
內存交互操作
Java內存模型定義的8個操作指令來進行內存之間的交互
read 讀取主內存的值,並傳輸至工作內存
load 將read的變量值存放到工作內存
read
好比快遞運輸車,工作內存好比站點,運輸車將快遞運輸到站點,站點必須得卸貨 load
。
use 將工作內存的變量值,傳遞給執行引擎
assign 執行引擎對變量進行賦值
use
好比站點進行快遞員分配,站點說我把快遞分給你了快遞員A。快遞員A接收到快遞assign
開始派送。
store 工作內存將變量傳輸到主內存
write 主內存將工作內存傳遞過來的變量進行存儲
store
和 write
就好理解了,快遞員A將快遞送到你家門口(store),然後你得簽收(write)
lock 用作主內存變量,它把一個變量在內存裏標識爲一個線程獨佔狀態
unlock 用作主內存變量,它對被鎖定的變量進行解鎖
下圖展示下工作內存和主內存間的指令操作交互
指令規則
- read 和 load、store和write必須成對出現
- assign操作,工作內存變量改變後必須刷回主內存
- 同一時間只能運行一個線程對變量進行lock,當前線程lock可重入,unlock次數必須等於lock的次數,該變量才能解鎖
- 對一個變量lock後,會清空該線程工作內存變量的值,重新執行load或者assign操作初始化工作內存中變量的值。
- unlock前,必須將變量同步到主內存(store/write操作)
二、重排序
從java源碼到最終實際執行的指令序列,會經歷下面3種重排序
從源碼到最終執行的指令序列的示意圖
重排序的現象
a=1,b=a 這一組 b依賴a,不會重排序;
a=1,b=2 這一組 a和b沒有關係,那麼就有可能被重排序執行 b=2,a=1
-
編譯器優化的重排序
編譯器在不改變單線程程序語義的前提下,可以重新安排語句執行順序 -
指令級並行的重排序
現代處理器採用了指令級並行技術將多條指令重疊執行。在不存在數據依賴的時候,處理器可以改變指令執行順序 -
內存系統重排序
處理器使用高速緩存,使得多運算單元加載和存儲主內存操作看上去可能在亂序執行
重排序的代碼示例,文章底部的參考文章裏有示例,這裏就不羅列了。
三、內存屏障
JMM(java 內存模型) 在不改變程序執行結果的前提下,儘可能的支持處理器的重排序。通過禁止特定特定類型的編譯器重排序和處理器重排序,爲開發者提供一致的內存可見性保證,如 volatile
、final
。
Java編譯器在生成指令的時候會在適當位置插入內存屏障來進制特定類型的處理器排序。
內存屏障說的通俗一點就是一個欄杆,在兩個指令之間插入欄杆,後面的指令就不能越過欄杆先執行。
JMM定義的內存屏障指令分爲4類
-
LoadLoad
指令示例 Load1LoadLoad
Load2
確保Load1數據裝載一定先於Load2及後續所有Load指令 -
LoadStore
指令示例 Load1LoadStore
Store2
確保Load1數據裝載一定先於Store2及後續所有Store指令 -
StoreStore
指令示例 Store1StoreStore
Store2
確保Store1主內存落地(從工作內存刷入主存,其它線程可見)一定先於Store2及後續所有Store指令 -
StoreLoad
指令示例 Store1StoreLoad
Load2
確保Store1主內存落地(從工作內存刷入主存,其它線程可見)一定先於Load2及後續所有Load指令
處理器對重排序的支持
從上面可以看到不同的處理器架構對重排序的支持也是不一樣(其它處理器架構暫不羅列),所以不同的平臺JMM的內存屏障施加也略有不同,具體來說,比如 X86 對Load1Load2不支持重排序,那麼你就沒有必要施加 LoadLoad
屏障。
四、volatile的內存語義
volatile我們都知道是java的關鍵字用來保證數據可見性,防止指令重排的效果。包括JUC裏AQS Lock的底層實現也是基於volatitle來實現。
volatile寫的內存語義
當寫一個volatile變量的時候,JMM會把該線程對應的本地內存變量值刷新到主內存
volatile讀的內存語義
當讀一個volatile變量的時候,JMM會把線程本次內存置爲無效。線程接下來將從主內存中讀取共享變量(也就是重新從主內存獲取值,更新運行內存中的本地變量)
上面兩個語義,保證了volatile變量寫入對線程的可見性
volatile內存屏障插入規則
volatile內存屏障策略
代碼簡單示例
class X {
int a, b;
volatile int v, u;
void f() {
int i, j;
i = a;// load a 普通load
j = b;// load b 普通load
i = v;// load v volatile load
// LoadLoad
j = u;// load u volatile load
// LoadStore
a = i;// store a 普通store
b = j;// store b 普通store
// StoreStore
v = i;// store v volatile store
// StoreStore
u = j;// store u volatile store
// StoreLoad
i = u;// load u volatile load
// 兩個屏障 LoadLoad 和 LoadStore
j = b;// load b 普通load
a = i;// store a 普通store
}
}
上述代碼可以套用volatile屏障規則對應。
當然不同的處理器架構重排序的支持也是不一樣,比如X86 只有當 store1 load2 的時候會進行重排序,那麼就會省略掉很多類型的內存屏障。
五、final的內存語義
final在Java中是一個保留的關鍵字,可以聲明成員變量、方法、類以及本地變量。
被final修飾的變量不能被修改,方法不能被重寫,類不能被繼承。
我們暫時把final修飾的稱作域,對於final域,編譯器和處理器要遵守兩個重排序規則
寫規則
- 在構造函數內對一個final域的寫入,與隨後把這個被構造的對象的引用賦值給一個引用變量,這兩個操作不可重排序
JMM禁止編譯器把final域的寫重排序到構造函數之外
編譯器會在final域寫入的後面插入StoreStore
屏障,禁止處理器把final域的寫重排序到構造函數之外。
該規則可以保證在對象引用爲任意線程可見之前,對象的final域已經被正確初始化,而普通域無法保障。
讀規則
- 初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。
在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操作。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。
該規則保證在讀一個對象的final域之前,一定會先讀包含這個域的對象引用。
我有一個微信公衆號,經常會分享一些Java技術相關的乾貨;如果你喜歡我的分享,可以用微信搜索“Java團長”或者“javatuanzhang”關注。
參考資料