天天看點

「JVM」深入剖析HotspotJVM記憶體區域

作者:Java程式媛睡不着

本文導讀

本文詳細的講述了Java虛拟機運作時資料區的程式計數器、虛拟機棧,本地方法棧,方法區,堆,常量池,以及直接記憶體(堆外記憶體),對各個區域的作用,服務對象以及其中可能産生的問題展開讨論。

JVM基礎知識

JVM 全稱是 Java Virtual Machine,也就是我們耳熟能詳的 Java 虛拟機。它能識别 .class 字尾的檔案,并且能夠解析它的指令,最終調用作業系統上的函數,完成我們想要的操作。

JVM是一個龐大的知識體系,可以從記憶體結構,記憶體配置設定政策,垃圾回收,性能監控工具,類檔案結構,類加載機制,位元組碼執行引擎,JVM自身優化技術,程式性能調優幾個方面去學習。

記憶體結構是了解JVM核心也是前提,同時 JVM 是一個虛拟化的作業系統,是以除了要虛拟指令之外,最重要的一個事情就是需要虛拟化記憶體。

JVM記憶體區域

這裡講解的是JVM記憶體的各個區域,重點講解這些區域的作用,服務對象以及其中可能産生的問題。

宏觀上JVM處于圖檔中虛線的位置,本篇重點講解JVM運作時資料區

「JVM」深入剖析HotspotJVM記憶體區域

定義:Java 虛拟機在執行 Java 程式的過程中會把它所管理的記憶體劃分為若幹個不同的資料區域,該區域記憶體主要分為堆、程式計數器、方法區、虛拟機棧和本地方法棧等。同時按照與線程的關系也可以這麼劃分區域:線程私有區域:一個線程擁有單獨的一份記憶體區域。線程共享區域:被所有線程共享,且隻有一份。這裡還有一個直接記憶體,這個雖然不是運作時資料區的一部分,但是會被頻繁使用。你可以了解成沒有被虛拟機化的作業系統上的其他記憶體(比如作業系統上有 8G 記憶體,被 JVM 虛拟化了 3G,那麼還剩餘 5G, JVM 是借助一些工具使用這 5G 記憶體的,這個記憶體部分稱之為直接記憶體)

程式計數器(Program Counter Register)

程式計數器是一塊很小的記憶體空間,是目前線程執行的位元組碼的行号訓示器,各線程之間獨立存儲,互不影響。主要用來記錄各個線程執行的位元組碼的位址,例如,分支、循環、跳轉、異常、線程恢複等都依賴于計數器。由于 Java 是多線程語言,當執行的線程數量超過 CPU 核數時,線程之間會根據時間片輪詢争奪 CPU 資源。如果一個線程的時間片用完了,或者是其它原因導緻這個線程的 CPU 資源被提前搶奪,那麼這個退出的線程就需要單獨的一個程式計數器,來記錄下一條運作的指令。因為 JVM 是虛拟機,内部有完整的指令與執行的一套流程,是以在運作 Java 方法的時候需要使用程式計數器(記錄位元組碼執行的位址或行号),如果是遇到本地方法(native 方法),這個方法不是 JVM 來具體執行,是以程式計數器不需要記錄了,這個是因為在作業系統層面也有一個程式計數器,這個會記錄本地代碼的執行的位址,是以在執行 native 方法時,JVM 中程式計數器的值為空(Undefined)。另外程式計數器也是 JVM 中唯一不會 OOM(OutOfMemory)的記憶體區域。

虛拟機棧(VM Stack)

虛拟機棧是線程運作 java 方法所需的資料,指令、傳回位址。棧的資料結構是先進後出(FILO)的資料結構,虛拟機棧的作用,在 JVM 運作過程中存儲目前線程運作方法所需的資料,指令、傳回位址 。虛拟機棧是基于線程的:哪怕你隻有一個 main() 方法,也是以線程的方式運作的。線上程的生命周期中,參與計算的資料會頻繁地入棧和出棧,棧的生命周期是和線程一樣的。虛拟機棧的大小預設為 1M,可用參數 –Xss 調整大小,例如-Xss256k。參數官方文檔(JDK1.8):docs.oracle.com/javase/8/do…

