對象住哪裏?—— 深入剖析 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 分配的流程

  1. 線程創建對象時,先檢查自己的 TLAB 是否有足夠空間;
  2. 若空間足夠:直接在 TLAB 中分配對象,更新 TLAB 的指針位置;
  3. 若空間不足:
  • 檢查 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配置):

  1. Eden 區(伊甸園):新對象的 “出生地”,90% 以上的新對象首先分配到 Eden 區;
  • 示例:執行new User(),若對象無逃逸且非大對象,先進入 Eden 區;
  1. Survivor 區(倖存者區):Eden 區 GC 後存活的對象會進入 S0 或 S1 區;
  • 流程:Eden 區滿時觸發 Minor GC,存活對象被複制到 S0 區(S1 區為空),並將對象的 “年齡計數器” 加 1;
  • 晉升:當對象在 Survivor 區存活次數達到閾值(默認 15 次,可通過-XX:MaxTenuringThreshold配置),會被 “晉升” 到老年代;
  1. Survivor 區的 “複製算法”:每次 Minor GC 僅複製存活對象(通常僅 5% 左右),避免內存碎片,效率極高。

(3)老年代的分配場景:“長期對象” 與 “特殊對象”

以下對象會直接或間接進入老年代:

  1. 長期存活對象:在 Survivor 區存活次數達到閾值的對象(如頻繁被使用的緩存對象);
  2. 大對象:超過 “大對象閾值”(可通過-XX:PretenureSizeThreshold配置,默認無閾值,JDK 8 後由 JVM 動態判斷)的對象,直接分配到老年代(避免在新生代頻繁 GC 導致的複製開銷);
  • 示例:創建一個 10MB 的數組(byte[] arr = new byte[1024*1024*10]),若超過閾值,直接進入老年代;
  1. 動態年齡判斷:Survivor 區中某一年齡段的對象總大小超過 Survivor 區的 50%,則該年齡及以上的對象直接晉升老年代(避免 Survivor 區溢出);
  2. 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指向同一常量池對象;