JVM執行時資料區域

李紅歐巴發表於2019-05-08

一、執行時資料區域

JVM執行時資料區域

程式計數器

記錄正在執行的虛擬機器位元組碼指令的地址(如果正在執行的是本地方法則為空)。

Java 虛擬機器棧

每個 Java 方法在執行的同時會建立一個棧幀用於儲存區域性變數表運算元棧常量池引用等資訊。 從方法呼叫直至執行完成的過程,就對應著一個棧幀在 Java 虛擬機器棧中入棧和出棧的過程。 對於執行引擎來說,活動執行緒中,只有棧頂的棧幀是有效的,稱為當前棧幀,這個棧幀所關聯的方法稱為當前方法。 執行引擎所執行的所有位元組碼指令都只針對當前棧幀進行操作。

JVM執行時資料區域

運算元棧:
一個後進先出(Last-In-First-Out)的運算元棧,也可以稱之為表示式棧(Expression Stack)。
運算元棧和區域性變數表在訪問方式上存在著較大差異,運算元棧並非採用訪問索引的方式來進行資料訪問的,
而是**通過標準的入棧和出棧操作來完成一次資料訪問**。
每一個運算元棧都會擁有一個明確的棧深度用於儲存數值,一個32bit的數值可以用一個單位的棧深度來儲存,而2個單位的棧深度則可以儲存一個64bit的數值,
當然運算元棧所需的容量大小在編譯期就可以被完全確定下來,並儲存在方法的Code屬性中。複製程式碼

可以通過 -Xss 這個虛擬機器引數來指定每個執行緒的 Java 虛擬機器棧記憶體大小:

java -Xss512M HackTheJava複製程式碼

該區域可能丟擲以下異常:

  • 當執行緒請求的棧深度超過最大值,會丟擲 StackOverflowError 異常;
  • 棧進行動態擴充套件時如果無法申請到足夠記憶體,會丟擲 OutOfMemoryError 異常。

本地方法棧

本地方法棧與 Java 虛擬機器棧類似,它們之間的區別只不過是本地方法棧為本地方法服務。

本地方法一般是用其它語言(C、C++ 或組合語言等)編寫的,並且被編譯為基於本機硬體和作業系統的程式,對待這些方法需要特別處理。

JVM執行時資料區域

所有物件都在這裡分配記憶體,是垃圾收集的主要區域("GC 堆")。

現代的垃圾收集器基本都是採用分代收集演算法,其主要的思想是針對不同型別的物件採取不同的垃圾回收演算法,可以將堆分成兩塊:

  • 新生代(Young Generation)
  • 老年代(Old Generation)

堆不需要連續記憶體,並且可以動態增加其記憶體,增加失敗會丟擲 OutOfMemoryError 異常。

可以通過 -Xms 和 -Xmx 兩個虛擬機器引數來指定一個程式的堆記憶體大小,第一個引數設定初始值,第二個引數設定最大值。

java -Xms1M -Xmx2M HackTheJava複製程式碼

JVM執行時資料區域

方法區

用於存放已被載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

和堆一樣不需要連續的記憶體,並且可以動態擴充套件,動態擴充套件失敗一樣會丟擲 OutOfMemoryError 異常。

對這塊區域進行垃圾回收的主要目標是對常量池的回收和對類的解除安裝,但是一般比較難實現。

HotSpot 虛擬機器把它當成永久代來進行垃圾回收。但是很難確定永久代的大小,因為它受到很多因素影響,並且每次 Full GC 之後永久代的大小都會改變,所以經常會丟擲 OutOfMemoryError 異常。為了更容易管理方法區,從 JDK 1.8 開始,移除永久代,並把方法區移至元空間,它位於本地記憶體中,而不是虛擬機器記憶體中。

執行時常量池

執行時常量池是方法區的一部分。

Class 檔案中的常量池(編譯器生成的各種字面量和符號引用)會在類載入後被放入這個區域。

除了在編譯期生成的常量,還允許動態生成,例如 String 類的 intern()。

直接記憶體

在 JDK 1.4 中新加入了 NIO 類,它可以使用 Native 函式庫直接分配堆外記憶體(Native 堆),然後通過一個儲存在 Java 堆裡的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作。

這樣能在一些場景中顯著提高效能,因為避免了在 Java 堆和 Native 堆中來回複製資料

二、HotSpot虛擬機器物件

物件的建立

物件的建立步驟:

JVM執行時資料區域

  1. 類載入檢查

虛擬機器遇到一條 new 指令時,首先將去檢查這個指令的引數是否能在常量池中定位到這個類的符號引用, 並且檢查這個符號引用代表的類是否已被載入過、解析和初始化過。 如果沒有,那必須先執行相應的類載入過程。

  1. 分配記憶體

在類載入檢查通過後,接下來虛擬機器將為新生物件分配記憶體。 物件所需的記憶體大小在類載入完成後便可確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從 Java 堆中劃分出來。分配方式有 “指標碰撞” 和 “空閒列表” 兩種,選擇那種分配方式由 Java 堆是否規整決定, 而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。

  • 記憶體分配的兩種方式
