《深入java虛擬機器》讀書筆記之Java記憶體區域

寧願。發表於2019-01-21

前言

該讀書筆記用於記錄在學習《深入理解Java虛擬機器——JVM高階特性與最佳實踐》一書中的一些重要知識點,對其中的部分內容進行歸納,主要是方便之後進行復習。

執行時資料區域

Java虛擬機器在執行過程中會將其管理的記憶體劃分為多個不同的資料區域。其中一些區域隨著虛擬機器啟動而建立,一些區域生命週期則依賴使用者執行緒的啟動和結束。

下面是JDK1.7

JDK1.7

程式計數器

是一塊較小的記憶體空間,用於記錄當前執行緒所執行的位元組碼的行號,在執行過程中通過改變計數器的值來選擇下一條被執行的指令。分支、迴圈、異常處理等都通過程式計數器實現。

在多執行緒環境下,CPU在不同執行緒間進行切換時,為了保證CPU下一次切換到執行緒時能繼續之前的執行軌跡進行,需要時用程式計數器記錄下切換前執行到哪一步。該區域為各個執行緒私有,互不干擾。該區域不會發生OOM。

如果執行緒在執行一個java方法,那麼程式計數器將會記錄正在執行的虛擬機器位元組碼的指令地址。而如果正在執行的是一個native方法,計數器的值則為空。

Java虛擬機器棧

Java虛擬機器棧由執行緒私有,其生命週期同執行緒一樣。Java虛擬機器棧主要是用於描述Java程式中方法執行時的記憶體模型。

每一個方法在執行的時候都會建立一個棧幀,棧幀裡儲存區域性變數表、運算元棧,動態連結等資訊。每一個方法從開始呼叫到執行結束的過程就對應著一個棧幀在java虛擬機器棧的入棧到出棧的過程。

棧幀

棧幀是支援虛擬機器進行方法呼叫和方法執行的資料結構。棧幀儲存了方法的區域性變數表,運算元棧,動態連結和方法返回地址等資訊。每一個方法從呼叫開始到執行結束都對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

  • 區域性變數表是一組變數值儲存空間,用於儲存方法引數和方法內部定義的變數。
  • 運算元棧用於執行一個方法,本質是一個棧。如對於兩數相加,會先將兩個數入棧,執行加法操作是將兩個數出棧相加然後將結果入棧。
  • 動態連結是指在執行期間才能確定具體呼叫某一個方法。
  • 方法返回地址表示一個方法完成後返回到呼叫方的地址,方法返回可以使正常返回或者未被處理的異常。

本地方法棧

本地方法棧和虛擬機器棧的作用一樣,不同之處在於java虛擬機器棧服務於虛擬機器中的位元組碼(java方法)。而本地方法棧則是為虛擬機器使用native修飾的方法服務。

Java堆

java堆是Java虛擬機器管理的記憶體中最大的一塊,同時堆被所有的執行緒所共享,堆記憶體在虛擬機器啟動是被建立。Java堆的作用在於存放物件的例項,幾乎所有的物件例項都在Java堆上分配記憶體。同時Java堆也是垃圾收集器管理的主要區域,根據分代收集演算法還可以將堆分為新生代和老年代,更細緻的可以分為Eden區、from區、to區。

方法區

方法區和堆一樣被各個執行緒所共享。方法區用於儲存已經被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。方法區也被稱為“永久代”(PermGen)。

執行時常量池

執行時常量池在JDK1.7之前是方法區的一部分,在JDK1.7的時候執行時常量池就被移到堆中。常量池用於儲存編譯期生成的各種字面量和符號引用,但並不是說只有編譯期才能生成常量。在執行時也可以將新的常量放入常量池中(String的intern()方法)。

直接記憶體

直接記憶體並不是Java虛擬機器執行時資料區的一部分,也不是虛擬機器規範中定義的記憶體區域,所以直接記憶體的大小不受Java堆大小的影響,但是還是受宿主機記憶體大小影響,也會發生OOM。在Java中使用該區域的典型代表就是NIO。

元空間(MetaSpace)

在JDK1.8之後,HotSpot JVM移除了方法區,而使用本地化記憶體來代替,這塊區域被稱為“元空間”。“永久代”被移除使得JVM引數PermSize 和MaxPermSize會被忽略,當前在啟動時會有警告資訊。新增了一個MaxMetaspaceSize對後設資料區大小進行調整。預設情況下,類後設資料分配受到可用的本機記憶體容量的限制。

為什麼要移除方法區?

