volatile關鍵字有兩個作用
- 保證被volatile修飾的共享變量(vlatile int a=1)對所有線程總數可見的,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值總是可以被其他線程立即得知(數據的可見性)。
- 禁止指令重排序優化。
volatile解決數據的可見性原理:對於聲明瞭volatile的變量進行寫操作,JVM就會向處理器發送一條Lock前綴的指令,把這個變量所在的緩存行的數 據寫回到系統內存,並把其他緩存裡面的變量值設置為失效,從主內存獲取(主要是通過MESI的緩存一致性協議來實現)MESI協議如下圖:
volatile關鍵字另一個作用就是禁止指令重排:禁止指令重排是通過內存屏障(Memory Barrier)實現的。
內存屏障,又稱內存柵欄,是一個CPU指令,它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變量的內存可見性。由於編譯器和處理器都能執行指令重排優化。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,在Memory Barrier之前的所有的操作指令必須在內存屏障之前執行 併發送緩存失效的信號。所有在Memory Barrier後的指令之後的指令,都必須在 Memory Barrier屏障之前的指令執行完後再被執行,也就是說通過插入內存屏障禁止在內存屏障前後的指令執行重排序優化。Memory Barrier的另外一個作用是強制刷新各種CPU的緩存數據,因此任何CPU上的線程都能讀取到這些數據的最新版本。總之,volatile變量正是通過內存屏障實現其在內存中的語義,即可見性和禁止指令重排。
一段代碼引發的思考
下面這段代碼,演示了一個使用 volatile 以及沒使用volatile 這個關鍵字,對於變量更新的影響
volatile 的作用
volatile 可以使得在多處理器環境下保證了共享變量的可見性,那麼到底什麼是可見性呢?不知道大家有沒有思考過這個問題?
在單線程的環境下,如果向一個變量先寫入一個值,然後在沒有寫干涉的情況下讀取這個變量的值,那這個時候讀取到的這個變量的值應該是之前寫入的那個值。這本來是一個很正常的事情。但是在多線程環境下,讀和寫發生在不同的線程中的時候,可能會出現:讀線程不能及時的讀取到其他線程寫入的最新的值。這就是所謂的可見性為了實現跨線程寫入的內存可見性,必須使用到一些機制來實現。而 volatile 就是這樣一種機制
volatile 關鍵字是如何保證可見性的?
我們可以使用【hsdis】這個工具,來查看前面演示的這段代碼的彙編指令,在運行的代碼中,設置 jvm 參數如下【-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileTest.*(替換成實際運行的代碼)】然後在輸出的結果中,查找下 lock 指令,會發現,在修改帶有volatile 修飾的成員變量時,會多一個 lock 指令。lock是一種控制指令,在多處理器環境下,lock 彙編指令可以基於總線鎖或者緩存鎖的機制來達到可見性的一個效果。
為了更好的理解可見性的本質,我們需要從硬件層面進行梳理
從硬件層面瞭解可見性的本質
一臺計算機中最核心的組件是 CPU、內存、以及 I/O 設備。在整個計算機的發展歷程中,除了 CPU、內存以及 I/O 設備不斷迭代升級來提升計算機處理性能之外,還有一個非常核心的矛盾點,就是這三者在處理速度的差異。CPU 的計算速度是非常快的,內存次之、最後是 IO 設備比如磁盤。而在絕大部分的程序中,一定會存在內存訪問,有些可能還會存在 I/O 設備的訪問
為了提升計算性能,CPU 從單核升級到了多核甚至用到了超線程技術最大化提高 CPU 的處理性能,但是僅僅提升CPU 性能還不夠,如果後面兩者的處理性能沒有跟上,意味著整體的計算效率取決於最慢的設備。為了平衡三者的速度差異,最大化的利用 CPU 提升性能,從硬件、操作系統、編譯器等方面都做出了很多的優化
- CPU 增加了高速緩存
- 操作系統增加了進程、線程。通過 CPU 的時間片切換最大化的提升 CPU 的使用率
- 編譯器的指令優化,更合理的去利用好 CPU 的高速緩存
然而每一種優化,都會帶來相應的問題,而這些問題也是導致線程安全性問題的根源。為了瞭解前面提到的可見性問題的本質,我們有必要去了解這些優化的過程
CPU 高速緩存
線程是 CPU 調度的最小單元,線程設計的目的最終仍然是更充分的利用計算機處理的效能,但是絕大部分的運算任務不能只依靠處理器“計算”就能完成,處理器還需要與內存交互,比如讀取運算數據、存儲運算結果,這個 I/O 操作是很難消除的。而由於計算機的存儲設備與處理器的運算速度差距非常大,所以現代計算機系統都會增加一層讀寫速度儘可能接近處理器運算速度的高速緩存來作為內存和處理器之間的緩衝:將運算需要使用的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步到內存之中。
通過高速緩存的存儲交互很好的解決了處理器與內存的速度矛盾,但是也為計算機系統帶來了更高的複雜度,因為它引入了一個新的問題,緩存一致性。
什麼叫緩存一致性呢?
首先,有了高速緩存的存在以後,每個 CPU 的處理過程是,先將計算需要用到的數據緩存在 CPU 高速緩存中,在 CPU進行計算時,直接從高速緩存中讀取數據並且在計算完成之後寫入到緩存中。在整個運算過程完成後,再把緩存中的數據同步到主內存。
由於在多CPU種,每個線程可能會運行在不同的CPU內,並且每個線程擁有自己的高速緩存。同一份數據可能會被緩存到多個 CPU 中,如果在不同 CPU 中運行的不同線程看到同一份內存的緩存值不一樣就會存在緩存不一致的問題,為了解決緩存不一致的問題,在 CPU 層面做了很多事情,主要提供了兩種解決辦法
- 總線鎖
- 緩存鎖
總線鎖和緩存鎖
總線鎖,簡單來說就是,在多 cpu 下,當其中一個處理器要對共享內存進行操作的時候,在總線上發出一個 LOCK#信號,這個信號使得其他處理器無法通過總線來訪問到共享內存中的數據,總線鎖定把 CPU 和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,這種機制顯然是不合適的,如何優化呢?最好的方法就是控制鎖的保護粒度,我們只需要保證對於被多個 CPU 緩存的同一份數據是一致的就行。所以引入了緩存鎖,它核心機制是基於緩存一致性協議來實現的。
緩存一致性協議
為了達到數據訪問的一致,需要各個處理器在訪問緩存時遵循一些協議,在讀寫時根據協議來操作,常見的協議有MSI,MESI,MOSI 等。最常見的就是 MESI 協議。接下來給大家簡單講解一下 MESI,MESI 表示緩存行的四種狀態,分別是
- M(Modify) 表示共享數據只緩存在當前 CPU 緩存中,並且是被修改狀態,也就是緩存的數據和主內存中的數據不一致
- E(Exclusive) 表示緩存的獨佔狀態,數據只緩存在當前CPU 緩存中,並且沒有被修改
- S(Shared) 表示數據可能被多個 CPU 緩存,並且各個緩存中的數據和主內存數據一致
- I(Invalid) 表示緩存已經失效
在 MESI 協議中,每個緩存的緩存控制器不僅知道自己的讀寫操作,而且也監聽(snoop)其它 Cache 的讀寫操作
對於 MESI 協議,從 CPU 讀寫角度來說會遵循以下原則:
CPU 讀請求:緩存處於 M、E、S 狀態都可以被讀取,I 狀態 CPU 只能從主存中讀取數據
CPU 寫請求:緩存處於 M、E 狀態才可以被寫。對於 S 狀態的寫,需要將其他 CPU 中緩存行置為無效才可寫
使用總線鎖和緩存鎖機制之後,CPU 對於內存的操作大概可以抽象成下面這樣的結構。從而達到緩存一致性效果
總結可見性的本質
由於 CPU 高速緩存的出現使得 如果多個 cpu 同時緩存了相同的共享數據時,可能存在可見性問題。也就是 CPU0 修改了自己本地緩存的值對於 CPU1 不可見。不可見導致的後果是 CPU1 後續在對該數據進行寫入操作時,是使用的髒數據。使得數據最終的結果不可預測。
瞭解到這裡,大家應該會有一個疑問,剛剛不是說基於緩存一致性協議或者總線鎖能夠達到緩存一致性的要求嗎?為什麼還需要加 volatile 關鍵字?或者說為什麼還會存在可見性問題呢?
MESI 優化帶來的可見性問題
MESI 協議雖然可以實現緩存的一致性,但是也會存在一些問題。就是各個 CPU 緩存行的狀態是通過消息傳遞來進行的。如果 CPU0 要對一個在緩存中共享的變量進行寫入首先需要發送一個失效的消息給到其他緩存了該數據的 CPU。並且要等到他們的確認回執。CPU0 在這段時間內都會處於阻塞狀態。為了避免阻塞帶來的資源浪費。在 cpu 中引入了 Store Bufferes 。
CPU0 只需要在寫入共享數據時,直接把數據寫入到 store bufferes 中,同時發送 invalidate 消息,然後繼續去處理其他指令。當收到其他所有CPU發送了invalidate acknowledge消息時,再將 store bufferes 中的數據數據存儲至 cache line中。最後再從緩存行同步到主內存。
但是這種優化存在兩個問題
- 數據什麼時候提交是不確定的,因為需要等待其他 cpu給回覆才會進行數據同步。這裡其實是一個異步操作
- 引入了 storebufferes 後,處理器會先嚐試從 storebuffer中讀取值,如果 storebuffer 中有數據,則直接從storebuffer 中讀取,否則就再從緩存行中讀取
我們來看一個例子
exeToCPU0和exeToCPU1分別在兩個獨立的CPU上執行。假如 CPU0 的緩存行中緩存了 isFinish 這個共享變量,並且狀態為(E)、而 Value 可能是(S)狀態。那麼這個時候,CPU0 在執行的時候,會先把 value=10 的指令寫入到storebuffer中。並且通知給其他緩存了該value變量的 CPU。在等待其他 CPU 通知結果的時候,CPU0 會繼續執行 isFinish=true 這個指令。而因為當前 CPU0 緩存了 isFinish 並且是 Exclusive 狀態,所以可以直接修改 isFinish=true。這個時候 CPU1 發起 read操作去讀取 isFinish 的值可能為 true,但是 value 的值不等於 10。這種情況我們可以認為是 CPU 的亂序執行,也可以認為是一種重排序,而這種重排序會帶來可見性的問題
這下硬件工程師也抓狂了,我們也能理解,從硬件層面很難去知道軟件層面上的這種前後依賴關係,所以沒有辦法通過某種手段自動去解決。所以硬件工程師就說: 既然怎麼優化都不符合你的要求,要不你來寫吧。所以在 CPU 層面提供了 memory barrier(內存屏障)的指令,從硬件層面來看這個 memroy barrier 就是 CPU flush store bufferes中的指令。軟件層面可以決定在適當的地方來插入內存屏障。
CPU 層面的內存屏障
什麼是內存屏障?從前面的內容基本能有一個初步的猜想,內存屏障就是將 store bufferes 中的指令寫入到內存,從而使得其他訪問同一共享內存的線程的可見性。X86 memory barrier指令包括lfence(讀屏障) sfence(寫屏障) mfence(全屏障)
Store Memory Barrier(寫屏障) 告訴處理器在寫屏障之前的所有已經存儲在存儲緩存(store bufferes)中的數據同步到主內存,簡單來說就是使得寫屏障之前的指令的結果對屏障之後的讀或者寫是可見的Load Memory Barrier(讀屏障) 處理器在讀屏障之後的讀操作,都在讀屏障之後執行。配合寫屏障,使得寫屏障之前的內存更新對於讀屏障之後的讀操作是可見的Full Memory Barrier(全屏障) 確保屏障前的內存讀寫操作的結果提交到內存之後,再執行屏障後的讀寫操作有了內存屏障以後,對於上面這個例子,我們可以這麼來改,從而避免出現可見性問題
總的來說,內存屏障的作用可以通過防止 CPU 對內存的亂序訪問來保證共享數據在多線程並行執行下的可見性但是這個屏障怎麼來加呢?回到最開始我們講 volatile 關鍵字的代碼,這個關鍵字會生成一個 Lock 的彙編指令,這個指令其實就相當於實現了一種內存屏障
這個時候問題又來了,內存屏障、重排序這些東西好像是和平臺以及硬件架構有關係的。作為 Java 語言的特性,一次編寫多處運行。我們不應該考慮平臺相關的問題,並且這些所謂的內存屏障也不應該讓程序員來關心。
JMM
JMM 全稱是 Java Memory Model. 什麼是 JMM 呢?通過前面的分析發現,導致可見性問題的根本原因是緩存以及重排序。 而 JMM 實際上就是提供了合理的禁用緩存以及禁止重排序的方法。所以它最核心的價值在於解決可見性和有序性。JMM 屬於語言級別的抽象內存模型,可以簡單理解為對硬件模型的抽象,它定義了共享內存中多線程程序讀寫操作的行為規範:在虛擬機中把共享變量存儲到內存以及從內存中取出共享變量的底層實現細節通過這些規則來規範對內存的讀寫操作從而保證指令的正確性,它解決了CPU 多級緩存、處理器優化、指令重排序導致的內存訪問問題,保證了併發場景下的可見性。需要注意的是,JMM 並沒有限制執行引擎使用處理器的寄存器或者高速緩存來提升指令執行速度,也沒有限制編譯器對指令進行重排序,也就是說在 JMM 中,也會存在緩存一致性問題和指令重排序問題。只是 JMM 把底層的問題抽象到 JVM 層面,再基於 CPU 層面提供的內存屏障指令,以及限制編譯器的重排序來解決併發問題
JMM 抽象模型分為主內存、工作內存;主內存是所有線程共享的,一般是實例對象、靜態字段、數組對象等存儲在堆內存中的變量。工作內存是每個線程獨佔的,線程對變量的所有操作都必須在工作內存中進行,不能直接讀寫主內存中的變量,線程之間的共享變量值的傳遞都是基於主內存來完成
Java 內存模型底層實現可以簡單的認為:通過內存屏障(memory barrier)禁止重排序,即時編譯器根據具體的底層體系架構,將這些內存屏障替換成具體的 CPU 指令。對於編譯器而言,內存屏障將限制它所能做的重排序優化。而對於處理器而言,內存屏障將會導致緩存的刷新操作。比如,對於 volatile,編譯器將在 volatile 字段的讀寫操作前後各插入一些內存屏障。
JMM 是如何解決可見性有序性問題的
簡單來說,JMM 提供了一些禁用緩存以及禁用重排序的方法,來解決可見性和有序性問題。這些方法大家都很熟悉:volatile、synchronized、final;
JMM 如何解決順序一致性問題
重排序問題
為了提高程序的執行性能,編譯器和處理器都會對指令做重排序,其中處理器的重排序在前面已經分析過了。所謂的重排序其實就是指執行的指令順序。編譯器的重排序指的是程序編寫的指令在編譯之後,指令可能會產生重排序來優化程序的執行性能。從源代碼到最終執行的指令,可能會經過三種重排序。
2 和 3 屬於處理器重排序。這些重排序可能會導致可見性問題。
編譯器的重排序,JMM 提供了禁止特定類型的編譯器重排序。
處理器重排序,JMM 會要求編譯器生成指令時,會插入內存屏障來禁止處理器重排序
當然並不是所有的程序都會出現重排序問題,編譯器的重排序和 CPU 的重排序的原則一樣,會遵守數據依賴性原則,編譯器和處理器不會改變存在數據依賴關係的兩個操作的執行順序,比如下面的代碼,
a=1; b=a;
a=1;a=2;
a=b;b=1;
這三種情況在單線程裡面如果改變代碼的執行順序,都會導致結果不一致,所以重排序不會對這類的指令做優化。這種規則也成為 as-if-serial。不管怎麼重排序,對於單個線程來說執行結果不能改變。比如
int a=2; //1
int b=3; //2
int rs=a*b; //3
1 和 3、2 和 3 存在數據依賴,所以在最終執行的指令中,3 不能重排序到 1 和 2 之前,否則程序會報錯。由於 1 和 2不存在數據依賴,所以可以重新排列 1 和 2 的順序
JMM 層面的內存屏障
為了保證內存可見性,Java 編譯器在生成指令序列的適當位置會插入內存屏障來禁止特定類型的處理器的重排序,在 JMM 中把內存屏障分為四類
HappenBefore原則
它的意思表示的是前一個操作的結果對於後續操作是可見的,所以它是一種表達多個線程之間對於內存的可見性。所以我們可以認為在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作必須要存在happens-before 關係。這兩個操作可以是同一個線程,也可以是不同的線程
J MM 中 有 哪些方法建立 happen- before 規則
- 程序順序規則:一個線程中的每個操作,happens-before 於該線程中的任意後續操作; 可以簡單認為是 as-if-serial。 單個線程中的代碼順序不管怎麼變,對於結果來說是不變的順序規則表示 1 happenns-before 2; 3 happens-before 4
- volatile 變量規則:對於 volatile 修飾的變量的寫的操作,一定 happen-before 後續對於 volatile 變量的讀操作;根據 volatile 規則,2 happens before 3
- 傳遞性規則:如果 1 happens-before 2; 3happens-before 4; 那麼傳遞性規則表示: 1 happens-before 4;
- start 規則:如果線程 A 執行操作 ThreadB.start(),那麼線程 A 的 ThreadB.start()操作 happens-before 線程 B 中的任意操作
- join 規則:如果線程 A 執行操作 ThreadB.join()併成功返回,那麼線程 B 中的任意操作 happens-before 於線程A 從 ThreadB.join()操作成功返回。
- 監視器鎖的規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖
假設 x 的初始值是 10,線程 A 執行完代碼塊後 x 的值會變成 12(執行完自動釋放鎖),線程 B 進入代碼塊時,能夠看到線程 A 對 x 的寫操作,也就是線程 B 能夠看到 x==12。