Java 內存模型與volatile特性深入分析

​Java Memory Model操作規則及特性,以及JMM中volatile的特殊規則

2018年拍攝於京都智積院,千利休最喜歡的庭院之一。

微信公衆號

王皓的GitHub:https://github.com/TenaciousDWang

 

這一回主要講講Java的內存模型JMM(Java Memory Model)及其特性和規則以及volatile關鍵字相對於JMM的特殊規則,這個關鍵字平時雖然用的很少,但是卻很重要。需要注意本回說的JMM對內存的劃分概念與Java堆棧方法區對內存的劃分不在一個維度上他們之間基本上是沒有關係的,爲了避免混淆,這裏先說明一下。

 

現在我們計算機的性能越來越強,CPU的處理速度非常快,我現在主力機使用一顆AMD 2700,已經8核16線程,默頻小超至4.0GHz穩定使用(之前一直Intel,瞧不起AMD,現在AMD真香警告),大家可能發現其實CPU不用太好,只需要換一個固態,速度立馬就提升一大截,其實操作系統並不怎麼喫CPU,我的NAS使用一顆IntelG4620,裝上固態一樣可以起飛,老的筆記本臺機換上固態也能第二春,其實我們說電腦慢大部分原因主要是因爲時間浪費在IO操作上,也就是數據在存儲設備上的讀寫,CPU的計算能力和數據處理都是納秒級別,也許說的有些不嚴謹,但是基本上是這個意思,CPU處理能裏也是有高有底,這裏不作爲因素來說明。

 

CPU從存儲設備讀取數據運算,運算結束後寫入存儲設備,一進一出,會花費大量的時間,於是有了內存,內存的速度比現在的機械與固態硬盤快了好幾個數量級,我們把一部分數據放入內存緩存供CPU運算讀寫,速度提升了起來,但是CPU運算速度實在太快了,人們爲了進一步壓榨CPU的運算能力,又創造了一種能夠基本跟上CPU速度的高速緩存,介於CPU與內存之間,其實這個高速緩存直接封裝到了CPU裏與核心在一起,告訴緩存是分級的,這裏以我的AMD 2700舉例,其共有三級緩存。

 

L1 Cache(一級緩存)。集成在CPU內部中,用於CPU在處理數據過程中數據的暫時保存。由於緩存指令和數據與CPU同頻工作,L1級高速緩存緩存的容量越大,存儲信息越多,可減少CPU與內存之間的數據交換次數,提高CPU的運算效率。但因高速緩衝存儲器均由靜態RAM組成,結構較複雜,在有限的CPU芯片面積上,L1級高速緩存的容量不可能做得太大。

 

L2 Cache(二級緩存)是CPU的第二層高速緩存,分內部和外部兩種芯片。內部的芯片二級緩存運行速度與主頻相同,而外部的二級緩存則只有主頻的一半。L2高速緩存容量也會影響CPU的性能,原則是越大越好,現在家庭用CPU容量最大的是4MB,而服務器和工作站上用CPU的L2高速緩存普遍大於4MB,有的高達8MB或者19MB。二級緩存是CPU性能表現的關鍵之一,在CPU核心不變化的情況下,增加二級緩存容量能使性能大幅度提高。而同一核心的CPU高低端之分往往也是在二級緩存上有差異,由此可見二級緩存對於CPU的重要性。

 

L3 Cache(三級緩存)是爲讀取二級緩存後未命中的數據設計的—種緩存,在擁有三級緩存的CPU中,只有約5%的數據需要從內存中調用,這進一步提高了CPU的效率。

 

每一個型號CPU核心架構是不一樣的,不同的CPU爲了特定性能都有着不同的解決方案,好像跑題了......總之有個高速緩存這個玩意,很好的解決了CPU與內存在速度上的矛盾。

 

這樣就可以起飛了嗎?對不起還不能,雖然加了高速緩存,但是結構更加複雜了。

 

 

當多個邏輯處理器的運算任務都涉及到同一塊主內存區域時,可能導致緩存數據不一致,那麼將這些高速緩存同步回主內存時,如何保證數據的一致性呢。爲了解決這個緩存一致性問題,所有高速緩存都需要遵循一些協議,在讀寫時需要根據協議來操作,這類協議有MSI,MESI,MOSI,其他的大家可以自行百度,這裏說一個最出名的就是Intel 的MESI協議,MESI協議保證了每個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從內存重新讀取。

 

