JVM執行時記憶體資料區域

AIGeorge發表於2020-08-26

1 討論背景

周志明老師寫的《深入理解Java虛擬機器》應該很多程式設計師都讀過,第二章中闡述了Java虛擬機器在執行Java程式的過程中是如何管理記憶體的,以及這些記憶體是如何被劃分成更細的邏輯區域的。如下圖所示,按照書中的論述JVM執行時資料區域包含以下幾個資料區[1]。

 

按照《Java虛擬機器規範(Java SE 7版)》,各區域的功能簡要介紹如下:

  • 程式計數器:各執行緒私有。用於記錄每個執行緒下一條待執行的位元組碼指令以及相關資訊。這是唯一的不會丟擲OOM異常的區域。
  • Java虛擬機器棧:各執行緒私有。虛擬機器棧由一個個的棧幀組成,每個棧幀包含了對應方法執行所需要的資訊,具體包括:區域性變數表、運算元棧(類似於編譯型語言體系下的資料暫存器)、動態連結(某些介面符號可能會動態的指向不同的目標方法)、函式返回地址以及其他一些相關資訊。理論上當函式呼叫鏈超過棧的深度時就會觸發StackOverflow,當該區域設定為動態擴充套件時,虛擬機器無法為棧申請到更多記憶體時就會觸發OOM。事實中基本上不管哪種情況,結果都很可能會是StackOverflow,因為棧容量和棧幀的大小決定了棧的深度(棧幀大小*深度<=棧容量),所以當OOM時,棧深度一定也已經不夠用了,所以丟擲StackOverflow異常也無可厚非。可以透過“-Xss”來配置虛擬機器棧固定大小。
  • Java堆:各執行緒公有。虛擬機器工作的主要記憶體區域(大部分情況下也是最大的),絕大部分物件例項的記憶體分配都在這裡進行。Java 7和之前的Java堆細分為:新生代(伊甸區、存活區0、存活區1)、年老代和永久代。Java 8去除了永久代,替換以Metaspace。在JVM的執行中,大部分情況下,GC主要就發生在堆區域,
  • 方法區:各執行緒公有。用於存放類定義、常量池、靜態變數(static修飾)、編譯後的位元組碼等。方法區實際上是從堆上劃分出來的一塊區域,但是其GC機制是單獨的,與堆不同,所以為了區分方法區和堆,通常又把方法區叫做“非堆”。方法區對應了堆中的永久代。因此在Java8以及之後版本中,永久代被抹除了,方法區也移到了後設資料空間(metaspace)中。
  • 執行時常量池:各執行緒公有。用於存放類資訊中的常量(字面量、符號引用等),每個類編譯後的資訊中的都有一個常量池,可以透過javap -vebose xxxx.class命令來檢視。
  • 直接記憶體:程式間公有。直接記憶體不屬於Java虛擬機器執行時資料區的一部分,它是指作業系統分配給虛擬機器以及其他程式所執行的那塊記憶體區域,之所以這麼說,是因為很多伺服器都是虛擬機器(作業系統級別),對於物理機來說,這塊記憶體就是指作業系統所管控的實體記憶體。透過在堆中建立一個DirectByteBuffer例項來對直接記憶體進行訪問。

很多讀者瞭解完這些後還是雲裡霧裡,各論壇還是會出現各種沒有定論的問題,比如

  1. 字串常量池屬於哪個資料區?書中對字串常量池和執行時常量池描述的相當晦澀和模糊。
  2. Java6、Java7和Java8的執行時記憶體資料區域到底有何不一樣?
  3. 什麼是字面量,什麼又是字串常量?
  4. 什麼是 本地記憶體?他和 直接記憶體相同嘛?什麼又是 堆外記憶體

下面我們圍繞這幾個問題做一些討論和引申,從而幫助我們更好的理解執行時資料區域劃分。

2 字串常量池

我們先來回答第一和第二個問題。

2.1 字串常量池在哪

在不同的Java版本中,規範規定的字串常量池的位置也不一樣。以下三張圖分別代表了Java6、Java7和Java8體系下的Java虛擬機器與執行時資料區域劃分,哪些是執行緒私有,哪些是執行緒公有,哪些又是程式間公有都比較清晰了。

2.1.1 Java 6 虛擬機器執行資料區

 

當我們聽到“字串常量池也是方法區的一部分”的時候,我們要知道他大概暗指的是Java 6或者之前的版本。如上圖所示,在Java 6虛擬機器規範中,字串常量池確實是方法區的一部分,受永久代記憶體區大小的限制。當頻繁使用Spring.intern()時,可能會引發OOM(PermGen space)。

2.1.2 Java 7 虛擬機器執行資料區

 

從Java 7 開始,規範將 字串常量池遷移到了 Java堆中,受Java堆大小的限制。當頻繁大量使用String.intern()時,可能會引發OOM(Java heap space)。

2.1.3 Java 8 虛擬機器執行資料區

 

