一、前言
作為一名java開發工程師,每天要處理上千個物件,你居然說我沒物件?
就算沒有物件,那就new一個唄,
GirlFriend gf = new GirlFriend();
不會就這么容易吧?當然不會!
那么GirlFriend物件到底是怎么產生的呢?
二、類加載
當遇到new指令時,jvm首先去常量池尋找該類的符號參考,找不到,則執行類加載,
以下是類加載各個階段的主要任務,現在記不住也沒有什么關系,

1. 裝載
我覺得這里使用裝載更好一點,第一,可以避免與類加載程序中的“加載”混淆;第二,裝載體現的就是一個“裝”字,僅僅是把貨物從一個地方搬到另外一個地方而已,而這里的加載,卻包含搬運貨物、處理貨物等一系列流程,
裝載階段,將.class位元組碼檔案的二進制資料讀入記憶體中,然后將這些資料翻譯成類的元資料,元資料包括方法代碼,變數名,方法名,訪問權限與回傳值,接著將元資料存入方法區,最后會在堆中創建一個Class物件,用來封裝類在方法區中的資料結構,因此我們可以通過訪問此Class物件,來間接訪問方法區中的元資料,
在Java7與Java8之后,方法區有不同的實作,這部分詳細內容可以參考我的另外一篇博客靈性一問——為什么用元空間替換永久代?
總結來講,裝載的子流程為:
.class檔案讀入記憶體——>元資料放進方法區——>Class物件放進堆中
最后我們訪問此Class物件,即可獲取該類在方法區中的結構,
2. 連接
連接又包括驗證、準備、初始化
2.1 驗證
驗證被加載類的正確性與安全性,看class檔案是否正確,是否對會對虛擬機造成安全問題等,主要去驗證檔案格式與符號參考等,
對整個類加載機制而言,驗證階段是一個很重要但是非必需的階段,如果我們的代碼能夠確保沒有問題,那么就沒有必要去驗證,畢竟驗證需要花費一定的的時間,可以使用-Xverfity:none來關閉大部分的驗證,
2.2 準備
在這個階段中,主要是為類變數(靜態變數)分配記憶體以及初始化默認值,因為靜態變數全域只有一份,是跟著類走的,因此分配記憶體其實是在方法區上分配,
這里有3個注意點:
(1)在準備階段,虛擬機只為靜態變數分配記憶體,實體變數要等到初始化階段才開始分配記憶體,這個時候還沒有實體化該類,因此還不存在實體變數,
(2)為靜態變數初始化默認值,注意,是初始化對應資料型別的默認值,不是自定義的值,
(3)被final修飾的靜態變數,如果值比較小,則在編譯后直接內嵌到位元組碼中,如果值比較大,也是在編譯后直接放入常量池中,因此,準備階段結束后,final型別的靜態變數已經有了用戶自定義的值,而不是默認值,
2.3 決議
決議階段,主要是將class檔案中常量池中的符號參考轉化為直接參考,
符號參考的含義:
可以直接理解為是一個字串,用這個字串來表示一個目標,就像博主的名字是SunAlwaysOnline,這個SunAlwaysOnline字串就是一個符號參考,代表博主,但是現在不能通過名字直接找到我本人,
直接參考的含義:
直接參考是一個指向目標的指標,能夠通過直接參考定位到目標,
將符號參考轉化為直接參考,就能將平淡無奇的字串轉化為指向物件的指標,
3. 初始化
執行初始化,就是虛擬機執行類構造器<clinit>方法的程序,<clinit>方法是由編譯器自動去搜集類中的所有類變數與靜態陳述句塊合并產生的,可能存在多個執行緒同時執行某個類的<clinit>()方法,虛擬機此時會對該方法進行加鎖,保證只有一個執行緒能執行,
到了這個階段,類變數與類成員變數才會被賦予用戶自定義的值,
更詳細的類加載機制與常問的類初始化順序,可以移步我的另外一篇文章類的奇幻漂流——類加載機制探秘
其中值得注意的一點是,類加載中的裝載階段,是利用雙親委派機制進行裝載位元組碼的,
雙親委派機制
當一個類加載器收到了一個類加載請求時,它自己不會先去嘗試加載這個類,而是把這個請求轉交給父類加載器,每一個層的類加載器都是如此,因此所有的類加載請求都應該傳遞到最頂層的啟動類加載器中,只有當父類加載器在自己的加載范圍內沒有搜尋到該類時,并向子類反饋自己無法加載后,子類加載器才會嘗試自己去加載,
加載標準類別庫與用戶代碼,會有不同的方式:

