题目

请介绍 JVM 的内存模型、垃圾回收机制,以及如何进行 JVM 性能调优。

参考答案

前言

JVM 这道题,堪称面试中的"瑞士军刀"——无论你面的是初级开发还是高级架构,它都有可能出现,只是追问的深度不同。

很多同学背了一堆参数和概念,被面试官追问一句"你线上调过 JVM 吗?"就瞬间破功。这篇文章的目标是:带你从 JVM 内存布局出发,一路讲到 GC 调优实战,让你的回答既有理论深度,又有落地经验。

放心,我不会让你背 50 个 JVM 参数。我们只抓重点,用最轻松的方式搞定最硬核的知识。


一、先看全景——你的 Java 程序跑在怎样的"地基"上?

在聊细节之前,你得先有一个大局观。JVM 启动后会从操作系统那里申请一大块内存,然后把这块内存划分为不同的"功能区",各司其职。

用一句话记住这个布局:

堆和元空间是"公共广场",所有人都能逛;栈、程序计数器、本地方法栈是"私人房间",每人一间。


二、拆开看——每个区域到底装了什么?

2.1 程序计数器

它是啥:一块小小的内存,记录当前线程正在执行的字节码行号。

人话版:就像你看书用的书签。CPU 切来切去,每次切回来都得知道"上次读到哪了"。每个线程有自己独立的书签,互不干扰。

面试要记住的点:它是 JVM 规范中唯一一个不会抛出 OutOfMemoryError 的区域。


2.2 Java 虚拟机栈

它是啥:每个方法执行时,JVM 都会创建一个栈帧压入虚拟机栈。方法执行完,栈帧出栈。栈帧里装着局部变量表、操作数栈、方法返回值等。

人话版:栈就是你的办公桌。每调用一个方法,就在桌上叠一层文件(栈帧)。方法执行完,把最上面那层文件收走。桌上堆太高(递归太深),桌子塌了——这就是 StackOverflowError。

// 经典递归翻车现场:没有正确的终止条件,栈帧无限叠加
public static void recursiveCall() {
    recursiveCall();  // 每次调用压一个栈帧,最终 StackOverflowError
}
面试要记住的点
  • 栈深度超过 JVM 允许的最大值 → StackOverflowError
  • 栈扩容时无法申请到足够内存 → OutOfMemoryError

2.3 本地方法栈

跟虚拟机栈功能一样,只不过它服务的是 native 方法(用 C/C++ 写的本地方法)。了解即可,面试中很少单独问。


2.4 堆

它是啥:存放所有对象实例和数组的地方。是 GC 垃圾回收的主战场,也是 JVM 中最大的一块内存。

人话版:堆就像公司的仓库。new 出来的所有对象都丢进去。仓库管理不善就会又乱又满,GC 就是那个定期来清理仓库的管理员。

堆的内部还有更细的划分,这是理解 GC 的关键:

为什么叫"伊甸园"?因为绝大部分对象刚 new 出来就"夭折"了(朝生夕死),在 Eden 区活不过一次 GC。


2.5 方法区 / 元空间

它是啥:存储类的元数据(类名、方法名、字段描述符)、静态变量、常量池、JIT 编译后的代码。

重要变迁:JDK 7 及之前叫"永久代"(PermGen),在堆里;JDK 8 及之后改成了元空间(Metaspace),使用系统本地内存,不再受堆大小限制。

// 元空间 OOM 的经典场景:动态生成大量类
// 比如 CGLIB 动态代理、大量 JSP 编译成 Servlet
// 解决:设置 -XX:MaxMetaspaceSize=256m

面试考点:JDK 8 为什么把永久代干掉,换成元空间?

  • 永久代大小难控制(太小容易 OOM,太大浪费堆空间)
  • 永久代的内存回收效率低
  • 元空间使用本地内存,只受系统内存限制,更加灵活

三、垃圾回收(GC)——JVM 面试的灵魂考题

3.1 怎么判断对象"死了"?

