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實體來對直接記憶體進行訪問,
很多讀者了解完這些后還是云里霧里,各論壇還是會出現各種沒有定論的問題,比如
- 字串常量池屬于哪個資料區?書中對字串常量池和運行時常量池描述的相當晦澀和模糊,
- Java6、Java7和Java8的運行時記憶體資料區域到底有何不一樣?
- 什么是字面量,什么又是字串常量?
- 什么是本地記憶體?他和直接記憶體相同嘛?什么又是堆外記憶體?
下面我們圍繞這幾個問題做一些討論和引申,從而幫助我們更好的理解運行時資料區域劃分,
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中會輸出:
false
false
在Java7中則輸出:
true
false
首先我們分析下Java6中的場景,Java6中字串常量池還是運行時常量池的一部分,所以使用String.intern()時,會把堆中的字串復制到方法區中,回傳的是方法區中的物件參考,所以不管如何,堆中物件和方法區中物件應用都不會想等, 而在Java7中,這個情況發生了變化,字串常量池轉移到了堆中,對于str1來說,字串常量池StringTable會記錄其在堆中的參考(即str1),所以str1.intern() == str1成立,而str2情況則不一樣了,因為“Hello Java”字串已經存在于方法區的運行時常量池中,所以intern()回傳的是方法區中的物件參考,所以str2.intern() == str2不成立,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/47205.html
標籤:Java
上一篇:常見排序演算法
下一篇:JavaSE基礎--part1