Java 8 虛擬機器規範徹底移除了永久代(-XX:Permsize和-XX:MaxPermsize均已失效),替而代之的則是 元空間(Metaspace)字串常量池仍然在 Java堆中,但方法區已經遷移到了元空間中。這時候由於濫用 String.intern()引發的OOM依舊在Java堆中。

2.2 字串常量池是啥

那麼字串常量池的資料結構是怎麼實現的呢?答案是HashMap,每個字串常量池對應了一個StringTable的資料結構,其本質並不是Table,而是一個HashMap。這個HashMap的容量是固定的(預設1009),可以透過 -XX:StringTableSize來設定,注意這個值是指雜湊表中桶的數量,不是佔用記憶體的大小。所以這個值最好是一個質數,並且要大於預設的1009[2]。

3 字面量和字串常量

如以下程式碼:

String str = "123";

其中”123”就是我們經常看到的“字面量”。字面量是隨著Class資訊等在類被載入完畢後一起進入 執行時常量池的。 而

String str2 = str.intern();

這句程式碼則嘗試將str的值放入字串常量池,然而”123”已經在類資訊的常量池中了,所以StringTable實際記錄的是類資訊常量池中該字串的引用。

對於語句:

String str = new StringBuilder("hello").append(" world").toString().intern();

這會將新建立的“hello world”的堆內物件引用(str)放入到 字串常量池中,因為這是第一次出現,沒有其他地方存在該值的引用。

4 本地記憶體和直接記憶體

首先需要說明的是, 本地記憶體(Native Memory)和 堆外記憶體(Off-heap Memory)的含義是一樣的。而關於 直接記憶體本地記憶體的關係,StackOverflow上也沒有說清楚的帖子,第二部分中的三張圖已經可以很好的說明直接記憶體和本地記憶體的關係了,所謂的本地記憶體是作業系統分配給JVM虛擬機器(作為一個程式)使用的記憶體塊中除去堆的那一部分。而直接記憶體則是所有程式共享的作業系統所控制的記憶體。所以可以這麼說:本地記憶體和直接記憶體的關係就像“蘋果”和“水果”的關係,蘋果屬於水果,是水果更具體的限定。Java8中的元空間就屬於本地記憶體空間,而他們都是直接記憶體的一部分。 透過DirectByteBuffer分配的記憶體區域一定在本地記憶體中,它也受直接記憶體大小的限制。本地記憶體的大小也有限制,比如Window中對每個程式執行所需的記憶體大小做了2G的預設限制,這隻時候其上執行的JVM的本地記憶體大小≈2G-JVM堆記憶體大小。

5 字串常量池所屬資料區的具體說明

下面我們舉2個例子討論下在Java6和Java7(含之後版本)下字串常量池遷移帶來的變化

5.1 例子1

請給出以下程式碼丟擲異常的型別:

import java.util.ArrayList;import java.util.List;public class Test {	public static void main(String[] args){		List<String> list = new ArrayList<String>();		int i = 0;		while(true) {			list.add( String.valueOf(i++).intern());		}	}}

然後啟動引數中我們加上:

-XX:PermSize=10M -XX:MaxPermSize=10M

分析下這個程式碼,其意圖在於不斷的產生新的字串,並且放入 字串常量池中,試圖撐爆永久代。然而這隻會在Java 6 中發生,對於Java7和Java8來說,字串常量池已經遷移到了 Java堆中,如果這時候我們新增以下虛擬機器引數:

-Xms10M -Xmx10M

則會引發:java.lang.OutOfMemoryError: GC overhead limit exceeded 這樣的錯誤,這個異常的本質與 OOM(Heap space)一直,都是堆記憶體溢位。

5.2 例子2

以下程式碼在Java6和Java7中輸出也不相同:

public class TestStringConstantPool {	public static String hello = "Hello Java";		public static void main(String[] args) {				String str1 = new StringBuilder("Hello ").append("World").toString();		System.out.println(str1.intern() == str1);				String str2 = new StringBuilder("Hello ").append("Java").toString();		System.out.println(str2.intern() == str2);	}}

在Java6中會輸出:

falsefalse

在Java7中則輸出:

truefalse

首先我們分析下Java6中的場景,Java6中字串常量池還是執行時常量池的一部分,所以使用String.intern()時,會把堆中的字串複製到方法區中,返回的是方法區中的物件引用。所以不管如何,堆中物件和方法區中物件應用都不會想等。 而在Java7中,這個情況發生了變化,字串常量池轉移到了堆中,對於 str1來說,字串常量池StringTable會記錄其在堆中的引用(即str1)。所以 str1.intern() == str1成立。而 str2情況則不一樣了,因為 “Hello Java”字串已經存在於方法區的執行時常量池中,所以intern()返回的是方法區中的物件引用。所以 str2.intern() == str2不成立。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31478593/viewspace-2714845/,如需轉載,請註明出處,否則將追究法律責任。

相關文章