GC 不是随便删东西的,它得先搞清楚谁还活着、谁已经死了

方法一:引用计数法(Java 不用这个)

给每个对象加个计数器,有人引用就 +1,引用失效就 -1。归零就回收。

听起来很美好,但它有个致命缺陷——循环引用

class Node {
    Node next;
}

Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;  // A 和 B 互相引用
a = null;
b = null;    // 俩对象计数器永远 = 1,永远回收不掉!

方法二:可达性分析(Java 用的就是这个)

从一组叫 GC Roots 的根对象出发,顺着引用链一路找下去。能找到的就是活的,找不到的就是死的。

        GC Roots (栈中的引用、静态变量等)
             │
      ┌──────┼──────┐
      ▼      ▼      ▼
    Object1 Object2 Object3
      │               │
      ▼               ▼
    Object4         Object5  ← 从 GC Roots 到不了它
      │
      ▼
    Object6   ← 能从 GC Roots 到达,它是活的!

哪些是 GC Roots?

  • 虚拟机栈中引用的对象(局部变量)
  • 静态属性引用的对象
  • 常量池引用的对象
  • JNI 引用的对象
  • 被 synchronized 持有的对象

3.2 GC 分代回收——一场精心策划的"人口清理"

HotSpot JVM 将堆分为新生代和老年代,用不同的策略来回收,这就是分代回收理论

弱分代假说:大部分对象都是朝生夕死的。 强分代假说:活得越久的对象,越不容易死。

基于这两个假设,JVM 做了这样的设计:

区域 对象特征 GC 名称 触发时机 回收特点
新生代 朝生夕死 Minor GC / Young GC Eden 区满了 频繁、速度快
老年代 活得久 Major GC / Old GC 老年代空间不足 比 Minor GC 慢很多
整个堆 混合 Full GC 老年代/元空间满、System.gc() 最慢,STW(Stop The World)时间长
Minor GC 的完整过程(重点!)

这是面试中一定要能讲清楚的过程:

第 1 步:新对象诞生在 Eden 区
        ┌─────────────┐
        │   Eden区    │ ← 新对象都往这里放
        ├──────┬──────┤
        │ S0区 │ S1区 │ ← 两个 Survivor 区,同一时间只有一个在用
        └──────┴──────┘

第 2 步:Eden 区满了,触发 Minor GC
        - 标记 Eden 和当前 Survivor 中存活的对象
        - 把活着的对象复制到另一个空的 Survivor 区
        - 年龄 +1
        - 清空 Eden 和刚腾出来的 Survivor 区

第 3 步:对象在两个 Survivor 之间来回复制
        - 每"熬过"一次 GC,年龄 +1
        - 年龄超过阈值(默认 15,参数 -XX:MaxTenuringThreshold),
          晋升到老年代
        
第 4 步:特殊情况
        - 活下来的对象太多,Survivor 区放不下 → 直接进老年代(空间担保)
        - 超大对象(比如大数组)→ 直接进老年代(参数 -XX:PretenureSizeThreshold)

这个过程就像搬家游戏:新员工(对象)进公司,试用期(Eden 区)没过就走了,过了一轮考核的搬到"骨干区"(Survivor),考核多次都挺过来的晋升为"元老"(老年代)。


3.3 四种引用类型——软硬皆施,各有用处

Java 提供了四种引用,从强到弱:

// 1. 强引用(Strong Reference)——"打死我也不放手"
Object obj = new Object();  // 只要 obj 还指向它,GC 绝不回收

// 2. 软引用(Soft Reference)——"内存不够了可以牺牲"
SoftReference<Object> softRef = new SoftReference<>(new Object());
// 内存充足时不会被回收,内存不足时会被回收
// 典型用途:缓存、图片加载

// 3. 弱引用(Weak Reference)——"下次 GC 我就没了"
WeakReference<Object> weakRef = new WeakReference<>(new Object());
// 不管内存足不足,下一次 GC 必回收
// 典型用途:WeakHashMap、ThreadLocal 中的 key

