天天看點

JVM運作時記憶體資料區域

JVM運作時記憶體資料區域

1 讨論背景

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

JVM運作時記憶體資料區域

按照《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 虛拟機運作資料區

JVM運作時記憶體資料區域

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

2.1.2 Java 7 虛拟機運作資料區

JVM運作時記憶體資料區域

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

2.1.3 Java 8 虛拟機運作資料區

JVM運作時記憶體資料區域

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中會輸出:

false
false
           

在Java7中則輸出:

true
 false
           

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

繼續閱讀