8.JVM記憶體分配機制超詳細解析

盛開的太陽發表於2021-10-13

一、物件的載入過程

之前研究過類的載入過程。具體詳情可檢視文章:https://www.cnblogs.com/ITPower/p/15356099.html

那麼,當一個物件被new的時候,是如何載入的呢?有哪些步驟,如何分配記憶體空間的呢?

1.1 物件建立的主要流程

還是這段程式碼為例說明:

public static void main(String[] args) {
    Math math = new Math();
    math.compute();

    new Thread().start();
}

當我們new一個Math物件的時候,其實是執行了一個new指令建立物件。我們之前研究過類載入的流程,那麼建立一個物件的流程是怎樣的呢?如下圖所示。下面我們一個環節一個環節的分析。

fa

1.1.1類載入檢查

當虛擬機器執行到一條new指令時,首先去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個

符號引用代表的類是否已經被載入、解析和初始化過(也就是檢查類是否已經被載入過)。如果沒有,那必須先執行相應的類載入流程。

1.1.2分配記憶體空間

類載入檢查通過以後,接下來就是給new的這個物件分配記憶體空間。物件需要多大記憶體是在類載入的時候就已經確定了的。為物件分配空間的過程就是從java堆中劃分出一塊確定大小的記憶體給到這個物件。那麼到底如何劃分記憶體呢?如果存在併發,多個物件同時都想佔用同一塊記憶體該如何處理呢?

1)如何給物件劃分記憶體空間?

通常,給物件分配記憶體有兩種方式:一種是指標碰撞,另一種是空閒列表。

  • 指標碰撞

指標碰撞(Bump the Pointer),預設採用的是指標碰撞的方式。如果Java堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離。

8.JVM記憶體分配機制超詳細解析
  • 空閒列表

如果Java堆中的記憶體不是規整的,已使用的記憶體和空閒的記憶體相互交錯,那就沒有辦法簡單地進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄

8.JVM記憶體分配機制超詳細解析

不同的記憶體分配方式,在垃圾回收的時候採用不同的方法。

2)如何解決多個物件併發佔用空間的問題?

當有多個執行緒同時啟動的時候,多個執行緒new的物件都要分配記憶體,不管記憶體分配使用的是哪種方式,指標碰撞也好,空閒列表也好,這些物件都要去爭搶這塊記憶體。當多個執行緒都想爭搶某一塊記憶體的時候,這時該如何處理呢?通常有兩種方式:CAS和本地執行緒分配緩衝。

  • CAS(compare and swap)

CAS可以理解為多個執行緒同時去爭搶一個快記憶體,搶到了的就使用,沒搶到的就重試去搶下一塊記憶體。

虛擬機器採用CAS配上失敗重試的方式保證更新操作的原子性來對分配記憶體空間的動作進行同步處理。

  • 本地執行緒分配緩衝(Thread Local Allocation Buffer,TLAB)

什麼是TLAB呢?簡單說,TLAB是為了避免多執行緒爭搶記憶體,在每個執行緒初始化的時候,就在堆空間中為執行緒分配一塊專屬的記憶體。自己執行緒的物件就往自己專屬的那塊記憶體存放就可以了。這樣多個執行緒之間就不會去哄搶同一塊記憶體了。jdk8預設使用的就是TLAB的方式分配記憶體。

把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在Java堆中預先分配一小塊記憶體。通過-XX:+UseTLAB引數來設定虛擬機器是否使用TLAB(JVM會預設開啟-XX:+UseTLAB),­-XX:TLABSize 指定TLAB大小。

1.1.3 初始化

記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭), 如果使用TLAB,這一工作過程也

可以提前至TLAB分配時進行。這一步操作保證了物件的例項欄位在Java程式碼中可以不賦初始值就直接使用,程式能訪問

到這些欄位的資料型別所對應的零值。

1.1.4 設定物件頭

我們來看看這個類:

public class Math {
    public static int initData = 666;
    public static User user = new User();

    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();

        new Thread().start();
    }
}

