JVM併發機制探討—記憶體模型、記憶體可見性和指令重排序

SamChi的部落格發表於2015-03-20

併發本來就是個有意思的問題,尤其是現在又流行這麼一句話:“高帥富加機器,窮矮搓搞優化”。從這句話可以看到,無論是高帥富還是窮矮搓都需要深入理解併發程式設計,高帥富加多了機器,需要協調多臺機器或者多個CPU對共享資源的訪問,因此需要了解併發,窮矮搓搞優化需要編寫各種多執行緒的程式碼來壓榨 CPU的計算資源,讓它在同一時刻做更多的事情,這個更需要了解併發。

在我前一篇關於併發的文章http://my.oschina.net/chihz/blog/54731中提到過管程,管程的特色是在程式語言中 對併發的細節進行封裝,使程式設計師可以直接在語言中就得到併發的支援,而不必自己去處理一些像是控制訊號量之類容易出錯且繁瑣的細節問題。一些語言是通過在 編譯時解開語法糖的方式去實現管程,但Java在編譯後生成的位元組碼層面上對併發仍然是一層封裝,比如syncrhonized塊在編譯之後只是對應了兩 條指令:monitorenter和monitorexit。更多的併發細節是在JVM執行時去處理的,而不是編譯。這篇文章主要是針對JVM處理併發的 一些細節的探討。

JAVA記憶體模型

對於我們平時開發的業務應用來說,記憶體應該是訪問速度最快的儲存裝置,對於頻繁訪問的資料,我們總是習慣把它們放到記憶體快取中,有句話不是說麼,緩 存就像是清涼油,哪裡有問題就抹一抹。但是CPU的運算速度比起記憶體的訪問速度還要快幾個量級,為了平衡這個差距,於是就專門為CPU引入了快取記憶體,頻 繁使用的資料放到快取記憶體當中,CPU在使用這些資料進行運算的時候就不必再去訪問記憶體。但是在多CPU時代卻有一個問題,每個CPU都擁有自己的高速緩 存,記憶體又是所有CPU共享的公共資源,於是記憶體此時就成了一個臨界區,如果控制不好各個CPU對記憶體的併發訪問,那麼就會產生錯誤,出現資料不一致的情 況。為了避免這種情況,需要採取快取一致性協議來保證,這類協議有很多,各個硬體平臺和作業系統的實現不盡相同。

JVM需要實現跨平臺的支援,它需要有一套自己的同步協議來遮蔽掉各種底層硬體和作業系統的不同,因此就引入了Java記憶體模型。對於Java來說 開發者並不需要關心任何硬體細節,因此沒有多核CPU和快取記憶體的概念,多核CPU和快取記憶體在JVM中對應的是Java語言內建的執行緒和每個執行緒所擁有 的獨立記憶體空間,Java記憶體模型所規範的也就是資料線上程自己的獨立記憶體空間和JVM共享記憶體之間同步的問題。下面這兩張圖說明了硬體平臺和JVM記憶體 模型的相似和差異之處。

CPU快取和記憶體模型

JVM執行緒記憶體模型

Java記憶體模型規定,對於多個執行緒共享的變數,儲存在主記憶體當中,每個執行緒都有自己獨立的工作記憶體,執行緒只能訪問自己的工作記憶體,不可以訪問其它 執行緒的工作記憶體。工作記憶體中儲存了主記憶體共享變數的副本,執行緒要操作這些共享變數,只能通過操作工作記憶體中的副本來實現,操作完畢之後再同步回到主記憶體當 中。如何保證多個執行緒操作主記憶體的資料完整性是一個難題,Java記憶體模型也規定了工作記憶體與主記憶體之間互動的協議,首先是定義了8種原子操作:

(1) lock:將主記憶體中的變數鎖定,為一個執行緒所獨佔

(2) unclock:將lock加的鎖定解除,此時其它的執行緒可以有機會訪問此變數

(3) read:將主記憶體中的變數值讀到工作記憶體當中

(4) load:將read讀取的值儲存到工作記憶體中的變數副本中。

(5) use:將值傳遞給執行緒的程式碼執行引擎

(6) assign:將執行引擎處理返回的值重新賦值給變數副本

(7) store:將變數副本的值儲存到主記憶體中。

(8) write:將store儲存的值寫入到主記憶體的共享變數當中。

我們可以看到,要保證資料的同步,lock和unlock定義了一個執行緒訪問一次共享記憶體的界限,有lock操作也必須有unlock操作,另外一 些操作也必須要成對出現才可以,像是read和load、store和write需要成對出現,如果單一指令出現,那麼就會造成資料不一致的問題。 Java記憶體模型也針對這些操作指定了必須滿足的規則:

(1) read和load、store和write必須要成對出現,不允許單一的操作,否則會造成從主記憶體讀取的值,工作記憶體不接受或者工作記憶體發起的寫入操作而主記憶體無法接受的現象。

