(五)JVM成神路之對象內(nèi)存布局、對象從生到死歷程、強弱軟虛引用全面剖析

引言

上篇文章中曾詳細談到了JVM的內(nèi)存區(qū)域,其中也曾提及了:Java程序運行過程中,絕大部分創(chuàng)建的對象都會被分配在堆空間內(nèi)。而本篇文章則會站在對象實例的角度,闡述一個Java對象從生到死的歷程、Java對象在內(nèi)存中的布局以及對象引用類型。

一、Java對象在內(nèi)存中的布局

Java源代碼中,使用new關鍵字創(chuàng)建出的對象實例,我們都知道在運行時會被分配到內(nèi)存上存儲,但分配的時候是直接在內(nèi)存中“挖”一個對應大小的坑,然后把對象實例丟進去存儲嗎?其實并不然,Java對象一般在內(nèi)存中的布局通常由對象頭、實例數(shù)據(jù)、對齊填充三部分組成,如下:

對象布局

在HotSpot虛擬機源碼的hotspot/src/share/vm/oops/目錄下,instanceOop、instanceKlass、oop幾個C++的文件描述了對象的定義(有興趣的小伙伴可以自行去研究,在開篇中提供了HotSpot源碼)。

1.1、對象頭(Object Header)

Java對象頭其實是一個比較復雜的東西,它通常也會由多部分組成,其中包含了MarkWord和類型指針(ClassMetadataAddress/KlassWord),如果是數(shù)組對象,還會存在數(shù)組長度。如下:

完整對象布局

下面我們重點分析對象頭的構(gòu)成,JVM采取2個字寬/字長存儲對象頭,如果對象是數(shù)組,額外需要存儲數(shù)組長度,所以數(shù)組對象在32位虛擬機中采取3個字寬存儲對象頭。而64位虛擬機采取兩個半字寬+半字寬對齊數(shù)據(jù)存儲對象頭,而在32位虛擬機中一個字寬的大小為4byte,64位虛擬機下一個字寬大小為8byte,64位開啟指針壓縮(-XX:+UseCompressedOops)的情況下,MarkWord為8byte,KlassWord為4byte。

而關于這塊的內(nèi)容很多資料都含糊不清,幾乎都是基于32位虛擬機而言的,那么我在這里分別列出32位/64位的對象頭信息,對象頭結(jié)構(gòu)及存儲大小說明如下:

虛擬機位數(shù) 對象頭結(jié)構(gòu)信息 說明 大小
32位 MarkWord HashCode、分代年齡、是否偏向鎖和鎖標記位 4byte/32bit
32位 ClassMetadataAddress/KlassWord 類型指針指向?qū)ο蟮念愒獢?shù)據(jù),JVM通過這個指針確定該對象是哪個類的實例 4byte/32bit
32位 ArrayLenght 如果是數(shù)組對象存儲數(shù)組長度,非數(shù)組對象不存在 4byte/32bit
虛擬機位數(shù) 對象頭結(jié)構(gòu)信息 說明 大小
64位 MarkWord unused、HashCode、分代年齡、是否偏向鎖和鎖標記位 8byte/64bit
64位 ClassMetadataAddress/KlassWord 類型指針指向?qū)ο蟮念愒獢?shù)據(jù),JVM通過這個指針確定該對象是哪個類的實例 8byte/64bit 開啟指針壓縮的情況下為4byte/32bit
64位 ArrayLenght 如果是數(shù)組對象存儲數(shù)組長度,非數(shù)組對象不存在 4byte/32bit

其中32位的JVM中對象頭內(nèi)MarkWord在默認情況下存儲著對象的HashCode、分代年齡、是否偏向鎖、鎖標記位等信息,而64位JVM中對象頭內(nèi)MarkWord的默認信息存儲著HashCode、分代年齡、是否偏向鎖、鎖標記位、unused,如下:

機位數(shù) 鎖狀態(tài) 哈希碼 分代年齡 是否偏向鎖 鎖標志信息
32位 無鎖態(tài)(默認) 25bit 4bit 1bit 2bit
位數(shù) 鎖狀態(tài) 哈希碼 分代年齡 是否偏向鎖 鎖標志信息 unused
64位 無鎖態(tài)(默認) 31bit 4bit 1bit 2bit 26bit

由于對象頭的信息是與對象自身定義的成員屬性數(shù)據(jù)沒有關系的額外存儲成本,因此考慮到JVM的空間效率,MarkWord被設計成為一個非固定的數(shù)據(jù)結(jié)構(gòu),以便可以復用方便存儲更多有效的數(shù)據(jù),它會根據(jù)對象本身的狀態(tài)復用自己的存儲空間,除了上述列出的MarkWord默認存儲結(jié)構(gòu)外,還有如下可能變化的結(jié)構(gòu):

32/64bit虛擬機markword結(jié)構(gòu)

markword信息:

  • unused:未使用的區(qū)域。
  • identity_hashcode:對象最原始的哈希值,就算重寫hashcode()也不會改變。
  • age:對象年齡。
  • biased_lock:是否偏向鎖。
  • lock:鎖標記位。
  • ThreadID:持有鎖資源的線程ID。
  • epoch:偏向鎖時間戳。
  • ptr_to_lock_record:指向線程棧中lock_record的指針。
  • ptr_to_heavyweight_monitor:指向堆中monitor對象的指針。

LockRecord:LockRecord存在于線程棧中,翻譯過來就是鎖記錄,它會拷貝一份對象頭中的markword信息到自己的線程棧中去,這個拷貝的markword稱為Displaced Mark Word ,另外還有一個指針指向?qū)ο蟆?br> 關于MrakWord這塊區(qū)域更多是提供給Synchronized鎖使用,如果對這塊感興趣的可以看之前的文章:深入理解Java并發(fā)編程之Synchronized關鍵字實現(xiàn)原理剖析,里面詳細談到了對象在運行過程中,鎖膨脹/鎖升級時這塊區(qū)域的變化。