對於一個類,通常我們看到的是成員變數和方法,但並不是說一個類的資訊只有我們目光所及的這些內容。在物件初始化零值之後,虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的後設資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊存放在物件的物件頭Object Header中。 在HotSpot虛擬機器中,物件在記憶體中包含3個部分:

  • 物件頭(Header)
  • 例項資料(Instance Data)
  • 物件填充(Padding)

例項資料就不多說了,就是我們經常看到的並使用的資料。物件頭和填充資料下面我們重點研究。先來說物件頭。

1. 物件頭的組成部分

HotSpot虛擬機器的物件頭包括以下幾部分資訊:

第一部分:Mark Word標記欄位,32位佔4個位元組,64位佔8個位元組。用於儲存物件自身的執行時資料, 如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。

第二部分:Klass Pointer型別指標,即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。 開啟壓縮佔4個位元組,關閉壓縮佔8個位元組。

第三部分:陣列長度,通常是4位元組,只有物件陣列才有。

2.Mark Word標記欄位

如下圖所示是一個32位機器的物件頭的mark word標記欄位。物件不同的狀態對應的物件頭的結構也是不一樣的。根據鎖狀態劃分物件有5種狀態,分別是:無狀態、輕量級鎖、重量級鎖、GC標記、偏向鎖。

8.JVM記憶體分配機制超詳細解析

無鎖狀態,就是普通物件的狀態。一個物件被new出來以後,沒有任何的加鎖標記,這時候他的物件頭分配是

  • 25位:用來儲存物件的hashcode
  • 4位:用來儲存分代年齡。之前說過一個新生物件的年齡超過15還沒有被回收就會被放入到老年代。為什麼年齡設定為15呢?因為分代年齡用4個位元組儲存,最大就是15了。
  • 1位:儲存是否是偏向鎖
  • 2位:儲存鎖標誌位

最後這兩個就和併發程式設計有關係了,後面我們會重點研究併發程式設計的時候研究這一塊。

3.Klass Pointer型別指標

在64位機器下,型別指標佔8個位元組,但是當開啟壓縮以後,佔4個位元組

一個物件new出來以後是被放在堆裡的,類的後設資料資訊是放在方法區裡的,在new物件的頭部有一個指標指向方法區中該類的後設資料資訊。這個頭部的指標就是Klass Pointer。而當程式碼執行到math.compute()方法呼叫的時候,是怎麼找到compute()方法的呢?實際上就是通過型別指標去找到的。(知道了math指向的物件的地址,再根據物件的型別指標找到方法區中的原始碼資料,再從原始碼資料中找到compute()方法)。

public static void main(String[] args) {
  	Math math = new Math();
  	math.compute();
}
8.JVM記憶體分配機制超詳細解析

對於Math類來說,他還有一個類物件, 如下程式碼所示:

Class<? extends Math> mathClass = math.getClass();

這個類物件是儲存在哪裡的呢?這個類物件是方法區中的後設資料物件麼?不是的。這個類物件實際上是jvm虛擬機器在堆中建立的一塊和方法區中原始碼相似的資訊。如下圖堆空間右上角。

8.JVM記憶體分配機制超詳細解析

那麼在堆中的類物件和在方法區中的類元物件有什麼區別呢?

類的後設資料資訊是放在方法區的。堆中的類資訊,可以理解為是類裝載後jvm給java開發人員提供的方便的訪問類的資訊。通過類的反射我們知道,我們可以通過Math的class拿到這個類的名稱,方法,屬性,繼承關係,介面等等。我們知道jvm的大部分實現是通過c++實現的,jvm在拿到Math類的時候,他不會通過堆中的類資訊(上圖堆右上角math類資訊)拿到,而是直接通過型別指標找到方法區中後設資料實現的,這塊型別指標也是c++實現的。在方法區中的類後設資料資訊都是c++獲取實現的。而我們java開發人員要想獲得類後設資料資訊是通過堆中的類資訊獲得的。堆中的class類是不會儲存後設資料資訊的。我們可以吧堆中的類資訊理解為是方法區中類後設資料資訊的一個映象。

Klass Pointer型別指標的含義:Klass不是class,class pointer是類的指標;而Klass Pointer指的是底層c++對應的類的指標

4.陣列長度