接下來我們來說JMM,JMM的主要目標是定義程序中各個變量的訪問規則,就是向內存中存取變量的規則,這裏變量指的共享變量,線程私有變量不會被共享,所以不會存在競爭問題。

 

前面花了很多篇幅說了如何解決CPU與內存交互效率的問題,中間添加了高速緩存來提高IO速度,在JMM中,JVM虛擬機的開發團隊在主內存與Java線程相當於處理器之間加入了工作內存。這裏我們可以簡單的吧主內存當做Java的堆,裏面存儲了實例變量,靜態變量等,工作內存可以簡單當做Java的棧,雖然這樣對應不太對,但是便於理解,一般情況下系統硬件或JVM虛擬機會將工作內存放入到CPU的高速緩存或寄存器中以優化速度,因爲程序訪問內存主要是訪問的工作內存,處理完成後再又工作內存寫入主內存。

 

 

之前我們提到過緩存一致性問題,那麼這個問題JVM是如何解決的呢?首先我們來看一下工作內存與主內存交互操作的實現細節,根據周志明《深入理解JVM虛擬機》中所說目前JVM文檔中已經放棄對於工作內存與主內存交互的八種基本原子性操作的描述,但是JMM並沒有改變,爲了便於理解,我們還是根據這八種基本操作爲基礎來說一下規則。首先看一下八種基本原子性操作的定義。

 

lock鎖定,在主內存中鎖定一個變量爲線程獨佔。

 

unlock解鎖,在主內存中,釋放一個鎖定變量上的鎖,使其可以被其他線程鎖定。

 

read讀取,在主內存中,讀取變量值到工作內存中。

 

load載入,在工作內存中,把read操作從主內存中讀取的值,放入到工作內存中的副本中。

 

use使用,在工作內存中,把一個變量值傳遞給執行引擎,JVM中遇到使用變量的字節碼執行時使用。

 

aasign賦值,在工作內存中,把從執行引擎接收的值賦給工作變量中,JVM中遇到給變量賦值的字節碼指令時調用。

 

store存儲,與read相反,這個是在工作變量中把內存的值轉送到主內存。

 

write寫入,在主內存中把store傳來的值寫入主內存中。

 

接下來我們來看一下JVM定義的規則是如何來保證多線程下內存訪問是安全的。

 

1、變量從主內存中複製到工作內存,需要順序執行read與load操作,反之必須順序執行store與write操作,要保證按順序但是不用保證連續執行例如a,b兩個變量,順序可以是read a,read b,load b,load a。

 

2、不允許read、load、store、write裏面某個操作單獨出現,read了就一定要load,store了就一定要write。

 

3、不允許線程放棄assign操作,賦值操作必須執行且比如同步到主內存。

 

4、不允許在無assign操作的情況下把工作內存的值同步到主內存中。

 

5、工作內存中的變量必須執行load與assign後才能執行use與store。

 

6、一個變量允許一個線程lock,可以重複操作但是unlock時要執行同樣次數才能解鎖變量。

 

7、如果一個線程執行lock操作,所有工作內存中該變量的值清空,必須從主內存中重新load或assign新的值。

 

8、unlock前該線程必須執行過lock,不允許unlock其他線程的lock。

 

9、在執行unlock前必須把該變量從工作內存中同步到主內存中。

 

在說完了JMM對於內存交互操作的規則之後,我們接下來開始說volatile關鍵字,volatile用來修飾變量,該類型的變量具備一些特殊的訪問規則,總共有兩種特性。

 

第一種,保證此變量對多有線程可見,我們之前講過當一個線程對一個變量進行操作時,屬於黑盒操作,對於其他線程不可見,只對當前持有鎖的線程可見,這裏被volatile定義之後,則對所有變量可見,當該變量被修改時,新的值對於其他線程是可以立刻得知的,普通的變量是做不到這一點的。

 

volatile解決的是多線程共享變量的可見性問題,類似於synchronized,但是不具備其互斥性,所以說對volatile變量的操作並非都是原子性,所以說它並不是多線程安全的,讀取沒問題,但是多寫場景必定出現線程安全問題。

 

volatile只能保證可見性,如果不符合一下場景,則必須使用synchronized與JUC裏的Lock或者atomic類來保證原子性和線程安全,atomic類可以保證基本數據類型的自增和自減,加法與減法屬於原子性操作,其實就是保證讀取變量的原始值、進行操作、寫入工作內存這三個過程處於一個不可分割的操作,避免多線程情況下由於上述三步分開導致執行結果與預期值不符的情況。

 

