一段程式碼引發的思考,下面這段 程式碼演示了使用valatile和沒有使用volatile關鍵字對於變數更新的影響
public class App {
public volatile static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
int i = 0;
while(!stop){
i ++;
}
});
t1.start();
System.out.println("begin");
Thread.sleep(1000);
stop = true;
}
}
volatile的作用
可以使得在多處理器環境下保證共用變數的可見性,什麼是可見性?
在單執行緒的環境下,如果向一個變數先寫入一個值,然後再沒有寫干涉的情況下讀取這個變數,這個時候讀取到的這個變數值應該是之前寫入的值。這本來是一個很正常的事情,但是在多執行緒環境下,讀和寫發生在不同執行緒中的時候可能會出現:讀執行緒不能即使讀取到其他執行緒寫入的最新值。這就是所謂的可見性。為了實現多執行緒寫入的記憶體可見性,必須使用一些機制。而volatile就是這樣一種機制
volatile 關鍵字是如何保證可見性的?
在執行main函數之前,加入虛擬機器器引數
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*XXX.function(xxx替換成實際執行的類,function替換方法名)
然後在輸出的結果中,查詢下 lock 指令, 會發現,在修改帶有 volatile 修飾的成員變數時,會多一個lock 指令。 lock是一種控制指令, 在多處理器環境下, lock 組合指令可以基於匯流排鎖或者快取鎖的機制來達到可見性的一個效果
為了更好的理解可見性的本質, 我們需要從硬體層面進行梳理
一臺計算機最核心的元件時CPU,記憶體以及I/O裝置不斷迭代升級來提升計算機處理能力之外,還有一個非常核心的矛盾點,就是這三者在處理速度的差異。CPU 的計算速度是非常快的,記憶體次之、最後是 IO 裝置比如磁碟。而在絕大部分的程式中,一定會存在記憶體存取,有些可能還會存在 I/O 裝置的存取。
為了提升計算效能, CPU 從單核升級到了多核甚至用到了超執行緒技術最大化提高 CPU 的處理效能,但是僅僅提升CPU 效能還不夠,如果後面兩者的處理效能沒有跟上,意味著整體的計算效率取決於最慢的裝置。 為了平衡三者的速度差異,最大化的利用 CPU 提升效能,從硬體、作業系統、編譯器等方面都做出了很多的優化。
然而每一種優化,都會帶來相應的問題,而這些問題也是導致執行緒安全性問題的根源。 為了瞭解前面提到的可見性問題的本質,我們有必要去了解這些優化的過程
CPU快取架構
CPU快取即高速緩衝記憶體,是位於CPU與主記憶體間的一種容量較小但速度很高的記憶體。由於CPU的速度遠高於主記憶體,CPU直接從記憶體中存取資料要等待一定時間週期,Cache中儲存著CPU剛用過或迴圈使用的一部分資料,當CPU再次使用該部分資料時可從Cache中直接呼叫,減少CPU的等待時間,提高了系統的效率
通過快取記憶體的儲存互動很好的解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來了更高的複雜度,因為它引入了一個新的問題,快取一致性
什麼叫快取一致性呢?
首先,有了快取記憶體的存在以後, 每個 CPU 的處理過程是,先將計算需要用到的資料快取在 CPU 快取記憶體中,在 CPU進行計算時,直接從快取記憶體中讀取資料並且在計算完成。之後寫入到快取中。 在整個運算過程完成後,再把快取中的資料同步到主記憶體
由於在多 CPU 種,每個執行緒可能會執行在不同的 CPU 內,並且每個執行緒擁有自己的快取記憶體。 同一份資料可能會被快取到多個 CPU 中,如果在不同 CPU 中執行的不同執行緒看到同一份記憶體的快取值不一樣就會存在快取不一致的問題。
為了解決快取不一致的問題,在 CPU 層面做了很多事情,
主要提供了兩種解決辦法l
1. 匯流排鎖
2. 快取鎖
匯流排鎖,簡單來說就是,在多 cpu 下,當其中一個處理器要對共用記憶體進行操作的時候,在匯流排上發出一個 LOCK#訊號,這個訊號使得其他處理器無法通過匯流排來存取到共用記憶體中的資料, 匯流排鎖定把 CPU 和記憶體之間的通訊鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體地址的資料,所以匯流排鎖定的開銷比較大, 這種機制顯然是不合適的
如何優化呢? 最好的方法就是控制鎖的保護粒度,我們只需要保證對於被多個 CPU 快取的同一份資料是一致的就行。 所以引入了快取鎖, 它核心機制是基於快取一致性協定來實現的
快取一致性協定
為了達到資料存取的一致,需要各個處理器在存取快取時遵循一些協定,在讀寫時根據協定來操作,常見的協定有MSI, MESI, MOSI 等。 最常見的就是 MESI 協定
MESI 表示快取行的四種狀態,分別是:
2.E(Exclusive) 表示快取的獨佔狀態,資料只快取在當前CPU 快取中,並且沒有被修改
3.S(Shared) 表示資料可能被多個 CPU 快取,並且各個快取中的資料和主記憶體資料一致
4.I(Invalid) 表示快取已經失效
在 MESI 協定中,每個快取的快取控制器不僅知道自己的讀寫操作,而且也監聽(snoop)其它 Cache 的讀寫操作
關鍵 對於 MESI 協定, 從 CPU 讀寫角度來說會遵循以下原則:CPU 讀請求:快取處於 M、 E、 S 狀態都可以被讀取, I 狀態 CPU 只能從主記憶體中讀取資料CPU 寫請求:快取處於 M、 E 狀態才可以被寫。對於 S 狀態的寫,需要將其他 CPU 中快取行置為無效才可寫使用匯流排鎖和快取鎖機制之後, CPU 對於記憶體的操作大概可以抽象成下面這樣的結構。從而達到快取一致性效果
MESI 優化帶來的可見性問題
MESI 協定雖然可以實現快取的一致性,但是也會存在一些問題。
就是各個 CPU 快取行的狀態是通過訊息傳遞來進行的。 如果 CPU0 要對一個在快取中共用的變數進行寫入,首先需要傳送一個失效的訊息給到其他快取該資料的 CPU(Invalid)。並且要等到他們的確認回執。 CPU0 在這段時間內都會處於阻塞狀態。 為了避免阻塞帶來的資源浪費。 在 cpu 中引入了 Store Bufferes
CPU0 只需要在寫入共用資料時,直接把資料寫入到 storebufferes 中, 同時傳送 invalidate 訊息,然後繼續去處理其他指令。當收到其他所有 CPU 傳送了 invalidate acknowledge 訊息時, 再將 store bufferes 中的資料資料儲存至 cache line中。最後再從快取行同步到主記憶體。
但是這種優化存在兩個問題
1. 資料什麼時候提交是不確定的,因為需要等待其他 cpu給回覆才會進行資料同步。這裡其實是一個非同步操作
2. 引入了 storebufferes 後,處理器會先嚐試從 storebuffer中讀取值,如果 storebuffer 中有資料,則直接從storebuffer 中讀取,否則就再從快取行中讀取
看個例子:
//cpu已經快取了Flag
//M(Modify) E(Exclusive) S(Shared) I(Invalid) 狀態
value = 3 //(S)
void cpu0(){
value = 10; //( M) ->[ storebufferes ->通知其他cpu快取行失效(i)]
Flag = true;//(E)
}
void cpu1(){
if(Flag){//true
assert value ==10;//flase
}
}
cpu0和cpu1分別在倆個獨立cpu上執行,假如cpu0快取行中快取了isFlag這個共用變數且狀態(E),而Vlaue可能是(S)狀態。
這時候,CPU0在執行的時候,會先把value=10寫入到storebuffer中,並且通知其他快取了value的cpu.。在等待其他CPU通知結果的時候,cpu0會先執行isFlag=true的指令。
而因為當前cpu0快取了isFlag並且是(E)狀態,所以可以直接修改isFlag=true,但是value值還不等於10.
這種情況我們可以認為是CPU的亂序執行,也可以認為是重排序,這種重排序會帶來可見性問題。
從硬體層面很難去知道軟體層面上的這種前後依賴關係,沒有辦法通過某種手段自動去解決。所以在 CPU 層面提供了 memory barrier(記憶體屏障)的指令,從硬體層面來看這個 memroy barrier 就是 CPU flushstore bufferes 中的指令。軟體層面可以決定在適當的地方來插入記憶體屏障。
總的來說,記憶體屏障的作用可以通過防止 CPU 對記憶體的亂序存取來保證共用資料在多執行緒並行執行下的可見性但是這個屏障怎麼來加呢?回到最開始我們講 volatile 關鍵字的程式碼,這個關鍵字會生成一個 Lock 的組合指令,這個指令其實就相當於實現了一種記憶體屏障.
這個時候問題又來了, 記憶體屏障、重排序這些東西好像是和平臺以及硬體架構有關係的。 作為 Java 語言的特性,一次編寫多處執行。 我們不應該考慮平臺相關的問題,並且這些所謂的記憶體屏障也不應該讓程式設計師來關心。
什麼是JMM
JMM 全稱是 Java Memory Model. 什麼是 JMM 呢?
JMM模型跟CPU快取模型結構類似,是基於CPU快取模型建立起來的,JMM模型是標準化的,遮蔽掉了底層不同計算機的區別。對於硬體記憶體來說只有暫存器、快取記憶體、主記憶體的概念,並沒有工作記憶體(執行緒私有資料區域)和主記憶體(堆記憶體)之分,因為JMM只是一種抽象的概念,是一組規則,並不實際存在,不管是工作記憶體的資料還是主記憶體的資料,對於計算機硬體來說都會儲存在計算機主記憶體中,當然也有可能儲存到CPU快取或者暫存器中。
通過這些規則來規範對記憶體的讀寫操作從而保證指令的正確性,它解決了 CPU 多級快取、處理器優化、指令重排序導致的記憶體存取問題,保證了並行場景下的可見性
需要注意的是, JMM 並沒有限制執行引擎使用處理器的暫存器或者快取記憶體來提升指令執行速度,也沒有限制編譯器對指令進行重排序,也就是說在 JMM 中,也會存在快取一致性問題和指令重排序問題。只是 JMM 把底層的問題抽象到 JVM 層面,再基於 CPU 層面提供的記憶體屏障指令,以及限制編譯器的重排序來解決並行問題
java 記憶體模型底層實現可以簡單的認為: 通過記憶體屏障(memory barrier)禁止重排序,即時編譯器根據具體的底層體系架構,將這些記憶體屏障替換成具體的 CPU 指令。對於編譯器而言,記憶體屏障將限制它所能做的重排序優化。而對於處理器而言,記憶體屏障將會導致快取的重新整理操作。比如,對於 volatile,編譯器將在 volatile 欄位的讀寫操作前後各插入一些記憶體屏障
簡單來說, JMM 提供了一些禁用快取以及進位制重排序的方法,來解決可見性和有序性問題。 這些方法大家都很熟悉:volatile、 synchronized、 final;以及HappenBefore規則
注意:X86處理器不會對讀-讀、讀-寫和寫-寫操作做重排序, 會省略掉這3種操作型別對應的記憶體屏障。僅會對寫-讀操作做重排序,所以volatile寫-讀操作只需要在volatile寫後插入StoreLoad屏障
為了提高程式的執行效能,編譯器和處理器都會對指令做重排序,其中處理器的重排序在前面已經分析過了。 所謂的重排序其實就是指執行的指令順序。編譯器的重排序指的是程式編寫的指令在編譯之後,指令可能會產生重排序來優化程式的執行效能
從原始碼到最終執行的指令,可能會經過三種重排序
2 和 3 屬於處理器重排序。這些重排序可能會導致可見性問題。
編譯器的重排序, JMM 提供了禁止特定型別的編譯器重排序
處理器重排序, JMM 會要求編譯器生成指令時,會插入記憶體屏障來禁止處理器重排序
硬體層提供了一系列的記憶體屏障 memory barrier / memory fence(Intel的提法)來提供一致性的能力。拿X86平臺來說,有幾種主要的記憶體屏障:
記憶體屏障有兩個能力:
對Load Barrier來說,在讀指令前插入讀屏障,可以讓快取記憶體中的資料失效,重新從主記憶體載入資料
對Store Barrier來說,在寫指令之後插入寫屏障,能讓寫入快取的最新資料寫回到主記憶體
Lock字首實現了類似的能力,它先對匯流排和快取加鎖,然後執行後面的指令,最後釋放鎖後會把快取記憶體中的資料重新整理回主記憶體。在Lock鎖住匯流排的時候,其他CPU的讀寫請求都會被阻塞,直到鎖釋放。
JMM 層面的記憶體屏障
為了保證記憶體可見性, Java 編譯器在生成指令序列的適當位置會插入記憶體屏障來禁止特定型別的處理器的重排序,在 JMM 中把記憶體屏障分為四類
我們通過 javap -v xxx.class命令檢視組合指令會發現,假如了volatile關鍵字後有這麼一條指令
flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
煩是volatile關鍵字 最後一定會執行 stroeload();
不同平臺下實現的檔案
如果存在重排序情況下, JMM提供了倆級別記憶體屏障(cpu,語言級別)
lock組合指令:cpu級別記憶體屏障,鎖住快取行
volatile :語言級別記憶體屏障,禁止編譯器對程式碼優化(重排序)
as-if-serial語意的意思是:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語意。
為了遵守as-if-serial語意,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關係,這些操作就可能被編譯器和處理器重排序。
a =1;
b=2;
c = a*b;
A和C之間存在資料依賴關係,同時B和C之間也存在資料依賴關係。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程式的結果將會被改變)。但A和B之間沒有資料依賴關係,編譯器和處理器可以重排序A和B之間的執行順序
在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係。