JVM學習-02-Java記憶體區域與記憶體溢位異常

deyang發表於2024-12-01

第二章、Java記憶體區域與記憶體溢位異常

2.1 概述

  • 介紹Java虛擬機器記憶體的各個區域
  • 講解這些區域的作用、服務物件以及其中可能產生的問題

2.2 執行時資料區域

2.2.1 執行時資料區域

  • 程式計數器:當前執行緒所執行的位元組碼的行號指示器,每條執行緒都需要有一個獨立的程式計數器(執行緒私有),不會發生OOM。
  • 虛擬機器棧:執行緒私有,每個方法被執行的時候,Java虛擬機器都會同步建立一個棧幀用於儲存區域性變數表、運算元棧、動態連線、方法出口等資訊,會丟擲StackOverflowError和OutOfMemoryError異常。
  • 本地方法棧:與虛擬機器棧相似,為本地方法的執行服務,會丟擲StackOverflowError和OutOfMemoryError異常。
  • :執行緒共享,存放物件例項,大小可透過引數-Xmx和-Xms設定,會丟擲OutOfMemoryError異常。
  • 方法區:執行緒共享,用於儲存已被虛擬機器載入的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等資料。
  • 執行時常量池:方法區的一部分,用於存放在類載入後Class檔案常量池表中的資訊(各種字面量與符號引用)。
  • 直接記憶體:不是虛擬機器執行時資料區的一部分,NIO使用Native函式庫直接分配堆外記憶體,然後DirectByteBuffer物件作為這塊記憶體的引用。

2.2.2 額外知識:

  • Class物件是存放在堆區的,不是方法區,這點很多人容易犯錯。類的後設資料(後設資料並不是類的Class物件。Class物件是載入的最終產品,類的方法程式碼,變數名,方法名,訪問許可權,返回值等等都是在方法區的)才是存在方法區的。

  • 程式計數器(PC) 不存在Error(溢位情況) 不存在GC(垃圾回收)
    虛擬機器棧(JVM) :存在Error(溢位情況) 不存在GC(垃圾回收)
    本地方法棧(NM)::存在Error(溢位情況) 不存在GC(垃圾回收)
    堆空間(Heap)::存在Error(溢位情況) 存在GC(垃圾回收)
    方法區(Method Area)::存在Error(溢位情況) 存在GC(垃圾回收)

    總結:

    • 不存在Error(溢位情況) :程式計數器(PC)
    • 不存在GC(垃圾回收):程式計數器(PC)、虛擬機器棧(JVM)、本地方法棧(NM)
  • 堆中:Class物件、字串常量池、靜態成員(在Class物件內),在JDK 1.7的HotSpot實現中,原本放在方法區中的靜態變數、字串常量池等被移到了堆記憶體中

    • java.lang.Class 物件和 static 成員變數在執行時記憶體的位置。這裡先給出結論,JDK 1.8 中,兩者都位於堆(Heap),且static 成員變數位於 Class物件內。

    • Class物件是存放在堆區的,不是方法區,這點很多人容易犯錯。類的後設資料(後設資料並不是類的Class物件!Class物件是載入的最終產品,類的方法程式碼,變數名,方法名,訪問許可權,返回值等等都是在方法區的)才是存在方法區的。

2.2.3 jdk6,7,8三個版本的記憶體模型

在JDK 1.7的HotSpot實現中,原本放在方法區中的靜態變數、字串常量池等被移到了堆記憶體中

補充:類的例項物件、類的後設資料、類的class物件關係圖

Class物件儲存在Java堆中_class物件在堆還是方法區-CSDN部落格

Person類的例項(Oop)-->Person類的後設資料()< === >Person類的class物件

2.2.4 java棧溢位分析

從一道面試題開始學習JVM:Java最大棧深度有多大?_棧的深度-CSDN部落格

一文讀懂Java虛擬機器棧 (baidu.com)

-Xss設定執行緒棧大小

  • 執行緒棧大小越大,能夠支援越多的方法呼叫,也即是能夠儲存更多的棧幀,深度就越大
  • 區域性變數表內容越多,那麼棧幀就越大,棧深度就越小。

棧大小(-Xss設定) = 棧幀大小 * 棧深度

測試:

public class StackTest {

    private int count = 0;