如果一個物件是陣列的話,除了Mark Word標記欄位和Klass Pointer型別指標意外,還會有一個陣列長度。用來記錄陣列的長度,通常佔4個位元組。

物件頭在hotspot的C++原始碼裡的註釋如下:

5.物件對齊(Object alignment)

我們上面說了物件有三塊:物件頭,實體,物件對齊。那麼什麼是物件對齊呢?

對於一個物件來說,有的時候有物件對齊,有的時候沒有。JVM內部會將物件的讀取資訊按照8個位元組對齊。至於為什麼要按8個位元組對齊呢?這是計算機底層原理了,經過大量的實踐證明,物件按照8個位元組讀取效率會非常高。也就是說,最後要求位元組數是8的整數倍。可以是8,16,24,32.

6.程式碼檢視物件結構

如何檢視物件的內部結構和大小呢?我們可以通過引用jol-core包,然後呼叫裡面的幾個方法即可檢視

引入jar包

引入jar包:
  
  <dependency>
			<groupId>org.openjdk.jol</groupId>
			<artifactId>jol-core</artifactId>
			<version>0.9</version>
  </dependency>

測試程式碼

import org.openjdk.jol.info.ClassLayout;

/**
 * 查詢類的內部結構和大小
 */
public class JOLTest {
    public static void main(String[] args) {
        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 Object());
        System.out.println(layout2.toPrintable());

    }

    class A {
        int id;
        String name;
        byte b;
        Object o;
    }
}

執行程式碼執行結果:

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.lxl.jvm.JOLTest$A 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)                           12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758)
     12     4                int A.id                                      0
     16     1               byte A.b                                       0
     17     3                    (alignment/padding gap)                  
     20     4   java.lang.String A.name                                    null
     24     4   java.lang.Object A.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


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

這裡一共有四行:

  • 前兩行是物件頭(Mark Word), 佔用8個位元組;
  • 第三行是Klass Pointer型別指標,佔用4個位元組,如果不壓縮的話會佔用8個位元組;
  • 第四行是Object Alignment物件對齊,物件對齊是為了保證整個物件佔用的位數是8的倍數。
陣列物件的內部結構
[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

這裡一共有5行:

  • 頭兩行是Mark word標記欄位,佔了8位;
  • 第三行是Klass Pointer型別指標,佔了4位;
  • 第四行是陣列特有的,標記陣列長度的,佔了4位。
  • 第五行是物件對齊object alignment,由於前面4行一共是16位,所以這裡不需要進行補齊
A(自定義)物件的內部結構
com.lxl.jvm.JOLTest$A 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)       12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758)
     12     4          int A.id              0
     16     1          byte A.b              0
     17     3          (alignment/padding gap)                  
     20     4   java.lang.String A.name      null
     24     4   java.lang.Object A.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

這一共有四行:

  • 前兩行是物件頭(Mark Word), 佔用8個位元組;
  • 第三行是Klass Pointer型別指標,佔用4個位元組,如果不壓縮的話會佔用8個位元組;
  • 第四行是int型別 佔4位。
  • 第五行是byte型別:佔1位。
  • 第六行是byte補位:步3位。
  • 第七行是String型別:佔4位
  • 第八行是Object型別:佔4位
  • 第九行是object alignment物件對齊補4位。前面28位,不是8的倍數,所以補4位。

1.1.5.執行方法

這裡的init方法,不是構造方法,是c++呼叫的init方法。執行方法,即物件按照程式設計師的意願進行初始化,也就是說真正意義上的為屬性賦值(注意,這與上面的初始化賦零值不同,這是賦程式設計師設定的值),並且呼叫構造方法。

1.1.6 指標壓縮

1. 什麼是java物件的指標壓縮

從jdk1.6開始,在64位作業系統中,jvm預設開啟指標壓縮。指標壓縮就是將Klass Pointer型別指標進行壓縮,已經Object物件,String物件進行指標壓縮。看下面的例子:

import org.openjdk.jol.info.ClassLayout;

/**
 * 查詢類的內部結構和大小
 */
public class JOLTest {
    public static void main(String[] args) {
        System.out.println();
        ClassLayout layout2 = ClassLayout.parseInstance(new A());
        System.out.println(layout2.toPrintable());
    }