// 4. 虚引用(Phantom Reference)——"我就是个通知而已"
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);
// 无法通过虚引用获取对象,唯一用途是对象被回收时收到一个系统通知
// 典型用途:管理直接内存的回收

面试关键词

引用类型 回收条件 典型使用场景
强引用 绝不回收 普通的 new 对象
软引用 内存不足时才回收 本地图片缓存
弱引用 下次 GC 必回收 WeakHashMapThreadLocal
虚引用 跟没有一样,只是通知 直接内存(NIO DirectByteBuffer)回收追踪

四、垃圾回收器——JVM 的"清洁工团队"

JVM 提供了多种 GC 收集器,各有各的适用场景。下图帮助记忆核心收集器:

新生代收集器                  老年代收集器
┌──────────┐                ┌──────────────┐
│  Serial  │                │  Serial Old  │  ← 单线程,Client 模式默认
├──────────┤                ├──────────────┤
│ ParNew   │                │    CMS       │  ← 经典搭配:ParNew + CMS
├──────────┤                ├──────────────┤
│Parallel  │                │Parallel Old  │  ← 吞吐量优先经典搭配
│Scavenge  │                │              │
└──────────┘                └──────────────┘
      ↑                           ↑
      └───────────┬───────────────┘
                  │
              G1 (Garbage First)   ← JDK 9+ 默认
                  │
              ZGC / Shenandoah     ← 超低延迟,JDK 11+
4.1 各收集器一句话速记
收集器 一句话总结
Serial 单线程干活,干的时候所有人停下(STW)。就像小便利店,只有一个收银员,顾客少时够用
ParNew Serial 的多线程版本。本质上还是"全停",但清理时多个线程同时干活
Parallel Scavenge 关注吞吐量(干活时间 / 总时间)。追求单位时间内完成尽可能多的工作,不在乎单次暂停
CMS 老年代收集器,追求最短停顿时间。"一边打扫一边营业",大部分阶段和应用线程并发执行
G1 放弃连续分代,把堆切成等大的 Region。可预测停顿时间,JDK 9 起的默认收集器
ZGC 超低延迟,暂停时间不超过 10ms,支持 TB 级堆。JDK 11 实验性引入,JDK 15 正式版
4.2 CMS 的"七步成诗"与它的致命缺陷

CMS 是面试最爱问的老年代收集器,因为它设计精巧但问题也不少:

CMS 工作流程(以老年代回收为例):

初始标记        并发标记          重新标记         并发清除
(initial mark) (concurrent mark) (remark)  (concurrent sweep)
    │               │                │              │
    ▼               ▼                ▼              ▼
   STW          与用户线程         STW          与用户线程
  短暂暂停      并发执行         短暂暂停       并发执行

CMS 的三个致命缺陷

  1. 对 CPU 资源敏感:并发阶段会占用 CPU,减少应用吞吐量
  2. 浮动垃圾:并发清除时新产生垃圾只能等下次 GC
  3. 内存碎片:标记-清除算法不整理内存,碎片多了会提前触发 Full GC

你可以这样记 CMS 的最终下场:JDK 14 中 CMS 被正式移除,它的继任者 G1 上位了。

4.3 G1——现在的"当红辣子鸡"

G1 的设计思路跟传统收集器完全不同:

传统收集器:                     G1 收集器:
┌──────────────┐               ┌───┬───┬───┬───┐
│              │               │ E │ S │ O │ H │  ← Region
│   新生代      │               ├───┼───┼───┼───┤
│              │               │ O │ E │ S │ H │
├──────────────┤               ├───┼───┼───┼───┤
│              │               │ H │ E │ O │ S │
│   老年代      │               ├───┼───┼───┼───┤
│              │               │ S │ O │ E │ O │
└──────────────┘               └───┴───┴───┴───┘
  连续的内存块                   整个堆被切分成等大的 Region
                               (E=Eden, S=Survivor, O=Old, H=Humongous)

