JVM運行時數據區域
一、運行時數據區域
- 相應腦圖
程序計數器
記錄正在執行的虛擬機字節碼指令的地址(如果正在執行的是本地方法則為空)。
Java 虛擬機棧
每個 Java 方法在執行的同時會創建一個棧幀用於存儲局部變量表、操作數棧、常量池引用等信息。 從方法調用直至執行完成的過程,就對應著一個棧幀在 Java 虛擬機棧中入棧和出棧的過程。 對於執行引擎來說,活動線程中,只有棧頂的棧幀是有效的,稱為當前棧幀,這個棧幀所關聯的方法稱為當前方法。 執行引擎所運行的所有字節碼指令都只針對當前棧幀進行操作。
操作數棧: 一個後進先出(Last-In-First-Out)的操作數棧,也可以稱之為表達式棧(Expression Stack)。 操作數棧和局部變量表在訪問方式上存在著較大差異,操作數棧並非采用訪問索引的方式來進行數據訪問的, 而是**通過標準的入棧和出棧操作來完成一次數據訪問**。 每一個操作數棧都會擁有一個明確的棧深度用於存儲數值,一個32bit的數值可以用一個單位的棧深度來存儲,而2個單位的棧深度則可以保存一個64bit的數值, 當然操作數棧所需的容量大小在編譯期就可以被完全確定下來,並保存在方法的Code屬性中。
可以通過 -Xss 這個虛擬機參數來指定每個線程的 Java 虛擬機棧內存大小:
java -Xss512M HackTheJava
該區域可能拋出以下異常:
- 當線程請求的棧深度超過最大值,會拋出 StackOverflowError 異常;
- 棧進行動態擴展時如果無法申請到足夠內存,會拋出 OutOfMemoryError 異常。
本地方法棧
本地方法棧與 Java 虛擬機棧類似,它們之間的區別只不過是本地方法棧為本地方法服務。
本地方法一般是用其它語言(C、C++ 或匯編語言等)編寫的,並且被編譯為基於本機硬件和操作系統的程序,對待這些方法需要特別處理。
堆
所有對象都在這裏分配內存,是垃圾收集的主要區域("GC 堆")。
現代的垃圾收集器基本都是采用分代收集算法,其主要的思想是針對不同類型的對象采取不同的垃圾回收算法,可以將堆分成兩塊:
- 新生代(Young Generation)
- 老年代(Old Generation)
堆不需要連續內存,並且可以動態增加其內存,增加失敗會拋出 OutOfMemoryError 異常。
可以通過 -Xms 和 -Xmx 兩個虛擬機參數來指定一個程序的堆內存大小,第一個參數設置初始值,第二個參數設置最大值。
java -Xms1M -Xmx2M HackTheJava
方法區
用於存放已被加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。
和堆一樣不需要連續的內存,並且可以動態擴展,動態擴展失敗一樣會拋出 OutOfMemoryError 異常。
對這塊區域進行垃圾回收的主要目標是對常量池的回收和對類的卸載,但是一般比較難實現。
HotSpot 虛擬機把它當成永久代來進行垃圾回收。但是很難確定永久代的大小,因為它受到很多因素影響,並且每次 Full GC 之後永久代的大小都會改變,所以經常會拋出 OutOfMemoryError 異常。為了更容易管理方法區,從 JDK 1.8 開始,移除永久代,並把方法區移至元空間,它位於本地內存中,而不是虛擬機內存中。
運行時常量池
運行時常量池是方法區的一部分。
Class 文件中的常量池(編譯器生成的各種字面量和符號引用)會在類加載後被放入這個區域。
除了在編譯期生成的常量,還允許動態生成,例如 String 類的 intern()。
直接內存
在 JDK 1.4 中新加入了 NIO 類,它可以使用 Native 函數庫直接分配堆外內存(Native 堆),然後通過一個存儲在 Java 堆裏的 DirectByteBuffer 對象作為這塊內存的引用進行操作。
這樣能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆中來回復制數據。
二、HotSpot虛擬機對象
對象的創建
對象的創建步驟:
- 類加載檢查
虛擬機遇到一條 new 指令時,首先將去檢查這個指令的參數是否能在常量池中定位到這個類的符號引用, 並且檢查這個符號引用代表的類是否已被加載過、解析和初始化過。 如果沒有,那必須先執行相應的類加載過程。
- 分配內存
在類加載檢查通過後,接下來虛擬機將為新生對象分配內存。 對象所需的內存大小在類加載完成後便可確定,為對象分配空間的任務等同於把一塊確定大小的內存從 Java 堆中劃分出來。分配方式有 “指針碰撞” 和 “空閑列表” 兩種,選擇那種分配方式由 Java 堆是否規整決定, 而Java堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。
- 內存分配的兩種方式
內存分配的兩種方式 | 指針碰撞 | 空閑列表 |
---|---|---|
適用場景 | 堆內存規整(即沒有內存碎片)的情況 | 堆內存不規整的情況 |
原理 | 用過的內存全部整合到一邊,沒有用過的內存放在另一邊,中間有一個分界值指針,只需要向著沒用過的內存方向將指針移動一段與對象大小相等的距離 | 虛擬機會維護一個列表,在該列表和總分記錄哪些內存塊是可用的,在分配的時候,找一塊足夠大的內存塊劃分給對象示例,然後更新列表記錄 |
GC收集器 | Serial ParNew | CMS |
- 內存分配並發問題
在創建對象的時候有一個很重要的問題,就是線程安全,因為在實際開發過程中,創建對象是很頻繁的事情, 作為虛擬機來說,必須要保證線程是安全的,通常來講,虛擬機采用兩種方式來保證線程安全:
(1)CAS+失敗重試: CAS 是樂觀鎖的一種實現方式。所謂樂觀鎖就是, 每次不加鎖而是假設沒有沖突而去完成某項操作, 如果因為沖突失敗就重試,直到成功為止。 虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性。
(2)TLAB: 每一個線程預先在Java堆中分配一塊內存,稱為本地線程分配緩沖(Thread Local Allocation Buffer,TLAB)。 哪個線程要分配內存,就在哪個線程的TLAB上分配,只有TLAB用完並分配新的TLAB時,才采用上述的CAS進行內存分配。
- 初始化零值
內存分配完成後,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭), 這一步操作保證了對象的實例字段在 Java 代碼中可以不賦初始值就直接使用, 程序能訪問到這些字段的數據類型所對應的零值。
- 設置對象頭
初始化零值完成之後,虛擬機要對對象進行必要的設置, 例如這個對象是那個類的實例、如何才能找到類的元數據信息、對象的哈希嗎、對象的 GC 分代年齡等信息。 這些信息存放在對象頭中。 另外,根據虛擬機當前運行狀態的不同,如是否啟用偏向鎖等,對象頭會有不同的設置方式。
- 執行init方法
在上面工作都完成之後,從虛擬機的視角來看,一個新的對象已經產生了, 但從 Java 程序的視角來看,對象創建才剛開始,<init> 方法還沒有執行,所有的字段都還為零。 所以一般來說,執行 new 指令之後會接著執行 <init > 方法, 把對象按照程序員的意願進行初始化,這樣一個真正可用的對象才算完全產生出來。
對象的內存布局
在 Hotspot 虛擬機中,對象在內存中的布局可以分為3塊區域:
(1)對象頭
(2)實例數據
(3)對齊填充
- 對象頭
Hotspot虛擬機的對象頭包括兩部分信息:
一部分用於存儲對象自身的運行時數據(哈希碼、GC分代年齡、鎖狀態標誌等等),
另一部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是那個類的實例。
- 實例數據
實例數據部分是對象真正存儲的有效信息,也是在程序中所定義的各種類型的字段內容。
- 對齊填充
對齊填充部分不是必然存在的,也沒有什麽特別的含義,僅僅起占位作用。 因為Hotspot虛擬機的自動內存管理系統要求對象起始地址必須是8字節的整數倍, 換句話說就是對象的大小必須是8字節的整數倍。而對象頭部分正好是8字節的倍數(1倍或2倍), 因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。
對象的訪問定位
建立對象就是為了使用對象,我們的Java程序通過棧上的 reference 數據來操作堆上的具體對象。 對象的訪問方式視虛擬機的實現而定,目前主流的訪問方式有兩種:
(1)使用句柄
(2)直接指針
- 使用句柄
如果使用句柄的話,那麽Java堆中將會劃分出一塊內存來作為句柄池, reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息
- 直接指針
如果使用直接指針訪問,那麽Java 堆對象的布局中就必須考慮如何放置訪問類型數據的相關信息, 而reference中存儲的直接就是對象的地址。
這兩種對象訪問方式各有優勢:
(1)使用句柄來訪問的最大好處是 reference 中存儲的是穩定的句柄地址, 在對象被移動時只會改變句柄中的實例數據指針,而reference本身不需要修改。
(2)使用直接指針訪問方式最大的好處就是速度快,它節省了一次指針定位的時間開銷。
三、String類和常量池
- String對象的兩種創建方式
String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2);//false
這兩種不同的創建方法是有差別的:
第一種方式是在常量池中獲取對象("abcd" 屬於字符串字面量,因此編譯時期會在常量池中創建一個字符串對象),
第二種方式一共會創建兩個字符串對象(前提是 String Pool 中還沒有 "abcd" 字符串對象)。
-
"abcd" 屬於字符串字面量,因此編譯時期會在常量池中創建一個字符串對象,指向這個 "abcd" 字符串字面量;
-
使用 new 的方式會在堆中創建一個字符串對象。
- String類型的常量池比較特殊。它的主要使用方法有兩種:
-
直接使用雙引號聲明出來的String對象會直接存儲在常量池中。
-
如果不是用雙引號聲明的String對象,可以使用 String 提供的 intern 方法。 String.intern() 是一個 Native 方法,它的作用是: 如果運行時常量池中已經包含一個等於此 String 對象內容的字符串,則返回常量池中該字符串的引用; 如果沒有,則在常量池中創建與此 String 內容相同的字符串,並返回常量池中創建的字符串的引用。
String s1 = new String("計算機");
String s2 = s1.intern();
String s3 = "計算機";
System.out.println(s2);//計算機
System.out.println(s1 == s2);//false,因為一個是堆內存中的String對象一個是常量池中的String對象,
System.out.println(s2 == s3);//true,因為兩個都是常量池中的String對象
- 字符串拼接:
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";//常量池中的對象
String str4 = str1 + str2; //TODO:在堆上創建的新的對象
String str5 = "string";//常量池中的對象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
註意:盡量避免多個字符串拼接,因為這樣會重新創建對象。 如果需要改變字符串的話,可以使用 StringBuilder 或者 StringBuffer。
面試題:String s1 = new String("abc");問創建了幾個對象?
創建2個字符串對象(前提是 String Pool 中還沒有 "abcd" 字符串對象)。
-
"abc" 屬於字符串字面量,因此編譯時期會在常量池中創建一個字符串對象,指向這個 "abcd" 字符串字面量;
-
使用 new 的方式會在堆中創建一個字符串對象。
(字符串常量"abc"在編譯期就已經確定放入常量池,而 Java 堆上的"abc"是在運行期初始化階段才確定)。
String s1 = new String("abc");// 堆內存的地址值
String s2 = "abc";
System.out.println(s1 == s2);// 輸出false
//因為一個是堆內存,一個是常量池的內存,故兩者是不同的。
System.out.println(s1.equals(s2));// 輸出true
四、8種基本類型的包裝類和常量池
-
Java基本類型的包裝類的大部分都實現了常量池技術, 即Byte,Short,Integer,Long,Character,Boolean; 這5種包裝類默認創建了數值**[-128,127]**的相應類型的緩存數據, 但是超出此範圍仍然會去創建新的對象。
-
兩種浮點數類型的包裝類Float,Double 並沒有實現常量池技術。
valueOf() 方法的實現比較簡單,就是先判斷值是否在緩存池中,如果在的話就直接返回緩存池的內容。
Integer的部分源碼:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
在 Java 8 中,Integer 緩存池的大小默認為 -128~127。
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
- 示例1:
Integer i1=40;
//Java 在編譯的時候會直接將代碼封裝成 Integer i1=Integer.valueOf(40);從而使用常量池中的對象。
Integer i2 = new Integer(40);
//創建新的對象。
System.out.println(i1==i2);//輸出false
- 示例2:Integer有自動拆裝箱功能
Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
System.out.println("i1=i2 " + (i1 == i2)); //輸出 i1=i2 true
System.out.println("i1=i2+i3 " + (i1 == i2 + i3)); //輸出 i1=i2+i3 true
//i2+i3得到40,比較的是數值
System.out.println("i1=i4 " + (i1 == i4)); //輸出 i1=i4 false
System.out.println("i4=i5 " + (i4 == i5)); //輸出 i4=i5 false
//i5+i6得到40,比較的是數值
System.out.println("i4=i5+i6 " + (i4 == i5 + i6)); //輸出 i4=i5+i6 true
System.out.println("40=i5+i6 " + (40 == i5 + i6)); //輸出 40=i5+i6 true
JVM運行時數據區域