深入JVM-內存模型

本文討論以 JDK8 版本展開

Java虛擬機棧

棧幀

棧幀:棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構。棧幀存儲了方法的局部變量表、操作數棧、動態連接、方法返回地址和附件信息。每一個方法從調用至執行完成的過程,都對應着一個棧幀在虛擬機棧裏從入棧到出棧的過程。

棧對應線程,棧幀對應方法

在活動線程中, 只有位於棧頂的幀纔是有效的。稱爲當前棧幀,正在執行的方法稱爲當前方法,定義當前方法的類是當前類。在執行引擎運行時,所有指令都只能針對當前棧幀進行操作。而StackOverflowError 表示請求的棧溢出,導致內存耗盡,通常出現在遞歸方法中。
虛擬機棧通過pop和push的方式,對每個方法對應的活動棧幀進行運算處理,方法正常執行結束,肯定會跳轉到另一個棧幀上。在執行的過程中,如果出現了異常,會進行異常回溯,返回地址通過異常處理表確定。
如果當前方法調用另一個方法完成,則該方法將不再是當前方法。調用方法時,將創建新棧幀,並在控制權轉移到新方法時對應的棧幀。在方法返回時,當前棧幀將其方法調用的結果(如果有的話)傳遞迴前一幀。當前一幀變爲當前幀時,當前幀將被丟棄。
在這裏插入圖片描述

局部變量

每個棧幀都包含一個稱爲局部變量的變量數組,存放方法參數和方法內部定義的局部變量的區域。

局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。
局部變量表中的變量不可直接使用,如需要使用的話,必須通過相關指令將其加載至操作數棧中作爲操作數使用。
Java虛擬機使用局部變量在方法調用時傳遞參數。在類方法調用時,所有參數都將從連續的局部變量(從局部變量0開始)傳遞。在調用實例方法時,始終使用局部變量0將引用傳遞給在其上調用實例方法的對象。隨後將任何參數傳遞到從局部變量1開始的連續局部變量中。

public int test(int a, int b) {
    Object obj = new Object();
    return a + b;
}

如果局部變量是Java的8種基本基本數據類型,則存在局部變量表中,如果是引用類型。如new出來的String,局部變量表中存的是引用,而實例在堆中。
在這裏插入圖片描述

操作數棧

每個棧幀均包含一個後進先出(LIFO)堆棧,稱爲其操作數堆棧。框架的最大操作數堆棧深度是在編譯時確定的。

Java虛擬機的解釋執行引擎稱爲“基於棧的執行引擎”,其中所指的“棧”就是操作數棧。當JVM爲方法創建棧幀的時候,在棧幀中爲方法創建一個操作數棧,保證方法內指令可以完成工作。
創建包含操作數堆棧的棧幀時,該操作數堆棧爲空。Java虛擬機提供了將局部變量或字段中的常量或值加載到操作數堆棧上的指令。其他Java虛擬機指令從操作數堆棧中獲取操作數,對其進行操作,然後將結果壓回操作數堆棧。操作數堆棧還用於準備要傳遞給方法的參數並接收方法結果。

public class OperandStackTest {

    public int sum(int a, int b) {
        return a + b;
    }
}

編譯生成.class文件之後,再反彙編查看彙編指令

> javac OperandStackTest.java
> javap -v OperandStackTest.class > 1.txt

