對象住哪裏?—— 深入剖析 JVM 內存結構與對象分配機制
在 Java 程序運行時,我們創建的每一個對象(如new User())都需要佔用 JVM 內存,但這些對象究竟 “居住” 在哪個內存區域?為何有的對象很快被回收,有的卻能長期存活?要解答這些問題,必須先理清 JVM 的內存結構劃分,再深入對象從創建到銷燬的全生命週期分配邏輯 —— 這不僅是面試高頻考點,更是理解 JVM 性能優化、內存泄漏排查的核心基礎。
一、前置認知:JVM 內存結構 —— 對象的 “居住地圖”
在探討 “對象住哪裏” 之前,需先明確 JVM 的內存區域劃分。根據《Java 虛擬機規範(Java SE 8)》,JVM 運行時數據區分為線程私有區域和線程共享區域,不同區域的功能與對象存儲特性完全不同。
1. 線程私有區域:每個線程獨立擁有,隨線程創建 / 銷燬
線程私有區域的內存生命週期與線程一致,無需垃圾回收(GC),主要用於存儲線程執行相關的數據:
|
內存區域 |
核心功能 |
是否存儲對象 |
|
程序計數器 |
記錄當前線程執行的字節碼指令地址(如分支、循環、跳轉的位置),確保線程切換後能恢復執行 |
否(僅存儲地址值) |
|
虛擬機棧 |
存儲線程執行方法時的 “棧幀”(包含局部變量表、操作數棧、方法出口等),每個方法調用對應一個棧幀入棧 |
局部變量表中存儲對象引用(而非對象本身) |
|
本地方法棧 |
與虛擬機棧功能類似,僅服務於 Native 方法(如System.currentTimeMillis()) |
否 |
關鍵結論:線程私有區域僅存儲 “對象引用”(類似指針),對象本身不會直接存儲在此區域。
2. 線程共享區域:所有線程共用,隨 JVM 啓動 / 關閉
線程共享區域是對象的 “主要居住地”,也是 GC 的核心作用區域,分為三個核心模塊:
|
內存區域 |
核心功能 |
存儲對象類型 |
關鍵特性 |
|
堆(Heap) |
JVM 中最大的內存區域,專門用於存儲對象實例(包括成員變量) |
所有對象實例(如new User()) |
1. 線程共享,需 GC 回收;2. 可通過-Xms(初始堆大小)、-Xmx(最大堆大小)配置;3. 是對象分配的 “默認首選區域” |
|
方法區 |
存儲類信息(如類名、字段、方法)、常量、靜態變量、JIT 編譯後的代碼 |
常量對象(如String s = "abc")、靜態對象(如static User user = new User()) |
1. 線程共享,JDK 8 後由 “元空間(Metaspace)” 實現(替代永久代);2. 常量池中的字符串對象可能被緩存(如字符串常量池) |
|
運行時常量池 |
方法區的一部分,存儲編譯期生成的常量(如final int a = 10)、符號引用等 |
編譯期常量對象 |
1. 常量池中的對象一旦創建,通常不會被回收;2. JDK 7 後部分常量池(如字符串常量池)遷移至堆中 |
核心地圖:絕大多數對象實例(99% 以上)居住在堆中,常量對象、靜態對象則根據 JDK 版本不同,居住在方法區(元空間)或堆中;線程私有區域僅存儲對象的 “引用地址”,而非對象本體。
二、核心流程:對象分配的 “常規路徑”—— 從堆到棧
當我們執行User user = new User()時,對象的分配並非直接 “扔到堆裏”,而是遵循 “優先棧上分配→TLAB 分配→堆分配” 的分層策略,JVM 通過這種方式優化內存使用效率與 GC 性能。
1. 第一步:棧上分配 ——“臨時對象” 的最優選擇
(1)什麼是棧上分配?
對於生命週期極短、無逃逸(僅在當前方法內使用)
public void test() { // User對象僅在test()方法內使用,無逃逸 User user = new User(); user.setName("臨時用户"); // 方法執行結束後,棧幀出棧,user對象隨棧幀銷燬(無需GC)}
(2)為什麼優先棧上分配?
- 避免 GC 開銷:棧幀隨方法執行結束自動銷燬,對象無需等待 GC 回收,減少 GC 壓力;
- 提升訪問速度:棧內存的訪問速度遠快於堆(棧是連續內存,堆是離散內存,需尋址)。
(3)棧上分配的條件(JVM 優化技術:逃逸分析)
JVM 通過 “逃逸分析” 判斷對象是否符合棧上分配條件:
- 無逃逸:對象僅在當前方法內使用,未被返回、未被傳遞到其他方法 / 線程;
- 標量可替換:對象可拆分為基本類型(如User類的name(String)、age(int)可拆分為局部變量)。
反例:若test()方法返回user對象(return user),則對象發生 “方法逃逸”,無法棧上分配,需進入堆中。
2. 第二步:TLAB 分配 —— 堆中 “線程私有” 的緩衝區域
若對象不符合棧上分配條件(如存在逃逸),JVM 會優先在堆的 “TLAB 區域” 分配對象,而非直接使用堆的共享區域。
(1)TLAB:Thread-Local Allocation Buffer(線程本地分配緩衝)
JVM 為每個線程在堆中預先分配一塊 “私有小內存”(默認佔堆大小的 1%),線程創建對象時,優先在自己的 TLAB 中分配,無需競爭共享堆資源。
(2)TLAB 分配的優勢:解決線程安全與性能問題
- 避免線程競爭:若所有線程直接在堆的共享區域分配對象,需通過鎖保證線程安全(如 CAS 操作),會產生性能開銷;TLAB 是線程私有,分配時無需加鎖;
- 提升分配效率:TLAB 是連續內存塊,對象分配只需移動 “指針”(如 TLAB 初始指針為 0,分配一個 16 字節的對象後,指針移動到 16),類似棧的 “指針碰撞” 分配方式。
(3)TLAB 分配的流程
- 線程創建對象時,先檢查自己的 TLAB 是否有足夠空間;
- 若空間足夠:直接在 TLAB 中分配對象,更新 TLAB 的指針位置;
- 若空間不足:
- 檢查 TLAB 的使用率(如是否超過 50%),若使用率低,直接擴容 TLAB 並分配;
- 若使用率高,將 TLAB 中剩餘空間歸還給堆,重新申請新的 TLAB;
- 若多次申請 TLAB 失敗(如堆空間不足),則進入 “堆的共享區域” 分配。
3. 第三步:堆分配 ——“長期對象” 的最終歸宿
當對象不符合棧上分配條件,且 TLAB 空間不足時,JVM 會將對象分配到堆的 “共享區域”。根據對象的生命週期,堆又分為 “新生代” 和 “老年代”,不同代的分配策略與 GC 機制不同。
(1)堆的代際劃分:基於 “對象存活時間” 的優化
JVM 根據 “大多數對象存活時間短” 的特性(弱代假説),將堆分為新生代(Young Generation)和老年代(Old Generation),比例通常為 1:2(可通過-XX:NewRatio配置):
|
代際 |
佔堆比例 |
存儲對象類型 |
GC 機制 |
分配策略 |
|
新生代 |
1/3 |
新創建的對象(除大對象外) |
Minor GC(輕量 GC) |
1. 優先分配到 Eden 區;2. 存活對象進入 Survivor 區;3. 多次存活後進入老年代 |
|
老年代 |
2/3 |
1. 存活時間長的對象;2. 大對象;3. 新生代無法容納的對象 |
Major GC(Full GC 的一部分) |
直接分配(大對象)或從新生代晉升(長期存活對象) |
(2)新生代的分配細節:Eden 區與 Survivor 區
新生代內部進一步分為 “Eden 區” 和兩個大小相等的 “Survivor 區”(S0、S1),比例通常為 8:1:1(可通過-XX:SurvivorRatio配置):
- Eden 區(伊甸園):新對象的 “出生地”,90% 以上的新對象首先分配到 Eden 區;
- 示例:執行new User(),若對象無逃逸且非大對象,先進入 Eden 區;
- Survivor 區(倖存者區):Eden 區 GC 後存活的對象會進入 S0 或 S1 區;
- 流程:Eden 區滿時觸發 Minor GC,存活對象被複制到 S0 區(S1 區為空),並將對象的 “年齡計數器” 加 1;
- 晉升:當對象在 Survivor 區存活次數達到閾值(默認 15 次,可通過-XX:MaxTenuringThreshold配置),會被 “晉升” 到老年代;
- Survivor 區的 “複製算法”:每次 Minor GC 僅複製存活對象(通常僅 5% 左右),避免內存碎片,效率極高。
(3)老年代的分配場景:“長期對象” 與 “特殊對象”
以下對象會直接或間接進入老年代:
- 長期存活對象:在 Survivor 區存活次數達到閾值的對象(如頻繁被使用的緩存對象);
- 大對象:超過 “大對象閾值”(可通過-XX:PretenureSizeThreshold配置,默認無閾值,JDK 8 後由 JVM 動態判斷)的對象,直接分配到老年代(避免在新生代頻繁 GC 導致的複製開銷);
- 示例:創建一個 10MB 的數組(byte[] arr = new byte[1024*1024*10]),若超過閾值,直接進入老年代;
- 動態年齡判斷:Survivor 區中某一年齡段的對象總大小超過 Survivor 區的 50%,則該年齡及以上的對象直接晉升老年代(避免 Survivor 區溢出);
- Minor GC 後存活對象無法放入 Survivor 區:若 Minor GC 後存活對象過多,Survivor 區無法容納,這些對象會直接 “晉升” 到老年代(稱為 “分配擔保”)。
三、特殊場景:對象分配的 “例外情況”—— 方法區與常量池
除了堆和棧,部分特殊對象會 “居住” 在方法區(元空間)或運行時常量池中,這些場景容易被誤解,需重點區分。
1. 常量對象:字符串常量池與運行時常量池
(1)字符串常量池的 “居住變遷”
字符串對象的分配因創建方式不同(new String() vs 字面量"abc"),居住區域也不同,且 JDK 版本對其影響極大:
|
創建方式 |
JDK 6 及之前的居住區域 |
JDK 7 及之後的居住區域 |
關鍵特性 |
|
String s1 = "abc" |
方法區(永久代)的字符串常量池 |
堆中的字符串常量池 |
1. 優先檢查常量池,若存在則直接返回引用;2. 不存在則創建字符串對象並放入常量池;3. 對象不會被 GC 回收(除非常量池清理) |
|
String s2 = new String("abc") |
1. 字符串常量池(若 “abc” 不存在則創建);2. 堆中創建新對象 |
1. 堆中的字符串常量池(若 “abc” 不存在則創建);2. 堆中創建新對象 |
1. 必然在堆中創建一個新對象;2. 常量池中的對象是 “原型”,堆中的對象是 “副本”;3. 堆中的對象可被 GC 回收,常量池中的對象通常不回收 |
經典面試題:s1 == s2的結果?
答案:false。因為s1指向常量池中的對象,s2指向堆中的新對象,兩者引用地址不同。
(2)其他常量對象
編譯期常量(如final String s = "abc"、final int a = 10)會存儲在運行時常量池中,JDK 7 後運行時常量池雖在方法區(元空間),但常量對象本身仍存儲在堆中,常量池僅存儲對象的引用地址。
2. 靜態對象:靜態變量引用的對象
靜態變量(static修飾)存儲在方法區(元空間),但靜態變量引用的對象實例仍存儲在堆中,方法區僅存儲對象的 “引用地址”:
public class Test { // 靜態變量user存儲在方法區(元空間),但user引用的對象實例存儲在堆中 public static User user = new User(); }
- 居住區域:user變量(引用)在方法區,new User()對象實例在堆中;
- 回收時機:靜態變量的生命週期與類一致,只有當類被 “卸載”(如類加載器被回收)時,其引用的對象才可能被 GC 回收(通常很難觸發,因此靜態對象易導致內存泄漏)。
3. 類信息與 JIT 代碼:方法區(元空間)的 “非對象” 存儲
需特別注意:方法區(元空間)存儲的是 “類信息”(如類結構、方法字節碼),而非對象實例。例如:
- User.class的類名、字段(name、age)、方法(setName())存儲在方法區;
- JIT(即時編譯器)將熱點代碼(如頻繁調用的方法)編譯為本地機器碼後,也存儲在方法區;
- 這些數據不屬於 “對象”,無需 GC 回收(元空間的內存由操作系統管理,JVM 不主動 GC)。
四、實戰分析:通過案例定位 “對象的居住地”
結合具體代碼案例,分析不同對象的居住區域,加深理解:
案例 1:臨時無逃逸對象
public class StackAllocationTest { public static void main(String[] args) { for (int i = 0; i < 100000; i++) { createTempUser(); // 循環創建臨時對象 } } private static void createTempUser() { // User對象僅在createTempUser()內使用,無逃逸 User user = new User(); user.setAge(18); user.setName("臨時用户"); // 方法結束,棧幀出棧,user對象隨棧幀銷燬(棧上分配) }}
- 對象居住地:User對象通過棧上分配,居住在虛擬機棧的棧幀中;
- 驗證:運行時觀察堆內存變化,堆大小基本不變(無大量對象創建),且無頻繁 Minor GC。
案例 2:大對象分配
public class LargeObjectTest { public static void main(String[] args) { // 創建100MB的字節數組(大對象) byte[] largeArr = new byte[1024 * 1024 * 100]; }}
- 對象居住地:大數組超過 JVM 動態判斷的 “大對象閾值”,直接分配到老年代;
- 驗證:通過 JVisualVM 觀察堆結構,老年代內存佔用增加 100MB 左右,新生代無明顯變化。
案例 3:字符串常量與堆對象
public class StringAllocationTest { public static void main(String[] args) { String s1 = "hello"; // 常量池中的對象(堆中,JDK 7+) String s2 = new String("hello"); // 堆中的新對象(副本) String s3 = s2.intern(); // 返回常量池中的對象引用(與s1相同) System.out.println(s1 == s2); // false(s1指向常量池,s2指向堆) System.out.println(s1 == s3); // true(s3指向常量池) }}
- 對象居住地:s1指向堆中字符串常量池的對象,s2指向堆中的新對象,s3與s1指向同一常量池對象;