跳转至

JVM 内存结构与 GC


1. 引入:它解决了什么问题?

问题背景:Java 程序运行在 JVM(Java Virtual Machine)之上,JVM 负责管理程序的内存分配和回收。如果不理解 JVM,遇到以下问题将无从下手:

  • OOM(OutOfMemoryError):程序崩溃,不知道是哪里内存泄漏
  • 频繁 Full GC:系统每隔几分钟停顿几秒,用户体验极差
  • CPU 100%:GC 线程占满 CPU,业务线程无法执行
  • 响应时间抖动:偶发性的长时间停顿(GC Stop-The-World)

JVM 知识解决的核心问题: - 理解内存分区 → 知道对象存在哪里,为什么会 OOM - 理解 GC 算法 → 知道内存如何被回收,为什么会停顿 - 理解 GC 收集器 → 能根据业务场景选择合适的 GC 策略 - 掌握排查工具 → 能定位和解决线上内存问题


2. 类比:用生活模型建立直觉

JVM 内存 = 办公室空间

把 JVM 比作一个办公室

JVM 区域 办公室类比 特征
堆(Heap) 公共办公区(所有人共用) 存放所有对象,GC 主要管理这里
虚拟机栈(Stack) 每个员工的私人工作台 每个线程独有,存放局部变量和方法调用
元空间(MetaSpace) 公司的规章制度手册 存放类的定义信息,不在堆里
程序计数器(PC) 员工的工作进度便签 记录当前执行到哪行代码
直接内存 公司租用的外部仓库 不受 JVM 管理,NIO 使用

GC = 保洁员

  • Minor GC:每天打扫新生代(年轻区),频率高,速度快
  • Full GC:大扫除,整个办公室都清理,耗时长,期间所有人停工(Stop-The-World)

分代收集 = 物品分区管理

  • 新生代:新买的物品,大部分很快就不用了(大多数对象朝生夕死)→ 用复制算法,快速清理
  • 老年代:用了很久的物品,不常清理 → 用标记整理,减少碎片

3. 原理:逐步拆解核心机制

3.1 JVM 内存分区详解

flowchart LR
    subgraph sg1["线程共享 - 所有线程共用"]
        Heap["堆 Heap<br/>新生代: Eden + S0 + S1<br/>老年代: Old Gen<br/>GC 主要管理区域"]
        MetaSpace["元空间 MetaSpace<br/>类信息 / 方法信息<br/>运行时常量池 / 静态变量<br/>JDK8 替代永久代,使用本地内存"]
    end
    subgraph sg2["线程私有 - 每个线程独有"]
        Stack["虚拟机栈<br/>栈帧: 局部变量表 + 操作数栈 + 返回地址<br/>StackOverflowError"]
        NativeStack["本地方法栈<br/>Native 方法调用"]
        PC["程序计数器<br/>当前执行指令地址<br/>唯一不会 OOM 的区域"]
    end
    DirectMem["直接内存<br/>NIO ByteBuffer<br/>不受 JVM 堆管理,受物理内存限制"]

3.2 对象的生命周期(分代收集)

flowchart LR
    A["new 对象"] --> B["分配到 Eden 区"]
    B --> C{"Eden 区满?"}
    C -->|是,触发 Minor GC| D["存活对象移到 Survivor S0<br/>年龄 age = 1"]
    D --> E{"再次 Minor GC"}
    E --> F["S0 存活对象移到 S1<br/>age++"]
    F --> G{"age >= 15?<br/>或 Survivor 空间不足?"}
    G -->|是| H["晋升到老年代 Old Gen"]
    G -->|否| I["继续在 Survivor 间复制"]
    H --> J{"老年代满?"}
    J -->|是,触发 Full GC| K["整堆回收(Stop-The-World)"]

关键数字: - 默认晋升年龄阈值:15(可通过 -XX:MaxTenuringThreshold 调整) - Eden : S0 : S1 = 8 : 1 : 1(默认比例) - 大对象(超过 -XX:PretenureSizeThreshold)直接进入老年代

3.3 GC 算法原理

标记阶段:从 GC Roots(栈中引用、静态变量、JNI 引用)出发,标记所有可达对象。

