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,典型场景就是无限递归;如果栈允许动态扩展但扩不了,会抛 OOM(HotSpot 基本不允许扩展,所以这种情况少)。这里顺便纠正一个高频误区:局部变量表里存的是引用,不是对象本身,对象本体在堆里。你可能经常听到“基本类型在栈里,对象在堆里”这种粗糙说法,严格说应该是“对象的引用在栈里,对象实例在堆里”,这个细节在面试里很加好感。
本地方法栈(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:线程私有的分配缓冲区
TLAB(Thread Local Allocation Buffer)是 HotSpot 在 Eden 区给每个线程划出的一块私有分配空间,默认开启,对应参数 -XX:+UseTLAB。对象分配时优先在 TLAB 里进行,TLAB 用完了再通过 CAS 去 Eden 区申请新的一块,或者直接在 Eden 里分配。这样绝大多数新对象分配不需要和其他线程争抢,吞吐量提升非常明显。
要注意,TLAB 不是对象的最终归属地,它只是“分配缓冲”的概念,对象物理上还是在 Eden。JDK 9+ 可以用 -Xlog:gc+tlab=trace 查看 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:NewRatio=2 决定老年代和新生代的比例是 2:1,所以新生代默认占堆的三分之一。新生代内部又分成 1 个 Eden 和 2 个 Survivor,默认 -XX:SurvivorRatio=8,表示 Eden:Survivor=8:1:1。为什么要留两个 Survivor?因为复制算法需要一个始终空闲的 Survivor 作为目的地,来回倒腾,避免内存碎片化。如果你把 Survivor 配成 1 个,对象复制时就没有备用空间,新生代就退化成了一种低效的标记-清除模式。
3.2 Minor GC 时对象怎么搬家
Eden 空间不足时触发 Minor GC(只回收新生代)。存活对象从 Eden 复制到 Survivor,同时 GC 年龄加 1。默认 -XX:MaxTenuringThreshold=15(部分收集器默认不同),年龄到阈值就晋升老年代。如果 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:SurvivorRatio | Eden:Survivor 比例 | 默认 8 | 即 Eden:Survivor:Survivor=8: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 8):
java -Xms2g -Xmx2g -Xmn1g -XX:SurvivorRatio=8 \ -XX:MaxTenuringThreshold=15 -XX:MaxMetaspaceSize=512m \ -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 exceeded | GC 消耗超 98% 且回收不足 2% | 基本等同堆溢出,优先 dump 分析 |
| Metaspace | 类元数据不断增长 | 看自定义类加载器数量,jmap -clstats,检查反射/代理生成类 |
| unable to create new native thread | 线程数超系统限制 | -Xss 调小、检查线程池泄漏、ulimit -u |
| Direct 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:format=b,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*:file=gc.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 的系列,把这块补完整。