簡單總結(jié)一下,對象頭主要由MarkWord、KlassWord和有可能存在的數(shù)組長度三部分組成。MarkWord主要是用于存儲對象的信息以及鎖信息,KlassWord則是存儲指向元空間中類元數(shù)據(jù)的指針,當然,如果當前對象是數(shù)組,那么也會在對象頭中存儲當前數(shù)組的長度。

1.2、實例數(shù)據(jù)(Instance Data)

實例數(shù)據(jù)是指一個聚合量所有標量的總和,也就是是指當前對象屬性成員數(shù)據(jù)以及父類屬性成員數(shù)據(jù)。舉個例子:

public class A{
    int ia = 0;
    int ib = 1;
    long l = 8L;
    
    public static void main(String[] args){
        A a = new A();
    }
}

上述案例中,A類存在三個屬性ia、ib、l,其中兩個為int類型,一個long類型,那么此時對象a的實例數(shù)據(jù)大小則為4 + 4 + 8 = 16byte(字節(jié))。

那此時再給這個案例加點料試試看,如下:

public class A{
    int ia = 0;
    int ib = 1;
    long l = 8L;
    B b = new B();
    
    public static void main(String[] args){
        A a = new A();
    }
    
    public static class B{
        Object obj = new Object();   
    }
}

此時對象a的實例數(shù)據(jù)大小又該如何計算呢?需要把B類的成員數(shù)據(jù)也計算進去嘛?實則不需要的,如果當類的一個成員屬于引用類型,那么是直接存儲指針的,而引用指針的大小為一個字寬,也就是在32位的VM中為32bit,在64位的VM中為64bit大小。所以此時對象a的實例數(shù)據(jù)大小為:4 + 4 + 8 + 8 = 24byte(未開啟指針壓縮的情況下是這個大小,但如果開啟了則不為這個大小,稍后詳細分析)。

1.3、對齊填充(Padding)

對齊填充在一個對象中是可能存在,也有可能不存在的,因為在64bit的虛擬機中,《虛擬機規(guī)范》中規(guī)定了:為了方便內(nèi)存的單元讀取、尋址、分配,Java對象的總大小必須要為8的整數(shù)倍,所以當一個對象的對象頭+實例數(shù)據(jù)大小不為8的整數(shù)倍時,此刻就會出現(xiàn)對齊填充部分,將對象大小補齊為8的整數(shù)倍。

如:一個對象的對象頭+實例數(shù)據(jù)大小總和為28bytes,那么此時就會出現(xiàn)4bytes的對齊填充,JVM為對象補齊成8的整數(shù)倍:32bytes。

1.4、指針壓縮(CompressedOops)

指針壓縮屬于JVM的一種優(yōu)化思想,一方面可以節(jié)省很大的內(nèi)存開支,第二方面也可以方便JVM跳躍尋址(稍后分析),在64bit的虛擬機中為了提升內(nèi)存的利用率,所以出現(xiàn)了指針壓縮這一技術,指針壓縮的技術會將Java程序中的所有引用指針(類型指針、堆引用指針、棧幀內(nèi)變量引用指針等)都會壓縮一半,而在Java中一個指針的大小是占一個字寬單位的,在64bit的虛擬機中一個字寬的大小為64bit,所以也就意味著在64位的虛擬機中,指針會從原本的64bit壓縮為32bit的大小,而指針壓縮這一技術在JDK1.7之后是默認開啟的。

可能有些小伙伴會覺得,一個指針才節(jié)省32bit空間,而好像并不能節(jié)省多少空間,但如果你這樣想就錯了,Java程序運行時,其內(nèi)部最多的不是常量,也不是對象,而是指針,棧幀中的引用指針、對象頭的類元指針、堆中的引用指針....,指針是JVM中運行時數(shù)量最多的東西,所以當每個指針能夠被壓縮一半時,從程序整體而言,能夠為程序節(jié)省非常大的空間。

指針壓縮失效:指針壓縮帶來的好處是無可厚非,幾乎能夠為Java程序節(jié)省很大的內(nèi)存空間,一般而言,如果不開啟壓縮的情況下對象內(nèi)存需要14GB,在開啟指針壓縮之后幾乎能夠在10GB內(nèi)存內(nèi)分配下這些對象。但是壓縮技術帶來好處的同時,也存在非常大的弊端,因為指針通過壓縮技術后被壓縮到32bit,而Java中32bit的指針最大尋址為32GB,也就代表著如果你的堆內(nèi)存為32G時出現(xiàn)了OOM問題,你此時將內(nèi)存擴充到48GB時仍有可能會出現(xiàn)OOM,因為內(nèi)存超出32GB后,32bit的指針無法尋址,所有壓縮的指針將會失效,發(fā)生指針膨脹,所有指針將會從壓縮后的32Bit大小回到壓縮前的64Bit大小。

有些小伙到這里又會疑惑了,32bit的指針不是最大才支持4GB9(2的32次方)內(nèi)存嘛?為什么Java中32bit的指針支持尋址32GB呢?其實這跟前面所說的對齊填充存在巨大的聯(lián)系。在前面提到過,64位的虛擬機中,對象大小必須要為8的整數(shù)倍,如果當一個對象總大小不足8的整數(shù)倍時會出現(xiàn)對齊填充補齊。從這個結(jié)論可以得知:當內(nèi)存bit為第二位時絕對不可能是一個對象的開始,只有當內(nèi)存位置為8的整數(shù)倍才有可能是對象的開始位置,所以可以以8bit為一個位置來尋址,4GB的位置可以被當作4*8=32GB,最終可以尋址32GB。舉個例子帶大家理解:
一個人只能走4步,普通人一步一米,所以這個人最多只能走4米,但是有另外一個人,一步能夠走8米,所以這個人能最多走32米。

