1. 程式人生 > 程式設計 >java記憶體模型與volatile

java記憶體模型與volatile

前言

在計算機硬體結構中,為了平衡cpu和記憶體之間由於速度帶來的差距,cpu中引入了cache作為處理器與記憶體之間的緩衝。在多核的處理器中,每個核都有屬於自己的cache,這就帶來了cache一致性的問題。前面提到的MESI協議就是用於處理cache一致性問題的一個協議,它將cache的內容分成幾個狀態,並要求每個核監聽匯流排上傳來的其他核發出的事件,根據這些外部事件以及自身操作cache的內部事件來維護cache的內容和狀態,以達到cache一致性。但MESI協議中特定的優化有時會導致cache中存在臨時的不一致的資料,所以引入了記憶體屏障來規避這個問題。

即使有cache的存在,當處理器等待cache的載入時仍然會浪費時間。所以處理器會在當前指令因等待資料阻塞時嘗試執行其他不依賴這個資料的指令,來儘可能提高處理速度,這稱為亂序執行。處理器會保證亂序執行的結果與順序執行的結果一致,但僅在當前處理器範圍內。如果有其他任務的計算依賴當前任務的中間結果,就有可能出現不符合預期的結果,這個問題同樣可以通過記憶體屏障來規避。

java的記憶體模型

java虛擬機器器規範中定義了java自身的記憶體模型,通過這個記憶體模型來遮蔽不同的作業系統和硬體帶來的差異,達到各個平臺執行效果一致的目標。java記憶體模型規定所有的變數都儲存在主記憶體中,每個執行緒有自己的工作記憶體,執行緒在訪問變數時都直接從工作記憶體中訪問,而不能訪問主記憶體。一個執行緒不能訪問其他的執行緒的工作記憶體,執行緒之間的變數傳遞都需要經過主記憶體來完成。這裡的執行緒、工作記憶體和主記憶體有有點類似計算機硬體結構中的處理器、cache和記憶體的關係。此外,java虛擬機器器中的即時編譯中也有類似指令重排序的優化。

volatile變數

在java中有一個用於實現單例模式的方式,叫做“雙成例檢查”。雙成例檢查利用了synchronized和volatile關鍵詞保證了在併發執行的情況下單例模式的正確性。但是在jdk1.5以前(不包括1.5)的版本是存在問題的,其中具體的原因就是volatile關鍵詞底層實現在jdk1.5才完全正確。

根據volatile的特性,如果一個變數被標記為volatile,那麼它將獲得兩個額外的屬性:

  1. 在一個執行緒中對於volatile變數的修改會立即被其他執行緒感知到,也就是可見性。前面提到,在java記憶體模型中,各個執行緒之間的變數傳遞都需要先經過主記憶體,所以為了效能考慮,執行緒不會總是從主記憶體獲取最新的變數的值,而是在特定的時機才從主記憶體同步最新的內容。而volatile關鍵詞則能夠強制觸發其他執行緒同步主記憶體的內容。
  2. 禁止指令重排序。對於一個普通的變數,只會保證所有依賴這個變數的地方都能獲得正確的結果,而並不會保證對這個變數賦值的順序和實際的程式碼執行順序一致,比如不依賴這個變數的程式碼可能會被挪到之前或者之後執行,也就是“看起來就像是順序執行一樣”。而volatile關鍵詞能夠禁止指令重排序。

在jdk1.5之前的版本,volatile並沒有禁止指令重排序的作用,所以即使把變數宣告為volatile也會存在volatile變數前後的程式碼重排序的情況,這也是在jdk1.5之前不能使用雙成例檢查來實現單例的原因。

volatile的實現

前面提到記憶體屏障能夠避免cache中存在過期資料以及避免亂序執行,而volatile自身也是通過記憶體屏障來實現上述的2個特性的。

記憶體屏障通常分為幾個級別:讀寫(保證屏障前的讀寫操作都早於屏障後的讀寫操作)、讀(只保證讀操作)以及寫(只保證寫操作)。不同體系結構的硬體對記憶體屏障的實現都不一樣,比如在x86中記憶體屏障的指令是:

  • lfence 讀操作屏障
  • sfence 寫操作屏障
  • mfence 讀寫操作屏障

而當我們把實際的java位元組碼反彙編成彙編指令時,可以看到並沒有這幾個屏障,而是在寫入volatile變數之後新增一條lock addl $0,0 (%esp)指令。lock指令的作用是可以使當前處理器的cache內容被寫入記憶體,同時使其他處理器的cache失效,這種操作相當於將本執行緒的工作記憶體的內容同步到主記憶體,也就保證了可見性。而在指令重排序的角度,由於lock指令之前的操作的結果都同步到了記憶體,也就相當於lock之前的操作都已經完成,這樣就相當於“屏障後邊的操作無法穿越到屏障前面”的效果。

lock實際的作用

可以看到,lock實際上具備了記憶體屏障的語義,那lock具體的作用是什麼呢。lock是一個指令字首,在它後面的指令會保證原子執行。其實現方式就是在指令執行期間設定處理器的LOCK#訊號,這樣就能確保處理器能夠互斥的操作記憶體(通過鎖定匯流排來實現),當指令執行完畢之後LOCK#訊號會自動取消。從intel奔騰Pro處理器開始,當要鎖定的記憶體地址已經被載入到cache時,會直接鎖定對應的cache而不是設定LOCK#訊號

也就是說,volatile的實現中通過lock字首+一條空的指令來鎖定cache,實現了可見性和禁止重排序的功能。至於為什麼要用addl $0,0 (%esp)配合lock字首是因為lock字首隻支援記憶體操作類的指令,所以不能直接用lock字首加空指令nop。