內存可見性問題
在主線程對變量的修改對於線程讀取該變量是不可見的,線程讀取的是本地內存緩存的變量值。
如何解決共享變量可見性的問題
使用volatile變量,可以解決共享數據在多線程環境下可見性的問題。
使用volatile關鍵字修飾變量后,在生成匯編指令的時候,會生成一個lock指令。
思考lock匯編指令來保證可見性問題?
lock指令在多核處理器下會引發兩件時間:
- 會將當前處理器緩存行的數據寫回到系統內存
- 這個會寫內存的操作會使其他CPU里緩存了該地址內存的數據無效。
什么是可見性?
在多線程環境下,讀和寫發生在不同的線程中,可能會出現:讀線程不能及時的讀取到其他線程寫入的最新的值,這就是所謂的可見性問題。
硬件層面
CPU/內存/IO設備之間存在讀取速度的差距和存儲大小的差異
為了減小各類型設備之間的讀取效率差異,增加CPU的處理效率
- CPU層面增加了高速緩存
- 操作系統,進程-》線程 | 利用CPU時間片來切換
- 編譯器的優化,更合理的利用CPU的高速緩存。
CPU層面的高速緩存
因為高速緩存的存在,會導致一個緩存一致性問題。
總線鎖和緩存鎖
總線鎖
總線鎖:在多核CPU下,當其中一個處理器要對共享內存進行操作的時候,在總線上發出一個LOCK#信號,這個信號使得其他CPU無法通過總線來訪問到共享內存中的數據,總線鎖定把CPU和內存之間的通信鎖住了,在鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖的開銷比較大,這種機制顯然是不合理的。
緩存鎖
優化方法:最好的辦法是控制控制鎖的保護粒度,我們只需要保證對於被多個CPU緩存的同一份數據是一致的就行。在P6架構的CPU后,引入了緩存鎖,如果當前數據已經被CPU緩存了,並且是要寫回到主內存中的,就可以采用緩存鎖來解決問題。
所謂的緩存鎖,就是指內存區域如果被緩存在處理器的緩存行中,並且在Lock期間被鎖定,那么當它執行鎖操作回寫到內存時,不再總線上加鎖,而是修改內部的內存地址,基於緩存一致性協議來保證操作的原子性。
X86架構下:引入了緩存鎖。由高速緩存寫入主內存的時候,不會在總線上加鎖。只有數據寫入高速緩存的時候,才會加上緩存鎖。在數據寫入高速緩存的時候,才可以使用緩存鎖,緩存鎖具有一定的機制(MESI),保證多個高速緩存之間數據的一致性。
總線鎖和緩存鎖怎么選擇,取決於很多因素,比如CPU是否支持、以及存在無法緩存的數據時(比較大或者快約多個緩存行的數據),必然還是會使用總線鎖。
緩存一致性協議
MSI MESI MOSI
為了達到數據訪問的一致,需要各個處理器在訪問緩存時遵循一些協議,在讀寫時根據協議來操作,常見的協議有MSI,MESI,MOSI等。最常見的就是MESI協議。
MESI表示緩存行的四種狀態,分別是
1.M(Modify) 表示共享數據只緩存在當前CPU緩存中,並且是被修改狀態,也就是緩存的數據和主內存中的數據不一致
2.E(Exclusive) 表示緩存的獨占狀態,數據只緩存在當前CPU緩存中,並且沒有被修改
3.S(Shared) 表示數據可能被多個CPU緩存,並且各個緩存中的數據和主內存數據一致
4.I(Invalid) 表示緩存已經失效
寫線程是如何讓其他CPU緩存失效的?
CPU0在寫入本地高速緩存時,會向其他cpu發出一個失效通信,告知該緩存失效,而其他cpu會向cpu0發送一個回執通信ACK,cpu0在收到該ack時,才會繼續向下執行其他指令,此處為一個強一致性,優秀的工程師為了解決這一問題,又引入了store buffers.
而引入store buffers之后,導致了指令重排序問題。
工程師考慮如何提高CPU的利用率,但是業務場景中如何使用是開發人員的事情。無法從硬件層面解決業務上的問題,繼而提供了內存屏障。
MESI的一個優化
Store Bufferes(寫緩沖區)
Store Bufferes是一個寫的緩沖,對於上述描述的情況,CPU0可以先把寫入的操作先存儲到StoreBufferes中,Store Bufferes中的指令再按照緩存一致性協議去發起其他CPU緩存行的失效。而同步來說CPU0可以不用等到Acknowledgement,繼續往下執行其他指令,直到收到CPU0收到Acknowledgement再更新到緩存,再從緩存同步到主內存。
失效隊列:什么時候失效,cpu空閑的時候。
Store Bufferes 失效隊列會導致不一致性,需要控制及時的更新和失效。
指令重排序
通過內存屏障禁止了指令重排序
X86的memory barrier指令包括lfence(讀屏障) sfence(寫屏障) mfence(全屏障)
- Store Memory Barrier(寫屏障) ,告訴處理器在寫屏障之前的所有已經存儲在存儲緩存(storebufferes)中的數據同步到主內存,簡單來說就是使得寫屏障之前的指令的結果對屏障之后的讀或者寫是可見的
- Load Memory Barrier(讀屏障) ,處理器在讀屏障之后的讀操作,都在讀屏障之后執行。配合寫屏障,使得寫屏障之前的內存更新對於讀屏障之后的讀操作是可見的
- Full Memory Barrier(全屏障) ,確保屏障前的內存讀寫操作的結果提交到內存之后,再執行屏障后的讀寫操作
軟件層面
使用volatile提供對內存屏障指令的調用。
JMM(JavaMemoryModel)
JMM定義了共享內存中多線程程序讀寫操作的行為規范:在虛擬機中把共享變量存儲到內存以及從內存中取出共享變量的底層實現細節。通過這些規則來規范對內存的讀寫操作從而保證指令的正確性,它解決了CPU多級緩存、處理器優化、指令重排序導致的內存訪問問題,保證了並發場景下的可見性.
需要注意的是,JMM並沒有主動限制執行引擎使用處理器的寄存器和高速緩存來提升指令執行速度,也沒主動限制編譯器對於指令的重排序,也就是說在JMM這個模型之上,仍然會存在緩存一致性問題和指令重排序問題。JMM是一個抽象模型,它是建立在不同的操作系統和硬件層面之上對問題進行了統一的抽象,然后再Java層面提供了一些高級指令,讓用戶選擇在合適的時候去引入這些高級指令來解決可見性問題。
JMM是如何解決可見性和有序性問題的
導致可見性問題有兩個因素,一個是高速緩存導致的可見性問題,另一個是指令重排序。那JMM是如何解決可見性和有序性問題的呢?
分析硬件層面的內容時,已經提到過了,對於緩存一致性問題,有總線鎖和緩存鎖,緩存鎖是基於MESI協議。而對於指令重排序,硬件層面提供了內存屏障指令。而JMM在這個基礎上提供了volatile、final等關鍵字,使得開發者可以在合適的時候增加相應相應的關鍵字來禁止高速緩存和禁止指令重排序來解決可見性和有序性問題。
volatile的原理
Happens-Before模型
除了顯示引用volatile關鍵字能夠保證可見性以外,在Java中,還有很多的可見性保障的規則。
從JDK1.5開始,引入了一個happens-before的概念來闡述多個線程操作共享變量的可見性問題。所以我們可以認為在JMM中,如果一個操作執行的結果需要對另一個操作可見,那么這兩個操作必須要存在happens-before關系。這兩個操作可以是同一個線程,也可以是不同的線程。
程序順序規則(as-if-serial語義)
- 不能改變程序的執行結果(在單線程環境下,執行的結果不變。)
- 依賴問題,如果兩個指令存在依賴關系,是不允許重排序
int a = 0;
int b = 0;
void test(){
int a = 1; a
int b = 1; b
//int b = 1;
//int a = 1;
int c=a*b; c
}
a happens -before b ; b happens before c
傳遞性規則
a happens-before b , b happens- before c, a happens-before c
volatile變量規則
volatile 修飾的變量的寫操作,一定happens-before后續對於volatile變量的讀操作.
內存屏障機制來防止指令重排.
監視器鎖規則
int x = 10;
synchronized(this){
//后序線程讀取到的x的值一定是12
if(x<12){
x= 12;
}
}
x=12;
start規則
public class StartDemo(){
int x = 0;
Thread t1 = new Thread(()->{
//讀取x的值,一定是20
if(x==20){
}
});
}
x = 20;
t1.start();
join規則
public class Test{
int x = 0;
Thread t1 = new Thread(()->{
x = 200;
})
t1.start();
t1.join();//保證結果的可見性。
System.out.println(x);//在此處讀到的x的值一定是200。
}