天天看點

【JVM進階之路】二:Java記憶體區域1、運作時資料區2、JDK的記憶體區域變遷

1、運作時資料區

Java 虛拟機定義了若幹種程式運作期間會使用到的運作時資料區,其中有一些會随着虛拟機啟動而建立,随着虛拟機退出而銷毀。另外一些則是與線程一一對應的,這些與線程對應的資料區域會随着線程開始和結束而建立和銷毀。

根據《Java虛拟機規範》的規定,Java虛拟機所管理的記憶體将會包括以下幾個運作時資料區域:

【JVM進階之路】二:Java記憶體區域1、運作時資料區2、JDK的記憶體區域變遷

1.1、程式計數器

程式計數器(Program Counter Register)也被稱為PC寄存器,是一塊較小的記憶體空間。

它可以看作是目前線程所執行的位元組碼的行号訓示器。在Java虛拟機的概念模型裡,位元組碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,它是程式控制流的訓示器,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴這個計數器來完成。

每一條 Java虛拟機線程都有自己的程式計數器。在任意時刻,一條 Java 虛拟機線程隻會執行一個方法的代碼,這個正在被線程執行的方法稱為該線程的目前方法

如果這個方法不是 native 的,那 PC 寄存器就儲存 Java 虛拟機正在執行的位元組碼指令的位址,如果該方法是 native 的,那 PC 寄存器的值是 undefined。

1.2、Java虛拟機棧

與程式計數器一樣,Java虛拟機棧(Java Virtual Machine Stack)也是線程私有的,它的生命周期與線程相同。虛拟機棧描述的是Java方法執行的線程記憶體模型:每個方法被執行的時候,Java虛拟機都 會同步建立一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動态連接配接、方法出口等資訊。每一個方法被調用直至執行完畢的過程,就對應着一個棧幀在虛拟機棧中從入棧到出棧的過程。

把Java記憶體區域可以粗略地劃分為堆記憶體(Heap)和棧記憶體(Stack),其中,“棧”通常就是指這裡講的虛拟機棧,或者更多的情況下隻是指虛拟機棧中局部變量表部分。

局部變量表存放了編譯期可知的各種Java虛拟機基本資料類型(boolean、byte、char、short、int、 float、long、double)、對象引用(reference類型,它并不等同于對象本身,可能是一個指向對象起始位址的引用指針,也可能是指向一個代表對象的句柄或者其他與此對象相關的位置)和returnAddress 類型(指向了一條位元組碼指令的位址)。

這些資料類型在局部變量表中的存儲空間以局部變量槽(Slot)來表示,其中64位長度的long和 double類型的資料會占用兩個變量槽,其餘的資料類型隻占用一個。局部變量表所需的記憶體空間在編譯期間完成配置設定,當進入一個方法時,這個方法需要在棧幀中配置設定多大的局部變量空間是完全确定 的,在方法運作期間不會改變局部變量表的大小。

Java 虛拟機棧可能發生如下異常情況:

  • 如果線程請求配置設定的棧容量超過 Java 虛拟機棧允許的最大容量時,Java 虛拟機将會抛出一個 StackOverflowError 異常。
  • 如果 Java 虛拟機棧可以動态擴充,并且擴充的動作已經嘗試過,但是目前無法申請到足夠的記憶體去完成擴充,或者在建立新的線程時沒有足夠的記憶體去建立對應的虛拟機棧,那 Java 虛拟機将會抛出一個 OutOfMemoryError 異常。

1.3、本地方法棧

本地方法棧(Native Method Stacks)與虛拟機棧所發揮的作用是非常相似的,其差別隻是虛拟機棧為虛拟機執行Java方法(也就是位元組碼)服務,而本地方法棧則是為虛拟機使用到的本地(Native)方法服務。

Java 虛拟機規範允許本地方法棧被實作成固定大小的或者是根據計算動态擴充和收縮的。

本地方法棧可能發生如下異常情況:

  • 如果線程請求配置設定的棧容量超過本地方法棧允許的最大容量時,Java 虛拟機将會抛出一個StackOverflowError 異常。
  • 如果本地方法棧可以動态擴充,并且擴充的動作已經嘗試過,但是目前無法申請到足夠的記憶體去完成擴充,或者在建立新的線程時沒有足夠的記憶體去建立對應的本地方法棧,那 Java 虛拟機将會抛出一個 OutOfMemoryError 異常。

1.4、Java堆

