Java記憶體模型(JMM)
執行緒安全問題
Java多執行緒程式設計的基本正規化是:面向物件+共享記憶體。Java物件儲存在JVM堆,所有執行緒共享堆。多執行緒訪問物件的狀態(共享變數)時,如果不加同步措施,就會產生執行緒安全問題。
Java提供了一系列工具幫助我們開發正確的多執行緒應用,例如鎖、Volatile關鍵字、CAS等等,我們要做的就是根據特定的場景選擇最適合的工具。那麼首先要明白有哪些執行緒安全問題存在,以及它們存在的原因。
可見性
可見性是指當一個執行緒修改了某個共享變數的值時,其他執行緒能否立即知道這個修改。Java多執行緒程式設計存在可見性問題,一個執行緒修改了共享變數,其它執行緒可能還會讀到它的舊值。可見性問題主要是由記憶體模型引起的,具體點說是由CPU快取引起的。
x86架構的CPU有多核,且帶有多級快取,每個核上面有L1、L2快取;L3快取為所有核共用。CPU通過快取一致性協議保證多個CPU之間的快取同步,即保證記憶體可見性。
但是,快取一致性協議對效能有很大損耗,CPU會在這個基礎上進行各種優化。例如,在計算單元和L1之間加了Store Buffer、Load Buffer(還有其他各種Buffer)。L1、L2、L3和主記憶體之間是同步的,由快取一致性協議保證。但是Store Buffer、Load Buffer和L1之間卻是非同步的。
對於作業系統來講,多CPU,每個CPU多核,每個核上面可能還有多個硬體執行緒,就相當於一個個的邏輯CPU,每個邏輯CPU都有自己的快取。這些快取和主記憶體之間不是完全同步的。
具體到Java的程式設計模型是這樣的:共享變數儲存在主記憶體中,每個執行緒都有一個私有的本地記憶體,本地記憶體中儲存了該執行緒已讀/寫共享變數的副本。本地記憶體是一個抽象概念,並不真實存在,它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。
一般情況下,執行緒各自為戰,只讀寫本地記憶體內共享變數的副本,因此執行緒A對共享變數的修改對執行緒B不是立即可見的。如果要同步,必須通過主記憶體,而且要實現一套同步協議,保證資料一致。例如執行緒A修改共享變數之後,強制回寫主記憶體,並且以某種方式強制執行緒B重新從主記憶體讀取該變數,這樣就實現了可見性。
Java中保證可見性的工具有兩種:一種是鎖,一種是volatile關鍵字。
原子性
原子性操作是指不可被中斷的操作。從執行執行緒以外的任何執行緒來看,原子操作是不可分割的,看不到中間結果。Java中,除long和double以外的任何型別的變數的讀寫操作都是原子的。非原子性操作也叫原子性問題本質上還是可見性問題,即其它執行緒可能“看到了不該看的東西”。
CPU使用基於對快取加鎖或匯流排加鎖的方式來實現多處理器之間的原子操作。
所謂匯流排鎖就是使用處理器提供的一個LOCK #訊號,當一個處理器在總線上輸出此訊號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔共享記憶體。
所謂快取鎖定是指記憶體區域如果被快取在處理器的快取行中,並且在Lock操作期間被鎖定,那麼當它執行鎖操作回寫到記憶體時,處理器不在總線上聲言LOCK #訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改由兩個以上處理器快取的記憶體區域資料,當其他處理器回寫已被鎖定的快取行的資料時,會使快取行無效。Java中的CAS操作正是利用了處理器提供的CMPXCHG指令實現的,所以本質上是快取鎖定。
Java中實現原子性有兩種方式,一種是鎖,一種是CAS。另外volatile關鍵字可以保證對long和double型別變數的讀寫原子性。
java.util.concurrent.atomic包下的原子操作類都是基於CAS實現,其內部又依賴Unsafe類。Java 9以後,推薦用VarHandle替代atomic包和Unsafe的大部分功能。
CAS的三個問題:
- ABA問題。自旋CAS的過程是不斷檢查值有沒有變化,所以A->B->A這種變化無法檢查出來。AtomicStampedReference通過增加一個版本號檢查解決了ABA問題。compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp);
- 迴圈時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。所以CAS也稱為樂觀鎖,適用於競爭不是很激烈的場景。
- 只能保證一個共享變數的原子操作。可以使用AtomicReference類封裝多個共享變數,保證它們的原子性。
重排序
重排序是指編譯器和處理器為了優化程式效能而對指令序列進行重新排序的一種手段。編譯器和處理器在重排序時,會遵守資料依賴性規則,不會改變存在資料依賴關係的兩個操作的執行順序。但這都是針對單個執行緒內的操作而言的,即重排序只保證單執行緒內的序列語義,即as-if-serial語義:不管怎麼重排序,單執行緒程式的執行結果不能被改變。
除了指令重排序,CPU還有記憶體重排序,Store Buffer的延遲寫入就是記憶體重排序。因此,一共有三類重排序:
- 編譯器重排序。對於沒有先後依賴關係的語句,編譯器可以重新調整語句的執行順序。
- CPU指令重排序。在指令級別,讓沒有依賴關係的多條指令並行。
- CPU記憶體重排序。CPU有自己的快取,指令的執行順序和寫入主記憶體的順序不完全一致。
第三種重排序會造成記憶體可見性問題。雖然指令沒有重排序,是寫入記憶體的操作被延遲了,也就是記憶體被重排序了,造成了記憶體可見性問題。
Java中重排序的細節由happen-before規則定義。
Java記憶體模型(JMM)與happen-before
多執行緒程式設計既要保證正確性,還要保證效能,給開發者帶來了很大的挑戰。例如,可以給任何訪問共享變數的方法加上synchronized關鍵字保證執行緒安全,但這種互斥同步方式非常影響效能。
為了平衡開發效率和系統執行效率,引入了Java記憶體模型(JMM)。JMM明確規範了在多執行緒場景下,什麼時候可以重排序,什麼時候不能重排序。在系統執行效率方面,要讓編譯器和CPU可以靈活地重排序;而在開發效率方面,要對開發者做一些承諾,明確告知開發者不需要感知什麼樣的重排序,需要感知什麼樣的重排序。然後,根據需要決定這種重排序對程式是否有影響。如果有影響,就需要開發者顯示地通過volatile、synchronized等執行緒同步機制來禁止重排序。
為了描述這個規範,JMM引入了happen-before,用於描述兩個操作之間的記憶體可見性。如果A happen-before B,意味著A的執行結果必須對B可見,也就是保證跨執行緒的記憶體可見性。基於happen-before的這種描述方法,JMM對開發者做出了一系列承諾:
- 程式順序原則:單執行緒中的每個操作,happen-before對應該執行緒中任意後續操作(也就是as-if-serial語義保證)。
- volatile規則:對volatile變數的寫入,happen-before對應後續對這個變數的讀取,保證了volatile變數的可見性。JSR-133增強了volatile語義,禁止volatile變數的寫入和非volatile變數的讀取或寫入重排序。
- 鎖規則:對鎖的解鎖,happen-before後續對這個鎖的加鎖。
- 傳遞性:A先於B,B先於C,那麼A必然先於C。
除了上述四條核心規則,還有:
- 執行緒的start()方法先於它的每一個動作。
- 執行緒的所有操作先於執行緒的終結(Thread.join())。
- 執行緒的中斷(interrupt())先於被中斷執行緒的程式碼。
- 物件的建構函式的執行、結束先於finalize()方法。
- final關鍵字:建構函式內部對final域的寫,happen-before後續對final域所在物件的讀;對final域所在物件的讀,happen-before後續對final域的讀。保證了final域的賦值,一定在建構函式之前完成,避免了建構函式溢位的問題。
記憶體屏障
禁止編譯器重排序和CPU重排序都是使用記憶體屏障(Memory Barrier)來實現的。編譯器的記憶體屏障,只是為了告訴編譯器不要對指令進行重排序。當編譯完成之後,這種記憶體屏障就消失了,CPU並不會感知到編譯器中記憶體屏障的存在。而CPU的記憶體屏障是CPU提供的指令,可以由開發者顯示呼叫。
在理論層面,可以把基本的CPU記憶體屏障分成四種:
- LoadLoad:禁止讀和讀的重排序。
- StoreStore:禁止寫和寫的重排序。
- LoadStore:禁止讀和寫的重排序。
- StoreLoad:禁止寫和讀的重排序。
但JDK中Unsafe類中提供的記憶體屏障有點不同。首先是loadFence,相當於LoadLoad+LoadStore,保證該屏障之後的讀寫不會重排序到該屏障之前的讀操作。
public native void loadFence();
其次是storeFence,相當於StoreStore+LoadStore,保證該屏障之前的讀寫不會重排序到該屏障之後的寫操作。
public native void storeFence();
最後是fullFence,相當於loadFence+storeFence+StoreLoad,保證該屏障之前的讀寫不會重排序到該屏障之後的讀寫。
volatile的記憶體語義
以下示例執行緒安全:
class VolatileExample {
int i = 0;
volatile boolean flag = false;
public void write(){
i = 1; //1
flag = true; //2
}
public void read(){
if(flag){ //3
i = a; //4
}
}
}
假設執行緒A執行writer()方法之後,執行緒B執行reader()方法。根據happens-before規則,這個過程建立的happens-before關係可以分為3類:
- 根據程式次序規則,1 happens-before 2;3 happens-before 4
- 根據volatile規則,2 happens-before 3
- 根據happens-before的傳遞性規則,1 happens-before 4
因此read()方法中總能讀到最新的a,保證了可見性。
從JSR-133開始(即從JDK5開始),volatile變數的寫-讀可以實現執行緒之間的通訊。
volatile寫的記憶體語義:
當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體重新整理到主記憶體。
volatile讀的記憶體語義:
當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。
結合起來,就可以用volatile變數的寫-讀實現執行緒之間的通訊:
執行緒A寫一個volatile變數,隨後執行緒B讀這個volatile變數,這個過程實質上是執行緒A通過主記憶體向執行緒B傳送訊息。
volatile記憶體語義的實現
為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的後面插入一個StoreLoad屏障。
- 在每個volatile讀操作的後面插入一個LoadLoad屏障。
- 在每個volatile讀操作的後面插入一個LoadStore屏障。
那麼,volatile寫的過程如下:
volatile讀的過程如下: