JVM调优面试考题
06 - JVM调优 · 高频面试考题背诵指南
目录
- Q1:JVM 内存区域划分?各区域存什么?哪些区域会 OOM?
- Q2:JDK8 的元空间(Metaspace)为什么替换永久代(PermGen)?
- Q3:对象的内存布局(对象头 + 实例数据 + 对齐填充)
- Q4:什么是逃逸分析?JVM 的三种逃逸优化?
- Q5:什么是 GC Roots?哪些对象可以作为 GC Roots?
- Q6:三种 GC 算法的原理、优缺点
- Q7:新生代和老年代?对象晋升的条件?
- Q8:CMS 垃圾收集器的工作原理和缺点
- Q9:G1 垃圾收集器的 Region 设计 and 工作原理
- Q10:ZGC 的原理和为什么能实现 <10ms 的 STW?
- Q11:CPU 飙高 100% 的完整排查步骤(结合招行项目)
- Q12:OOM 内存溢出的完整排查步骤(结合医博项目)
- Q13:频繁 Full GC 的排查思路
- Q14:生产环境核心 JVM 参数
- Q15:Arthas 常用诊断命令
- Q16:类加载过程?什么是双亲委派模型?为什么要破坏它?
一、JVM 内存结构
Q1:JVM 内存区域划分?各区域存什么?哪些区域会 OOM?
核心考点:5 大内存区域的职责,线程共享 vs 线程私有,各区域可能的错误。
背诵话术:
JVM 内存区域分为以下几块:
线程私有区域(每个线程独享):
- 程序计数器(PC Register):记录当前线程正在执行的字节码指令的地址(行号)。线程切换后恢复执行时靠它找回位置。它是 JVM 规范中唯一不会 OOM 的区域。
- Java 虚拟机栈(JVM Stack):每调用一个方法,就压入一个栈帧(Stack Frame),栈帧中存储局部变量表、操作数栈、动态链接、方法出口等信息。方法返回时弹出栈帧。
StackOverflowError:递归调用过深,栈超出深度限制(-Xss 参数控制)。OutOfMemoryError:线程数过多时,JVM 无法申请到足够的栈内存。- 本地方法栈(Native Method Stack):与 JVM 栈类似,为 Native 方法(C/C++ 方法)服务。同样可能 SOE/OOM。
线程共享区域(所有线程共享):
4. 堆(Heap):JVM 中最大的内存区域,存储所有对象实例和数组。GC 的主要管理区域。分为新生代(Young,包含 Eden 和 Survivor 区)和老年代(Old)。OutOfMemoryError: Java heap space是最常见的 OOM,如内存泄漏、对象过多。
5. 方法区(Method Area)/ 元空间(Metaspace):存储已加载的类信息(类结构、方法字节码、常量池、静态变量等)。JDK8 以前是”永久代(PermGen)”,JDK8 起改为元空间(Metaspace,使用直接内存)。动态生成大量类(如 CGlib 代理、JSP 编译、大量反射)时可能 OOM:OutOfMemoryError: Metaspace。
Q2:JDK8 的元空间(Metaspace)为什么替换永久代(PermGen)?
核心考点:PermGen 大小固定易 OOM,Metaspace 使用本地内存动态扩展。
背诵话术:
永久代(PermGen)的缺点:
- 大小固定:通过
-XX:MaxPermSize指定,默认只有 64MB 或 256MB。在运行时动态生成大量类(如 Spring/Hibernate 使用大量 CGLIB 代理、部署多个 Web 应用)时,极容易出现java.lang.OutOfMemoryError: PermGen space错误,且很难调优(调大了又浪费,调小了容易 OOM)。- GC 效率低:永久代中的类信息回收(类卸载)条件苛刻,且发生在 Full GC 时,效率低下。
元空间(Metaspace)的优势:
- 使用本地内存(Native Memory):不受 JVM 堆大小限制,默认可以动态增长直到机器物理内存耗尽,大幅减少了 OOM 的发生。可以通过
-XX:MaxMetaspaceSize设置上限。- GC 改进:类信息的回收不再强绑定 Full GC,类卸载效率有所提升。
Q3:对象的内存布局(对象头 + 实例数据 + 对齐填充)
核心考点:Mark Word 的内容随锁状态变化,对象头是 synchronized 锁升级的物理基础。
背诵话术:
Java 对象在堆内存中的布局分三部分:
1. 对象头(Header):
- Mark Word(8 字节):存储对象的运行时元数据,内容随锁状态动态变化:
- 无锁状态:hashCode(31 bit)+ GC 年龄(4 bit)+ 锁标志位(01)
- 偏向锁状态:线程 ID(54 bit)+ Epoch(2 bit)+ 锁标志位(01)
- 轻量级锁状态:指向栈帧 Lock Record 的指针(62 bit)+ 锁标志位(00)
- 重量级锁状态:指向 Monitor 对象的指针(62 bit)+ 锁标志位(10)
- GC 标记状态:空(62 bit)+ 锁标志位(11)
- 类型指针(Klass Pointer,4 或 8 字节):指向该对象所属类的元数据(在元空间中),JVM 通过它确定对象的类型。开启指针压缩(
-XX:+UseCompressedOops,默认开启)时占 4 字节。2. 实例数据(Instance Data):对象的字段值,包括父类继承的字段。引用类型字段占 4 字节(开启指针压缩时),基本类型按各自大小。
3. 对齐填充(Padding):JVM 要求对象起始地址必须是 8 字节的整数倍,不足时用填充字节补齐,这对 CPU 缓存行访问更友好。
Q4:什么是逃逸分析?JVM 的三种逃逸优化?
核心考点:JIT 的运行时优化,减少堆内存分配压力。
背诵话术:
逃逸分析是 JIT 编译器在运行时分析对象的作用域范围的优化技术:如果一个对象的引用不会逃逸出方法范围(不会被其他线程访问、不会作为方法返回值传出),JVM 就可以对该对象进行以下优化:
栈上分配(Stack Allocation):将对象分配在线程的 JVM 栈上,而不是堆上。对象随栈帧的销毁而自动释放,无需 GC 参与,大幅减少 GC 压力和堆内存分配开销。
标量替换(Scalar Replacement):将对象拆解为若干个基本类型的标量(如将
Point(x, y)对象分解为两个 int 字段),直接在栈中存储这些标量,不再创建对象本身,进一步消除了对象头等开销。同步消除(Synchronization Elimination):如果分析出某个锁对象不会逃逸(只有一个线程会访问),则锁操作没有实际意义,JIT 会将
synchronized代码块中的加锁/解锁操作直接消除,提升性能。
二、垃圾回收
Q5:什么是 GC Roots?哪些对象可以作为 GC Roots?
核心考点:可达性分析算法,从 GC Roots 出发不可达的对象是”垃圾”。
背诵话术:
JVM 判断对象是否”存活”,使用的是可达性分析算法(而不是引用计数法):从一系列 GC Roots(根对象) 出发,沿引用链向下遍历,能到达的对象是存活的,不可达的对象将被 GC 回收。
可以作为 GC Roots 的对象:
- JVM 栈(局部变量表)中引用的对象:即正在执行的方法中的局部变量所引用的对象。这是最常见的 GC Root,方法执行期间其局部变量引用的对象不能被回收。
- 方法区中类静态属性引用的对象:
static修饰的字段所引用的对象,只要类不卸载,这些对象就不会被回收。这是常见内存泄漏的来源之一(大对象被 static 字段持有)。- 方法区中常量引用的对象:如字符串常量池中的字符串。
- 本地方法栈(Native Stack)中 JNI 引用的对象:Native 方法中通过 JNI 创建或引用的 Java 对象。
- JVM 内部引用:如基本数据类型对应的 Class 对象、系统类加载器、常驻的异常对象(NullPointerException 等)。
Q6:三种 GC 算法的原理、优缺点
核心考点:三种算法各自解决了什么问题,又引入了什么新问题。
背诵话术:
1. 标记-清除(Mark-Sweep):
- 原理:第一阶段标记所有存活对象,第二阶段清除所有未标记的对象,释放内存。
- 优点:实现简单,不需要移动对象。
- 缺点:回收后内存碎片化严重,大量不连续的内存空间可能导致大对象无法分配(触发 Full GC);标记和清除两次扫描效率较低。
2. 标记-整理(Mark-Compact):
- 原理:标记存活对象后,将所有存活对象向内存的一端移动,然后清理边界以外的空间,消除了碎片。
- 优点:无内存碎片,内存利用率高。
- 缺点:移动对象需要修改所有指向该对象的引用,开销大,STW 时间长,适合老年代(存活对象少,移动成本相对低)。
3. 复制算法(Copying):
- 原理:将内存分为两块相等的区域(From 和 To),每次只使用 From 区,GC 时将 From 区存活的对象复制到 To 区,然后清空 From 区,两区互换角色。
- 优点:无内存碎片,分配速度快(只需移动指针)。
- 缺点:内存利用率只有 50%;如果存活对象多,复制代价大。适合新生代(存活对象少,大部分是垃圾,复制代价小)。
Q7:新生代和老年代?对象晋升的条件?
核心考点:Eden:S0:S1 = 8:1:1,四种晋升条件,动态年龄判断。
背诵话术:
JVM 堆的分代结构:
- 新生代(Young Generation):占堆的 1/3,分为 Eden 区(80%)和两个 Survivor 区(S0/S1,各 10%),比例为 Eden:S0:S1 = 8:1:1。大多数对象在新生代被快速创建和回收(朝生夕死),通过 Minor GC 回收。
- 老年代(Old Generation):占堆的 2/3,存储生命周期较长的对象。通过 Major GC 或 Full GC 回收。
对象晋升老年代的四个条件:
- 年龄阈值(Age Threshold):对象每经历一次 Minor GC 存活下来,年龄 +1。当年龄达到
MaxTenuringThreshold(默认 15)时,晋升老年代。- 大对象直接进入老年代:超过
-XX:PretenureSizeThreshold大小的大对象(如大数组)直接分配在老年代,防止在新生代反复复制。- 动态年龄判断(重要!):Survivor 区中,如果相同年龄所有对象的总大小超过 Survivor 空间的 50%,则年龄大于等于该年龄的对象直接晋升老年代,无需等到 MaxTenuringThreshold。这是防止 Survivor 区满的自适应机制。
- 空间担保失败:Minor GC 前,JVM 会检查老年代剩余空间是否大于新生代所有对象的总大小(保守担保),如果不够,则直接触发 Full GC。
Q8:CMS 垃圾收集器的工作原理和缺点
核心考点:四阶段并发收集,浮动垃圾,内存碎片,ConcurrentModeFailure。
背诵话术:
CMS(Concurrent Mark-Sweep) 是以获取最短停顿时间为目标的老年代收集器,是第一个真正意义上的”并发”GC。
四个工作阶段:
- 初始标记(Initial Mark,STW):仅标记 GC Roots 直接关联的对象,速度极快,但需要 STW。
- 并发标记(Concurrent Mark,与用户线程并发):从初始标记的对象出发,遍历所有可达对象进行标记,耗时最长,但与用户线程并发执行,不影响服务。
- 重新标记(Remark,STW):修正并发标记阶段因用户线程继续运行而产生的标记变动(增量更新),需要 STW,但耗时比初始标记长一些。
- 并发清除(Concurrent Sweep,与用户线程并发):清除死亡对象,释放内存,与用户线程并发执行。
CMS 的缺点:
- 浮动垃圾(Floating Garbage):并发标记和并发清除阶段,用户线程还在运行,可能产生新的垃圾(在已标记为存活的对象之后又变成了垃圾),这些垃圾只能留待下次 GC 再清理。
- 内存碎片:CMS 使用”标记-清除”算法,不整理内存,长时间运行后会产生大量内存碎片,导致大对象无法分配,提前触发 Full GC(串行的 Full GC)。
ConcurrentModeFailure:CMS 在并发清理时,如果老年代剩余空间不足以分配新晋升的对象,会触发Concurrent Mode Failure,退化为 Serial Old 单线程收集,STW 时间极长。
Q9:G1 垃圾收集器的 Region 设计和工作原理
核心考点:Region 取代分代硬边界,可预测的停顿时间,Mixed GC。
背诵话术:
G1(Garbage First) 是 JDK9 的默认 GC,设计目标是可预测的停顿时间(Predictable Pause Time)。
核心设计——Region(区域):G1 将整个堆划分为多个大小相等的独立 Region(默认 1~32MB,由堆大小自动决定),每个 Region 可以动态担任 Eden、Survivor、Old 或 Humongous(大对象专区)等角色。打破了传统分代 GC 的物理边界,使 GC 可以灵活选择”垃圾最多的 Region”优先回收(Garbage First 的得名原因)。
G1 的工作阶段:
- Young GC(Minor GC):仅回收 Eden 和 Survivor Region,存活对象复制到新的 Survivor 或 Old Region。
- 全局并发标记(Concurrent Marking):并发完成全堆的可达性分析,找出垃圾最多的 Region 并排序(类似 CMS 的并发标记阶段)。
- Mixed GC:在 Young GC 的同时,也回收部分”垃圾最多”的 Old Region,逐步清理老年代,避免触发 Full GC。
可预测停顿的原理:G1 通过
-XX:MaxGCPauseMillis(默认 200ms)设置期望停顿时间,每次 GC 时根据各 Region 的垃圾量和预测回收耗时,动态决定本次回收哪些 Region,使停顿时间控制在预期范围内。
Q10:ZGC 的原理和为什么能实现 <10ms 的 STW?
核心考点:着色指针(Colored Pointer)和读屏障(Load Barrier)实现并发转移。
背诵话术:
ZGC 是 JDK11 引入、JDK15 正式商用的低延迟 GC,设计目标是停顿时间不超过 10ms,且不随堆大小增长(可扩展到 TB 级堆)。
核心技术 1——着色指针(Colored Pointer):ZGC 在 64 位指针的高 4 位(第 41~44 bit)上嵌入了 GC 状态信息(Finalizable、Remapped、Marked1、Marked0),这些标记位用来表示对象当前处于哪个 GC 阶段,无需独立的标记位图数组,内存效率更高。
核心技术 2——读屏障(Load Barrier):ZGC 在每次读取对象引用时(
Object ref = obj.field),会插入一小段代码(读屏障),检查指针是否需要修复(如对象是否已经被转移到新地址)。如果对象已被转移,读屏障会自动将指针更新为新地址(自愈),从而实现对象的并发转移,而不需要 STW。为何 STW 极短:ZGC 只有两个极短的 STW 阶段(初始标记和再标记),标记、转移和重映射等耗时操作全部与用户线程并发执行,因此 STW 时间几乎不随堆大小增加而增长,稳定在 <10ms 内。
三、线上排障实战
Q11:CPU 飙高 100% 的完整排查步骤(结合招行项目)
核心考点:top → top -Hp → printf → jstack grep,或 Arthas thread -n。
背诵话术:
在招行流程调度中台曾发生过一次,因接收到非规范的复杂 XML 报文,触发了自研报文解析引擎中的
while死循环,导致服务器 CPU 瞬间飙到 100%。标准排查步骤(纯命令行,无 Arthas):
top:查看系统整体资源情况,找到 CPU 占用最高的 Java 进程,获取其 PID(假设是1203)。top -Hp 1203:打印该进程下所有线程的 CPU 占用,找出占用最高的线程 PID(假设是1215)。printf "%x\n" 1215:将十进制线程 PID 转为十六进制(得到4bf),因为 jstack 输出中的线程 ID 是十六进制。jstack 1203 | grep -A 30 "4bf":过滤出该线程的堆栈信息,清晰展示了死循环发生在报文解析工具类的第 218 行,定位到具体问题代码。使用 Arthas(更快):
1
2 java -jar arthas-boot.jar # 启动 Arthas,选择目标 Java 进程
thread -n 3 # 自动打印 CPU 占用最高的前 3 个线程的堆栈,一步到位10 秒内就能精准定位问题线程。
Q12:OOM 内存溢出的完整排查步骤(结合医博项目)
核心考点:预设 JVM 参数自动 Dump → MAT 分析大对象 → 追溯 GC Root 引用链。
背诵话术:
在医博系统导出大宽表(50 万患者就诊记录)时,原生 Apache POI 一次性将所有数据加载到堆内存,导致老年代被撑满,发生 OOM。
事前准备(必须提前配置的 JVM 参数):
1
2
3
4 -XX:+HeapDumpOnOutOfMemoryError # OOM 时自动生成堆转储文件
-XX:HeapDumpPath=/data/logs/ # 指定 Dump 文件路径
-XX:+PrintGCDateStamps # 打印 GC 日志(便于分析 GC 频率)
-Xloggc:/data/logs/gc.log # GC 日志输出路径事中(发生 OOM 后):
- JVM 在 OOM 时自动生成
.hprof堆转储文件(位于-XX:HeapDumpPath指定路径)。- 如果没有提前配置,可手动执行
jmap -dump:live,format=b,file=heap.hprof <pid>生成(会暂停应用一会儿)。事后(MAT 分析):
- 将
.hprof文件下载到本地,用 Eclipse MAT(Memory Analyzer Tool) 打开。- 查看 Leak Suspects Report(内存泄漏猜想报告),MAT 会自动分析并列出占用内存最大的可疑对象。
- 查看 Dominator Tree(支配树):按占用内存从大到小排列所有对象,医博系统中立刻就能看到
ArrayList<PatientRecord>一个对象占用了 90% 的堆内存。- 右键该对象 → Path to GC Roots:追溯从 GC Roots 到该对象的完整引用链,找到是哪段代码持有了这个大 List,确认是 POI 导出方法中一次性查询并加载所有数据的代码。
- 修复:将 POI 替换为 EasyExcel 的 SAX 流式写出,按批次(每批 1000 条)从数据库读取并写盘,JVM 内存占用降低 95%,OOM 彻底消除。
Q13:频繁 Full GC 的排查思路
核心考点:Full GC 的触发根因,分析 GC 日志。
背诵话术:
频繁 Full GC 会导致长时间 STW,严重影响服务可用性。排查思路如下:
第一步:查看 GC 日志:通过
-Xloggc输出的 GC 日志,或 Arthas 的gc命令,观察 Full GC 的频率、每次 Full GC 的持续时间、回收前后老年代内存变化。常见 Full GC 原因及处理:
- 大量大对象直接进入老年代:检查代码中是否有大数组或大 List 的一次性创建,改为流式处理或分批处理。调大
-XX:PretenureSizeThreshold让大对象进入老年代前先尝试在新生代分配。- 老年代长期存活对象过多:检查是否有内存泄漏(如 ThreadLocal 未 remove、static 集合持有大量对象),用 MAT 分析 Dominator Tree。
- 元空间不足(Metaspace OOM):动态生成了大量类(如 Groovy/BeanShell 脚本、大量 CGlib 代理),检查是否有类加载器泄漏(类无法卸载)。
- System.gc() 被主动调用:在代码中搜索
System.gc()调用,或第三方库触发,加 JVM 参数-XX:+DisableExplicitGC禁止主动触发。- Promotion Failure(晋升失败):Minor GC 时新生代存活对象要晋升老年代,但老年代空间不足,触发 Full GC。考虑适当调大堆大小或老年代比例。
四、JVM 调优参数与工具
Q14:生产环境核心 JVM 参数
核心考点:各参数的含义和常用设置值,一次说清楚。
背诵话术:
生产环境常用的 JVM 启动参数(以 Spring Boot 应用为例):
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 # 堆内存设置(初始 = 最大,避免动态扩容触发 GC)
-Xms4g -Xmx4g
# 新生代大小(通常设为堆的 1/4~1/3)
-Xmn1g
# 每个线程的栈大小(默认 512k~1m,递归深、局部变量多时适当调大)
-Xss512k
# 元空间上限(防止无限扩展占用系统内存)
-XX:MaxMetaspaceSize=256m
# 使用 G1 垃圾收集器(JDK9+ 默认,也可显式指定)
-XX:+UseG1GC
# G1 的期望最大停顿时间(毫秒)
-XX:MaxGCPauseMillis=200
# OOM 时自动生成堆转储文件(必配!)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump.hprof
# 打印详细 GC 日志(含时间戳)
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/gc.log
# GC 日志文件滚动(防止日志文件无限增大)
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20m
Q15:Arthas 常用诊断命令
核心考点:常用命令的功能和使用场景,面试时能流利说出 5~8 个。
背诵话术:
Arthas 是阿里开源的 Java 在线诊断工具,无需重启即可动态分析 JVM:
命令 功能 典型使用场景 dashboard实时展示系统概览(线程、GC、内存、CPU) 快速评估系统整体健康状态 thread -n 3打印 CPU 占用最高的前 N 个线程堆栈 CPU 飙高时快速定位问题线程 thread -b找出当前所有阻塞其他线程的罪魁祸首 排查死锁 jad com.xx.Service反编译指定类为 Java 源码 确认线上运行的字节码是否和预期一致 watch com.xx.Service methodName "{params,returnObj,throwExp}"监控方法的入参、返回值、异常 排查方法逻辑问题,无需加日志重启 trace com.xx.Service methodName追踪方法内部各子调用的耗时分布 定位方法中哪一步最慢 ognl "@com.xx.ConfigHolder@instance.getConfig()"直接执行 Java 表达式,访问静态变量/调用方法 在线查看配置、缓存内容 heapdump /tmp/dump.hprof生成堆内存快照 在线排查内存问题,不需要 jmap classloader查看类加载器信息 排查类加载冲突、Metaspace OOM
五、类加载机制
Q16:类加载过程?什么是双亲委派模型?为什么要破坏它?
核心考点:类加载的三层 ClassLoader,双亲委派向上委托,Tomcat/JDBC SPI 为什么破坏。
背诵话术:
类的完整生命周期:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。
类加载的三个核心 ClassLoader(JDK8):
- Bootstrap ClassLoader(启动类加载器):C++ 实现,加载
JAVA_HOME/jre/lib/下的核心类库(rt.jar、java.lang.*)。它是所有 ClassLoader 的”始祖”,Java 中无法直接引用它(用null表示)。- Extension ClassLoader(扩展类加载器):加载
JAVA_HOME/jre/lib/ext/下的扩展类库。JDK9 被 Platform ClassLoader 取代。- Application ClassLoader(应用类加载器):加载
classpath下用户自己写的类(com.xxx.*)。ClassLoader.getSystemClassLoader()返回的就是它。
双亲委派模型(Parent Delegation):一个类加载器收到类加载请求时,不会自己先加载,而是先把请求委派给父加载器,父加载器无法加载时(在自己的加载范围内找不到该类),才由子加载器自己尝试加载。最终形成一条链:Application → Extension → Bootstrap(找不到再逐层回退)。
为什么需要双亲委派:防止核心类库被篡改。比如用户自己写了一个
java.lang.String,如果没有双亲委派,JVM 可能在用户 classpath 中加载了这个假的 String,造成安全漏洞。有了双亲委派后,Bootstrap ClassLoader 会优先加载rt.jar中真正的java.lang.String,用户的冒牌货永远不会被加载。
破坏双亲委派的三大场景:
- Tomcat(Web 容器的类隔离):Tomcat 需要支持多个 Web 应用独立部署,如果所有应用共享同一个 ClassLoader,则不同应用的同名类会互相冲突。Tomcat 为每个 Web 应用创建独立的
WebAppClassLoader,优先自己加载应用内的类(打破双亲委派,先自己尝试,失败了才委托给父加载器),实现了”应用 A 用 Spring 5、应用 B 用 Spring 6 互不干扰”的类隔离。
- JDBC SPI(ServiceLoader 困境):
DriverManager类在rt.jar中(由 Bootstrap 加载),但具体的数据库驱动(如com.mysql.cj.jdbc.Driver)在应用 classpath 下(由 Application 加载)。Bootstrap 加载的类无法通过常规的向上委托去加载 classpath 下的类。JDK 引入了 线程上下文类加载器(Thread Context ClassLoader):在 Bootstrap 类中通过Thread.currentThread().getContextClassLoader()拿到应用的 ClassLoader,用它去加载具体驱动类,打破了双亲委派。
- 热部署/代码热替换:如 Arthas 的
redefine功能、OSGi 模块化框架,需要在运行时卸载并重新加载某个类,传统的双亲委派模型不允许卸载类,只能通过自定义 ClassLoader 来打破这个限制。







