尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

JVM内存结构详解:从对象分配到GC回收的完整路径

发布时间:2026/9/24 21:59:33

资讯中心
01
ARTICLE

JVM内存结构详解:从对象分配到GC回收的完整路径

JVM内存结构详解:从对象分配到GC回收的完整路径
JVM 这话题我见过太多人把“内存结构”当成八股文来背程序计数器、虚拟机栈、本地方法栈、堆、方法区几个名字背得滚瓜烂熟可真到线上 OOM 或者 GC 频繁的时候依然不知道从哪下手。做 Java 性能排查这些年我最大的体会是——内存结构这张图不是拿来背的是拿来当路径图用的。只要你把“一个对象从 new 出来到分配进内存再到被回收或者晋升”这条完整链路走一遍后面什么垃圾回收、调优参数、线上故障排查全都是顺着这条路长出来的。这是 JVM 系列的第一篇咱们就聚焦两件事Java 的内存结构以及对象到底是怎么分配进去的。文章按现在线上最常见的 JDK 8 HotSpot 来讲面试和实战都够用。1. 内存结构先有地图才谈得上定位1.1 先用两个阵营记住六个区JVM 规范里的运行时数据区Runtime Data Area可以分成六个逻辑区域程序计数器、Java 虚拟机栈、本地方法栈、堆、方法区、运行时常量池规范里单独列实现上算方法区的一部分。我自己记忆的时候从来不一个个硬背而是先分成两个阵营线程私有的和线程共享的。线程私有的包括程序计数器、虚拟机栈、本地方法栈线程共享的是堆和方法区。这个划分本身就是面试题的答案来源为什么局部变量天然不存在并发问题因为它活在私有栈里其他线程根本碰不到为什么静态变量、对象实例要考虑并发因为它们都在共享区。理解了阵营划分后续看锁、看并发、看 GC 都会顺很多。另外“分代”“永久代”“元空间”这些词是 HotSpot 的实现概念不是 JVM 规范规定的这点先有个印象后面会反复用到。很多人一上来就纠结“方法区到底是不是永久代”其实先分清规范层面和实现层面这个问题就不攻自破了。1.2 线程私有区程序计数器、虚拟机栈、本地方法栈先看程序计数器Program Counter Register。它是当前线程正在执行的字节码行号指示器分支、循环、跳转、异常恢复、线程切换后能回到正确位置全靠它。这玩意内容极小也是 JVM 规范里唯一不会 OOM 的区域。为什么每个线程都要有一份因为线程的本质是轮流抢占 CPU 时间片切走再切回来的时候必须知道刚才执行到哪一行了否则整个执行流程就乱了。这个点理解到位你就能解释“多线程为什么必须上下文切换”这种底层问题而不是只会背概念。虚拟机栈Java Virtual Machine Stack相当于每个线程私有的“方法调用栈”。每次调用一个方法就压入一个栈帧方法返回栈帧弹出去。栈帧里装的是局部变量表基本类型、引用类型、returnAddress、操作数栈、动态链接、方法出口这些信息。栈深度超出限制会抛 StackOverflowError典型场景就是无限递归如果栈允许动态扩展但扩不了会抛 OOMHotSpot 基本不允许扩展所以这种情况少。这里顺便纠正一个高频误区局部变量表里存的是引用不是对象本身对象本体在堆里。你可能经常听到“基本类型在栈里对象在堆里”这种粗糙说法严格说应该是“对象的引用在栈里对象实例在堆里”这个细节在面试里很加好感。本地方法栈Native Method Stack是给 native 方法用的HotSpot 直接把虚拟机栈和本地方法栈合并了所以 -Xss 同时控制两者。日常你不太需要纠结它但要认识它jstack 输出线程栈时会出现 Native Thread 相关的内容能对上号排查 JNI 相关问题时会少走弯路。很多从 C 转过来的同学反而对这块更敏感因为 JNI 调用栈和 Java 栈是两套体系出了问题要分别看。1.3 线程共享区堆、方法区与直接内存堆Java Heap是 JVM 管理的最大一块内存几乎所有对象实例和数组在这里分配。注意我说“几乎所有”因为逃逸分析之后有一部分对象可以被分配到栈上或者被标量替换根本不进堆后面会展开。堆在物理上不需要连续逻辑上连续就行并且可以被划分成新生代和老年代供主流分代收集器使用。很多资料会把“分代”说成规范要求其实不是分代是 HotSpot 这类收集器的实现策略规范只是规定了“堆存在”。方法区Method Area存的是类的结构信息类元信息、字段、方法、常量池、静态变量、JIT 编译后的机器码等。JDK 7 及之前HotSpot 用永久代PermGen实现方法区JDK 8 开始改成元空间Metaspace。元空间最大的变化是默认使用本地内存不受堆大小约束。这个改动坑过很多线上服务堆内存看着没涨但服务莫名挂了一查是反射、动态代理一类的代码在疯狂生成类Metaspace 撑爆了操作系统内存。所以我一向建议在 JVM 参数里显式配置 -XX:MaxMetaspaceSize给元空间一个明确上限别让它裸奔。这里要特别提醒一个概念混淆网上搜“JVM 内存模型”可能搜出两种东西。一个是本文讲的运行时数据区另一个是并发编程里的 Java 内存模型JMM讲主内存、工作内存、volatile、happens-before 那套。两者名字像但解决的问题完全不同。你在面试里被问“JVM 内存模型”时最好先确认对方指的是哪个不然答了半天方向错了很尴尬。堆外还有一个容易被忽略的区域——直接内存Direct Memory。它不属于 JVM 运行时数据区是 NIO 通过 DirectByteBuffer 在堆外分配的本地内存好处是省一次从堆到操作系统缓冲区的拷贝。线上最常见的问题是-Xmx 设置得很小GC 也正常内存却越占越多进程被系统杀掉。这种时候十有八九是只盯着堆内内存没算堆外。JDK 8 以后 Metaspace 也在堆外所以堆外内存的概念必须纳入排查范围否则方向从一开始就错了。1.4 运行时常量池和 String.intern 的误区运行时常量池是方法区的一部分存编译期生成的字面量与符号引用。它最有名的应用是 String.intern()JDK 7 以后字符串常量池被移到了堆所以永久代“字符串 OOM”的经典案例在 JDK 8 后基本见不到了。但这不是说常量池就不会出问题动态生成字符串引用过多堆里字符串对象膨胀依然会 OOM。给你个直觉例子无限调用 String.intern() 塞入大量唯一的字符串最终结果是堆溢出而不是元空间溢出。这个小细节能区分你是真懂还是只会背“intern 可以省内存”这句话。我一直觉得内存结构的价值在于它能解释“现象”。比如为什么要设置 -Xmx因为堆有上限为什么要设置 -XX:MaxMetaspaceSize因为元空间默认无上限为什么线程数太多会 OOM因为每个线程都要占栈空间。这些看似零散的知识全部都能收进这张内存结构图里。2. 对象分配不是一步是一条路2.1 new 指令的第一件事类加载检查new 一个对象时JVM 不是马上就分配内存。它先到常量池里定位这个类的符号引用检查这个类是否已经完成加载、解析、初始化。如果没有还得先触发类加载流程。这就是为什么一个类第一次创建实例往往会消耗更多时间后面再 new 就快很多——类加载只需要一次。类加载完成后才进入内存分配。所以你在回答“对象创建过程”时标准链路应该包含类加载检查、分配内存、初始化零值、设置对象头、执行 init 方法。这五步缺一个都不完整面试官一般会顺着这条链路一路追问。实际写代码的时候肉眼感知最明显的就是“第一个请求特别慢”经常就是因为类加载在作祟很多人误以为是数据库连接慢其实是 JVM 在初始化类。2.2 分配内存指针碰撞还是空闲列表HotSpot 给对象分配空间按堆是否规整分为两种方式。如果 GC 用的是复制算法或标记-整理算法比如 Serial、ParNew、Parallel Scavenge内存就是连续规整的分配时就移动一个指针指针往右挪对象大小叫指针碰撞Bump the Pointer。如果 GC 用的是标记-清除算法比如 CMS内存会有碎片JVM 需要维护一个空闲列表每次分配找一块足够大的空间叫空闲列表Free List。两种方式如何选核心取决于回收器是否做压缩整理。这也是为什么 CMS 在并发收集时代对内存碎片很敏感取代它的 G1 用 Region 划分又走了不同的分配路径。这些收集器相关的内容后面单独开篇这里你先记住对象分配方式和 GC 算法是强耦合的不是随便选的。并发分配的问题也要解决。多个线程同时 new同一块空间不能分给两个人。HotSpot 的做法一个是 CAS 加失败重试另一个是下面要重点说的 TLAB。CAS 方案在竞争激烈时性能会下降所以 TLAB 才是主路径。2.3 TLAB线程私有的分配缓冲区TLABThread Local Allocation Buffer是 HotSpot 在 Eden 区给每个线程划出的一块私有分配空间默认开启对应参数 -XX:UseTLAB。对象分配时优先在 TLAB 里进行TLAB 用完了再通过 CAS 去 Eden 区申请新的一块或者直接在 Eden 里分配。这样绝大多数新对象分配不需要和其他线程争抢吞吐量提升非常明显。要注意TLAB 不是对象的最终归属地它只是“分配缓冲”的概念对象物理上还是在 Eden。JDK 9 可以用 -Xlog:gctlabtrace 查看 TLAB 相关日志JDK 8 下用 -XX:PrintTLAB。有时候线上对象分配特别频繁可以从 TLAB 浪费率和分配失败次数判断要不要调 -XX:TLABSize但大多数情况下默认值够用。有一个值得注意的坑TLAB 里剩余空间不够分配时会触发“TLAB 慢分配”并回退到 Eden这种回退如果频繁说明对象大小不均匀或者偏大。如果你碰到“大量小对象却频繁 TLAB 外分配”的情况多半是 TLAB 设置太小或者对象生命周期异常导致回收跟不上。2.4 对象的内存布局对象头、实例数据、对齐填充分配好空间之后JVM 会把对象内存清零然后设置对象头Object Header。HotSpot 在 64 位 JVM 上默认开启指针压缩-XX:UseCompressedOops对象头一般是 12 字节Mark Word 8 字节 Klass Pointer 4 字节如果是数组还有额外 4 字节记录数组长度。Mark Word 是个大杂烩对象的 hashCode、GC 分代年龄、锁状态标志位、偏向锁信息都在里面。你背的 synchronized 偏向锁、轻量级锁很多状态就是从这部分“抠”出来的。实例数据区放的是你定义的实例字段存储顺序和代码定义顺序及对齐策略有关。最后是对齐填充——HotSpot 要求对象起始地址是 8 的倍数不足就补位。这个逻辑和 C 语言“结构体内存对齐”是完全一样的道理很多追求极致的团队会把 boolean、byte 这类小字段排在一起减少填充浪费。了解完布局你就能回答“一个空 Object 占多少内存”这种问题了默认 16 字节12 字节头 4 字节对齐。2.5 逃逸分析对象不一定进堆HotSpot 默认开启逃逸分析-XX:DoEscapeAnalysis。如果一个对象只在方法内部创建没有被 return、没有被传入其他方法、没有挂到任何共享容器里它就相当于“没有逃逸”。这种情况下JVM 可以做标量替换-XX:EliminateAllocations把这个对象拆散成几个局部变量直接在栈帧里分配方法结束栈帧销毁连 GC 都不用参与。这就是“对象一定在堆上吗”的标准答案不一定。逃逸分析加标量替换之后短生命周期的小对象可以不在堆上。顺带一提逃逸分析还支持锁消除-XX:EliminateLocks对不可能产生竞争的锁直接去掉减少无谓开销。实际经验是逃逸分析对方法内创建的、生命周期极短的小对象效果明显但如果对象放进 List 返回出去逃逸了就老老实实进堆。3. 对象在堆里的“晋升之路”3.1 为什么要分新生代和老年代大部分对象“朝生夕灭”创建出来没多久就会被回收。分代假说的核心是为不同寿命的对象准备不同的区域和回收策略。新生代对象寿命短适合复制算法Minor GC 频繁但单次很快老年代对象寿命长用标记-清除或标记-整理GC 次数少但单次较慢。HotSpot 默认由 -XX:NewRatio2 决定老年代和新生代的比例是 2:1所以新生代默认占堆的三分之一。新生代内部又分成 1 个 Eden 和 2 个 Survivor默认 -XX:SurvivorRatio8表示 Eden:Survivor8:1:1。为什么要留两个 Survivor因为复制算法需要一个始终空闲的 Survivor 作为目的地来回倒腾避免内存碎片化。如果你把 Survivor 配成 1 个对象复制时就没有备用空间新生代就退化成了一种低效的标记-清除模式。3.2 Minor GC 时对象怎么搬家Eden 空间不足时触发 Minor GC只回收新生代。存活对象从 Eden 复制到 Survivor同时 GC 年龄加 1。默认 -XX:MaxTenuringThreshold15部分收集器默认不同年龄到阈值就晋升老年代。如果 Survivor 空间装不下存活对象多出来的对象会直接晋升老年代这叫“分配担保”老年代需要足够空间兜底。实际 GC 日志里你会看到类似[GC (Allocation Failure) ... 2549K-512K(9216K)]的输出对象被搬来搬去。这里有个常见的认知误区对象必须等到 15 岁才晋升不一定。HotSpot 有动态年龄判定如果 Survivor 中某一年龄及以上的对象大小总和超过 Survivor 空间的一半这些对象会提前晋升不会傻等 15 岁。这个机制是为了防止 Survivor 空间被“半死不活”的对象长期占据导致新生代 GC 效率低下。3.3 大对象直接进老年代的真相如果设置了 -XX:PretenureSizeThreshold单位字节比如 3145728 表示 3MB超过阈值的对象会直接分配到老年代。目的是避免大对象在 Eden 和 Survivor 之间来回复制节省大量拷贝时间。这个参数在 Serial、ParNew 这类收集器下行为比较可控但对 G1 和大对象Humongous Object的逻辑不完全一致G1 的大对象分配用的是另一套规则。实操提醒不要天真地以为配个大对象阈值就能解决一切。我在线上见过最典型的大对象事故是某个批量接口一次查出几十万条数据放到 List 里直接打爆老年代或者频繁触发 Full GC。这种问题靠 JVM 参数只能缓解根因还是代码设计。大对象出现频繁应该先从数据库分页、流式处理、分批提交这些方向找答案而不是一味调参。3.4 一条链路串起来从 new 到最终位置把上面的路径按优先顺序排一下就是完整的对象分配主流程逃逸分析对象未逃逸栈上分配或标量替换方法结束即销毁。需要进堆时优先在 TLAB 分配。TLAB 不够尝试在 Eden 通过 CAS 分配。Eden 空间不足触发 Minor GC存活对象复制到 Survivor年龄加 1。年龄达到阈值或动态年龄判定达标或 Survivor 装不下晋升老年代。老年代空间不足触发 Major GC 或 Full GC再不足抛 OOM。这套流程能一条线讲清楚就已经是“JVM 上手”水平了。后面排查问题无非是对号入座大量对象 TLAB 外分配看是不是对象太大大量对象从 Survivor 直接晋升看是不是 Survivor 太小或对象生命周期长于预期。4. 参数、工具与线上排查4.1 核心参数速查表参数作用常用配置备注-Xms / -Xmx堆初始值和最大值-Xms2g -Xmx2g线上建议设成相同值避免扩容抖动-Xmn新生代大小-Xmn1g手动指定后优先级高于 NewRatio-XX:NewRatio老年代:新生代比例默认 2未指定 -Xmn 时生效-XX:SurvivorRatioEden:Survivor 比例默认 8即 Eden:Survivor:Survivor8:1:1-XX:MaxTenuringThreshold晋升年龄阈值默认 15部分收集器不同实际受动态年龄判定影响-XX:PretenureSizeThreshold大对象直接进老年代单位字节CMS、ParNew 等行为可预期-XX:UseTLAB开启线程本地分配缓冲默认 true一般不用动-XX:MetaspaceSize / MaxMetaspaceSize元空间初始/最大默认受本地内存限制建议设上限避免无界增长-XX:UseCompressedOops指针压缩JDK 8 默认开启对象头 Klass Pointer 变 4 字节-Xss线程栈大小默认 512K~1M线程数多时考虑调小实际配置示例JDK 8java -Xms2g -Xmx2g -Xmn1g -XX:SurvivorRatio8 \ -XX:MaxTenuringThreshold15 -XX:MaxMetaspaceSize512m \ -XX:PrintGCDetails -XX:PrintGCDateStamps \ -Xloggc:/data/logs/gc.logJDK 9 的 GC 日志参数变成了统一的 -Xlog格式上要跟着升级-Xlog:gc*:file/data/logs/gc.log:time,uptime,level为什么 -Xms 和 -Xmx 要相等因为 JVM 在堆不够用时会动态扩容扩容过程本身就涉及内存重新分配和 GC线上环境这种抖动完全可以避免。为什么元空间要设上限避免反射、动态代理生成的类无限累积最后把本地内存打满这个坑见得太多了。4.2 常见 OOM内在原因与处理方向OOM 类型常见原因排查方向Java heap space堆内存不足或对象泄漏jmap -histo:live、dump 加 MAT 分析对象分布与引用链GC overhead limit exceededGC 消耗超 98% 且回收不足 2%基本等同堆溢出优先 dump 分析Metaspace类元数据不断增长看自定义类加载器数量jmap -clstats检查反射/代理生成类unable to create new native thread线程数超系统限制-Xss 调小、检查线程池泄漏、ulimit -uDirect buffer memory堆外内存用完检查 NIO/DirectByteBuffer 是否及时释放配 MaxDirectMemorySize每个 OOM我建议大脑里先过一遍“分配链路”的第 6 步是哪一级空间不足是“临时空间不足”还是“对象根本释放不了”前者调参后者查泄漏处理路径完全不同。比如 Metaspace OOM很多人第一反应是加大 MaxMetaspaceSize但如果罪魁祸首是每次请求都用反射生成新类加多大都没用早晚还会爆。4.3 排查工具用法要点排查顺序我是这么用的jps先确认 Java 进程 PID很多工具都要用它。jstat -gcutil 1000每 1 秒输出一次各区使用率、YGC/FGC 次数与耗时。只要看几行就能判断是新生代 GC 频繁还是老年代 GC 频繁。jmap -heap 看堆配置和当前各区占用jmap -histo 看对象类型数量排名jmap -dump:formatb,file/data/heap.hprof 做堆 dump。jstack thread.log抓线程栈看死锁、线程堆积、阻塞在哪。jconsole / VisualVM可视化监控适合本地开发和测试环境。Arthas线上利器dashboard 看各区内存heapdump 导堆jad 反编译线上代码确认发布版本trace 看方法耗时。很多团队已经把 Arthas 作为线上排查标配。使用上有三个坑要提醒。一是 jmap -histo:live 在某些版本会触发 Full GC线上要谨慎尽量在低峰期操作。二是 heap dump 文件可能很大导出前先确认磁盘空间否则拷到一半磁盘满问题没定位到还添乱。三是 dump 文件用 Eclipse MAT 或 JProfiler 分析重点看 Dominator Tree支配树和 Leak Suspects泄漏嫌疑人别傻看顶层统计数据那只能告诉你哪个类对象多不能告诉你为什么多。5. 高频面试问答与实战避坑5.1 几个高频问题的简洁答案整理几个面试常问的你自己先答一遍再看答案对象一定分配在堆上吗不一定。逃逸分析后可以被标量替换或栈上分配TLAB 只是在 Eden 里的分配缓冲并不改变对象最终在堆里的事实。栈和堆有什么区别线程私有与共享、生命周期、是否需要 GC、OOM 类型都不同。栈溢出是 StackOverflowError堆不足是 OutOfMemoryError: Java heap space。为什么要两个 Survivor复制算法需要一个始终空闲的区域作为目标防止内存碎片化。Minor GC、Major GC、Full GC 有什么区别Minor 只回收新生代Major 回收老年代Full 回收整个堆和方法区。CMS 下 Major GC 的边界有些绕把“老年代空间不足触发”讲清楚即可。方法区会 OOM 吗JDK 8 后会以 Metaspace OOM 的形式出现典型原因是动态生成类。5.2 我踩过的坑和几条实用经验第一个坑生产环境没开 GC 日志。等线上出问题再想找当时的 GC 日志已经什么都晚了。JDK 8 就把-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log写进启动脚本JDK 9 用-Xlog:gc*:filegc.log:time,uptime,level。GC 日志很小建议至少保留最近几轮的滚动文件。第二个坑拿 JVM 参数“调教”代替代码优化。见过一个团队把 -Xmx 从 4G 调到 8G以为问题解决了结果对象泄漏还在继续只是爆得更晚。正确顺序永远是先做 heap dump 分析找到谁是问题根源再谈调参。参数只是兜底不是解药。第三个坑误以为 Survivor 越大越好。有人觉得 Survivor 大能少晋升其实 Survivor 太大会压缩 Eden导致 Minor GC 触发得更频繁整体吞吐反而下降。调整评判标准要看晋升速率和 GC 停顿时间不是看某个区大小。第四个经验记住一个排查命令组合拳。遇到内存问题先 jps 拿 PID再 jstat -gcutil 观察 10 秒 GC 行为然后 jmap -histo 看对象分布如果发现某个业务对象特别多dump 下来用 MAT 看引用链。这套流程走完八成问题能定位。第五个经验字符串 intern 别乱用。intern 可以节省重复字符串内存但随便 intern 大量唯一字符串等于自己给自己制造堆压力。比如数据库查出来的几千个城市名根本不会有太高收益反而可能把字符串常量池撑大得不偿失。最后再分享一个我个人的习惯每次给线上 JVM 加参数或者做调整时我都会顺便更新启动配置的注释写清楚每个参数为什么这么配。比如“-Xmn1g观察 YGC 平均耗时在 20ms 以下决定”“MaxTenuringThreshold 从默认 15 降到 6因为动态年龄判定后晋升年龄基本都在 5 到 6 之间”。几个月后再回看你不会记得当时拍脑袋的参数只有注释能告诉你当时的判断依据。这也是 JVM 调优里最容易忽略、但长期价值最高的习惯。后面我会继续写垃圾回收器和 JMM 的系列把这块补完整。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。