使用永久代來儲存類資訊、常量、靜態變數等資料不是個好主意, 很容易遇到記憶體溢位的問題.JDK8的實現中將類的後設資料放入 本地記憶體, 將字串池和類的靜態變數放入java堆中。同時對永久代進行調優是很困難的,同時將元空間與堆的垃圾回收進行了隔離,避免永久代引發的Full GC和OOM等問題。

各個JDK版本的執行時資料區域一覽

JDK1.6

JDK1.6

JDK1.7

JDK1.7

JDK1.8

JDK1.8

記憶體區域內的異常

堆疊溢位

Eorror

主要發生的異常分為兩種:OutOfMemeryErrorStackOverFlowError。其中OutOfMemeryError是當Java虛擬機器由於記憶體不足而無法分配物件時丟擲,並且垃圾收集器不再有可用的記憶體。而StackOverFlowError是當堆疊溢位發生時丟擲的。

在記憶體區域的各部分中,程式計數器不會發生記憶體溢位的情況。

虛擬機器棧和本地方法棧用於儲存方法執行的順序,當方法的呼叫層次過深(遞迴)時,可能會導致分配的棧記憶體不足時將會丟擲StackOverFlowError的異常。從上圖我們可以看出這一塊區域還會丟擲OutOfMemeryError。這是因為擋在多執行緒情況下,虛擬機器中大量的執行緒進行方法呼叫導致建立的棧幀建立過多使得Java虛擬機器由於記憶體不足而無法分配物件。

對於Java堆和方法區可能會由於程式中建立的例項過多而導致OutOfMemeryError

如果執行緒請求的棧深度大於虛擬機器鎖允許的最大深度,將丟擲StackOverFlowError,StackOverFlowError一般是函式呼叫層級過多導致,比如死遞迴、死迴圈。這類異常一般需要我們檢查程式碼是否存在邏輯上的問題。

如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間或者堆中存在著大量無法被gc的類資訊,將丟擲OutOfMemeoryError,前者一般是在多執行緒環境才會產生,一般用“減少記憶體的方法”,既減少最大堆和減少棧容量來換取更多的執行緒支援。減少最大堆使得棧可被分配的記憶體變大,可以容納更多的執行緒,減少棧容量使得可建立的棧幀數量變大。後者需要我們dump出堆異常模型檢查問題。如果是記憶體溢位則調整堆的大小,記憶體洩露則需要檢查相關程式碼。

方法區或後設資料區溢位

方法區和後設資料區用於存放class的相關資訊,當工程中類比較多,而方法區或者後設資料區太小,在啟動時容易丟擲OOM異常。

JDK1.7之前,通過-XX:PermSize,-XX:MaxPerSize,調整方法區的大小;

JDK1.8後,通過-XX:MetaspaceSize ,-XX:MaxMetaspaceSize,調整後設資料區的大小;

本機直接記憶體溢位

jdk本身很少操作直接記憶體,而直接記憶體(DirectMemory)導致溢位最大的特徵是:Heap Dump檔案不會看到明顯異常,而程式中直接或者間接的用到了NIO。

直接記憶體不受java堆大小限制,但受本機總記憶體的限制,可以通過MaxDirectMemorySize來設定。

物件的建立

在我們平時使用Java語言建立物件時,使用最多的肯定是通過new關鍵字完成,當然還有其他的方式如反射,克隆等。我們使用很簡單,但是實際上關於建立一個物件在虛擬機器中是做了大量的事情的。

當虛擬機器檢測到一條new指令時,首先到常量池中檢測new指令所攜帶的引數是否能和常量池中的某一符號引用所對應,如果找到了對應的符號引用還需要檢查其所代表的類是否已經完成了載入、解析、初始化等過程。如果以上條件不滿足那麼還需要先進行類載入過程。

記憶體分配

當完成了類載入過程後就需要對物件進行記憶體分配了,為一個物件分配記憶體的大小實際上在類載入過程中就已經確定下來,這裡的記憶體分配過程就是講一塊確定大小的記憶體從Java堆中劃分出來。

指標碰撞(Bump the Pointer)

假設堆中的記憶體是一塊絕對規整的,即被使用的記憶體和沒有被使用的記憶體有著明確的劃分,一邊是使用過的,一邊是沒有使用的,存在著一個指標作為分界的標誌。那麼分配過程實際上就是講指標像沒有被使用的區域移動確定大小的距離。這種分配方式被稱為“指標碰撞”。

空閒列表(Free List)