flowchart TD
    subgraph GC Roots
        R1["虚拟机栈中的引用"]
        R2["静态变量引用"]
        R3["JNI 引用"]
    end

    R1 --> A["对象 A(可达,存活)"]
    R2 --> B["对象 B(可达,存活)"]
    A --> C["对象 C(可达,存活)"]
    D["对象 D(不可达,待回收)"]
    E["对象 E(不可达,待回收)"]

三种回收算法对比

算法 步骤 优点 缺点 适用区域
标记-清除 标记存活对象 → 清除未标记 简单 产生内存碎片 老年代
标记-整理 标记 → 将存活对象移到一端 → 清理边界外 无碎片 移动对象开销大 老年代
复制算法 将存活对象复制到另一半空间 无碎片、速度快 空间利用率 50% 新生代

3.4 G1 vs CMS 收集器

CMS(Concurrent Mark Sweep)

flowchart LR
    C1["初始标记(STW,快)"] --> C2["并发标记(与业务线程并发)"] --> C3["重新标记(STW,快)"] --> C4["并发清除(与业务线程并发)"]

G1(Garbage First)

flowchart LR
    GA["将堆划分为等大的 Region<br/>每个 Region 约 1-32MB"] --> GB["每个 Region 可以是<br/>Eden/Survivor/Old/Humongous"] --> GC["优先回收垃圾最多的 Region<br/>Garbage First 名字由来"] --> GD["可设置最大停顿时间目标<br/>-XX:MaxGCPauseMillis=200"]
对比项 CMS G1
设计目标 最短停顿时间 可预测的停顿时间
内存碎片 有(标记-清除) 无(标记-整理)
适用堆大小 中小堆(< 6GB) 大堆(> 6GB)
JDK 版本 JDK 9 废弃 JDK 9+ 默认
缺点 并发模式失败时 Full GC 停顿极长 内存占用较高

3.5 OOM 排查流程

flowchart TD
    A["发现 OOM 或内存持续增长"] --> B{"OOM 类型?"}

    B -->|"Java heap space"| C["堆内存溢出"]
    B -->|"Metaspace"| D["元空间溢出"]
    B -->|"StackOverflowError"| E["栈溢出"]
    B -->|"Direct buffer memory"| F["直接内存溢出"]

    C --> C1["1. 开启 HeapDump<br/>-XX:+HeapDumpOnOutOfMemoryError"]
    C1 --> C2["2. jmap -dump:format=b,file=heap.hprof <pid>"]
    C2 --> C3["3. MAT 分析 Dominator Tree<br/>找到持有大量内存的对象"]
    C3 --> C4["4. 定位代码中的内存泄漏根因"]

    D --> D1["类加载过多<br/>检查动态代理/反射/热部署"]

    E --> E1["递归调用未设终止条件<br/>或方法调用链过深"]

    F --> F1["NIO 直接内存未释放<br/>检查 ByteBuffer 使用"]

4. 特性:关键对比

各内存区域 OOM 类型

内存区域 异常类型 常见原因
OutOfMemoryError: Java heap space 内存泄漏、对象过多
元空间 OutOfMemoryError: Metaspace 动态生成类过多(如 CGLib)
虚拟机栈 StackOverflowError 递归过深、方法调用链过长
直接内存 OutOfMemoryError: Direct buffer memory NIO 直接内存未释放
程序计数器 不会 OOM 唯一不会 OOM 的区域

常用 JVM 参数速查

参数 含义 示例
-Xms 初始堆大小 -Xms2g
-Xmx 最大堆大小 -Xmx4g
-Xss 每个线程栈大小 -Xss512k
-XX:MetaspaceSize 元空间初始大小 -XX:MetaspaceSize=256m
-XX:+UseG1GC 使用 G1 收集器
-XX:MaxGCPauseMillis G1 最大停顿时间目标 -XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError OOM 时自动导出堆快照

5. 边界:异常情况与常见误区

❌ 误区1:堆内存设置越大越好

堆内存越大,单次 Full GC 的停顿时间越长。对于延迟敏感的服务,应该: - 使用 G1 并设置 -XX:MaxGCPauseMillis 控制停顿时间 - 或使用 ZGC/Shenandoah(JDK 11+)实现毫秒级停顿

