Files
autumn-recruitment/01.Java/jvm/JVM 内存模型.md
T

9.9 KiB
Raw Blame History

tags, create time, update time
tags create time update time
java/lang
jvm-memory
heap
metaspace
oom
gc-roots
2026-08-08 18:00 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,栈和计数器不会。程序计数器的设计决定了它只需要极小的空间——因为它是线程私有的,切换时只需恢复计数值即可。

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 方法。按照程序员的意愿对对象进行初始化,设置好各个字段的真正值。

整个流程可以用下图概括:

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:

// JVM 参数(不需要写在 Java 代码里,这里是示意配置)
// -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/
// -XX:OnError="jstack %p > /data/logs/jstack.txt"

配合 jmap -dump:format=b,file=heap.bin <pid> 可以手动导出堆快照,然后用 Eclipse MAT 分析对象留存情况。

关联笔记