淺析虛擬機記憶體管理模型
Java虛擬機在執行Java程式的過程中會把Java程式所管理的記憶體劃分為若干個不同的數據區域,這些區域可以劃分為5各部分:虛擬機棧、堆、方法區、本地方法棧、程式計數器,如圖:
虛擬機棧
Java虛擬機棧(Java Virtual Machine Stack)是執行緒私有的,它的生命周期與執行緒相同。也就是,每個方法被執行的時候,Java虛擬機都會同步創建一個棧幀 (Stack Frame)用於存儲局部變數表、操作數棧、動態連接、方法出口等資訊。每一個方法被調用直至執行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。下面講解一下虛擬機棧中的內容:
局部變數表
局部變數表存放了編譯期可知的各種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)方法服務。