1. 先厘清"内存模型"到底在问什么:两个容易被混为一谈的概念
有开发经验的同学应该遇到过这种场面:面试官问"聊聊JVM的内存模型",回答者嘴里的内容和面试官想听的内容经常对不上。有人从synchronized、volatile、happens-before 开始大谈多线程可见性;有人掏出-Xms、-Xmx,从堆讲到栈再讲到方法区。两边其实都没有错,但聊的根本是两个东西。
前一种属于 Java 语言规范里的 Java Memory Model(JMM),它解决的是多线程场景下共享变量的可见性、有序性和原子性问题,核心是主内存和工作内存之间的抽象关系,加上一系列 happens-before 规则。后一种说的是 JVM 规范里的运行时数据区,也就是我们常说的堆、栈、方法区、程序计数器、本地方法栈这些区域的划分和各自的行为。这两者名字都带着"内存模型"四个字,但一个是并发语义问题,一个是内存布局和管理问题。
这篇文章我打算把后者作为主线,把 JVM 运行时数据区的每个部分拆开讲清楚,同时也会穿插说明它和 JMM 的区别,因为实际排查问题的时候,这两套知识经常要配合使用。比如一个线程不安全的程序表现出诡异的内存可见性问题,你用堆栈分析工具是看不出所以然的,必须回到 JMM 那套规则里找答案。反过来,一个 OOM 问题,光懂并发规则也没用,你得知道 OOM 是哪个区域抛出来的,这个区域什么条件下会溢出。
我在前几年整理 JVM 排查思路的时候,最大的体会是:堆栈结构、内存分区这些概念必须落到"一次对象分配、一次GC、一次线程切换、一次方法调用"这样的具体动作上,才能形成真正的直觉。如果只是背参数和结论,遇到线上问题还是会慌。所以下面我会尽量用"发生什么动作、产生什么后果、如何验证"的方式来展开。
另外需要说明的是,本文讨论的 JVM 实现以 HotSpot 虚拟机为主。JVM 规范只是定义了概念和规则,不同的虚拟机会有实现层面的差异,但主流场景下接触最多的就是 HotSpot,聊它最有实际意义。
2. 运行时数据区全景:每个区域的职责、生命周期和常见异常
JVM 在运行 Java 程序时,把自己管理的内存划分为若干个区域,这些区域在 Java 虚拟机规范里被统称为运行时数据区。我习惯用"谁拥有、存什么、什么时候消失"三个问题来记忆每个区域,因为这三个问题的答案直接决定了你在写代码时该不该担心它。
2.1 程序计数器:最不起眼但最基础的区域
程序计数器是一块较小的内存空间,可以看作当前线程所执行的字节码的行号指示器。字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等功能都依赖它。
注意,程序计数器是线程私有的区域,每个线程都有自己的一份。这里有个天然的安全设计:如果线程正在执行的是一个 Java 方法,计数器记录的是正在执行的虚拟机字节码地址;如果执行的是本地方法(native method),计数器的值为 undefined。这个区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域,因为它的内存需求是确定的、极小的,随着线程创建而创建,线程销毁而消亡。
这一块我们平时写业务代码几乎感知不到,但它确实参与了每一次方法调用和每一条字节码指令的执行。理解它主要是为了把整个运行模型补完整,同时明白线程私有的区域有哪些。
2.2 虚拟机栈:每一次方法调用的实时状态
虚拟机栈描述的是 Java 方法执行的线程内存模型,它是线程私有的,生命周期与线程一致。每个方法从调用到执行完成的过程,对应着一个栈帧在虚拟机栈中的入栈和出栈。
一个栈帧里主要包含这几样东西:
- 局部变量表:存放方法参数和方法内部定义的局部变量。它的容量以变量槽为单位,
long、double类型占两个槽位,其余类型占一个。特别注意,局部变量表在编译期就已经确定了大小,运行期不会改变。 - 操作数栈:作为方法执行的工作区,字节码指令从局部变量表取数据压入操作数栈,运算后再把结果弹出写回局部变量表。
- 动态链接:指向运行时常量池中该方法的引用,支持方法调用过程中的符号引用转换为直接引用。
- 方法返回地址:方法正常退出或异常退出时,需要恢复上层调用者的执行状态。
这里我最想强调的是局部变量表和操作数栈的配合关系。很多人读字节码读不进去,本质上是不理解"基于栈的指令集"是怎么工作的。举个例子,执行int a = 1 + 2,大致会经历iconst_1(把常量1压栈)、iconst_2(把常量2压栈)、iadd(弹出两个值相加并把结果压栈)、istore_0(弹出结果存入局部变量表第0个槽位)。理解了操作数栈,再看字节码就像看一台机器在运行。
如果线程请求的栈深度大于虚拟机允许的深度,会抛出 StackOverflowError。这个异常在递归没有终止条件时最典型,但还有一个常见场景是构造了特别深的调用链,比如某些重试机制层层调用却忘了控制深度。我在实际开发中遇到过一个问题:一个数据处理方法在循环里自己调自己来处理嵌套的 JSON 数据,数据层数不深时没事,一旦输入变成深度嵌套结构,直接 StackOverflowError。这种问题不是光调大栈深度就能根治的,更好的做法是把递归改成显式栈结构的迭代实现。
2.3 本地方法栈:被大多数人忽略的角落
本地方法栈与虚拟机栈的作用非常相似,区别在于虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈为虚拟机使用到的本地(native)方法服务。在 HotSpot 中,这两者直接合二为一,所以这也是很少有人讨论它的原因。
不过本地方法栈在实际业务中并不罕见。Java 通过 JNI 调用 C/C++ 编写的本地库、或者某些中间件里带了 native 实现时,都会用到它。如果本地方法栈溢出,同样会抛 StackOverflowError;如果允许动态扩展而扩展时无法申请到足够内存,则会抛 OutOfMemoryError。这类问题排查起来比纯 Java 代码复杂,通常需要结合本地库的日志和内存状态一起分析。
2.4 Java 堆:对象的主要栖身之所,也是 GC 的主战场
Java 堆是 JVM 所管理的内存中最大的一块,它在虚拟机启动时创建,被所有线程共享。几乎所有的对象实例和数组都在这里分配内存。这里说"几乎"是因为随着 JIT 编译技术和逃逸分析的发展,栈上分配、标量替换等优化手段让一些对象天然就不进堆。
堆的大小通过启动参数控制:初始大小-Xms,最大大小-Xmx。如果堆没有内存完成实例分配,且堆无法再扩展时,会抛出 OutOfMemoryError: Java heap space。
堆是垃圾收集器管理的主要区域,这也是它被称为"GC 堆"的原因。从垃圾回收的角度,Java 堆可以细分为新生代和老年代;从内存分配的角度,Java 堆可以划分出线程私有的分配缓冲区(TLAB)。这些划分视角会在后面专门展开,因为理解了堆的内部结构,才算真正理解 GC 行为。
2.5 方法区与元空间:存储类型元数据和静态结构的地方
方法区是 JVM 规范中的一个逻辑区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在 HotSpot 中,JDK 8 之前方法区的实现叫永久代(PermGen),JDK 8 之后改名为元空间(Metaspace),且不再使用 Java 堆内存,而是使用本地内存。
这个改动影响很大。永久代的大小很难精准设置,太小容易永久代溢出,太大又浪费堆空间。元空间改为使用本地内存后,默认情况下元空间的大小只受进程可用内存限制,并通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来调节。
有一点很多人记错:字符串常量池在 JDK 7 时就已经从永久代移到了 Java 堆中,所以 JDK 7 及以后,一个无限循环创建不重复字符串的程序,最终会抛 Java heap space,而不是 PermGen space。静态变量在 JDK 8 之后跟随类对象存放在堆中,这点我在最后一部分会具体说明。
方法区溢出在实际项目中相对少见,但在动态生成类特别多的场景下仍然会发生。比如某些框架的 CGLIB 代理、大量反射生成动态类、很多热部署插件的情况下,元空间可能被撑满,报出 OutOfMemoryError: Metaspace。
2.6 直接内存:不属于运行时数据区,但总来添乱
直接内存并不是 JVM 运行时数据区的一部分,它对应的是 Java 的 NIO 中基于通道和缓冲区的 I/O 方式,可以使用 native 函数库直接分配堆外内存。这样做的目的是避免数据在 Java 堆和 native 堆之间来回复制,提升 I/O 效率。DirectByteBuffer对象本身在堆上,但它引用的内存可能在堆外。
问题在于,直接内存的分配和回收不受堆大小控制,默认大小与堆大小无关,可以通过-XX:MaxDirectMemorySize调整(默认等于最大堆大小)。如果滥用堆外内存,进程可能会因为整体内存被耗尽而崩溃,表现是抛 OutOfMemoryError: Direct buffer memory,或者更恶劣的——进程直接被操作系统杀掉。
我在排查线上问题时见过几次"服务内存占用持续上涨但堆内存很正常"的情况,最终原因就是项目里用了大量基于 Netty 的异步通信,ByteBuf 分配后有个别路径没有释放。这时候用 jstat 看堆内存看不出问题,得靠进程级别的内存统计、堆外内存跟踪工具,或者仔细检查缓冲区是否及时 recycle。
3. 对象在堆上的完整旅程:从分配指令到回收标记
聊完运行时数据区的框架,我们把镜头拉近,看看一个普通的 Java 对象从创建到被回收经历了什么。这个过程其实非常机械,但每一步都蕴含着内存模型设计者的考量。
3.1 对象创建时的内存分配路径
当虚拟机遇到一条 new 指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查该类是否已被加载、解析、初始化过。如果没有,则执行类加载过程。
类的检查通过后,虚拟机开始为新对象分配内存。对象所需内存的大小在类加载完成后即可完全确定,因为对象的内存布局是固定的。分配方式有两种:如果 Java 堆中的内存是规整的,已使用的内存在一边,空闲内存在另一边,中间放着一个指针作为分界点,那么分配内存就是把这个指针向空闲方向挪动一段与对象大小相等的距离,这叫指针碰撞(Bump the Pointer)。如果 Java 堆中的内存不是规整的,已使用和空闲内存相互交错,虚拟机就必须维护一个列表记录哪些内存块可用,分配时找一块足够大的空间划分给对象实例,更新列表记录,这叫空闲列表(Free List)。
选择哪种分配方式取决于 Java 堆是否规整,而堆是否规整又取决于垃圾收集器是否带有压缩整理能力。因此,使用 Serial、ParNew 等带压缩整理功能的收集器时,系统采用指针碰撞;使用 CMS 这种基于标记清除算法的收集器时,通常采用空闲列表。G1 这种基于 Region 的收集器则因为整体上并不规整,但 Region 内部是规整的,所以分配策略会更复杂。
这里有个并发安全问题值得注意。对象创建在虚拟机中是极其频繁的行为,即使只修改一个指针所指向的位置,并发情况下也不是线程安全的。虚拟机对这个问题有两种解决方案:一是对分配内存的动作进行同步处理,相当于把 CAS 配上失败重试保证更新操作的原子性;二是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块私有内存,称为本地线程分配缓冲(TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并需要重新分配新的 TLAB 时,才需要同步锁定。通过-XX:+UseTLAB设置,这个机制在 HotSpot 中默认是开启的。
实际调优的时候,如果发现年轻代 GC 频繁并且单次 GC 后存活对象很少,可以考虑适当增大 TLAB 大小,减少线程抢占堆内存分配锁的几率。虽然 TLAB 空间较小且位于 Eden 区,但它确实是减少分配竞争的重要手段。
3.2 对象的内存布局:对象头、实例数据和对齐填充
对象在堆中的存储布局可以分成三个部分:对象头、实例数据和对齐填充。
对象头包含两类信息。第一类是对象自身的运行时数据,比如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等,这部分官方称它为 Mark Word。需要注意的是,这些信息都不是固定的,它们会随着锁状态、GC 状态等变化重用存储空间,也就是说 32 位或 64 位空间会被反复利用。第二类是类型指针,即对象指向它的类型元数据的指针,JVM 通过这个指针来确定该对象是哪个类的实例。如果对象是 Java 数组,对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通 Java 对象的元数据确定对象大小,但从数组的元数据上却无法确定数组的大小,所以必须以特殊方式存储长度。
实例数据部分是对象真正存储的有效信息,也就是程序代码中定义的各种类型的字段内容,包括从父类继承下来的字段和子类本身定义的字段。存储顺序受虚拟机的分配策略和字段在源码中的定义顺序影响。HotSpot 默认的分配策略是 longs/doubles、ints、shorts/chars、bytes/booleans、oops,相同宽度的字段总是被分配到一起,这个策略是为了让字段在内存中尽量紧凑,减少填充浪费。
对齐填充不是必然存在的,它没有特别的含义,仅仅起着占位符的作用。HotSpot 要求对象起始地址必须是 8 字节的整数倍,所以对象大小必须被 8 字节整除,不够的自然要补齐。这也是"64 位机器上对象实际占用内存往往比字段之和要大"的根本原因。
3.3 对象访问定位:句柄还是直接指针
创建对象只是开始,Java 程序需要通过栈上的引用数据来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位和访问对象,所以访问方式取决于虚拟机实现。
主流访问方式有句柄和直接指针两种。如果使用句柄访问,Java 堆中会划分出一块内存作为句柄池,reference 中存储的是对象的句柄地址,句柄中包含对象实例数据与类型数据各自的具体地址信息。如果使用直接指针访问,reference 中存储的直接就是对象地址,访问对象时不需要多一次间接定位的开销。
HotSpot 主要使用直接指针方式进行对象访问。从整个开发链路的收益来讲,用一句"世界观不同"概括:句柄方式在对象移动(GC 时常见)时只需要修改句柄中的实例数据指针,reference 本身不需要修改;直接指针方式速度更快,节省一次指针定位的开销。HotSpot 选择直接指针,显然更看重访问速度。
3.4 对象存活判断与回收:谁说引用计数简单可靠
对象在堆上活到什么时候算钩?垃圾收集器判断对象是否存活,第一反应往往是引用计数算法:给对象添加引用计数器,每有一个地方引用它,计数器加一;引用失效,计数器减一;计数器为零,对象可回收。这个算法简单高效,但它有一个致命缺陷,很难解决对象之间相互循环引用的问题。假设两个对象互相引用,且已经没有外部引用指向它们,它们的计数器永远不为零,永远不会被回收。
因此主流虚拟机使用的是可达性分析算法。基本思路是从一系列称为 GC Roots 的根对象出发,通过引用链向下搜索,搜索走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,证明此对象是不可用的、可回收的。
可以作为 GC Roots 的对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI 引用的对象等等。注意,这里提到的"方法区中引用的对象"在 JDK 8 后对应的就是元空间中静态属性指向的对象和常量池中的引用。
结合上面的结构,可以做一个不太严谨但很有帮助的类比:把 GC Roots 想象成仓库的货物清单,把堆里的对象想象成仓库里的货物。每次清扫垃圾时,不是逐个检查货物是否过期,而是从清单出发,沿着货物之间的挂载关系全部走一遍,凡是走不到的就是垃圾。这个过程的开销与堆大小和引用关系复杂度相关,所以不同收集器在具体实现上做了大量优化。
4. 垃圾收集器如何反过来塑造 JVM 的内存模型
不少初学者以为"JVM 内存模型"是一成不变的地图,实际上不同垃圾收集器会在逻辑上重新编排内存区域的组织方式,尤其是堆内部。理解这一点,很多调优参数和性能瓶颈就都能说得通了。
4.1 分代收集理论背后的两个假设
当前商业虚拟机的垃圾收集,普遍遵循分代收集理论。它建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象,就越难以消亡。
这两个假说决定了 JVM 堆为什么会被划分为新生代和老年代。新生代里对象存活率低,适合用复制算法,每次回收只需复制少量存活对象,代价可控;老年代里对象存活率高,适合用标记-清除或标记-整理算法,避免反复复制。
分代之后,带来一个新的问题:新生代对象可能被老年代对象引用,导致新生代 GC 时不能只扫描新生代对象来确定存活关系。为了省去扫描整个老年代的开销,虚拟机引入了记忆集(Remembered Set)的概念,它用于记录非收集区域指向收集区域的跨代引用。G1 中的记忆集设计尤其精细,因为它的内存布局不再是物理上的连续分代,每个 Region 之间都可能存在引用关系。
4.2 三种基础收集算法的适用边界
标记-清除算法先标记出所有需要回收的对象,标记完成后统一回收掉被标记的对象。它有两个不足:一是执行效率不稳定,如果堆中有大量对象需要回收,标记和清除两个动作都会产生大开销;二是内存空间碎片化问题,清理出的内存不连续,后续分配大对象时可能找不到连续空间而被迫提前触发一次 GC。
标记-复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块用完了,把还存活的对象复制到另一块上,再把已使用过的内存空间一次性清理掉。实现简单、运行高效,但空间利用率只有一半。新生代回收之所以用它,是因为新生代的存活对象少,复制开销低,浪费一半空间的代价可以被接受。HotSpot 的默认新生代布局并不严格对半,而是 Eden、Survivor 区按 8:1:1 划分,每次新生代回收把 Eden 和一个 Survivor 区中存活的对象复制到另一个 Survivor 区,这样只有约 10% 的空间被闲置浪费。
标记-整理算法与标记-清除的标记过程一致,但后续不是直接清理可回收对象,而是让所有存活对象向内存空间一端移动,然后直接清理掉边界以外的内存。它解决了碎片化问题,但移动对象的代价很高。这种算法适合老年代,因为老年代 GC 频率低,可以接受移动成本。
4.3 从分区模型看收集器演进:G1 与 ZGC 的 Region 化改造
G1 收集器把连续的 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 都可以根据需要扮演 Eden、Survivor 或者老年代。这种逻辑分代、物理不分代的设计,让 G1 能够以可预测的停顿为目标进行回收:它跟踪各个 Region 里的垃圾堆积价值,维护一个优先级列表,每次根据用户允许的收集停顿时间优先回收价值最大的 Region。
ZGC 的 Region 化设计更进一步。它同样将堆划分为 Region,但 Region 大小可以动态变化,并且引入了染色指针和读屏障技术。染色指针把一些标记信息直接编码在指针本身,而不是存在对象头上,这让 ZGC 在标记和转移阶段几乎不需要停顿。从内存模型的视角看,ZGC 实际上改变了对象头里部分信息的存放位置和读取方式,这是非常底层的变化。
G1 和 ZGC 的兴起给了我们一个很重要的启发:JVM 的内存模型是动态演进的。各种收集器在"遵守 JVM 规范逻辑结构"的前提下,会对物理内存的组织和对象访问方式做非常大的改造。你如果只看 JDK 8 时期的内存分区图去理解 JDK 17 的 G1,或者去看 JDK 21 的 ZGC,就会明显感觉截图对不上。
4.4 为什么说"GC Roots 枚举"和"安全点"会影响停顿
聊到这里,有必要把"为什么 GC 一定会停顿用户线程"这个老问题解释清楚。可达性分析从 GC Roots 出发,要求整个分析过程中引用关系不能发生变化,否则分析结果不准确。因此 GC 时必须暂停所有用户线程,也就是 Stop The World。
为了减少安全点检查的开销,HotSpot 不是在任意位置都能进入 GC 状态,而是设置了一些安全点,让线程在执行到安全点时再挂起。安全点的选定不能太少也不能太多,太少会让 GC 等待时间太长,太多会增大运行开销。实际中常见的是让线程在方法调用、循环跳转、异常跳转等位置进入安全点检测。
这个细节直接影响你对"为什么 GC 日志里 STW 时间有波动"的理解。后台线程、低优先级线程如果长时间不进入安全点,GC 就需要等它,停顿时间会异常拉长。排查这类问题,就得结合线程转储看哪个线程卡在哪个调用栈上。
5. 从内存模型的角度复盘线上故障:异常类型、诊断工具与恢复策略
内存模型的最终价值,体现在排查线上问题上。这一节我围绕 JVM 抛出的典型异常、诊断手段和分析思路做一次完整的复盘式梳理。
5.1 各类溢出的触发条件与现场表现
- StackOverflowError:线程请求栈深度超过虚拟机允许深度。常见触发场景是无限递归、深层 JSON 嵌套解析、某些中间件回调链过长。现场表现是某一线程异常退出,其他线程不受影响。这个错误不会导致整个 JVM 挂掉,但如果是核心线程反复触发,服务可用性会明显下降。
- OutOfMemoryError: Java heap space:堆中没有足够内存分配新对象。常见触发场景是对象泄漏(比如缓存无界增长)、大对象过多、堆配置过小。现场表现可能是 GC 频繁但回收量极少,最终某个分配请求直接失败。
- OutOfMemoryError: GC overhead limit exceeded:当 HotSpot 花费超过 98% 的时间执行 GC 而回收不到 2% 的堆内存时抛出。这本质上不是单纯的内存不足,而是暗示系统已经陷入 GC 死循环式的不健康状态。
- OutOfMemoryError: Metaspace:元空间容量不足,常见于动态生成类、热部署后未清理类加载器。
- OutOfMemoryError: Direct buffer memory:堆外内存超限,常见于 NIO 缓冲区未释放。
- OutOfMemoryError: unable to create new native thread:无法创建新的线程。这个一般不是堆问题,而是进程线程数达到操作系统限制或者进程占用线程内存过大。
实践中判断异常时,我会先看异常类型缩小范围,再看 GC 日志和堆转储确认根因。最怕的情况是堆内存看着正常,但进程内存不断上涨,这时要怀疑直接内存、线程数量或者某些 JNI 资源泄漏。
5.2 诊断工具使用:jps、jstat、jcmd、jmap 的正确打开方式
先说最基础的 jps:列出当前机器上运行的 Java 进程,拿到进程号是后续所有操作的前提。这个命令偶尔会因为权限或环境变量问题查不到进程,可以用 ps 替代。
jstat 是观察 GC 行为最直接的工具。jstat -gc <pid> 1000可以每秒输出一次各区域容量、使用量、GC 次数和耗时。它会告诉你新生代、老年代、元空间各自的当前用量和累计 GC 时间。观察一段时间后,如果看见老年代使用率持续攀升且 GC 频率持续增加,基本可以判断存在对象无法被回收或晋升过快。
jcmd 是一个综合能力很强的命令。jcmd <pid> VM.flags可以查看当前生效的 JVM 参数,jcmd <pid> GC.heap_info可以输出堆各代容量和使用量的摘要。它比 jmap 更轻量,日常诊断优先用它。
jmap 适合生成堆转储文件。jmap -dump:format=b,file=heap.bin <pid>会生成二进制堆转储,之后用 MAT 或 VisualVM 分析。注意 jmap 执行时会让 JVM 进入停顿状态,线上执行要谨慎,尽量避免在核心交易时段操作。
这里我要特别强调一个习惯:生产环境一定要预先加上-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=/opt/logs/。一旦 OOM 发生,自动生成堆转储文件,省去现场到处命令的慌乱。很多事故复盘时缺失最重要的堆转储,就是因为这个参数没开。
5.3 一个典型的堆转储分析案例:缓存持有导致老年代胀满
为了说明分析思路,我构造一个完全虚拟但非常常见的场景:某接口服务,平时运行稳定,某次发版后第二天开始 GC 频繁,老年代使用率持续高位,最终 OOM。
处理步骤大致如下:先看 GC 日志,发现 Full GC 前老年代使用率已经接近上限,Full GC 后回收量极低,说明老年代中几乎没有可回收对象。接着看线程栈和堆转储,用 MAT 的 Leak Suspects 功能查看最大的对象持有链,发现大量业务对象被一个全局静态 Map 持有,而 Map 的 key 是某段时间戳,value 是用户会话详情对象。代码逻辑是每次请求都往这个 Map 里写数据,但清理任务只在特定条件下执行,导致数据只增不减。
根因定位后,解决方案通常是:改用带过期时间的本地缓存框架,或给这些对象加容量上限和 LRU 淘汰策略。修复后观察老年代使用率曲线,回落并稳定在安全水位。这个案例里最核心的分析点,是判断"什么对象占用了老年代",以及"谁还在引用这些对象"。
6. 几个容易被记错的细节,以及自测与验证方法
JVM 内存模型相关的知识里,有几个细节争议很大,网上资料也经常互相矛盾。我基于 HotSpot 和实际验证结果,把常见误区整理一下。
6.1 静态变量到底放在哪
从 JVM 规范的概念模型来看,静态变量属于类信息,归方法区管;但从 HotSpot 的实现来看,JDK 7 之后类和静态变量已经转移到 Java 堆中。你可以在很多官方材料里看到"方法区存放类型信息、常量、静态变量"的说法,但结合 HotSpot 的实际布局,更准确的说法是:类的元数据在元空间,静态字段对应的对象引用和基本类型值在堆中。判断依据很简单:如果静态变量引用了一个大对象,这个对象实例一定在堆里,触发 GC 时它会被纳入堆空间统计。
6.2 字符串常量池的历次迁移
字符串常量池存放字符串字面量。JDK 7 之前它在永久代,JDK 7 移到堆中。这个迁移的直接后果是字符串相关的 OOM 信息从 PermGen space 变成了 Java heap space。有人用反射的-XX:StringTableSize调整字符串常量桶的数量,默认值在 JDK 11+ 是 60013,如果业务里有大量字符串 intern 操作,调大这个值可以减少哈希碰撞带来的性能损耗。
6.3 栈帧一定在堆里吗
虚拟机栈的栈帧属于线程私有区域,自然不在堆里。但有一个非常冷门的问题:栈帧里的局部变量表,存放的是引用还是对象本身?答案是引用。对象本体永远在堆中(逃逸分析栈上分配除外)。所以"栈上存对象"这种说法是不准确的,准确的表达是"栈上可以存对象的引用,或者极小对象的字段被拆出来直接放在栈上(标量替换后)"。
6.4 逃逸分析真的能让对象不进入堆吗
HotSpot 默认开启逃逸分析,配合标量替换和栈上分配,可以在确认对象不会被外部访问后,把对象拆散成多个基本类型成员直接在栈上分配。这样可以避免堆分配和 GC 标记的开销。但逃逸分析是 JIT 编译期的优化,不是所有对象、所有场景都能触发。如果一段代码里对象既没逃逸、方法又足够热,才可能真正实现栈上分配。用-XX:+PrintEscapeAnalysis可以观察。
6.5 自测方法:一行命令观察内存分布
如果对当前 JVM 的内部状态感到好奇,最直接的自测办法是写一个极小的 Java 程序,在不同启动参数下运行,再用jcmd和jstat对比输出。比如:
启动一个带-Xms64m -Xmx256m -XX:+UseG1GC的简单程序,循环创建数组但不释放,观察老年代使用率如何变化。把数组改成局部变量,观察是否出现栈上分配相关的 GC 差异。再比如设置-XX:MaxMetaspaceSize=16m后,用 CGLIB 动态生成类,观察何时抛出 Metaspace OOM。这些实验成本很低,但对建立直觉特别有效。
我个人在带人入门 JVM 时,一直建议先做两个实验:第一个是模拟 StackOverflowError,看清楚栈深度和异常栈的对应关系;第二个是故意制造堆 OOM,开启自动堆转储,再亲手打开 MAT 看一次 Leak Suspects。做完这两个实验,再去谈参数调优和 GC 选型,理解深度会完全不同。
最后再分享一个习惯:排查任何 JVM 内存问题,我都先记录三条基线——堆各代当前用量、GC 频率和单次耗时、JVM 参数里与内存相关的设置。没有这三条基线,后面的任何操作都容易变成盲人摸象。JDK 版本不同、收集器不同,内存模型的细微行为就有区别,保持对版本的敏感,比死记任何一张分区图都更加重要。