Java併發之記憶體模型(JMM)淺析
背景
學習Java併發程式設計,JMM是繞不過的檻。在Java規範裡面指出了JMM是一個比較開拓性的嘗試,是一種試圖定義一個一致的、跨平臺的記憶體模型。JMM的最初目的,就是為了能夠支多執行緒程式設計的,每個執行緒可以是和其他執行緒在不同的CPU核心上執行,或者對於多處理器的機器而言,該模型需要實現的就是使得每一個執行緒就像執行在不同的機器、不同的CPU或者本身就不同的執行緒上一樣,這種情況實際上在專案開發中是常見的。簡單來說,就是為了遮蔽系統和硬體的差異,讓一套程式碼在不同平臺下能到達相同的訪問結果。 當然你要是想做高效能運算,這個還是要和硬體直接打交道的,博主之前搞高效能運算,用的一般都是C/C++,更老的語言還有Fortran,不過現在平行計算也是有很多計算框架和協議的,如MPI協議、基於CPU計算的OpenMp,GPU計算的Cuda、OpenAcc等。JMM在設計之初也是有不少缺陷的,不過後續也逐漸完善起來,還有一個算不上缺陷的缺陷,就是有點難懂。
什麼是JMM
JMM即為JAVA 記憶體模型(java memory model)。Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在JVM中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節的實現規則。它其實就是JVM內部的記憶體資料的訪問規則,執行緒進行共享資料讀寫的一種規則,在JVM內部,多執行緒就是根據這個規則讀寫資料的。
注意,此處的變數與Java程式設計裡面的變數有所不同步,它只是包含了例項欄位、靜態欄位和構成陣列物件的元素,但不包含區域性變數和方法引數(區域性變數和方法引數執行緒私有的,不會共享,當然不存在資料競爭問題,如果區域性變數是一個reference引用型別,它引用的物件在Java堆中可被各個執行緒共享,但是reference引用本身在Java棧的區域性變量表中,是執行緒私有的)。為了獲得較高的執行效能,Java記憶體模型並沒有限制執行引起使用處理器的特定暫存器或者快取來和主記憶體進行互動,也沒有限制即時編譯器進行調整程式碼執行順序這類優化措施。
JMM和JVM有什麼區別
- JVM: Java虛擬機器模型 主要描述的是Java虛擬機器內部的結構以及各個結構之間的關係,Java虛擬機器在執行Java程式的過程中,會把它管理的記憶體劃分為幾個不同的資料區域,這些區域都有各自的用途、建立時間、銷燬時間。
- JMM:Java記憶體模型 主要規定了一些記憶體和執行緒之間的關係,簡單的說就是描述java虛擬機器如何與計算機記憶體(RAM)一起工作。
JMM中的主記憶體、工作記憶體與jJVM中的Java堆、棧、方法區等並不是同一個層次的記憶體劃分,
JMM核心知識點
Java執行緒之間的通訊由Java記憶體模型(JMM)控制,JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係:JMM規定了所有的變數都儲存在主記憶體(Main Memory)中。每個執行緒還有自己的工作記憶體(Working Memory),執行緒的工作記憶體中儲存了該執行緒使用到的變數的主記憶體的副本拷貝,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數(volatile變數仍然有工作記憶體的拷貝,但是由於它特殊的操作順序性規定,所以看起來如同直接在主記憶體中讀寫訪問一般)。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒之間值的傳遞都需要通過主記憶體來完成。
圖:JMM記憶體模型
這上如可以看見java執行緒中工作記憶體是通過cache來和主記憶體互動的,這是因為計算機的儲存裝置與處理器的運算能力之間有幾個數量級的差距,所以現代計算機系統都不得不加入一層或多層讀寫速度儘可能接近處理器運算速度的快取記憶體(cache
)來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中沒這樣處理器就無需等待緩慢的記憶體讀寫了。
執行緒和執行緒之間想進行資料的交換一般大致要經歷兩大步驟:1.執行緒1把工作記憶體1中的更新過的共享變數重新整理到主記憶體中去;2.執行緒2到主記憶體中去讀取執行緒1重新整理過的共享變數,然後copy一份到工作記憶體2中去。(當然具體實現沒有這麼簡單,具體的操作步驟在下文細講)
三大特徵
Java記憶體模型是圍繞著併發程式設計中原子性、可見性、有序性這三個特徵來建立的,那我們依次看一下這三個特徵
1. 原子性
- 定義: 一個或者多個操作不能被打斷,要麼全部執行完畢,要麼不執行。在這點上有點類似於事務操作,要麼全部執行成功,要麼回退到執行該操作之前的狀態。
- 注意點: 一般來說在java中基本型別資料的訪問大都是原子操作,但是對於64位的變數如long 和double型別,在32位JVM中,分別處理高低32位,兩個步驟就打破了原子性,這就導致了long、double型別的變數在32位虛擬機器中是非原子操作,資料有可能會被破壞,也就意味著多個執行緒在併發訪問的時候是執行緒非安全的。所以現在官方建議最好還是使用64JVM,64JVM在安全上和效能上都有所提升。
- 總結: 對於別的執行緒而言,他要麼看到的是該執行緒還沒有執行的情況,要麼就是看到了執行緒執行後的情況,不會出現執行一半的場景,簡言之,其他執行緒永遠不會看到中間結果。
- 解決方案
- 鎖機制:鎖具有排他性,也就是說它能夠保證一個共享變數在任意一個時刻僅僅被一個執行緒訪問,這就消除了競爭;
- CAS(compare-and-swap)
2.可見性
定義:可見性是指當多個執行緒訪問同一個變數時,當一個執行緒修改了這個變數的值,其他執行緒能夠立即獲得修改的值。
實現原理:JMM是通過將在工作記憶體中的變數修改後的值同步到主記憶體,在讀取變數前需要從主記憶體獲取最新值到工作記憶體中,這種只從主記憶體的獲取值的方式來實現可見性的 。
存在問題:多執行緒程式在可見性方面存在問題,這意味著某些執行緒可能會讀到舊資料,即髒讀。
解決方案
-
- volatile變數:volatile的特殊規則保證了volatile變數值修改後的新值會立刻同步到主記憶體,所以每次獲取的volatile變數都是主記憶體中最新的值,因此volatile保證了多執行緒之間的操作變數的可見性
- synchronized關鍵字,在同步方法/同步塊開始時(Monitor Enter),使用共享變數時會從主記憶體中重新整理變數值到工作記憶體中(即從主記憶體中讀取最新值到執行緒私有的工作記憶體中),在同步方法/同步塊結束時(Monitor Exit),會將工作記憶體中的變數值同步到主記憶體中去(即將執行緒私有的工作記憶體中的值寫入到主記憶體進行同步)。
- Lock介面的最常用的實現ReentrantLock(重入鎖)來實現可見性:當我們在方法的開始位置執行lock.lock()方法,這和synchronized開始位置(Monitor Enter)有相同的語義,即使用共享變數時會從主記憶體中重新整理變數值到工作記憶體中(即從主記憶體中讀取最新值到執行緒私有的工作記憶體中),在方法的最後finally塊裡執行lock.unlock()方法,和synchronized結束位置(Monitor Exit)有相同的語義,即會將工作記憶體中的變數值同步到主記憶體中去(即將執行緒私有的工作記憶體中的值寫入到主記憶體進行同步)。
- final關鍵字的可見性是指:被final修飾的變數,在建構函式數一旦初始化完成,並且在建構函式中並沒有把“this”的引用傳遞出去(“this”引用逃逸是很危險的,其他的執行緒很可能通過該引用訪問到只“初始化一半”的物件),那麼其他執行緒就可以看到final變數的值。
3.有序性
定義: 即程式執行的順序按照程式碼的先後順序執行。這個在單一執行緒中自然可以保證,但是多執行緒中就不一定可以保證。
問題原因: 首先處理器為了提高程式執行效率,可能會對目的碼進行重排序。重排序是對記憶體訪問操作的一種優化,它可以在不影響單執行緒程式正確性的前提下進行一定的調整,進而提高程式的效能。其保證依據是處理器對涉及依賴關係的資料指令不會進行重排序,沒有依賴關係的則可能進行重排序,即一個指令Instruction 2必須用到Instruction 1的結果,那麼處理器會保證Instruction 1會在Instruction 2之前執行。(PS:平行計算優化中最基本的一項就是去除資料的依賴關係,方法有很多。)但是在多執行緒中可能會對存在依賴的操作進行重排序,這可能會改變程式的執行結果。
Java有兩種編譯器,一種是Javac靜態編譯器,將原始檔編譯為位元組碼,程式碼編譯階段執行;另一種是動態編譯JIT,會在執行時,動態的將位元組碼編譯為本地機器碼(目的碼),提高java程式執行速度。通常javac不會進行重排序,而JIT則很可能進行重排序
圖:java編譯
總結:在本執行緒內觀察,操作都是有序的;如果在一個執行緒中觀察另外一個執行緒,所有的操作都是無序的。這是因為在多執行緒中JMM的工作記憶體和主記憶體之間存在延遲,而且java會對一些指令進行重新排序。
解決方案
-
- volatile關鍵字本身通過加入記憶體屏障來禁止指令的重排序。
- synchronized關鍵字通過一個變數在同一時間只允許有一個執行緒對其進行加鎖的規則來實現。
-
happens-before 原則:java有一個內建的有序規則,無需加同步限制;如果可以從這個原則中推測出來順序,那麼將會對他們進行有序性保障;如果不能推匯出來,換句話說不與這些要求相違背,那麼就可能會被重排序,JVM不會對有序性進行保障。
八種基本記憶體互動操作
JMM定義了8種操作來完成主記憶體與工作記憶體的互動細節,虛擬機器必須保證這8種操作的每一個操作都是原子的,不可再分的。(對於double和long型別的變數來說,load、store、read和write操作在某些平臺上允許例外)
- lock (鎖定):作用於主記憶體的變數,把一個變數標識為執行緒獨佔狀態
- unlock (解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定
- read (讀取):作用於主記憶體變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用
- load (載入):作用於工作記憶體的變數,它把read操作從主存中變數放入工作記憶體中
- use (使用):作用於工作記憶體中的變數,它把工作記憶體中的變數傳輸給執行引擎,每當虛擬機器遇到一個需要使用到變數的值,就會使用到這個指令
- assign (賦值):作用於工作記憶體中的變數,它把一個從執行引擎中接受到的值放入工作記憶體的變數副本中
- store (儲存):作用於主記憶體中的變數,它把一個從工作記憶體中一個變數的值傳送到主記憶體中,以便後續的write使用
- write (寫入):作用於主記憶體中的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中
現在我們模擬一下兩個執行緒修改資料的操作流程。執行緒1 讀取主記憶體中的值oldNum為1,執行緒2 讀取主記憶體中的值oldNum,然後修改值為2,流程如下
從上圖可以看出,實際使用中在一種有可能,其他執行緒修改完值,執行緒的Cache還沒有同步到主存中,每個執行緒中的Cahe中的值副本不一樣,可能會造成"髒讀"。快取一致性協議,就是為了解決這樣的問題還現,(在這之前還有匯流排鎖機制,但是由於鎖機制比較消耗效能,最終還是被逐漸取代了)。它規定每個執行緒中的Cache使用的共享變數副本是一樣的,採用的是匯流排嗅探技術,流程大致如下
當CPU寫資料時,如果發現操作的變數式共享變數,它將通知其他CPU該變數的快取行為無效,所以當其他CPU需要讀取這個變數的時候,發現自己的快取行為無效,那麼就會從主存中重新獲取。
volatile 會在store時加上一個lock寫完主記憶體後unlock,這樣保證變數在回寫主記憶體時保證變數不被別的變數修改,而且鎖的粒度比較小,效能較好。
Volatile
作用
保證了多執行緒操作下變數的可見性,即某個一個執行緒修改了被volatile修飾的變數的值,這個被修改變數的新值對其他執行緒來說是立即可見的。
執行緒池中的許多引數都是採用volatile來修飾的 如執行緒工廠threadFactory,拒絕策略handler,等到任務的超時時間keepAliveTime,keepAliveTime的開關allowCoreThreadTimeOut,核心池大小corePoolSize,最大執行緒數maximumPoolSize等。因為線上程池中有若干個執行緒,這些變數必需保持對所有執行緒的可見性,不然會引起執行緒池執行錯誤。
缺點
對任意單個volatile變數的讀/寫具有原子性,但類似於volatile++這種複合操作(自增操作是三個原子操作組合而成的複合操作)不具有原子性,原因就是由於volatile會在store操作時加上lock,其餘執行緒在執行store時,由於獲取不到鎖而阻塞,會導致當執行緒對值的修改失效。
原理
底層實現主要是通過彙編的lock的字首指令,他會鎖定這塊記憶體區域的快取(快取行鎖定)並寫回到主記憶體,lock字首指令實際上相當於一個記憶體屏障(也可以稱為記憶體柵欄),記憶體屏障會提供3個功能:
- 它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成;
- 它會強制將對快取的修改操作立即寫入主存;
- 如果是寫操作,它會導致其他CPU中對應的快取行無效(MESI快取一直性協議)。
總結
JMM模型則是對於JVM對於記憶體訪問的一種規範,多執行緒工作記憶體與主記憶體之間的互動原則進行了指示,他是獨立於具體物理機器的一種記憶體存取模型。
對於多執行緒的資料安全問題,三個方面,原子性、可見性、有序性是三個相互協作的方面,不是說保障了任何一個就萬事大吉了,另外也並不一定是所有的場景都需要全部都保障才能夠執行緒安全。
參考資料
https://www.cnblogs.com/lewis0077/p/5143268.html
《java併發程式設計》