【JVM】Java內存模型

原文:多線程之Java內存模型(JMM)(一)html

概述

多任務和高併發是衡量一臺計算機處理器的能力重要指標之一。通常衡量一個服務器性能的高低好壞,使用每秒事務處理數(Transactions Per Second,TPS)這個指標比較能說明問題,它表明着一秒內服務器平均能響應的請求數,而TPS值與程序的併發能力有着很是密切的關係。在討論Java內存模型和線程以前,先簡單介紹一下硬件的效率與一致性。java

硬件的效率與一致性

因爲計算機的存儲設備與處理器的運算能力之間有幾個數量級的差距,因此現代計算機系統都不得不加入一層讀寫速度儘量接近處理器運算速度的高速緩存(cache)來做爲內存與處理器之間的緩衝:將運算須要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中沒這樣處理器就無需等待緩慢的內存讀寫了。數組

基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,可是引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每一個處理器都有本身的高速緩存,而他們又共享同一主存,以下圖所示:多個處理器運算任務都涉及同一塊主存,須要一種協議能夠保障數據的一致性,這類協議有MSIMESIMOSIDragon Protocol等。Java虛擬機內存模型中定義的內存訪問操做與硬件的緩存訪問操做是具備可比性的,後續將介紹Java內存模型。
緩存

除此以外,爲了使得處理器內部的運算單元能竟可能被充分利用,處理器可能會對輸入代碼進行亂起執行(Out-Of-Order Execution)優化,處理器會在計算以後將對亂序執行的代碼進行結果重組,保證結果準確性。與處理器的亂序執行優化相似,Java虛擬機的即時編譯器中也有相似的指令重排序(Instruction Recorder)優化。服務器

Java內存模型(JMM)

  
定義Java內存模型並非一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓Java的併發操做不會產生歧義。可是,也必須得足夠寬鬆,使得虛擬機的實現能有足夠的自由空間去利用硬件的各類特性(寄存器、高速緩存等)來獲取更好的執行速度。通過長時間的驗證和修補,在JDK1.5發佈後,Java內存模型就已經成熟和完善起來了。JMM給咱們一種規範,它描述了多線程程序如何與內存交互。多線程

JMM大體描述:併發

JMM描述了線程如何與內存進行交互。Java虛擬機規範視圖定義一種Java內存模型,來屏蔽掉各類操做系統內存訪問的差別,以實現Java程序在各類平臺下都能達到一致的訪問效果。app

JMM描述了JVM如何與計算機的內存進行交互。jvm

JMM都是圍繞着原子性,有序性和可見性進行展開的。函數

JMM的主要目標是定義程序中各個變量的訪問規則,虛擬機將變量存儲到內存和從內存取出變量這樣的底層細節。此處的變量指在堆中存儲的元素。

多線程的時候爲何容易出錯?

Java內存模型規定全部的共享變量都存儲在主內存中,而每條線程有本身的工做內存(本地內存),工做內存保存了共享變量的副本,而不一樣內存又沒法訪問對方的工做內存,因此若是線程在工做內存中修改了變量副本,其它線程是無從得知的。
線程的傳值均須要經過主內存來完成

主內存與工做內存如何交互?

Java內存模型定義了8種操做來完成主內存與工做內存的交互細節,虛擬機必須保證這8種操做的每個操做都是原子的,不可再分的。

lock: 做用於主內存的變量,把變量標識爲線程獨佔的狀態。
unlock: 與lock對應,把主內存中處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其餘線程鎖定。
read: 做用於主內存的變量,把一個變量的值從主內存傳輸到線程的工做內存,便於隨後的load使用。
load:做用於工做內存的變量,把read讀取到的變量放入工做內存副本。
use:做用於工做內存,把工做內存的變量值傳遞給執行引擎,每當虛擬機遇到一個須要使用到變量的值的字節碼指令時將會執行這個操做。
assign: 做用於工做內存,把執行引擎收到的值賦給工做內存的變量,虛擬機遇到賦值字節碼時候執行這個操做。
store:做用於工做內存,把變量的值傳輸到住內存中,以便隨後的write使用。
write:做用於主內存,把store操做從工做內存獲得的值放入主內存的變量中。

