JVM 內存結構詳解,看這一篇就夠了
本文主要對JVM 內存結構進行講解,注意不要和Java內存模型混淆了。
內存結構是指 Jvm 運行時將數據分區域存儲,強調對內存空間的劃分。
內存模型(Java Memory Model,簡稱 JMM )是定義了線程和主內存之間的抽象關系,即 JMM 定義了 JVM 在計算機內存(RAM)中的工作方式,是虛擬機的內存管理模型,是一種虛擬機工程規范。
運行時數據區
內存是非常重要的系統資源,是硬盤和 CPU 的中間倉庫及橋梁,承載著操作系統和應用程序的實時運行。JVM 內存布局規定了 Java 在運行過程中內存申請、分配、管理的策略,保證了 JVM 的高效穩定運行。不同的 JVM 對于內存的劃分方式和管理機制存在著部分差異。
下圖是 JVM 整體架構,中間部分就是 Java 虛擬機定義的各種運行時數據區域。
Java 虛擬機(源于網絡)
Java 虛擬機定義了若干種程序運行期間會使用到的運行時數據區,其中有一些會隨著虛擬機啟動而創建,隨著虛擬機退出而銷毀。另外一些則是與線程一一對應的,這些與線程一一對應的數據區域會隨著線程開始和結束而創建和銷毀。
- 線程私有:程序計數器、虛擬機棧、本地方法區。
- 線程共享:堆、方法區, 堆外內存(Java7的永久代或JDK8的元空間、代碼緩存)。
程序計數器
程序計數器(Program Counter Register)是一塊較小的內存空間,它的作用可以看做是當前線程所執行的字節碼的行號指示器。在虛擬機的概念模型里(僅是概念模型,各種虛擬機可能會通過一些更高效的方式去實現),字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。
PC 寄存器用來存儲指向下一條指令的地址(IDEA 插件 Jclasslib查看字節碼)
由于Java虛擬機的多線程是通過線程輪流切換并分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對于多核處理器來說是一個內核)只會執行一條線程中的指令。因此,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間的計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。
如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是Natvie方法,這個計數器值則為空(Undefined)。
此內存區域是唯一一個在Java虛擬機規范中沒有規定任何OutOfMemoryError情況的區域。
- 使用程序計數器(PC寄存器)存儲字節碼指令地址有什么用呢?為什么使用程序計數器記錄當前線程的執行地址呢?
因為CPU需要不停的切換各個線程,這時候切換回來以后,就得知道接著從哪開始繼續執行。JVM的字節碼解釋器就需要通過改變PC寄存器的值來明確下一條應該執行什么樣的字節碼指令。
- 程序計數器(PC寄存器)為什么會被設定為線程私有的?
多線程在一個特定的時間段內只會執行其中某一個線程方法,CPU會不停的做任務切換,這樣必然會導致經常中斷或恢復。為了能夠準確的記錄各個線程正在執行的當前字節碼指令地址,所以為每個線程都分配了一個PC寄存器,每個線程都獨立計算,不會互相影響。
JVM線程棧幀
(1)什么是虛擬機棧
與程序計數器一樣,Java虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是Java方法執行的內存模型:每個方法被執行的時候都會同時創建一個棧幀(Stack Frame)用于存儲局部變量表、操作棧、動態鏈接、方法出口等信息。每一個方法被調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。
棧幀的內部結構
局部變量表存放了編譯期可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不等同于對象本身,根據不同的虛擬機實現,它可能是一個指向對象起始地址的引用指針,也可能指向一個代表對象的句柄或者其他與此對象相關的位置)和returnAddress類型(指向了一條字節碼指令的地址)。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。
操作數棧,和局部變量區一樣,也被組織成一個以字長為單位的數組,但和前者不同的是,它不是通過索引來訪問的,而是通過入棧和出棧來訪問的,可把操作數棧理解為存儲計算時,臨時數據的存儲區域。
除了局部變量區和操作數棧外,java棧幀還需要一些數據來支持常量池解析、正常方法返回以及異常派發機制。這些數據都保存在java棧幀的幀數據區中。
在Java虛擬機規范中,對這個區域規定了兩種異常狀況:如果線程請求的棧深度大于虛擬機所允許的深度,將拋出StackOverflowError異常;如果虛擬機棧可以動態擴展(當前大部分的Java虛擬機都可動態擴展,只不過Java虛擬機規范中也允許固定長度的虛擬機棧),當擴展時無法申請到足夠的內存時會拋出OutOfMemoryError異常。
可以通過參數-Xss來設置每個線程的棧大小,棧的大小直接決定了函數調用的最大可達深度。
(2)棧運行原理
- JVM 直接對 Java 棧的操作只有兩個,對棧幀的壓棧和出棧,遵循“先進后出/后進先出”原則。
- 在一條活動線程中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱為當前棧幀(Current Frame),與當前棧幀對應的方法就是當前方法(Current Method),定義這個方法的類就是當前類(Current Class)。
- 執行引擎運行的所有字節碼指令只針對當前棧幀進行操作。
- 如果在該方法中調用了其他方法,對應的新的棧幀會被創建出來,放在棧的頂端,稱為新的當前棧幀。
- 不同線程中所包含的棧幀是不允許存在相互引用的,即不可能在一個棧幀中引用另外一個線程的棧幀。
- 如果當前方法調用了其他方法,方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接著,虛擬機會丟棄當前棧幀,使得前一個棧幀重新成為當前棧幀。
- Java 方法有兩種返回函數的方式,一種是正常的函數返回,使用 return 指令,另一種是拋出異常,不管用哪種方式,都會導致棧幀被彈出。
public class StackFrameTest {
public static void main(String[] args) {
test1();
}
private static void test1(){
System.out.println("test1 start");
test2();
System.out.println("test1 end");
}
private static void test2(){
System.out.println("test1 start");
test3();
System.out.println("test1 end");
}
private static int test3(){
System.out.println("test1 start");
int i = 1;
System.out.println("test1 end");
return i;
}
}
IDEA 在 debug 時候,可以在 debug 窗口看到 Frames 中各種方法的壓棧和出棧情況。
本地方法棧(Native Method Stacks)
本地方法棧(Native Method Stacks)與虛擬機棧所發揮的作用是非常相似的,其區別不過是虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則是為虛擬機使用到的Native方法服務。具體做法是 Native Method Stack 中登記 native 方法,在 Execution Engine 執行時加載本地方法庫當某個線程調用一個本地方法時,它就進入了一個全新的并且不再受虛擬機限制的世界。它和虛擬機擁有同樣的權限。
虛擬機規范中對本地方法棧中的方法使用的語言、使用方式與數據結構并沒有強制規定,因此具體的虛擬機可以自由實現它(并不是所有 JVM 都支持本地方法。如果 JVM 產品不打算支持 native 方法,也可以無需實現本地方法棧)。甚至有的虛擬機(譬如Sun HotSpot虛擬機)直接就把本地方法棧和虛擬機棧合二為一。與虛擬機棧一樣,本地方法棧區域也會拋出StackOverflowError和OutOfMemoryError異常。
棧是運行時的單位,而堆是存儲的單位。
棧解決程序的運行問題,即程序如何執行,或者說如何處理數據。堆解決的是數據存儲的問題,即數據怎么放、放在哪。
堆內存
(1)內存劃分
對于大多數應用,Java 堆是 Java 虛擬機管理的內存中最大的一塊,被所有線程共享。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數據都在這里分配內存。
為什么“幾乎所有的對象實例都在這里分配內存,而不是全部”?
為了進行高效的垃圾回收,虛擬機把堆內存邏輯上劃分成三塊區域(分代的唯一理由就是優化 GC 性能):
- 新生代(年輕代):新對象和沒達到一定年齡的對象都在新生代。
- 老年代(養老區):被長時間使用的對象,老年代的內存空間應該要比年輕代更大。
- 元空間(JDK1.8 之前叫永久代):像一些方法中的操作臨時對象等,JDK1.8 之前是占用 JVM 內存,JDK1.8 之后直接使用物理內存。
年輕代和老年代的劃分是為了更好的內存分派及回收(源于網絡)
- 年輕代 (Young Generation)
年輕代是所有新對象創建的地方。當填充年輕代時,執行垃圾收集。這種垃圾收集稱為 Minor GC。年輕一代被分為三個部分——伊甸園(Eden Memory)和兩個幸存區(Survivor Memory,被稱為from/to或s0/s1),默認比例是8:1:1
- 大多數新創建的對象都位于 Eden 內存空間中。
- 當 Eden 空間被對象填充時,執行Minor GC,并將所有幸存者對象移動到一個幸存者空間中。
- Minor GC 檢查幸存者對象,并將它們移動到另一個幸存者空間。所以總有一個幸存者空間是空的。
- 經過多次 GC 循環后存活下來的對象被移動到老年代。通常,這是通過設置年輕一代對象的年齡閾值來實現的,然后他們才有資格提升到老一代。
- 老年代(Old Generation)。
老年代中存放的對象是存活了很久的,年齡大于15的對象。在老年代觸發的gc叫major gc也叫full gc。full gc會包含年輕代的gc。但老年代只要執行gc就一定是full gc。在執行full gc的情況下,會阻塞程序的正常運行。老年代的gc比年輕代的gc效率上慢10倍以上,對效率有很大的影響。
大對象直接進入老年代(大對象是指需要大量連續內存空間的對象)。這樣做的目的是避免在 Eden 區和兩個Survivor 區之間發生大量的內存拷貝。
java8前后
?? 持久代在物理層面,是在堆空間的;而在邏輯層面,是在方法區的;因為方法區其實物理上也是在堆中的,但是由于功能和作用的區別,邏輯上方法區是獨立于堆的。
- 元空間
元空間是metaspace,在jdk1.8的時候,jvm移除了永久代的概念,元空間也是對java虛擬機的方法區的一種實現。元空間與永久代最大的區別在于,元空間不在虛擬機中,使用本地內存。
永久代的回收會隨著full gc進行移動,消耗性能。每種類型的垃圾回收都需要特殊處理元數據。將元數據剝離出來,簡化了垃圾收集,提高了效率。
Java 虛擬機規范規定,Java 堆可以是處于物理上不連續的內存空間中,只要邏輯上是連續的即可,像磁盤空間一樣。實現時,既可以是固定大小,也可以是可擴展的,主流虛擬機都是可擴展的(通過 -Xmx 和 -Xms 控制),如果堆中沒有完成實例分配,并且堆無法再擴展時,就會拋出 OutOfMemoryError 異常。
(2)對象的分配過程
為對象分配內存是一件非常嚴謹和復雜的任務,JVM 的設計者們不僅需要考慮內存如何分配、在哪里分配等問題,并且由于內存分配算法和內存回收算法密切相關,所以還需要考慮 GC 執行完內存回收后是否會在內存空間中產生內存碎片。
- new 的對象先放在伊甸園區,此區有大小限制。
- 當伊甸園的空間填滿時,程序又需要創建對象,JVM 的垃圾回收器將對伊甸園區進行垃圾回收(Minor GC),將伊甸園區中的不再被其他對象所引用的對象進行銷毀。再加載新的對象放到伊甸園區
- 然后將伊甸園中的剩余對象移動到幸存者 0 區
- 如果再次觸發垃圾回收,此時上次幸存下來的放到幸存者 0 區,如果沒有回收,就會放到幸存者 1 區
- 如果再次經歷垃圾回收,此時會重新放回幸存者 0 區,接著再去幸存者 1 區
- 什么時候才會去養老區呢? 默認是 15 次回收標記
- 在養老區,相對悠閑。當養老區內存不足時,再次觸發 Major GC,進行養老區的內存清理
- 若養老區執行了 Major GC 之后發現依然無法進行對象的保存,就會產生 OOM 異常
五、方法區
- 方法區(Method Area)與 Java 堆一樣,是所有線程共享的內存區域。
- 雖然 Java 虛擬機規范把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫 Non-Heap(非堆),目的應該是與 Java 堆區分開。
- 運行時常量池(Runtime Constant Pool)是方法區的一部分。Class 文件中除了有類的版本/字段/方法/接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用于存放編譯期生成的各種字面量和符號引用,這部分內容將類在加載后進入方法區的運行時常量池中存放。運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的是 String.intern()方法。受方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。
- 方法區的大小和堆空間一樣,可以選擇固定大小也可選擇可擴展,方法區的大小決定了系統可以放多少個類,如果系統類太多,導致方法區溢出,虛擬機同樣會拋出內存溢出錯誤
- JVM 關閉后方法區即被釋放
(1)方法區內部結構
方法區用于存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等。
類型信息
對每個加載的類型(類 class、接口 interface、枚舉 enum、注解 annotation),JVM 必須在方法區中存儲以下類型信息
- 這個類型的完整有效名稱(全名=包名.類名)
- 這個類型直接父類的完整有效名(對于 interface或是 java.lang.Object,都沒有父類)
- 這個類型的修飾符(public,abstract,final 的某個子集)
- 這個類型直接接口的一個有序列表
域(Field)信息
- JVM 必須在方法區中保存類型的所有域的相關信息以及域的聲明順序
- 域的相關信息包括:域名稱、域類型、域修飾符(public、private、protected、static、final、volatile、transient 的某個子集)
方法(Method)信息
JVM 必須保存所有方法的
- 方法名稱
- 方法的返回類型
- 方法參數的數量和類型
- 方法的修飾符(public,private,protected,static,final,synchronized,native,abstract 的一個子集)
- 方法的字符碼(bytecodes)、操作數棧、局部變量表及大小(abstract 和 native 方法除外)
- 異常表(abstract 和 native 方法除外)每個異常處理的開始位置、結束位置、代碼處理在程序計數器中的偏移地址、被捕獲的異常類的常量池索引
(2)方法區在 JDK6、7、8中的演進細節
jdk1.6及之前 | 有永久代,靜態變量存放在永久代上 |
jdk1.7 | 有永久代,但已經逐步“去永久代”,字符串常量池、靜態變量移除,保存在堆中 |
jdk1.8及之后 | 取消永久代,類型信息、字段、方法、常量保存在本地內存的元空間,但字符串常量池、靜態變量仍在堆中 |
只有 HotSpot 才有永久代的概念
(3)方法區和永久代以及元空間有什么關系
方法區和永久代以及元空間的關系很像(實際不是)Java 中接口和類的關系,類實現了接口,這里的類就可以看作是永久代和元空間,接口可以看作是方法區,也就是說永久代以及元空間是 HotSpot 虛擬機對虛擬機規范中方法區的兩種實現方式。
關系示意圖