    public static class A {
        int id;
        String name;
        byte b;
        Object o;
    }
}

執行這段程式碼,A的類結構:

com.lxl.jvm.JOLTest$A 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)       12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758)
     12     4          int A.id              0
     16     1          byte A.b              0
     17     3          (alignment/padding gap)                  
     20     4   java.lang.String A.name      null
     24     4   java.lang.Object A.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

預設情況下是開啟指標壓縮的。上面分析過這個類結構,這裡主要看第三行Klass Pointer和第七行String佔4位,第八行Object佔4位。我們知道這裡儲存的都是指標的地址。

下面我們手動設定關閉指標壓縮:

指標壓縮的命令有兩個:UseCompressedOops(壓縮所有的指標物件,包括header頭和其他) 和 UseCompressedClassPointers(只壓縮指標物件)
  
開啟指標壓縮:  -XX:+UseCompressedOops(預設開啟),
禁止指標壓縮:  -XX:-UseCompressedOops 
  
引數的含義:
  				compressed:壓縮的意思
  				oop(ordinary object pointer):物件指標

在main方法的VM配置引數中設定XX:-UseCompressedOops

8.JVM記憶體分配機制超詳細解析

然後再來看執行結果:

com.lxl.jvm.JOLTest$A 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)            d0 0c be 26 (11010000 00001100 10111110 00100110) (649989328)     12     4        (object header)            02 00 00 00 (00000010 00000000 00000000 00000000) (2)     16     4        int A.id                   0     20     1        byte A.b                   0     21     3        (alignment/padding gap)                       24     8   java.lang.String A.name         null     32     8   java.lang.Object A.o            nullInstance size: 40 bytesSpace losses: 3 bytes internal + 0 bytes external = 3 bytes total

來看變化點:

  • Klass Pointer型別指標原來是4位,現在多了4位。型別指標佔了8位
  • String物件原來佔用4位,不壓縮是8位
  • Object物件原來佔用4位,不壓縮佔用8位

從現象上可以看出壓縮和不壓縮的區別。那麼為什麼要進行指標壓縮呢?

2.為什麼要進行指標壓縮?

1.在64位平臺的HotSpot中使用32位指標,記憶體使用會多出1.5倍左右,使用較大指標在主記憶體和快取之間移動資料, 佔用較大寬頻,同時GC也會承受較大壓力(佔用記憶體少,可以儲存更多物件,觸發GC的頻率降低)。為了減少64位平臺下記憶體的消耗,預設啟用指標壓縮功能 。

2.在jvm中,32位地址最大支援4G記憶體(2的32次方),可以通過對物件指標的壓縮編碼、解碼方式進行優化,使得jvm只用32位地址就可以支援更大的記憶體配置(小於等於32G)

3.堆記憶體小於4G時,不需要啟用指標壓縮,jvm會直接去除高32位地址,即使用低虛擬地址空間

4.堆記憶體大於32G時,壓縮指標會失效,會強制使用64位(即8位元組)來對java物件定址,這就會出現1的問題,所以堆內 存不要大於32G為好.

二、物件的記憶體分配

物件的記憶體分配流程如下:

8.JVM記憶體分配機制超詳細解析

物件建立的過程中會給物件分配記憶體,分配記憶體的整體流程如下:

第一步:判斷棧上是否有足夠的空間。

​ 這裡和之前理解有所差別。之前一直都認為new出來的物件都是分配在堆上的,其實不是,在滿足一定的條件,會先分配在棧上。那麼為什麼要在棧上分配?什麼時候分配在棧上?分配在棧上的物件如何進行回收呢?下面來詳細分析。

1.為什麼要分配在棧上?

通過JVM記憶體模型中,我們知道Java的物件都是分配在堆上的。當堆空間(新生代或者老年代)快滿的時候,會觸發GC,沒有被任何其他物件引用的物件將被回收。如果堆上出現大量這樣的垃圾物件,將會頻繁的觸發GC,影響應用的效能。其實這些物件都是臨時產生的物件,如果能夠減少這樣的物件進入堆的概率,那麼就可以成功減少觸發GC的次數了。我們可以把這樣的物件放在堆上,這樣該物件所佔用的記憶體空間就可以隨棧幀出棧而銷燬,就減輕了垃圾回收的壓力。

