JVM二:全面理解Java記憶體模型(JMM)及Java記憶體區域
一、計算機記憶體
1.1、計算機硬體記憶體架構。
計算機CPU(central processing unit)和記憶體的互動是最頻繁的,記憶體是我們的快取記憶體區。使用者磁碟和CPU的互動,而CPU運轉速度越來越快,磁碟遠遠跟不上CPU的讀寫速度,才設計了記憶體,使用者快取使用者IO等待導致CPU的等待成本。但是隨著CPU的發展,記憶體的讀寫速度也遠遠跟不上CPU的讀寫速度,因此,為了解決這一糾紛,CPU廠商在每顆CPU上加入了快取記憶體,用來緩解這種症狀,CPU與記憶體的互動如下圖:
也就是,當程式在執行過程中,會將運算需要的資料從主存複製一份到CPU的快取記憶體當中,那麼CPU進行計算時就可以直接從它的快取記憶體讀取資料和向其中寫入資料,當運算結束之後,再將快取記憶體中的資料重新整理到主存當中。
同樣,我們知道單核CPU的主頻不可能無限增長,想要提升效能,需要多個處理器協同工作,Intel總裁貝瑞特單膝下跪事件標識著多核時代的到來。
基於快取記憶體的儲存互動很好的解決了處理器與記憶體之間的矛盾,也引入了新的問題:快取一致性問題。在多處理器系統中,每個處理器有自己的快取記憶體,而他們又共享同一塊記憶體(主存),當多個處理器運算都涉及到同一塊記憶體區域的時候,就有可能出現快取不一致的問題
i=i+1;
當執行緒執行這個語句時,會先從主存當中讀取i的值,然後複製一份到快取記憶體當中,然後CPU執行指令對i進行加1操作,然後將資料寫入快取記憶體,最後將快取記憶體中i最新的值重新整理到主存當中。
這個程式碼在單執行緒中執行是沒有任何問題的,但是在多執行緒中執行就會有問題了。在多核CPU中,每條執行緒可能運行於不同的CPU中,因此每個執行緒執行時有自己的快取記憶體(對單核CPU來說,其實也會出現這種問題,只不過是以執行緒排程的形式來分別執行的)。本文我們以多核CPU為例。
假設同時有2個執行緒執行這段程式碼,假如初始時i的值為0,那麼我們希望兩個執行緒執行完之後i的值變為2,但是事實會是這樣嗎?
可能存在下面一種情況:初始時,兩個執行緒分別讀取i的值存入各自所在的CPU的快取記憶體當中,然後執行緒1進行加1操作,然後把i的最新值1寫入到記憶體。此時執行緒2的快取記憶體當中i的值還是0,進行加1操作之後,i的值為1,然後執行緒2把i的值寫入記憶體。
最終結果i的值是1,而不是2,這就是快取一致性問題。通常稱這種被多個執行緒訪問的變數為共享變數。也就是說,如果一個變數在多個CPU中都存在快取(一般在多執行緒程式設計時才會出現),那麼就可能存在快取不一致的問題。
為了解決這一問題,在硬體層面的解決辦法有兩種:
( 1 )通過在匯流排加LOCK#鎖的方式。
在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決快取不一致的問題。因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。
但是這種方式會有一個問題,由於在鎖住匯流排期間,其他CPU無法訪問記憶體,導致效率低下。
( 2 )通過快取一致性協議。
需要各個處理器執行時都遵守一些協議,在執行時將需要這些協議儲存資料的一致性。協議包括:MSI/MESI/MOSI/Synapse/Firely/DragonProtocol等。
最出名的就是Intel的MESI協議,MESI協議保證了每個快取中使用的共享變數的副本是一致的。它核心的思想是:當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。
二、Java記憶體區域
2.1、Java執行時資料區域(jvm記憶體模型,也是靜態記憶體儲存模型,是對jvm記憶體的物理劃分)。
Java虛擬機器在執行Java程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域。這些區域都有各自的用途,以及建立和銷燬的時間,有的區域隨著虛擬機器程序的啟動而存在,有些區域則依賴使用者執行緒的啟動和結束而建立和銷燬。根據《Java虛擬機器規範(Java SE 7版)》的規定,Java虛擬機器所管理的記憶體將會包括以下幾個執行時資料區域。
(1)方法區(Method Area):
方法區(Method Area)與Java堆一樣。是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap (非堆),目的應該是與Java堆區分開來。
對於習慣在HotSpot虛擬機器上開發和部署程式的開發者來說,很多人願意把方法區稱為“永久代”(Permanent Generation),本質上兩者並不等價,僅僅是因為HotSpot慮扣機的設計團隊選擇把GC分代收集擴充套件至方法區,或者說使用永久代來實現方法區而已。對於其他虛擬機器(如BEA JRockit、IBM J9等)來說是不存在永久代的概念的。即使是HotSpot虛擬機器本身,根據官方釋出的路線圖資訊,現在也有放棄永久代並“搬家”至Native Memory來實現方法區的規劃了。
Java虛擬機器規範對這個區域的限制非常寬鬆,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。相對而言,垃圾收集行為在這個區域是比較少出現的,但並非資料進入了方法區就如永久代的名字一樣“永久”存在了。這個區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,一般來說這個區域的回收“成績”比較難以令人滿意,尤其是型別的解除安裝, 條件相苛刻,但是這部分割槽域的回收確實是有必要的。在Sun公司的BUG列表中,曾出現過的若干個嚴重的BUG就是由於低版本的HotSpot虛擬機器對此區域未完全回收而導致記憶體洩露。
根據Java虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError異常。
(2)堆(Heap):
對於大多數應用來說,Java堆(Java Heap)是Java虛擬機器所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。這一點在Java虛擬機器規範中的描述是:所有的物件例項以及陣列都要在堆上分配,但是隨著JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的物件都分配在堆上也漸漸變得不是那麼“絕對”了。
Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱做“GC堆”(Garbage Collected Heap,幸好國內沒翻譯成“垃圾堆”)。從記憶體回收的角度來看,由於現在收集器基本都採用分代收集演算法,所以Jaya堆中還可以細分為:新生代和老年代;再細緻一點的有Eden空間、From Survivor空間、To Survivor空間等。從記憶體分配的角度來看,執行緒共享的Java堆中可能劃分出多個執行緒私有的分配緩對區(Thead Local Allocation Buffer, TLAB)。不過無論如何劃分,都與存放內容無關,無論哪個區域,儲存的都仍然是物件例項,進一步劃分的目的是為了更好地回收記憶體,或者更快地分配記憶體。 在本章中,我們僅僅針對記憶體區域的作用進行討論,Java堆中的上述各個區域的分配、回收等細節將是第3章的主題。:
根據Java虛擬機器規範的規定,Java的堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像我們的磁碟空間一樣。在實現時,既可以實現成固定大小的,也可以是可擴充套件的,不過當前主流的虛擬機器都是按照可擴充套件來實現的(通過-Xmx和-Xms控制)如果在堆中沒在記憶體完成例項分配。並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError。
(3)程式計數器(Program Counter Register):
程式計數器(Piognim Coumter Resiatce是塊較小的記憶體空間,它可以看作是當前線後中程所執行的位元組碼的行號指示器。在虛擬機器的概念模整裡(僅是概念模型,各種虛擬機器可能會通過一些更高效的方式去實現),位元組碼直譯器工作時就是通過改變這個記數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都都要依賴這個計數器來完成。
由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。因此為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。
如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址,如果正在執行的是Naive方法,這個計數器值則為空(Undefined)。此記憶體區域是唯一一個在Java虛擬機器規範中沒有規定任何OutOtMemoryEror情況的區域。
(4)Java虛擬機器棧(Java Virtual Machine Stacks):
與程式計數器一樣,Java虛擬機器棧也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame)用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。每個方法從呼叫直至執行完成的過程,就對應者一個棧幀在虛擬機器棧中入棧到出棧的過程。
經常有人把Java記憶體區分為堆記憶體(Heap)和棧記憶體(Stack),這種分法比較粗糙,Java記憶體區域的劃分方式實際上遠比這複雜。這種劃分方式的流行只能說明大多數程式設計師最關注的、與物件記憶體分配關係最密切的記憶體區域是這兩塊。其中所指的“堆”筆者在後面會專門講述,而所指的“棧”就是現在講的虛擬機器棧,或者說是虛擬機器棧中區域性變量表部分。
區域性變量表存放了編譯期可知的各種資料型別(boolean、byte、char、short.、int、float、long、double)、物件引用(reference型別,它不等同於物件本身,可能是一個指向物件起始地址的引用指標,也可能是指向一個代表物件的控制代碼或其他與此物件相關的位置)和return Address型別(指向了一條位元組碼指令的地址對)。
其中64位長度的long和double型別的資料會佔用2個區域性變數間(Slot),其餘的資料型別只佔用1個。區域性變數所需的記憶體空間在編譯器間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變量表的大小。
在Java虛擬機器規範中,對這個區城規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;如果虛擬機器棧可以動態擴充套件(當前大部分Java虛擬機器都可動態擴充套件,只不過Java虛擬機器規範中也允許固定長度的虛擬機器棧),如果擴充套件時無非申請到足夠的記憶體,將會丟擲OutOfMemoryError異常。
(5)本地方法棧(Native Method Stack):
本地方法棧屬於執行緒私有的資料區域,這部分主要與虛擬機器用到的 Native 方法相關,一般情況下,我們無需關心此區域。
2.2、Java記憶體模型(JMM)(是對計算機CPU中暫存器和快取記憶體及主記憶體的抽象描述)。
Java記憶體模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括例項欄位,靜態欄位和構成陣列物件的元素)的訪問方式。
由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用於儲存執行緒私有的資料,而Java記憶體模型中規定所有變數都儲存在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可以訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝的自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,不能直接操作主記憶體中的變數,工作記憶體中儲存著主記憶體中的變數副本拷貝,前面說過,工作記憶體是每個執行緒的私有資料區域,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,其簡要訪問過程如下圖:
需要注意的是,JMM與Java記憶體區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過這組規則控制程式中各個變數在共享資料區域和私有資料區域的訪問方式,JMM是圍繞原子性,有序性、可見性展開的。
JMM與Java記憶體區域唯一相似點,都存在共享資料區域和私有資料區域,在JMM中主記憶體屬於共享資料區域,從某個程度上講應該包括了堆和方法區。
而工作記憶體資料執行緒私有資料區域,從某個程度上講則應該包括程式計數器、虛擬機器棧以及本地方法棧。或許在某些地方,我們可能會看見主記憶體被描述為堆記憶體,工作記憶體被稱為執行緒棧,實際上他們表達的都是同一個含義。
關於JMM中的主記憶體和工作記憶體說明如下:
(1)主記憶體
主要儲存的是Java例項物件,所有執行緒建立的例項物件都存放在主記憶體中,不管該例項物件是成員變數還是方法中的本地變數(也稱區域性變數),當然也包括了共享的類資訊、常量、靜態變數。由於是共享資料區域,多條執行緒對同一個變數進行訪問可能會發現執行緒安全問題。
(2)工作記憶體
主要儲存當前方法的所有本地變數資訊(工作記憶體中儲存著主記憶體中的變數副本拷貝),每個執行緒只能訪問自己的工作記憶體,即執行緒中的本地變數對其它執行緒是不可見的,就算是兩個執行緒執行的是同一段程式碼,它們也會各自在自己的工作記憶體中建立屬於當前執行緒的本地變數,當然也包括了位元組碼行號指示器、相關Native方法的資訊。注意由於工作記憶體是每個執行緒的私有資料,執行緒間無法相互訪問工作記憶體,因此儲存在工作記憶體的資料不存線上程安全問題。
2.3、Java記憶體模型(JMM)與計算機硬體記憶體架構。
2.4、JMM主記憶體與工作記憶體的資料儲存型別以及操作方式。
根據虛擬機器規範,對於一個例項物件中的成員方法而言,如果方法中包含本地變數是:
(1)基本資料型別(boolean,byte,short,char,int,long,float,double),將直接儲存在工作記憶體的棧幀結構中。
(2)引用型別,那麼該變數的引用會儲存在工作記憶體的棧幀中。
(3)而物件例項將儲存在主記憶體(共享資料區域,堆)中。
(4)但對於例項物件的成員變數,不管它是基本資料型別或者包裝型別(Integer、Double等)還是引用型別,都會被儲存到堆區。
(5)static變數以及類本身相關資訊將會儲存在主記憶體中。
(6)需要注意的是,在主記憶體中的例項物件可以被多執行緒共享,倘若兩個執行緒同時呼叫了同一個物件的同一個方法,那麼兩條執行緒會將要操作的資料拷貝一份到自己的工作記憶體中,執行完成操作後才重新整理到主記憶體。
示意圖如下所示:
三、JMM三大性質
3.1、原子性。
原子性指的是一個操作是不可中斷的,即使是在多執行緒環境下,一個操作一旦開始就不會被其他執行緒影響。比如對於一個靜態變數int x,兩條執行緒同時對他賦值,執行緒A賦值為1,而執行緒B賦值為2,不管執行緒如何執行,最終x的值要麼是1,要麼是2,執行緒A和執行緒B間的操作是沒有干擾的,這就是原子性操作,不可被中斷的特點。
有點要注意的是,對於32位系統的來說,long型別資料和double型別資料(對於基本資料型別,byte,short,int,float,boolean,char讀寫是原子操作),它們的讀寫並非原子性的,也就是說如果存在兩條執行緒同時對long型別或者double型別的資料進行讀寫是存在相互干擾的,因為對於32位虛擬機器來說,每次原子讀寫是32位的,而long和double則是64位的儲存單元,這樣會導致一個執行緒在寫時,操作完前32位的原子操作後,輪到B執行緒讀取時,恰好只讀取到了後32位的資料,這樣可能會讀取到一個既非原值又不是執行緒修改值的變數,它可能是“半個變數”的數值,即64位資料被兩個執行緒分成了兩次讀取。但也不必太擔心,因為讀取到“半個變數”的情況比較少見,至少在目前的商用的虛擬機器中,幾乎都把64位的資料的讀寫操作作為原子操作來執行,因此對於這個問題不必太在意,知道這麼回事即可。
3.2、可見性。
可見性指的是當一個執行緒修改了某個共享變數的值,其他執行緒是否能夠馬上得知這個修改的值。對於序列程式來說,可見性是不存在的,因為我們在任何一個操作中修改了某個變數的值,後續的操作中都能讀取這個變數值,並且是修改過的新值。但在多執行緒環境中可就不一定了,前面我們分析過,由於執行緒對共享變數的操作都是執行緒拷貝到各自的工作記憶體進行操作後才寫回到主記憶體中的,這就可能存在一個執行緒A修改了共享變數x的值,還未寫回主記憶體時,另外一個執行緒B又對主記憶體中同一個共享變數x進行操作,但此時A執行緒工作記憶體中共享變數x對執行緒B來說並不可見,這種工作記憶體與主記憶體同步延遲現象就造成了可見性問題,另外指令重排以及編譯器優化也可能導致可見性問題,通過前面的分析,我們知道無論是編譯器優化還是處理器優化的重排現象,在多執行緒環境下,確實會導致程式輪序執行的問題,從而也就導致可見性問題。
3.3、有序性。
有序性是指對於單執行緒的執行程式碼,我們總是認為程式碼的執行是按順序依次執行的,這樣的理解並沒有毛病,畢竟對於單執行緒而言確實如此,但對於多執行緒環境,則可能出現亂序現象,因為程式編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未必一致,要明白的是,在Java程式中,倘若在本執行緒內,所有操作都視為有序行為,如果是多執行緒環境下,一個執行緒中觀察另外一個執行緒,所有操作都是無序的,前半句指的是單執行緒內保證序列語義執行的一致性,後半句則指指令重排現象和工作記憶體與主記憶體同步延遲現象。
|__3.3.1、有序性 —— happens-before原則。
倘若在程式開發中,僅靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性,那麼編寫併發程式可能會顯得十分麻煩,幸運的是,在JMM中,還提供了happens-before原則來輔助保證程式執行的原子性、可見性以及有序性的問題,它是判斷資料是否存在競爭、執行緒是否安全的依據,happens-before 原則內容如下:
(1)程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。
(2)鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。
(3)volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作。
(4)傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C。
(5)執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每一個動作。
(6)執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生。
(7)執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過Thread.join()方法結束。
(8)物件終結規則:一個物件的初始化完成先行發生於它的finalize()方法的開始。