Java內存模型還規定了在執行上述八種基本操做時,必須知足以下規則:

  • 不容許readload,storewrite操做之一單獨出現。
  • 不容許一個線程丟棄它最近的assign操做。即變量在工做內存中改變了帳號必須把變化同步回主內存。
  • 不容許一個線程無緣由地(沒有發生過任何assign操做)把數據從工做內存同步回主內存中。
  • 一個新的變量只容許在主內存中誕生,不容許工做內存直接使用未初始化的變量。
  • 一個變量同一時刻只容許一條線程進行lock操做,但同一線程能夠lock屢次,lock屢次以後必須執行一樣次數的unlock操做。
  • 若是對一個變量執行lock操做,將會清空工做內存中此變量的值,在執行引擎使用這個變量前須要從新執行loadassign操做初始化變量的值。
  • 若是一個變量事先沒有被lock操做鎖定,則不容許對它執行unlock操做;也不容許去unlock一個被其餘線程鎖定的變量。
  • 對一個變量執行unlock操做以前,必須先把此變量同步到主內存中(執行storewrite操做)。

這8種操做定義至關嚴謹,實踐起來又比較麻煩,可是能夠有助於咱們理解多線程的工做原理。有一個與此8種操做相等的Happen-before原則。

Happen-before原則

這個是Java內存模型下無需任何同步器協助就已經存在,能夠直接在編碼中使用。若是兩個操做之間的關係不在此列,而且沒法從下列規則推導出來的話,它們的順序就沒有保障,虛擬機能夠對他們進行任意的重排。

自然的happens-before:

程序次序規則:一個線程內,按照代碼順序,書寫在前面的操做先行發生於書寫在後面的操做;
鎖定規則:一個unlock操做先行發生於後面對同一個鎖的lock操做;
volatile變量規則:對一個變量的寫操做先行發生於後面對這個變量的讀操做;
傳遞規則:若是操做A先行發生於操做B,而操做B又先行發生於操做C,則能夠得出操做A先行發生於操做C
線程啓動規則: Thread對象的start()方法先行發生於此線程的每個動做;
線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;
線程終結規則:線程中全部的操做都先行發生於線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;
對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始。

這8條原則摘自《深刻理解Java虛擬機》。
這8條規則中,前4條規則是比較重要的,後4條規則都是顯而易見的。

下面咱們來解釋一下前4條規則:

對於程序次序規則來講,個人理解就是一段程序代碼的執行在單個線程中看起來是有序的。注意,雖然這條規則中提到「書寫在前面的操做先行發生於書寫在後面的操做」,這個應該是程序看起來執行的順序是按照代碼順序執行的,由於虛擬機可能會對程序代碼進行指令重排序。雖然進行重排序,可是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。所以,在單個線程中,程序執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程序在單線程中執行結果的正確性,但沒法保證程序在多線程中執行的正確性。

第二條規則也比較容易理解,也就是說不管在單線程中仍是多線程中,同一個鎖若是出於被鎖定的狀態,那麼必須先對鎖進行了釋放操做,後面才能繼續進行lock操做。

第三條規則是一條比較重要的規則。直觀地解釋就是,若是一個線程先去寫一個變量,而後一個線程去進行讀取,那麼寫入操做確定會先行發生於讀操做。

第四條規則實際上就是體現happens-before原則具有傳遞性。

Java運行時數據區

JVM定義了一些程序運行時會使用到的運行時數據區,其中一些會隨着虛擬機啓動而建立,隨着虛擬機退出而銷燬。另一些是與現場一一對應的,這些線程對應的數據區會隨着線程的開始和結束而建立和銷燬。
這部分參考JVM規範

1. PC寄存器

能夠支持多條線程同時容許,每一條Java虛擬機線程都有本身的PC寄存器。任意時刻,一條JVM線程以後執行一個方法的代碼,這個方法被稱爲當前方法(current method)。
若是這個方法不是Native的,那麼PC寄存器就保存JVM正在執行的字節碼指令地址。
若是是Native的,那麼PC寄存器的值爲undefined
PC寄存器的容量至少能保證一個returnAddress類型的數據或者一個平臺無關的本地指針的值。

2. JVM Stack(虛擬機棧)