2.什麼情況下會分配在棧上?

為了減少臨時物件在堆內分配的數量,JVM通過逃逸分析確定該物件會不會被外部訪問。如果不會逃逸可以將該物件在棧上分配記憶體。隨棧幀出棧而銷燬,減輕GC的壓力。

3.什麼是逃逸?

那麼什麼是逃逸分析呢?要知道逃逸分析,先要知道什麼是逃逸?我們來看一個例子

public class Test {

    public User test1() {
        User user = new User();
        user.setId(1);
        user.setName("張三");
        return user;
    }

    public void test2() {
        User user = new User();
        user.setId(2);
        user.setName("李四");
    }
}

Test裡有兩個方法,test1()方法構建了user物件,並且返回了user,返回回去的物件肯定是要被外部使用的。這種情況就是user物件逃逸出了test1()方法。

而test2()方法也是構建了user物件,但是這個物件僅僅是在test2()方法的內部有效,不會在方法外部使用,這種就是user物件沒有逃逸。

判斷一個物件是否是逃逸物件,就看這個物件能否被外部物件訪問到。

結合棧上分配來理解為何沒有逃逸出去的物件為什麼應該分配在棧上呢?來看下圖:

image

Test2()方法的user物件只會在當前方法內有效,如果放在堆裡,在方法結束後,其實這個物件就已經是垃圾的,但卻在堆裡佔用堆記憶體空間。如果將這個物件放入棧中,隨著方法入棧,邏輯處理結束,物件就變成垃圾了,再隨著棧幀出棧。這樣可以節約堆空間。尤其是這種非逃逸物件很多的時候。可以節省大量的堆空間,降低GC的次數。

4.什麼是物件的逃逸分析?

就是分析物件動態作用域,當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為引數傳遞到其他地方中。 上面的例子中,很顯然test1()方法中的user物件被返回了,這個物件的作用域範圍不確定,test2方法中的user物件我們可以確定當方法結束這個物件就可以認為是無效物件了,對於這樣的物件我們其實可以將其分配在棧記憶體裡,讓其在方法結束時跟隨棧記憶體一起被回收掉。

大白話說就是:判斷user物件是否會逃逸到方法外,如果不會逃逸到方法外,那麼就建議在堆中分配一塊記憶體空間,用來儲存臨時的變數。是不是不會逃逸到方法外的物件就一定會分配到堆空間呢?不是的,需要滿足一定的條件:第一個條件是JVM開啟了逃逸分析。可以通過設定引數來開啟/關閉逃逸分析。

-XX:+DoEscapeAnalysis     開啟逃逸分析
-XX:-DoEscapeAnalysis			關閉逃逸分析  

JVM對於這種情況可以通過開啟逃逸分析引數(-XX:+DoEscapeAnalysis)來優化物件記憶體分配位置,使其通過標量替換優先分配在棧上(棧上分配),JDK7之後預設開啟逃逸分析,如果要關閉使用引數(-XX:-DoEscapeAnalysis)

5.什麼是標量替換?

如果一個物件通過逃逸分析能過確定他可以在棧上分配,但是我們知道一個執行緒棧的空間預設也就1M,棧幀空間就更小了。而物件分配需要一塊連續的空間,經過計算如果這個物件可以放在棧幀上,但是棧幀的空間不是連續的,對於一個物件來說,這樣是不行的,因為物件需要一塊連續的空間。那怎麼辦呢?這時JVM做了一個優化,即便在棧幀中沒有一塊連續的空間方法下這個物件,他也能夠通過其他的方式,讓這個物件放到棧幀裡面去,這個辦法就是標量替換

什麼是標量替換呢?

如果有一個物件,通過逃逸分析確定在棧上分配了,以User為例,為了能夠在有限的空間裡能夠放下User中所有的東西,我們不會在棧上new一個完整的物件了,而是隻是將物件中的成員變數放到棧幀裡面去。如下圖:

8.JVM記憶體分配機制超詳細解析