    public void recursiveCalls(String a){
        count++;
        System.out.println("stack depth: " + count);
        recursiveCalls(a);
    }

    public void test(){
        try {
            recursiveCalls("a");
        } catch (Exception e) {
            System.out.println(e);
        }
    }

    public static void main(String[] args) {
        new StackTest().test();
    }
}

我們設定啟動引數

-Xms256m -Xmx256m -Xmn128m -Xss256k

輸出內容:

stack depth: 1556
Exception in thread "main" java.lang.StackOverflowError
	at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)

可以發現,棧深度為1556的時候,就報 StackOverflowError了。

接下來我們調整-Xss執行緒棧大小為 512k,輸出內容:

stack depth: 3249
Exception in thread "main" java.lang.StackOverflowError
	at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)

發現棧深度變味了3249,說明了:

隨著執行緒棧的大小越大,能夠支援越多的方法呼叫,也即是能夠儲存更多的棧幀。

區域性變數表內容越多,那麼棧幀就越大,棧深度就越小。可用相同方法測試

2.2.5 Metaspace (有待完善?感覺不是很清晰)

Java 記憶體分割槽之什麼是 CCS區 Compressed Class Space 類壓縮空間_compressedclassspacesize-CSDN部落格

JVM元空間(Metaspace) - Yungyu - 部落格園 (cnblogs.com)

Metaspace由兩大部分組成:Klass Metaspace和NoKlass Metaspace。

  • Klass Metaspace就是用來存klass的,就是class檔案在jvm裡的執行時資料結構(不過我們看到的類似A.class其實是存在heap裡的,是java.lang.Class的物件例項) ,這部分預設放在Compressed Class Pointer Space中,是一塊連續的記憶體區域,緊接著Heap,和之前的perm一樣
  • NoKlass Metaspace專門來存klass相關的其他的內容,比如method,constantPool等,可以由多塊不連續的記憶體組成。 這塊記憶體是必須的,雖然叫做NoKlass Metaspace,但是也其實可以存klass的內容,上面已經提到了對應場景。 NoKlass Metaspace在本地記憶體中分配。
 Metaspace       used 2937K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 314K, capacity 386K, committed 512K, reserved 1048576K

  • reserved:後設資料的空間保留(但不一定committed提交)的量。所謂的保留,更加接近一種記賬的概念,還沒實際分配,承諾給你,但是還沒有給你(如果記憶體不夠了,給的時候可能給不了)
  • committed: 空間塊的數量。作業系統分配真正的記憶體:正在使用的記憶體Chunk + GC回收的Free Chunk
  • capacity: 當前分配塊的後設資料的空間。分配下來正在使用的記憶體Chunk,被GC回收的(Free Chunk)不算了
    • 因為有GC的存在,有些Chunk的資料可能會被回收,那麼這些Chunk屬於committe的一部分,但不屬於capacity。
  • used:載入的類的空間量。正在使用的記憶體Chunk之和,不包括碎片記憶體。
    • 這些被分配的Chunk,基本很難被100%用完,存在碎片記憶體的情況,這些Chunk實際被使用的記憶體之和即used的大小。

2.2.6 class space (有待確認?不知對錯???感覺不是很清晰)

至於class space,要記住的是,metaspace並不是全部用來放類物件的。比如說,因為每一個ClassLoader都被分配了一塊記憶體,這塊記憶體可能並沒有被用完,於是就會有一些記憶體碎片;metaspace還需要放所謂靜態變數。所以,class space是指實際上被用於放class的那塊記憶體的和

2.2.7 可參考部落格

JVM記憶體結構簡述(JDK1.8)_jvm1.8記憶體模型-CSDN部落格

通俗易懂,一文徹底理解JVM方法區 (baidu.com)

Class物件儲存在Java堆中_class物件在堆還是方法區-CSDN部落格

《Java虛擬機器規範》閱讀(一):簡介和Java虛擬機器結構 - 朱樣年華 - 部落格園 (cnblogs.com)

2.3 HotSpot虛擬機器物件探秘

2.3.1 物件的建立

【Java】關於Java物件的建立過程_java物件建立的過程-CSDN部落格

