淺析虛擬機器記憶體管理模型

等不到的口琴發表於2021-02-03

Java虛擬機器在執行Java程式的過程中會把Java程式所管理的記憶體劃分為若干個不同的資料區域,這些區域可以劃分為5各部分:虛擬機器棧、堆、方法區、本地方法棧、程式計數器,如圖:

2002319-20210203210949922-1046773488

虛擬機器棧

Java虛擬機器棧(Java Virtual Machine Stack)是執行緒私有的,它的生命週期與執行緒相同。也就是,每個方法被執行的時候,Java虛擬機器都會同步建立一個棧幀 (Stack Frame)用於儲存區域性變數表、運算元棧、動態連線、方法出口等資訊。每一個方法被呼叫直至執行完畢的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。下面講解一下虛擬機器棧中的內容:

2002319-20210203211037086-2046105052

區域性變數表

區域性變數表存放了編譯期可知的各種Java虛擬機器基本資料型別(boolean、byte、char、short、int、float、long、double)、物件引用(reference型別,它並不等同於物件本身,可能是一個指向物件起始地址的引用指標,也可能是指向一個代表物件的控制程式碼或者其他與此物件相關的位置)和returnAddress型別(指向了一條位元組碼指令的地址)。

這些資料型別在區域性變數表中的儲存空間以區域性變數槽(Slot)來表示,其中64位長度的long和double型別的資料會佔用兩個變數槽,其餘的資料型別只佔用一個。區域性變數表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變數表的大小。請讀者注意,這裡說的“大小”是指變數槽的數量,虛擬機器真正使用多大的記憶體空間(譬如按照1個變數槽佔用32個位元、64個位元,或者更多)來實現一個變數槽,這是完全由具體的虛擬機器實現自行決定的事情。

returnAddress型別目前已經很少見了,它是為位元組碼指令jsr、jsr_w和ret服務的,指向了一條位元組碼指令的地址。雖然long以及double是分配在兩個變數槽中,但是由於線上程內部,所以不會有資料競爭和執行緒安全問題。

運算元棧

運算元棧(Operand Stack)也常被稱為操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同區域性變數表一樣,運算元棧的最大深度也在編譯的時候被寫入到Code屬性的max_stacks資料項之中。當一個方法剛剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是出棧和入棧操作。譬如在做算術運算的時候是通過將運算涉及的運算元棧壓入棧頂後呼叫運算指令來進行的,又譬如在呼叫其他方法的時候是通過運算元棧來進行方法引數的傳遞。舉個例子,例如整數加法的位元組碼指令iadd,這條指令在執行的時候要求運算元棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會把這兩個int值出棧並相加,然後將相加的結果重新入棧。

寫個小案例:

package com.courage;
public class DeOperandStack {
    public static void main(String[] args) {
        int i = 1;
        int j = 2;
        int k = i + j;
    }
}

此時DeOperandStack類中只有一個執行緒(main),區域性變數表中擁有的變數:

預設args為0號變數,所以這個執行緒中有四個區域性變數,那麼是如何利用運算元棧進行加減的呢?

首先將第一個常數壓入棧,然後儲存區域性變數表1號變數,然後將第二個常數壓入棧,然後儲存區域性變數表2號變數,然後將區域性變數表1,2兩個數值載入進棧,彈出相加之後將結果壓入棧,在將棧頂資料儲存到3號變數。

動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線(Dynamic Linking)。我們知道Class檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池裡指向方法的符號引用作為引數。這些符號引用一部分會在類載入階段或者第一次使用的時候就被轉化為直接引用,這種轉化被稱為靜態解析。另外一部分將在每一次執行期間都轉化為直接引用,這部分就稱為動態連線。

方法出口

當一個方法開始執行後,只有兩種方式退出這個方法:

  • 遇到方法返回的位元組碼指令
  • 遇到了異常

第一種方式是執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者(呼叫當前方法的方法稱為呼叫者或者主調方法),方法是否有返回值以及返回值的型別將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為“正常呼叫完成”(Normal Method Invocation Completion)。