例如,IADD 指令將兩個int值加在一起。它要求int將要相加的值是操作數堆棧的前兩個值,並由前面的指令壓入該值。這兩個int值都從操作數堆棧中彈出。將它們相加,然後將它們的總和推回操作數堆棧。子計算可嵌套在操作數堆棧上,從而產生可被包含計算使用的值。

  public int sum(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3 // 最大棧深度爲2 局部變量個數爲3
         0: iload_0 // 從[局部變量0]中裝載int類型值入棧
         1: iload_1 // 從[局部變量1]中裝載int類型值入棧
         2: iadd    // 將棧頂元素彈出棧,執行int類型的加法,結果入棧
         3: ireturn //從方法中返回int類型的數據
      LineNumberTable:
        line 10: 0

動態鏈接

每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程中的動態連接(Dynamic Linking)。

動態鏈接將這些符號引用轉換爲具體的方法引用,根據需要加載類以解析尚未定義的符號,並將變量訪問轉換爲與這些變量的運行時位置關聯的存儲結構中的適當偏移量。

方法返回地址

當一個方法開始執行後,只有兩種方式可以退出,一種是遇到方法返回的字節碼指令;一種是遇見異常,並且 這個異常沒有在方法體內得到處理。

無論何種退出情況,都將返回至方法當前被調用的位置。方法退出的過程相當於彈出當前棧幀,退出可能有三種方式:

  1. 返回值壓入上層調用棧幀
  2. 異常信息拋給能夠處理的棧幀
  3. PC 計數器指向方法調用後的下一條指令

Java對象內存佈局

Java虛擬機棧的局部變量中引用類型是指向堆中的對象,元數據區中的靜態變量如果是引用類型也會指向堆中的對象。
那堆中的對象會指向元數據區嘛?元數據區中會包含類的信息,堆中會有對象,那怎麼知道對象是哪個類創建的呢?

一個Java對象在內存中包括3個部分:對象頭、實例數據和對齊填充。
在這裏插入圖片描述

對象頭

Mark Word:對象自身的運行時數據(Mark Word)

  • 如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等
  • 該部分數據被設計成1個非固定的數據結構 以便在極小的空間存儲儘量多的信息(會根據對象狀態複用存儲空間)

Class Pointer:對象類型指針

  • 即對象指向它的類元數據的指針
  • 虛擬機通過這個指針來確定這個對象是哪個類的實例

Length:保存數組長度

  • 如果對象是數組,那麼在對象頭中還必須有一塊用於記錄數組長度的數據。
  • 因爲虛擬機可以通過普通Java對象的元數據信息確定對象的大小,但是從數組的元數據中卻無法確定數組的大小。

實例數據

存儲的信息:對象真正有效的信息

  • 包括了對象的所有成員變量,其大小由各個成員變量的大小決定,比如:byte和boolean是1個字節,short和char是2個字節,int和float是4個字節,long和double是8個字節,reference是4個字節(64位系統中是8個字節)。

這部分數據的存儲順序會受到虛擬機分配參數(FieldAllocationStyle)和字段在Java源碼中定義順序的影響。

// HotSpot虛擬機默認的分配策略如下:
longs/doubles、ints、shorts/chars、bytes/booleans、oop(Ordinary Object Pointers)
// 從分配策略中可以看出,相同寬度的字段總是被分配到一起
// 在滿足這個前提的條件下,父類中定義的變量會出現在子類之前

CompactFields = true;
// 如果 CompactFields 參數值爲true,那麼子類之中較窄的變量也可能會插入到父類變量的空隙之中。

對齊填充

存儲的信息:佔位符

  • 佔位作用
  • Java對象佔用空間是8字節對齊的,即所有Java對象佔用bytes數必須是8的倍數。例如,一個包含兩個屬性的對象:int和byte,這個對象需要佔用8+4+1=13個字節,這時就需要加上大小爲3字節的padding進行8字節對齊,最終佔用大小爲16個字節。

總結

在這裏插入圖片描述

內存模型

在這裏插入圖片描述

認識

  • 一塊是非堆區,一塊是堆區。
  • 堆區分爲兩大塊,一個是Old區,一個是Young區。
  • Young區分爲兩大塊,一個是Survivor區(S0+S1),一塊是Eden區。Eden:S0:S1=8:1:1 S0和S1一樣大,也可以叫From和To。

Eden區

一般情況下,新創建的對象都會被分配到Eden區,一些特殊的大的對象會直接分配到Old區。

比如有對象A,B,C等創建在Eden區,但是Eden區的內存空間肯定有限,比如有100M,假如已經使用了 100M或者達到一個設定的臨界值,這時候就需要對Eden內存空間進行清理,即垃圾收集(Garbage Collect), 這樣的GC我們稱之爲Minor GC,Minor GC指得是Young區的GC。
經過GC之後,有些對象就會被清理掉,有些對象可能還存活着,對於存活着的對象需要將其複製到Survivor 區,然後再清空Eden區中的這些對象。

Survivor區

Survivor區分爲兩塊S0和S1,也可以叫做From和To。 在同一個時間點上,S0和S1只能有一個區有數據,另外一個是空的。

接着上面的GC來說,比如一開始只有Eden區和From中有對象,To中是空的。 此時進行一次GC操作,From區中對象的年齡就會+1,我們知道Eden區中所有存活的對象會被複制到To區,From區中還能存活的對象會有兩個去處。
若對象年齡達到之前設置好的年齡閾值,此時對象會被移動到Old區,此時Eden區和From區沒有達到閾值的 對象會被複制到To區。 此時Eden區和From區已經被清空(被GC的對象肯定沒了,沒有被GC的對象都有了各自的去處)。
這時候From和To交換角色,之前的From變成了To,之前的To變成了From。也就是說無論如何都要保證名爲To的Survivor區域是空的。
Minor GC會一直重複這樣的過程,知道To區被填滿,然後會將所有對象複製到老年代中。

Old區

一般Old區都是年齡比較大的對象,或者相對超過了某個閾值的對象。如果Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或者等於該年齡的對象就可以直接進入老年代。

在Old區也會有GC的操作,Old區的GC我們稱作爲Major GC。

默認閾值是15,我們可以通過JVM參數設置這個閾值。

空間分配擔保

在發生Minor GC之前,虛擬機會先檢查老年代中最大可用連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於將嘗試進行一次Minor GC。如果小於或者HandlePromotionFailure設置爲不允許,那這時就改爲一次Full GC。
分配擔保解釋:
新生代使用複製算法完成垃圾收集,爲了節約內存Survivor的設置的比較小,當Minor GC後如果還有大量對象存活,超過了一個Survivor的內存空間,這時就需要老年代進行分配擔保,把Survivor中無法容納的對象直接進入老年代。若虛擬機檢查老年代中最大可用連續空間大於新生代所有對象總空間那麼就能保證不需要發生Full GC,因爲老年代的內存空間夠用。反之,如果老年代中最大可用連續空間小於新生代所有對象總空間就需要在嘗試Minor GC失敗後進行Full Gc或者直接Full GC。

調優

命令 解釋
-XX:NewSize和-XX:MaxNewSize 用於設置年輕代的大小,建議設爲整個堆大小的1/3或者1/4,兩個值設爲一樣大。
-XX:InitialSurvivorRatio 用於設置新生代Eden/Survivor空間的初始比例
-XX:+PrintTenuringDistribution 這個參數用於顯示每次Minor GC時Survivor區中各個年齡段的對象的大小。
-XX:NewRatio Old區/Young區的內存比例
-XX:MaxTenuringThreshold 配置一個對象從新生代晉升到老年代的閾值(默認值是15)
-XX:+HeapDumpOnOutOfMemoryError 可以讓JVM在遇到OOM異常時,輸出堆內信息,特別是對相隔數月纔出現的OOM異常尤爲重要。

問題

  1. 如何理解Minor/Major/Full GC?

    • Minor GC:新生代
    • Major GC:老年代
    • Full GC:新生代+老年代
  2. 爲什麼需要Survivor區?只有Eden不行嗎?
    如果沒有Survivor,Eden區每進行一次Minor GC,並且沒有年齡限制的話,存活的對象就會被送到老年代。 這樣一來,老年代很快被填滿,觸發Major GC(因爲Major GC一般伴隨着Minor GC,也可以看做觸發了Full GC)。老年代的內存空間遠大於新生代,進行一次Full GC消耗的時間比Minor GC長得多。
    執行時間長有什麼壞處?頻發的Full GC消耗的時間很長,會影響大型程序的執行和響應速度。
    可能你會說,那就對老年代的空間進行增加或者較少咯。 假如增加老年代空間,更多存活對象才能填滿老年代。雖然降低Full GC頻率,但是隨着老年代空間加大,一旦發生Full GC,執行所需要的時間更長。
    假如減少老年代空間,雖然Full GC所需時間減少,但是老年代很快被存活對象填滿,Full GC頻率增加。
    所以Survivor的存在意義,就是減少被送到老年代的對象,進而減少Full GC的發生,Survivor的預篩選保證,只有經歷16 次Minor GC還能在新生代中存活的對象,纔會被送到老年代。

  3. 爲什麼需要兩個Survivor區?
    最大的好處就是解決了碎片化。也就是說爲什麼一個Survivor區不行?第一部分中,我們知道了必須設置Survivor區。假設 現在只有一個Survivor區,我們來模擬一下流程:
    剛剛新建的對象在Eden中,一旦Eden滿了,觸發一次Minor GC,Eden中的存活對象就會被移動到Survivor區。這樣繼續循環下去,下一次Eden滿了的時候,問題來了,此時進行Minor GC,Eden和Survivor各有一些存活對象,如果此時把Eden區的存活對象硬放到Survivor區,很明顯這兩部分對象所佔有的內存是不連續的,也就導致了內存碎片化。永遠有一個Survivor space是空的,另一個非空的Survivor space無碎片。

  4. 新生代中Eden:S1:S2爲什麼是8:1:1?

    • 新生代中的可用內存:複製算法用來擔保的內存爲9:1
    • 可用內存中Eden:S1區爲8:1
    • 即新生代中Eden:S1:S2 = 8:1:1

總結

在這裏插入圖片描述
上圖可以看出一個對象從出生到最後被回收的整個流程。

參考鏈接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章