在 Java 中,建立物件的方式有很多種,比如最常見的透過new xxx()來建立一個物件,透過反射Class.forName(xxx).newInstance()來建立物件等。其實無論是哪種建立方式,JVM 底層的執行過程是一樣的。

建立物件大致分為 5 個步驟:

  1. 檢查類是否載入(非必然步驟,如果沒有就執行類的載入);
  2. 分配記憶體;
  3. 初始化零值;
  4. 設定頭物件;
  5. 執行方法(該方法由例項成員變數宣告、例項初始化塊和構造方法組成)。
(1) 類載入檢查

當需要建立一個類的例項物件時,比如透過new xxx()方式,虛擬機器首先會去檢查這個類是否在常量池中能定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被載入、解析和初始化,如果沒有,那麼必須先執行類的載入流程;如果已經載入過了,就不會再次載入。

Q:為什麼在物件建立時,需要有這一個檢查判斷?
A:主要原因在於:類的載入,通常都是懶載入,只有當使用類的時候才會載入,所以先要有這個判斷流程。

(2) 分配記憶體

在類載入檢查透過後,接下來虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成 後便可完全確定。

虛擬機器如何在堆中分配記憶體主要有兩種方式:

  • 指標碰撞(Bump The Pointer):假設Java堆中記憶體是絕對規整的,所有被使用過的記憶體都被放在一 邊,空閒的記憶體被放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那 個指標向空閒空間方向挪動一段與物件大小相等的距離。
  • 空閒列表(Free List):如果Java堆中的記憶體並不是規整的,已被使用的記憶體和空閒的記憶體相互交錯在一起,那就沒有辦法簡單地進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄。

選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有空間壓縮整理(Compact)的能力決定。因此,當使用Serial、ParNew等帶壓縮整理過程的收集器時,系統採用的分配演算法是指標碰撞,既簡單又高效;而當使用CMS這種基於清除 (Sweep)演算法的收集器時,理論上就只能採用較為複雜的空閒列表來分配記憶體。

🍳記憶體分配時的執行緒安全問題:物件建立在虛擬機器中是非常頻繁的行為,即使僅僅修改一個指標所指向的位置,在併發情況下也並不是執行緒安全的,可能出現正在給物件A分配記憶體,指標還沒來得及修改,物件B又同時使用了原來的指標來分配記憶體的情況。

解決這個問題 有兩種可選方案:

  • CAS+重試機制:透過 CAS 操作移動指標,只有一個執行緒可以移動成功,移動失敗的執行緒重試,直到成功為止。
  • TLAB (thread local Allocation buffer):也稱為本地執行緒分配緩衝,這個處理方式思想很簡單,就是當執行緒開啟時,虛擬機器會為每個執行緒分配一塊較大的空間,然後執行緒內部建立物件的時候,就從自己的空間分配,這樣就不會有併發問題了,當執行緒自己的空間用完了才會從堆中分配記憶體,之後會轉為透過 CAS+重試機制來解決併發問題。
(3)初始化零值

記憶體分配完成之後,虛擬機器必須將分配到的記憶體空間(但不包括物件頭)都初始化為零值(比如 int 型別賦值為 0,引用型別為null等操作)。如果使用了TLAB的話,這一項工作也可以提前至TLAB分配時順便進行。這步操作保證了物件的例項欄位 在Java程式碼中可以不賦初始值就直接使用,使程式能訪問到這些欄位的資料型別所對應的零值。

(4)設定頭物件

