原來New關(guān)鍵字創(chuàng)建對象的背后還隱藏了這么多秘密,看完這篇文章我頓悟了
前言
對于前面幾篇文章, 主要就是說明了一個.java文件是如何一步步編譯, 解析最后加載到JVM中運行的, 那么本篇文章將說明對象是如何創(chuàng)建的, 包括創(chuàng)建過程、對象頭與指針壓縮、jvm對象內(nèi)存分配詳解、逃逸分析,線上分配,標(biāo)量替換等等內(nèi)容。
內(nèi)容有點多,所以準(zhǔn)備分為三篇文章來寫:
- JVM對象創(chuàng)建及對象大小與指針壓縮
- 對象內(nèi)存分配
- 對象內(nèi)存回收
如果感覺文章中有的圖片字太小不清楚的可以通過公眾號加我,然后說明是哪篇文章的圖片,然后我發(fā)給你。
對象的創(chuàng)建
對象創(chuàng)建的主要流程:
圖片
1.類加載檢查
虛擬機遇到一條new指令時,首先將去檢查這個指令的參數(shù)是否能在常量池中定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執(zhí)行相應(yīng)的類加載過程。new指令對應(yīng)到語言層面上講是,new關(guān)鍵詞、對象克隆、對象序列化等。
對于我們來說,我們寫的java代碼是new 一個對象,實際上對于底層jvm實際上是執(zhí)行了一個new 指令。
這里用的插件是:jclasslib Bytecode Viewer
圖片
首先會判斷這個類有沒有被加載過,如果沒有加載過,那么它首先會執(zhí)行加載類的過程(前幾篇文章有講),如果加載過了,那么就要開始new對象了,這個對象一般來說可能放在堆中也有可能放在棧里邊,但是不管放在哪,前提都是需要分配一塊內(nèi)存空間的。
2.分配內(nèi)存
在類加載檢查通過后,接下來虛擬機將為新生對象分配內(nèi)存。對象所需內(nèi)存的大小在類 加載完成后便可完全確定,為對象分配空間的任務(wù)等同于把 一塊確定大小的內(nèi)存從Java堆中劃分出來。
這個步驟有兩個問題:
- 如何劃分內(nèi)存。
- 在并發(fā)情況下, 可能出現(xiàn)正在給對象A分配內(nèi)存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內(nèi)存的情況。
劃分內(nèi)存的方法:
“指針碰撞”(Bump the Pointer)(默認(rèn)用指針碰撞)
如果Java堆中內(nèi)存是絕對規(guī)整的,所有用過的內(nèi)存都放在一邊,空閑的內(nèi)存放在另一邊,中間放著一個指針作為分界點的指示器,比如下圖中藍色實線表示當(dāng)前指針位置,虛線表示挪動后的位置,那所謂分配內(nèi)存就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離。
圖片
“空閑列表”(Free List)
如果Java堆中的內(nèi)存并不是規(guī)整的,已使用的內(nèi)存和空 閑的內(nèi)存相互交錯,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內(nèi)存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例, 并更新列表上的記錄。
圖片
但是具體使用的是指針碰撞的方式還是使用的是空閑列表的分配方式,取決于使用的什么垃圾回收算法,如果使用的是標(biāo)記整理的話,那么最終剩余的內(nèi)存肯定是第一種,那么使用的也就是指針碰撞的方式,如果使用的是標(biāo)記清除的話,那么最終剩余的內(nèi)存肯定是第二種,所以就使用空閑列表的方式來分配內(nèi)存。
解決并發(fā)問題的方法:
不管使用哪種方式分配,都會出現(xiàn)并發(fā)問題,也就是兩個線程同時創(chuàng)建了一個對象,然后爭搶同一塊內(nèi)存
圖片
多個線程創(chuàng)建了多個對象,但是內(nèi)存空間只有一塊,那么jvm為了解決這種并發(fā)問題,采取了以下兩種措施
CAS(compare and swap)
虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性來對分配內(nèi)存空間的動作進行同步處理。
CAS配上失敗重試也就是線程A和線程B同時爭搶這一塊內(nèi)存,如果線程A先爭搶到了這塊內(nèi)存,那么線程B重新進行分配,發(fā)現(xiàn)這塊內(nèi)存分配給了線程A,然后就會在這塊內(nèi)存后面進行內(nèi)存分配操作。這樣線程A、B對象的內(nèi)存空間就在并發(fā)的情況下被分配了。
本地線程分配緩沖(TLAB: Thread Local Allocation Buffer)
把內(nèi)存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中(比如Eden區(qū))預(yù)先分配一小塊內(nèi)存。通過-XX:+/-UseTLAB參數(shù)來設(shè)定虛擬機是否使用TLAB(JVM會默認(rèn)開啟-XX:+UseTLAB)
那么這個內(nèi)存也不可能特別大,好像默認(rèn)是Eden區(qū)的1% , 通過-XX:TLABSize 可以指定TLAB大小。如果這個時候放不下了,那么就會恢復(fù)CAS配上失敗重試的方式進行分配。當(dāng)然,一般不推薦你去改JVM默認(rèn)的參數(shù)設(shè)置。
圖片
線程A和線程B在Eden區(qū)預(yù)先分配一塊屬于自己的內(nèi)存空間,然后把各自的對象放到各自的空間種。JDK8默認(rèn)使用的就是這種方式。
對象的分配過程會在下一篇文章詳細說明。
3.初始化零值
內(nèi)存分配完成后,虛擬機需要將分配到的內(nèi)存空間都初始化為零值(不包括對象頭), 如果使用TLAB,這一工作過程也可以提前至TLAB分配時進行。這一步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數(shù)據(jù)類型所對應(yīng)的零值。
也就是對于對象的成員變量,比如int initData = 666;那么在這個過程,會先給initData 賦一個0值,就和前面有一篇文章中提到過靜態(tài)變量的初始化賦值是一樣的。最終可能有一步會把真正的值666賦給initData。
4.設(shè)置對象頭
初始化零值之后,虛擬機要對對象進行必要的設(shè)置,例如這個對象是哪個類的實例、如何才能找到類的元數(shù)據(jù)信息、對象的哈希碼、對象的GC分代年齡等信息。這些信息存放在對象的對象頭Object Header之中。
在HotSpot虛擬機中,對象在內(nèi)存中存儲的布局可以分為3塊區(qū)域:對象頭(Header)、 實例數(shù)據(jù)(Instance Data)和對齊填充(Padding)。HotSpot虛擬機的對象頭包括兩部分信息,第一部分用于存儲對象自身的運行時數(shù)據(jù), 如哈希碼(HashCode)、GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時 間戳等。對象頭的另外一部分是類型指針,即對象指向它的類元數(shù)據(jù)的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
32位對象頭
圖片
對象頭中有一個Mark Word標(biāo)記字段,第一列是對象的一個狀態(tài),可能有一些對象被加鎖了或者是被GC標(biāo)記了,不同的對象,它對象頭的結(jié)構(gòu)是不一樣的,比如說一個對象是正常的對象,也就是沒有任何的鎖,對象頭中前面25bit存儲是對象的hashCode,中間4bit存儲的是對象的分代年齡,分代年齡在上一篇文章中有講過,它是4bit,也就以為著它的分代年齡是<=15的,因為4位(bit)大小可以表示從0到15的數(shù)值,因此無法存儲大于15的數(shù)值,當(dāng)然還有一些偏向鎖、鎖標(biāo)志位等鎖的標(biāo)記。關(guān)于鎖的相關(guān)內(nèi)容也會在后面寫并發(fā)相關(guān)的文章的時候進行詳解。
還有一塊就是Klass Pointer類型指針,一個對象new出來之后是放到堆中的,但是在這個對象的頭部區(qū)域,有一個指針,指向方法區(qū)的對象所屬的類的元數(shù)據(jù)信息。如下圖中畫紅線的地方的示例。
圖片
比如說就是下面這一段代碼,要想在元數(shù)據(jù)區(qū)找到compute方法對應(yīng)的代碼,就是通過這個類型指針Klass Pointer去找。
圖片
那么還有一個對象叫做類對象,比如Math類所屬的對象mathClass,這個對象是放在堆中的。
圖片
堆中的這個mathClass對象和元數(shù)據(jù)區(qū)的Math.class是什么關(guān)系呢?
Math.class是類的元數(shù)據(jù)信息,也就是我們編寫的代碼,那么mathClass是類裝載完之后,是jvm給我們開發(fā)人員在我們想訪問類的元數(shù)據(jù)信息是提供的一個對象,我們可以通過這個對象mathClass去訪問類的元數(shù)據(jù)信息,簡單一點就是反射,通過反射是可以獲取到很多信息的,類的名稱。方法的名稱等等。但是mathClass對象中是不會存儲這些代碼的,代碼只是存儲在方法區(qū)。
這個是jvm提供給我們開發(fā)人員去使用的,但是jvm內(nèi)部不會這么干,而是通過剛剛講的類型指針。而元數(shù)據(jù)信息的存儲介質(zhì)是C++對象,這個類型指針也是C++實現(xiàn)的。
還有一塊就是數(shù)組的長度,如果是一個數(shù)組對象的話,對象頭中還有一塊會存儲數(shù)組的長度。
64位對象頭
圖片
對象頭在hotspot的C++源碼markOop.hpp文件里的注釋如下:
// Bit-format of an object header (most significant first, big endian layout below):
//
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
5.執(zhí)行方法
執(zhí)行方法,即對象按照程序員的意愿進行初始化。對應(yīng)到語言層面上講,就是為屬性賦值(注意,這與上面的賦零值不同,這是由程序員賦的值),和執(zhí)行構(gòu)造方法。
這一步的話比如就會把initData賦值為666, 因為在初始化零值這個步驟中initData被賦值為0,這一步可以說是真正的進行賦值。也就是下圖中框起來的部分,這個過程是C++調(diào)用的。
圖片
對象大小與指針壓縮
對象大小可以用jol-core包查看,引入依賴
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
以下這幾行代碼的話主要就是想查看new Object() 以及new int[]{}還有new A()對象的大小。
package com.liuxs.fusionx;
import org.openjdk.jol.info.ClassLayout;
/**
* @author: Liu Yuehui
* @ClassName: JOLSample
* @date: 2023/11/27 0:25
* @description: 查看對象大小
* @version:v1:2023/11/27 0:25:
**/
public class JOLSample {
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 A());
System.out.println(layout2.toPrintable());
}
// -XX:+UseCompressedOops 默認(rèn)開啟的壓縮所有指針
// -XX:+UseCompressedClassPointers 默認(rèn)開啟的壓縮對象頭里的類型指針Klass Pointer
// Oops : Ordinary Object Pointers
public static class A {
//8B mark word
//4B Klass Pointer 如果關(guān)閉壓縮-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,則占用8B
int id; //4B
String name; //4B 如果關(guān)閉壓縮-XX:-UseCompressedOops,則占用8B
byte b; //1B
Object o; //4B 如果關(guān)閉壓縮-XX:-UseCompressedOops,則占用8B
}
}
運行結(jié)果:
圖片
Object對象大概是可以分為以下幾塊
圖片
這里的類型指針只占了4個字節(jié)是因為64位系統(tǒng)默認(rèn)是8字節(jié),但是會涉及到指針壓縮,壓縮之后就是4字節(jié)。
這里有一個叫對象對齊,也就是上面說到對象頭的第三塊對齊填充(Padding),這塊部分有的時候有,有的時候沒有,也就是jvm內(nèi)部會把內(nèi)存的讀取信息按照8個字節(jié)對齊,這個是整個jvm底層包括計算機組成原理經(jīng)過大量實踐證明的,也就是通過8個字節(jié)的對象的對齊,會讓整個計算機的存取效率非常之高。
比如我這個操作系統(tǒng)是64位的,它的內(nèi)存大概是一格一格的,比如下面這張圖,一共就是64位,現(xiàn)在有個對象只占一點空間,你在查這個對象的時候,還要評估這個對象的大小,然后從這個大小的起始位置去偏移,這就比較麻煩了,那么8個字節(jié)的存取說白了就是對象尋址最優(yōu)的一種方式。
圖片
比如Object對象中,Mark Word標(biāo)記字段和Klass pointer類型指針占了12字節(jié),也就是這個Object對象真正的大小是12字節(jié),但是為了滿足對象對齊是8的整數(shù)倍,所以有搞了4個字節(jié)的對齊,這樣就成了16字節(jié),也就是8的2倍。讓我們對象總共的大小是16字節(jié)。
圖片
數(shù)組對象會多一個數(shù)組長度。
圖片
A對象
其它內(nèi)容一樣,這里就不過多贅述了,這里的bate類型的b只占用了1字節(jié),但是會有內(nèi)部對齊,對齊成為了4字節(jié),然后Object對象只占用了4字節(jié),就是因為Object對象存儲的是指針,只占了4個字節(jié)是因為64位系統(tǒng)默認(rèn)是8字節(jié),但是會涉及到指針壓縮,壓縮之后就是4字節(jié)。
圖片
什么是Java對象的指針壓縮?
現(xiàn)象
圖片
對于上面查看對象大小的代碼先在IDEA中設(shè)置一些jvm的參數(shù)。
XX:-UseCompressedOops禁止指針壓縮
運行結(jié)果
圖片
可以發(fā)現(xiàn)對象對齊沒有了,但是多了一個對象頭,也就是說會有兩個4字節(jié)大小的位置來存儲類型指針。包括A類中name和Object對象也都是8字節(jié),這些對象都是放在堆中的,如果不開啟指針壓縮,會無形的增大很多空間,會導(dǎo)致整個堆的壓力非常大,很容易就放滿,然后GC...
1.jdk1.6 update14開始,在64bit操作系統(tǒng)中,JVM支持指針壓縮。
2.jvm配置參數(shù):UseCompressedOops,compressed--壓縮、oop(ordinary object pointer)--對象指針。
3.啟用指針壓縮:-XX:+UseCompressedOops(默認(rèn)開啟),禁止指針壓縮:-XX:-UseCompressedOops。
為什么要進行指針壓縮?
1.在64位平臺的HotSpot中使用32位指針(實際存儲用64位),內(nèi)存使用會多出1.5倍左右,使用較大指針在主內(nèi)存和緩存之間移動數(shù)據(jù),占用較大寬帶,同時GC也會承受較大壓力。
2.為了減少64位平臺下內(nèi)存的消耗,啟用指針壓縮功能。
3.在jvm中,32位地址最大支持4G內(nèi)存(2的32次方),但是現(xiàn)在的機器基本都是64位的,也就是2的64次方,這絕對是一個非常大的數(shù)字,也就是64位能表述的內(nèi)存非常大,可以通過對對象指針的存入堆內(nèi)存時壓縮編碼、取出到cpu寄存器后解碼方式進行優(yōu)化(對象指針在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的內(nèi)存配置(小于等于32G)。
4.堆內(nèi)存小于4G時,不需要啟用指針壓縮,jvm會直接去除高32位地址,即使用低虛擬地址空間。
5.堆內(nèi)存大于32G時,壓縮指針會失效,會強制使用64位(即8字節(jié))來對java對象尋址,這就會出現(xiàn)1的問題,所以堆內(nèi)存不要大于32G為好。
說的簡單一點就是如果壓縮了,只占用4個字節(jié),如果沒有壓縮占用8個字節(jié),是為了節(jié)約內(nèi)存空間。