JVM 運行時數(shù)據(jù)區(qū)域,書中沒有說清楚的方法區(qū)、永久代、元空間
本文轉載自微信公眾號「飛天小牛肉」,作者小牛肉。轉載本文請聯(lián)系飛天小牛肉公眾號。
數(shù)據(jù)庫系列吭哧吭哧寫得差不多了,準備寒假看完 JVM,然后開學來看看框架背背八股就準備秋招了。話不多說,JVM 第一個知識點必定要奉獻給 Java 程序運行時的數(shù)據(jù)區(qū)域劃分。
老規(guī)矩,背誦版在文末。點擊閱讀原文可以直達我收錄整理的各大廠面試真題
JVM 運行時數(shù)據(jù)區(qū)域總覽
JVM 在執(zhí)行 Java 程序的過程中(簡稱運行時)會把它所管理的內存劃分為若干個不同的數(shù)據(jù)區(qū)域。這些區(qū)域有各自的用途,以及創(chuàng)建和銷毀的時間,有的區(qū)域隨著虛擬機進程的啟動而一直存在,有些區(qū)域則是依賴用戶線程的啟動和結束而建立和銷毀。
根據(jù)《Java 虛擬機規(guī)范》的規(guī)定,Java 虛擬機所管理的內存將會包括以下幾個運行時數(shù)據(jù)區(qū)域,如下圖所示:
從圖中可以看到,線程共享的區(qū)域是方法區(qū)和堆,線程隔離(線程私有)的區(qū)域是虛擬機棧、本地方法棧和程序計數(shù)器。
簡單解釋下線程共享和線程私有是啥意思:
- 所謂線程私有,通俗來說就是每個線程都會創(chuàng)建一個屬于自己的空間,每個線程之間的這塊私有空間互不影響,獨立存儲。比如程序計數(shù)器就是線程私有的,每個線程都會擁有一個屬于自己的程序計數(shù)器,互不干涉。
- 線程共享就沒啥好說的,簡單理解為公共場所,誰都能去,存儲的數(shù)據(jù)所有線程都能訪問。
下面我們來分別解釋下這幾個數(shù)據(jù)區(qū)域 ??
線程私有:程序計數(shù)器 Program Counter Register
程序計數(shù)器是一塊較小的內存空間,它可以看作是當前線程所執(zhí)行的字節(jié)碼的行號指示器。字節(jié)碼解釋器工作時就是通過改變這個計數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令,分支、循環(huán)、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數(shù)器來完成。
由于 Java 虛擬機的多線程是通過線程輪流切換、分配處理器執(zhí)行時間的方式來實現(xiàn)的,在任何一個確定的時刻,一個處理器(對于多核處理器來說是一個內核)都只會執(zhí)行一條線程中的指令。
因此,為了線程切換后能恢復到正確的執(zhí)行位置,每條線程都需要有一個獨立的程序計數(shù)器,各個線程之間的計數(shù)器互不影響。
那么程序計數(shù)器里存的到底是什么東西呢?
- 如果線程正在執(zhí)行的是一個 Java 方法,這個計數(shù)器記錄的是正在執(zhí)行的虛擬機字節(jié)碼指令的地址
- 如果正在執(zhí)行的是本地(Native)方法,這個計數(shù)器值則應為空(Undefined)。至于什么是 Native 方法,在本地方法棧那一小節(jié)會詳細解釋
注意!此內存區(qū)域是唯一一個在《Java虛擬機規(guī)范》中沒有規(guī)定任何 OutOfMemoryError(內存溢出)情況的區(qū)域。這個問題也算是一個比較常見的面試題了
線程私有:虛擬機棧 Java Virtual Machine Stack
虛擬機棧其實是由一個一個的 棧幀(Stack Frame) 組成的,一個棧幀描述的就是一個 Java 方法執(zhí)行的內存模型。也就是說每個方法在執(zhí)行的同時都會同步創(chuàng)建一個棧幀,用于存儲局部變量表、操作數(shù)棧、動態(tài)鏈接、方法的返回地址等信息。
每一個方法被調用直至執(zhí)行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。
其中,局部變量表存放了以下三種類型的數(shù)據(jù):
- 編譯期可知的各種 Java 虛擬機的基本數(shù)據(jù)類型:boolean、byte、char、short、int、float、long、double
- 對象引用,reference 類型:它并不等同于對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或者其他與此對象相關的位置。(關于 reference 類型,具體涉及到對象的訪問定位的兩種方式,使用句柄訪問和使用直接指針訪問,這個在后續(xù)文章中會詳細介紹)
- returnAddress 類型:指向了一條字節(jié)碼指令的地址
這些數(shù)據(jù)類型在局部變量表中的存儲空間以 局部變量槽 (Slot) 來表示,或者說局部變量表的基本存儲單元是 Slot,JVM 為每一個 Slot 都分配了一個訪問索引,通過這個索引就可以成功訪問到局部變量表中存儲的某個值。
其中,64 位長度的 long 和 double 類型的數(shù)據(jù)會占用兩個 Slot,其余的數(shù)據(jù)類型都是 32 位只占用一個。
在《Java虛擬機規(guī)范》中,對虛擬機棧這個內存區(qū)域規(guī)定了兩類異常狀況:
- 如果線程請求的棧深度大于虛擬機所允許的深度,將拋出 StackOverflowError 異常(棧溢出)
- 如果使用的 JVM 支持動態(tài)擴展虛擬機棧容量的話,當棧擴展時無法申請到足夠的內存就會拋出 OutOfMemoryError 異常(內存溢出)
線程私有:本地方法棧 Native Method Stacks
本地方法棧和上面我們所說的虛擬機棧作用基本一樣,區(qū)別只不過是本地方法棧為虛擬機使用到的 Native 方法服務,而虛擬機棧為虛擬機執(zhí)行 Java 方法(也就是字節(jié)碼)服務。
這里解釋一下 Native 方法的概念,其實不僅 Java,很多語言中都有這個概念。
"A native method is a Java method whose implementation is provided by non-java code."
就是說一個 Native 方法其實就是一個接口,但是它的具體實現(xiàn)是在外部由非 Java 語言比如 C 或 C++ 等來寫的。Java 通過 JNI 來調用本地方法, 而本地方法是以庫文件的形式存放的(在 WINDOWS 平臺上是 DLL 文件形式,在 UNIX 機器上是 SO 文件形式)。
所以同一個 Native 方法,如果用不同的虛擬機去調用它,那么得到的結果和運行效率可能是不一樣的,因為不同的虛擬機對于某個 Native 方法都有自己的實現(xiàn),比如 Object 類的 hashCode 方法。
那么為什么需要 Native 方法呢?
其主要原因就是 Java 雖然使用起來很方便,但是有些層次的任務用 Java 實現(xiàn)起來不容易,或者對程序的效率有比較高的要求時,Java 語言可能并不是最好的選擇。所以 Native 方法使得 Java 程序能夠超越 Java 運行時的界限,有效地擴充了 JVM。
與虛擬機棧一樣,本地方法棧也會在棧深度溢出或者棧擴展失敗時分別拋出 StackOverflowError 和 OutOfMemoryError 異常
線程共享:堆 Heap
Java 堆是虛擬機所管理的內存中最大的一塊。堆是被所有線程共享的一塊內存區(qū)域,在虛擬機啟動時創(chuàng)建。此內存區(qū)域的唯一目的就是存放對象實例,“幾乎” 所有的對象實例都在這里分配內存。
注意!這里我們用的是幾乎,技術發(fā)展至今,其實并非所有的對象實例都會分配到堆上,比如逃逸技術,這個我們后續(xù)文章我再做解釋~
堆是垃圾收集器管理的內存區(qū)域,因此一些資料中它也被稱作 “GC 堆”(Garbage Collected Heap)。
對于堆這個概念小伙伴們肯定還聽說過各種諸如新生代、老年代、永久代、Eden 空間、From Survivor 空間、To Survivor 空間等名詞,需要注意的是,這些區(qū)域劃分僅僅是一部分垃圾收集器的共同特性或者說設計風格而已,只是為了通過這種分代設計來更好地回收內存,或者更快地分配內存,而非某個 Java 虛擬機具體實現(xiàn)的固有內存布局,更不是《Java虛擬機規(guī)范》里對 Java 堆的進一步細致劃分
根據(jù)《Java虛擬機規(guī)范》的規(guī)定,Java 堆可以處于物理上不連續(xù)的內存空間中,但在邏輯上它應該被視為連續(xù)的,這點就像我們用磁盤空間去存儲文件一樣,并不要求每個文件都連續(xù)存放。但對于大對象(典型的如數(shù)組對象),多數(shù)虛擬機實現(xiàn)出于實現(xiàn)簡單、存儲高效的考慮,很可能會要求連續(xù)的內存空間。
Java 堆既可以被實現(xiàn)成固定大小的,也可以是可擴展的,當前主流的 Java 虛擬機都是按照可擴展來實現(xiàn)的(通過參數(shù) -Xmx 和 -Xms 設定)
如果在堆中沒有內存來完成對象實例的分配,并且堆也無法再擴展時,JVM 就會拋出 OutOfMemoryError 異常
線程共享:方法區(qū) Method Area
方法區(qū)通俗點理解就是,在虛擬機完成類加載之后,存儲這個類相關的類型信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼緩存等數(shù)據(jù)。
It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods used in class and instance initialization and interface initialization. 它存儲每個類的結構,如運行時的常量池、字段和方法數(shù)據(jù),以及方法和構造函數(shù)的代碼,包括類和實例初始化和接口初始化中使用的特殊方法
舉個簡單的小例子:
方法區(qū)其實本身很好理解,但是《Java 虛擬機規(guī)范》/ 《深入理解 Java 虛擬機》提到的一句話:方法區(qū)是堆的一個邏輯部分,真的是讓我困惑了很長時間。
下面我來結合我的理解給大家解釋下,我覺得這個 “方法區(qū)是堆的一個邏輯部分” 應該適用于 JDK 8 以前,而不適用 JDK 8
先來看 JDK 8 之前:
可以看到,JDK 8 之前,堆和方法區(qū)其實是連在一起的,或者說,方法區(qū)就是堆的一部分。
但是呢,方法區(qū)存儲的東西又有些特別,在過去自定義類加載器使用不普遍的時候,類幾乎是 “靜態(tài)的” 并且很少被卸載和回收,因此類也可以被看成 “永久的”(這也就是永久代的含義),另外由于類作為 JVM 實現(xiàn)的一部分,它們不由程序來創(chuàng)建,所以為了和堆區(qū)分開來呢,就給了 “方法區(qū)” 這樣一個名字用來存儲類的信息,也有人把方法區(qū)稱為 “非堆”。
需要注意的是,無論是方法區(qū)還是非堆,其實都只是一個邏輯上的概念,在 JDK 8 之前,其具體的實現(xiàn)方法是永久代。
永久代是 HotSpot 虛擬機給出的實現(xiàn),但是對于其他虛擬機實現(xiàn),譬如 BEA JRockit、IBM J9 等來說,是不存在永久代的概念的。
永久代是一段連續(xù)的內存空間,我們在 JVM 啟動之前可以通過設置 -XX:MaxPermSize 的值來控制永久代的大小,32 位機器默認的永久代的大小為 64M,64 位的機器則為 85M。
永久代的垃圾回收和老年代的垃圾回收是綁定的,一旦其中一個區(qū)域被占滿,這兩個區(qū)都要進行垃圾回收。
顯然這種設計并不是一個好的主意,由于我們可以通過 ?XX:MaxPermSize 設置永久代的大小,一旦類的元數(shù)據(jù)超過了設定的大小,程序就會耗盡內存,并出現(xiàn)內存溢出錯誤 (java.lang.OutOfMemoryError: PermGen space)。
而且有極少數(shù)的方法(例如適用 String的 intern()方法可以在運行過程中手動的將字符串添加到 字符串常量池中,在 JDK1.7 之前的 HotSpot 虛擬機中,字符串常量池被存儲在永久代中)會因永久代的原因而導致不同虛擬機下有不同的表現(xiàn)
所以我們總結下 HotSpots 在 JDK 8 拋棄永久代,轉而用元空間來實現(xiàn)方法區(qū)的兩大原因:
- 由于永久代的垃圾回收和老年代的垃圾回收是綁定的,一旦其中一個區(qū)域被占滿,這兩個區(qū)都要進行垃圾回收,增大了 OOM 發(fā)生的概率
- 有少數(shù)的方法例如 String 的 intern() 方法會因永久代的原因而導致不同虛擬機下有不同的表現(xiàn),不利于代碼遷移
那么元空間到底是個啥,和方法區(qū)有啥區(qū)別?
元空間與永久代之間最大的區(qū)別在于:元空間不再與堆連續(xù),并且是存在于本地內存(Native memory)中的。
運行時數(shù)據(jù)區(qū)域的對比如下圖:
元空間存在于本地內存,意味著只要本地內存足夠,它就不會 OOM,不會出現(xiàn)像永久代中的 java.lang.OutOfMemoryError: PermGenspace
運行時常量池 Runtime Constant Pool
運行時常量池是方法區(qū)的一部分。上面我們說過方法區(qū)包含類信息,而描述類信息的 Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表 (Constant Pool Table),用于存放編譯期生成的各種字面量(字面量相當于 Java 語言層面常量的概念,如文本字符串,聲明為 final 的常量值等)與符號引用。有一些文章會把 class 常量池表稱為靜態(tài)常量池。
都是常量池,常量池表和運行時常量池有啥關系嗎?運行時常量池是干嘛的呢?
運行時常量池可以在運行期間將 class 常量池表中的符號引用解析為直接引用。簡單來說,class 常量池表就相當于一堆索引,運行時常量池根據(jù)這些索引來查找對應方法或字段所屬的類型信息和名稱及描述符信息
為什么需要常量池這個東西呢?主要是為了避免頻繁的創(chuàng)建和銷毀對象而影響系統(tǒng)性能,其實現(xiàn)了對象的共享。以字符串常量池為例,字符串 String 既然作為 Java 中的一個類,那么它和其他的對象分配一樣,需要耗費高昂的時間與空間代價,作為最基礎最常用的數(shù)據(jù)類型,大量頻繁的創(chuàng)建字符串,將會極大程度的影響程序的性能。為此,JVM 為了提高性能和減少內存開銷,在實例化字符串常量的時候進行了一些優(yōu)化:
- 為字符串開辟了一個字符串常量池 String Pool,可以理解為緩存區(qū)
- 創(chuàng)建字符串常量時,首先檢查字符串常量池中是否存在該字符串
- 若字符串常量池中存在該字符串,則直接返回該引用實例,無需重新實例化;若不存在,則實例化該字符串并放入池中。
需要注意的是,字符串常量池的位置在 JDK 1.7 前后有所變化,可以參考下面這張表:
最后放上這道題的背誦版:
面試官:講一下 JVM 運行時數(shù)據(jù)區(qū)域
小牛肉:JVM 在執(zhí)行 Java 程序的過程中會把它所管理的內存劃分為若干個不同的數(shù)據(jù)區(qū)域。線程共享的區(qū)域是方法區(qū)和堆,線程私有的區(qū)域是虛擬機棧、本地方法棧和程序計數(shù)器。
所謂線程私有就是每個線程都會創(chuàng)建一個屬于自己的空間,每個線程之間的這塊私有空間互不影響,獨立存儲。
先來說線程私有的三個區(qū)域:
程序計數(shù)器:程序計數(shù)器是一塊較小的內存空間,它可以看作是當前線程所執(zhí)行的字節(jié)碼的行號指示器。字節(jié)碼解釋器工作時就是通過改變這個計數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令,分支、循環(huán)、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數(shù)器來完成。
這個內存區(qū)域是唯一一個在《Java 虛擬機規(guī)范》中沒有規(guī)定任何 OutOfMemoryError 情況的區(qū)域。
虛擬機棧:虛擬機棧其實是由一個一個的棧幀(Stack Frame)組成的,一個棧幀描述的就是一個 Java 方法執(zhí)行的內存模型。也就是說每個方法在執(zhí)行的同時都會同步創(chuàng)建一個棧幀,用于存儲局部變量表、操作數(shù)棧、動態(tài)鏈接、方法的返回地址等信息。每一個方法被調用直至執(zhí)行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。
虛擬機棧這個內存區(qū)域有兩種異常狀況:
- 如果線程請求的棧深度大于虛擬機所允許的深度,將拋出 StackOverflowError 異常(棧溢出)
- 如果使用的 JVM 支持動態(tài)擴展虛擬機棧容量的話,當棧擴展時無法申請到足夠的內存就會拋出 OutOfMemoryError 異常(內存溢出)
本地方法棧:本地方法棧和虛擬機棧作用基本一樣,區(qū)別只不過是本地方法棧為虛擬機使用到的 Native 方法服務,而虛擬機棧為虛擬機執(zhí)行 Java 方法(也就是字節(jié)碼)服務
本地方法棧也會在棧深度溢出或者棧擴展失敗時分別拋出 StackOverflowError 和 OutOfMemoryError 異常
再來說線程共享的兩個區(qū)域:
堆:Java 堆是虛擬機所管理的內存中最大的一塊。堆是被所有線程共享的一塊內存區(qū)域,在虛擬機啟動時創(chuàng)建。此內存區(qū)域的唯一目的就是存放對象實例,“幾乎” 所有 new 出來的對象實例都在這里分配內存。
Java 堆既可以被實現(xiàn)成固定大小的,也可以是可擴展的,如果在堆中沒有內存來完成對象實例的分配,并且堆也無法再擴展時,JVM 就會拋出 OutOfMemoryError 異常
方法區(qū):方法區(qū)就是在虛擬機完成類加載之后,存儲這個類相關的類型信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼緩存等數(shù)據(jù)。
在 JDK 8 之前,堆和方法區(qū)其實是連在一起的,或者說方法區(qū)其實就是堆的一部分,HotSpot 虛擬機給出的具體實現(xiàn)是永久代,永久代是一個連續(xù)的內存空間,由于永久代的垃圾回收和老年代的垃圾回收是綁定的,一旦其中一個區(qū)域被占滿,這兩個區(qū)都要進行垃圾回收,增大了 OOM 發(fā)生的概率,另外,有少數(shù)的方法例如 String 的 intern() 方法會因永久代的原因而導致不同虛擬機下有不同的表現(xiàn),不利于代碼遷移。這兩個原因呢,促使 HotSpots 在 JDK 8 之后將方法區(qū)的實現(xiàn)更換成了元空間。
元空間與永久代之間最大的區(qū)別在于:元空間不再與堆連續(xù),并且是存在于本地內存(Native memory)中的,這意味著只要本地內存足夠,它就不會發(fā)生 OOM