1、運算結果並不依賴volatile變量的當前值,或者確保單線程操作。

 

2、volatile變量不需要與其他的狀態變量共同參與不變約束。

 

下面的場景非常適合volatile的使用,其他線程可見的情況下,可以停止所有線程,這是多讀場景。

 

 

第二種,volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。首先我們先看一下什麼是指令排序。

 

指令1把變量A的值增加10,指令2把變量A的值乘5,指令三把變量B的值加8,這裏面指令1與2之間是有依賴的他們之間不能重排,因爲(A+10)*5與A*5+10顯然是不對等的,但是指令3可以放到指令1與指令2中間,或其他位置。

 

指令排序主要還是編譯器以及CPU爲了優化代碼或者執行的效率而執行的優化操作;應用條件是單線程場景下,對於併發多線程場景下,指令重排會產生不確定的執行效果。如何禁用指令重排呢,這裏我們就用到了volatile關鍵字來設置內存屏障阻止關於此變量的指令重排。

 

volatile關鍵字禁止指令重排序有兩層意思:

 

  1、當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行。

 

  2、在進行指令優化時,不能將在對volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句放到其前面執行。

 

舉個例子就明白了。

 

 

由於flag變量爲volatile變量,那麼在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

 

並且volatile關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

 

對volatile型flag變量做操作時,字節碼指令裏多執行了一個“lock addI $ 0x0, (%esp)”,這個操作就是一個內存屏障(Memory Barrier)。

 

最後我們總結一下JMM中volatile的特殊規則。

 

1、工作內存中,每次使用volatile型變量都必須從主內存內重刷新值,用於保證當前線程對其他線程修改後的值可見。

 

2、工作內存中,每次修改volatile型變量都必須立刻同步寫入主內存,以保證其他線程對於當前線程修改的可見行。

 

3、A對volatile型變量a執行use,F對volatile型變量a執行與A動作關聯load,P對volatile型變量a執行與F動作相關聯的read,B對volatile型變量b執行use,G對volatile型變量b執行與B動作關聯load,Q對volatile型變量b執行與G動作相關聯的read,如果A先於B,那麼P肯定先於Q,基於禁止指令重排特性。

 

根據上面各類規則,最後我們來看一下JMM模型的整體特徵。

 

1、原子性(Atomicity)

 

由JMM直接保證的原子性操作有read、load、assign、use、store、write,這些對基本數據類型的訪問讀寫是具備原子性的long與double的特殊非原子性可以忽略現在的商用JVM不會發生,非複合操作可以保證原子性,複合操作JVM使用lock與unlock來保證內存操作的原子性,這兩個操作沒有開放給用戶使用,但是提供了monitorenter與monitorexit這兩個字節碼指令,對應的就是synchronized,所以我們可以使用synchronized來實現原子性操作。

 

2、可見性(Visibility)

 

當一個線程修改了共享變量的值,其他線程能夠立刻得知,這裏主要使用了volatile的特殊規則,上面已經說過原理了,在此不再贅述。同步代碼塊也可以實現可見性,利用了unlock操作之前,必須把值同步回主內存中實現可見。final也可以實現可見性,final型變量初始化完成就可以被其他線程看見。

 

3、有序性(ordering)

 

在Java中,如果在本線程內觀察,所有操作都是有序的,在一個線程中觀察其他線程所有操作都是無序的,前半句指“線程內表現爲串行的語意”(Within-Thread As-If-Serial Semantics),後面句指指令重排與工作內存與主內存同步規則。

 

Java提供了volatile與synchronized來保證線程之間的有序性,volatile本身具備禁止指令重排特性,synchronized是一個同步塊可以保證一個共享變量同時只能一個線程訪問,可以保證線程間的有序性。

 

除此之外,Java語言還有一個“先行發生”(hanppen-before)原則,這個是天然的無需任何同步器協助就已經存在的有序性,如果兩個操作的執行次序無法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序,以下這8條原則摘自《深入理解Java虛擬機》。

 

1、程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作,準確來說是控制流程順序,還要考慮分支和循環操作。

 

2、鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作。

 

3、volatile變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作。

 

4、傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C。

 

5、線程啓動規則:Thread對象的start()方法先行發生於此線程的每個一個動作。

 

6、線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。

 

7、線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行。

 

8、對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始。

 

以上就是關於JMM與JMM操作規則和特性。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章