記憶體分配的兩種方式指標碰撞空閒列表
適用場景堆記憶體規整(即沒有記憶體碎片)的情況堆記憶體不規整的情況
原理用過的記憶體全部整合到一邊,沒有用過的記憶體放在另一邊,中間有一個分界值指標,只需要向著沒用過的記憶體方向將指標移動一段與物件大小相等的距離虛擬機器會維護一個列表,在該列表和總分記錄哪些記憶體塊是可用的,在分配的時候,找一塊足夠大的記憶體塊劃分給物件示例,然後更新列表記錄
GC收集器Serial ParNewCMS
  • 記憶體分配併發問題

在建立物件的時候有一個很重要的問題,就是執行緒安全,因為在實際開發過程中,建立物件是很頻繁的事情, 作為虛擬機器來說,必須要保證執行緒是安全的,通常來講,虛擬機器採用兩種方式來保證執行緒安全:

(1)CAS+失敗重試: CAS 是樂觀鎖的一種實現方式。所謂樂觀鎖就是, 每次不加鎖而是假設沒有衝突而去完成某項操作, 如果因為衝突失敗就重試,直到成功為止。 虛擬機器採用CAS配上失敗重試的方式保證更新操作的原子性。

(2)TLAB: 每一個執行緒預先在Java堆中分配一塊記憶體,稱為本地執行緒分配緩衝(Thread Local Allocation Buffer,TLAB)。 哪個執行緒要分配記憶體,就在哪個執行緒的TLAB上分配,只有TLAB用完並分配新的TLAB時,才採用上述的CAS進行記憶體分配。

  1. 初始化零值

記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭), 這一步操作保證了物件的例項欄位在 Java 程式碼中可以不賦初始值就直接使用, 程式能訪問到這些欄位的資料型別所對應的零值。

  1. 設定物件頭

初始化零值完成之後,虛擬機器要對物件進行必要的設定, 例如這個物件是那個類的例項、如何才能找到類的後設資料資訊、物件的雜湊嗎、物件的 GC 分代年齡等資訊。 這些資訊存放在物件頭中。 另外,根據虛擬機器當前執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式。

  1. 執行init方法

在上面工作都完成之後,從虛擬機器的視角來看,一個新的物件已經產生了, 但從 Java 程式的視角來看,物件建立才剛開始,<init> 方法還沒有執行,所有的欄位都還為零。 所以一般來說,執行 new 指令之後會接著執行 <init > 方法, 把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全產生出來。

物件的記憶體佈局

在 Hotspot 虛擬機器中,物件在記憶體中的佈局可以分為3塊區域:

(1)物件頭

(2)例項資料

(3)對齊填充

  1. 物件頭

Hotspot虛擬機器的物件頭包括兩部分資訊:

一部分用於儲存物件自身的執行時資料(雜湊碼、GC分代年齡、鎖狀態標誌等等),

另一部分是型別指標,即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是那個類的例項

  1. 例項資料

例項資料部分是物件真正儲存的有效資訊,也是在程式中所定義的各種型別的欄位內容。

  1. 對齊填充

對齊填充部分不是必然存在的,也沒有什麼特別的含義,僅僅起佔位作用。 因為Hotspot虛擬機器的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍, 換句話說就是物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的倍數(1倍或2倍), 因此,當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。

物件的訪問定位

建立物件就是為了使用物件,我們的Java程式通過棧上的 reference 資料來操作堆上的具體物件。 物件的訪問方式視虛擬機器的實現而定,目前主流的訪問方式有兩種:

(1)使用控制程式碼

(2)直接指標

  1. 使用控制程式碼

如果使用控制程式碼的話,那麼Java堆中將會劃分出一塊記憶體來作為控制程式碼池, reference中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與型別資料各自的具體地址資訊

JVM執行時資料區域

  1. 直接指標

如果使用直接指標訪問,那麼Java 堆物件的佈局中就必須考慮如何放置訪問型別資料的相關資訊, 而reference中儲存的直接就是物件的地址

JVM執行時資料區域

這兩種物件訪問方式各有優勢:

(1)使用控制程式碼來訪問的最大好處是 reference 中儲存的是穩定的控制程式碼地址, 在物件被移動時只會改變控制程式碼中的例項資料指標,而reference本身不需要修改

(2)使用直接指標訪問方式最大的好處就是速度快,它節省了一次指標定位的時間開銷。

三、String類和常量池

  1. String物件的兩種建立方式
String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2);//false複製程式碼

這兩種不同的建立方法是有差別的:

第一種方式是在常量池中獲取物件("abcd" 屬於字串字面量,因此編譯時期會在常量池中建立一個字串物件),

第二種方式一共會建立兩個字串物件(前提是 String Pool 中還沒有 "abcd" 字串物件)。

  • "abcd" 屬於字串字面量,因此編譯時期會在常量池中建立一個字串物件,指向這個 "abcd" 字串字面量;

  • 使用 new 的方式會在堆中建立一個字串物件。

JVM執行時資料區域

  1. 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物件複製程式碼
  1. 字串拼接:
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複製程式碼

JVM執行時資料區域

注意:儘量避免多個字串拼接,因為這樣會重新建立物件。 如果需要改變字串的話,可以使用 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執行時資料區域

相關文章