在新手阶段大家背得最多的一句话就是类是模板对象是实例。可真到做排查、看监控或者被面试官追着问的时候很多人会发现这句话根本不顶用。为什么因为它没有回答最核心的那个问题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 回收”这条主线一步步推过去。