「JVM」深入剖析HotspotJVM記憶體區域

棧幀:在每個 Java 方法被調用的時候,都會建立一個棧幀,并入棧。一旦方法完成相應的調用,則出棧。

棧幀大體都包含四個區域:局部變量表、操作數棧、動态連接配接、傳回位址

一、局部變量表: 顧名思義就是局部變量的表,用于存放我們的局部變量的(方法中的變量)。首先它是一個 32 位的長度,主要存放我們的 Java 的八大基礎資料類型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用兩個也可以存放下,如果是局部的一些對象,比如我們的 Object 對象,我們隻需要存放它的一個引用位址即可。局部變量表在編譯期間完成配置設定,方法在棧幀中配置設定多大記憶體是完全确定的,在方法運作期間不會改變局部變量表的大小。

二、操作資料棧: 存放 java 方法執行的操作數的,它就是一個棧,先進後出的棧結構,操作數棧,就是用來操作的,操作的的元素可以是任意的 java 資料類型,是以我們知道一個方法剛剛開始的時候,這個方法的操作數棧就是空的。操作數棧本質上是 JVM 執行引擎的一個工作區,也就是方法在執行,才會對操作數棧進行操作,如果代碼不不執行,操作數棧其實就是空的。

三、動态連接配接: Java 語言特性多态(後續章節細講,需要結合 class 與執行引擎一起來講)。

四、傳回位址: 正常傳回(調用程式計數器中的位址作為傳回)、異常的話(通過異常處理器表<非棧幀中的>來确定)同時,虛拟機棧這個記憶體也不是無限大,它有大小限制,預設情況下是 1M。如果我們不斷的往虛拟機棧中入棧幀,但是就是不出棧的話,那麼這個虛拟機棧就會爆掉(例如方法D()會抛出Exception in thread "main" java.lang.StackOverflowError)。

public class MethodAndStack {
    public static void main(String[] args) {
        A();
    }
    public static void A() {
        B();
    }
    public static void B() {
        C();
    }
    public static void C() {
    }
    public static void D() {
        D()
    }
}           

這段代碼很簡單,就是起一個 main 方法,在 main 方法運作中調用 A 方法,A方法中調用 B 方法,B方法中運作C方法。我們把代碼跑起來,線程1來運作這段代碼, 線程1跑起來,就會有一個對應 的虛拟機棧,同時在執行每個方法的時候都會打包成一個棧幀。比如 main 開始運作,打包一個棧幀送入到虛拟機棧。C 方法運作完了,C 方法出棧,接着 B 方法運作完了,B 方法出棧、接着 A 方法運作完了,A 方法出棧,最後 main 方法運作完了,main 方法這個棧幀就出棧了。這個就是 Java 方法運作對虛拟機棧的一個影響。虛拟機棧就是用來存儲線程運作方法中的資料的。而每一個方法對應一個棧幀。

「JVM」深入剖析HotspotJVM記憶體區域

本地方法棧(Native Method Stact)

本地方法棧跟 Java 虛拟機棧的功能類似,Java 虛拟機棧用于管理 Java 函數的調用,而本地方法棧則用于管理本地方法的調用。但本地方法并不是用 Java 實作的,而是由 C 語言實作的(比如 Object.hashcode 方法)。本地方法棧是和虛拟機棧非常相似的一個區域,它服務的對象是 native 方法。你甚至可以認為虛拟機棧和本地方法棧是同一個區域。虛拟機規範無強制規定,各版本虛拟機自由實作 ,HotSpot 直接把本地方法棧和虛拟機棧合二為一 。本地方法棧也會抛出StackOverflowError和OutOfMemorryError,上述說過程式計數器不會記錄本地方法棧。

方法區(Method Area)

方法區主要是用來存放已被虛拟機加載的類相關資訊,包括類資訊、靜态變量、常量、運作時常量池、字元串常量池等。方法區是 JVM 對記憶體的“邏輯劃分”,在 JDK1.7 及之前很多開發者都習慣将方法區稱為“永久代”,是因為在 HotSpot 虛拟機中,設計人員使用了永久代來實作了 JVM 規範的方法區。在 JDK1.8 及以後使用了元空間來實作方法區。

