1. 程式人生 > 其它 >深入理解java虛擬機器筆記-java記憶體模式與執行緒1

深入理解java虛擬機器筆記-java記憶體模式與執行緒1

一、硬體的效率與一致性

絕大多數的運算任務都不可能只靠處理器“計算”就能完成。 處理器至少要與記憶體互動, 如讀取運算資料、儲存運算結果等, 這個I/O操作就是很難消除的(無法僅靠暫存器來完成所有運算任務) 。 由於計算機的儲存裝置與處理器的運算速度有著幾個數量級的差距, 所以現代計算機系統都不得不加入一層或多層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache) 來作為記憶體與處理器之間的緩衝: 將運算需要使用的資料複製到快取中, 讓運算能快速進行, 當運算結束後再從快取同步回記憶體之中, 這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體速度之間的矛盾, 但是也為計算機系統帶來更高的複雜度, 它引入了一個新的問題: 快取一致性(Cache Coherence) 。 在多路處理器系統中, 每個處理器都有自己的快取記憶體, 而它們又共享同一主記憶體(Main Memory) , 這種系統稱為共享記憶體多核系統(Shared Memory Multiprocessors System) , 如圖12-1所示。 當多個處理器的運算任務都涉及同一塊主記憶體區域時, 將可能導致各自的快取資料不一致。 如果真的發生這種情況, 那同步回到主記憶體時該以誰的快取資料為準呢? 為了解決一致性的問題, 需要各個處理器訪問快取時都遵循一些協議, 在讀寫時要根據協議來進行操作, 這類協議有MSI、 MESI(Illinois Protocol) 、 MOSI、Synapse、 Firefly及Dragon Protocol等。 從本章開始, 我們將會頻繁見到“記憶體模型”一詞, 它可以理解為在特定的操作協議下, 對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。 不同架構的物理機器可以擁有不一樣的記憶體模型, 而Java虛擬機器也有自己的記憶體模型, 並且與這裡介紹的記憶體訪問操作及硬體的快取訪問操作具有高度的可類比性。

 

 

 

除了增加快取記憶體之外, 為了使處理器內部的運算單元能儘量被充分利用, 處理器可能會對輸入程式碼進行亂序執行(Out-Of-Order Execution) 優化, 處理器會在計算之後將亂序執行的結果重組, 保證該結果與順序執行的結果是一致的, 但並不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致, 因此如果存在一個計算任務依賴另外一個計算任務的中間結果, 那麼其順序性並不能靠程式碼的先後順序來保證。 與處理器的亂序執行優化類似, Java虛擬機器的即時編譯器中也有指令重排序(Instruction Reorder) 優化。

二、 Java記憶體模型

《Java虛擬機器規範》 中曾試圖定義一種“Java記憶體模型”來遮蔽各種硬體和作業系統的記憶體訪問差異, 以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。 在此之前, 主流程式語言(如C和C++等) 直接使用物理硬體和作業系統的記憶體模型。 因此, 由於不同平臺上記憶體模型的差異, 有可能導致程式在一套平臺上併發完全正常, 而在另外一套平臺上併發訪問卻經常出錯, 所以在某些場景下必須針對不同的平臺來編寫程式。

定義Java記憶體模型並非一件容易的事情, 這個模型必須定義得足夠嚴謹, 才能讓Java的併發記憶體訪問操作不會產生歧義; 但是也必須定義得足夠寬鬆, 使得虛擬機器的實現能有足夠的自由空間去利用硬體的各種特性(暫存器、 快取記憶體和指令集中某些特有的指令) 來獲取更好的執行速度。 經過長時間的驗證和修補, 直至JDK 5 釋出後, Java記憶體模型才終於成熟、 完善起來了。

2.1 主記憶體與工作記憶體

Java記憶體模型的主要目的是定義程式中各種變數的訪問規則, 即關注在虛擬機器中把變數值儲存到記憶體和從記憶體中取出變數值這樣的底層細節。 此處的變數(Variables) 與Java程式設計中所說的變數有所區別, 它包括了例項欄位、 靜態欄位和構成陣列物件的元素, 但是不包括區域性變數與方法引數, 因為後者是執行緒私有的, 不會被共享, 自然就不會存在競爭問題。 為了獲得更好的執行效能, Java記憶體模型並沒有限制執行引擎使用處理器的特定暫存器或快取來和主記憶體進行互動, 也沒有限制即時編譯器是否要進行調整程式碼執行順序這類優化措施。 Java記憶體模型規定了所有的變數都儲存在主記憶體中 。 每條執行緒還有自己的工作記憶體(Working Memory, 可與前面講的處理器快取記憶體類比) , 執行緒的工作記憶體中儲存了被該執行緒使用的變數的主記憶體副本, 執行緒對變數的所有操作(讀取、 賦值等) 都必須在工作記憶體中進行, 而不能直接讀寫主記憶體中的資料。 不同的執行緒之間也無法直接訪問對方工作記憶體中的變數, 執行緒間變數值的傳遞均需要通過主記憶體來完成, 執行緒、 主記憶體、 工作記憶體三者的互動關係如圖

 

 