棧幀空間中沒有一塊完整的空間放User物件,為了能夠放下,我們採用標量替換的方式,不是將整個User物件放到棧幀中,而是將User中的成員變數拿出來分別放在每一塊空閒空間中。這種不是放一個完整的物件,而是將物件打散成一個個的成員變數放到棧幀上,當然會有一個地方標識這個屬性是屬於那個物件的,這就是標量替換。

通過逃逸分析確定該物件不會被外部訪問,並且物件可以被進一步分解時,JVM不會建立該物件,而是將該物件成員變數分解若干個被這個方法使用的成員變數所代替,這些代替的成員變數在棧幀或暫存器上分配空間,這樣就不會因為沒有一大塊連續空間導致物件記憶體不夠分配了。開啟標量替換引數是

-XX:+EliminateAllocations

JDK7之後預設開啟。

6.標量替換與聚合量

那什麼是標量,什麼是聚合量呢?

標量即不可被進一步分解的量,而JAVA的基本資料型別就是標量(如:int,long等基本資料型別以及 reference型別等),標量的對立就是可以被進一步分解的量,而這種量稱之為聚合量。而在JAVA中物件就是可以被進一步分解的聚合量。

7. 總結+案例分析

new出來的一部分物件是可以放在棧上的,那什麼樣的物件放在棧上呢?通過逃逸分析判斷一個物件是否會逃逸到方法外,如果不會逃逸到方法外,那麼就建議在堆中分配一塊記憶體空間來儲存這樣的變數。那是不是說所有不會逃逸到方法外的物件就一定會分配到堆空間呢?不是的,需要滿足一定的條件:

  • 開啟逃逸分析
  • 開啟標量替換

下面舉例分析:

public class AllotOnStack {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println(end-start); 
    }
    private static void alloc() {
        User user = new User();
        user.setId(1);
        user.setName("zhuge");
     }
}

上面有一段程式碼,在main方法中呼叫1億次alloc()方法。在alloc()方法中,new了User物件,但是這個物件是沒有逃逸出alloc()方法的。for迴圈執行了1億次,這時會產生1億個物件,如果分配在堆上,那麼會有大量的GC產生;如果分配在棧上,那麼幾乎不會有GC產生。這裡說的是幾乎,也就是不一定完全沒有gc產生,產生gc還可能是因為其他情況。

為了能夠看到在棧上分配的明顯的效果,我們分幾種情況來分析:

  • 預設情況下

設定引數:

我當前使用的是jdk8,預設開啟逃逸分析(‐XX:+DoEscapeAnalysis),開啟標量替換的(‐XX:+EliminateAllocations)。

-Xmx15m -Xms15m  -XX:+PrintGC 

設定上面的引數:將堆記憶體設定的小一些,並且設定列印GC日誌,方便我們清晰的看到結果。

執行結果:

10

我們看到沒有產生任何的GC。因為開啟了逃逸分析,開啟了標量替換。這就說明,物件沒有分配在堆上,而是分配在棧上了。

有沒有疑惑,為什麼棧上可以放1億物件?

因為產生一個物件,當這個方法執行完的時候,物件會隨棧幀一起被回收。然後分配下一個物件,這個物件執行完再次被回收。以此類推。

  • 關閉逃逸分析,開啟標量替換

這種情況是關閉了逃逸分析,開啟了標量替換。設定jvm引數如下:

-Xmx15m -Xms15m -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations

其實只有開啟了逃逸分析,標量替換才會生效。所以,這種情況是不會將物件分配在棧上的,都分配在堆上,那麼會產生大量的GC。我們來看執行結果:

[GC (Allocation Failure)  4842K->746K(15872K), 0.0003706 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0003987 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0004303 secs]
......
[GC (Allocation Failure)  4842K->746K(15872K), 0.0004012 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0003712 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0003978 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0003969 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0011955 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0004206 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0004172 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0013991 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0006041 secs]
[GC (Allocation Failure)  4842K->746K(15872K), 0.0003653 secs]
773

我們看到產生了大量的GC,並且耗時從原來的10毫秒延長到773毫秒

  • 開啟逃逸分析,關閉標量替換