JVM 在執行某個類的時候,必須先加載。在加載類(加載、驗證、準備、解析、初始化)的時候,JVM 會先加載 class 檔案,而在 class 檔案中除了有類的版本、字段、方法和接口等描述資訊外,還有一項資訊是常量池 (Constant Pool Table),用于存放編譯期間生成的各種字面量和符号引用。字面量包括字元串(String a=“b”)、基本類型的常量(final 修飾的變量),符号引用則包括類和方法的全限定名(例如 String 這個類,它的全限定名就是 Java/lang/String)、字段的名稱和描述符以及方法的名稱和描述符。

JDK1.7的HotSpot中,已經把原本放入永久代的字元串常量池移出(java常量池不在堆中也不在棧中,是獨立的記憶體空間管理。常量池:存放字元串常量和基本類型常量)

元空間(class metadata)

方法區與堆空間類似,也是一個共享記憶體區,是以方法區是線程共享的。假如兩個線程都試圖通路方法區中的同一個類資訊,而這個類還沒有裝入 JVM,那麼此時就隻允許一個線程去加載它,另一個線程必須等待。在 HotSpot 虛拟機、Java7 版本中已經将永久代的靜态變量和運作時常量池轉移到了堆中,其餘部分則存儲在 JVM 的非堆記憶體中,而 Java8 版本已經将方法區中實作的永久代去掉了,并用元空間(class metadata)代替了之前的永久代,并且元空間的存儲位置是本地記憶體。

元空間大小參數:jdk1.7 及以前(初始和最大值):-XX:PermSize;-XX:MaxPermSize;jdk1.8 以後(初始和最大值):-XX:MetaspaceSize; -XX:MaxMetaspaceSizejdk1.8 以後大小就隻受本機總記憶體的限制(如果不設定參數的話)JVM 參數參考:docs.oracle.com/javase/8/do…

Java8 為什麼使用元空間替代永久代,這樣做有什麼好處呢? 官方給出的解釋是:移除永久代是為了融合 HotSpot JVM 與 JRockit VM 而做出的努力,因為 JRockit 沒有永久代,是以不需要配置永久代。永久代記憶體經常不夠用或發生記憶體溢出,抛出異常 java.lang.OutOfMemoryError: PermGen。這是因為在 JDK1.7 版本中,指定的 PermGen 區大小為8M,由于 PermGen 中類的中繼資料資訊在每次 FullGC 的時候都可能被收集,回收率都偏低,成績很難令人滿意;還有為 PermGen 配置設定多大的空間很難确定,PermSize 的大小依賴于很多因素,比如,JVM 加載的 class 總數、常量池的大小和方法的大小等。

常量池與運作時常量池(Constant pool and Runtime Constant Pool)

而當類加載到記憶體中後,JVM 就會将 class 檔案常量池中的内容存放到運作時的常量池中;在解析階段,JVM 會把符号引用替換為直接引用(對象的索引值)。例如,類中的一個字元串常量在 class 檔案中時,存放在 class 檔案常量池中的;在 JVM 加載完類之後,JVM 會将這個字元串常量放到運作時常量池中,并在解析階段,指定該字元串對象的索引值。運作時常量池是全局共享的,多個類共用一個運作時常量池,class 檔案中常量池多個相同的字元串在運作時常量池隻會存在一份。

「JVM」深入剖析HotspotJVM記憶體區域

常量池有很多概念,包括運作時常量池、class 常量池、字元串常量池。虛拟機規範隻規定以上區域屬于方法區,并沒有規定虛拟機廠商的實作。嚴格來說是靜态常量池和運作時常量池,靜态常量池是存放字元串字面量、符号引用以及類和方法的資訊,而運作時常量池存放的是運作時一些直接引用。運作時常量池并不隻有編譯期能産生(預置入Class檔案中),運作期間也可能産生新的常量池(使用較多是String類的intern()方法)

