JVM系列一(Java記憶體區域和對象創建).
- 2019 年 12 月 2 日
- 筆記
一、JVM 記憶體區域

堆 – Heap
執行緒共享,JVM中最大的一塊記憶體,此記憶體的唯一目的就是存放對象實例,Java 堆是垃圾收集器管理的主要區域,因此很多時候也被稱為「GC堆」(Garbage Collected Heap),可以通過 -Xmx 和 -Xms 參數來控制該區域大小。
方法區 – Method Area
執行緒共享,它用來存儲已被虛擬機載入的類資訊(版本、欄位、方法、介面等描述資訊)、常量、靜態變數、即時編譯器編譯後的程式碼等數據。
在 JDK 1.7 中,方法區被描述成堆(Heap)的一個邏輯部分,該區域也被稱為 Non-Heap(非堆),HotSpot 虛擬機在 1.7 中使用永生代(Permanent Generation)來實現方法區,這樣垃圾收集器可以像管理 Java 堆一樣管理這部分記憶體,能夠省去專門為方法區編寫記憶體管理程式碼的工作,因此也常常有人將永生代和方法區等價,因此永生代的參數(-XX:PermSize、-XX:MaxPermSize)也限制了方法區的記憶體大小。
在 JDK 1.8 中,為了減少方法區的記憶體溢出問題以及後續 HotSpot 和 JRockit 的合併事宜, HotSpots 取消了永久代(-XX:PermSize、-XX:MaxPermSize 參數即被廢棄),元空間(Metaspace)登上舞台,方法區存在於元空間,同時,元空間不再與堆連續,而且是存在於本地記憶體(Native memory)中,意味著只要本地記憶體足夠,它不會出現像永久代中 「java.lang.OutOfMemoryError: PermGen space」 這種錯誤,默認情況下元空間可以無限使用本地記憶體,可以通過(-XX:MetaspaceSize、-XX:MaxMetaspaceSize)限制元空間的大小。
運行時常量池 – Runtime Constant Pool
執行緒共享,存儲的內容包括 Class 文件常量池(該部分內容在類編譯後進入)以及翻譯出來的直接引用。
Class 常量池的內容包括:

對於運行時常量池,Java 虛擬機規範沒有做任何細節的要求,不同的提供商實現的虛擬機可以按照自己的需要來實現這個記憶體區域。運行時常量池相對於 Class 文件常量池的一個重要特徵是具備動態性,也就是說並非預置入 Class 文件常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中,比較常見的比如 String 類的 intern() 方法。
虛擬機棧/本地方法棧
執行緒私有,生命周期與執行緒相同,描述的是 Java 方法執行的記憶體模型:每個方法執行的同時都會創建一個棧幀(Stack Frame)用於存儲局部變數表、操作數棧、動態鏈接、方法出口等資訊。每個方法從調用直到執行完成的過程,就對應著一個棧幀入棧到出棧的過程。
局部變數表存放了編譯器可知的各種基本類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference 類型)、returnAddress 類型(指向了一條位元組碼執行的地址)。其中64位長度的 long 和 double 類型的數據會佔用兩個局部變數空間(Slot)。局部變數表所需的記憶體空間在編譯期間完成分配,在方法運行期間不會改變局部變數表的大小。
虛擬機棧和本地方法棧的區別不過是虛擬機棧為虛擬機執行 Java 方法服務,而本地方法棧為虛擬機執行 Native 方法服務。HotSpot 虛擬機直接把虛擬機棧和本地方法棧合二為一。可通過 -Xss 參數設置虛擬機棧大小,-Xoss 參數設置本地方法棧(HotSpot 虛擬機上該參數不生效)。
程式計數器
執行緒私有,一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器,此記憶體區域是唯一一個在Java虛擬機規範中沒有規定任何 OutOfMemoryError 情況的區域,因此該區域也變成了程式設計師最不關注的一個區域。
直接記憶體 – Direct Memory
執行緒私有,並不是虛擬機運行時數據區的一部分,也不是 Java 虛擬機規範中定義的記憶體區域。Java NIO (New Input/Output)是一種基於通道(Channel)與快取區(Buffer)的 I/O 方式,它可以使用 Native 函數庫直接分配堆外記憶體,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆中來回複製數據。
該區域也可能導致記憶體溢出,一個明顯的特徵是在 Heap Dump 文件中不會看見明顯的異常。因此,伺服器管理員在根據實際記憶體配置虛擬機參數時,需要考慮到直接記憶體需要的空間,可以通過 -XX:MaxDirectMemorySize 來指定直接記憶體的大小,如果不指定,則默認與 Java 堆的最大值(-Xmx)一樣。
二、Java 對象創建
接下來看看我們平常的一個 new 操作在 JVM 中又是怎樣一種過程呢?(討論的是普通 Java 對象,不包括數組和 Class 對象等)。
1. 棧空間分配
當執行 new 操作的時候,首先進行的是在Java 棧的局部變數表中分配一個對象引用(reference 類型,不等同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄)。
2. 類載入檢查
JVM 檢查這個對象是否能在常量池(指的是 Class 文件常量池)中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被載入、解析和初始化過。如果沒有,那必須先執行類載入過程(靜態塊、靜態變數、靜態方法載入進靜態方法區等操作)。
3. 分配記憶體
對象所需的記憶體大小在類載入完成後便可完全確定,因此為對象分配記憶體空間其實就是怎樣把一塊確定大小的記憶體從 Java 堆中劃分出來。一般有兩種分配方式:
指針碰撞 Java 堆中的記憶體是絕對規整的,所有用過的記憶體放在一邊,空閑的記憶體放在另一邊,中間放著一個指針作為分界點的指示器,分配記憶體就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離。
空閑列表 Java 堆中的記憶體並不是規整的,虛擬機維護了一個列表,記錄了哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄。
記憶體分配的方式由 Java 堆是否規整決定, Java 堆是否規整又是由所採用的垃圾收集器是否帶有 compact(壓縮整理)功能決定。比如 Serial、ParNew 等基於 stop-and-copy 演算法的收集器就具有 compact 功能,而 CMS 這種基於 mark-and-sweep 演算法的收集器就不具有 compact 功能。
虛擬機默認使用 CAS 配上失敗重試的方式保證記憶體分配操作的原子性,可通過 -XX:+/-UseTLAB 指定使用 TLAB(Thread Local Allocation Buffer, 本地執行緒分配緩衝);
HotSpot VM 的自動記憶體管理系統要求對象起始地址必須是 8 位元組的整數倍,換句話說,就是對象的大小必須是 8 位元組的整數倍。因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。
4. 初始化工作
接下來虛擬機載入非靜態塊、非靜態方法、非靜態變數,並將分配到的記憶體空間都初始化零值(引用類型初始化為 null,int 類型初始化為 0 等),這一步操作保證了對象的實例欄位在 Java 程式碼中可以不賦初始值就能直接使用。
5. 對象頭設置
接下來虛擬機將進行對象頭的填充設置,HotSpot 虛擬機的對象頭包括一般兩部分資訊:
第一部分(Mark Word) 存儲對象自身的運行時數據,如哈希碼、GC 分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,這部分數據的長度在 32 位和 64 位虛擬機(未開啟壓縮指針)中分別為 32bit 和 64 bit。
第二部分(類型指針) 對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。但是並不是所有的虛擬機實現都必須在對象數據上保留類型指針,比如通過句柄訪問。下文會提到。
如果對象是一個數組,那麼對象頭中還必須有一塊用於記錄數組長度的數據,因為虛擬機從數組的元數據中無法確定數組的大小。
6.構造器工作
如果有父類,則父類按上述流程保證被載入。
7. 對象的訪問定位
現在堆中的對象實例有了,棧中的 reference 也有了,怎麼將兩者關聯在一起呢?目前主流的方式有使用句柄和直接指針兩種:
使用句柄 Java 堆中劃分出一塊記憶體作為句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象的實例數據與類型數據各自的具體地址資訊。它的優點就是 reference 存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。
直接指針 reference 中存儲的直接就是對象地址。它的好處就是速度更快,節省了一次指針定位的時間開銷。

HotSpot VM 使用的直接指針進行對象訪問。