面试题库:JVM 内存模型、垃圾回收机制与性能调优
题目
请介绍 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 必回收 | WeakHashMap、ThreadLocal |
| 虚引用 | 跟没有一样,只是通知 | 直接内存(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 的三个致命缺陷:
- 对 CPU 资源敏感:并发阶段会占用 CPU,减少应用吞吐量
- 浮动垃圾:并发清除时新产生垃圾只能等下次 GC
- 内存碎片:标记-清除算法不整理内存,碎片多了会提前触发 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 一个接一个。
排查思路:
jstat -gc <pid> 1000确认 GC 频率和耗时jmap -dump导出堆快照,用 MAT 分析哪些对象占了最多内存- 常见原因:内存泄漏(静态集合不断增长)、大对象频繁分配、堆太小
// 常见的内存泄漏写法
public class MemoryLeak {
private static List<Object> list = new ArrayList<>(); // 静态集合!GC 永远不会回收
public void addData(Object data) {
list.add(data); // 数据只增不减,迟早 OOM
}
}
场景二:CPU 飙升 100%
现象:CPU 突然飙到 100%,应用假死。
排查思路:
top -H -p <pid>找到 CPU 占用最高的线程printf "%x\n" <tid>转成十六进制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 能自动保留事故现场,方便事后分析。
更多推荐


所有评论(0)