New 一個對象在堆中的歷程
小伙伴們大家好呀,我是小牛肉~ 我寫文章的流程一般都是先在看書和看博客的過程中做做筆記,然后過一段時間再把這些筆記總結成文章輸出出來,這樣一來能夠加深影響,二來也不至于文章的質量太低。從這篇文章的草稿筆記到現在決定開始成文,其實已經有一個月了,本來覺得趁著寒假可以順理成章地脫離惡心的深度學習然后好好地把 JVM 知識點全都掃一遍,正好囤幾篇文章,誰知道回家后根本無心看書,只能每天刷幾道 LeetCode 來彌補下日積月累的焦慮和罪惡感。
STOP,廢話結束
今天介紹兩個 JVM 中的高頻基礎題:
- 對象的創建過程(new 一個對象在堆中的歷程)
- 對象在堆上分配的兩種方式
對象的創建過程分五步走,如下圖:

我感覺 JVM 如果不看 GC 收集器那塊(滑稽),似乎東西還不多
老規矩,背誦版在文末。點擊閱讀原文可以直達我收錄整理的各大廠面試真題
類加載檢查
對象創建過程的第一步,所謂類加載檢查,就是檢測我們接下來要 new 出來的這個對象所屬的類是否已經被 JVM 成功加載、解析和初始化過了(具體的類加載過程會在后續文章詳細解釋~)
具體來說,當 Java 虛擬機遇到一條字節碼 new 指令時:
1)首先檢查根據 class 文件中的常量池表(Constant Pool Table)能否找到這個類對應的符號引用
此處可以回顧一波常量池表 (Constant Pool Table) 的概念:
用于存放編譯期生成的各種字面量(字面量相當于 Java 語言層面常量的概念,如文本字符串,聲明為 final 的常量值等)與符號引用。有一些文章會把 class 常量池表稱為靜態常量池。
都是常量池,常量池表和方法區中的運行時常量池有啥關系嗎?運行時常量池是干嘛的呢?
運行時常量池可以在運行期間將 class 常量池表中的符號引用解析為直接引用。簡單來說,class 常量池表就相當于一堆索引,運行時常量池根據這些索引來查找對應方法或字段所屬的類型信息和名稱及描述符信息
2)然后去方法區中的運行時常量池中查找該符號引用所指向的類是否已被 JVM 加載、解析和初始化過
如果沒有,那就先執行相應的類加載過程
如果有,那么進入下一步,為新生對象分配內存
分配內存
類加載檢查通過后,這個對象待會兒要是被創建出來得有地方放他對吧?
所以接下來 JVM 會為新生對象分配內存空間。
至于 JVM 怎么知道這個空間得分配多大呢?事實上,對象所需內存的大小在類加載完成后就已經可以完全確定了。在 Hotspot 虛擬機中,對象在內存中的布局可以分為 3 塊區域:對象頭、實例數據和對齊填充。
1)Hotspot 虛擬機的對象頭包括兩部分信息:
- 第一部分用于存儲對象自身的運行時數據(如哈希碼(HashCode)、GC 分代年齡、鎖狀態標志、線程持有的鎖、偏向線程 ID、偏向時間戳等,這部分數據的長度在 32 位和 64 位的虛擬機(未開啟壓縮指針)中分別為 32 個比特和 64 個比特,官方稱它為 “Mark Word”。學過 synchronized 的小伙伴對這個一定不陌生~)
- 另一部分是類型指針,即對象指向它的類型元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例
2)實例數據部分存儲的是這個對象真正的有效信息,即我們在程序代碼里面所定義的各種類型的字段內容,無論是從父類繼承下來的,還是在子類中定義的字段都必須記錄起來。
3)對齊填充部分不是必須的,也沒有什么特別的含義,僅僅起占位作用。因為 Hotspot 虛擬機的自動內存管理系統要求對象起始地址必須是 8 字節的整數倍,換句話說就是對象的大小必須是 8 字節的整數倍。而對象頭部分正好是 8 字節的倍數(1 倍或 2 倍),因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。
對象在堆上的兩種分配方式
為對象分配內存空間的任務通俗來說把一塊確定大小的內存塊從 Java 堆中劃分出來給這個對象用。
根據堆中的內存是否規整,有兩種劃分方式,或者說對象在堆上的分配有兩種方式:
1)假設 Java 堆中內存是絕對規整的,所有被使用過的內存都被放在一邊,空閑的內存被放在另一邊,中間放著一個指針作為分界點的指示器,那所分配內存就僅僅是把這個指針 向 空閑空間方向 挪動一段與對象大小相等的距離,這種分配方式稱為 指針碰撞(Bump The Pointer)

