天天看點

JVM二:全面了解Java記憶體模型(JMM)及Java記憶體區域

一、計算機記憶體

1.1、計算機硬體記憶體架構。

  計算機CPU(central processing unit)和記憶體的互動是最頻繁的,記憶體是我們的高速緩存區。使用者磁盤和CPU的互動,而CPU運轉速度越來越快,磁盤遠遠跟不上CPU的讀寫速度,才設計了記憶體,使用者緩存使用者IO等待導緻CPU的等待成本。但是随着CPU的發展,記憶體的讀寫速度也遠遠跟不上CPU的讀寫速度,是以,為了解決這一糾紛,CPU廠商在每顆CPU上加入了高速緩存,用來緩解這種症狀,CPU與記憶體的互動如下圖:

JVM二:全面了解Java記憶體模型(JMM)及Java記憶體區域

  也就是,當程式在運作過程中,會将運算需要的資料從主存複制一份到CPU的高速緩存當中,那麼CPU進行計算時就可以直接從它的高速緩存讀取資料和向其中寫入資料,當運算結束之後,再将高速緩存中的資料重新整理到主存當中。

  同樣,我們知道單核CPU的主頻不可能無限增長,想要提升性能,需要多個處理器協同工作,Intel總裁貝瑞特單膝下跪事件辨別着多核時代的到來。

  基于高速緩存的存儲互動很好的解決了處理器與記憶體之間的沖突,也引入了新的問題:緩存一緻性問題。在多處理器系統中,每個處理器有自己的高速緩存,而他們又共享同一塊記憶體(主存),當多個處理器運算都涉及到同一塊記憶體區域的時候,就有可能出現緩存不一緻的問題,比如下面這段代碼:

i=i+;
           

  當線程執行這個語句時,會先從主存當中讀取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需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從記憶體重新讀取。

JVM二:全面了解Java記憶體模型(JMM)及Java記憶體區域

二、Java記憶體區域

2.1、Java運作時資料區域(jvm記憶體模型,也是靜态記憶體存儲模型,是對jvm記憶體的實體劃分)。

  Java虛拟機在執行Java程式的過程中會把它所管理的記憶體劃分為若幹個不同的資料區域。這些區域都有各自的用途,以及建立和銷毀的時間,有的區域随着虛拟機程序的啟動而存在,有些區域則依賴使用者線程的啟動和結束而建立和銷毀。根據《Java虛拟機規範(Java SE 7版)》的規定,Java虛拟機所管理的記憶體将會包括以下幾個運作時資料區域。

JVM二:全面了解Java記憶體模型(JMM)及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記憶體模型中規定所有變量都存儲在主記憶體,主記憶體是共享記憶體區域,所有線程都可以通路,但線程對變量的操作(讀取指派等)必須在工作記憶體中進行,首先要将變量從主記憶體拷貝的自己的工作記憶體空間,然後對變量進行操作,操作完成後再将變量寫回主記憶體,不能直接操作主記憶體中的變量,工作記憶體中存儲着主記憶體中的變量副本拷貝,前面說過,工作記憶體是每個線程的私有資料區域,是以不同的線程間無法通路對方的工作記憶體,線程間的通信(傳值)必須通過主記憶體來完成,其簡要通路過程如下圖:

JVM二:全面了解Java記憶體模型(JMM)及Java記憶體區域

  需要注意的是,JMM與Java記憶體區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過這組規則控制程式中各個變量在共享資料區域和私有資料區域的通路方式,JMM是圍繞原子性,有序性、可見性展開的。

  JMM與Java記憶體區域唯一相似點,都存在共享資料區域和私有資料區域,在JMM中主記憶體屬于共享資料區域,從某個程度上講應該包括了堆和方法區。

  而工作記憶體資料線程私有資料區域,從某個程度上講則應該包括程式計數器、虛拟機棧以及本地方法棧。或許在某些地方,我們可能會看見主記憶體被描述為堆記憶體,工作記憶體被稱為線程棧,實際上他們表達的都是同一個含義。

  關于JMM中的主記憶體和工作記憶體說明如下:

  (1)主記憶體

  主要存儲的是Java執行個體對象,所有線程建立的執行個體對象都存放在主記憶體中,不管該執行個體對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類資訊、常量、靜态變量。由于是共享資料區域,多條線程對同一個變量進行通路可能會發現線程安全問題。

  (2)工作記憶體

  主要存儲目前方法的所有本地變量資訊(工作記憶體中存儲着主記憶體中的變量副本拷貝),每個線程隻能通路自己的工作記憶體,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作記憶體中建立屬于目前線程的本地變量,當然也包括了位元組碼行号訓示器、相關Native方法的資訊。注意由于工作記憶體是每個線程的私有資料,線程間無法互相通路工作記憶體,是以存儲在工作記憶體的資料不存線上程安全問題。

2.3、Java記憶體模型(JMM)與計算機硬體記憶體架構。

JVM二:全面了解Java記憶體模型(JMM)及Java記憶體區域

2.4、JMM主記憶體與工作記憶體的資料存儲類型以及操作方式。

  根據虛拟機規範,對于一個執行個體對象中的成員方法而言,如果方法中包含本地變量是:

  (1)基本資料類型(boolean,byte,short,char,int,long,float,double),将直接存儲在工作記憶體的棧幀結構中。

  (2)引用類型,那麼該變量的引用會存儲在工作記憶體的棧幀中。

  (3)而對象執行個體将存儲在主記憶體(共享資料區域,堆)中。

  (4)但對于執行個體對象的成員變量,不管它是基本資料類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區。

  (5)static變量以及類本身相關資訊将會存儲在主記憶體中。

  (6)需要注意的是,在主記憶體中的執行個體對象可以被多線程共享,倘若兩個線程同時調用了同一個對象的同一個方法,那麼兩條線程會将要操作的資料拷貝一份到自己的工作記憶體中,執行完成操作後才重新整理到主記憶體。

  示意圖如下所示:

JVM二:全面了解Java記憶體模型(JMM)及Java記憶體區域

三、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()方法的開始。