這種情況是關閉了逃逸分析,開啟了標量替換。設定jvm引數如下:

-Xmx15m -Xms15m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:-EliminateAllocations

其實只有開啟了逃逸分析,標量替換不生效,表示的含義是如果物件在棧空間放不下了,那麼會直接放到堆空間裡。我們來看執行結果:

[GC (Allocation Failure)  4844K->748K(15872K), 0.0003809 secs]
[GC (Allocation Failure)  4844K->748K(15872K), 0.0003817 secs]
.......
[GC (Allocation Failure)  4844K->748K(15872K), 0.0003751 secs]
[GC (Allocation Failure)  4844K->748K(15872K), 0.0004613 secs]
[GC (Allocation Failure)  4844K->748K(15872K), 0.0005310 secs]
[GC (Allocation Failure)  4844K->748K(15872K), 0.0003402 secs]
[GC (Allocation Failure)  4844K->748K(15872K), 0.0003661 secs]
[GC (Allocation Failure)  4844K->748K(15872K), 0.0004457 secs]
[GC (Allocation Failure)  4844K->748K(15872K), 0.0004528 secs]
[GC (Allocation Failure)  4844K->748K(15872K), 0.0005270 secs]
657

我們看到開啟了逃逸分析,但是沒有開啟標量替換也產生了大量的GC。

通常,我們都是同時開啟逃逸分析和標量替換。

第二步:判斷是否是大物件,不是放到Eden區

判斷是否是大物件,如果是則直接放入到老年代中。如果不是,則判斷是否是TLAB?如果是則在Eden去分配一小塊空間給執行緒,把這個物件放在Eden區。如果不採用TLAB,則直接放到Eden區。

什麼是TLAB呢?本地執行緒分配緩衝(Thread Local Allocation Buffer,TLAB)。簡單說,TLAB是為了避免多執行緒爭搶記憶體,在每個執行緒初始化的時候,就在堆空間中為執行緒分配一塊專屬的記憶體。自己執行緒的物件就往自己專屬的那塊記憶體存放就可以了。這樣多個執行緒之間就不會去哄搶同一塊記憶體了。jdk8預設使用的就是TLAB的方式分配記憶體。

通過-XX:+UseTLAB引數來設定虛擬機器是否啟用TLAB(JVM會預設開啟-XX:+UseTLAB),­-XX:TLABSize 指定TLAB大小。

1.物件是如何在Eden區分配的呢?

這一塊的詳細資訊參考文章:https://www.cnblogs.com/ITPower/p/15384588.html

這裡放上記憶體分配的圖,然後我們案例來證實:

8.JVM記憶體分配機制超詳細解析

案例程式碼:

public class GCTest { 
    public static void main(String[] args) throws InterruptedException { 
        byte[] allocation1, allocation2;
        allocation1 = new byte[60000*1024];
    } 
}

來看這段程式碼,定義了一個位元組陣列allocation2,給他分配了一塊記憶體空間60M。

來看看程式執行的效果,這裡為了方便檢測效果,設定一下jvm引數列印GC日誌詳情

-XX:+PrintGCDetails    列印GC相信資訊

a) Eden去剛好可以放得下物件

執行結果:

Heap
 PSYoungGen      total 76288K, used 65536K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
  eden space 65536K, 100% used [0x000000076ab00000,0x000000076eb00000,0x000000076eb00000)
  from space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
  to   space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
 ParOldGen       total 175104K, used 0K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
  object space 175104K, 0% used [0x00000006c0000000,0x00000006c0000000,0x00000006cab00000)
 Metaspace       used 3322K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 365K, capacity 388K, committed 512K, reserved 1048576K
  • 新生代約76M
    • Eden區約65M,佔用了100%
    • from/to月1M,佔用0%
  • 老年代月175M,佔用0%
  • 後設資料空間約3M,佔用365k。

我們看到新生代Eden區被放滿了。其實我們的物件只有60M,Eden區有65M,為什麼會被放滿呢?因為Eden區還存放了JVM啟動的一些類。因為Eden區能夠放得下,所以不會放到老年代裡。