實際上很多情況下記憶體區域並非像上面那種情況,而是已經使用過的記憶體和沒使用過的相互交錯,這時沒有辦法使用指標碰撞了。虛擬機器會維護一個用於記錄那些記憶體是可用的的列表。在記憶體分配時找出一塊足夠大小的地方劃分給物件。這種方式被稱為“空閒列表”。

使用哪種方式

從上面的描述可知使用哪種方式是由Java堆是否規整來決定,而Java堆是否規整則由使用的垃圾收集器是否帶有壓縮整理功能決定。

  • 指標碰撞:使用Serial,ParNew等帶有Compact過程的收集器。
  • 空閒列表:使用CMS這種基於Mark-Sweep演算法的收集器。

保證記憶體分配的安全性

在程式執行過程中,物件建立時非常頻繁的事情,在多執行緒情況下這個過程就變得非常危險。

同步

通過對物件建立過程進行同步保證在併發下的安全性,在虛擬機器中通過CAS+失敗重試的方式保證原子性。

TLAB(thread location allocation buffer)

通過預先在Java堆中為不同的執行緒分配一塊記憶體將執行緒間的記憶體分配過程隔離開來,這塊記憶體區域被稱為執行緒本地緩衝(thread location allocation buffer)。執行緒進行記憶體分配時在TLAB上進行。只有當TLAB不足需要重新分配時才需要同步操作。虛擬機器是否使用TLAB可以通過引數-XX:+/-UseTLAB來控制。

初始化

上面的記憶體分配過程完成後,就需要將分配到的記憶體都初始化為零值。同時設定物件資訊,例如該物件是哪個類的例項、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊都是存放在物件的物件頭中。

以上過程完成後會呼叫方法對物件的資訊進行初始化,就是呼叫構造方法。

物件記憶體佈局

在HotSpot Jvm中,物件的記憶體佈局主要分為三個區域:

  1. 物件頭(Header)
  2. 例項資料(Instance Data)
  3. 對其填充(Padding)

物件頭(Header)

物件頭中主要包含兩部分,第一部分用於儲存物件自身的執行時資料如雜湊碼、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。這一部分的資料在32位和64位虛擬機器中的長度分別為32bit和64bit。

物件的另一部分是型別指標,也即是一個物件指向它所屬類的指標。虛擬機器可以通過這個指標確定物件屬於具體哪一個類,當然這一過程並不是一定得,查詢物件的所屬類並不是一定要通過物件本身。如果儲存的是一個陣列,那麼物件頭中還會儲存該陣列的長度。

例項資料(Instance Data)

例項資料是用於儲存程式程式碼中定義的各個欄位的內容,包括從父類繼承的和子類重新定義的。該部分的儲存順序受到虛擬機器分配策略引數和欄位在原始碼中定義的資料順序影響。HotSpot Jvm中預設的分配策略為longs/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary Object Points),即相同大小的欄位會被分配在一起。在滿足分配策略的條件下父類中的欄位會被分配在子類的前面。

如果CompactFields為true(預設),那麼子類中較小的變數會被插入到父類的空隙中取。

對其填充(Padding)

這一部分並不是必要的,僅僅起到佔位符的作用,除此外沒有其他的作用。因為HotSpot虛擬機器的自動記憶體管理系統要求物件的大小要求時8位元組的倍數,物件頭部分固定為8的倍數,而例項資料如果大小不到8的倍數就由對其填充來補充。

物件的訪問定位

物件是在堆區域建立,為了使用物件,我們需要通過棧上的reference資料來操作堆上的物件。目前主流的訪問方式有兩種:

  • 控制程式碼訪問
  • 直接指標訪問

控制程式碼訪問

使用控制程式碼訪問的方式需要在堆上額外分配出一塊區域來作為控制程式碼池,在棧上的reference資料則儲存物件的控制程式碼地址而控制程式碼則包括物件例項資料和型別資料的地址。

控制程式碼訪問

//圖來自《深入理解java虛擬機器》

直接指標訪問

直接指標訪問的方式中reference儲存的是物件的例項地址,而物件型別資料的地址則是儲存在物件的例項中。

直接指標訪問

//圖來自《深入理解java虛擬機器》

使用控制程式碼方式是reference資料不儲存物件的具體地址而是通過控制程式碼來指向,當物件移動(垃圾回收)後也不需要修改reference中的值。

使用直接指標訪問好處在於直接定位到物件例項資料,不需要經過控制程式碼這一次定位,在頻繁的物件定位過程中能有效提升效率,HotSpot使用直接指標定位方式。

相關文章