運作時常量池是在類加載完成之後,将靜态常量池中的符号引用值轉存到運作時常量池中,類在解析之後,将符号引用替換成直接引用。運作時常量池在 JDK1.7 版本之後,就移到堆記憶體中了,這裡指的是實體空間,而邏輯上還是屬于方法區(方法區是邏輯分區)。所謂靜态常量池,也就是上圖*.class檔案中的常量池。

堆(Heap)

堆是 JVM 上最大的記憶體區域,我們申請的幾乎所有的對象,都是在這裡存儲的。我們常說的垃圾回收,操作的對象就是堆。堆空間一般是程式啟動時,就申請了,但是并不一定會全部使用。堆一般設定成可伸縮的。随着對象的頻繁建立,堆空間占用的越來越多,就需要不定期的對不再使用的對象進行回收。這個在 Java 中,就叫作 GC(Garbage Collection)。

那一個對象建立的時候,到底是在堆上配置設定,還是在棧上配置設定呢?這和兩個方面有關:對象的類型和在 Java 類中存在的位置。Java 的對象可以分為基本資料類型和普通對象。對于普通對象來說,JVM 會首先在堆上建立對象,然後在其他地方使用的其實是它的引用。比如,把這個引用儲存在虛拟機棧的局部變量表中。對于基本資料類型來說(byte、short、int、long、float、double、char),有兩種情況。當你在方法體内聲明了基本資料類型的對象,它就會在棧上直接配置設定。其他情況,都是在堆上配置設定。

堆大小參數:-Xms:堆的最小值;-Xmx:堆的最大值;-Xmn:新生代的大小;-XX:NewSize;新生代最小值;-XX:MaxNewSize:新生代最大值;例如- Xmx256m

「JVM」深入剖析HotspotJVM記憶體區域

符号引用

符号引用則屬于編譯原理方面的概念,包括了如下三種類型的常量:類和接口的全限定名、字段名稱和描述符、方法名稱和描述符。

一個 java 類(假設為 People 類)被編譯成一個 class 檔案時,如果 People 類引用了 Tool 類,但是在編譯時 People 類并不知道引用類的實際記憶體位址,是以隻能使用符号引用來代替。而在類裝載器裝載 People 類時,此時可以通過虛拟機擷取 Tool 類的實際記憶體位址,是以便可以既将符号 org.simple.Tool 替換為 Tool 類的實際記憶體位址,及直接引用位址。

即在編譯時用符号引用來代替引用類,在加載時再通過虛拟機擷取該引用類的實際位址,以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時能無歧義地定位到目标即可。符号引用與虛拟機實作的記憶體布局是無關的,引用的目标不一定已經加載到記憶體中。

直接記憶體(堆外記憶體)

直接記憶體有一種更加科學的叫法,堆外記憶體。JVM 在運作時,會從作業系統申請大塊的堆記憶體,進行資料的存儲;同時還有虛拟機棧、本地方法棧和程式計數器,這塊稱之為棧區。作業系統剩餘的記憶體也就是堆外記憶體。它不是虛拟機運作時資料區的一部分,也不是 java 虛拟機規範中定義的記憶體區域;如果使用了 NIO,這塊區域會被頻繁使用,在 java 堆内可以用directByteBuffer 對象直接引用并操作;

這塊記憶體不受 java 堆大小限制,但受本機總記憶體的限制,可以通過-XX:MaxDirectMemorySize 來設定(預設與堆記憶體最大值一樣),是以也會出現 OOM 異常。

1、直接記憶體主要是通過 DirectByteBuffer 申請的記憶體,可以使用參數“MaxDirectMemorySize”來限制它的大小。2、其他堆外記憶體,主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申請的記憶體。

堆外記憶體的洩漏是非常嚴重的,它的排查難度高、影響大,甚至會造成主機的死亡。後續章節會詳細講。

同時,要注意 Oracle 之前 計劃在 Java 9 中去掉 sun.misc.Unsafe API 。這裡删除 sun.misc.Unsafe 的原因之一是使 Java 更加安全,并且有替代方案。目前我們主要針對的 JDK1.8 ,JDK1.9 暫時不放入讨論範圍中,我們大緻知道 java 的發展即可。

繼續閱讀