記憶體結構:在HotSpot虛擬機器裡,物件在堆記憶體中的儲存佈局可以劃分為三個部分:

  • 物件頭(Header)HotSpot虛擬機器物件的物件頭部分包括兩類資訊。

    • 第一類是用於儲存物件自身的執行時資料,如哈 希碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。這部分資料的長度在32位和64位的虛擬機器中分別為32個位元和64個位元,官方稱它為“Mark Word”。具體各個欄位含義去執行緒加鎖那裡看!

    • 另外一部分是型別指標,即物件指向它的型別後設資料(儲存在元空間)的指標,Java虛擬機器透過這個指標 來確定該物件是哪個類的例項。簡單聊一聊UseCompressedOops UseCompressedClassPointers這兩個JVM引數_usercompressedclasspointres和oops-CSDN部落格

    • 此外,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料,因為虛擬機器可以透過普通 Java物件的後設資料資訊確定Java物件的大小,但是如果陣列的長度是不確定的,將無法透過後設資料中的 資訊推斷出陣列的大小。

  • 例項資料(Instance Data)

    例項資料部分是物件真正儲存的有效資訊,即我們在程式程式碼裡面所定義的各種型別的字 段內容,無論是從父類繼承下來的,還是在子類中定義的欄位都必須記錄起來。這部分的儲存順序會 受到虛擬機器分配策略引數(-XX:FieldsAllocationStyle引數)和欄位在Java原始碼中定義順序的影響。 HotSpot虛擬機器預設的分配順序為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),從以上預設的分配策略中可以看到,相同寬度的欄位總是被分配到一起存放,在滿足這個前提條件的情況下,在父類中定義的變數會出現在子類之前。如果HotSpot虛擬機器的 +XX:CompactFields引數值為true(預設就為true),那子類之中較窄的變數也允許插入父類變數的空 隙之中,以節省出一點點空間。

    整數型別:byte(1位元組), short(2位元組), int(4位元組), long(8位元組)
    浮點型別:float(4位元組), double(8位元組)
    字元型別:char(2位元組)
    布林型別: boolean(1個位元組)

  • 對齊填充(Padding)

    首先解釋一下上面的一些術語,在java的物件中,物件的大小都為8byte的倍數。

    alignment/padding gap

    • alignment 對齊,對齊都是向8byte對齊
    • padding 補齊,補齊是向4byte補齊,物件對齊的最小粒度為4byte。

    alignment/padding gap‌會在以下情況下存在:(有待確認,不確定??)

    • 當物件包含基本資料型別時‌,如果這些基本資料型別的位元組和不是4的倍數,且位元組和大於4,此時會觸發alignment/padding gap。
    • 當物件包含非基本資料型別(即引用資料型別)時‌,如果所有基本資料型別的位元組和不是4的倍數,此時也會觸發alignment/padding gap。
    • 快取行填充‌:在多執行緒環境中,為了最佳化效能,Java可能會對某些需要頻繁讀取和修改的欄位進行快取行填充。

    Q: 為什麼要對齊資料?

    A: 為了CPU能夠高效定址 。

    另外位元組對齊的作用不僅是便於cpu快速訪問,同時合理的利用位元組對齊可以有效地節省儲存空間

    • CPU一次訪問時,要麼讀0x01~0x04,要麼讀0x05~0x08…硬體不支援一次訪問就讀到0x02~0x05

      例:如果0x02~0x05存了一個int,讀取這個int就需要先讀0x01~0x04,留下0x02~0x04的內容,再讀0x05~0x08,留下0x05的內容,兩部分拼接起來才能得到那個int的值,這樣讀一個int就要兩次記憶體訪問,效率就低了。
                  

例子:

1.普通物件

Object object = new Object()

佔幾個位元組?
在64位CPU上,物件頭中物件標誌佔8個位元組,型別指標(指標壓縮開啟)佔4個位元組。例項資料區無資料。對齊區12不是8的倍數了,擴充4個位元組變成16。因此一共16個位元組

public static class ArtisanTest {
        int id;        //4B
        String name;   //4B
        byte b;        //1B
        Object o;      //4B
}

2.陣列物件(多了個length)

public static void main(String[] args) {
    int[] a = {1};
    System.out.println(ClassLayout.parseInstance(a).toPrintable());
}

列印的記憶體佈局資訊:

列印的記憶體佈局資訊:

可以看到SIZE一共是24byte
物件頭:物件標誌8位元組。型別指標4位元組。陣列長度4位元組。
例項資料:int 1 資料4位元組。
對齊:前面一共20位元組。填充4位元組。使其滿足8的倍數。

(5)執行方法

<init>方法Java在編譯的時候生成的,該方法包含這個類中的例項成員變數宣告、例項初始化塊和構造方法,作用是給物件執行初始化操作。按照程式設計師的意願對物件進行初始化,這樣一個真正可用的物件才算完全被構造出來。

類中有多少個構造方法就有多少個<init>方法。建立物件時使用哪個構造方法,就執行對應的<init>方法。<init>方法中的語句順序與例項成員變數初始化順序一致,下圖是例項成員變數的初始化順序:

當然父類也有<init>方法,初始化物件時,先執行父類的<init>方法再執行子類的<init>方法,如圖所示:

到這裡初始化操作完成之後,Java物件才算真正意義上建立了,這時候才能夠使用這個物件。

順便擴充套件一下前面的類載入階段時的靜態成員變數初始化。靜態成員變數初始化對應的是方法,並且也是JVM自動生成的。方法中的語句順序與靜態成員變數初始化順序一致,下圖是靜態成員變數的初始化順序:

注意方法不會在建立物件時執行,只有在類載入的初始化階段時候,才會執行對應的方法。具體檢視Java類的初始化時機。【Java】關於Java類初始化的時機_java類的初始化是什麼時候-CSDN部落格

列印物件的記憶體佈局

最後,如果我們想看下物件建立後的大小,可以新增第三方jol包,使用它來列印物件的記憶體佈局情況。

<dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.9</version>
</dependency>

測試類

public class ObjectHeaderTest {

    public static void main(String[] args) {
        System.out.println("=========列印Object物件的大小========");
        ClassLayout layout = ClassLayout.parseInstance(new Object());
        System.out.println(layout.toPrintable());


        System.out.println("========列印陣列物件的大小=========");
        ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
        System.out.println(layout1.toPrintable());


        System.out.println("========列印有成員變數的物件大小=========");
        ClassLayout layout2 = ClassLayout.parseInstance(new ArtisanTest());
        System.out.println(layout2.toPrintable());
    }

    /**
     * ‐XX:+UseCompressedOops 表示開啟壓縮普通物件指標
     * ‐XX:+UseCompressedClassPointers 表示開啟壓縮類指標
     *
     */
    public static class ArtisanTest {

        int id;        //4B
        String name;   //4B
        byte b;        //1B
        Object o;      //4B
    }
}

執行結果:

=========列印Object物件的大小========
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

========列印陣列物件的大小=========
[I object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
     12     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     0    int [I.<elements>                             N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

========列印有成員變數的物件大小=========
com.example.myspringboot001.test.ObjectHeaderTest$ArtisanTest object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           61 e0 00 f8 (01100001 11100000 00000000 11111000) (-134160287)
     12     4                int ArtisanTest.id                            0
     16     1               byte ArtisanTest.b                             0
     17     3                    (alignment/padding gap)                  
     20     4   java.lang.String ArtisanTest.name                          null
     24     4   java.lang.Object ArtisanTest.o                             null
     28     4                    (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

2.3.2 物件的訪問定位

物件訪問方式也是由虛擬機器實 現而定的,主流的訪問方式主要有使用控制代碼直接指標兩種

使用控制代碼訪問:Java堆中將可能會劃分出一塊記憶體來作為控制代碼池,reference中儲存的就 是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料各自具體的地址資訊。

直接指標訪問:Java堆中物件的記憶體佈局就必須考慮如何放置訪問型別資料的相關 資訊,reference中儲存的直接就是物件地址,如果只是訪問物件本身的話,就不需要多一次間接訪問的開銷。

使用直接指標來訪問最大的好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件訪問在Java中非常頻繁,因此這類開銷積少成多也是一項極為可觀的執行成本,就本書討論的主要虛擬機器HotSpot而言,它主要使用第二種方式進行物件訪問(有例外情況,如果使用了Shenandoah收集器的 話也會有一次額外的轉發,具體可參見第3章),但從整個軟體開發的範圍來看,在各種語言、框架中 使用控制代碼來訪問的情況也十分常見。

2.3.3 實戰:OutOfMemoryError異常

看 2.2.4 java棧溢位分析

看書 第88頁

程式計數器(PC) 不存在Error(溢位情況) 不存在GC(垃圾回收)
虛擬機器棧(JVM) :存在Error(溢位情況) 不存在GC(垃圾回收)
本地方法棧(NM)::存在Error(溢位情況) 不存在GC(垃圾回收)
堆空間(Heap)::存在Error(溢位情況) 存在GC(垃圾回收)
方法區(Method Area)::存在Error(溢位情況) 存在GC(垃圾回收)

總結:

  • 不存在Error(溢位情況) :程式計數器(PC)
  • 不存在GC(垃圾回收):程式計數器(PC)、虛擬機器棧(JVM)、本地方法棧(NM)

相關文章