G1 的核心思想:不追求一次清理整个堆,而是优先回收垃圾最多的 Region(Garbage First 名字的由来),每次只回收一部分 Region,把停顿时间控制在用户设定的目标内(-XX:MaxGCPauseMillis=200)。


五、JVM 性能调优——从"玄学"到"科学"

很多人觉得 JVM 调优是门玄学——同样的参数在生产 A 环境好好的,到 B 环境就崩了。其实调优是有套路的,核心就是六个字:监控 → 分析 → 调整

5.1 第一步:明确你的目标

调优前先问自己三个问题:

  • 我要的是吞吐量还是低延迟?(这两个通常是矛盾的)
  • 内存吃紧吗?(堆太小频繁 GC,堆太大 GC 单次时间太长)
  • GC 频率和时间是什么水平?(需要先监控)

5.2 第二步:用对工具看数据

调优最忌讳的就是"猜",一切要以监控数据为准:

工具/命令 用途 一句话说明
jps 查看 Java 进程 PID jps -l 列出所有 Java 进程
jstat 实时监控 GC 情况 jstat -gc <pid> 1000 每秒输出一次 GC 数据
jmap 导出堆快照 jmap -dump:live,file=heap.hprof <pid>
jstack 查看线程堆栈 jstack <pid> 排查死锁、线程挂起
jinfo 查看/修改 JVM 参数 jinfo -flag MaxHeapSize <pid>
jvisualvm 图形化监控工具 JDK 自带(JDK 9+ 需要单独下载)
Arthas 阿里开源诊断工具 在线诊断神器,不需要重启 JVM
MAT 分析堆转储文件 Eclipse Memory Analyzer,分析内存泄漏利器
5.3 第三步:核心参数速查
# ========== 堆内存设置 ==========
-Xms2g                    # 初始堆大小(建议和 -Xmx 设一样,避免内存抖动)
-Xmx2g                    # 最大堆大小
-Xmn1g                    # 新生代大小(或 -XX:NewRatio=2 表示老年代/新生代=2:1)

# ========== 元空间设置 ==========
-XX:MetaspaceSize=128m    # 元空间初始大小
-XX:MaxMetaspaceSize=256m # 元空间最大大小(防止无限制增长)

# ========== GC 选择 ==========
-XX:+UseG1GC              # 使用 G1 收集器(JDK 9+ 默认)
-XX:+UseZGC               # 使用 ZGC(JDK 11+,超低延迟)

# ========== GC 日志 ==========
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20M
# 输出详细 GC 日志到文件,滚动保存 5 个,每个 20M

# ========== OOM 时自动 Dump ==========
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump

# ========== G1 特有参数 ==========
-XX:MaxGCPauseMillis=200  # G1 期望的最大停顿时间(不是硬保证)
-XX:G1HeapRegionSize=4m   # G1 Region 大小(1M~32M,建议设成 2 的幂)
5.4 第四步:常见问题排查

场景一:频繁 Full GC

现象:系统响应越来越慢,日志里 Full GC 一个接一个。

排查思路:

  1. jstat -gc <pid> 1000 确认 GC 频率和耗时
  2. jmap -dump 导出堆快照,用 MAT 分析哪些对象占了最多内存
  3. 常见原因:内存泄漏(静态集合不断增长)、大对象频繁分配、堆太小
// 常见的内存泄漏写法
public class MemoryLeak {
    private static List<Object> list = new ArrayList<>();  // 静态集合!GC 永远不会回收
    
    public void addData(Object data) {
        list.add(data);  // 数据只增不减,迟早 OOM
    }
}

场景二:CPU 飙升 100%

现象:CPU 突然飙到 100%,应用假死。

排查思路:

  1. top -H -p <pid> 找到 CPU 占用最高的线程
  2. printf "%x\n" <tid> 转成十六进制
  3. jstack <pid> | grep <hex_tid> 定位到代码行

场景三:OOM 不同"死法"的含义