這裡所講的主記憶體、 工作記憶體與第2章所講的Java記憶體區域中的Java堆、 棧、 方法區等並不是同一個層次的對記憶體的劃分, 這兩者基本上是沒有任何關係的。 如果兩者一定要勉強對應起來, 那麼從變數、 主記憶體、 工作記憶體的定義來看, 主記憶體主要對應於Java堆中的物件例項資料部分, 而工作記憶體則對應於虛擬機器棧中的部分割槽域。 從更基礎的層次上說, 主記憶體直接對應於物理硬體的記憶體, 而為了獲取更好的執行速度, 虛擬機器(或者是硬體、 作業系統本身的優化措施) 可能會讓工作記憶體優先儲存於暫存器和快取記憶體中, 因為程式執行時主要訪問的是工作記憶體。

2.2 記憶體間互動操作

關於主記憶體與工作記憶體之間具體的互動協議, 即一個變數如何從主記憶體拷貝到工作記憶體、 如何從工作記憶體同步回主記憶體這一類的實現細節, Java記憶體模型中定義了以下8種操作來完成。 Java虛擬機器實現時必須保證下面提及的每一種操作都是原子的、 不可再分的。 ·lock(鎖定) : 作用於主記憶體的變數, 它把一個變數標識為一條執行緒獨佔的狀態。

·unlock(解鎖) : 作用於主記憶體的變數, 它把一個處於鎖定狀態的變數釋放出來, 釋放後的變數才可以被其他執行緒鎖定。

·read(讀取) : 作用於主記憶體的變數, 它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中, 以便隨後的load動作使用。

·load(載入) : 作用於工作記憶體的變數, 它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。

·use(使用) : 作用於工作記憶體的變數, 它把工作記憶體中一個變數的值傳遞給執行引擎, 每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。

·assign(賦值) : 作用於工作記憶體的變數, 它把一個從執行引擎接收的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。

·store(儲存) : 作用於工作記憶體的變數, 它把工作記憶體中一個變數的值傳送到主記憶體中, 以便隨後的write操作使用。

·write(寫入) : 作用於主記憶體的變數, 它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。如果要把一個變數從主記憶體拷貝到工作記憶體, 那就要按順序執行read和load操作, 如果要把變數從工作記憶體同步回主記憶體, 就要按順序執行store和write操作。

注意, Java記憶體模型只要求上述兩個操作必須按順序執行, 但不要求是連續執行。 也就是說read與load之間、 store與write之間是可插入其他指令的, 如對主記憶體中的變數a、 b進行訪問時, 一種可能出現的順序是read a、 read b、 load b、 load a。

除此之外, Java記憶體模型還規定了在執行上述8種基本操作時必須滿足如下規則:

·不允許read和load、 store和write操作之一單獨出現, 即不允許一個變數從主記憶體讀取了但工作記憶體不接受, 或者工作記憶體發起回寫了但主記憶體不接受的情況出現。

·不允許一個執行緒丟棄它最近的assign操作, 即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。

·不允許一個執行緒無原因地 把資料從執行緒的工作記憶體同步回主記憶體中。

·一個新的變數只能在主記憶體中“誕生”, 不允許在工作記憶體中直接使用一個未被初始化(load或assign) 的變數, 換句話說就是對一個變數實施use、 store操作之前, 必須先執行assign和load操作。

·一個變數在同一個時刻只允許一條執行緒對其進行lock操作, 但lock操作可以被同一條執行緒重複執行多次, 多次執行lock後, 只有執行相同次數的unlock操作, 變數才會被解鎖。

·如果對一個變數執行lock操作, 那將會清空工作記憶體中此變數的值, 在執行引擎使用這個變數前, 需要重新執行load或assign操作以初始化變數的值。 ·如果一個變數事先沒有被lock操作鎖定, 那就不允許對它執行unlock操作, 也不允許去unlock一個被其他執行緒鎖定的變數。 ·對一個變數執行unlock操作之前, 必須先把此變數同步回主記憶體中(執行store、 write操作) 。 這8種記憶體訪問操作以及上述規則限定, 再加上稍後會介紹的專門針對volatile的一些特殊規定, 就已經能準確地描述出Java程式中哪些記憶體訪問操作在併發下才是安全的。

2.3 對於volatile型變數的特殊規則

關鍵字volatile可以說是Java虛擬機器提供的最輕量級的同步機制, 但是它並不容易被正確、 完整地理解, 以至於許多程式設計師都習慣去避免使用它, 遇到需要處理多執行緒資料競爭問題的時候一律使用synchronized來進行同步。 瞭解volatile變數的語義對後面理解多執行緒操作的其他特性很有意義, 在本節中我們將多花費一些篇幅介紹volatile到底意味著什麼。