(2) 線上程中使用了assign操作改變了變數副本,那麼就必須把這個副本通過store-write同步回主記憶體中。如果執行緒中沒有發生assign操作,那麼也不允許使用store-write同步到主記憶體。

(3) 在對一個變數實行use和store操作之前,必須實行過load和assign操作。

(4) 變數在同一時刻只允許一個執行緒對其進行lock,有多少次lock操作,就必須有多少次unlock操作。在lock操作之後會清空此變數在工作記憶體中原 先的副本,需要再次從主記憶體read-load新的值。在執行unlock操作前,需要把改變的副本同步回主存。

記憶體可見性

通過上面Java記憶體模型的概述,我們會注意到這麼一個問題,每個執行緒在獲取鎖之後會在自己的工作記憶體來操作共享變數,在工作記憶體中的副本回寫到主 記憶體,並且其它執行緒從主記憶體將變數同步回自己的工作記憶體之前,共享變數的改變對其它執行緒是不可見的。那麼很多時候我們需要一個執行緒對共享變數的改動,其它 執行緒也需要立即得知這個改動該怎麼辦呢?比如以下的情景,有一個全域性的狀態變數open:

boolean open= true;

這個變數用來描述對一個資源的開啟關閉狀態,true表示開啟,false表示關閉,假設有一個執行緒A,在執行一些操作後將open修改為false:

//執行緒A

resource.close();  
open = false;

執行緒B隨時關注open的狀態,當open為true的時候通過訪問資源來進行一些操作:

//執行緒B

while(open) {  

doSomethingWithResource(resource);  
}

當A把資源關閉的時候,open變數對執行緒B不可見,如果此時open變數的改動尚未同步到執行緒B的工作記憶體中,那麼執行緒B就會用一個已經關閉了的資源去做一些操作,因此產生錯誤。

所以對於上面的情景,要求一個執行緒對open的改變,其他的執行緒能夠立即可見,Java為此提供了Volatile關鍵字,在宣告open變數的時 候加入volatile關鍵字就可以保證open的記憶體可見性,即open的改變對所有的執行緒都是立即可見的。volatile保證可見性的原理是在每次 訪問變數時都會進行一次重新整理,因此每次訪問都是主記憶體中最新的版本。

指令重排序

很多介紹JVM併發的書或文章都會談到JVM為了優化效能,採用了指令重排序,但是對於什麼是指令重排序,為什麼重排序會優化效能卻很少有提及,其實道理很簡單,假設有這麼兩個共享變數a和b:

private int a;  
private int b;

線上程A中有兩條語句對這兩個共享變數進行賦值操作:

a = 1;  
b = 2;

假設當執行緒A對a進行復制操作的時候發現這個變數在主記憶體已經被其它的執行緒加了訪問鎖,那麼此時執行緒A怎麼辦?等待釋放鎖?不,等待太浪費時間了,它會去嘗試進行b的賦值操作,b這時候沒被人佔用,因此就會先為b賦值,再去為a賦值,那麼執行的順序就變成了:

b = 2;  
a = 1;

對於在同一個執行緒內,這樣的改變是不會對邏輯產生影響的,但是在多執行緒的情況下指令重排序會帶來問題,看下面這個情景:

線上程A中:

context = loadContext();  
inited = true;

線上程B中:

while(!inited ){  
 sleep  
}  

doSomethingwithconfig(context);

假設A中發生了重排序:

inited = true;  
context = loadContext();

那麼B中很可能就會拿到一個尚未初始化或尚未初始化完成的context,從而引發程式錯誤。

想到有一條古老的原則很適合用在這個地方,那就是先要保證程式的正確然後再去優化效能。此處由於重排序產生的錯誤顯然要比重排序帶來的效能優化要重 要的多。要解決重排序問題還是通過volatile關鍵字,volatile關鍵字能確保變數線上程中的操作不會被重排序而是按照程式碼中規定的順序進行訪 問。

最後的總結

這篇文章簡單的介紹了Java記憶體模型、記憶體可見性和指令重排序。不過最後看來其實主要是在解釋volatile這個關鍵字,個人感覺 volatile關鍵字是Java當中最令人困惑和最難理解的關鍵字。相對於synchronized塊的程式碼鎖,volatile應該是提供了一個輕量 級的針對共享變數的鎖,當我們在多個執行緒間使用共享變數進行通訊的時候需要考慮將共享變數用volatile來修飾,對於需要使用volatile的各種 情景,看到IBM Developer Works上有一篇文章總結的很不錯,推薦一下: http://www.ibm.com/developerworks/cn/java/j-jtp06197.html

補充說明:64位long和double

在JVM規範中Java記憶體模型要求lock、unlock、read、load、assign、use、store、write這8個操作必須是 原子的,但是對於64位的long和double來說,如果沒有被volatile修飾符修飾,那麼可以不是原子的,注意是可以,即虛擬機器在實現的時候可 以選擇是否是原子操作。目前幾乎所有的商用虛擬機器都將此實現為原子操作,因此不必每次用到它們都去加volatile修飾。

相關文章