JVM 面试八股文30条(深度详解版)
📖 学习指南
🎯 学习目标:通过本文,你将系统掌握 JVM 的核心知识,能够自信地应对任何相关面试问题。
适合人群
- 🔰 初学者:想系统学习 JVM 的开发者
- 🚀 有经验者:想深入理解 JVM 原理的高级开发者
- 💼 面试准备者:想刷 JVM 面试题的求职者
学习建议
- 先理解概念,再深入原理:先搞懂”是什么”,再搞懂”为什么”
- 结合实战场景:不要死记硬背,要理解实际应用场景
- 动手实践:在自己电脑上安装环境,执行本文的示例代码
- 反复复习:面试前一周,每天复习 10 个问题
学习时间估算
- ⏱️ 快速复习(只看一句话总结):2 小时
- 📚 系统学习(看深度解析):1-2 天
- 💪 深入理解(研究源码):1 周+
🗺️ 知识图谱
mindmap
root((JVM))
内存区域
堆 Heap
虚拟机栈
本地方法栈
程序计数器
方法区/元空间
对象与引用
对象内存布局
内存分配策略
四种引用类型
垃圾回收
GC Roots 与可达性分析
三色标记与漏标
标记-清除/复制/整理
垃圾收集器
CMS
G1
ZGC
Shenandoah
类加载机制
双亲委派模型
类加载五阶段
自定义类加载器
调优与诊断
JVM 参数调优
GC 日志分析
OOM 排查
JIT 与逃逸分析
⚠️ 常见陷阱与误区
陷阱 1:以为”方法区”就是”永久代”
❌ 错误理解:
1 | |
✅ 正确做法:
- 回答时说”方法区的 HotSpot 实现,在 JDK 8 之前是永久代,JDK 8 之后是元空间”,而不是笼统地说”方法区就是永久代”
陷阱 2:误以为 Full GC 一定会回收老年代所有垃圾,或一定伴随 STW 很久
❌ 错误理解:
- 认为 Full GC 结束后老年代一定会变干净、内存占用一定大幅下降
- 认为所有收集器的 Full GC 停顿都很长
✅ 正确做法:
- Full GC 只是回收范围更大(新生代+老年代+方法区),是否”彻底”取决于可达性分析的结果,和 Minor GC 一样只回收不可达对象
- G1、ZGC 等现代收集器已经大幅压缩了 Full GC 的停顿时间,不能一概而论
陷阱 3:误以为调用 System.gc() 一定会触发 GC
❌ 错误理解:
1 | |
✅ 正确做法:
System.gc()只是建议 JVM 尽快进行一次垃圾回收,是否真正执行、何时执行由 JVM 自行决定- 可以用
-XX:+DisableExplicitGC直接屏蔽这个建议,线上代码应避免手动调用System.gc()
💡 面试技巧
技巧 1:结构化回答
不要只回答”是什么”,要按照以下结构回答:
- 一句话总结(概念)
- 深度解析(原理、实现、优缺点)
- 面试加分回答(实际项目经验、源码理解、行业最佳实践)
技巧 2:结合实战场景
不要只背概念,要结合实际项目经验回答。
技巧 3:引导到你会的方向
如果遇到不会的问题,不要慌,可以引导到你会的方向。
🎯 实战演练(真实面试场景)
场景 1:线上服务频繁 Full GC,你怎么排查?
回答思路:
- 观察现象:用
jstat -gcutil <pid> 1000观察老年代(O)使用率和 GC 频率,确认是不是真的频繁 Full GC,而不是 Minor GC - 抓取证据:开启/查看 GC 日志(
-Xlog:gc*),结合jmap -histo:live <pid>或-XX:+HeapDumpOnOutOfMemoryError拿到的堆转储文件(用 MAT/JProfiler 分析) - 定位原因:常见原因排查顺序——① 内存泄漏(某类对象数量持续增长不释放)② 大对象/大集合一次性加载 ③ Survivor 空间过小导致对象过早晋升老年代 ④ 显式调用了
System.gc()⑤ 元空间不足(类加载过多,如动态代理、热部署) - 给出方案:根据定位结果对症下药,比如调大堆/调整新生代比例、修复代码中的泄漏点、更换为低停顿收集器(G1/ZGC)
🚀 学习路径总结
第一阶段:基础概念(1-2 天)
- 理解核心概念
- 掌握基本操作
- 完成入门教程
第二阶段:高级特性(2-3 天)
- 掌握高级特性
- 理解实现原理
- 完成进阶教程
第三阶段:实战应用(1 周+)
- 搭建实际项目
- 解决实战问题
- 阅读源码(可选)
第四阶段:面试准备(1 周)
- 刷完本文的所有问题
- 复习相关知识点
- 准备项目经验
- 模拟面试
📚 扩展学习资源
官方资源
书籍推荐
- 《深入理解Java虚拟机》(周志明 著,第 3 版)
- 《Java性能权威指南》(Scott Oaks 著)
博客推荐
JVM 面试八股文30条(深度详解版)
本文力求把每个知识点讲透,不只是背答案,而是理解「为什么」。
适用于 Java 后端开发面试深入复习,建议结合实战场景理解。
一、JVM 内存区域(1-6)
第 1 题:JVM 的内存区域是怎么划分的?
一句话结论
JVM 内存 = 堆 + 虚拟机栈 + 本地方法栈 + 程序计数器 + 元空间。Java 8 之后,方法区(永久代)被元空间(Metaspace)取代。
深度解析
JVM 内存区域全景图:
1 | |
用图区分「线程私有」和「线程共享」两栏,并标注每块区域会不会 OOM / StackOverflowError(面试常问):
flowchart TB
subgraph PRIVATE["线程私有(每个线程一份)"]
direction TB
PC["程序计数器 PC Register\n不会 OOM\n不会 StackOverflowError\n(唯一不会有内存溢出问题的区域)"]
VMS["虚拟机栈 JVM Stack\n会 StackOverflowError(栈深度超限)\n会 OOM(-Xss 设太大,线程数过多)"]
NMS["本地方法栈 Native Method Stack\nHotSpot 中与虚拟机栈合二为一\n同样会 StackOverflowError / OOM"]
end
subgraph SHARED["线程共享(所有线程一份)"]
direction TB
HEAP["堆 Heap\n会 OOM:Java heap space\n对象实例、数组\n新生代 + 老年代"]
META["方法区 / 元空间 Metaspace\n会 OOM:Metaspace\n类元数据、运行时常量池、JIT 编译代码"]
end
各区域详解:
① 程序计数器(PC Register)
1 | |
程序计数器记录的是当前线程正在执行的字节码指令的地址。
- 如果执行的是 Java 方法,存的是字节码指令地址;
- 如果执行的是 Native 方法,存的是 undefined。
为什么需要程序计数器?
→ 线程切换后,要恢复到正确的执行位置。
为什么是线程私有?
→ 每个线程的执行位置不同,不能共享。
② 虚拟机栈(JVM Stack)
1 | |
栈的结构:
1 | |
栈帧(Stack Frame):每个方法执行时都会创建一个栈帧,方法结束时栈帧出栈。
局部变量表:存放方法参数和局部变量(编译时确定大小)。
操作数栈:字节码指令的工作区(如iadd会从操作数栈弹出两个数,相加后压栈)。
StackOverflowError:
1 | |
③ 本地方法栈(Native Method Stack)
和虚拟机栈类似,但是为 Native 方法(如
System.currentTimeMillis())服务。
HotSpot 虚拟机中,本地方法栈和虚拟机栈是合二为一的。
④ 堆(Heap)★ 最重要的区域
1 | |
堆的结构(分代收集理论):
1 | |
为什么分代?
→ 研究表明:大多数对象都是「朝生夕死」的(IBM 研究:98% 的对象活不过一次 GC)。
→ 分代后,可以对不同代使用不同的垃圾回收算法,提高 GC 效率。
⑤ 元空间(Metaspace,Java 8+)
Java 8 之前:方法区 = 永久代(PermGen),在堆上,容易 OOM。
Java 8 之后:方法区 = 元空间(Metaspace),在本地内存(Native Memory)中,不再受-XX:MaxPermSize限制。
元空间中存放什么?
1 | |
为什么要用元空间替代永久代?
1 | |
面试加分回答
「JVM 内存区域是理解 GC、OOM、性能调优的基础。面试时经常追问:字符串常量池在哪里? 答案是:Java 7 之前在永久代,Java 7 开始移到堆中。另外,直接内存(Direct Memory) 不是 JVM 运行时数据区的一部分,但 NIO 的
DirectByteBuffer会分配直接内存,不受 Java 堆限制,所以-Xmx设置得再大,也可能发生 OOM(直接内存溢出)。」
第 2 题:Java 8 为什么要移除永久代(PermGen),引入元空间(Metaspace)?
一句话结论
永久代容易 OOM(大小固定,难调优),元空间使用本地内存(理论上无限),且 GC 效率更高(元空间中的类元数据只在 ClassLoader 卸载时回收,不需要频繁 GC)。
深度解析
永久代的问题:
1 | |
问题 ①:大小难调
→ 永久代的大小在启动时固定(-XX:MaxPermSize),如果设置太小,容易 OOM;如果设置太大,浪费内存。问题 ②:GC 复杂
→ 永久代在堆上,会被 Full GC 回收(永久代的 GC 和老年代绑定),但类元数据的回收条件很苛刻(ClassLoader 卸载),导致 Full GC 频繁但效果不好。
元空间的优势:
1 | |
优势 ①:自动扩容
→ 元空间使用本地内存,可以动态扩容(直到物理内存耗尽)。优势 ②:GC 更简单
→ 元空间中的类元数据只在 ClassLoader 卸载 时回收(此时元数据全部可回收),不需要像永久代那样复杂的可达性分析。优势 ③:字符串常量池移到堆中
→ Java 7 开始,字符串常量池从永久代移到堆中,便于 GC 回收不再使用的字符串。
元空间的内存管理:
1 | |
面试加分回答
「元空间和永久代的区别是 JVM 内存管理的一个经典改进。面试时可能会追问:元空间会不会 OOM? 答案是:会!虽然元空间默认不限制大小,但如果设置了
-XX:MaxMetaspaceSize,或者物理内存耗尽,仍然会 OOM(MetaspaceError)。另外,元空间的 GC 触发条件是:元空间使用率达到-XX:MetaspaceSize时触发 Full GC,所以如果元空间增长很快(如大量使用动态代理),需要调整MetaspaceSize避免频繁 Full GC。」
第 3 题:对象的创建过程是怎样的?
一句话结论
new Object()的过程 = 类加载检查 → 分配内存 → 初始化零值 → 设置对象头 → 执行<init>方法(构造函数)。
深度解析
完整流程:
1 | |
步骤 ①:类加载检查
1 | |
步骤 ②:分配内存
1 | |
内存分配的两个方式:
1 | |
哪种方式取决于垃圾收集器是否有压缩整理功能:
- 指针碰撞:Serial、ParNew(标记-复制算法,内存规整)
- 空闲列表:CMS(标记-清除算法,会产生碎片)
并发安全问题:
1 | |
步骤 ③:初始化零值
1 | |
这步操作保证了 Java 代码中的成员变量可以不赋初值就直接使用(有默认零值)。
步骤 ④:设置对象头(Object Header)
1 | |
Mark Word 的状态变化:
1 | |
步骤 ⑤:执行 <init> 方法(构造函数)
1 | |
面试加分回答
「对象的创建过程涉及很多细节,面试时经常追问:对象一定在堆上分配吗? 答案是:不一定!JVM 有 逃逸分析(Escape Analysis) 优化,如果一个对象没有逃逸出方法(即不会被其他线程访问),JVM 可以在栈上分配对象(栈上分配的对象,方法结束时自动销毁,不需要 GC)。另外,标量替换(把对象拆成基本类型)也是逃逸分析的一个优化手段。」
第 4 题:对象的内存布局是怎样的?
一句话结论
对象在内存中 = 对象头(Header)+ 实例数据(Instance Data)+ 对齐填充(Padding)。对象头包含 Mark Word 和 Klass Pointer。
深度解析
对象内存布局:
1 | |
对象头详解:
1 | |
实例数据(Instance Data):
1 | |
字段重排(Field Reordering):
JVM 为了节省空间,会对字段进行重排(把相同大小的字段放在一起):
1
2
3
4
5
6
7
8
9
10// 源码中的顺序:
int a; // 4 字节
byte b; // 1 字节
long c; // 8 字节
// 内存中的顺序(可能):
long c; // 8 字节
int a; // 4 字节
byte b; // 1 字节
padding; // 3 字节(对齐到 8 的倍数)
对齐填充(Padding):
1 | |
面试加分回答
「对象的内存布局是理解 synchronized 锁升级、对象哈希码、GC 分代年龄的基础。面试时可能会追问:为什么 GC 分代年龄最大是 15? 答案是:分代年龄存储在 Mark Word 的 4 位中(2^4 = 16,但 0 表示未经过 GC,所以最大是 15)。另外,对象的哈希码什么时候计算? 答案是:第一次调用
hashCode()时计算,并存储在 Mark Word 中(这也是为什么加了偏向锁的对象调用hashCode()会导致锁升级)。」
第 5 题:对象的访问定位方式有哪些?
一句话结论
Java 通过栈上的引用(Reference)访问堆中的对象,有两种方式:句柄访问 和 直接指针访问。HotSpot 使用直接指针访问。
深度解析
方式 ①:句柄访问(Handle Access)
1 | |
优点:引用中存储的是稳定的句柄地址,对象被移动(GC 时)时,只需要修改句柄中的指针,不需要修改引用本身。
缺点:多了一次指针定位的开销(访问对象需要两次指针跳转)。
方式 ②:直接指针访问(Direct Pointer Access)★ HotSpot 使用
1 | |
优点:访问速度快(只需要一次指针跳转)。
缺点:对象被移动时(GC 时),需要修改所有对该对象的引用。
为什么 HotSpot 选择直接指针访问?
1 | |
面试加分回答
「对象的访问定位方式是一个比较冷门但有趣的知识点。面试时可能会追问:引用(Reference)存在哪里? 答案是:局部变量(如
Object obj)存在虚拟机栈的局部变量表中;成员变量(如class A { Object obj; })存在堆中的对象实例数据中。另外,Java 中的引用有四种类型(强引用、软引用、弱引用、虚引用),见第 9 题。」
第 6 题:OutOfMemoryError 有哪些类型?分别发生在什么区域?
一句话结论
OOM 有 5 种常见类型:Java 堆溢出、虚拟机栈/本地方法栈溢出、元空间溢出、直接内存溢出、GC Overhead Limit Exceeded。
深度解析
① Java 堆溢出(最常见)
1 | |
② 虚拟机栈溢出(StackOverflowError)
1 | |
注意:StackOverflowError 不是 OOM,是另一种错误(Error)。
③ 元空间溢出(Java 8+)
1 | |
④ 直接内存溢出
1 | |
⑤ GC Overhead Limit Exceeded
1 | |
面试加分回答
「OOM 的排查是 Java 工程师的必备技能。生产环境中,OOM 的排查流程是:
- 查看错误日志,确定 OOM 类型;
- 如果是堆溢出,用
jmap -histo:live pid查看堆中对象分布,找到占用内存最多的类;- 用
jmap -dump:format=b,file=heap.hprof pid导出堆转储文件,用 MAT(Memory Analyzer Tool)分析内存泄漏;- 如果是元空间溢出,检查是否加载了太多类(如动态代理使用不当)。」
二、垃圾回收基础(7-10)
第 7 题:如何判断对象是否可以被回收?
一句话结论
Java 使用可达性分析算法(Reachability Analysis):从 GC Roots 出发,不可达的对象可以被回收。
深度解析
引用计数法(Reference Counting,Java 不用):
1 | |
可达性分析算法(Java 使用):
1 | |
图解:
1 | |
面试加分回答
「可达性分析有个细节:枚举根节点(GC Roots Enumeration)需要 Stop The World(STW)。因为如果不暂停所有线程,根节点集合会不断变化,分析就不准确。但现代 GC(如 CMS、G1、ZGC)都做了优化,STW 的时间非常短(毫秒级)。另外,OopMap(Ordinary Object Pointer Map)是 HotSpot 用来快速枚举 GC Roots 的数据结构,不需要遍历整个栈找引用。」
第 8 题:GC Roots 有哪些?
一句话结论
GC Roots = 虚拟机栈引用 + 本地方法栈引用 + 静态变量 + 常量 + 被同步锁持有的对象 + Java 虚拟机内部引用。
深度解析
固定可作为 GC Roots 的对象(固定不变):
1 | |
动态加入的 GC Roots(根据收集器不同):
1 | |
面试加分回答
「GC Roots 的枚举是 GC 的一个核心步骤。在分代收集中,Young GC 需要扫描老年代(因为老年代对象可能引用年轻代对象),这很慢。解决方式是 Card Table(卡表):把老年代划分为 512 字节的卡页,如果卡页中有对象引用了年轻代,就标记为「脏卡」,Young GC 时只需要扫描脏卡,不需要扫描整个老年代。」
第 9 题:Java 的四种引用类型?
一句话结论
强引用(不回收)> 软引用(内存不足时回收)> 弱引用(下次 GC 就回收)> 虚引用(随时可回收,用于回收通知)。
深度解析
① 强引用(Strong Reference)
1 | |
强引用是 Java 中最常见的引用,几乎没有约束。
② 软引用(Soft Reference)
1 | |
③ 弱引用(Weak Reference)
1 | |
ThreadLocal 的内存泄漏问题:
1
2
3
4// ThreadLocalMap 的 key 是弱引用
// → 如果 ThreadLocal 没有外部强引用了,key(ThreadLocal)会被回收
// → 但 value 是强引用,不会被回收 → 内存泄漏!
// 解决:用完 ThreadLocal 后,调用 remove() 方法
④ 虚引用(Phantom Reference)
1 | |
面试加分回答
「四种引用的强度排序:强引用 > 软引用 > 弱引用 > 虚引用。面试时经常追问:ThreadLocal 为什么要用弱引用? 答案是:如果 key 是强引用,即使 ThreadLocal 的外部强引用被回收了,ThreadLocal 也不会被回收(因为 ThreadLocalMap 的 key 还持有强引用)→ 内存泄漏。用弱引用后,ThreadLocal 的外部强引用被回收时,ThreadLocal 会在下次 GC 时被回收,减少内存泄漏的风险(但 value 仍然需要手动
remove()才能完全避免泄漏)。」
第 10 题:垃圾回收算法有哪些?
一句话结论
三种基础算法:标记-清除、标记-复制、标记-整理。分代收集理论:新生代用标记-复制,老年代用标记-清除或标记-整理。
深度解析
① 标记-清除算法(Mark-Sweep)
1 | |
② 标记-复制算法(Copying)★ 新生代使用
1 | |
HotSpot 的优化(Eden + Survivor):
1
2
3
4
5
6
7
8
9
10
11新生代(Young Gen):
Eden(伊甸园) :80%
From Survivor :10%
To Survivor :10%
GC 时:
→ Eden + From 中存活的对象 → 复制到 To
→ 清空 Eden + From
→ From 和 To 交换角色
→ 只浪费 10% 的内存(To 区),不是 50%!
③ 标记-整理算法(Mark-Compact)★ 老年代使用
1 | |
三种算法对比:
| 算法 | 是否碎片 | 是否浪费内存 | STW 时间 | 适用场景 |
|---|---|---|---|---|
| 标记-清除 | ❌ 有碎片 | ✅ 不浪费 | 中等 | 老年代(CMS) |
| 标记-复制 | ✅ 无碎片 | ❌ 浪费一半 | 短 | 新生代 |
| 标记-整理 | ✅ 无碎片 | ✅ 不浪费 | 长 | 老年代(Serial Old、Parallel Old) |
面试加分回答
「标记-复制算法有一个有趣的细节:对象存活率很高时,复制算法的效率会很低(需要复制大量对象)。所以复制算法只适合对象存活率低的场景(新生代,98% 的对象活不过一次 GC)。另外,HotSpot 的 Survivor 区设计也是经过优化的:如果 Survivor 区太小,存活对象放不下,就会提前晋升到老年代(Premature Promotion),导致老年代增长过快,引发 Full GC。」
补充题:对象的内存分配策略是怎样的?(高频必考,面试必问)
一句话结论
对象优先在 Eden 区分配;大对象直接进入老年代;长期存活的对象按年龄晋升老年代;同时存在”动态年龄判定”和”空间分配担保”两条兜底规则。
深度解析
① 对象优先在 Eden 区分配
1 | |
大多数对象都是”朝生夕死”的——这正是新生代用标记-复制算法、且 Eden:Survivor 默认按 8:1:1 划分的依据(见第 10 题)。
② 大对象直接进入老年代
1 | |
为什么大对象要直接进入老年代?
→ 新生代用的是标记-复制算法,对象要在 Eden 和 Survivor 之间来回复制。
→ 如果大对象也走这条路,复制的成本非常高(内存拷贝本身就慢),还容易导致 Survivor 区放不下,提前触发 Minor GC 甚至 Full GC。
→ 让大对象一步到位分配到老年代,可以避免这种大量内存复制的开销。
③ 长期存活的对象将进入老年代(年龄晋升)
1 | |
1 | |
阈值设得越小,对象越容易晋升老年代(节省 Survivor 空间,但老年代压力大,Full GC 更频繁);设得越大,对象在新生代停留时间越长(Survivor 空间要求更高)。
④ 动态年龄判定(Dynamic Age Determination)
1 | |
这是很多人不知道的细节:MaxTenuringThreshold 只是一个”上限”,不是必须等到的固定值。动态年龄判定的意义在于:与其让一批”注定要晋升”的同龄对象反复复制、拖慢 Minor GC,不如提前一次性晋升,腾出 Survivor 空间。
⑤ 空间分配担保(Handle Promotion Failure)
1 | |
历史与现状:
-XX:+HandlePromotionFailure参数在 JDK 6 Update 24 之前需要显式配置,之后已经是默认行为,参数本身被保留但不再需要手动开启,很多老资料仍会提到它,理解其含义即可,不必再手动配置。
⑥ Minor GC / Major GC / Full GC 触发条件对比
| 类型 | 回收区域 | 常见触发条件 | STW 时长 |
|---|---|---|---|
| Minor GC(Young GC) | 新生代(Eden + Survivor) | Eden 区空间不足 | 短(毫秒级) |
| Major GC(Old GC) | 老年代 | 老年代空间不足(多数收集器实际以 Full GC 形式体现,单独的 Major GC 在 CMS 等场景下才有明确区分) | 较长 |
| Full GC | 整个堆(新生代 + 老年代)+ 方法区/元空间 | 1. 老年代空间不足;2. 空间分配担保失败;3. 元空间不足;4. 显式调用 System.gc();5. CMS 出现 Concurrent Mode Failure |
长(可达秒级,是调优的重点排查对象) |
面试加分回答
「对象分配策略是分代收集理论在工程上的具体落地,面试时经常追问一个连环问题:为什么要有动态年龄判定? 因为固定的
MaxTenuringThreshold是一刀切的:如果本轮 Minor GC 后,某个年龄段的对象已经占了 Survivor 一半以上空间,说明这批对象大概率会继续存活,与其让它们在 Eden/Survivor 之间反复复制拖慢下次 GC,不如直接晋升老年代,用空间换时间。另外,空间分配担保容易被问到”如果担保失败会怎样”——答案是退化为一次 Full GC,这也是为什么线上如果发现 Full GC 频繁,除了看老年代本身,还要排查新生代对象是否”晋升过快”(比如 Survivor 设置太小、或者业务代码里有大量长生命周期的临时对象)。」
三、垃圾收集器(11-17)
第 11 题:常见的垃圾收集器有哪些?
一句话结论
新生代收集器:Serial、ParNew、Parallel Scavenge;老年代收集器:Serial Old、Parallel Old、CMS;整堆收集器:G1(Java 7+)、ZGC(JDK 11 实验特性引入,JDK 15 转正为生产可用,JDK 21 引入分代 ZGC 并成为默认模式)、Shenandoah(Java 12+)。
深度解析
收集器的发展史:
1 | |
各收集器特点:
| 收集器 | 作用区域 | 线程数 | 算法 | 目标 | 适用场景 |
|---|---|---|---|---|---|
| Serial | 新生代 | 单线程 | 标记-复制 | 简单高效(客户端) | 客户端、嵌入式 |
| Serial Old | 老年代 | 单线程 | 标记-整理 | 简单 | Client 模式 |
| ParNew | 新生代 | 多线程 | 标记-复制 | 配合 CMS | 服务端(Java 8-) |
| Parallel Scavenge | 新生代 | 多线程 | 标记-复制 | 吞吐量优先 | 后台计算 |
| Parallel Old | 老年代 | 多线程 | 标记-整理 | 吞吐量优先 | 配合 PS |
| CMS | 老年代 | 并发 | 标记-清除 | 低延迟 | 互联网应用(Java 8-) |
| G1 | 整堆 | 并发 | 标记-整理+复制 | 可预测停顿 | 大内存(Java 8+ 推荐) |
| ZGC | 整堆 | 并发 | 染色指针 | 超低延迟(< 10ms) | 超大内存(JDK 11+ 实验,JDK 15+ 生产可用) |
收集器的搭配关系:
1 | |
Java 8 的默认收集器:Parallel Scavenge + Parallel Old(吞吐量优先)
Java 9+ 的默认收集器:G1
面试加分回答
「收集器的选择是一个经典问题。面试时经常问:你们的线上服务用的什么收集器?为什么? 典型回答:Java 8 用 CMS(低延迟),Java 8+ 用 G1(可预测停顿)。如果是大内存(> 32GB)+ 低延迟(< 10ms) 的场景,用 ZGC。另外,CMS 已经在 Java 9 被标记为 Deprecated,Java 14 被移除,所以新项目不要用 CMS 了。」
第 12 题:CMS 收集器的工作原理?
一句话结论
CMS(Concurrent Mark Sweep)= 一款并发的、低延迟的老年代收集器。流程:初始标记(STW)→ 并发标记 → 重新标记(STW)→ 并发清除。
深度解析
CMS 的完整流程:
1 | |
CMS 的优点:
1 | |
CMS 的缺陷:
1 | |
面试加分回答
「CMS 的「重新标记」阶段使用了 写后屏障(Write Barrier) 来记录引用变化。具体来说,是 增量更新(Incremental Update) 算法:当黑色对象的引用变化时,把它标记为灰色(需要重新扫描)。另一个算法是 原始快照(SATB,G1 使用),记录删除前的引用关系。面试时可能会追问这两个算法的区别。」
第 13 题:G1 收集器的工作原理?
一句话结论
G1(Garbage First)= 面向服务端应用的收集器,把堆划分为多个大小相等的 Region,优先回收垃圾最多的 Region(Garbage First)。可预测停顿时间。
深度解析
G1 的内存布局(不再物理分代):
1 | |
关键点:G1 不再是物理分代(固定新生代、老年代各占多少),而是逻辑分代(Region 的身份可以变化)。
G1 的收集流程:
1 | |
G1 的核心优势:
1 | |
G1 的重要参数:
1 | |
面试加分回答
「G1 的 Humongous Object(大对象) 处理是一个细节:大小超过 Region 一半的对象,会被分配到 Humongous Region(连续的多个 Region)。大对象会直接进入老年代,且 G1 在 Full GC 时才会回收大对象。所以如果应用中大对象很多,会导致频繁 Full GC。解决方式:调大 Region 大小(
-XX:G1HeapRegionSize),或减少大对象的使用。」
第 14 题:ZGC 的工作原理?
一句话结论
ZGC(JDK 11 引入,JDK 15 转正为生产可用,JDK 21 引入分代 ZGC 并成为默认模式)= 超低延迟的收集器(STW < 10ms),核心是「并发压缩」+「染色指针」+「内存多重映射」。
深度解析
ZGC 的设计目标:
1 | |
ZGC 的核心技术:
① 染色指针(Colored Pointer)
1 | |
为什么要把标记存在指针里?
→ 这样在标记阶段,不需要访问对象本身,只需要看指针的标记位!
→ 可以并发标记,不需要 STW!
② 内存多重映射(Multi-Mapping)
1 | |
③ 并发压缩(Concurrent Compaction)
1 | |
面试加分回答
「ZGC 的 转发指针(Forwarding Pointer) 是并发转移的关键:当对象被移动到新地址后,ZGC 在原对象地址放一个「转发指针」,指向新地址。这样,如果有其他线程访问旧地址,会通过转发指针自动跳转到新地址(不需要 STW 修改所有引用)。这个技术让 ZGC 的 STW 时间极低(< 10ms)。另外,ZGC 的版本演进要记清楚:JDK 11 引入(实验特性)→ JDK 15 转正为生产可用 → JDK 21 引入分代 ZGC(Generational ZGC)并成为默认模式,推荐大内存、低延迟场景使用。」
第 15 题:三色标记算法?
一句话结论
三色标记 = 白色(未标记)、灰色(自身标记,引用未标记)、黑色(自身和引用都已标记)。CMS 使用「增量更新」,G1 使用「原始快照(SATB)」来解决漏标问题。
深度解析
三色标记的定义:
1 | |
标记过程:
1 | |
漏标问题(Dijkstra 算法):
1 | |
两种解决方案:
① 增量更新(Incremental Update,CMS 使用)
1 | |
② 原始快照(SATB,Snapshot-At-The-Beginning,G1 使用)
1 | |
面试加分回答
「三色标记是并发 GC 的基础理论。面试时可能会追问:为什么 G1 用 SATB 而不是增量更新? 答案是:SATB 的写屏障(Write Barrier)开销更小(只需要记录删除的引用,不需要重新扫描黑色对象)。另外,ZGC 使用的是染色指针,不需要写屏障,所以 ZGC 的性能更好(对应用吞吐量的影响 < 15%,而 G1 可能影响 20%~30%)。」
第 16 题:记忆集(Remembered Set)和卡表(Card Table)?
一句话结论
记忆集(RSet)= 记录「其他 Region/代 中哪些对象引用了本 Region/代 的对象」。卡表(Card Table)= 记忆集的实现方式,把内存划分为卡页,标记哪些卡页中有跨代/跨 Region 引用。
深度解析
问题背景(分代收集):
1 | |
卡表(Card Table):
1 | |
图解:
1 | |
记忆集(Remembered Set,RSet):
1 | |
RSet 的结构:
1 | |
面试加分回答
「卡表和 RSet 是 JVM 性能优化的的重要技术。面试时可能会追问:写屏障(Write Barrier)是什么? 答案是:当程序修改对象引用时(如
obj.field = newObj),JVM 会执行一段额外的代码(写屏障),用来标记卡表(如果是跨代引用)或更新 RSet(如果是跨 Region 引用)。写屏障会有一定的性能开销,但比起扫描整个老年代/整个堆,这个开销是值得的。」
第 17 题:并发标记的问题(漏标、误标)?
一句话结论
并发标记的核心问题:1. 漏标(对象应该存活但被回收了,严重!)→ 用增量更新或 SATB 解决;2. 误标(对象应该被回收但仍然存活,无害)→ 下次 GC 会回收。
深度解析
漏标(Missing Mark,严重问题):
1 | |
漏标是一个”时序问题”,光看文字很难建立直觉,下面用三个时间点拆解同一个场景:
flowchart TB
subgraph t0["t0:并发标记开始前"]
direction LR
A0["A(黑色)\n已标记,已扫描完引用"] -->|引用| B0["B(灰色)\n已标记,引用还没扫描完"]
B0 -->|引用| C0["C(白色)\n尚未标记"]
end
subgraph t1["t1:并发标记进行中,用户线程并发修改引用"]
direction LR
A1["A(黑色)"] -->|"新增引用 A.field = C"| C1["C(白色)"]
B1["B(灰色)"] -.->|"引用被置空 B.field = null"| C1
end
subgraph t2["t2:标记结束,若没有屏障介入"]
direction LR
A2["A(黑色,不会再被扫描)"] -->|"引用仍指向 C"| C2["C(依然是白色 = 未标记)"]
C2 -->|"清除阶段"| GONE["❌ 被当作垃圾误回收\n但 A 仍在引用 C → 悬挂指针 / 崩溃"]
end
t0 --> t1 --> t2
漏标发生需要同时满足两个条件(缺一不可):
- 条件 1:至少有一个黑色对象新增了对白色对象的引用(如上图 A.field = C)
- 条件 2:至少有一个灰色对象删除了对该白色对象的引用(如上图 B.field = null)
两种解决方案,分别”堵”住上面两个条件之一:
flowchart LR
subgraph SATB["SATB(原始快照,G1 使用)"]
direction TB
S1["拦住条件 2:\n灰色对象删除引用时"] --> S2["写前屏障记录\n被删除的旧引用"] --> S3["旧引用指向的对象\n本轮当作存活处理\n(哪怕已经不可达)"]
end
subgraph INC["增量更新(CMS 使用)"]
direction TB
I1["拦住条件 1:\n黑色对象新增引用时"] --> I2["写后屏障把该\n黑色对象重新标记为灰色"] --> I3["灰色对象会被\n重新扫描一遍引用"]
end
一句话区分:SATB 记录的是”删除前”的旧引用(保证开始并发标记那一刻的对象图快照里可达的对象都不会被漏掉);增量更新记录的是”新增”的引用(让黑色对象重新变灰、重新扫描)。
解决方案(见第 15 题):
- 增量更新(CMS):黑色对象新增引用时,重新标记为灰色
- 原始快照 SATB(G1):灰色对象删除引用时,记录旧引用,确保对象不被漏标
误标(Over-Marking,无害问题):
1 | |
误标是无害的,因为垃圾只是「晚一点回收」,不会导致程序错误。
漏标是有害的,会导致程序访问已回收的对象(悬挂指针),造成崩溃或数据错误。
面试加分回答
「并发标记的漏标问题是设计并发 GC 时的核心挑战。CMS 和 G1 用了不同的方案:CMS 用增量更新(记录新增的引用),G1 用SATB(记录删除的引用)。这两种方案各有优劣:增量更新可能导致重复扫描(黑色对象被重新标记为灰色,需要重新扫描);SATB 可能导致过度标记(实际上已经不再引用的对象,仍然被标记为存活)。G1 选择 SATB 的原因是:SATB 的写屏障开销更小,更适合大堆场景。」
四、类加载机制(18-21)
第 18 题:类加载的过程?
一句话结论
类加载 = 加载(Loading)→ 验证(Verification)→ 准备(Preparation)→ 解析(Resolution)→ 初始化(Initialization)。其中验证、准备、解析合称「连接(Linking)」。
深度解析
完整流程:
1 | |
用图直观看”准备”和”初始化”两个阶段对同一个静态变量做了什么(这是高频考点,一定要记住 a 经历了两次赋值):
flowchart LR
L["① 加载 Loading\n读取 .class 字节流\n生成 Class 对象"] --> V["② 验证 Verification\n校验字节码合法性"]
V --> P["③ 准备 Preparation\nstatic int a = 10;\n此时 a = 0(赋零值)\n(static final 常量除外,直接赋真值)"]
P --> R["④ 解析 Resolution\n符号引用 → 直接引用\n如 #quot;java.lang.Object#quot; → 指针"]
R --> I["⑤ 初始化 Initialization\n执行 #60;clinit#62;() 方法\n此时 a = 10(赋代码中的真实值)"]
准备阶段 vs 初始化阶段(重要!):
1 | |
面试加分回答
「类加载的初始化阶段是面试高频追问点。需要注意:
() 方法是线程安全的 (JVM 会保证多线程环境下,只有一个线程能执行() 方法,其他线程会阻塞等待)。这也是静态内部类实现单例模式的线程安全保证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24public class Singleton {
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // 首次调用时,触发 Holder 类的初始化
}
}
```」
---
### 第 19 题:双亲委派模型?
#### 一句话结论
**双亲委派模型 = 类加载器收到加载请求时,先委托父加载器加载,父加载器无法加载时才自己加载。防止核心类被篡改(如自定义 java.lang.Object)。**
---
#### 深度解析
**类加载器的层次结构:**双亲委派模型(Parent Delegation Model):
┌─────────────────────────────────────────────┐
│ 启动类加载器(Bootstrap ClassLoader) │
│ → C++ 实现,是 JVM 的一部分 │
│ → 加载/jre/lib 下的核心类库│
│ → 如:java.lang.、java.util. │
└─────────────────┬───────────────────────────┘
│ 子加载器
┌─────────────────┴───────────────────────────┐
│ 扩展类加载器(Extension ClassLoader,Java 8)│
│ → Java 实现,sun.misc.Launcher$ExtClassLoader│
│ → 加载/jre/lib/ext 下的扩展类│
└─────────────────┬───────────────────────────┘
│ 子加载器
┌─────────────────┴───────────────────────────┐
│ 应用程序类加载器(Application ClassLoader) │
│ → Java 实现,sun.misc.Launcher$AppClassLoader│
│ → 加载用户类路径(ClassPath)上的类 │
│ → 是程序中默认的类加载器 │
└─────────────────┬───────────────────────────┘
│ 子加载器
┌─────────────────┴───────────────────────────┐
│ 自定义类加载器(Custom ClassLoader) │
│ → 继承 java.lang.ClassLoader │
│ → 可以重写 loadClass() 或 findClass() │
└─────────────────────────────────────────────┘
1 | |
双亲委派的工作流程:
1 | |
双亲委派的好处:
1 | |
面试加分回答
「双亲委派模型不是强制约束,而是一个推荐的最佳实践。Java 中有些场景就打破了双亲委派:1. SPI(Service Provider Interface):如
java.sql.Driver(JDBC 驱动),核心接口由启动类加载器加载,但实现类由应用程序类加载器加载,启动类加载器无法加载应用程序类路径上的类 → 使用线程上下文类加载器(Thread Context ClassLoader) 来解决;2. OSGi:为了实现模块化,每个模块有自己的类加载器,不再遵循双亲委派。」
第 20 题:如何打破双亲委派模型?
一句话结论
打破双亲委派 = 重写
loadClass()方法(不推荐)或重写findClass()方法(推荐)。典型场景:Tomcat 的 WebAppClassLoader、SPI 机制、OSGi。
深度解析
方式 ①:重写 loadClass()(不推荐,会破坏双亲委派)
1 | |
为什么不推荐重写
loadClass()?
→ 会破坏双亲委派模型,可能导致核心类被篡改!
→ Java 官方推荐:重写findClass(),不要重写loadClass()
方式 ②:重写 findClass()(推荐)
1 | |
典型场景:Tomcat 的 WebAppClassLoader
1 | |
面试加分回答
「打破双亲委派的经典场景是 Tomcat 的 WebAppClassLoader。如果每个 Web 应用都遵循双亲委派,那么所有 Web 应用都只能使用同一个版本的库(在 ClassPath 上的版本)。但实际情况是:不同 Web 应用可能依赖同一个库的不同版本。Tomcat 的解决方案是:每个 Web 应用有自己的 WebAppClassLoader,先加载自己应用下的类,加载不了再委托父加载器,从而打破了双亲委派。」
第 21 题:自定义类加载器的场景?
一句话结论
自定义类加载器的典型场景:热部署(Hot Swap)、模块化(如 OSGi)、字节码加密/解密、从非标准位置加载类(如网络、数据库)。
深度解析
场景 ①:热部署(Hot Swap)
1 | |
场景 ②:字节码加密/解密
1 | |
场景 ③:从网络/数据库加载类
1 | |
面试加分回答
「自定义类加载器的一个重要细节:同一个类,被不同的类加载器加载,JVM 会认为它们是不同的类。即使类的全限定名相同,如果类加载器不同,它们也是不兼容的(赋值时会报
ClassCastException)。这也是为什么在热部署时,需要创建一个新的类加载器来加载新版本的类,而不能直接使用旧的类加载器。」
五、JVM 调优(22-26)
第 22 题:常见的 JVM 调优参数?
一句话结论
JVM 调优参数 = 堆内存设置(-Xms、-Xmx)+ 垃圾收集器选择(-XX:+UseG1GC 等)+ gc 日志(-Xlog:gc)+ 溢出时转储(-XX:+HeapDumpOnOutOfMemoryError)。*
深度解析
① 堆内存设置(最常用):
1 | |
② 垃圾收集器选择:
1 | |
③ GC 日志(调优必备):
1 | |
④ OOM 时转储堆内存(生产环境必备):
1 | |
面试加分回答
「JVM 调优参数的设置需要结合应用场景。面试时可能会问:你们的生产环境用的什么参数? 典型回答(Java 8 + G1):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
```」
---
### 第 23 题:如何排查 CPU 飙高问题?
#### 一句话结论
**CPU 飙高排查流程 = `top` 找 CPU 高的进程 → `top -Hp pid` 找 CPU 高的线程 → `jstack pid | grep 线程 ID(16 进制)` 找对应的线程栈 → 分析线程栈找出问题代码。**
---
#### 深度解析
**完整排查步骤:**
```bash
# 步骤 1:找出 CPU 使用率高的 Java 进程
top
# 找到 PID(如:12345)
# 步骤 2:找出该进程中 CPU 使用率高的线程
top -Hp 12345
# 找到线程 ID(如:12367)
# 步骤 3:把线程 ID 转换成 16 进制(因为 jstack 中的线程 ID 是 16 进制)
printf "%x\n" 12367
# 输出:304f
# 步骤 4:用 jstack 导出线程栈,并查找该线程
jstack 12345 | grep -A 20 "304f"
# -A 20:显示匹配行之后的 20 行(完整的线程栈)
# 步骤 5:分析线程栈
# → 如果是自己的业务代码:检查是否有死循环、密集计算
# → 如果是 GC 线程:可能是频繁 GC(内存不足、内存泄漏)
# → 如果是某个第三方库的代码:检查使用方式是否正确
常见原因:
1 | |
面试加分回答
「CPU 飙高问题排查是 Java 工程师的必备技能。除了
top + jstack,还可以用 arthas(阿里开源的 Java 诊断工具)来排查:
1
2
3
4
5
6
7
8# 启动 arthas
java -jar arthas-boot.jar
# 查看 CPU 最高的线程
thread -n 5
# 反编译有问题的类
jad com.example.MyService
# 查看方法执行耗时
trace com.example.MyService methodNamearthas 比 jstack 更强大,生产环境必备!」
第 24 题:如何排查内存泄漏?
一句话结论
内存泄漏排查 =
jmap -histo:live pid查看堆中对象分布 → 找到异常多的类 →jmap -dump:format=b,file=heap.hprof pid导出堆转储 → 用 MAT(Memory Analyzer Tool)分析泄漏点。
深度解析
完整排查步骤:
1 | |
常见内存泄漏场景:
1 | |
面试加分回答
「内存泄漏的排查,除了
jmap + MAT,也可以用 arthas 的dashboard命令实时监控堆内存使用情况,用heapdump命令在线导出堆转储。另外,Java 8 的jmap命令在执行时会导致 JVM 停顿(STW),如果堆很大(如 32GB),停顿时间可能很长(几十秒)。解决方式:使用 G1 的-XX:+G1HeapDumpBeforeFullGC,在 Full GC 之前自动 dump 堆,不需要手动执行jmap。」
第 25 题:JVM 调优的思路?
一句话结论
JVM 调优 = 明确目标(吞吐量 vs 延迟) → 收集 GC 日志 → 分析 GC 日志(用 GCViewer 或 GCEasy) → 调整参数 → 验证效果。没有银弹,需要结合应用场景。
深度解析
调优步骤:
1 | |
调优案例:
1 | |
面试加分回答
「JVM 调优的最重要原则:不要过早优化(Premature Optimization)。在调优之前,先确认:1. 是否是代码问题(如内存泄漏、死循环);2. 是否是数据库问题(如慢查询)。很多所谓的「JVM 调优」,实际上应该是「代码优化」或「数据库优化」。另外,GCEasy(https://gceasy.io/)是在线的 GC 日志分析工具,上传 GC 日志后,会自动生成分析报告(包括 GC 频率、停顿时间、内存使用情况等),非常方便。」
第 26 题:逃逸分析?
一句话结论
逃逸分析(Escape Analysis)= JVM 的一种分析技术,判断对象是否「逃逸」出方法(即是否被其他线程或外部方法访问)。如果没有逃逸,JVM 可以做标量替换、栈上分配、锁消除等优化。
深度解析
什么是「逃逸」?
1 | |
逃逸分析的优化:
① 栈上分配(Stack Allocation)
1 | |
② 标量替换(Scalar Replacement)
1 | |
③ 锁消除(Lock Elision)
1 | |
面试加分回答
「逃逸分析是 JVM 的很致优化之一。但需要注意的是:逃逸分析本身是有成本的(需要分析对象是否逃逸)。对于短方法(如 getter/setter),逃逸分析的成本可能高于优化带来的收益。另外,标量替换 和 栈上分配 在 HotSpot 中并没有完全实现(主要是标量替换),因为实现栈上分配需要修改垃圾收集器,成本很高。面试时可能会追问:Java 中的对象一定在堆上分配吗? 答案是:理论上可以栈上分配,但 HotSpot 目前主要使用标量替换。」
六、实战问题(27-30)
第 27 题:方法区、栈、堆分别存什么?
一句话结论
方法区(元空间)存:类元数据、常量、静态变量、JIT 缓存;栈存:局部变量、方法参数、引用(对象地址);堆存:对象实例、数组。
深度解析
各区域存储内容的详细对比:
1 | |
代码示例:
1 | |
面试加分回答
「方法区(元空间)中存储的内容,面试时经常追问:字符串常量池在哪里? 答案是:Java 7 之前在永久代(方法区),Java 7 开始移到堆中。静态变量在哪里? 这里要小心,很多人会答错:静态变量本身(变量槽)从未搬迁过,一直随 Class 元数据存放在方法区/元空间,与字符串常量池的迁移无关;只有当静态变量是引用类型时,它指向的对象实例才存放在堆中——这一点在任何版本都成立,不要和”字符串常量池 Java 7 后移到堆”混为一谈。这些细节反映了 JVM 内存模型的演进。」
第 28 题:synchronized 的锁升级过程?
一句话结论
synchronized 的锁升级 = 无锁 → 偏向锁(单线程访问)→ 轻量级锁(多线程交替访问)→ 重量级锁(多线程同时访问)。锁升级后不能降级(但偏向锁可以撤销)。
深度解析
锁的状态(存储在对象头的 Mark Word 中):
1 | |
锁升级的触发条件:
1 | |
锁升级的性能影响:
1 | |
面试加分回答
「锁升级是 JVM 对
synchronized的重要优化(Java 6 引入)。面试时可能会追问:为什么有了 synchronized,还需要 java.util.concurrent.locks.Lock(如 ReentrantLock)? 答案是:1.ReentrantLock可以尝试获取锁(tryLock()),而synchronized会一直阻塞;2.ReentrantLock可以中断(lockInterruptibly()),而synchronized不支持中断;3.ReentrantLock可以绑定多个条件变量(Condition),而synchronized只有一个等待队列。但 Java 6 之后,synchronized的性能已经和ReentrantLock接近,优先使用synchronized(更简洁)。」
第 29 题:JVM 有哪些命令行工具?
一句话结论
JVM 命令行工具 = jps(查看 Java 进程)、jstat(查看 GC 情况)、jmap(查看堆内存)、jstack(查看线程栈)、jinfo(查看 JVM 参数)、jcmd(全能工具)。
深度解析
常用工具详解:
① jps(JVM Process Status):查看 Java 进程
1 | |
② jstat(JVM Statistics Monitoring Tool):查看 GC 情况和类加载情况
1 | |
③ jmap(Memory Map for Java):查看堆内存使用情况,导出堆转储
1 | |
④ jstack(Stack Trace for Java):查看线程栈
1 | |
⑤ jinfo(Configuration Info for Java):查看和修改 JVM 参数
1 | |
⑥ jcmd(JVM Command):JDK 7+ 的全能工具
1 | |
面试加分回答
「JVM 命令行工具是排查问题的基础。但生产环境中,这些工具可能无法正常使用(如目标进程没有响应)。这时可以用 jcmd 或 JMX(Java Management Extensions)。另外,arthas(阿里开源)是比这些命令行工具更强大的诊断工具,支持在线反编译、查看方法执行耗时、监控 JVM 状态等,生产环境必备!」
第 30 题:线上 OOM 如何快速止损?
一句话结论
线上 OOM 快速止损 = 保留现场(dump 堆、保存 GC 日志)→ 快速重启(恢复服务)→ 分析现场(用 MAT 分析 heap dump)→ 修复代码 → 验证。
深度解析
完整止损流程:
1 | |
OOM 的常见原因和解决方案:
1 | |
面试加分回答
「线上 OOM 的最重要原则:先恢复服务,再排查问题。很多新手会先在线上排查问题(如用 jmap dump 堆),但这可能导致服务长时间不可用。正确的做法:1. 如果配置了自动 dump,直接重启;2. 如果没有自动 dump,在重启之前手动 dump(如果 dump 时间不影响服务可用性的话);3. 重启后,在测试环境复现问题,用 dump 文件分析。另外,-XX:+HeapDumpOnOutOfMemoryError 一定要开启(性能影响极小),这是线上排查 OOM 的唯一依据!」
总结:JVM 面试复习 Checklist
| 模块 | 题号 | 核心考点 |
|---|---|---|
| JVM 内存区域 | 1-6 | 内存区域划分、对象创建过程、对象内存布局、OOM 类型 |
| 垃圾回收基础 | 7-10 | 可达性分析、GC Roots、四种引用、GC 算法 |
| 垃圾收集器 | 11-17 | 收集器对比、CMS、G1、ZGC、三色标记、RSet |
| 类加载机制 | 18-21 | 类加载过程、双亲委派、打破双亲委派、自定义类加载器 |
| JVM 调优 | 22-26 | 调优参数、CPU 飙高排查、内存泄漏排查、调优思路、逃逸分析 |
| 实战问题 | 27-30 | 各区域存储内容、synchronized 锁升级、JVM 工具、OOM 止损 |
最后的建议:
JVM 的知识点很多,但面试中最高频的是:垃圾收集器(CMS/G1/ZGC)、类加载机制、JVM 调优、OOM 排查。
建议把这几块深入理解,并结合实际场景(如:你们的线上服务用的什么收集器?为什么?)来准备,这样面试时更有说服力。
祝你面试顺利!🚀
如果觉得这篇文章对你有帮助,欢迎分享给更多的小伙伴!
补充篇:BAT 高频遗漏题(31-38题)
第 31 题:字符串常量池(String Pool)是什么?
一句话总结:字符串常量池是 JVM 为了复用字符串对象而维护的一块特殊内存区域;"abc" 字面量自动入池,new String("abc") 强制在堆里新建对象。
深度解析:
1 | |
intern() 方法(面试高频!):
1 | |
面试加分回答:
“我们项目里有一处坑:从数据库查出大量字符串,每次
new String(rs.getString(1)),导致堆里大量重复字符串对象。后来改成直接用rs.getString(1).intern(),让重复字符串共享常量池里的对象,节约了约 30% 的堆内存。但要注意:intern()有性能开销(要查常量池),且 JDK 7+ 常量池在堆里,太多唯一字符串会导致堆压力大。”
第 32 题:直接内存(Direct Memory)是什么?为什么 NIO 用它?
一句话总结:直接内存是堆外内存(不受 GC 直接管理),NIO 的 DirectByteBuffer 用它来避免用户态和内核态之间的内存拷贝,提升 I/O 性能。
深度解析:
1 | |
直接内存的坑(面试高频!):
1 | |
面试加分回答:
“我们 Netty 项目里用到了大量直接内存(Netty 默认用 DirectByteBuffer 做 I/O 缓冲区)。有一次线上出现 OOM: Direct buffer memory,排查发现是 Netty 的
PlatformDependent.directBufferPreferred()返回 true,但直接内存没设上限(-XX:MaxDirectMemorySize=2g),默认跟堆内存一样大,加上堆的 8G,物理内存 16G 不够用了。解决方案:显式设置-XX:MaxDirectMemorySize=4g。”
第 33 题:对象访问定位有哪几种方式?
一句话总结:Java 通过栈上的 reference 访问堆里的对象,有两种方式:句柄访问和直接指针访问;HotSpot 用的是直接指针访问。
深度解析:
1 | |
面试加分回答:
“HotSpot 用直接指针访问,主要是性能考虑(少一次寻址)。但很多人不知道:这个设计导致 GC 做标记时,除了标记堆里的对象,还要扫描栈里的 reference——如果栈很深(如几千层递归),GC roots 扫描会非常慢。这也是为什么大栈(如 -Xss 设太大)会影响 GC 性能。”
第 34 题:ZGC 有哪些改进?和 G1 比有什么优势?
一句话总结:ZGC 于 JDK 11 引入(实验特性),JDK 15 转正为生产可用,JDK 21 引入分代 ZGC 并成为默认模式;核心改进是染色指针 + 内存多重映射 + 读屏障,停顿时间和堆大小基本无关。
深度解析:
| 对比项 | G1 | ZGC(JDK 11 实验,JDK 15+ 生产可用) |
|---|---|---|
| 目标停顿时间 | < 200ms(可设 -XX:MaxGCPauseMillis) | < 10ms(不随堆大小增加) |
| 算法 | 区域化 + 标记整理 | 并发标记 + 染色指针 + 转移 |
| 内存占用 | 约堆的 10~20% 作为 Region 开销 | 约堆的 3% 作为染色指针开销 |
| 适用堆大小 | 4GB ~ 32 GB(太大 G1 也会卡) | 8 GB ~ 16 TB(真的!) |
| JDK 版本 | JDK 7+ | JDK 11 引入,JDK 15+ 生产推荐,JDK 21 起默认分代 ZGC |
ZGC 的三大黑科技:
1 | |
面试加分回答:
“我们公司从 JDK 11 升级到 JDK 17 的主要原因就是 ZGC。原来 G1 在堆 16G 时,Full GC 偶尔会停顿 2
3 秒,对实时交易系统来说不可接受。切换到 ZGC 后,99.99% 的停顿都在 10ms 以内,GC 日志里甚至基本看不到 STW 日志。但要注意:ZGC 的读屏障会有一定的吞吐量开销(约 515%),如果应用是吞吐量优先(如离线计算),G1 可能更合适。”
第 35 题:G1 的 Humongous Object(大对象)是什么?
一句话总结:G1 中超过 Region 大小 50% 的对象是大对象,直接进入 Humongous Region,避免多次拷贝,但会独占整个 Region。
深度解析:
1 | |
面试加分回答:
“我们项目里有一个坑:用
ArrayList<byte[]>缓存大文件,每个 byte[] 是 3MB,Region=4MB,所以每个 byte[] 都是大对象,直接进 Old 区,很快触发 Full GC。解决方案:① 调大 Region 大小(-XX:G1HeapRegionSize=8m)② 或者把大对象拆成小块(< 2MB)③ 或者不用堆内存,用直接内存(DirectByteBuffer)。”
第 36 题:JVM 有哪些性能监控和故障排查工具?
一句话总结:命令行工具有 jps、jstat、jmap、jstack、jinfo;GUI 工具有 JConsole、VisualVM、JProfiler、Arthas。
深度解析:
| 工具 | 作用 | 使用场景 |
|---|---|---|
| jps | 查看正在运行的 Java 进程 | 快速找到 Java 进程 ID |
| jstat | 监控 JVM 统计信息(GC、类加载) | 线上看 GC 频率和耗时 |
| jmap | 生成堆转储快照(heap dump) | OOM 后分析内存泄漏 |
| jstack | 生成线程快照 | 排查死锁、线程死循环 |
| jinfo | 查看/修改 JVM 参数 | 动态调整参数(不用重启) |
| Arthas | 在线诊断工具(阿里开源) | 线上排查问题(无需重启) |
Arthas 常用命令(面试高频!):
1 | |
面试加分回答:
“我们线上排查问题首选 Arthas,不用重启应用。曾经遇到一个 CPU 100% 的问题,用 Arthas 的
thread -n 3找到最耗 CPU 的线程,再用jad反编译对应的类,发现是同事写了个死循环。另外,jmap -histo:live pid可以强制触发 Full GC 后再统计对象(live 参数),用来判断是不是有内存泄漏很有效。”
第 37 题:JIT 编译器(即时编译)是什么?C1 和 C2 编译器有什么区别?
一句话总结:JIT(Just-In-Time)把热点代码(频繁执行的方法/循环)编译成机器码,提升执行速度;**C1(Client 编译器)**启动快,**C2(Server 编译器)**峰值性能好。
深度解析:
1 | |
分层编译(Tiered Compilation,JDK 7+ 默认开启):
五个 Level 的区别不是优化程度递增,而是是否携带 profiling(性能监控)信息——这是最容易答错的点。
1 | |
面试加分回答:
“JIT 的逃逸分析(Escape Analysis)是很多优化的基础——如果对象只在方法内使用(没有逃逸出方法),JIT 可以做:① 栈上分配(不在堆里分配,方法结束自动销毁,不用 GC)② 锁消除(如果对象没有逃逸,加的 synchronized 可以去掉)③ 标量替换(把对象拆成基本类型,存在寄存器里,更快)。可以用
-XX:+DoEscapeAnalysis开启(JDK 7+ 默认开启)。”
第 38 题:Java 内存模型(JMM)的八大原子操作是什么?
一句话总结:JMM 定义了主内存和工作内存之间的交互协议,有 8 种原子操作:lock、unlock、read、load、use、assign、store、write。
深度解析:
1 | |
volatile 的底层实现(连接 8 种操作):
1 | |
面试加分回答:
“JMM 的 8 种原子操作是理解 volatile、synchronized 底层的关键。有个易错点:
read和load必须连续执行(中间不能插入其他指令),这就是 volatile 保证’每次都从主内存读’的底层实现。另外,JDK 5 修正了 JMM 的定义(JSR-133),之前的 JMM 有缺陷(volatile 的语义不够强),现在的是正确的。”
JVM 38 题完结!
补充篇(续):BAT 高频遗漏题(39-45题)
第 39 题:Compact String(Java 9+ 字符串优化)是什么?
一句话结论
Java 9 引入了 Compact String,将
String的内部存储从char[](每个字符 2 字节)改为byte[](根据编码用 1 或 2 字节),节省约 50% 内存。
深度解析
Java 8 及之前的 String 存储:
1 | |
Java 9+ 的 Compact String:
1 | |
性能对比:
| 场景 | Java 8(char[]) | Java 9+(byte[]) | 内存节省 |
|---|---|---|---|
| 纯英文 “abc” | 6 字节 | 3 字节 | 50% |
| 中英文混合 “你好abc” | 10 字节 | 8 字节(你2+好2+3) | 20% |
| 纯中文 “你好” | 4 字节 | 4 字节 | 0% |
面试加分回答
「Compact String 是 Java 9 的一个重要优化。在生产环境中,如果应用有大量纯英文的字符串(如 JSON、XML、日志),升级到 Java 9+ 可以节省约 30%~50% 的堆内存(字符串通常占堆的很大比例)。另外,Compact String 是默认开启的,不需要任何参数配置。如果想禁用(调试时),可以用
-XX:-CompactStrings。」
第 40 题:安全点(Safepoint)和安全区域(Safe Region)?
一句话结论
安全点(Safepoint)= 线程可以安全暂停的位置(如方法调用、循环跳转);安全区域(Safe Region)= 一段代码内,线程可以随时暂停。GC、锁降级等需要所有线程到达安全点才能执行。
深度解析
为什么需要安全点?
1 | |
安全点的工作原理:
1 | |
安全区域(Safe Region):
1 | |
面试加分回答
「安全点是 JVM 实现 Stop-The-World 的基础。面试时可能会追问:为什么安全点不选在每条字节码指令后面? 答案是:如果每条指令都检查安全点,性能开销太大!所以只选在「方法调用」和「循环跳转」这些「不那么频繁」的位置。另外,JVM 参数
-XX:+UseCountedLoopSafepoints可以让「计数循环」(如for (int i = 0; i < 1000000; i++))也在每次循环结束时检查安全点,避免大循环导致 GC 停顿时间过长。」
第 41 题:写屏障(Write Barrier)和读屏障(Read Barrier)?
一句话结论
写屏障(Write Barrier)= 当程序修改对象引用时,JVM 插入一段额外代码(用来更新卡表、Remember Set 等);读屏障(Read Barrier)= 当程序读取对象引用时,JVM 插入一段额外代码(用来支持并发 GC,如 ZGC)。
深度解析
写屏障(Write Barrier):
1 | |
代码示例(G1 的写后屏障):
1 | |
读屏障(Read Barrier,ZGC 用到):
1 | |
代码示例(ZGC 的读屏障):
1 | |
面试加分回答
「写屏障是分代 GC和分区 GC的基础(需要记录跨代/跨 Region 引用)。读屏障是并发转移 GC(如 ZGC、Shenandoah)的基础(需要修正旧引用)。面试时可能会追问:写屏障的性能开销有多大? 答案是:写屏障会有一定的性能开销(每次修改引用都要执行额外代码),但比起扫描整个老年代/整个堆,这个开销是值得的。ZGC 的读屏障开销更大(每次读取引用都要检查),但 ZGC 通过染色指针(把标记信息存在指针里)来减少读屏障的开销。」
第 42 题:Code Cache(代码缓存)是什么?如何调优?
一句话结论
Code Cache = JIT 编译器生成的本地代码(机器码)的存储区域。如果 Code Cache 满了,JIT 编译会停止,导致性能下降。
深度解析
Code Cache 的作用:
1 | |
Code Cache 的大小:
1 | |
Code Cache 满了会怎样?
1 | |
Code Cache 的调优参数:
1 | |
面试加分回答
「Code Cache 是 JIT 编译器的「工作区」。在生产环境中,如果应用有很多热点方法(如大型电商系统的商品搜索、订单提交等),Code Cache 可能会满。建议:监控 Code Cache 的使用率(可以用 JConsole 或 VisualVM 查看),如果使用率 > 80%,就调大
-XX:ReservedCodeCacheSize。另外,Java 9+ 引入了 AOT 编译(Ahead-Of-Time),可以在启动前就编译好机器码,减少启动后的 JIT 编译压力。」
第 43 题:压缩指针(Compressed Oops)的详细原理?
一句话结论
压缩指针(Compressed Oops)= 在 64 位 JVM 上,用 32 位存储对象指针(而不是 64 位),节省约 50% 的内存(对象引用占的空间)。前提是堆内存 < 32GB。
深度解析
为什么需要压缩指针?
1 | |
压缩指针的原理:
1 | |
如何开启/关闭压缩指针?
1 | |
压缩指针的效果:
| 对比项 | 开启压缩指针 | 关闭压缩指针 |
|---|---|---|
| 对象指针大小 | 4 字节 | 8 字节 |
| 堆内存限制 | < 32GB | 无限制 |
| 内存占用 | 少 ~50% | 多 ~50% |
| 适用场景 | 堆 < 32GB(大多数场景) | 堆 >= 32GB(大内存场景) |
面试加分回答
「压缩指针是 JVM 内存优化的重要手段。在生产环境中,如果堆内存 < 32GB,一定要开启压缩指针**(
-XX:+UseCompressedOops,默认开启),可以节省约 30%~50% 的内存(对象引用占的空间)。但如果堆内存 >= 32GB,压缩指针会自动关闭(因为 32 位最多表示 32GB),这时可以考虑升级到 64GB 堆**(虽然指针占更多内存,但总内存更多,可能性能更好)。另外,Compressed Class Pointers(压缩类指针,-XX:+UseCompressedClassPointers)自 JDK 8 引入元空间时就已经存在并默认开启,用来压缩指向元空间中类元数据的指针,可以进一步节省内存,和-XX:+UseCompressedOops是两个独立的开关。」
第 44 题:On-Stack Replacement(OSR,栈上替换)是什么?
一句话结论
OSR(On-Stack Replacement)= JIT 编译器可以在方法执行过程中,把「解释执行」的热循环替换成「编译后的机器码」,不需要等方法调用结束。
深度解析
为什么需要 OSR?
1 | |
OSR 的工作原理:
1 | |
OSR 的适用场景:
1 | |
面试加分回答
「OSR 是 JIT 编译器的「黑科技」之一。在没有 OSR 之前,如果热代码在一个「大循环」里,JIT 无法编译它(因为 JIT 通常只在「方法调用」时编译)。有了 OSR 之后,即使热代码在循环里,JIT 也能编译它。面试时可能会追问:OSR 和普通的 JIT 编译有什么区别? 答案是:普通的 JIT 编译是在「方法调用」时替换(从解释执行换成机器码执行);OSR 是在「循环执行过程中」替换(不需要等方法调用结束)。另外,OSR 编译后的代码和普通的 JIT 编译代码是分开存储的(因为栈帧布局不同)。」
第 45 题:JVM 调优实战案例(高频!)
一句话结论
JVM 调优实战 = 明确目标(吞吐量 vs 延迟) → 收集 GC 日志 → 分析 GC 日志(用 GCViewer 或 GCeasy) → 调整参数 → 验证效果。
深度解析
案例 1:Full GC 频繁(最常见)
1 | |
案例 2:GC 停顿时间长(影响响应时间)
1 | |
案例 3:CPU 飙高(GC 线程占用了太多 CPU)
1 | |
案例 4:OOM:Java heap space(堆内存不足)
1 | |
面试加分回答
「JVM 调优的最重要原则:不要过早优化(Premature Optimization)。在调优之前,先确认:1. 是否是代码问题(如内存泄漏、死循环);2. 是否是数据库问题(如慢查询)。很多所谓的『JVM 调优』,实际上应该是『代码优化』或『数据库优化』。另外,GC 日志分析是调优的基础,推荐用 GCeasy(https://gceasy.io/)在线分析 GC 日志,会自动生成分析报告(包括 GC 频率、停顿时间、内存使用情况等),非常方便。」
第 46 题:JVM 类加载的详细过程是什么?
一句话结论
JVM 类加载 = 加载(Load)→ 连接(Link)→ 初始化(Initialize)。连接又分验证(Verify)、准备(Prepare)、解析(Resolve)三步。
深度解析
类加载的三个阶段:
1 | |
示例:
1 | |
触发类初始化的条件(主动引用):
| 场景 | 示例 |
|---|---|
| new 对象 | new LoadTest() |
| 访问类的静态变量(非 final) | int x = LoadTest.a |
| 访问类的静态方法 | LoadTest.staticMethod() |
| 反射调用 | Class.forName("LoadTest") |
| 初始化子类 | 子类初始化时,父类必须先初始化 |
| 主类(main 方法所在类) | JVM 启动时自动初始化主类 |
注意:访问
static final常量(如B = 20)不会触发类初始化(常量在编译阶段已经放入常量池)。
面试加分回答
类加载的核心是初始化阶段(
<clinit>()方法)。这个方法由编译器自动收集所有类变量的赋值动作和静态代码块合并产生。JVM 会保证在多线程环境下,<clinit>()方法会被正确地加锁、同步(所以静态代码块在多线程环境下是线程安全的)。另外,类加载是懒惰的(Lazy Initialization):只有在第一次使用类时才会初始化,这也是为什么第一次调用某个类的方法时会稍微慢一点的原因。
第 47 题:双亲委派模型是什么?如何打破双亲委派?
一句话结论
双亲委派:子类加载器先委托父加载器加载,父加载器加载不了时才自己加载。打破双亲委派的典型场景:Tomcat(Web 应用隔离)、SPI(JDBC 驱动加载)、OSGi。
深度解析
双亲委派模型的工作流程:
1 | |
双亲委派的好处:
| 好处 | 说明 |
|---|---|
| 避免重复加载 | 父加载器加载过的类,子加载器不会重复加载 |
| 核心类不被篡改 | 你写的 java.lang.String 不会被加载(启动类加载器已经加载了真正的 String) |
打破双亲委派的场景一:Tomcat(Web 应用隔离)
1 | |
Tomcat 需要打破双亲委派:每个 Web 应用可以有自己版本的依赖(如不同版本的 Spring),不能委托给父加载器(否则会冲突)。
打破双亲委派的场景二:SPI(如 JDBC 驱动)
1 | |
如何自定义 ClassLoader 打破双亲委派?
1 | |
面试加分回答
双亲委派模型是 JVM 类加载的默认机制,但不是强制的。打破双亲委派的核心原因是隔离需求(Tomcat 的每个 Web 应用需要隔离)和SPI 机制(核心库需要加载用户代码)。实际项目中,如果你需要热加载(如开发时的热部署),也需要自定义 ClassLoader 打破双亲委派。另外,Java 9 引入的**模块化系统(JPMS)**对类加载机制做了重构,不再是传统的双亲委派模型,但这是进阶话题,面试中很少问到。
第 48 题:方法内联(JIT 优化)是什么?
一句话结论
方法内联是 JIT 的最重要的优化之一:把频繁调用的小方法的字节码直接复制到调用方,减少方法调用的开销(压栈、跳转、返回)。
深度解析
方法调用的开销:
1 | |
方法内联后(JIT 自动优化):
1 | |
方法内联的条件:
| 条件 | 说明 |
|---|---|
| 方法体积小 | 默认:-XX:MaxInlineSize=35 字节码(小于 35 字节码才内联) |
| 热点方法 | 调用频率高的方法(由热点探测算法判断) |
| 非递归 | 递归方法不能被内联(会无限展开) |
| 非 native | native 方法不能被内联 |
如何查看方法内联(JVM 参数):
1 | |
面试加分回答
方法内联是 JIT 优化的核心,它不仅能减少方法调用的开销,还能触发其他优化(如死代码消除、逃逸分析)。实际项目中,写代码时可以把频繁调用的小方法拆出来(提高代码可读性),JIT 会自动帮你内联(不会影响性能)。但如果你用反射调用方法,JIT 就无法内联(反射调用开销大),这也是为什么反射性能差的原因之一。
第 49 题:逃逸分析(JIT 优化)是什么?有哪些优化?
一句话结论
逃逸分析是 JIT 的高级优化:分析对象的作用域,如果对象没有逃逸出方法(即没有被人引用),就可以做栈上分配、锁消除、标量替换等优化。
深度解析
什么是「对象逃逸」?
1 | |
逃逸分析的三大优化:
优化一:栈上分配(Stack Allocation)
1 | |
优化二:锁消除(Lock Elision)
1 | |
优化三:标量替换(Scalar Replacement)
1 | |
开启/关闭逃逸分析(JVM 参数):
1 | |
面试加分回答
逃逸分析是 JIT 的高级优化,它能做的优化(栈上分配、锁消除、标量替换)都能显著减少 GC 压力和同步开销。但要注意:逃逸分析本身也有开销(需要分析对象的作用域),对于短生命周期的小对象,逃逸分析带来的收益可能抵不过分析的开销。另外,逃逸分析在 C2 编译器(Server Compiler)中才生效,C1 编译器(Client Compiler)不做逃逸分析。这也是为什么服务器端的 Java 应用性能比客户端好的原因之一。
第 50 题:JVM 的线程模型是怎样的?和用户线程、内核线程的关系?
一句话结论
HotSpot JVM 的线程模型是 1:1 模型:一个 Java 线程直接映射到一个操作系统内核线程(由 OS 调度)。Java 没有自己实现的用户线程(Green Threads),线程的创建、调度、销毁都由操作系统完成。
深度解析
三种线程模型:
| 模型 | 说明 | 优点 | 缺点 | 代表 |
|---|---|---|---|---|
| 1:1(内核线程模型) | 一个用户线程映射到一个内核线程 | 真正的并行(多核 CPU) | 线程创建/切换开销大 | Java、Linux pthread |
| N:1(用户线程模型) | 多个用户线程映射到一个内核线程 | 线程切换快(不需要系统调用) | 无法真正并行(一个内核线程) | 早期的 Green Threads |
| M:N(混合模型) | M 个用户线程映射到 N 个内核线程 | 兼顾切换速度和并行能力 | 实现复杂 | Go 的 Goroutine |
Java 的线程模型(1:1):
1 | |
为什么 Java 不用用户线程(Green Threads)?
1 | |
Java 能实现「轻量级线程」吗?—— Project Loom(虚拟线程)
1 | |
虚拟线程(Virtual Thread)= M:N 模型:多个虚拟线程映射到少量平台线程(内核线程),兼顾了切换速度和并行能力。
面试加分回答
JVM 的线程模型是1:1 模型(一个 Java 线程对应一个 OS 内核线程),这是 Java 能利用多核 CPU 的根本原因。但这也带来了线程创建/切换开销大的问题(每个线程占 1MB 栈空间,线程切换需要系统调用)。Java 19 引入的虚拟线程(Project Loom)就是为了解决这个问题:虚拟线程是轻量级线程(占用极少内存),由 JVM 调度,底层映射到少量平台线程(内核线程),可以创建数百万个虚拟线程而不会耗尽内存。这是 Java 并发编程的重大突破,未来可能取代线程池成为主流的并发模型。