因此,GirlFriend的位元組碼最侄訓由Application ClassLoader進行裝載,回傳Class物件,
這里有一個進階的知識點,那就是對雙親委派的幾次破壞,例如jdbc、tomcat與java9的模塊系統,感興趣的可以參考我的另外一篇文章深度思考:老生常談的雙親委派機制,JDBC、Tomcat是怎么反其道而行之的?
有了Class物件,如果要創建實體物件,還需要在堆上開辟出一塊空間來,
三、在堆上分配記憶體
在堆上分配記憶體前,首先得知道到底要分配多大的空間,
其實在類加載完成后,jvm就已經能計算好物件所占的位元組數,
物件在記憶體中的布局,包括物件頭、實體資料與對齊填充,
物件的記憶體布局
物件頭
物件頭中又包括Mark Word與Klass Word,當該物件是一個陣列時,物件頭還會增加一塊區域,用來保存陣列的長度,以64位系統為例,物件頭存盤內容如下圖所示:
|---------------------------------------------------------------------------------------------------------------|
| Object Header (128 bits) |
|---------------------------------------------------------------------------------------------------------------|
| Mark Word (64 bits) | Klass Word (64 bits) |
|---------------------------------------------------------------------------------------------------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:0 | lock:01 | OOP to metadata object | 無鎖
|----------------------------------------------------------------------|---------|------------------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:01 | OOP to metadata object | 偏向鎖
|----------------------------------------------------------------------|---------|------------------------------|
| ptr_to_lock_record:62 | lock:00 | OOP to metadata object | 輕量鎖
|----------------------------------------------------------------------|---------|------------------------------|
| ptr_to_heavyweight_monitor:62 | lock:10 | OOP to metadata object | 重量鎖
|----------------------------------------------------------------------|---------|------------------------------|
| | lock:11 | OOP to metadata object | GC
|---------------------------------------------------------------------------------------------------------------|
Mark Word
主要存盤hashcode、gc年齡、鎖標志等,在32位系統上,Mark Word為32位,在64位系統上,為64位,即8個位元組,
Klass Word
存盤物件的型別指標,指向物件類元資料,虛擬機能夠通過這個指標,來確定該物件到底是哪個類的實體,在32位系統上,該區域占用32位,在64位系統上,占用64位,但是!當64位機器設定最大堆記憶體為32G以下時,將會默認開啟指標壓縮,將8位元組的指標壓縮為4位元組,當然也可以使用+UseCompressedOops直接開啟指標壓縮,
Array Length
前面說過,如果物件是一個陣列,那么物件頭會增加一個額外的區域,用來記錄陣列的長度,在32位系統上,該區域占用32位,在64位系統上,占用64位,同樣的,如果開啟指標壓縮,則會壓縮到32位,
可以看得出來,一個非陣列的物件的物件頭占用12個位元組,即Mark Word(8)+Klass Word(4),
實體資料
基本資料型別占用的長度就不用多說了,對于參考型別占用的長度,同樣視系統位數而定,32位系統占用4位元組,64位系統8位元組,開啟指標壓縮那就占用4位元組,
實體資料部分只會存放物件的實體資料,并不會存放靜態資料,
對齊填充
HotSpot虛擬機規定物件的起始地址必須是8的整數倍,也就是要求物件的大小必須是8的整數倍,因此如果一個物件的物件頭+實體資料占用的總記憶體沒有達到8的倍數時,會進行對齊填充,將總大小填充到最近的8的倍數上,
在一個64位的機器上,開啟指標壓縮,那么new Object()占用的位元組數為16位元組(Mark Word占用8位元組+Klass Word被壓縮后占用4個位元組+對齊填充占用4個位元組)
怎么去判斷具有父類的子類物件大小,可以參考我的另外一篇文章物件的記憶體布局,怎樣確定物件的大小
因此,下一步需要在堆上找到一塊大小為16位元組的空閑記憶體區域,
分配記憶體區域的機制
指標碰撞
前提是需要堆是規整的,已使用的記憶體占據堆的左側,未使用的記憶體在右側,中間使用一個指標作為分界線,需要分配記憶體空間時,就把指標往空閑的一側移動即可,一般來說,標記復制和標記整理演算法,可以使得堆是規整的,
空閑串列
如果當前堆不是規整的,例如垃圾收集器使用標記清除演算法,此時jvm需要維護一個空閑區域的串列,分配記憶體時,直接去串列中尋找足夠大的空閑區域即可,
記憶體分配并發安全
然而,物件的創建作業是很頻繁的,為了保證效率,JVM可以并發地給物件分配記憶體空間,
由于分配記憶體的時候不是原子性的操作,至少需要以下幾步:查找空閑串列、分配記憶體、修改空閑串列等等,這是不安全的,
解決并發時的安全問題也有兩種策略:
CAS
CAS屬于樂觀鎖,假設資料一般不會造成沖突,所以在拿資料的時候不會去加鎖,但是會在更新的時候判斷此期間內有沒有別的執行緒修改過資料,
虛擬機采用CAS配合上失敗重試的方式保證更新操作的原子性,原理和上面講的一樣,
了解更多關于CAS的知識,可以先移步我的另外一篇文章淺探CAS實作原理
TLAB
如果使用CAS其實對性能還是會有影響的,所以JVM又提出了一種更高級的優化策略:
每個執行緒在Java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩沖區(TLAB),執行緒內部需要分配記憶體時直接在TLAB上分配就行,避免了執行緒沖突,只有當緩沖區的記憶體用光需要重新分配記憶體的時候才會進行CAS操作分配更大的記憶體空間, 虛擬機是否使用TLAB,可以通過-XX:+/-UseTLAB引數來進行配置(jdk5及以后的版本默認是啟用TLAB的),
GC機制
當需要分配的記憶體大小沒有超過-XX:+PretenuerSizeThreshold的值時,意味著會先在Eden區分配,
如果Eden區滿了,會先判斷老年代最大的可用連續空間是否大于新生代的所有物件總空間,
如果大于(意味著就算沒有任何物件死亡的話,老年代也可以直接放得下),則直接進行一次Minor GC(只回收年輕代),虛擬機會采用復制演算法先進行釋放記憶體,回收死亡物件,然后將存活的物件一次性復制進from區域中,
如果小于,并且沒有開啟擔保的話,即老年代不愿意為Minor GC失敗而擔保,則進行FullGC(一般來說,只回收老年代),
如果開啟了擔保,JVM會判斷老年代的最大連續記憶體空間是否大于歷次晉升平均值的大小,
如果大于,說明由以前的經驗得來,老年代基本放得下,因此進行Minor GC,
如果小于,說明如果此時進行Minor GC,年輕代也不一定放得下,晉升到老年代時,老年代也不一定放得下,因此進行FullGC,
這里貼一張堆的圖,可以更好地了解堆的內部區域及引數,關于更多堆區的知識,可以參考我的另外一篇文章說說java中的堆區