2)如果 Java 堆中的內存并不是規整的,已被使用的內存和空閑的內存相互交錯在一起,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的連續空間劃分給這個對象,并更新列表上的記錄,這種分配方式稱為 空閑列表(Free List)。
選擇哪種分配方式由 Java 堆是否規整決定,那又有同學會問了,堆是否規整又由誰來決定呢?
Java 堆是否規整由所采用的垃圾收集器是否帶有空間壓縮整理(Compact)的能力決定的(或者說由垃圾收集器采用的垃圾收集算法來決定的,具體垃圾收集算法見后續文章):
因此,當使用 Serial、ParNew 等帶壓縮整理過程的收集器時,系統采用的分配算法是指針碰撞,既簡單又高效
而當使用 CMS 這種基于清除(Sweep)算法的收集器時,理論上就只能采用較為復雜的空閑列表來分配內存
對象創建時候的并發安全問題
另外,在為對象創建內存的時候,還需要考慮一個問題:并發安全問題。
對象創建在虛擬機中是非常頻繁的行為,以上面介紹的指針碰撞法為例,即使只修改一個指針所指向的位置,在并發情況下也并不是線程安全的,可能出現某個線程正在給對象 A 分配內存,指針還沒來得及修改,另一個線程創建了對象 B 又同時使用了原來的指針來分配內存的情況。
解決這個問題有兩種可選方案:
- 方案 1:CAS + 失敗重試:CAS 大伙應該都熟悉,比較并交換,樂觀鎖方案,如果失敗就重試,直到成功為止
- 方案 2:本地線程分配緩沖(Thread Local Allocation Buffer,TLAB):每個線程在堆中預先分配一小塊內存,每個線程擁有的這一小塊內存就稱為 TLAB。哪個線程要分配內存了,就在哪個線程的 TLAB 中進行分配,這樣各個線程之間互不干擾。如果某個線程的 TLAB 用完了,那么虛擬機就需要為它分配新的 TLAB,這時才需要進行同步鎖定。可以通過 -XX:+/-UseTLAB 參數來設定是否使用 TLAB。
初始化零值
內存分配完成之后,JVM 會將分配到的內存空間(當然不包括對象頭啦)都初始化為零值,比如 boolean 字段都初始化為 false 啊,int 字段都初始化為 0 啊之類的
這步操作保證了對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,使程序能訪問到這些字段的數據類型所對應的零值。
如果使用了 TLAB 的話,初始化零值這項工作可以提前至 TLAB 分配時就順便進行了
設置對象頭
上面我們說過,對象在內存中的布局可以分為 3 塊區域:對象頭(Object Header)、實例數據和對齊填充
對齊填充并不是什么有意義的數據,實例數據我們在上一步操作中進行了初始化零值,那么對于剩下的對象頭中的信息來說,自然不必多說,也是要進行一些賦值操作的:例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的 GC 分代年齡等信息。根據虛擬機當前運行狀態的不同,如是否啟用偏向鎖等,對象頭會有不同的設置方式。
執行 init 方法
上面四個步驟都走完之后,從 JVM 的視角來看,其實一個新的對象已經成功誕生了。
但是從我們程序員的視角來看,這個對象確實是創建出來了,但是還沒按照我們定義的構造函數來進行賦值呢,所有的字段都還是默認的零值啊。
構造函數即 Class 文件中的 () 方法,一般來說,new 指令之后會接著執行 ()方法,按照構造函數的意圖對這個對象進行初始化,這樣一個真正可用的對象才算完全地被構造出來了,皆大歡喜。
最后放上這道題的背誦版:
?? 面試官:講一下對象的創建過程
?? 小牛肉:new 一個對象在堆中的過程主要分為五個步驟:
1)類加載檢查:具體來說,當 Java 虛擬機遇到一條字節碼 new 指令時,它會首先檢查根據 class 文件中的常量池表(Constant Pool Table)能否找到這個類對應的符號引用,然后去方法區中的運行時常量池中查找該符號引用所指向的類是否已被 JVM 加載、解析和初始化過
- 如果沒有,那就先執行相應的類加載過程
- 如果有,那么進入下一步,為新生對象分配內存
2)分配內存:就是在堆中給劃分一塊內存空間分配給這個新生對象用。具體的分配方式根據堆內存是否規整有兩種方式:
- 堆內存規整的話采用的分配方式就是指針碰撞:所有被使用過的內存都被放在一邊,空閑的內存被放在另一邊,中間放著一個指針作為分界點的指示器,分配內存就是把這個指針向空閑空間方向挪動一段與對象大小相等的距離
- 堆內存不規整的話采用的分配方式就是空閑列表:所謂內存不規整就是已被使用的內存和空閑的內存相互交錯在一起,那就沒有辦法簡單地進行指針碰撞了,JVM 就必須維護一個列表,記錄哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的連續空間劃分給這個對象,并更新列表上的記錄,這就是空閑列表的方式
3)初始化零值:對象在內存中的布局可以分為 3 塊區域:對象頭、實例數據和對齊填充,對齊填充僅僅起占位作用,沒啥特殊意義,初始化零值這個操作就是初始化實例數據這個部分,比如 boolean 字段初始化為 false 之類的
4)設置對象頭:這個步驟就是設置對象頭中的一些信息
5)執行 init 方法:最后就是執行構造函數,構造函數即 Class 文件中的 ()方法,一般來說,new 指令之后會接著執行() 方法,按照構造函數的意圖對這個對象進行初始化,這樣一個真正可用的對象才算完全地被構造出來了

