後設資料空間約3M是存放的方法區中類程式碼資訊的映象。我們在上面型別指標裡面說過方法區中後設資料資訊在堆中的映象。

對於Math類來說,他還有一個類物件, 如下程式碼所示:

Class<? extends Math> mathClass = math.getClass();

這個類物件是儲存在哪裡的呢?這個類物件是方法區中的後設資料物件麼?不是的。這個類物件實際上是jvm虛擬機器在堆中建立的一塊和方法區中原始碼相似的資訊。如下圖堆空間右上角。

8.JVM記憶體分配機制超詳細解析

b) Eden區滿了,會觸發GC

public class GCTest {
    public static void main(String[] args) throws InterruptedException {
        byte[] allocation1, allocation2;
                /*, allocation3, allocation4, allocation5, allocation6*/
        allocation1 = new byte[60000*1024];
        allocation2 = new byte[8000*1024];
    }
}

來看這個案例,剛剛設定allocation1=60M Eden區剛好滿了,這時候在為物件allocation2分配8M,因為Eden滿了,這是會觸發GC,60M from/to都放不下,會直接放到old老年代,然後將allocation2的8M放到Eden區。來看執行結果:

[GC (Allocation Failure) [PSYoungGen: 65245K->688K(76288K)] 65245K->60696K(251392K), 0.0505367 secs] [Times: user=0.25 sys=0.04, real=0.05 secs] 
Heap
 PSYoungGen      total 76288K, used 9343K [0x000000076ab00000, 0x0000000774000000, 0x00000007c0000000)
  eden space 65536K, 13% used [0x000000076ab00000,0x000000076b373ef8,0x000000076eb00000)
  from space 10752K, 6% used [0x000000076eb00000,0x000000076ebac010,0x000000076f580000)
  to   space 10752K, 0% used [0x0000000773580000,0x0000000773580000,0x0000000774000000)
 ParOldGen       total 175104K, used 60008K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
  object space 175104K, 34% used [0x00000006c0000000,0x00000006c3a9a010,0x00000006cab00000)
 Metaspace       used 3323K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 365K, capacity 388K, committed 512K, reserved 1048576K

和我們預測的一樣

  • 年輕代76M,已用9343k
    • Eden65M,佔用了13%,這13%就有allocation2分配的80M,另外的部分是jvm執行產生的
    • from區10M,佔用6%。這裡面存的肯定不是allocation1的60M,因為存不下,這裡的應該是和jvm有關的資料
    • to去10M,佔用0%
  • 老年代175M,佔用了60M,這60M就是allocation1回收過來的
  • 後設資料佔用3M,使用365k。這一塊資料沒有發生變化,因為後設資料資訊沒有變。

第三步 是大物件 放入到老年代

1.什麼是大物件?

  • Eden園區放不下了肯定是大物件。
  • 通過引數設定什麼是大物件。-XX:PretenureSizeThreshold=1000000 (單位是位元組) -XX:+UseSerialGC。如果物件超過設定大小會直接進入老年代,不會進入年輕代,這個引數只在 Serial 和ParNew兩個收集器下有效。
  • 長期存活的物件將進入老年代。虛擬機器採用分代收集的思想來管理記憶體,虛擬機器給每個物件設定了一個物件年齡(Age)計數器。 如果物件在 Eden 出生並經過第一次 Minor GC 後仍然能夠存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並將物件年齡設為1。物件在 Survivor 中每熬過一次 MinorGC,年齡就增加1歲,當它的年齡增加到一定程度(預設為15歲,CMS收集器預設6歲,不同的垃圾收集器會略微有點不同),就會被晉升到老年代中。物件晉升到老年代的年齡閾值,可以通過引數 -XX:MaxTenuringThreshold 來設定。

2.為什麼要將大物件直接放入到老年代呢?

為了避免為大物件分配記憶體時的複製操作而降低效率。

3.什麼情況要手動設定分代年齡呢?

如果我的系統裡80%的物件都是有用的物件,那麼經過15次GC後會在Survivor中來回翻轉,這時候不如就將分代年齡設定為5或者8,這樣減少在Survivor中來回翻轉的次數,直接放入到老年代,節省了年輕代的空間。

相關文章