异常信息 含义 排查方向
java.lang.OutOfMemoryError: Java heap space 堆内存不足 扩大堆,或用 MAT 分析
java.lang.OutOfMemoryError: Metaspace 元空间不足 类加载过多(动态代理),增大 MaxMetaspaceSize
java.lang.OutOfMemoryError: unable to create new native thread 线程数达到上限 检查 ulimit -u,排查线程泄漏
java.lang.StackOverflowError 栈溢出 递归太深或循环依赖
java.lang.OutOfMemoryError: Direct buffer memory 直接内存不足 NIO 使用不当,DirectByteBuffer 没有正确释放

六、面试高频追问合集

Q1:什么情况下会发生栈内存溢出?

递归没有正确的终止条件、栈深度过大(如深度递归遍历一棵极深的树)。

Q2:方法区 / 元空间什么时候会 OOM?

运行时生成了大量的动态类(CGLIB 动态代理、大量 JSP、动态语言如 Groovy)。这种情况需要限制元空间大小或排查类加载器泄漏。

Q3:什么是 Stop The World?

GC 线程工作时,在某些阶段需要暂停所有用户线程,这个暂停就叫 STW。就像清洁工要打扫会议室,必须让正在开会的人全部出去。暂停时间直接影响用户体验。G1 和 ZGC 的核心卖点就是"STW 时间短"。

Q4:G1 和 CMS 有什么区别?

CMS 是老年代收集器,需要配合 ParNew 或 Serial 处理新生代。G1 是全堆收集器,把堆切成 Region,可预测停顿时间,还自带内存整理(不像 CMS 会产生大量碎片)。CMS 已经在 JDK 14 被移除了。

Q5:遇到过 JVM 调优的实际案例吗?

这是面试官最想听到的"实战故事"。建议准备一个真实的调优案例,比如:某接口 RT(响应时间)突然从 50ms 飙升到 2000ms → jstat 发现频繁 Full GC → jmap dump 堆发现某个缓存 Map 无限增长 → 加 LRU 淘汰策略 + 限制大小 → 搞定。这个模板几乎适用于 80% 的面试场景。


七、一句话带走

JVM 内存分五块:堆、栈、方法区、程序计数器、本地方法栈。堆是主战场,分新生代和老年代。GC 用可达性分析判生死,Minor GC 清新生代,Full GC 清全堆。调优三板斧:堆大小、GC 选择、内存泄漏排查。核心原则是——先监控后调参,别猜!


面试回答模板

如果你在面试中被问到"请介绍 JVM 与性能调优",可以参考下面的口语版回答:

JVM 内存主要分为五大区域:堆、虚拟机栈、方法区、程序计数器和本地方法栈。其中堆和方法区是线程共享的,栈、程序计数器、本地方法栈是线程私有的。堆里又分为新生代和老年代,新生代又细分为 Eden 区和两个 Survivor 区。

垃圾回收方面,JVM 通过可达性分析来判断对象是否存活,从 GC Roots 出发,能被引用链到达的对象就是活的,否则就是垃圾。GC 分为 Minor GC、Major GC 和 Full GC。Minor GC 发生在新生代,非常频繁但速度快;Full GC 会覆盖整个堆,会触发 Stop The World,对性能影响最大。常见的垃圾回收器有 Serial、Parallel、CMS、G1 和 ZGC,其中 G1 是现在的主流选择,它把堆分成多个大小相等的 Region,可以设定期望的最大停顿时间。

在性能调优方面,我通常会遵循"监控、分析、调整"三步走。先用 jstat、jstack、jmap 这些工具观察 GC 频率和内存分布,必要时导出堆快照用 MAT 分析。常见的问题比如频繁 Full GC,大概率是堆设置太小或者存在内存泄漏。调优的核心参数包括堆大小(-Xms、-Xmx)、GC 选择(-XX:+UseG1GC)、GC 日志输出等。有一点很关键:一定是在有监控数据的前提下精准调整,而不是凭感觉盲目改参数。另外还有个经验,线上启动时最好加上 -XX:+HeapDumpOnOutOfMemoryError,这样一旦 OOM 能自动保留事故现场,方便事后分析。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