對于Java應用程式來說,Java堆(Java Heap)是虛拟機所管理的記憶體中最大的一塊。Java堆是被所有線程共享的一塊記憶體區域,在虛拟機啟動時建立。此記憶體區域的唯一目的就是存放對象執行個體,Java裡“幾乎”所有的對象執行個體都在這裡配置設定記憶體。

Java堆是垃圾收集器管理的記憶體區域,是以一些資料中它也被稱作“GC堆”(Garbage Collected Heap,)。從回收記憶體的角度看,由于現代垃圾收集器大部分都是基于分代收集理論設計的,是以Java堆中經常會出現“新生代”“老年代”“永久代”“Eden空間”“From Survivor空間”“To Survivor空間”等名詞,需要注意的是這些區域劃分僅僅是一部分垃圾收集器的共同特性或者說設計風格而已,而非某個Java虛拟機具體 實作的固有記憶體布局,更不是《Java虛拟機規範》裡對Java堆的進一步細緻劃分。

如果從配置設定記憶體的角度看,所有線程共享的Java堆中可以劃分出多個線程私有的配置設定緩沖區 (Thread Local Allocation Buffer,TLAB),以提升對象配置設定時的效率。不過無論從什麼角度,無論如何劃分,都不會改變Java堆中存儲内容的共性,無論是哪個區域,存儲的都隻能是對象的執行個體,将Java 堆細分的目的隻是為了更好地回收記憶體,或者更快地配置設定記憶體。

根據《Java虛拟機規範》的規定,Java堆可以處于實體上不連續的記憶體空間中,但在邏輯上它應該 被視為連續的,這點就像我們用磁盤空間去存儲檔案一樣,并不要求每個檔案都連續存放。但對于大對象(典型的如數組對象),多數虛拟機實作出于實作簡單、存儲高效的考慮,很可能會要求連續的記憶體空間。

Java堆既可以被實作成固定大小的,也可以是可擴充的,不過目前主流的Java虛拟機都是按照可擴充來實作的(通過參數-Xmx和-Xms設定)。如果在Java堆中沒有記憶體完成執行個體配置設定,并且堆也無法再擴充時,Java虛拟機将會抛出OutOfMemoryError異常。

1.5、方法區

方法區(Method Area)與Java堆一樣,是各個線程共享的記憶體區域,它用于存儲已被虛拟機加載的類型資訊、常量、靜态變量、即時編譯器編譯後的代碼緩存等資料。雖然《Java虛拟機規範》中把方法區描述為堆的一個邏輯部分,但是它卻有一個别名叫作“非堆”(Non-Heap),目的是與Java堆區分開來。

【JVM進階之路】二:Java記憶體區域1、運作時資料區2、JDK的記憶體區域變遷

《Java虛拟機規範》對方法區的限制是非常寬松的,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充外,甚至還可以選擇不實作垃圾收集。相對而言,垃圾收集行為在這個區域的确是比較少出現的,但并非資料進入了方法區就如永久代的名字一樣“永久”存在了。這區域的記憶體回收目标主要是針對常量池的回收和對類型的解除安裝,一般來說這個區域的回收效果比較難令人滿意,尤其是類型的解除安裝,條件相當苛刻,但是這部分區域的回收有時又确實是必要的。以前Sun公司的Bug清單中,曾出現過的若幹個嚴重的Bug就是由于低版本的HotSpot虛拟機對此區域未完全回收而導緻記憶體洩漏。

根據《Java虛拟機規範》的規定,如果方法區無法滿足新的記憶體配置設定需求時,将抛出OutOfMemoryError異常。

值得一提的是:很多人都更願意把方法區稱呼為“永久代”(Permanent Generation),或将兩者混為一談。本質上這兩者并不是等價的,因為僅僅是當時的HotSpot虛拟機設計團隊選擇把收集器的分代設計擴充至方法區,或者說使用永久代來實作方法區而已,這樣使得 HotSpot的垃圾收集器能夠像管理Java堆一樣管理這部分記憶體,省去專門為方法區編寫記憶體管理代碼的工作。但是對于其他虛拟機實作,譬如BEA JRockit、IBM J9等來說,是不存在永久代的概念的。

1.6、運作時常量池

運作時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、字段、方法、接口等描述資訊外,還有一項資訊是常量池表(Constant Pool Table),用于存放編譯期生成的各種字面量與符号引用,這部分内容将在類加載後存放到方法區的運作時常量池中。