❌ 误区2:System.gc() 能立即触发 GC

System.gc() 只是建议 JVM 进行 GC,JVM 可以忽略。生产环境应禁用:-XX:+DisableExplicitGC

❌ 误区3:对象一定在堆上分配

JDK 6+ 引入了逃逸分析:如果对象不会逃逸出方法(不被外部引用),JIT 编译器可能将其分配在栈上,方法结束时自动回收,无需 GC。

边界:永久代 vs 元空间

JDK 7 及之前:方法区实现为永久代(PermGen),在堆内,大小固定,容易 OOM。 JDK 8+:改为元空间(MetaSpace),使用本地内存,大小默认不限制(受物理内存限制),但仍需设置上限防止无限增长。


6. 设计原因:为什么这样设计?

为什么要分代收集?

基于"弱分代假说":大多数对象朝生夕死(如方法内的临时对象),只有少数对象长期存活。

如果不分代,每次 GC 都要扫描全堆,代价极高。分代后: - 新生代 GC(Minor GC)只扫描新生代(约占堆的 1/3),速度快,频率高 - 老年代 GC(Major GC)只在老年代满时触发,频率低

这样大多数短命对象在 Minor GC 中就被回收,极大减少了 Full GC 的频率。

为什么 G1 要将堆划分为 Region?

传统收集器(CMS)的老年代是一块连续内存,回收时必须处理整个老年代,停顿时间不可控。G1 将堆划分为多个等大的 Region,每次只选择垃圾最多的 Region 进行回收(Garbage First),可以在有限时间内回收最多的垃圾,实现可预测的停顿时间

为什么 JDK 8 要用元空间替换永久代?

永久代大小固定(-XX:MaxPermSize),在大量使用动态代理、热部署的场景下容易 OOM。元空间使用本地内存,理论上只受物理内存限制,更灵活。同时,Oracle 收购 Sun 后需要将 HotSpot 与 JRockit 合并,JRockit 没有永久代,这也是推动改变的原因之一。


7. 总结:面试标准化表达

面试问:JVM 内存分区有哪些?

标准答法

JVM 内存分为线程共享线程私有两大类:

线程共享的有:(存放对象实例,分新生代和老年代,是 GC 的主要区域)和元空间(JDK 8 替代永久代,存放类信息、方法信息,使用本地内存)。

线程私有的有:虚拟机栈(每次方法调用创建一个栈帧,存放局部变量和操作数栈)、本地方法栈(Native 方法)、程序计数器(记录当前执行指令,唯一不会 OOM 的区域)。

此外还有直接内存,NIO 的 ByteBuffer 使用,不受 JVM 堆管理。

面试问:G1 和 CMS 的区别?

标准答法

CMS 是以最短停顿时间为目标的收集器,采用标记-清除算法,会产生内存碎片,适合中小堆。并发标记阶段与业务线程并发执行,但如果并发模式失败(老年代满了还没回收完),会退化为 Full GC,停顿时间极长。JDK 9 已废弃。

G1 是 JDK 9+ 的默认收集器,将堆划分为多个等大的 Region,优先回收垃圾最多的 Region,可以通过 -XX:MaxGCPauseMillis 设置停顿时间目标,实现可预测的停顿时间。采用标记-整理算法,无内存碎片,适合大堆(> 6GB)。

面试问:如何排查 OOM 问题?

标准答法

首先看 OOM 的类型:Java heap space 是堆溢出,Metaspace 是元空间溢出,StackOverflowError 是栈溢出。

对于堆溢出,排查步骤是: 1. 开启 -XX:+HeapDumpOnOutOfMemoryError 让 JVM 在 OOM 时自动导出堆快照 2. 用 jmap -dump 手动导出,或用 jmap -histo:live 快速查看存活对象分布 3. 用 MAT(Memory Analyzer Tool)分析堆快照,查看 Dominator Tree,找到持有大量内存的对象 4. 结合代码定位内存泄漏根因(常见:缓存未设上限、静态集合持有对象引用、ThreadLocal 未 remove)