而在JVM中開啟指針壓縮后,對于對象位置的尋址計算存在三種方式,如下:

  • ①如果堆的高位地址小于32GB,說明不需要基址base就能定位堆中任意對象,這種模式被稱為Zero-based Compressed Oops Mode,計算公式如下:
    • 計算公式:add = 0 + offset * 8
    • 計算前提:high_{heap} < 32GB
  • ②如果堆高位大于等于32GB,說明需要base基地址,這時如果堆空間小于4GB,說明基址+偏移能定位堆中任意對象,如下:
    • 計算公式:add = base + offset
    • 計算前提:size_{heap} < 4GB
  • ③如果堆空間大小處于4GB32GB之間,這時只能通過基址+偏移x縮放scale(Java中縮放為8),才能定位堆中任意對象,如下:
    • 計算公式:add = base + offset * 8
    • 計算前提:4GB <= size_{heap} < 32GB

1.5、JOL對象大小計算實戰(zhàn)

為了方便觀察到對象的內(nèi)存布局,首先導入一個OpenJDK組織提供的工具:JOLmaven依賴如下:

<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

在該工具中提供了兩個API:

  • GraphLayout.parseInstance(obj).toPrintable():查看對象外部信息:包括引用的對象
  • GraphLayout.parseInstance(obj).totalSize():查看對象占用空間總大小
先上一個面試題,在Java中創(chuàng)建一個Object對象會占用多少內(nèi)存?

按照上面的講解,我們可以來進行初步計算,對象頭大小應該理論上為mrakword+klassword=16bytes=128bit,同時Object類中是沒有定義任何屬性的,所以不存在實例數(shù)據(jù)。但如果在開啟指針壓縮的情況下,只會有12bytes,因為對象頭中的類元指針會被壓縮一半,所以會出現(xiàn)4bytes的對齊填充,最終不管是否開啟了指針壓縮,大小應該為16字節(jié),接著來論證一下(環(huán)境:默認開啟指針壓縮的JDK1.8版本):