初始化實體變數默認值
記憶體分配完畢后,會將記憶體空間中的實體變數都初始化為默認值,
下一步就是對物件頭進行必要的設定 ,例如該物件屬于哪一個類,hashcode與GC年齡等資訊,
賦予實體變數指定值
接著執行物件的init()方法,即先執行實體的代碼塊,再執行構造方法,根據傳入的屬性值給實體變數賦值,
最后一步,則是將堆中剛剛新建的物件實體的首地址賦值給堆疊中的物件參考,
到了這個階段,我們才可以使用參考變數訪問到物件實體,
四、new一個單例物件需要注意的
如下是一個雙重檢驗鎖(DCL)版本的單例模式
public class SingletonDCL {
private volatile static SingletonDCL instance;
private SingletonDCL() {
}
public static SingletonDCL getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new SingletonDCL();
}
}
}
return instance;
}
}
有幾個疑問:
為什么要檢驗兩次是否為null?
最初的想法,是直接利用synchronized將整個getInstance方法鎖起來,但這樣效率太低,考慮到實際代碼更為復雜,我們應當縮小鎖的范圍,
在單例模式下,要的就是一個單例,new SingletonDCL()只能被執行一次,因此,現在初步考慮成以下的這種方式:
public static SingletonDCL getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
//一些耗時的操作
instance = new SingletonDCL();
}
}
return instance;
}
但這樣,存在一個問題,執行緒1與執行緒2同時判斷instance為null,接著執行緒1拿到鎖之后,創建了單例物件并釋放鎖,執行緒2拿到鎖之后,又創建了單例物件,
此時執行緒1和執行緒2拿到了兩個不同的物件,違背了單例的原則,
因此,在獲取鎖之后,需要再進行一次null檢驗,
為什么使用volatile 修飾單例變數?
這段代碼,instance = new SingletonDCL(),在虛擬機層面,其實分為了3個指令:
- 為instance分配記憶體空間,相當于堆中開辟出來一段空間
- 實體化instance,相當于在上一步開辟出來的空間上,放置實體化好的SingletonDCL物件,各項實體變數已經初始化好并且被賦予指定值
- 將instance變數參考指向第一步開辟出來的空間的首地址
但由于虛擬機做出的某些優化,可能會導致指令重排序,由1->2->3變成1->3->2,這種重新排序在單執行緒下不會有任何問題,但出于多執行緒的情況下,可能會出現以下的問題:
執行緒1獲取鎖之后,執行到了instance = new SingletonDCL()階段,此時,剛好由于虛擬機進行了指令重排序,先進行了第1步開辟記憶體空間,然后執行了第3步,instance指向空間首地址,第2步還沒來得及執行,此時恰好有執行緒2執行getInstance方法,最外層判斷instance不為null(instance已經指向了某一段地址,因此不為null),直接回傳了單例物件,接著執行緒2在獲取單例物件屬性的時候,可能就會產生空指標錯誤!
因此這里需要用volatile 修飾單例變數,來避免指令重排序,
五、物件一定是創建在堆上嗎?
一般來說,基本資料型別與參考變數都是存放在虛擬機堆疊中的,確切一點地講,是位于堆疊中的區域變數表里,關于虛擬機堆疊的知識,大家可以參考我的另外一篇文章虛擬機堆疊的五臟六腑
但是,為了jvm的運行效率,在編譯期間,JIT會做很多優化,其中一個優化的技術叫做“逃逸分析”,
逃逸分析
簡答來說,它會分析物件的作用域,用來決定是否將物件分配在堆區,如果某個物件在a方法中創建,且其他任何方法都訪問不到該物件,那么稱該物件沒有逃逸出a方法,jdk1.7時,默認就開啟了逃逸分析,
例如:
private void a() {
GirlFriend gf = new GirlFriend();
System.out.println(gf.age);
}
無法在a方法的外部訪問到gf物件(果然愛情都是自私的),說明gf物件是沒有發生逃逸的,
如果某個物件沒有發生逃逸,那么就沒有必要分配到堆區,可以直接分配在堆疊上,現在需要解決的問題就是,如何去拆解這個物件?
標量替換
所謂標量替換,就是將聚合量拆解為標量,java實體物件是聚合量,基本資料型別是標量,
經過逃逸分析后,發現gf物件并沒有發生逃逸,因此這里可以對gf物件進行標量替換,
如下,可以將上述代碼優化為:
private void a() {
int age = 18;
System.out.println(age);
}
拆解過后,就可以直接在堆疊上分配了
堆疊上分配
一般的實體物件被分配在堆區,當物件死亡后,需要使用GC機制進行垃圾回收,如果在堆疊上分配,就可以隨著堆疊幀出堆疊而被銷毀,在一定程度上減輕了GC壓力,
另外值得一提的是,逃逸分析后,還可以實作對鎖的消除
同步消除
當某物件沒有逃逸出方法,對其同步的操作就可以消除掉,因為虛擬機堆疊本來就是執行緒私有的,沒有逃逸出方法,就是沒有逃逸出執行緒,
例如下面的代碼:
優化前:
private void a(){
GirlFriend gf = new GirlFriend();
synchronized (gf){
//不方便寫的某些業務
}
}
優化后:
private void a() {
GirlFriend gf = new GirlFriend();
//不方便寫的某些業務
}
只有先開啟了逃逸分析,才可以接著做標量替換、堆疊上分配與同步消除,
六、總結
new一個物件的背后:
- 首先去常量池尋找該類的符號參考,找不到,則執行類加載
- 在堆上分配記憶體,分配機制有指標碰撞與空閑串列,多執行緒下進行分配記憶體時,有cas失敗重試與TLAB
- 將分配到的記憶體空間中的資料型別都 初始化為零值
- 對物件頭進行設定 ,例如類元資料指標、hashcode與GC分代年齡等資訊
- 呼叫物件的init()方法 ,即實體代碼塊與構造方法,根據傳入的屬性值給實體變數賦值
- 堆疊中新建物件參考 ,并指向堆中剛剛新建的物件實體
注意點:
- new一個單例物件時,DCL的模式需要加上volatie,禁止指令重排序,
- 物件不一定非得在堆區分配,經過JIT逃逸分析后,如果能進行標量替換,就有可能在堆疊上分配,
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/301334.html
標籤:其他