另外一種退出方式是在方法執行的過程中遇到了異常,並且這個異常沒有在方法體內得到妥善處理。無論是Java虛擬機器內部產生的異常,還是程式碼中使用athrow位元組碼指令產生的異常,只要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為“異常呼叫完成(Abrupt Method Invocation Completion)”。

一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者提供任何返回值的。無論採用何種退出方式,在方法退出之後,都必須返回到最初方法被呼叫時的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層主調方法的執行狀態。

Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,Java
世界裡“幾乎”所有的物件例項以及陣列都在這裡分配記憶體。

從分配記憶體的角度看,所有執行緒共享的Java堆中可以劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer,TLAB),以提升物件分配時的效率。不過無論從什麼角度,無論如何劃分,都不會改變Java堆中儲存內容的共性,無論是哪個區域,儲存的都只能是物件的例項,將Java堆細分的目的只是為了更好地回收記憶體,或者更快地分配記憶體。

方法區

方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等資料。

和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,甚至還可以選擇不實現垃圾收集,這區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,一般來說這個區域的回收效果比較難令人滿意,尤其是型別的解除安裝,條件相當苛刻,但是這部分割槽域的回收有時又確實是必要的,此區域未完全回收會導致記憶體洩漏。

方法區、永久代、元空間的關係

之所以將這三個放一起,是這兒很容易混淆,對於Hotspot虛擬機器,JDK6、JDK7 時方法區是 PermGen(永久代),JDK8 時,方法區是 Metaspace(元空間),怎麼回事呢?

方法區 是JVM的規範,所有虛擬機器必須遵守的。常見的JVM虛擬機器Hotspot 、JRockit(Oracle)、J9(IBM)

PermGen space則是 HotSpot 虛擬機器 基於 JVM 規範對 方法區 的一個落地實現, 並且只有 HotSpot 才有 PermGen space。而如 JRockit(Oracle)、J9(IBM) 虛擬機器有 方法區 ,但是就沒有 PermGen space。

PermGen space 是JDK7及之前, HotSpot 虛擬機器 對 方法區 的一個落地實現,在JDK8被移除。

Metaspace(元空間)是 JDK8及之後, HotSpot 虛擬機器對方法區 的新的實現。

永久代以及元空間,可以用來存放堆中存活很久的物件。元空間與永久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體

類資訊

每一個類有一個Class物件,編譯期生成,儲存在同名的.class檔案中。這些Class物件包含了這個型別的父類、介面、建構函式、方法、屬性等詳細資訊,這些class檔案在程式執行時會被ClassLoader載入到JVM中,在JVM中就表現為一個Class物件,JVM使用該Class物件建立該類的所有常規物件,而這個物件的資訊則儲存在方法區的類資訊中。

常量池

執行時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池表(Constant Pool Table),用於存放編譯期生成的各種字面量與符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。既然執行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會丟擲OutOfMemoryError異常。

靜態變數區

靜態變數也叫類變數,類的所有例項都共享,這個區專門存放靜態變數和靜態塊。

static 修飾的 在JVM執行時就載入到記憶體中了 所以不需要例項類。

靜態變數在類載入的準備階段分配記憶體並設定類變數初始值的階段,從概念上講,這些變數所使用的記憶體都應當在方法區中進行分配,但必須注意到方法區本身是一個邏輯上的區域,在JDK 7及之前,HotSpot使用永久代來實現方法區時,實現是完全符合這種邏輯概念的;而在JDK 8及之後,類變數則會隨著Class物件一起存放在Java堆中,這時候“類變數在方法區”就完全是一種對邏輯概念的表述了,關於這部分內容,筆者已在4.3.1節介紹並且驗證過。

程式計數器

程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。在Java虛擬機器的概念模型裡,位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,它是程式控制流的指示器,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

由於Java虛擬機器的多執行緒是通過執行緒輪流切換、分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。

本地方法棧

本地方法棧(Native Method Stacks)與虛擬機器棧所發揮的作用是非常相似的,其區別只是虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,而本地方法棧則是為虛擬機器使用到的本地(Native)方法服務。

相關文章