Java記憶體模型為volatile專門定義了一些特殊的訪問規則, 在介紹這些比較拗口的規則定義之前,先用一些不那麼正式, 但通俗易懂的語言來介紹一下這個關鍵字的作用。

當一個變數被定義成volatile之後, 它將具備兩項特性:

第一項是保證此變數對所有執行緒的可見性, 這裡的“可見性”是指當一條執行緒修改了這個變數的值, 新值對於其他執行緒來說是可以立即得知的。 而普通變數並不能做到這一點, 普通變數的值線上程間傳遞時均需要通過主記憶體來完成。

比如,執行緒A修改一個普通變數的值, 然後向主記憶體進行回寫, 另外一條執行緒B線上程A回寫完成了之後再對 主記憶體進行讀取操作, 新變數值才會對執行緒B可見。

關於volatile變數的可見性, 經常會被開發人員誤解, 他們會誤以為下面的描述是正確的: “volatile變數對所有執行緒是立即可見的, 對volatile變數所有的寫操作都能立刻反映到其他執行緒之中。 換句話說, volatile變數在各個執行緒中是一致的, 所以基於volatile變數的運算在併發下是執行緒安全的”。 這句話 的論據部分並沒有錯, 但是由其論據並不能得出“基於volatile變數的運算在併發下是執行緒安全的”這樣的結論。 volatile變數在各個執行緒的工作記憶體中是不存在一致性問題的(從物理儲存的角度看, 各個執行緒的工作記憶體中volatile變數也可以存在不一致的情況, 但由於每次使用之前都要先重新整理, 執行引擎看不到不一致的情況, 因此可以認為不存在一致性問題) , 但是Java裡面的運算操作符並非原子操作,這導致volatile變數的運算在併發下一樣是不安全的, 我們可以通過一段簡單的演示來說明原因, 請看程式碼清單12-1中演示的例子。

由於volatile變數只能保證可見性, 在不符合以下兩條規則的運算場景中, 我們仍然要通過加鎖(使用synchronized、 java.util.concurrent中的鎖或原子類) 來保證原子性:

1.運算結果並不依賴變數的當前值, 或者能夠確保只有單一的執行緒修改變數的值。

2.變數不需要與其他的狀態變數共同參與不變約束。

解決了volatile的語義問題, 再來看看在眾多保障併發安全的工具中選用volatile的意義——它能讓我們的程式碼比使用其他的同步工具更快嗎? 在某些情況下, volatile的同步機制的效能確實要優於鎖 , 但是由於虛擬機器對鎖實行的許多消除和優化, 使得我們很難確切地說volatile就會比synchronized快上多少。 如果讓volatile自己與自己比較, 那可以確定一個原則: volatile變數讀操作的效能消耗與普通變數幾乎沒有什麼差別, 但是寫操作則可能 會慢上一些, 因為它需要在原生代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

不過即便如此, 大多數場景下volatile的總開銷仍然要比鎖來得更低。 我們在volatile與鎖中選擇的唯一判斷依 據僅僅是volatile的語義能否滿足使用場景的需求。 本節的最後, 我們再回頭來看看Java記憶體模型中對volatile變數定義的特殊規則的定義。 假定T表示一個執行緒, V和W分別表示兩個volatile型變數, 那麼在進行read、 load、 use、 assign、 store和write操作 時需要滿足如下規則: ·只有當執行緒T對變數V執行的前一個動作是load的時候, 執行緒T才能對變數V執行use動作; 並且,只有當執行緒T對變數V執行的後一個動作是use的時候, 執行緒T才能對變數V執行load動作。 執行緒T對變數V的use動作可以認為是和執行緒T對變數V的load、 read動作相關聯的, 必須連續且一起出現。

這條規則要求在工作記憶體中, 每次使用V前都必須先從主記憶體重新整理最新的值, 用於保證能看見其他執行緒對變數V所做的修改。

·只有當執行緒T對變數V執行的前一個動作是assign的時候, 執行緒T才能對變數V執行store動作; 並且, 只有當執行緒T對變數V執行的後一個動作是store的時候, 執行緒T才能對變數V執行assign動作。 執行緒T對變數V的assign動作可以認為是和執行緒T對變數V的store、 write動作相關聯的, 必須連續且一起出現。

這條規則要求在工作記憶體中, 每次修改V後都必須立刻同步回主記憶體中, 用於保證其他執行緒可以看到自己對變數V所做的修改。

·假定動作A是執行緒T對變數V實施的use或assign動作, 假定動作F是和動作A相關聯的load或store動作, 假定動作P是和動作F相應的對變數V的read或write動作; 與此類似, 假定動作B是執行緒T對變數W實施的use或assign動作, 假定動作G是和動作B相關聯的load或store動作, 假定動作Q是和動作G相應的對變數W的read或write動作。 如果A先於B, 那麼P先於Q。

這條規則要求volatile修飾的變數不會被指令重排序優化, 從而保證程式碼的執行順序與程式的順序相同。