
在新手階段大家背得最多的一句話就是類是模板對象是實例。可真到做排查、看監控或者被面試官追著問的時候很多人會發現這句話根本不頂用。為什么因為它沒有回答最核心的那個問題Java面向對象里類在內存中到底以什么形式存在對象在內存中又是什么兩者之間那層關聯靠什么維系。我當初學Java也是從記住這句口號開始的直到某天對著一個OOM日志發呆才意識到如果不懂類與對象在內存里如何落位調優和排錯永遠只是靠猜。這篇文章就把這條鏈路徹底拆開從類與對象的定義邊界到new一個對象后JVM做了什么再到引用類型和類加載機制最后落到幾個典型的內存關聯問題排查上。適合所有Java開發者尤其是準備面試、剛接觸JVM內存模型、或者被線上內存問題折磨過的人。1. 類和對象到底差在哪先解決定義邊界再談內存1.1 類是模板對象是實例一個簡單的代碼對照很多人把類理解成“帶屬性的函數集合”這個理解不能說錯但會忽略一個關鍵點類本身不占“對象式”的內存空間它更像是一張設計圖紙規定了對象該有哪些屬性、哪些行為。看這段代碼public class Person { String name; int age; public void sayHello() { System.out.println(hello, Im name); } } public class Main { public static void main(String[] args) { Person p1 new Person(); Person p2 new Person(); p1.name 張三; p2.name 李四; } }p1 和 p2 都是 Person 類創建的實例它們擁有各自獨立的 name 和 age 字段。你把 p1.name 改成任意值p2.name 不會受影響因為兩個對象在堆內存里是兩塊完全獨立的空間。但 p1 和 p2 能調用的 sayHello() 方法邏輯是共享的這個“共享的模板”就是類元數據它不會在每個對象里都復制一份。所以用“圖紙和房子”來類比非常合適類是圖紙對象是按圖紙蓋出來的房子。圖紙描述了三室兩廳但你不可能住進圖紙里你住的是具體的房子。每個房子的戶型結構一致但里面住的人、家具狀態都不同這片小區就是堆內存。這個類比還能延伸到內存關聯房主們的身份證或門牌號對應棧幀里的引用變量房產局登記表對應靜態引用或類元數據。你不需要真的把整個房子搬進身份證里只需要拿著門牌號就能找到房子。Java里的引用變量也是一樣它本身只是一個地址在HotSpot里通常就是指針指向堆中的對象。1.2 類本身也是一份數據Class對象與類元數據的區別這里有個容易混淆的點代碼里的 public class Person在JVM里到底以什么形式存在可以這么理解當我們編寫完 Person.java通過 javac 編譯成 Person.class 字節碼文件后文件里的內容是一串二進制指令和常量池。JVM 在運行期會把這些字節碼加載進來在元空間MetaspaceJDK8 之后的實現里生成一份“類元數據”包含類名、訪問標志、字段信息、方法信息、實現的接口、注解等。這份元數據就是那張“圖紙”。同時JVM 還會在堆內存里創建一個 java.lang.Class 對象作為程序訪問類元數據的入口。每個類被加載后JVM 都會創建一個唯一的 Class 對象通過 Person.class 或 Class.forName(Person) 能拿到它。于是這里出現了兩層結構類元數據存方法區/元空間描述“類長什么樣”Class對象存在堆里是運行時反射機制操作類的“門把手”。大多數時候我們說的“類”業務上指的是元數據這個概念但反射、動態代理依賴的是堆里那個 Class 對象。這也是一個非常經典的面試追問點Person.class 和 new Person() 有什么區別前者是類的反射入口不觸發對象的實例化后者會在堆上創建真正的對象實例。1.3 定義邊界沒理清后面所有內存概念都是空中樓閣為什么要先把類和對象邊界搞清楚因為后續所有關于內存關聯的討論都建立在這兩個概念之上。比如我們常說“對象在堆里”“引用在棧里”準確的說是對象的實例數據在堆里對象的類型信息類元數據在方法區/元空間里而指向對象的引用變量可能在棧幀的局部變量表里也可能作為對象字段在堆里還可能作為靜態變量在方法區/元空間的靜態區里。你如果沒分清“類”和“對象”就會在談論內存時經常把“類和對象存在哪里”混成一團。我自己的經驗是在看到任何一句話里出現“類”“對象”時先停頓一下問自己這句話說的是元數據層面、Class對象層面、還是真正的實例數據層面。一旦養成這個習慣再看JVM內存模型所有區域劃分都會清晰很多。后面幾個章節其實就是圍繞這三種形態的內存落位展開的。2. 從new開始追內存一個對象在JVM中完整落戶的全過程2.1 對象創建的三步曲類加載檢查、分配內存、初始化Java 里最普通的寫法是new Person()但 JVM 在背后做的事遠不止“開辟一塊空間”這么簡單。以 HotSpot 為例一條 new 指令的完整執行鏈路大概是這樣的第一步類加載檢查。new 指令對應的字節碼會在當前類的常量池中定位到一個符號引用檢查這個符號引用代表的類是否已經被加載、解析和初始化。如果沒有就必須先觸發對應類的加載過程。這就是網上常說的“對象創建可能觸發類加載”。第二步分配內存。類加載完成后對象所需的內存大小就已經完全確定了——類元數據里記錄了每個字段的類型JVM 可以算出實例數據要占多少字節。于是 GC 在堆中找一塊夠大的連續區域分配出來。如果 GC 是帶壓縮整理功能的可用內存是一塊規整的連續空間分配時只需要移動一個指針這叫“指針碰撞”Bump the Pointer。如果內存碎片比較多JVM 需要維護一個空閑列表從中尋找足夠大的塊這叫“空閑列表”Free List。分配這一步還有兩個優化細節值得關注。一個是 TLABThread Local Allocation Buffer畫每個線程都在堆上隨便搶位置并發效率太低。于是 JVM 給每個線程在新生代里劃出一小塊私有的緩沖區線程內部對象優先在 TLAB 里分配不需要加鎖。另一個是如果 TLAB 不夠了再通過 CAS 機制去公共區域搶占。這也是為什么堆內存分配雖然頻繁但整體吞吐量還不錯。第三步初始化零值與設置對象頭。內存分配完成后JVM 會將該區域的內存初始化為零值這樣實例字段即使沒有手動賦值默認也是 0、null、false。之后設置對象頭Mark Word存儲對象的哈希碼、GC分代年齡、鎖狀態、類元數據指針等信息。如果是數組對象對象頭里還會多一個數組長度字段。最后才是執行構造方法也就是init方法。到這里一個真正可用的對象才誕生。很多初學者以為new之后對象字段就已經是構造器里賦的值其實在此之前已經經歷了一次零值初始化。所以如果有人在構造器里觀察到字段初始值為 null不要驚訝那可能是在父類構造器調用階段子類字段還沒到初始化時機。2.2 對象在堆內存中的實際布局對象頭、實例數據、對齊填充對象在堆里不是簡單地把各個字段排在一起它有標準布局。HotSpot 里普通對象的布局分為三塊對象頭Header包含 Mark Word 和 Class Pointer。Mark Word 在不同狀態下存的含義不同無鎖時存哈希碼、對象年齡偏向鎖時存線程ID輕量級鎖時存鎖記錄的指針。Class Pointer 則指向方法區/元空間中的類元數據很多資料寫作_klass指針是對象和類關聯的直接證據。實例數據Instance Data存儲所有實例字段的值包括從父類繼承下來的字段。存儲順序受字段重排序影響默認按字段聲明順序同時進行對齊。對齊填充Padding對象大小必須是 8 字節的整數倍不夠時就需要填充。這也是 Java 對象沒有直接提供sizeof()的原因之一對象真實占用大小需要靠第三方工具或者 JOLJava Object Layout來看。舉個例子一個只有 int age 和 boolean flag 的類在 64 位虛擬機開啟指針壓縮時對象頭通常 12 字節實例數據 415 字節合計 17 字節對齊填充到 24 字節。所以別小看那些小對象如果列表里有幾萬個內存占用會超出你的直覺。2.3 棧幀里的引用如何與堆上的對象取得聯系方法執行時JVM 會創建對應的棧幀棧幀里的局部變量表存放了基本類型值也存放了引用類型。這個引用可能是直接指針也可能是句柄。HotSpot 默認使用直接指針訪問方式棧幀里存的就是堆對象的地址訪問對象時一次指針跳轉就能找到目標對象。句柄方式則是在堆中維護一個句柄池棧幀里存句柄池中的地址再通過句柄拿到對象地址。直接指針訪問的優點是快少了間接尋址缺點是對象被移動時需要同步修改棧里的引用。句柄訪問的優點是對象移動時只需改句柄池里的指針棧里的引用不變。HotSpot 選擇直接指針也說明它更看重訪問速度。這層關聯關系非常關鍵對象可以使用的前提是“有人提著它的地址”。當一個對象沒有被任何活躍的棧幀引用、也沒有被其他存活對象引用它就失去了和 GC Roots 的關聯變成垃圾等待回收。也就是說棧幀里的引用變量是對象是否“活著”的憑證之一。3. 引用類型如何“牽住”內存強、弱、隱藏與逃逸分析3.1 四種引用類型在內存關聯里的角色Java 引用不是一個單調的概念。java.lang.ref包定義了強引用、軟引用、弱引用、虛引用四種它們和垃圾回收器的協作方式完全不同直接決定了對象在內存中的存活時間。表格對比引用類型回收時機典型用途是否可通過引用獲取對象強引用GC 永遠不回收除非不可達普通對象引用Person p new Person()可以軟引用內存不足時回收緩存框架、圖片緩存可以直到被回收弱引用下一次 GC 即回收WeakHashMap、ThreadLocal 防止內存泄漏可以但很短暫虛引用對象回收時收到通知堆外內存回收Cleaner、對象生命周期跟蹤不能通過它訪問對象為什么需要區分這幾種引用因為有些對象“有價值但不是非留不可”。比如緩存幾萬個圖片如果全部強引用內存很容易被撐爆用軟引用/弱引用持有內存緊張時 GC 能自動清理拿不到后再從磁盤加載即可。合理使用引用類型是在可控內存里維持業務功能和 GC 回收之間平衡的關鍵。在內存關聯上軟引用、弱引用、虛引用對象本身也是對象它們各自還有內部的引用指向被引用的對象。簡單理解引用對象像一個“追蹤器”被引用對象如果只剩這些軟/弱/虛引用指向它就進入了可回收候選隊列。3.2 隱藏的引用局部變量表 slot 復用帶來的延遲回收這個是初學者最容易忽略的內存關聯細節也是很多線上問題“玄學”的根源。看這段代碼public static void test() { byte[] memory new byte[10 * 1024 * 1024]; // 10MB // 極端情況故意不再使用 memory int a 1; int b 2; // 別的業務代碼 }在 JVM 里局部變量表中的 slot 是可以復用的。memory這個引用在字節碼中占一個 slot當它所在的作用域結束后JVM 并不會主動清空這個 slot它仍然指向堆中的 10MB 字節數組。如果后續沒有新的變量復用到這個 slot這個引用就相當于一直“存活”GC 無法回收那 10MB 內存。很多人在一個大方法里創建了臨時大對象之后又執行了很多步驟以為進入下一個步驟時臨時對象就沒用了但內存監控顯示 GC 始終無法釋放。原因往往就是這個隱蔽的 slot 引用。解決方式也很直接盡量把變量的作用域寫小比如在獨立方法或代碼塊內創建實在不行在確定不再使用后主動memory null切斷引用注意編譯器優化別以為 JIT 一定會幫你處理這個問題。這也是一個高頻面試點一個局部變量作用域已經結束了它指向的對象會不會立即被 GC答案是不一定可能因為 slot 未復用而延遲回收。3.3 逃逸分析棧上分配與對象不逃逸的關系既然局部變量的引用是對象存活的憑證那 JVM 能不能判斷對象根本不“逃出去”干脆不放到堆里這就是逃逸分析干的事。JIT 編譯時如果判斷一個對象只在方法內部被使用不會逃逸出方法也不會被其他線程訪問就可以做以下優化棧上分配直接把對象的字段拆開分配到棧幀中方法結束后隨棧幀彈出自動銷毀標量替換把對象拆成若干個局部變量繞開真實對象結構鎖消除判斷對象鎖不會被其他線程訪問則去掉同步開銷。HotSpot 目前的實現里真正的棧上分配并不是主要手段但標量替換是實際存在的優化。比如 JVM 的-XX:DoEscapeAnalysis開啟后會用逃逸分析結果做標量替換很多小對象不再在堆里創建實體。這個概念和內存關聯有什么關系因為逃逸分析的本質是“切斷對象與 GC 的關系”。如果一個對象不逃逸GC 就不需要追蹤它。反過來如果你寫了一個明明可以在棧上解決的對象卻讓它逃逸出去堆內存壓力就會增加。所以理解逃逸分析不僅能讓你在面試里多聊一段還能幫助你寫出更低內存消耗的代碼。4. 類加載、元空間與Class對象對象的“結構藍圖”存在哪4.1 類加載的完整階段加載、驗證、準備、解析、初始化前面說 new 一個對象可能會觸發類加載那類加載本身又經歷什么類從字節碼變成 JVM 可用的類元數據一共有五個階段加載Loading通過類加載器讀取 class 文件的二進制流在堆中生成 Class 對象并把它綁定到 JVM 內部的數據結構上。驗證Verification校驗字節碼是否符合規范防止惡意或非法字節碼。這是安全的一道閘門。準備Preparation為類變量static 字段分配內存并設置零值。注意這里只賦零值代碼里寫的初始值還沒生效。解析Resolution把常量池里的符號引用替換為直接引用。類、接口、方法、字段的引用都會在這個階段被解析。初始化Initialization執行clinit方法為靜態變量賦代碼設定的初始值執行靜態代碼塊。和對象初始化不同的是類初始化只發生一次。如果類已經初始化創建再多對象也不會重新執行靜態代碼塊。所以我們在看內存占用時類元數據只占一份而對象實例可以有很多份這也是“模板與復制品”在運行時最直觀的體現。準備階段還給靜態字段分配了內存這點特別重要。Java 8 之后靜態字段本身存儲在哪有變化但類是全局共享的這一點不變。你寫的 static 變量不會被復制到每個對象里而是作為類級別數據存在。如果靜態變量是引用類型它指向的對象也是全局共享的GC 會把靜態變量當作 Root這就為后面的內存泄漏埋下線索。4.2 方法區/元空間到底存了什么類元數據與靜態變量JDK 8 之前類元數據存放在方法區永久代PermGen里。JDK 8 之后永久代被移除改為元空間Metaspace使用本地內存而不是 JVM 堆內存。元空間里存放類的基本信息、字段信息、方法信息、字節碼指令、常量池、注解等。這里有一個很經典的混淆元空間里存靜態變量嗎早期永久代里確實放靜態變量JDK 7 開始靜態變量和字符串常量池被移動到了堆中JDK 8 移除永久代后類元數據才最終落到元空間。如果你在面試中被問到“靜態變量存在哪里”回答“堆中”是相對準確的說法嚴格說要看 JVM 版本和存放的具體內容。看到很多八股文一股腦說靜態變量在方法區其實不嚴謹。總之元空間和堆之間不是完全隔離的類元數據可以通過 Class Pointer 與堆中的對象關聯。一個 Person 對象通過對象頭中的 Class Pointer 找到 Person 類的元數據從而調用對應的方法同時元數據也會感知到加載它的 ClassLoader 是哪個。4.3 雙親委派與自定義類加載器類在內存中的“身份證”如果說對象是房子那么類加載器就是城市的規劃局它決定每個類文件被誰接收、如何命名。JVM 使用雙親委派模型應用程序類加載器先委托給平臺類加載器平臺類加載器再委托給啟動類加載器只有父加載器加載不到時才反過來找子加載器。這樣設計的好處有兩個避免核心類被重復加載和替換。比如你不想自己在代碼里寫一個 java.lang.String然后讓系統用你的版本。保證帶有類名類加載器的“身份證”唯一性。JVM 判斷兩個類是否相等不僅看類名還要看類加載器是否相同。自定義類加載器在這套模型里玩出很多花活比如熱部署、插件化。每次重新加載同一個 class 文件JVM 都會創建一份新的類元數據和對應的 Class 對象。如果舊的類加載器沒有被釋放那舊類對象和它對應的對象實例全部無法回收這就是熱部署導致內存泄漏的一個重要原因。所以如果你在排查元空間內存持續增長、或者類數量一直在漲第一反應應該是看是不是自定義類加載器大量創建且沒有卸載的時機。類加載器持有的 Class 集合、引用對象、線程上下文等都可能是內存關聯鏈條上難以察覺的一環。5. 內存關聯出問題時我是這樣排查的典型案例與面試題還原5.1 案例一對象置null后為什么還是沒有釋放有一次排查線上服務代碼邏輯很清晰一個大方法里申請了個 50MB 的緩存數組處理完后繼續往下跑其余代碼占內存不高但監控卻顯示 GC 后內存遲遲降不下來。后來用了 MATMemory Analyzer Tool分析堆dump發現數組對象的 GC Root 鏈路上指向來源是在一個棧幀的局部變量里。局部變量的作用域雖然已經結束但 JVM 沒有清理局部變量表 slot導致那 50MB 一直無法回收。這種問題的定位思路是先看堆dump找到大對象查詢對象到 GC Roots 的引用鏈看到引用來自 Thread - ThreadLocal - Stack Frame 之類的路徑立刻能猜到是局部變量表遲到回收最后回源碼把大對象的作用域縮小或者在使用完后主動置 null。這類問題并不少見尤其是方法體非常長、容錯又高的代碼里。另一個相似場景是 ThreadLocal 使用后沒有 remove線程池復用時上一個請求的數據被下一個請求讀到還讓整個對象無法回收。所以 ThreadLocal 必須要配合 remove很現實。5.2 案例二靜態集合為什么是內存泄漏大戶靜態變量屬于類級別數據生命周期和類一樣長。如果你的代碼里有public class CacheHolder { public static ListObject cache new ArrayList(); }然后不斷往 cache 里 add 對象除非你主動 remove否則這些對象會一直被靜態引用牽住GC 永遠不可能回收它們。這個模式最常見的形態是緩存、監聽器注冊表、全局配置中心、埋點上報隊列。很多人覺得“反正有上限”但忘記了一個 add 漏了一個 remove時間一長就會變成一個內存黑洞。排查這類問題時重點看誰持有了靜態引用。使用 jmap 或 MAT 查找大對象時GC Roots 會直接指到java.lang.Class上的靜態字段。解決辦法也很簡單用 WeakHashMap 或軟引用緩存定期清理、設置容量上限使用成熟緩存框架比如 Caffeine它們內部已經處理了淘汰邏輯。注意這里有個面試高頻題兩個對象相互引用會不會導致內存泄漏答案是不會。JVM 的 GC 用的是可達性分析不是引用計數。A 引用 BB 引用 A但兩者都沒有被 GC Roots 直接或間接引用那么它們構成的引用環就是不可達的會被完整回收。你不需要手動把 A、B 都設為 nullGC 會處理。這個題之所以經典就是因為它在考察你對象和引用的內存關聯到底理解得多深。5.3 案例三DirectByteBuffer與堆外內存的誤區還有一個和“對象在堆內、內存卻能跑到堆外”相關的坑ByteBuffer.allocateDirect()分配的是堆外內存所以它不受-Xmx限制。DirectByteBuffer 對象本身很小放在堆內它內部持有Cleaner在對象被 GC 回收時會觸發堆外內存的釋放。很多人以為設置 Xmx 就能限制所有內存結果線上物理內存被打滿dump 下來卻沒多少堆內存。這時候要意識到可能是堆外內存失控。從類與對象內存關聯角度看這本質上是一個堆內小對象通過 JNI 關聯了一個堆外的內存塊。小對象DirectByteBuffer和大內存塊堆外不是同一個生命周期必須靠特殊的引用機制去聯動。如果代碼里不斷創建 DirectByteBuffer而沒有及時觸發 Cleaner堆外內存就會持續上漲進而表現為OutOfMemoryError: Direct buffer memory。排查堆外內存最直接的手段是使用jcmd、perf或 NMTNative Memory Tracking來看 Native 內存分布。這和普通堆內存排查路線非常不同也是很多“內存占用過高”問題里容易被忽略的方向。如果你在熱搜里看到那些“XX進程占用內存過高”的問題很多都藏著 DirectBuffer、線程棧、JIT 編譯產物等非堆內存的身影。5.4 面試題還原類與對象內存關聯的常見追問題目往往從最簡單的開始“說說類的加載過程。”這是開胃菜。考官真正想聽的是加載、驗證、準備、解析、初始化然后追問準備階段靜態變量為什么是零值。“對象在內存中如何布局”這是硬核考點。對象頭、實例數據、對齊填充為什么要對齊因為 CPU 讀取效率和對象尋址效率。“new 一個對象的過程包含哪些步驟”類加載檢查、分配內存、零值初始化、設置對象頭、執行構造方法。如果還能補充 TLAB 和指針碰撞基本就能通過。“引用的幾種類型各自在什么場景用”軟引用做緩存弱引用防止 ThreadLocal 泄漏虛引用做堆外內存回收等。最深的一層往往是“你如何判斷一個對象該被回收”。不只要背可達性分析還要能解釋 GC Roots 包括哪些棧幀引用、靜態變量、JNI引用、活動線程等。這一串概念全串起來其實就是今天講的類與對象在內存中的關聯全圖。我自己帶團隊面試時最怕聽到的答案不是“不會”而是那種只背概念、沒有任何推理過程的回答。比如“靜態變量在方法區”直接說出來卻回答不了“String 常量池為什么 JDK7 移動到堆”。所以我寫這篇文章的出發點不是讓你多背幾條八股而是希望你能把類與對象的生命軌跡在腦子里形成一張真正的內存地圖。以后無論是看 dump、調參數還是被面試官連環追問你都能順著“類加載 — 對象分配 — 引用連接 — GC 回收”這條主線一步步推過去。