Java虛拟機對于Class檔案每一部分(自然也包括常量池)的格式都有嚴格規定,如每一個位元組用于存儲哪種資料都必須符合規範上的要求才會被虛拟機認可、加載和執行,但對于運作時常量池,

《Java虛拟機規範》并沒有做任何細節的要求,不同提供商實作的虛拟機可以按照自己的需要來實作這個記憶體區域,不過一般來說,除了儲存Class檔案中描述的符号引用外,還會把由符号引用翻譯出來的直接引用也存儲在運作時常量池中。

運作時常量池相對于Class檔案常量池的另外一個重要特征是具備動态性,Java語言并不要求常量 一定隻有編譯期才能産生,也就是說,并非預置入Class檔案中常量池的内容才能進入方法區運作時常量池,運作期間也可以将新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。

既然運作時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會抛出OutOfMemoryError異常。

1.7、直接記憶體

直接記憶體(Direct Memory)并不是虛拟機運作時資料區的一部分,也不是《Java虛拟機規範》中定義的記憶體區域。

在JDK 1.4中新加入了NIO(New Input/Output)類,引入了一種基于通道(Channel)與緩沖區 (Buffer)的I/O方式,它可以使用Native函數庫直接配置設定堆外記憶體,然後通過一個存儲在Java堆裡面的 DirectByteBuffer對象作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了 在Java堆和Native堆中來回複制資料。

顯然,本機直接記憶體的配置設定不會受到Java堆大小的限制,但是,既然是記憶體,則肯定還是會受到 本機總記憶體(包括實體記憶體、SWAP分區或者分頁檔案)大小以及處理器尋址空間的限制,一般伺服器管理者配置虛拟機參數時,會根據實際記憶體去設定-Xmx等參數資訊,但經常忽略掉直接記憶體,使得 各個記憶體區域總和大于實體記憶體限制(包括實體的和作業系統級的限制),進而導緻動态擴充時出現 OutOfMemoryError異常。

2、JDK的記憶體區域變遷

2.1、jdk1.6/1.7/1.8記憶體區域變化

在上一節提到了,HotSpot虛拟機是是Sun/OracleJDK和OpenJDK中的預設Java虛拟機,是JVM應用最廣泛的一種實作。上面提到,Java虛拟機規範對方法區的限制很寬松,而且HotSpot虛拟機在這一區域發生過一些bug,是以HotSpot的方法區經曆了一些變遷,我們來看看HotSpot虛拟機記憶體區域的變遷。

  • JDK1.6時期和我們上面講的JVM記憶體區域是一緻的:
【JVM進階之路】二:Java記憶體區域1、運作時資料區2、JDK的記憶體區域變遷
  • JDK1.7時發生了一些變化,将字元串常量池、靜态變量,存放在堆上
【JVM進階之路】二:Java記憶體區域1、運作時資料區2、JDK的記憶體區域變遷
  • 在JDK1.8時徹底幹掉了方法區,而在直接記憶體中劃出一塊區域作為元空間,運作時常量池、類常量池都移動到元空間。

2.2、為什麼替換掉方法區

方法區為什麼被替代了呢?當然,或者更準确的說法應該是永久代為什麼被替換了?——Java虛拟機規範規定的方法區隻是換種方式實作。有客觀和主觀兩個原因。

  • 客觀上使用永久代來實作方法區的決定的設計導緻了Java應用更容易遇到記憶體溢出的問題(永久代有-XX:MaxPermSize的上限,即使不設定也有預設大小,而J9和JRockit隻要沒有觸碰到程序可用記憶體的上限,例如32位系統中的4GB限制,就不會出問題),而且有極少數方法 (例如String::intern())會因永久代的原因而導緻不同虛拟機下有不同的表現。
  • 主觀上當Oracle收購BEA獲得了JRockit的所有權後,準備把JRockit中的優秀功能,譬如Java Mission Control管理工具,移植到HotSpot 虛拟機時,但因為兩者對方法區實作的差異而面臨諸多困難。考慮到HotSpot未來的發展,在JDK 6的 時候HotSpot開發團隊就有放棄永久代,逐漸改為采用本地記憶體(Native Memory)來實作方法區的計劃了,到了JDK 7的HotSpot,已經把原本放在永久代的字元串常量池、靜态變量等移出,而到了 JDK 8,終于完全廢棄了永久代的概念,改用與JRockit、J9一樣在本地記憶體中實作的元空間(Meta-space)來代替,把JDK 7中永久代還剩餘的内容(主要是類型資訊)全部移到元空間中。