深入了解JMM
-
- MESI緩存一緻性協定
-
-
- 為什麼要有MESI?
- MESI
-
- JMM
-
-
- Java記憶體模型與硬體記憶體架構的關系
- JMM存在的必要性
-
- JMM資料原子操作
-
- 同步規則
- volatile原理與記憶體語義
-
- 可見性
- 原子性
- 有序性
-
-
- As-if-serial 語義
- 記憶體屏障
-
MESI緩存一緻性協定
為什麼要有MESI?
現在的處理器都是多核處理器,并且每個核都帶有多個緩存(指令緩存和資料緩存,見下圖)。為什麼需要緩存呢,這是因為CPU通路記憶體的速度比較慢,是以在CPU和記憶體之間加了個緩存以提高通路速度。既然每個核都有緩存,那麼假設兩個核或者多個核同時通路同一個變量時這些緩存是如何進行同步的呢(緩存細分為一個個緩存行),這就有了MESI協定。
MESI來保證多核間Cache的一緻性。
MESI
CPU緩存的最小機關就是Cache Line(緩存行),MESI描述了Cache Line的四種狀态。
狀态 | 描述 | 監聽任務 |
---|---|---|
M 修改(Modified) | 該Cache line有效,資料被修改了,和記憶體中的資料不一緻,資料隻存在于本Calche中。 | 緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存将該緩存行寫回主存并将狀态變成S (共享)狀态之前被延遲執行。 |
E 獨享、互斥(Exclusive) | 該Cache line有效,資料和記憶體中的資料一緻,資料隻存在于本Cache中。 | 緩存行也必須監聽其他緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S(共享)狀态。 |
S 共享(Shared) | 該Cache line有效,資料和記憶體中的資料一緻,資料存在于很多Cache中。 | 緩存行業必須監聽其他緩存使該緩存行無效或者獨享該緩存行的請求,并将該緩存行變成無效. (Invalid)。 |
I 無效(Invalid) | 該Cache line無效 | 無 |
通過一下案例來加深對MESI的了解。
假設有三個CPU1、CPU2、 CPU3,對應三個緩存分别是cache1、cache2、 cache3。 在主記憶體中定義
了x變量的值為1。CPU1h和CPU2并發的對x進行+1操作。
- CPU1從記憶體讀取變量x。CPU1會向總線發送一條讀的消息,CPU1讀取到資料之後,會将該緩存行cache1的狀态設定為E(獨享,在底層彙編中加lock信号,保證緩存的一緻性)。
- CPU2讀取變量x。再CPU1還沒有将x會寫入記憶體的時候,CPU2也發送了一條讀x變量的信号,通過總線嗅探機制,CPU1會嗅探到CPU2要讀取CPU1中的緩存行對應于記憶體的區域,那麼CPU1的緩存行的狀态将會由E轉換為S(共享狀态),并且CPU2對應的緩存行也是S狀态。
- CPU1修改資料。CPU1又向總線發送消息要求修改x變量,那麼CPU1将會鎖住該緩存行,并将狀态由S改為M(修改狀态);CPU2嗅探到CPU1要修改變量x,那麼CPU2會将相應的緩存行的狀态由S改為I(無效)。
- 同步資料。當CPU1将資料x寫回記憶體後,其對應緩存行狀态由M轉換為E,當CPU2再次發送讀消息時,CPU1狀态由E改為S,CPU2的狀态由I改為S。
原始MESI協定實作時的性能問題:
1.對于進行本地寫事件的CPU,遠端CPU處理失效并進行響應确認相對處理器自身的指令執行速度來說是相當耗時的,在等待所有CPU響應的過程中令處理器空轉效率并不高。
2.對于響應遠端寫事件的CPU,在其高速緩存壓力很大時,要求實時的處理失效事件也存在一定的困難,會有一定的延遲。
不進行優化的MESI協定在實際工作中效率會非常的低下,是以CPU的設計者在實作時對MESI協定進行了一定的改良。
例如:
- 本地緩存行将會通過寄存器控制器向遠端擁有相同緩存行的寄存器發送一個RFO請求(Request For Owner),告訴其他CPU裡面的緩存把緩存裡面的值為valid狀态,然後待收到各個緩存的(valid ack)已經完成無效狀态修改的回應之後,
- 再把自己的狀态改為Exclusive,之後再進行修改。
- 修改後再改為Modified狀态,資料寫入緩存行。
上面這幾步大家可以看到第一步的時候,CPU需要在等待所有的valid ack之後才會進行下面的操作。這部分就會讓CPU産生一定的阻塞,無法充分利用CPU。這個時候就印出來了存儲緩沖區 storeBuffer。
存儲緩存(Store Bufferes)
針對上述本地寫事件需要等待遠端處理器ACK确認,阻塞本地處理器的問題,引入了存儲緩存機制。
修改一個變量的時候,直接執行修改的操作不直接鎮對緩存行,而是針對一個叫制作storeBuffer的位置來操作的。這樣CPU在執行修改操作的時候,**直接把資料寫入到storeBuffer裡面,并發出廣播告知其他CPU,你們的緩存裡面需要變為validate狀态,然後去執行其他的操作,**等接受到validate ack的時候才會回來把緩沖區裡面的值寫入到緩存行裡面。
存儲緩存是屬于每個CPU核心的。當使用了存儲緩存後,每當發生本地寫事件時,本地CPU不再阻塞的等待遠端核的确認響應,而是将寫入的新值放入存儲緩存中,繼續執行後面的指令。存儲緩存會替處理器接受遠端CPU的ACK确認,當對應本地寫事件廣播得到了全部遠端CPU的确認後,再送出事務,将其新值寫入本地高速緩存中。存儲緩存的大小是十分有限的,當堆積的事務滿了之後,依然會阻塞CPU,直到有事務送出釋放出新的空間。
存儲緩存的引入将本地寫事件—>等待遠端寫通知确認消息并送出這一事務,從同步、強一緻性變成了異步、最終一緻性,提高了本地寫事件的處理效率。
本地處理器在進行本地讀事件時,由于可能存儲緩存中新修改的資料還未送出到本地緩存中,這就會造成一個核心内,對于同一緩存行其後續指令的讀操作無法讀取到之前寫操作的最新值。為此,在進行本地讀操作時,處理器會先在存儲緩存中查詢對應記錄是否存在,如果存在則會從存儲緩存中直接擷取,這一機制被稱為Store Fowarding。
失效隊列(Invalid Queue)
針對上述遠端核心響應遠端寫事件,實時的将對應緩存行設定為Invalid無效狀态延遲高的問題,引入了失效隊列機制。
失效隊列同樣是屬于每個CPU核心的。當使用了失效隊列後,每當監聽到遠端寫事件時,對應的高速緩存不再同步的處理失效緩存行後傳回ACK确認資訊,而是将失效通知存入失效隊列,立即傳回ACK确認消息。對于失效隊列中的寫失效通知,會在空閑時逐漸的進行處理,将對應的高速緩存中的緩存行設定為無效。失效隊列的引入在很大程度上緩解了存儲緩存空間有限,容易阻塞的問題。
處理失效的緩存也不是簡單的,需要讀取主存。并且存儲緩存也不是無限大的,那麼當存儲緩存滿的時候,處理器還是要等待失效響應的。為了解決上面兩個問題,引進了失效隊列(invalidate queue0)。
處理失效的工作如下:
- 收到失效消息時,放到失效隊列中去。
- 為了不讓處理器久等失效響應,收到失效消息需要馬上回複失效響應。
- 為了不頻繁阻塞處理器,不會馬上讀主存以及設定緩存為invlid,合适的時候再一塊處理失效隊列。
失效隊列的引入将監聽到遠端寫事件處理失效緩存行—>傳回ACK确認消息這一事務,從同步、強一緻性變成了異步、最終一緻性,提高了遠端寫事件的處理效率。
存儲緩存和失效隊列的引入在提升MESI協定實作的性能同時,也帶來了一些問題。由于MESI的高速緩存一緻性是建立在強一緻性的總線串行事務上的,而存儲緩存和失效隊列将事務的強一緻性弱化為了最終一緻性,使得在一些臨界點上全局的高速緩存中的資料并不是完全一緻的。
即MESI在一個CPU緩存行修改了之後,并沒有立即将緩存重新整理到主記憶體,其他CPU緩存行嗅探到了cache的改變重新去主記憶體擷取了舊值。
對于一般的緩存資料,基于異步最終一緻的緩存間資料同步不是大問題。但對于并發程式,多核高速緩存間短暫的不一緻将會影響共享資料的可見性,使得并發程式的正确性無法得到可靠保證,這是十分緻命的。但CPU在執行指令時,缺失了太多的上下文資訊,無法識别出緩存中的記憶體資料是否是并發程式的共享變量,是否需要舍棄性能進行強一緻性的同步。
CPU的設計者提供了記憶體屏障機制将對共享變量讀寫的高速緩存的強一緻性控制權交給了程式的編寫者或者編譯器,在緩存行修改。
參考連結:https://blog.csdn.net/qq_30055391/article/details/84892936
https://www.cnblogs.com/xiaoxiongcanguan/p/13184801.html
JMM
Java Memory Model簡稱JMM。
JMM與JVM記憶體區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過這組規則控制程式中各個變量在共享資料區域和私有資料區域的通路方式, JMM是圍繞原子性、有序性、可見性展開。
注意: JMM與JVM記憶體區域是不同的概念,JMM是抽象的概念,不是真實的;而JVM記憶體模型是真實的。JMM與JVM記憶體區域和前面的多核CPU緩存架構很相似,不過是一種抽象概念。
Java記憶體模型與硬體記憶體架構的關系
JMM模型跟CPU緩存模型結構類似,是基于CPU緩存模型建立起來的,JMM模型是标準化的,屏蔽掉了底層不同計算機的差別。對于硬體記憶體來說隻有寄存器、緩存記憶體、主記憶體的概念,并沒有工作記憶體(線程私有資料區域)和主記憶體(堆記憶體)之分,也就是說Java記憶體模型對記憶體的劃分對硬體記憶體并沒有任何影響。
JMM存在的必要性
由于JVM運作程式的實體是線程,而每個線程建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用于存儲線程私有的資料,線程與主記憶體中的變量操作必須通過工作記憶體間接完成,主要過程是将變量從主記憶體拷貝的每個線程各自的工作記憶體空間,然後對變量進行操作,操作完成後再将變量寫回主記憶體,如果存在兩個線程同時對一個主記憶體中的執行個體對象的變量進行操作就有可能誘發線程安全問題。
JMM資料原子操作
8個原子操作描述了 主記憶體 與 工作記憶體 之間的互動細節。
- lock(鎖定): 作用于主記憶體的變量,把一個變量标記為一條線程獨占狀态。
- unlock(解鎖): 作用于主記憶體的變量,把一-個處于鎖定狀态的變量釋放出來,釋放後的變量才可以被其他線程鎖定。
- read(讀取): 作用于主記憶體的變量,把-一個變量值從主記憶體傳輸到線程的工作記憶體中,以便随後的load動作使用。
- load(載入): 作用于工作記憶體的變量,它把read操作從主記憶體中得到的變量值放入工作記憶體的變量副本中。
- use(使用): 作用于工作記憶體的變量,把工作記憶體中的一個變量值傳遞給執行引擎。
- assign(指派): 作用于工作記憶體的變量,它把一個從執行引擎接收到的值賦給工作記憶體的變量。
- store(存儲): 作用于工作記憶體的變量,把工作記憶體中的一個變量的值傳送到主記憶體中,以便随後的write的操作。
- write(寫入): 作用于工作記憶體的變量,它把store操作從工作記憶體中的一個變量的值傳送到主記憶體的變量中。
同步規則
- 不允許一個線程無原因地(沒有發生過任何assign操作) 把資料從工作記憶體同步回主記憶體中
-
一個新的變量隻能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或者
assign)的變量。即就是對一個變量實施use和store操作之前,必須先自行assign和load操作。
-
一個變量在同一時刻隻允許一條線程對其進行lock操作, 但lock操作可以被同一線程重複執行多次,
多次執行lock後,隻有執行相同次數的unlock操作,變量才會被解鎖。lock和unlock必須成對出現。
-
如果對一個變量執行lock操作,将會清空工作記憶體中此變量的值,在執行引擎使用這個變量之前需
要重新執行load或assign操作初始化變量的值。
-
如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被
其他線程鎖定的變量。
- 對一個變量執行unlock操作之前,必須先把此變量同步到主記憶體中(執行store和write操作)
Demo練習
public class JMMDemo {
public boolean initFlag = false;
public void load(){
String name = Thread.currentThread().getName();
while (!initFlag){
}
System.out.println("線程"+name+"嗅探到initFlag的狀态改變");
}
public void refresh(){
String name = Thread.currentThread().getName();
initFlag = true;
System.out.println("線程"+name+"将initFlag改變了");
}
public static void main(String[] args) {
JMMDemo jmmDemo = new JMMDemo();
Thread threadA = new Thread(()->{
jmmDemo.refresh();
},"threadA");
Thread threadB = new Thread(() -> {
jmmDemo.load();
},"threadB");
threadB.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
threadA.start();
}
}
兩個線程的工作記憶體和主記憶體的互動流程如下:
運作結果如下:
說明線程2将資料改了,線程1并不知道,這是由于共享資料不可見導緻的。
當對initFlag變量加了Volatile關鍵字修飾後,運作結果如下:
volatile原理與記憶體語義
volatile是Java虛拟機提供的輕量級的同步機制。
volatile語義有如下兩個作用:
- 可見性:保證被volatile修飾的共享變量對所有線程總是可見的,也就是當一個線程修改了被volatile修飾的共享變量的值,新值總是可以被其他線程立即得知。
- 有序性:禁止指令重排序優化。
可見性
volatile緩存可見性實作原理:
- JMM記憶體互動層面: volatile修飾的變量的read. load. use操作和assign. store. write必須是連續的,即修改後必須立即同步回主記憶體,使用時必須從主記憶體重新整理,由此保證volatile的可見性。
- 底層實作:通過彙編lock字首指令,它會鎖定變量緩存行區域并寫回主記憶體,這個操作稱為”緩存鎖定”,緩存一緻性機制會阻止同時修改兩個以上處理器緩存的記憶體區域資料。一個處理器的緩存回寫到記憶體會導緻其他處理器的緩存失效。
總之,Volatile關鍵字的底層就是MESI和記憶體屏障實作的。 被volatile關鍵字修飾的變量,由于記憶體屏障底層的lock指令,就會觸發MESI協定;開始時,兩個線程都獲得了資料,是以狀态為S,當線程2要修改資料是,狀态變為M,由于底層lock指令會立馬将工作記憶體的值重新整理到主記憶體;那麼線程2通過總線嗅探機制會嗅探到變量的修改,則線程1的資料狀态變為I;将會重新從主記憶體中擷取值,進而實作資料一緻性。
在早期使用的是總線加鎖,而不是EMSI。
總線加鎖(性能太低)
CPU從主記憶體讀取資料到高速緩存,會在總線對這個資料加鎖,這樣其他CPU就無法讀或寫這個資料,直到這個CPU使用完資料釋放鎖之後其他CPU才能擷取該資料。這樣會導緻所有的線程會串行化執行,雖然解決了資料一緻性問題,但是性能低。
和volatile相比,都有加鎖lock,為什麼總線加鎖性能低?
volatile加鎖的粒度極小,隻有在資料向主記憶體寫回的時才加鎖,其餘期間,其他CPU也能通路該資料。
原子性
直接來看一個自增的案例:
private static void atomicDemo() {
System.out.println("原子性測試");
MyData myData=new MyData();
for (int i = 1; i <= 10; i++) {
new Thread(()->{
for (int j = 0; j <1000 ; j++) {
myData.addPlusPlus();
}
},String.valueOf(i)).start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t int type finally number value: "+myData.number);
}
class MyData{
int number=0;
public void addPlusPlus(){
number++;
}
}
10個線程,每個線程對number自增1000次;按照我們預期的想法以為最後輸出10000,但實際結果總是 <= 10000;結果如下:
運作結果如下:
或許有的人會想到之前将的 共享資料的記憶體不可及所導緻的,當然也有這個原因,我們加volatile關鍵字試試:
結果如下:
加了volatile還是小于10000,這是什麼原因呢?------volatile關鍵字不保證原子性操作。
不保證原子性操作的原因
volatile ++ 的操作分為4個步驟:
load、Increment、store、Memory Barriers 四個操作。
volatile關鍵字開啟了EMSI協定,而EMSI協定的底層也是由記憶體屏障來實作的。
如果你的字段是volatile,Java記憶體模型将在寫操作後插入一個寫屏障指令,在讀操作前插入一個讀屏障指令。這意味着如果你對一個volatile字段進行寫操作,你必須知道:1、一旦你完成寫入,任何通路這個字段的線程将會得到最新的值。2、在你寫入前,會保證所有之前發生的事已經發生,并且任何更新過的資料值也是可見的,因為記憶體屏障會把之前的寫入值都重新整理到緩存。
從Load到store到記憶體屏障,一共4步,其中最後一步jvm讓這個最新的變量的值在所有線程可見,也就是最後一步讓所有的CPU核心都獲得了最新的值,但中間的幾步(從Load到Store)是不安全的。
在某一時刻線程1将number的值(0)load取出來,放置到cpu緩存中,然後再将此值放置到寄存器A中,然後寄存器A中的值自增1,寄存器A中儲存的是中間值,沒有直接修改number(還沒有進行assign操作,也就不是M狀态),是以其他線程并不會擷取到這個自增1的值 。如果在此時線程2也執行同樣的操作,擷取值number=0,自增1變為1,然後馬上刷入主記憶體。此時由于線程2修改了number的值,實時的線程1中的number=0的值緩存失效,重新從主記憶體中讀取值number=1。接下來線程1恢複。A寄存器先前已經操作過了,不會重新從緩存中擷取number進行+1操作,将自增過後的A寄存器值1指派給cpu緩存number。這樣就出現了線程安全問題。
有序性
指令重排序
CPU為了提高程式運作的性能,允許編譯器和處理器對指令的執行順序進行重新編排。java語言規範規定JVM線程内部維持順序化語義。即隻要程式的最終結果與它順序化情況的結果相等,那麼指令的執行順序可以與代碼順序不一緻,此過程叫指令的重排序。
指令重排序需要遵守 As-if-serial 語義
As-if-serial 語義
As-if-serial 語義:不管怎麼重排序(編譯器和處理器為了提高并行度),(單線程)程式的執行結果不能被改變。編譯器、runtime 和處理器都必須遵守as-if-serial語義。
指令重排階段:
- 編譯器優化重排序 :位元組碼翻譯成機器碼的階段。
- 指令級并行重排序 :CPU處理器的執行階段。
案例分析
public class VolatileReOrderSample {
static int a,b;
static int x,y;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (;;){
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread thread1 = new Thread(() -> {
a = 1;
x = b;
});
Thread thread2 = new Thread(() -> {
b = 1;
y = a;
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
String result = "第"+i+"次("+x+","+y+")";
if (x==0 && y==0){
System.err.println(result);
break;
}else{
System.out.println(result);
}
}
}
}
按照正常代碼的順序執行的話,x和y的結果應該有以下可能:
(1,0),(0,1),(1,1)
在程式運作結果中出現了(0,0)的結果
這就是指令重排的原因,本來a = 1 在 x = b之前執行,重排後可能是a = 1 在 x = b之後執行,那麼就可能出現(0,0)的結果。
Volatile關鍵字可以解決指令重排所導緻的亂序的情況。
加了Volatile關鍵字後,就會按照指令的順序執行,禁止了指令排序。
volatile重排序規則
下圖是JMM針對編譯器制定的volatile重排序規則表
- 當第一個操作為普通的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作(1,3)
- 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確定volatile讀之後的操作不會被編譯器重排序到volatile讀之前(第二行)
- 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序(3,2)
- 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序(第三列)
volatile如何實作禁止指令重排序?
為了實作volatile關鍵字的記憶體語義,編譯器在生成位元組碼時,會在指令的序列中通過插入記憶體屏障來禁止指令重排序。
記憶體屏障
記憶體屏障(memory barrier)是一個CPU指令:lock; addl $0,0(%%esp)。它的作用有兩個:
a) 確定一些特定操作執行的順序;
b) 影響一些資料的可見性(可能是某些指令執行後的結果)。
編譯器和CPU可以在保證輸出結果一樣的情況下對指令重排序,使性能得到優化。在指令插入一個記憶體屏障,于告訴CPU和編譯器先于這個指令的必須先執行,後于這個指令的必須後執行。記憶體屏障另一個作用是強制更新一次不同CPU的緩存。例如,一個寫屏障會把這個屏障前寫入的資料重新整理到緩存,這樣任何試圖讀取該資料的線程将得到最新值,而不用考慮到底是被哪個cpu核心或者哪顆CPU執行的。
總之,volatile關鍵字正式通過記憶體屏障實作其在記憶體中的語義,即可見性和禁止重排優化。
記憶體屏障指令:
- 寫記憶體屏障(Store Memory Barrier):處理器将目前存儲緩存的值寫回主存,以阻塞的方式。
- 讀記憶體屏障(Load Memory Barrier):處理器處理失效隊列,以阻塞的方式。
4種類型的記憶體屏障:
屏障類型 | 指令示例 | 說明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | Load1在Load2之前讀取完成 |
StoreStoreBarriers | Store1;StoreStore;Store2 | Store1在Store2之前寫入完成,并對所有處理器可見 |
LoadStore Barriers | Load1;LoadStore;Store2 | Load1在Store2 之前讀取完成 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | Store1在Load2 之前寫入完成,并對所有處理器可見 |
下面是基于保守政策的JMM記憶體屏障插入政策。
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的後面插入一個Storel oad屏障。
- 在每個volatile讀操作的後面插入一個LoadLoad屏障。
- 在每個volatile讀操作的後面插入一個LoadStore屏障。