JVM调优
**一句话定位:**JVM 调优不是背参数,而是理解"内存怎么分配、垃圾怎么回收、延迟从哪来"这三件事。面试中 90% 的 JVM 调优问题,都能从这三件事推导出来。
很多人一听到"JVM 调优"就头大,觉得是玄学——参数一大堆,收集器七八个,出了问题不知道从哪下手。其实换个角度想:JVM 就是一个自动管理内存的"管家",调优就是跟这个管家商量好"活怎么干、干多快、占多少地方"。
JVM 内存结构:调优的地基
调优的前提是知道内存里装了什么。JVM 运行时数据区可以分成两大阵营:
线程私有
• 程序计数器:当前线程执行的字节码行号,唯一不会 OOM 的区域
• 虚拟机栈:每个方法调用一个栈帧,存局部变量、操作数栈
• 本地方法栈:为 Native 方法服务
线程共享
• 堆(Heap):对象实例和数组的主战场,GC 的主要工作区
• 方法区(元空间):类信息、常量、静态变量、JIT 编译代码
• 运行时常量池:方法区的一部分,存字面量和符号引用
**面试高频陷阱:**JDK 8 之后"永久代"被彻底移除,取而代之的是"元空间(Metaspace)"。元空间使用本地内存(Native Memory),不再受 JVM 堆大小限制,所以 -XX:MaxPermSize 这个参数在 JDK 8+ 已经失效,要用 -XX:MaxMetaspaceSize。
堆内存内部还有更细的划分,这是 GC 分代收集的基础:
| 区域 | 存放内容 | 调优意义 |
|---|---|---|
| Eden 区 | 新创建的对象 | 大部分对象"朝生夕死",Eden 大小直接影响 Minor GC 频率 |
| Survivor 0/1 | 经历过 Minor GC 还存活的对象 | 两个 Survivor 轮流使用,保证总有一个是空的,避免内存碎片 |
| 老年代 | 长期存活的对象、大对象 | Major GC/Full GC 发生在这里,停顿时间长,是调优的重点战场 |
**生动理解:**把堆想象成一个办公室。Eden 是工位区,新人(新对象)先来这上班;Survivor 是试用期通道,熬过几轮考核(Minor GC)就转正进老年代;老年代是正式员工区,人员稳定,清理起来动静大(Full GC)。
垃圾回收与收集器:面试绝对核心
哪些对象可以被回收?
这是面试的开场必问题。判断对象是否可回收有两种经典算法:
-
引用计数法:给对象加个计数器,被引用就 +1,引用失效就 -1,为 0 就回收。缺点:解决不了循环引用(A 引用 B,B 引用 A,谁都不为 0),所以 JVM 不用这个。
-
可达性分析(根搜索算法):从一组叫 "GC Roots" 的根对象出发,沿着引用链往下走,走不到的对象就是"不可达",可以回收。这是 JVM 实际使用的算法。(这个的核心是三色标记法)
**必背:GC Roots 包括哪些?**① 虚拟机栈中引用的对象(局部变量);② 方法区中类静态属性引用的对象;③ 方法区中常量引用的对象;④ 本地方法栈中 JNI 引用的对象。记不住就想:"栈里的、静态的、常量的、本地的"。
四大垃圾收集算法
| 算法 | 做法 | 优缺点与适用场景 |
|---|---|---|
| 标记-清除 | 先标记可回收对象,再统一清除 | 简单但产生内存碎片,碎片多了大对象找不到连续空间就提前触发 GC |
| 标记-复制 | 把存活对象复制到另一块空区域,然后整块清空原区域 | 无碎片、效率高,但空间利用率只有一半。新生代 Survivor 区就是用这个 |
| 标记-整理 | 标记存活对象后,把它们往一端移动,然后清理边界外的内存 | 无碎片但移动对象成本高。老年代常用,因为老年代对象存活率高 |
| 分代收集 | 新生代用复制算法(对象死得快),老年代用标记-清除/整理(对象活得久) | 这是 JVM 的实际策略,不是独立算法,而是组合拳 |
主流收集器对比
收集器是算法的具体实现。面试中常问的是这几个,按 JDK 版本演进梳理:
| 收集器 | 代 | 算法/特点 | 面试要点 |
|---|---|---|---|
| Serial | 新生代 | 单线程复制,STW | 最古老,Client 模式默认。单线程但没有线程切换开销,小内存场景反而快 |
| ParNew | 新生代 | Serial 的多线程版 | 除了多线程,和 Serial 几乎一样。唯一能和 CMS 配合的新生代收集器 |
| Parallel Scavenge | 新生代 | 多线程复制,吞吐量优先 | 目标是"高吞吐量"(CPU 用于用户代码的比例),支持自适应调节策略。JDK 8 默认 |
| Serial Old | 老年代 | 单线程标记-整理 | Serial 的老年代版本,也作为 CMS 失败后的后备预案 |
| Parallel Old | 老年代 | 多线程标记-整理 | Parallel Scavenge 的老年代搭档,JDK 8 默认老年代收集器 |
| CMS | 老年代 | 标记-清除,低延迟 | 以"最短停顿"为目标,并发收集。但有内存碎片、CPU 敏感、Concurrent Mode Failure 等问题,JDK 9 被标记废弃 |
| G1 | 整堆 | Region 划分,标记-整理+复制,可预测停顿 | JDK 9+ 默认。把堆分成多个 Region,可优先回收垃圾多的 Region(Garbage First),支持设置最大停顿时间目标 |
**面试话术模板:**被问"用过哪些收集器"时,不要干巴巴罗列。按这个逻辑说:"JDK 8 默认是 Parallel Scavenge + Parallel Old,主打吞吐量;如果追求低延迟,会用 CMS + ParNew,但 CMS 有碎片问题;JDK 9 之后默认 G1,它用 Region 化管理整堆,能通过 -XX:MaxGCPauseMillis 设置可预测的停顿目标,是现在的主流选择。"
CMS 与 G1 的核心区别
这是面试高频对比题,抓住三个维度:
-
内存布局:CMS 是传统分代(Eden/Survivor/Old 物理隔离),G1 是 Region 化(每个 Region 可以是 Eden、Survivor 或 Old,逻辑分代)。
-
回收算法:CMS 用标记-清除,会产生碎片;G1 整体看是标记-整理,Region 之间是复制,无碎片。
-
停顿模型:CMS 追求"尽量低"的停顿但不可预测;G1 可以设定停顿时间目标(默认 200ms),通过每次只回收部分 Region 来控制停顿。
调优三大指标:你到底在追求什么
调优不是"把所有参数都调到最大",而是在三个指标之间做权衡。面试官问"怎么调优",你先问自己:业务要什么?
| 指标 | 含义 | 典型场景 |
|---|---|---|
| 吞吐量 | 用户代码执行时间 / (用户代码时间 + GC 时间) | 后台计算、批处理、大数据任务。吞吐量越高,CPU 花在业务上的时间越多 |
| 延迟(停顿时间) | GC 时 STW(Stop The World)的时长 | Web 接口、API 服务、交易系统。停顿直接影响响应时间和用户体验 |
| 内存占用 | JVM 堆和元空间占用的内存大小 | 容器化部署、微服务、资源受限环境。内存省了可以多部署实例 |
**不可能三角:**这三个指标不可能同时最优。堆开大了,吞吐量可能上去但单次 Full GC 停顿变长;用低延迟收集器(G1/ZGC),CPU 开销会增大,吞吐量可能下降。调优的本质是:明确业务最看重哪个指标,然后在另外两个上做可接受的让步。
高频 JVM 参数:面试必背清单
参数不用全背,记住下面这些高频的就够应对 95% 的面试。按功能分组记忆:
堆内存设置
# 初始堆大小,建议和 Xmx 设成一样,避免运行时动态扩容带来的抖动
-Xms4g
# 最大堆大小
-Xmx4g
# 新生代大小,等价于 -XX:NewSize 和 -XX:MaxNewSize 同时设置
-Xmn1g
# 新生代与老年代的比例,2 表示 新生代:老年代 = 1:2
-XX:NewRatio=2
# Eden 与一个 Survivor 的比例,8 表示 Eden:Survivor = 8:1(两个 Survivor 共占 2 份)
-XX:SurvivorRatio=8
# 大对象直接进入老年代的阈值(字节),大于这个值的对象不经过新生代
-XX:PretenureSizeThreshold=3145728
# 对象进入老年代需要经历的 Minor GC 次数
-XX:MaxTenuringThreshold=15
收集器选择
# 使用 G1 收集器(JDK 9+ 默认,JDK 8 需手动开启)
-XX:+UseG1GC
# 使用 CMS 收集器(JDK 9 废弃,JDK 14 移除)
-XX:+UseConcMarkSweepGC
# 使用 Parallel Scavenge(JDK 8 默认)
-XX:+UseParallelGC
# G1 设置最大停顿时间目标(毫秒),这是"目标"不是"保证"
-XX:MaxGCPauseMillis=200
# G1 的 Region 大小,1~32MB 之间且为 2 的幂,不设则 JVM 自动根据堆大小计算
-XX:G1HeapRegionSize=16m
GC 日志与元空间
# JDK 8 打印 GC 详情
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
# JDK 9+ 统一日志格式
-Xlog:gc*:file=gc.log:time,uptime,level,tags
# 元空间初始大小和最大值(JDK 8+)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# 发生 OOM 时自动 dump 堆快照,事后分析必备
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
生产环境标配建议:-Xms 和 -Xmx 设为相同值(避免动态扩容);开启 HeapDumpOnOutOfMemoryError(出问题留现场);开启 GC 日志(排查问题的依据)。这三条是线上服务的"安全底线"。
调优实战方法论:从现象到方案
面试官最爱问的场景题:"给你一个线上服务,你怎么调优?"按这个四步走,显得专业又有条理:
首先看监控参数,看GC情况,系统指标(IO,内存占用,CPU,负载),看业务指标(接口响应时间,吞吐,错误率)
接着定位瓶颈,根据不同的现象,找问题,排查
接着进行调整,每次进行一次调整,使用Jmeter或者其他工具看效果
第一步:监控先行,别瞎调
没有数据的调优就是猜。先搞清楚现状:
-
看 GC 情况:Minor GC 频率和耗时、Full GC 频率和耗时、各代内存使用趋势。
-
看系统指标:CPU 使用率、负载、内存占用、磁盘 IO。
-
看业务指标:接口响应时间(P99/P999)、吞吐量、错误率。
常用工具:jstat(实时看 GC 和内存)、jmap(堆 dump 和对象统计)、jstack(线程栈分析)、jcmd(JDK 8+ 推荐的综合工具)、Arthas(阿里开源的在线诊断神器)。
第二步:定位瓶颈,找根因
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 频繁 Full GC | 老年代空间不足、内存泄漏、大对象直接进老年代、元空间满 | jstat 看老年代使用率走势;jmap dump 后用 MAT 分析大对象和泄漏嫌疑 |
| 频繁 Minor GC | Eden 区太小、对象创建速率太高 | 适当增大新生代;检查代码是否有循环内创建大对象的问题 |
| 单次 GC 停顿长 | 堆太大、存活对象多、收集器选择不当 | 换 G1 并设置 MaxGCPauseMillis;检查是否有大对象长期存活 |
| CPU 飙高 | 死循环、频繁 GC、锁竞争 | top 找高 CPU 进程 → top -Hp 找线程 → jstack 看线程栈 |
第三步:小步调整,每次只改一个变量
这是调优的黄金法则。一次改多个参数,出了效果你不知道是哪个起作用,出了问题也不知道是哪个搞砸的。常见调整方向:
-
堆大小不够 → 增大
-Xmx(但要考虑机器物理内存和容器限制)。 -
新生代太小导致频繁 Minor GC → 增大
-Xmn或调整NewRatio。 -
老年代频繁 Full GC 且非泄漏 → 增大老年代比例或换 G1。
-
停顿时间不达标 → 换 G1 并设置合理的
MaxGCPauseMillis。
第四步:压测验证,对比数据
改完参数不是结束,要用压测(JMeter、wrk、ab)验证:吞吐量有没有提升?P99 延迟有没有下降?GC 频率和耗时有没有改善?没有对比数据的调优都是耍流氓。
经典故障排查:面试场景题
OOM(OutOfMemoryError)
OOM常见的有堆溢出,栈溢出,元空间溢出,
OOM 分好几种,面试常考区分:
-
Java heap space:堆内存不够。要么是真的内存不足(调大堆),要么是内存泄漏(dump 分析找泄漏点)。
-
Metaspace:元空间满。常见于动态生成类太多(CGLIB 动态代理、反射、热部署),调大
MaxMetaspaceSize或检查类加载泄漏。 -
GC overhead limit exceeded:GC 花费了超过 98% 的时间但回收了不到 2% 的堆。这是内存泄漏的强烈信号。
-
unable to create new native thread:能创建的线程数达到系统上限,不是堆的问题,是线程太多了。
**排查 OOM 的标准动作:**① 线上开启 HeapDumpOnOutOfMemoryError 自动留现场;② 用 MAT(Memory Analyzer Tool)或 JVisualVM 打开 hprof 文件;③ 看 Dominator Tree(支配树)找占用内存最大的对象;④ 看 Leak Suspects 报告定位泄漏嫌疑;⑤ 结合代码确认是泄漏还是真的内存不足。
- 第一步先看异常日志,判断 OOM 类型,区分堆、元空间、直接内存(操作系统分配,用于存储网络,文件缓冲区数据) OOM,或是容器被 OOM 杀手杀掉。出现问题先保留现场,确认有没有 hprof 堆 dump 和 GC 日志,尽量不要立刻重启服务,防止现场丢失。接着查看监控,观察堆内存、元空间、类加载数量以及 FullGC 情况,如果 FullGC 后内存释放很少,基本就是内存泄漏;如果 GC 能回收,只是峰值打满,大概率是参数不足。拿到 dump 文件后用 MAT 分析,通过支配树找到大对象,追踪引用链定位泄漏点;元空间 OOM 重点查类加载器,直接内存 OOM 查找 DirectByteBuffer。之后到测试环境压测复现问题,验证根因,最后修复代码或者调整 JVM 参数,上线后持续监控内存与 GC 指标。
CPU 飙高,死锁排查
首先利用top指令找到CPU使用最高的Java进程,找到这个进程内占用CPU最高的线程。将线程ID转换成16进制,查看栈信息定位出问题的代码
线上 CPU 飙高,第一步先用top找到 Java 进程 PID,再用top -H看进程里占用 CPU 最高的线程,拿到线程 ID,转成十六进制,通过jstack打印线程栈,定位出消耗 CPU 的线程对应的代码,一般是死循环、大量计算、频繁 GC。如果是线程死锁,同样用jstack,它会直接检测并输出死锁信息,找到互相等待锁的两个线程,看锁对象和代码位置。拿到栈信息后结合业务代码,确认是逻辑死循环还是锁竞争导致死锁,修复后上线观察。
这是面试经典操作题,步骤要背熟:
-
top命令找到 CPU 使用率最高的 Java 进程,记下 PID。 -
top -Hp <PID>找到该进程内 CPU 最高的线程,记下线程 ID。 -
printf "%x\n" <线程ID>把线程 ID 转成十六进制(因为 jstack 里线程号是十六进制的)。 -
jstack <PID> | grep -A 20 <十六进制线程号>查看该线程的栈信息,定位到具体代码行。
频繁 Full GC 但堆没满
首先看 GC 日志,确认 FullGC 触发原因,区分是堆内存不足、元空间扩容、System.gc、大对象分配还是外部容器限制。接着看监控,观察老年代内存走势,如果每次 FullGC 回收很少,基本是内存泄漏;如果回收很多但很快又被打满,大概率是内存参数偏小或者晋升阈值不合理。然后获取堆 dump,用 MAT 分析老年代里存活对象,追踪引用链定位泄漏对象。如果是元空间触发 FullGC,就看类加载数量是否持续上涨,排查类加载器泄漏;如果是大对象频繁分配,检查代码里一次性创建超大数组。最后在测试环境压测复现,修复代码或者调整 JVM 参数,上线后持续观察 GC 指标。
这是个有区分度的问题。Full GC 不一定只在老年代满的时候触发,还要考虑:
-
元空间满:Metaspace 不足会触发 Full GC。
-
显式调用 System.gc():代码里或第三方库调用了,可用
-XX:+DisableExplicitGC禁用。 -
CMS 的 Concurrent Mode Failure:CMS 并发回收时老年代空间不够装新对象,退化为 Serial Old 单线程 Full GC。
-
晋升失败(Promotion Failure):Minor GC 时存活对象要晋升老年代,会进行预检查,但老年代空间不够(即使总空间够但碎片太多装不下连续空间)。
堆 dump 怎么抓取、怎么分析?
抓取堆 dump 有两种方式,一种是预配置参数,在 JVM 启动时加上-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath,发生 OOM 时自动导出 hprof 文件;另一种是线上手动抓取,用jmap -dump:format=b,file=xxx.hprof <pid>命令。拿到 dump 文件后,使用 MAT 工具打开,先看泄漏可疑报告,再查看支配树,找出占用内存最大的对象,顺着引用链定位是谁持有对象导致无法 GC。如果是元空间 OOM,重点查看类加载器;直接内存 OOM 则查找 DirectByteBuffer 对象,最后结合业务代码确认泄漏位置。
面试高频问答速查
Q1:Minor GC、Major GC、Full GC 有什么区别?
Minor GC 只回收新生代,频率高但停顿短;Major GC 只回收老年代(有些语境和 Full GC 混用);Full GC 回收整个堆(新生代+老年代+元空间),停顿最长,是调优要尽量避免的。
Q2:对象什么时候进入老年代?
三种情况:① 经历了 MaxTenuringThreshold 次 Minor GC 还存活(默认 15,CMS 默认 6);② Survivor 区中相同年龄对象大小总和超过 Survivor 空间一半时,年龄大于等于该年龄的对象直接进老年代(动态年龄判定);③ 大对象超过 PretenureSizeThreshold 直接进老年代。
Q3:什么是 STW?所有 GC 都会 STW 吗?
STW(Stop The World)是 GC 时暂停所有用户线程的现象。所有收集器都有 STW,只是长短不同。CMS 和 G1 的并发阶段可以和用户线程并行,但初始标记和重新标记阶段仍然需要 STW。ZGC 和 Shenandoah 把 STW 控制在亚毫秒级,但也不是零停顿。
Q4:G1 的 Region 为什么有 Humongous 区域?
超过 Region 大小一半的对象叫"巨型对象"(Humongous Object),G1 会用连续的 Humongous Region 来存它,而不是走正常的分配流程。巨型对象会直接进入老年代逻辑,且回收比较麻烦,所以代码里要尽量避免创建超大对象。
Q5:怎么判断有没有内存泄漏?
看老年代内存使用趋势:每次 Full GC 后老年代使用率的基线是否在持续上升。如果 Full GC 后使用率一次比一次高,且没有回落,基本就是内存泄漏。然后 dump 堆快照用 MAT 分析具体泄漏对象。
Q6:容器环境下 JVM 调优有什么特别注意的?
JVM 默认会看到宿主机的全部 CPU 和内存,而不是容器的限制。JDK 8u191+ 之后支持 -XX:+UseContainerSupport(默认开启),能自动感知容器的 CPU 和内存限制。但堆大小建议还是手动设置 -Xmx,一般设为容器内存限制的 50%~75%,留够元空间、线程栈和本地内存的空间,否则容易被 OOM Killer 杀掉。
总结:面试答题框架
记住这个答题框架,JVM 调优面试题万变不离其宗:
① 是什么:先讲清楚概念(内存结构、GC 算法、收集器)。
② 为什么:解释背后的原因(为什么分代、为什么 G1 用 Region、为什么会有 STW)。
③ 怎么用:落到实际操作(参数怎么配、问题怎么查、调优步骤是什么)。
④ 权衡:体现深度(吞吐量 vs 延迟 vs 内存,不可能三角,没有银弹)。
JVM 调优不是靠背参数就能搞定的,核心是理解内存分配和垃圾回收的机制,然后用数据驱动决策。面试中只要你能把"是什么、为什么、怎么用、怎么权衡"这四件事说清楚,再结合一两个实际排查案例,JVM 调优这一块就稳了。