public static void main(String[] args){
    Object obj = new Object();
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

結(jié)果運行如下:

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION            VALUE
      0     4        (object header)        ......  
      4     4        (object header)        ...... 
      8     4        (object header)        ......  
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

從結(jié)果中可以很明顯的看到,0~12byte為對象頭,12~16byte為對齊填充數(shù)據(jù),最終大小為16bytes,與上述的推測無誤,在開啟指針壓縮的環(huán)境下,會出現(xiàn)4bytes的對齊填充數(shù)據(jù)。

1.5.1、數(shù)組對象大小計算

上述簡單分析了Object對象的大小之后,我們再來看一個案例,如下:

public static void main(String[] args){
    Object obj = new int[9];
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

此時大小又為多少呢?因為該數(shù)組為int數(shù)組,而int類型的大小為32bit/4bytes,所以理論上它的大小為:(12bytes對象頭+9*4=36bytes數(shù)組空間) = 48bytes,對嗎?先看看運行結(jié)果:

[I object internals:
 OFFSET  SIZE   TYPE DESCRIPTION          VALUE
      0     4        (object header)      .....
      4     4        (object header)      .....
      8     4        (object header)      .....
     12     4        (object header)      .....
     16    36    int [I.<elements>        N/A
     52     4        (loss due to the next object alignment)
Instance size: 56 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

從結(jié)果中可以看出最終大小為56bytes,實際的大小與前面的推斷存在明顯出入,為什么呢?這是因為目前的obj對象是一個數(shù)組對象,在前面分析對象頭構(gòu)成的時候曾分析過,如果一個對象是數(shù)組對象,那么它的對象頭中也會使用4bytes存儲數(shù)組的長度,所以此時的obj對象頭大小為16bytes,其中12~16bytes用于存儲數(shù)組的長度,再加上9int類型的數(shù)組空間36bytes,大小為52bytes,因為52不為8的整數(shù)倍,所以JVM會為其補充4bytes的對齊填充數(shù)據(jù),最終大小就成了上述運行結(jié)果中的56bytes

PS/拓展:
①當平時開發(fā)過程中,使用數(shù)組對象array.length屬性時,它的長度是從哪兒獲取的呢?從現(xiàn)在之后,你就能得到答案:從對象的頭部中獲取到的。
②如果Java中,不考慮內(nèi)存的情況下,一個數(shù)組對象最大長度可以為多大呢?答案是int類型能夠表達的最大值,因為對象頭中只使用了4bytes存儲數(shù)組長度。
怎么樣?是不是很有趣?其實往往很多平時開發(fā)過程中的疑惑,當你搞懂底層概念之后,答案也自然而然的浮現(xiàn)在你眼前了。

1.5.2、實例對象大小計算

前面分析了數(shù)組對象之后,接著再來看看開發(fā)過程中經(jīng)常定義的實例對象,案例如下:

public class ObjectSizeTest {
    public static class A{
        int i = 0;
        long l = 0L;
        Object obj = new Object();
    }

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

// --------- 運行結(jié)果:-------------
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION            VALUE
      0     4        (object header)        ......  
      4     4        (object header)        ...... 
      8     4        (object header)        ......  
     12     4        int A.i                0
     16     8        long A.l               0
     24     4        java.lang.Object A.obj (object)
     28     4        (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

結(jié)果沒啥意外的,掌握了前面知識的小伙伴都可以獨立計算出來這個結(jié)果,唯一值得一提的就是可以看到,在24~28bytes這四個字節(jié)存儲的是obj對象的堆引用指針,此時因為開啟了指針壓縮,所以占32bit/4bytes大小。

至此,Java對象在內(nèi)存中的布局方式以及大小計算的方式已經(jīng)闡述完畢,接下來再來探討一下Java對象分配的過程。

二、Java對象分配過程詳解

在Java中存在很多種創(chuàng)建對象的方式,最常見且最常用的則是new關鍵字,但除開new關鍵字之外,也存在其他幾種創(chuàng)建對象的方式,如下:

  • ①通過調(diào)用Class類的newInstance方法完成對象創(chuàng)建。
  • ②通過反射機制調(diào)用Constructor類的newInstance方法完成創(chuàng)建。
  • ③類實現(xiàn)Cloneable接口,通過clone方法克隆對象完成創(chuàng)建。
  • ④從本地文件、網(wǎng)絡中讀取二進制流數(shù)據(jù),通過反序列化完成創(chuàng)建。
  • ⑤使用第三方庫Objenesis完成對象創(chuàng)建。

但無論通過哪種方式進行創(chuàng)建對象,虛擬機都會將創(chuàng)建的過程分為三步:類加載檢測、內(nèi)存分配以及對象頭設置。

2.1、類加載檢測

當虛擬機遇到一條創(chuàng)建指令時,首先去檢查這個指令的參數(shù)是否能在常量池中定位到一個類的符號引用,同時并檢查這個符號引用代表的類是否被加載解析初始化過。如果沒有,在雙親委派模式下,使用當前類加載器以當前創(chuàng)建對象的全限定名作為key值進行查找對應的.class文件,如果沒有找到文件,則拋出ClassNotFoundException異常,找到了則先完成類加載過程,完成了類加載過程后,再開始為其對象分配內(nèi)存。

2.2、內(nèi)存分配

當一個對象的類已經(jīng)被加載后,會依據(jù)第一階段分析的方式去計算出該對象所需的內(nèi)存空間大小,計算出大小后會開始對象分配過程,而內(nèi)存分配就是指在內(nèi)存中劃出一塊與對象大小相等的區(qū)域出來,然后將對象放進去的過程。但需要額外注意的是:Java的對象并不是直接一開始就嘗試在堆上進行分配的,分配過程如下:

對象分配過程

2.2.1、棧上分配

棧上分配是屬于C2編譯器的激進優(yōu)化,如果對于C2的激進優(yōu)化手段不明白的小伙伴可以參考之前的第三篇文章:《全面詳解執(zhí)行引擎子系統(tǒng)與JIT即時編譯原理》,建立在逃逸分析的基礎上,使用標量替換拆解聚合量,以基本量代替對象,然后最終做到將對象拆散分配在虛擬機棧的局部變量表中,從而減少對象實例的產(chǎn)生,減少堆內(nèi)存的使用以及GC次數(shù)。

逃逸分析:逃逸分析是建立在方法為單位之上的,如果一個成員在方法體中產(chǎn)生,但是直至方法結(jié)束也沒有走出方法體的作用域,那么該成員就可以被理解為未逃逸。反之,如果一個成員在方法最后被return出去了或在方法體的邏輯中被賦值給了外部成員,那么則代表著該成員逃逸了。
標量替換:建立在逃逸分析的基礎上使用基本量標量代替對象這種聚合量,標量泛指不可再拆解的數(shù)據(jù),八大基本數(shù)據(jù)類型就是典型的標量。

如果對象被分配在棧上,那么該對象就無需GC機制回收它,該對象會隨著方法棧幀的銷毀隨之自動回收。但如果一個對象大小超過了??捎每臻g(棧總大小-已使用空間),那么此時就不會嘗試將對象進行棧上分配。

棧上分配因為是建立在逃逸分析之上的,所以能夠被棧上分配的對象絕對是只在棧幀內(nèi)有用的,也就代表棧上分配的對象不會有GC年齡,隨著棧幀的入棧出棧動作而創(chuàng)建銷毀。

2.2.2、TLAB分配

TLAB全稱叫做Thread Local Allocation Buffer,是指JVM在Eden區(qū)為每條線程劃分的一塊私有緩沖內(nèi)存。在上篇對于JVM內(nèi)存區(qū)域分析的文章中曾分析到:大部分的Java對象是會被分配在堆上的,但也說到過堆是線程共享的,那么此時就會出現(xiàn)一個問題:當JVM運行時,如果出現(xiàn)兩條線程選擇了同一塊內(nèi)存區(qū)域分配對象時,不可避免的肯定會發(fā)生競爭,這樣就導致了分配速度下降,舉個例子理解一下:

背景:唐朝
故事:建房子
張三和李四兩家的孩子都長大了(在古代男子成年后需要分家),張三和李四都有點小錢,所以都想著花錢去官府買塊地,然后給各自的孩子建棟房子,后面張三和李四看上了同一塊地皮,雙方都不肯謙讓。此時該怎么辦?必然會出現(xiàn)沖突,誰贏了這塊地歸誰。而雙方一發(fā)生沖突,從吵架、打架、報官、調(diào)解....,又會耽誤一大段時間,最終導致建房子的事情一拖再拖....

從上述這個故事中可以看出,這種“多者看上同一塊地皮”的事情是非常影響性能的,那此時如何解決這類問題呢?

對于官府而言,類似“張三李四”這樣的事情如果是少量發(fā)生還好,但這種事情三天兩頭來一起,最終地方官府上報給朝廷,朝廷為了根治這類問題,直接推出了“土地私有化”制度,給每戶人家分配幾畝土地,如果要給自己的孩子建房子,那么不需要再在官府花錢買公用土地了,直接在自己分配的土地上建房子,此時這個問題就被根治了。

而在JVM中也存在類似的煩惱,在為對象分配內(nèi)存時,往往會出現(xiàn)多條線程競爭同一塊內(nèi)存區(qū)域的“慘案”,虛擬機為了根治這個問題同樣采取了類似于上述故事中“朝廷”的手段,為每條線程專門分配一塊內(nèi)存區(qū)域,這塊區(qū)域就被稱為TLAB區(qū),當一條線程嘗試為一個對象分配內(nèi)存時,如果開啟了TLAB分配的情況下,那么會先嘗試在TLAB區(qū)域進行分配。(程序啟動時可以通過參數(shù)-XX:UseTLAB設置是否開啟TLAB分配)。

而值得一提的是:TLAB并不是獨立在堆空間之外的區(qū)域,而是JVM直接在Eden區(qū)為每條線程劃分出來的。默認情況下,TLAB區(qū)域的大小只占整個Eden區(qū)的1%,不過也可以通過參數(shù):-XX:TLABWasteTargetPercent設置TLAB區(qū)所占用Eden區(qū)的空間占比。

一般情況下,JVM會將TLAB作為內(nèi)存分配的首選項(C2激進優(yōu)化下的棧上分配除外),只有當TLAB區(qū)分配失敗時才會開始嘗試在堆上分配。

TLAB分配過程

當創(chuàng)建一個對象時,開啟了激進優(yōu)化的情況時,首先會嘗試棧上分配,如果棧上分配失敗,會進行TLAB分配,首先會比較對象所需空間大小和TLAB剩余可用空間大小,如果TLAB可以放下去,那么就直接將對象分配在TLAB區(qū)。如果TLAB區(qū)的可用空間分配不下該對象,則會先判斷剩余空間是否大于規(guī)定的最大空間浪費大小,如果大于則直接在堆上進行分配,如果不大于則先使用空對象填充內(nèi)存間隙,然后將當前TLAB退回堆空間,重新根據(jù)期望值申請一個新的TLAB區(qū),再次進行分配。如下:

TLAB分配過程

在上面的TLAB分配過程分析中,提到了幾個名詞:最大空間浪費大小、內(nèi)存間隙以及期望值,釋義如下:
最大空間浪費:其意如名,是指JVM允許一個TLAB區(qū)最多剩余多少內(nèi)存不使用,一般來說這個值是動態(tài)的。
內(nèi)存間隙:當前 TLAB不夠分配時,如果剩余空間小于最大空間浪費限制,那么這個 TLAB區(qū)會被退回Eden區(qū),然后重新申請一個新的TLAB,而這個TLAB被退回到Eden區(qū)之后,該TLAB的剩余空間就會成為孔隙。如果不管這些孔隙,由于TLAB僅線程內(nèi)知道哪些被分配了,在GC掃描發(fā)生時,又需要做額外的檢查,那么會影響GC掃描效率。所以TLAB回歸Eden的時候,會將剩余可用的空間用一個dummy object(空對象) 填充滿。如果填充已經(jīng)確認會被回收的對象,也就是dummy object,GC會直接標記之后跳過這塊內(nèi)存,增加GC掃描效率。
期望值:期望值這個概念在JVM中是慣用的思想,無論是JIT還是GC等,都以期望值作為激進優(yōu)化的基礎,這個期望是根據(jù)JVM運行期間的“歷史數(shù)據(jù)”計算得出的,也就是每次輸入采樣值,根據(jù)歷史采樣值得出最新的期望值。

TLAB中常用的期望值算法EMA - 指數(shù)移動平均數(shù)算法

EMA(Exponential Moving Average)算法的核心在于設置合適的最小權重,最小權重越大,變化得越快,受歷史數(shù)據(jù)影響越小。根據(jù)應用設置合適的最小權重,可以讓你的期望更加理想。具體可以參考:百度百科

注意:當TLAB退回給堆空間時,那原本里面存儲的對象需要挪動到新的TLAB區(qū)域嗎?
答案是不需要的,因為TLAB區(qū)本身使用的就是Eden區(qū)的內(nèi)存劃出來的,所以直接將間隙內(nèi)存填充好空對象之后退回給堆空間即可,原本的對象不需要挪動到新分配的TLAB區(qū)中,照樣是可以通過原本的引用指針訪問之前位置中的對象的,唯一需要改變的就是將線程的TLAB區(qū)指向改成新申請的內(nèi)存區(qū)域。

2.2.3、年老代分配

如果在TLAB區(qū)嘗試分配失敗后,對象會進行判定:是否滿足年老代分配標準,如果滿足了則直接在年老代空間中分配??赡苡行┬』锇闀苫螅簩ο蟛皇窍葒L試在新生代進行分配之后,再進入年老代分配嗎?其實這是錯誤的概念,對象在初次分配時會先進行判定一次是否符合年老代分配標準,如果符合則直接進入年老代。

年老代分配條件

初次分配時,大對象直接進入年老代。
一般對象進入年老代的情況只有三種:大對象、長期存活對象以及動態(tài)年齡判斷符合條件的對象,在JVM啟動的時候你可以通過-XX:PretenureSizeThreshold參數(shù)指定大對象的閾值,如果對象在分配時超出這個大小,會直接進入年老代。

這樣做的好處在于:可以避免一個大對象在兩個survivor區(qū)域來回反復橫跳。因為每次新生代GC時,都會將存活的對象從一個survivor區(qū)移動到另外一個survivor區(qū),而一般來說,大對象絕對不屬于朝生夕死的對象,所以就代表著:大對象被分配之后很大幾率都會在兩個survivor區(qū)來回移動,大對象的移動對于JVM來說是比較沉重的負擔,內(nèi)存分配、數(shù)據(jù)拷貝等都需要時間以及資源開銷。同時因為大對象的遷移會存在耗時,所以也會導致GC時間變長。

所以對于大對象而言,直接進入年老代會比較合適,這也屬于JVM的細節(jié)方面優(yōu)化。

上述的這段是基于分代GC器而言的,實則不同的GC器對于大對象的判定標準也不一樣,尤其是到了后面的不分代GC器,大對象則不會進入年老代,而是會有專門存儲大對象的區(qū)域,如G1、ShenandoahGC中的Humongous區(qū)、ZGC中的Large區(qū)等。而這些GC器對于大對象的判定標準可以參考上篇:《深入理解虛擬機運行時數(shù)據(jù)區(qū)與內(nèi)存溢出、內(nèi)存泄露剖析》中的堆空間講解部分。

2.2.4、新生代分配

如果棧上分配、TLAB分配、年老代分配都未成功,此時就會來到Eden區(qū)嘗試新生代分配。而在新生代分配時,會存在兩種分配方式:

  • ①指針碰撞:指針碰撞是Java在為對象分配堆內(nèi)存時的一種內(nèi)存分配方式,一般適用于Serial、ParNew等不會產(chǎn)生內(nèi)存碎片、堆內(nèi)存完整的的垃圾收集器。
    • 分配過程:堆中已用分配內(nèi)存和為分配的空閑內(nèi)存分別會處于不同的一側(cè),通過一個指針指向分界點區(qū)分,當JVM要為一個新的對象分配內(nèi)存時,只需把指針往空閑的一端移動與對象大小相等的距離即可。
  • ②空閑列表:與指針碰撞一樣,空閑列表同樣是Java在為新對象分配堆內(nèi)存時的一種內(nèi)存分配方式,一般適用于CMS等一些會產(chǎn)生內(nèi)存碎片、堆內(nèi)存不完整的垃圾收集器。
    • 分配過程:堆中的已用內(nèi)存和空閑內(nèi)存相互交錯,JVM通過維護一張內(nèi)存列表記錄可用的空閑內(nèi)存塊信息,當創(chuàng)建新對象需要分配內(nèi)存時,從列表中找到一個足夠大的內(nèi)存塊分配給對象實例,并同步更新列表上的記錄,當GC收集器發(fā)生GC時,也會將已回收的內(nèi)存更新到內(nèi)存列表。

上述的兩種內(nèi)存分配方式,指針碰撞的方式更適用于內(nèi)存整齊的堆空間,而空閑列表則更適合內(nèi)存不完整的堆空間,一般來說,JVM會根據(jù)當前程序采用的GC器來決定究竟采用何種分配方式。

在Eden區(qū)分配內(nèi)存時,因為是共享區(qū)域,必然會存在多條線程同時操作的可能,所以為了避免出現(xiàn)線程安全問題,在Eden區(qū)分配內(nèi)存時需要進行同步處理,在HotSpot VM中采用的是線程CAS+失敗換位重試的方式保證原子性。

2.2.5、內(nèi)存分配小結(jié)

至此,關于Java對象的內(nèi)存分配階段已闡述完畢,簡單來說,如果當前JVM處于熱機狀態(tài),C2編譯器已經(jīng)介入的情況下,首先會嘗試將對象在棧上分配,如果棧上分配失敗則會嘗試TLAB分配,TLAB分配失敗則會判定對象是否滿足年老代分配標準,如果滿足則直接將對象分配在年老代,反之則嘗試將對象在新生代Eden區(qū)進行分配。

JVM如果處于冷機狀態(tài),C2編譯器還未工作的情況下,則TLAB分配作為對象分配的首選項。

2.3、初始化內(nèi)存

經(jīng)過內(nèi)存分配的步驟之后,當前創(chuàng)建的Java對象會在內(nèi)存中被分配到一塊區(qū)域,接著則會初始化分配到的這塊空間,JVM會將分配到的內(nèi)存空間(不包括對象頭)都初始化為零值,這樣做的好處在于:可以保證對象的實例字段在Java代碼中不賦初始值就直接使用,程序可以訪問到字段對應數(shù)據(jù)類型所對應的零值,避免不賦值直接訪問導致的空指針異常。

如果對象是被分配在棧上,那所有數(shù)據(jù)都會被分配在棧幀中的局部變量表中。
如果對象是TLAB分配,那么初始化內(nèi)存這步操作會被提前到內(nèi)存分配的階段進行。

2.4、設置對象頭

當初始化零值完成后,緊接著會對于對象的對象頭進行設置。首先會將對象的原始哈希碼、GC年齡、鎖標志、鎖信息組裝成MrakWord放入對象頭中,然后會將指向當前對象類元數(shù)據(jù)的類型指針KlassWord也加入對象頭中,如果當前對象是數(shù)組對象,那么還會將編碼時指定的數(shù)組長度ArrayLength放入對象中,最終當對象頭中的所有數(shù)據(jù)全部組裝完成后,會將該對象頭放在對象分配的內(nèi)存區(qū)域中存儲。

2.5、執(zhí)行<init>函數(shù)

當上述步驟全部完成后,最后會執(zhí)行<init>函數(shù),也就是構(gòu)造函數(shù),主要是對屬性進行顯式賦值。從Java層面來說,這也是真正的按照開發(fā)者的意愿對一個對象進行初始化賦值,經(jīng)過這個步驟之后才能夠在真正意義上構(gòu)建出一個可用對象。

三、一個對象從生到死的歷程

經(jīng)過分配過程之后,一個Java對象便在內(nèi)存中真正的誕生了,對象最終會出現(xiàn)在Eden區(qū)(TLAB分配也是在Eden區(qū),棧上分配不算),而線程棧中會出現(xiàn)一個指向?qū)ο蟮囊?,之后需要使用該對象時,直接通過引用中的直接地址或句柄訪問該塊內(nèi)存區(qū)域中的對象數(shù)據(jù)。

3.1、對象的訪問方式

在Java中對象都是通過reference訪問的,reference主要分為兩種訪問方式,一種為句柄訪問,另一種則為直接指針訪問。

3.1.1、句柄訪問

Java堆中會專門劃分出一塊內(nèi)存區(qū)域作為句柄池,用于存儲所有引用的地址,reference中存儲的就是對象的句柄地址,句柄包含對象實例數(shù)據(jù)與類型數(shù)據(jù)的信息,如下:

句柄訪問方式

當需要使用對象時,會先訪問reference中存儲的句柄地址,然后根據(jù)句柄地址中存儲的實際內(nèi)存地址再次定位后,訪問對象在內(nèi)存中的數(shù)據(jù)。

3.1.2、直接指針訪問

如果采用直接指針的方式訪問,那么reference中存儲的就是對象在堆中的內(nèi)存地址,而類型指針則放入到了對象頭中存儲,如下:

直接指針方式訪問

這種訪問模式下,當需要使用對象時,可以直接通過reference中存儲的堆內(nèi)存地址定位并訪問對象數(shù)據(jù)。

3.1.3、訪問方式小結(jié)

使用句柄方式訪問帶來的最大好處是:reference中存放的是穩(wěn)定句柄地址,在對象被移動(GC時會發(fā)生)時只改變句柄中實例數(shù)據(jù)指針,reference本身不用改變。但是總體來說,每次訪問對象時都需要經(jīng)過一次轉(zhuǎn)發(fā),訪問速度會比直接指針方式慢上很多。

使用指針訪問訪問帶來的最大好處就是速度快,節(jié)省了一次指針定位的時間開銷,由于對象訪問在Java中非常頻繁,所以積少成多,從整體上來看也是節(jié)省了非??捎^的執(zhí)行成本。但是當GC發(fā)生對象移動時,被移動的對象對應的所有reference中的引用信息也需要同步更新。

HotSpot虛擬機中是采用指針的訪問方式,通過直接指針定位并訪問對象數(shù)據(jù)(但使用Shenandoah收集器的話,也會有一次額外的轉(zhuǎn)發(fā))。

3.2、GC時的對象移動與對象晉升

在HotSpot中是通過直接指針方式訪問對象的,而運行過程中,reference位于線程棧中,對象的實例數(shù)據(jù)則存儲在堆中。當一條線程執(zhí)行完成一個方法后,與該方法對應的棧幀會被銷毀,而棧幀中的局部變量表也會隨之銷毀,此時局部變量表中的reference也會被回收。而此時堆中的對象就變成了沒有指針引用的“垃圾”對象,如果在下一次GC發(fā)生前還是沒有新的指針引用它,那么該對象則會被回收(具體的過程會在GC篇詳細闡述)。

而那些在GC發(fā)生時,依舊還存在著引用的對象,那么則會將其從Eden區(qū)移入到Survivor區(qū)中,而移動之后,與之對應的reference中的指針也必須要改為最新的內(nèi)存地址。

新生代中一共存在兩個Survivor區(qū):S0/S1,也被稱為或From/To區(qū),這兩個區(qū)域在同一時刻,永遠有一個是空的,當下次GC發(fā)生時,作為存活對象新的“避難所”。但From/To兩個名詞并不是一個區(qū)域固定的稱呼,而是動態(tài)的,存放對象的Survivor區(qū)被稱為From區(qū),而空的Survivor區(qū)被稱為To區(qū)。

當對象移動一次,那么對象頭內(nèi)MrakWord中的對象年齡則會+1(剛創(chuàng)建的對象年齡為0)。而大部分的分代GC器中,對于老年代的晉升標準默認為15歲(CMS為8歲),也就是當對象來回移動16次之后,這些依舊存活的對象會被轉(zhuǎn)入年老代存儲,可以通過參數(shù)-XX:MaxTenuringThreshold更改年齡閾值。

3.2.1、動態(tài)對象年齡判定

一般情況下,正常對象是需要達到指定的年齡閾值才能進入年老代的,但為了能更好的適應不同程序的內(nèi)存狀況,JVM并不總是要求對象的年齡必須達到閾值才能晉升到年老代,如果在Survivor區(qū)中相同年齡的所有對象大小總和大于Survivor空間的一半,那么Survivor區(qū)中所有大于或等于該年齡的對象就可以直接進入年老代,無需等到滿足閾值的標準后再晉升,這種晉升方式也被稱為JVM的動態(tài)對象年齡判定。

3.2.2、空間分配擔保機制

分配擔保是指年老代為新生代提供擔保,可以通過HandlePromotionFailure參數(shù)關閉或開啟(JDK1.6之后默認開啟)。當發(fā)生GC時,一個S區(qū)空間無法儲存Eden區(qū)和另外一個S區(qū)的存活對象時,這些對象會被直接轉(zhuǎn)移到年老代,這個過程就是空間分配擔保。在進行MinorGC前,如果老年代的連續(xù)空間大于新生代對象大小總和或歷次晉升的平均大小,如果大于,則此次MinorGC是安全的,則進行MinorGC,否則進行FullGC。

分配擔保的作用:假如大量對象在新生代發(fā)生GC后依舊存活(最極端情況為GC后新生代中所有對象全部存活),而Survivor空間是比較小的,這時就需要老年代進行分配擔保,把Survivor無法容納的對象放到老年代。老年代要進行空間分配擔保,前提是老年代得有足夠空間來容納這些對象,但一共有多少對象在內(nèi)存回收后存活下來是不可預知的,因此只好取之前每次垃圾回收后晉升到老年代的對象大小的平均值作為參考。使用這個平均值與老年代剩余空間進行比較,來決定是否進行FullGC來讓老年代騰出更多空間。

3.3、小結(jié)

對象創(chuàng)建之后,實例數(shù)據(jù)存在堆中,運行時線程通過棧幀中的指針訪問對象,當方法執(zhí)行結(jié)束時,對應的指針也會隨之銷毀,而堆中的對象會隨著下一次GC的來臨而被回收,而躲過一次GC的對象年齡會+1,當對象年齡達到指定閾值或滿足動態(tài)對象年齡判定標準等情況時,會從新生代移入到年老代存儲。

四、對象引用類型-強軟弱虛全面分析

在JDK1.2中,Java對引用概念的進行了拓充,在1.2之后Java提供了四個級別的引用,按照引用強度依次排序為強引用(StrongReference)、軟引用(SoftReference)、弱引用(WeakReference)、虛引用(PhantomReference)引用。除開強引用類型外,其余三種引用類型均可在java.lang.ref包中找到對應的類,開發(fā)過程中允許直接使用這些引用類型操作。

4.1、強引用類型(StrongReference)

強引用類型是Java程序運行過程中最常見的引用類型,通過new質(zhì)量創(chuàng)建出來的對象都屬于強引用類型,堆中的對象與棧中的變量保持著直接引用。如下:

Object obj = new Object();

在上述代碼中,通過new指令創(chuàng)建的Object實例會被分配在堆中存儲,而變量str會被放在當前方法對應的棧幀內(nèi)的局部變量表中存儲,在運行時可以直接通過str變量操作堆中的實例對象,那么str就是該Object實例對象的強引用。

周所周知,如果在Java程序運行過程中堆內(nèi)存不足時,GC機制會被觸發(fā),GC收集器會開始檢測可回收的"垃圾"對象,但是當GC器遇到存在強引用的對象時,GC機制不會強制回收它,因為存在強引用的對象都會被判定為“存活”對象,當GC掃描幾圈下來之后,發(fā)現(xiàn)堆中的對象都存在強引用時,這種情況GC機制寧愿拋出OOM也不會強制回收一部分對象。
因為保持強引用的對象是不會被GC機制回收的,所以一般在編碼時如果確定一個對象不再使用后,可以顯示的將對象引用清空,如:obj=null;,這樣能夠方便GC機制在查找垃圾時直接發(fā)現(xiàn)并標記該對象。

4.2、軟引用類型(SoftReference)

軟引用是指使用java.lang.ref.SoftReference類型修飾的對象,當一個對象只存在軟引用時,在堆內(nèi)存不足的情況下,該引用級別的對象將被GC機制回收。不過當堆內(nèi)存還充足的情況下,該引用級別的對象是不會被回收的,所以平時如果需要實現(xiàn)JVM級別的簡單緩存,那么可以使用該級別的引用類型實現(xiàn)。使用案例如下:

SoftReference<HashMap> cacheSoftRef = 
    new SoftReference<HashMap>(new HashMap<Object,Object>());
cacheSoftRef.get().put("竹子","熊貓");
System.out.println(cacheSoftRef.get().get("竹子"));

如上案例中便通過軟引用類型實現(xiàn)了一個簡單的緩存器。

4.3、弱引用類型(WeakReference)

弱引用類型是指使用java.lang.ref.WeakReference類型修飾的對象,與軟引用的區(qū)別在于:弱引用類型的對象生命周期更短,因為弱引用類型的對象只要被GC發(fā)現(xiàn),不管當前的堆內(nèi)存資源是否緊張,都會被GC機制回收。不過因為GC線程的優(yōu)先級比用戶線程更低,所以一般不會立馬發(fā)現(xiàn)弱引用類型對象,因此一般弱引用類型的對象也會有一段不短的存活周期。

從軟引和弱引的特性上來看,它們都適合用來實現(xiàn)簡單的緩存機制,用于保存那些可有可無的緩存數(shù)據(jù),內(nèi)存充足時可以稍微增加程序的執(zhí)行效率,而內(nèi)存緊張時會被回收,不會因此導致OOM。

4.4、虛引用類型(PhantomReference)

虛引用也在有些地方被稱為幽靈引用,虛引用是指使用java.lang.ref.PhantomReference類型修飾的對象,不過在使用虛引用的時候是需要配合ReferenceQueue引用隊列才能聯(lián)合使用。與其他的幾種引用類型不同的是:虛引用不會決定GC機制對一個對象的回收權,如果一個對象僅僅存在虛引用,那么GC機制將會把他當成一個沒有任何引用類型的對象,隨時隨刻可以回收它。不過它還有個額外的用途:跟蹤垃圾回收過程,也正是由于虛引用可以跟蹤對象的回收時間,所以也可以將一些資源釋放操作放置在虛引用中執(zhí)行和記錄。

當GC機制準備回收一個對象時發(fā)現(xiàn)它還存在虛引用,那么GC機制就會在回收前,把虛引用加入到與之關聯(lián)的引用隊列中,程序可以通過判斷隊列中是否加入該虛引用,來判斷被引用的對象是否將要GC回收,從而可以在finalize方法中采取一些對應的處理措施。

五、Java對象總結(jié)

前面的內(nèi)容從對象的內(nèi)存布局、分配過程、對象晉升、對象移動、訪問方式、對象引用等多個方面對Java對象進行了全面分析,至此,關于Java對象的探秘篇就結(jié)束了,下個章節(jié)中則會全面對Java的GC機制進行深入分析。

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容