JVM (三)--記憶體模型、變數可見性、指令重排、記憶體屏障
Java記憶體模型
Java記憶體模型(JMM):Java定義的一中抽象規範,用來遮蔽不同硬體和作業系統的記憶體訪問差異,讓Java程式在不同平臺下都能達到一致的記憶體訪問效果。
Java記憶體圖示:
1、主記憶體:執行緒共享記憶體,執行緒讀寫訪問較慢;
包括方法區和Java堆,對於一個共享變數(比如靜態變數,堆記憶體中的例項),主記憶體存有其“本尊”。
2、工作記憶體:執行緒私有的記憶體,執行緒訪問較快。
對於主記憶體中的某個變數,使用它的執行緒的記憶體空間儲存了它的一個”副本”。
執行緒對共享變數的所有操作都必須在其工作記憶體中進行,不能直接讀寫主記憶體中的變數。
不同執行緒之間也無法訪問彼此的工作記憶體,變數值線上程之間的傳遞只能通過主記憶體來傳遞。
舉個例子:
對於一個靜態變數 static int s = 0;
執行緒A執行程式碼 s = 3;
那麼,JMM的工作流程如下圖所示:
在上面過程中,執行緒A把靜態變數s=0從主記憶體中讀取到工作記憶體,再把s=3的更新值寫入主記憶體。
這從單執行緒的角度來看,完全沒有任何問題。
但是如果在程式中引入執行緒B,執行緒B執行如下程式碼:
System.out.println("s="+s);
則會出現2種結果,分別為s=0或者s=3。
出現s=0結果原因:執行緒A在工作記憶體中更新的s變數後,不會立即同步到主記憶體,所以雖然執行緒A在工作記憶體當中已經把變數s的值更新成3,但是執行緒B中從主記憶體中獲取到s的變數值仍然是0,所以輸出s=0.
變數可見性
volatile關鍵字具有許多特性,其中最重要的特性就是保證了用volatile關鍵字修飾的變數對所有執行緒的可見性。
變數可見性:當一個執行緒改變了變數的值,新的值會立即同步到主記憶體中。
當其他執行緒讀取這個變數的時候,也會到主記憶體中讀取到最新值。
volatile具有變數可見性:Java具有先行發生原則(如果一件事發生在另一件事之前,結果必須反映,即是這些事情是亂序的)
注意:這裡所謂的事件,實際上就是各種指令操作,比如讀操作、寫操作、初始化操作、鎖操作等等。
上面程式碼: static int s = 0;
改為:volatile static int s = 0;
執行緒A先讀取主記憶體中的s=0,執行緒A在工作記憶體中修改s=3後,立即將s=3同步到主記憶體中,這樣就保證了執行緒B讀取到的s值是被執行緒A修改過的了。
volatile關鍵字雖然能保證變數可見性,但是並不能保證變數的原子性,所以不能保證執行緒安全。
看下面的例子:
public class VolatileTest {
public volatile static int count = 0;
public static void main(String[] args) {
for(int i = 0 ;i < 10;i++){
new Thread(new Runnable() {
@Override
public void run() {
try{
Thread.sleep(1);
}catch (InterruptedException e){
e.printStackTrace();
}
for(int j=0;j<100;j++)
count++ ;
}
}).start();
}
try{
Thread.sleep(2000);
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("count="+count);
}
}
開啟10個執行緒,每個執行緒讓靜態變數count自增100次。
執行結果,可能小於1000:
分析:使用volatile修飾的變數, 為什麼在併發自增的時候會出現這樣的問題呢?
count++這一行程式碼本身並不是原子性操作,在位元組碼層面可以拆分成如下指令:
getstatic //讀取靜態變數(count)
iconst_1 //定義常量1
iadd //count增加1
putstatic //把count結果同步到主記憶體
雖然執行緒A執行getstatic的時候,獲得的count值都是主記憶體中的最新值,但是在getstatic到iadd這個過程中,由於不是原子性操作,其他執行緒在這個過程很可能已經訪問主記憶體中的count並對count的值進行了自增。這樣一來,執行緒A更新的count是沒被其他執行緒更新的陳舊的count值。
適合使用volatile場合:
1)執行結果並不依賴變數當前值,或者能夠確保只有單一的執行緒修改變數的值。(上面例子)
2)變數不需要與其他的狀態變數共同參與不變約束。
例:
volatile static int start = 3;
volatile static int end = 6;
執行緒A執行 while(start<end){/*do something*/}
執行緒B執行 start+=3: end+=3;
這種情況下,一旦線上程A的迴圈中執行了執行緒B,如果執行緒B在執行start+=3;後發生阻塞,則有可能使執行緒A中start==end,從而退出迴圈。
指令重排
指令重排概念:指令重排是指JVM在編譯Java程式碼,或者CPU在執行位元組碼的時候,對現有指令進行重新排序。
指令重排的目的:在不改變程式執行結果的前提下,優化程式的執行效率。
!!!注意,這裡所說的不改變執行結果,指的是不改變單執行緒下的程式執行結果。
在某種情況下,指令重排會影響多執行緒的執行結果。
例:
boolean contextReady = false;
線上程A中執行:
context = loadContext();
contextReady = true;
線上程B中執行:
while(!contextReady){sleep(200);}
doAfterContextReady(context);
以上程式看似沒有什麼問題。執行緒B迴圈等待上下文context的載入,一旦context載入完成,contextReady==true的時候,才執行doAfterContextReady方法。
但是,如果執行緒A執行的程式碼發生了指令重排,初始化和contextReady的賦值重排了執行順序:
boolean contextReady = false;
線上程A中執行:
contextReady = true;
context = loadContext();
線上程B中執行:
while(!contextReady){sleep(200);}
doAfterContextReady(context);
這種情況下,很可能context物件還沒載入外,變數contextReady已經為true,執行緒B直接跳出了迴圈等待,開始執行doAfterContextReady方法,結果自然會出現錯誤。
!!!注意,這裡Java程式碼的重排只是為了簡單示意,真正的指令重排是在位元組碼指令的層面。
記憶體屏障
記憶體屏障:記憶體屏障(Memory Barrier)是一種CPU指令,也稱為記憶體柵欄或者柵欄指令,是一種屏障指令,它使CPU或編譯器對屏障指令之前和之後發出的記憶體操作執行一個排序約束。這通常意味著在屏障之前釋出的操作被保證在屏障之後釋出的操作之前執行。
記憶體屏障分為4種類型:
1)LoadLoad屏障:
抽象場景:Load1;LoadLoad;Load2;
Load1和Load2代表兩條讀取指令。在Load2要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。
2)StoreStore屏障:
抽象場景:Store1;StoreStore;Store2;
Store1和Store2指令代表兩天寫入指令。在Store2寫入資料 執行前,保證Store1寫入資料操作對其他處理器可見。
3)LoadStore屏障:
抽象場景:Load1;LoadStore;Store2;
在Store2寫入資料前,保證Load1要讀取的資料被讀取完畢。
4)StoreLoad屏障:(開銷最大)
抽象場景:Store1;StoreLoad;Load2;
在Load2讀取操作執行前,保證Store1的寫入資料對所有處理器可見。
Volatile在記憶體屏障種起到的作用
在一個變數被volatile修飾後,JVM會為我們做兩件事:
1)在每個volatile變數 寫操作前插入StoreStore屏障,每個volatile寫操作之後插入StoreLoad屏障;
2)在每個volatile變數 讀操作前插入LoadLoad屏障,每個volatile讀操作之後插入LoadStore屏障;
還用剛才的例子:
原先程式碼:
boolean contextReady = false;
線上程A中執行:
context = loadContext();
contextReady = true;
給contextReady 增加volatile修飾符:
volatile boolean contextReady = false;
線上程A中執行:
context = loadContext();
StoreStore屏障
contextReady = true;
StoreLoad屏障
給contextReady增加volatile修飾符後:
在 contextReady = true;這一對有volatile修飾符修飾的contextReady寫操作 前加入了StoreStore屏障;
在 contextReady = true;這一對有volatile修飾符修飾的contextReady寫操作 後加入了StoreLoad屏障;
這樣一來,contextReady = true;前後的指令都不能與其發生指令重排。
volatile關鍵字特性總結:
1)保證變數線上程之間的可見性。可見性的保證是基於記憶體屏障指令的;
2)阻止 JVM在編譯Java程式時 和 CPU執行位元組碼檔案時 的指令重排;編譯時的指令重排遵循記憶體屏障約束,執行時的指令重排依靠於CPU記憶體屏障指令來阻止重排。