每個JVM線程都有本身的私有虛擬機棧,這個棧與線程同時建立,用於存儲棧幀(Frame)。
棧用來存儲局部變量與一些過程結果的地方。在方法調用和返回中也扮演了很重要的角色。
棧能夠試固定分配的也能夠動態調整。
若是請求線程分配的容量超過JVM棧容許的最大容量,拋出StackOverflowError異常。
若是JVM棧能夠動態擴展,擴展的動做也已經嘗試過,可是沒有申請到足夠的內存,則拋出OutOfMemoryError異常。

3. Heap(堆)

堆是能夠可供各個線程共享的運行時存儲區域,也是供全部類的實例和數組對象分配內存的區域。堆在JVM啓動的時候建立。
堆所存儲的就是被GC所管理的各類對象。
堆也是能夠固定大小和動態調整的。
實際所需的堆超過的GC所提供的最大容量,那麼JVM拋出OutOfMemoryError異常。

4. Method Area(方法區)

也是各個線程共享的運行時內存區,它存儲每個類的實例信息,運行時常量池,字段和方法數據,構造函數和普通方法的字節碼等內容。還有一些特殊方法。
方法區是堆的邏輯組成部分,也在JVM啓動時建立,簡單的JVM能夠不實現這個區域的垃圾收集。
方法區也可固定大小和動態分配與堆同樣,內存空間不夠,那麼JVM拋出OutOfMemoryError異常。

5. Run-Time Constant Pool(運行時常量池)

在方法區中分配,在加載類和接口到虛擬機以後,就建立對應的運行時常量池。
它是class文件中每個類或接口的常量池表的運行時表現形式。
存儲區域不夠用時候拋出OutOfMemoryError異常。

6. Native Method Stacks(原生方法棧或本地方法棧)

JDKNative的方法,System類和Thread類中有不少。使用C語言編寫的方法,這個也一般叫作C stack
能夠不支持本地方法棧,可是若是支持的時候,這個棧通常會在線程建立的時候按線程分配。
與棧的錯誤同樣,StackOverFlowErrorOutOfMemeoryError

注意,具體 JVM 的內存結構,其實取決於其實現,不一樣廠商的JVM,或者同一廠商發佈的不一樣版本,都有可能存在必定差別。

畫了一個簡單的內存結構圖,裏面展現了前面提到的堆、線程棧等區域,並從數量上說明了什麼是線程私有,例如,程序計數器、Java 棧等,以及什麼是 Java 進程惟一。另外,還額外劃分出了直接內存等區域。

裏簡要介紹兩點區別:

直接內存(Direct Memory)區域,它就是 Direct Buffer 所直接分配的內存,也是個容易出現問題的地方。儘管,在 JVM 工程師的眼中,並不認爲它是JVM 內部內存的一部分,也並未體現 JVM 內存模型中。

JVM 自己是個本地程序,還須要其餘的內存去完成各類基本任務,好比,JIT Compiler 在運行時對熱點方法進行編譯,就會將編譯後的方法儲存在 Code Cache 裏面;GC 等功能須要運行在本地線程之中,相似部分都須要佔用內存空間。這些是實現 JVM JIT 等功能的須要,但規範中並不涉及。

若是深刻到 JVM 的實現細節,你會發現一些結論彷佛有些模棱兩可。好比:

Java 對象是否是都建立在堆上的呢?

我注意到有一些觀點,認爲經過逃逸分析,JVM 會在棧上分配那些不會逃逸的對象,這在理論上是可行的,可是取決於 JVM 設計者的選擇。據我所知,Oracle Hotspot JVM 中並未這麼作,這一點在逃逸分析相關的文檔裏已經說明,因此能夠明確全部的對象實例都是建立在堆上。

目前不少書籍仍是基於 JDK 7 之前的版本,JDK 已經發生了很大變化,Intern 字符串的緩存和靜態變量曾經都被分配在永久代上,而永久代已經被元數據區取代。可是,Intern 字符串緩存和靜態變量並非被轉移到元數據區,而是直接在堆上分配,因此這一點一樣符合前面一點的結論:對象實例都是分配在堆上。

接下來,咱們來看看什麼是 OOM 問題,它可能在哪些內存區域發生?

首先,OOM 若是通俗點兒說,就是 JVM 內存不夠用了,javadoc 中對OutOfMemoryError的解釋是,沒有空閒內存,而且垃圾收集器也沒法提供更多內存。
這裏面隱含着一層意思是,在拋出 OutOfMemoryError 以前,一般垃圾收集器會被觸發,盡其所能去清理出空間,例如:

