--- tags: [java/lang, jvm-memory, heap, metaspace, oom, gc-roots] create time: 2026-08-08 18:00 update time: 2026-08-08 18:00 --- # JVM 内存模型 ## 概述 JVM 内存模型定义了程序运行时的数据布局——对象住在哪、方法代码存在哪、线程的局部变量放在哪。理解这块是调试 OOM、排查 GC 异常、调优堆大小的前提。本文将从运行时数据区的划分讲起,覆盖对象创建的生命周期和常见 OOM 类型的触发条件。 ## 运行时数据区 JVM 将内存划分为多个逻辑区域,各自有明确的生命周期和用途。可以按"是否线程私有"分成两大阵营。 ### 线程共享区域 **堆(Heap)**:所有线程共享,是 JVM 中最大的一块内存,存放所有对象实例和数组。堆又被进一步细分为新生代(Young Gen)和老年代(Old Gen),新生代再分为 Eden 区和两个 Survivor 区(From / To)。这是垃圾收集的主要舞台。 > [!NOTE] > 堆大小由 `-Xms`(初始堆)和 `-Xmx`(最大堆)控制,通常建议两者设为同一值,避免运行时动态扩缩带来的性能开销。 **方法区(Method Area)**:存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存。JDK 7 时期方法区还在永久代(PermGen)中实现;JDK 8 之后彻底移除了永久代,用**元空间(Metaspace)**代替,元空间使用本地内存(Direct Memory),通过 `-XX:MaxMetaspaceSize` 限制上限。 > [!NOTE] > 为什么要把元空间搬到本地内存?永久代大小固定且容易撑爆(尤其是动态代理场景下 Class 数量爆炸时),换成元空间后理论上只受限于本机内存总量,大幅降低了 OOM 的概率。 ### Java 虚拟机栈(JVM Stack) 每个线程创建时都会创建一个虚拟机栈,描述 Java 方法的调用过程。每个方法被执行时都会创建一个栈帧,存储在栈中,包含局部变量表、操作数栈、动态链接、方法出口等信息。**栈帧随着方法调用进入而压栈,返回时弹栈**。栈溢出会抛出 `StackOverflowError`。 **本地方法栈(Native Method Stack)**:与虚拟机栈功能类似,但服务的是 Native 方法(通常是用 C/C++ 编写的 JNI 方法)。HotSpot 直接将本地方法栈和虚拟机栈合二为一。 **程序计数器(Program Counter Register)**:一块很小的内存空间,记录当前线程执行的字节码行号。如果执行的是 Native 方法,计数器值为空。它是唯一不会发生 OutOfMemoryError 的区域。 ## GC Roots 判定标准 GC Roots Tracing 算法通过可达性分析判断对象是否存活——从一组根节点出发,沿着引用链搜索,被搜到的标记为存活,搜不到的标记为死亡。以下是 JVM 规范中定义的 GC Roots 来源: | 来源 | 说明 | |------|------| | 虚拟机栈中引用的对象 | 各线程栈帧中局部变量表里引用的对象实例 | | 方法区类静态属性引用的对象 | `static` 字段所指向的对象 | | 方法区常量引用的对象 | `final static` 常量关联的引用 | | 本地方法栈 JNI 引用的对象 | Native 方法通过 JNI 传入的句柄 | > [!NOTE] > 一个对象的死亡通常需要两次标记:第一次确认没有 GC Roots 路径可达;第二次才是真正回收。如果这个对象重写了 `finalize()` 方法且尚未执行过,虚拟机会在第二次标记时帮它触发一次——不过 finalize() 在 Java 9+ 已被标记为废弃。 > [!TIP] > 面试常考点:这 5 个区域中,只有堆和方法区可能 OOM,栈和计数器不会。程序计数器的设计决定了它只需要极小的空间——因为它是线程私有的,切换时只需恢复计数值即可。 ```mermaid graph TD subgraph 线程共享["线程共享区域"] H["堆 Heap\n对象实例/数组"] MA["方法区/元空间\n类信息/常量/静态变量"] end subgraph 线程私有["线程私有区域"] JS["Java 虚拟机栈\n栈帧/局部变量/操作数栈"] NMS["本地方法栈\nNative 方法"] PCR["程序计数器\n字节码行号"] end JS -->|访问| H MA -->|引用| H ``` ## 对象创建过程 在堆中分配一个对象并非简单的 `malloc`,而是经历了完整的一系列检查与初始化步骤。 **第一步:类加载检查**。当 JVM 遇到 `new` 指令时,首先检查参数能否在常量池中定位到这个类的符号引用,并检查该符号引用代表的类是否已被加载、解析和初始化。如果未加载,则执行对应的类加载流程。 **第二步:分配内存**。类检查通过后,就在堆中划出一块确定大小的空间。内存分配有两种主流方式: - **指针碰撞(Bump the Pointer)**:堆内存规整时使用,空闲空间和已占用空间各占一端,分配时把指针向空闲方向移动对象大小的距离。这种方式效率高,前提是堆必须规整。 - **空闲列表(Free List)**:堆非规整时(如采用标记-清除算法的收集器),维护一个列表记录哪些内存块可用,分配时从中选择一块足够大的空间。 > [!WARNING] > 热点问题的背后往往是一个取舍:G1 和 ZGC 等现代收集器为了做到堆规整,选择在回收阶段做整理,代价是 STW 时间或额外的屏障开销。 **第三步:初始化零值**。分配到的内存必须清零(置为 0 值),这一步确保了对象的字段在 Java 代码中不用一开始就赋值也能有默认值(int 为 0、reference 为 null 等)。 **第四步:设置对象头**。HotSpot 虚拟机会在对象头上设置一些自身运行时的数据,包括: - HashCode(延迟计算) - 分代年龄(达到阈值后晋升老年代) - 锁状态标志位 - 指向锁对象监控器的指针 - 偏向锁的 ThreadID - 指向栈中 VMEntryFrame 的指针 **第五步:执行 `init` 方法**。按照程序员的意愿对对象进行初始化,设置好各个字段的真正值。 整个流程可以用下图概括: ```mermaid flowchart LR A["new 指令"] --> B["类加载检查\n常量池定位+加载"] B --> C["分配内存\n指针碰撞 / 空闲列表"] C --> D["零值初始化\nmemset 到 0"] D --> E["设置对象头\nHash/分代年龄/锁标志"] E --> F["执行 init\n字段赋初值"] F --> G["对象可被访问"] ``` ## OOM 常见类型 `OutOfMemoryError` 是一个 Error 而非 Exception,表示 JVM 已经无法继续分配内存。它有几个不同的子类,各自对应不同的内存区域和问题场景。 ### `java.lang.OutOfMemoryError: Java heap space` **最常见**的 OOM。通常是对象存活数量过多、生命周期过长,或者存在内存泄漏(比如集合类持续 add 却不 remove)。也可能仅仅是因为堆设置得太小。 排查思路:用 MAT 或 JProfiler 导出堆快照(heap dump),分析 Dominator Tree 找到持有大量引用的大对象。 ### `java.lang.OutOfMemoryError: Metaspace` JDK 8 之后出现。元空间耗尽说明加载的 Class 太多,常见于: - 大量动态生成了 Class(如 MyBatis 扫描了超大包路径、频繁使用 CGLIB 动态代理) - 使用了过多的 OSGi 模块或者热部署框架(如 Spring Boot DevTools) 调优参数:`-XX:MaxMetaspaceSize` 设大一点,或者从根本上减少 Class 数量。 ### `java.lang.StackOverflowError` 严格来说这不是一个 OutOfMemoryError,而是栈深度超限。通常由无限递归或递归过深触发——每个方法调用会压入一个栈帧,超出 `-Xss` 设定的单线程栈大小时抛出此错误。排查思路:审查递归逻辑是否有正确的退出条件,或适当增大 `-Xss`。 ### `java.lang.OutOfMemoryError: unable to create new native thread` JVM 尝试创建新的 OS 线程失败。通常不是 JVM 内存不够,而是操作系统级别的线程数限制触顶(Linux 的 `ulimit -u`、Windows 的用户会话极限)。每个 Java 线程最终映射为一个原生线程,消耗约 1MB 的栈内存(由 `-Xss` 决定)。 解决方案:减少并发线程数、增大 `-Xss`(但会增加单个线程的内存消耗)、或者提升系统的线程数上限。 ### `java.lang.OutOfMemoryError: GC overhead limit exceeded` 当 GC 花费超过 98% 的时间却只回收了不到 2% 的堆内存时触发。本质上是一种自我保护机制——JVM 发现自己在做无效回收,干脆抛错而不是无限循环。 通常意味着堆仍然有少量空间,但这些空间被一群几乎不会死掉的对象占据着。加大堆容量或修复内存泄漏是根本办法。也可以通过 `-XX:-UseGCOverheadLimit` 关闭这个检查,但这只是掩耳盗铃。 ### `java.lang.OutOfMemoryError: Direct buffer memory` NIO 的 DirectByteBuffer 走的是堆外内存(通过 `Unsafe.allocateMemory` 分配),不受 `-Xmx` 控制。常见的触发场景是使用 Netty、gRPC 等大流量网络框架时直接 Buffer 分配过快。 调参:`-XX:MaxDirectMemorySize` 控制上限。 ## 实践场景 ### 秋招面试高频问题 | 问题 | 回答要点 | |------|---------| | JDK 7 和 JDK 8 在方法区上的区别? | 7 用永久代、8 用元空间;永久代在堆内、元空间在本机内存 | | 如何判断一段内存属于哪个区域? | 对象实例在堆、类信息在元空间、局部变量在栈帧、计数器只存一行号 | | 给一个 OOM 的排查流程 | GC Log → Dump 堆快照 → MAT 打开 → Dominator Tree → 找最大对象链 | ### 实战技巧 线上出现 OOM 时,可以通过 JVM 启动参数自动 dump: ```java // JVM 参数(不需要写在 Java 代码里,这里是示意配置) // -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/ // -XX:OnError="jstack %p > /data/logs/jstack.txt" ``` 配合 `jmap -dump:format=b,file=heap.bin ` 可以手动导出堆快照,然后用 Eclipse MAT 分析对象留存情况。 ## 关联笔记 - [[垃圾回收算法与收集器]]