JVM 會去嘗試回收軟引用指向的對象等。
在java.nio.BIts.reserveMemory() 方法中,咱們能清楚的看到,System.gc() 會被調用,以清理空間,這也是爲何在大量使用 NIO 的 Direct Buffer 之類時,一般建議不要加下面的
參數,畢竟是個最後的嘗試,有可能避免必定的內存不足問題。
-XX:+DisableExplictGC

固然,也不是在任何狀況下垃圾收集器都會被觸發的,好比,咱們去分配一個超大對象,相似一個超大數組超過堆的最大值,JVM 能夠判斷出垃圾收集並不能解決這個問題,因此直接拋出OutOfMemoryError。

從前面分析的數據區的角度,除了程序計數器,其餘區域都有可能會由於可能的空間不足發生OutOfMemoryError,簡單總結以下:

  • 堆內存不足是最多見的 OOM 緣由之一,拋出的錯誤信息是「java.lang.OutOfMemoryError:Java heap space」,緣由可能千奇百怪,例如,可能存在內存泄漏問題;也頗有可能就是堆的大小不合理,好比咱們要處理比較可觀的數據量,可是沒有顯式指定 JVM 堆大小或者指定數值偏小;或者出現 JVM 處理引用不及時,致使堆積起來,內存沒法釋放等。

  • 而對於 Java 虛擬機棧和本地方法棧,這裏要稍微複雜一點。若是咱們寫一段程序不斷的進行遞歸調用,並且沒有退出條件,就會致使不斷地進行壓棧。相似這種狀況,JVM 實際會拋出StackOverFlowError;固然,若是 JVM 試圖去擴展棧空間的的時候失敗,則會拋出OutOfMemoryError。

  • 對於老版本的 Oracle JDK,由於永久代的大小是有限的,而且 JVM 對永久代垃圾回收(如,常量池回收、卸載再也不須要的類型)很是不積極,因此當咱們不斷添加新類型的時候,永久代出現 OutOfMemoryError 也很是多見,尤爲是在運行時存在大量動態類型生成的場合;相似 Intern 字符串緩存佔用太多空間,也會致使 OOM 問題。對應的異常信息,會標記出來和永久代相關:「java.lang.OutOfMemoryError: PermGen space」。

  • 隨着元數據區的引入,方法區內存已經再也不那麼窘迫,因此相應的 OOM 有所改觀,出現OOM,異常信息則變成了:「java.lang.OutOfMemoryError: Metaspace」。

  • 直接內存不足,也會致使 OOM。

指令重排序

在執行程序時爲了提升性能,編譯器和處理器常常會對指令進行重排序。重排序分紅三種類型:

一、編譯器優化的重排序。編譯器在不改變單線程程序語義放入前提下,能夠從新安排語句的執行順序。
二、指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序。
三、內存系統的重排序。因爲處理器使用緩存和讀寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。

Java源代碼到最終實際執行的指令序列,會通過下面三種重排序:

爲了保證內存的可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。Java內存模型把內存屏障分爲LoadLoad、LoadStore、StoreLoadStoreStore四種:

一個案例

  • 一個本地變量多是原始類型,在這種狀況下,它老是「呆在」線程棧上。
  • 一個本地變量也多是指向一個對象的一個引用。在這種狀況下,引用(這個本地變量)存放在線程棧上,可是對象自己存放在堆上。
  • 一個對象可能包含方法,這些方法可能包含本地變量。這些本地變量任然存放在線程棧上,即便這些方法所屬的對象存放在堆上。
  • 一個對象的成員變量可能隨着這個對象自身存放在堆上。無論這個成員變量是原始類型仍是引用類型。
  • 靜態成員變量跟隨着類定義一塊兒也存放在堆上。
  • 存放在堆上的對象能夠被全部持有對這個對象引用的線程訪問。當一個線程能夠訪問一個對象時,它也能夠訪問這個對象的成員變量。若是兩個線程同時調用同一個對象上的同一個方法,它們將會都訪問這個對象的成員變量,可是每個線程都擁有這個本地變量的私有拷貝。

參考資料:
Java內存模型
第25講 | 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError

相關文章
相關標籤/搜索