最近整理 JVM 面试题的时候,我看到几个很真实的搜索词:
background concurrent copying gc freed 7101kb allocspace bytes、arthas启动无法获取jps进程、java.lang.outofmemoryerror: gc overhead limit exceeded。
这几个词放一起,恰恰说明一个现象:很多人不是背不下来八股,而是到了线上环境,面对真实日志和工具时根本不知道从哪里下手。
JVM 面试其实不是为了考你“JVM 有哪几块内存”,面试官真正想看的,是你遇到线上问题时,有没有一套完整的分析路径:内存模型怎么指导你判断对象分配,GC 日志怎么告诉你停顿在哪,STW 什么时候不可接受,Arthas 又该怎么在几分钟内定位到问题代码。
这篇文章不打算写成一本文档手册,而是把 JVM 面试的高频主线拆开,带你把“内存模型、GC、STW、收集器选型、Arthas 排障、高并发项目落地”串成一条链路。读完你可以带走两样东西:一套能用来回答面试题的分析框架,以及一份可以直接抄到项目里的排障清单。
1. 这篇文章真正要解决的问题
先说一个观察:很多 JVM 面试资料是按知识点组织的,比如“堆分几块”“GC Roots 有哪些”“CMS 和 G1 的区别”,但面试官问问题不是按这个顺序来的。
他们更常见的问法是:
- 线上 CPU 飙到 100%,你怎么查?
- 某次大促后接口偶发性超时,你怎么定位?
- 这个服务频繁 Full GC,你怀疑什么?
- 如果让你给一套 JVM 参数,你会怎么给?
你会发现,这些问题背后都是同一个能力模型,需要三层能力:
- 概念记忆层:知道运行时数据区、GC Roots、垃圾收集器这些基础词。
- 机制理解层:理解对象分配、可达性分析、STW 停顿、并发标记这些机制。
- 实战定位层:能通过 GC 日志、Arthas、jstack、heap dump 反推业务代码问题。
大多数读者的问题,不是第一层,而是第三层。
所以这篇文章的核心判断是:JVM 面试通关的关键,不是把八股背得更熟,而是建立一条“内存模型 -> 对象分配 -> GC 机制 -> STW -> 收集器选型 -> 工具定位 -> 业务落地”的链路。后面的章节全部按这条链路展开。
2. JVM 核心概念:从 JVM/JRE/JDK 到运行时数据区
2.1 别再被 JVM、JRE、JDK 这三个词绕晕
这几乎是所有 JVM 面试的第一道送分题,但翻车率不低。
三者的边界其实很清楚:
- JVM(Java Virtual Machine):虚拟机规范的具体实现,负责把字节码解释或编译成机器码执行。
- JRE(Java Runtime Environment):Java 运行时环境,包含 JVM 和你运行 Java 程序所需要的基础类库,比如
rt.jar、java.lang、java.util。 - JDK(Java Development Kit):Java 开发工具包,包含 JRE,外加
javac、jdb、javap、jstack、jmap等开发与诊断工具。
一句话记忆:JDK 是给开发者用的,JRE 是给运行环境用的,JVM 是两者共同依赖的“执行引擎”。
面试时如果被问到“JRE 和 JVM 之间有什么关系”,不要只说“JRE 包含 JVM”,还要补一句:JVM 本身是规范,真正决定 Java 跨平台的是 JVM 的实现,而 JRE 是让 JVM 能跑起来的最小运行集合。这句话能体现你理解的是体系,而不是名称。
2.2 运行时数据区:堆、栈、方法区到底在说什么
JVM 的运行时数据区,是面试里最容易被混用的概念。
很多人把“JVM 内存模型”理解为堆内存划分,其实在面试语境里,“JVM 内存模型”至少有两种指代:
- 运行时数据区(Runtime Data Area):描述 JVM 在运行 Java 程序时把内存划分成了哪些区域。
- Java 内存模型(JMM):描述多线程并发时共享变量的可见性、有序性、原子性语义,核心是主内存与工作内存的抽象。
这两个概念都会考,但问法完全不同。后者更偏并发编程,前者才是垃圾回收和调优的基础。
运行时数据区主要分成以下几个区域:
| 区域 | 是否线程共享 | 主要作用 | 异常类型 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 记录当前线程执行字节码的行号 | 无 |
| 虚拟机栈 | 线程私有 | 保存栈帧,每个方法调用对应一个栈帧 | StackOverflowError / OutOfMemoryError |
| 本地方法栈 | 线程私有 | 服务 native 方法 | StackOverflowError |
| Java 堆 | 线程共享 | 存放对象实例,是 GC 的主要区域 | OutOfMemoryError: Java heap space |
| 方法区 | 线程共享 | 存放类元数据、常量、静态变量等 | OutOfMemoryError: Metaspace(Java 8) |
这里有个高频追问:对象一定分配在堆上吗?
答案是:不一定。
HotSpot 做了很多优化,比如逃逸分析、标量替换、栈上分配。如果一个对象不会逃逸出方法作用域,就可能被拆散成局部变量,直接在栈帧里分配,不进入堆,也不触发 GC。这个答案很加分,因为它说明你不只背了书,还了解 JVM 的实际优化行为。
2.3 Java 8 之后为什么没有永久代了
这也是一个经典追问。
Java 8 之前,方法区在 HotSpot 里用永久代(PermGen)实现,位于堆内。Java 8 开始,永久代被移除,方法区的落地变成了元空间(Metaspace)。
元空间最大的变化是:不再占用堆内存,而是使用本地内存。
原因总结下来有三点:
- 永久代大小很难设置,小了容易
PermGen space,大了又浪费堆空间。 - 永久代在 Full GC 时需要扫描,增加了 GC 停顿,而元空间使用本地内存,不受堆 GC 影响。
- 字符串常量池和类元数据的生命周期管理,在元空间方案里更合理。
面试时可以补充一句:使用元空间后,如果项目中大量动态生成类(比如热部署、字节码增强),仍然可能出现OutOfMemoryError: Metaspace,因为本机内存同样有限。
3. 垃圾回收与 STW:GC 面试的主线逻辑
3.1 判断对象是否存活:引用计数 vs 可达性分析
垃圾回收的第一步,是搞清楚哪些对象可以被回收。
主流 JVM 用的是可达性分析(Reachability Analysis),思路是从一组称为 GC Roots 的根对象出发,沿着引用链向下遍历,能被遍历到的对象就是存活的,遍历不到的对象可以被回收。
引用计数法(Reference Counting)虽然实现简单,但无法解决循环引用问题:A 引用 B,B 引用 A,两者都不再被外部引用,但引用计数永远不为 0。所以主流 JVM 没有选它。
这里面试官经常追问:GC Roots 到底包括什么?
通常包含以下几类:
- 虚拟机栈(栈帧本地变量表)中引用的对象。
- 本地方法栈中 JNI 引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 被同步锁(synchronized)持有的对象。
- JVM 内部引用,如基本类型对应的 Class 对象、常驻的异常对象、系统类加载器。
3.2 分代收集:为什么到现在仍然有效
分代收集的理论基础是两条假说:
- 弱分代假说:绝大多数对象的生命周期很短,比如循环和临时方法里的对象。
- 强分代假说:熬过多次 GC 的对象,越不容易死亡。
所以现代 JVM 把堆划分为新生代和老年代。新生代又细分为 Eden 和两个 Survivor 区(S0、S1)。
对象创建时优先分配在 Eden,Eden 满后触发 Minor GC。存活对象被移动到 Survivor,多次存活后晋升到老年代。这样的设计让 JVM 能用“批量清理 + 少量复制”的方式处理大部分短命对象,而不是每次扫描整堆。
记住一个高频考点:新生代为什么是两个 Survivor,不是一个?
因为复制算法需要一个完整的空闲区域作为疏散目标。如果只有一个 Survivor,每次 Minor GC 把存活对象从 Eden 复制到 Survivor 后,Eden 和 Survivor 各有一部分,下次 GC 的清扫边界很难保证连续。两个 Survivor 交替工作,才能保证某个时刻总有一个区域是干净的。
3.3 Minor GC、Major GC、Full GC 与 STW 的关系
三个词在面试里经常被混着问:
| GC 类型 | 发生区域 | 特点 |
|---|---|---|
| Minor GC / Young GC | 新生代 | 频率高,速度快,也会产生 STW |
| Major GC / Old GC | 老年代 | 一般是 CMS 并发收集老年代阶段 |
| Full GC | 整个堆和方法区 | 停顿时间长,线上最容易引发告警 |
不管哪种 GC,都存在 STW(Stop The World)。STW 的意思是:GC 执行某些阶段时,必须暂停所有用户线程,保证对象引用关系不再变化,否则并发标记会出错。
很多人以为只有 Full GC 才有 STW,这是误解。Minor GC 同样有 STW,只是时间短。进入 2024 年以后,就算是低延迟收集器,也只是把 STW 压缩到极短,并不能彻底消灭。
所以面试时提到 STW,重点不是背定义,而是说清楚三点:
- STW 是因为需要冻结对象引用状态,保证 GC 正确性。
- STW 时间越长,用户请求延迟越高。
- 所有 GC 调优,本质上都在做同一个博弈:换多少吞吐 / 停顿去换一次更全面的回收。
4. CMS、G1、ZGC:收集器选型与机制对比
4.1 CMS:低停顿的先行者,为什么现在不建议新项目用
CMS(Concurrent Mark Sweep)是历史上第一个主打低停顿的收集器。它的核心思路是让大部分标记阶段和用户线程并发执行,而不是像 Parallel 那样全量 STW。
它的工作流程大致是:
- 初始标记:STW,只标记 GC Roots 直接引用的对象,时间很短。
- 并发标记:与用户线程并发,从根对象出发遍历引用链,耗时较长但不停顿。
- 重新标记:STW,修正并发标记期间变化的引用,时间可控。
- 并发清除:与用户线程并发,清理死亡对象。
CMS 的致命问题是:基于标记-清除算法,会产生内存碎片。碎片多了以后,老年代明明有空闲空间,却找不到连续内存分配给大对象,于是触发一次 Serial Old 的 Full GC 兜底,停顿极长,这就是著名的“并发模式失败”(Concurrent Mode Failure)。
所以如果项目是 Java 8 且已经稳定运行 CMS,可以继续用;如果是新项目,建议直接避开 CMS,优先考虑 G1 或 ZGC。
4.2 G1:区域化分代,Java 9 之后的默认选择
G1(Garbage First)把堆划分成一个个大小相等的 Region。它仍然有新生代和老年代的概念,但新生代和老年代不再是物理连续的内存块,而是由一组 Region 动态组成。
G1 的三个关键机制,是面试拉开差距的地方:
- RSet(Remembered Set):记录每个 Region 被外部 Region 引用的信息。这样 GC 时只需要扫描当前 Region 的 RSet,就能找到跨 Region 引用,不必扫描整个堆。
- SATB(Snapshot At The Beginning):在并发标记开始时记录对象图的快照,避免并发阶段对象引用变化导致对象被误回收。
- 混合回收(Mixed GC):不只回收新生代,还会根据停顿预测模型,选择回收一部分老年代 Region,把停顿时间控制在用户设定的
-XX:MaxGCPauseMillis附近。
面试官如果让你对比 G1 和 CMS,不要只背“G1 是区域化、可预测停顿”,要落到机制上:G1 用 Region 打破物理连续的限制,用 RSet 解决跨区扫描,用 SATB 解决并发标记的一致性问题,最后靠停顿预测模型控制 STW。这套逻辑一讲出来,面试官会立刻知道你不是在背答案。
4.3 ZGC:染色指针与读屏障,把 STW 压到 10ms 以内
ZGC 是 JDK 11 引入的实验性收集器,在 JDK 15 转正。它的目标是把 STW 控制在 10ms 以内,无论堆多大。
ZGC 的核心机制有两个:
- 染色指针(Colored Pointer):把 GC 标记信息直接保存在指针的未使用位上,不通过对象头额外记录,减少了内存占用和访问开销。
- 读屏障(Load Barrier):在应用程序读取引用时插入屏障,当对象被移动或处于重定位状态时,读屏障会让线程自己修正引用,相当于把重定位工作分摊到业务线程上。
ZGC 并不是完全不分代,JDK 21 已经引入了分代 ZGC,但它在 JDK 11-17 阶段最显著的特点是:堆扩容对停顿的影响非常小,很适合大堆低延迟场景。
4.4 三个收集器怎么选
| 收集器 | 核心思想 | 主要优点 | 主要缺点 | 典型适用场景 |
|---|---|---|---|---|
| CMS | 并发标记清除 | 低停顿,Java 8 时代主流 | 碎片化、并发失败风险 | 存量 Java 8 系统 |
| G1 | Region 化分代 + 停顿预测 | 可预测停顿,自动管理 Region | 大堆下停顿仍可能偏高 | Java 9+ 默认服务 |
| ZGC | 染色指针 + 读屏障 | STW 极短,支持超大堆 | 内存占用较高、JDK 需较新 | 在线交易、低延迟大堆 |
选型建议很明确:
- 如果追求吞吐量,且对停顿不敏感,Parallel 仍然不差。
- 如果是 Java 8 的新项目,至少用 G1;如果可以升级 JDK,优先考虑 ZGC。
- 如果系统本身不是高并发低延迟场景,不要为了炫技强行切换收集器,每种收集器都需要配套调参和监控。
5. 学会看 GC 日志:从一行日志定位系统问题
5.1 为什么必须打开 GC 日志
没有 GC 日志,线上一次 Full GC 只能靠监控猜。打开 GC 日志,才能知道每次 GC 的原因、耗时、回收了多少内存。
Java 8 常用参数:
-Xms4g -Xmx4g -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/jvm/dump/ -Xloggc:/data/logs/jvm/gc-%t.logJava 9+ 推荐统一使用-Xlog:
-Xms4g -Xmx4g -XX:+UseG1GC -Xlog:gc*:file=/data/logs/jvm/gc-%t.log:time,uptime,level,tags参数含义:
-Xlog:gc*:输出所有 GC 相关日志。file=/data/logs/jvm/gc-%t.log:按时间戳生成日志文件。time,uptime,level,tags:控制日志字段,便于定位停顿事件。
5.2 一条真实日志长什么样
比如这是热词里出现过的典型日志片段:
background concurrent copying gc freed 7101kb allocspace bytes 16(1496kb) l这种日志经常出现在并发类的收集器输出里,比如 Shenandoah 或相关并发复制阶段。很多人在搜索栏直接复制完整日志,就是想找翻译。
看这种日志不需要逐字翻译,抓住三个信息点就够:
freed 7101kb:本次 GC 释放了约 7MB 内存。allocspace bytes:这是对分配空间回收的描述。- 并发阶段:说明这次回收是并发执行的,用户线程没有长时间暂停。
如果用 GCViewer 或在线 GC 日志分析工具打开完整日志,重点看三条曲线:
- 吞吐量(Throughput):可用于生产的时间占比。
- 停顿时间(Pause Time):STW 的最大值和平均值。
- 堆内存占用趋势:确认是否存在对象堆积或泄漏。
5.3 高频报错:GC overhead limit exceeded
搜索词里的java.lang.outofmemoryerror: gc overhead limit exceeded非常典型。
这个错误的意思是:JVM 连续多次 GC,但每次回收后堆内存依然严重不足,GC 花费了大量时间却几乎没有释放空间,JVM 会主动抛出这个异常,保护系统不至于一直空转。
出现这个错误,第一反应不是调大堆内存,而是先确认:
- 堆里是不是堆积了大量未释放的对象,比如缓存、静态集合、ThreadLocal 未清理。
- 是不是有大对象频繁创建,导致老年代迅速占满。
- 是不是存在内存泄漏,比如外部 API 响应对象一直保存在集合里。
先用jmap -dump导出堆,再用 MAT 或 VisualVM 分析大对象和支配树,比盲目改-Xmx更有效。
6. Arthas 线上调优实战:从启动失败到定位问题
6.1 Arthas 到底解决什么问题
Arthas 是阿里巴巴开源的 Java 诊断工具。它能做的几件关键事:
- 在线查看类加载信息和运行方法。
- 查看方法入参、返回值、异常,不用重新部署。
- 查看线程栈,定位 CPU 飙高、线程阻塞。
- 查看堆内存和 GC 情况。
- 导出堆转储文件。
- 在线反编译类,核对线上代码是不是与 Git 一致。
6.2 启动方式与“无法获取 jps 进程”的坑
Arthas 最常见启动方式:
curl -O https://arthas.aliyun.com/arthas-boot.jar java -jar arthas-boot.jar启动后会列出当前机器的 Java 进程,输入编号回车即可。
如果已经知道 PID,也可以直接指定:
java -jar arthas-boot.jar 12345热词里有一条是“arthas启动无法获取jps进程”,这个问题在容器环境里尤其常见。可能原因和解决方案如下:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 看不到 Java 进程 | 目标 Java 进程由其他用户启动 | 切换到对应用户或使用 sudo 执行 |
| 容器里看不到宿主机进程 | PID namespace 隔离 | 在容器内部运行 Arthas,不要跨容器 attach |
| 进程存在但 attach 失败 | JDK 版本与 Arthas 版本不匹配 | 升级 Arthas 到最新版 |
| 端口被占用 | Arthas 默认 telnet 3658、http 8563 | 使用--telnet-port、--http-port换端口 |
真正的核心经验是:遇到“无法获取 jps 进程”时,不要卡在选择列表上,直接先用ps -ef | grep java找到 PID,再用指定 PID 的方式启动。这是最快的绕过方案。
6.3 一个典型的线上调优流程
假设线上服务出现 CPU 飙高、接口延迟变大,用 Arthas 的完整流程如下:
第一步,查看整体状态:
dashboard上面会显示 CPU 占用、内存占用、GC 次数、线程数量。看到 GC 相关指标异常,比如gc.ps_scavenge.count快速上涨,说明对象分配压力大。
第二步,找出最耗 CPU 的线程:
thread -n 3这会列出 CPU 使用率最高的 3 个线程,并打印线程栈。把栈里的业务代码定位出来,几乎就找到了问题入口。
第三步,观察方法调用链:
trace com.example.order.service.OrderService createOrder这会打印createOrder方法内部每个子调用的耗时分布。如果发现某个 SQL 查询耗时几百毫秒,说明数据库中慢查询,而不是 JVM 本身的问题。
6.4 trace 命令能跟踪 URL 调用路径吗
热词里有一个高频问题:Arthas 可以跟踪 URL 的调用路径吗?
准确回答是:可以,但不是像 APM 那样通过 URL 做全链路 Trace。
Arthas 的trace是方法级别的链路跟踪。你可以在 SpringMVC 的 Controller 方法上执行 trace,看到方法内每一层调用的耗时,但不会自动关联 HTTP 请求的 traceId。
如果项目已经接了全链路 Trace(比如 SkyWalking、Zipkin、自研 traceId 中间件),Arthas 的作用是从方法级补充细节。如果没有,可以用 Arthas 组合命令:
watch com.example.controller.OrderController createOrder '{params, returnObj, throwExp}' -x 3这样能看到某个 URL 入口方法的入参、返回值和异常,基本等价于临时加了一行日志。
6.5 导出堆转储
如果怀疑内存泄漏,用 Arthas 导 dump 比jmap更灵活:
heapdump /data/logs/jvm/dump/order-service.hprof导出后用 MAT 分析 dominator tree,能很快找到大对象持有链。
7. 亿级电商高并发场景的 JVM 实战
7.1 高并发项目里,JVM 最容易出问题的三个点
把话题落到亿级电商项目,JVM 最常见的问题不是单机参数,而是三个放大器:
- 大对象与超大数组:比如秒杀接口一次性加载大量商品信息,直接把年轻代打满,Minor GC 频繁触发。
- 线程数膨胀:每个线程默认栈大小
-Xss一般是 1MB,如果线程池配置过大,即使堆内存够,也会因本地内存耗尽而死。 - GC 停顿与请求超时叠加:Full GC 造成几百毫秒停顿,高并发下所有请求同时排队,接口超时告警集中爆发。
看一个真实的连锁反应:一个订单服务用默认线程池,默认堆 4G,大促时每秒下单量翻 10 倍。结果 Eden 区迅速占满,Minor GC 频率从每秒几次飙到每秒几十次,部分请求在 GC STW 期间超时,重试又带来更多流量,最后老年代也被打满,Full GC 出现。这时候不看 GC 日志,光调线程池参数是没用的。
7.2 高并发消费场景:Kafka 消费线程与 JVM 的配合
热词里有“kafka高并发消息处理办法”。从 JVM 角度看,Kafka 消费端最常见的问题是:消费线程阻塞和堆内存压力同时存在。
一种推荐的消费模型是:Kafka 拉取线程只负责拉消息,业务处理放到独立线程池。
@Component public class OrderMessageConsumer { private final ThreadPoolExecutor bizPool = new ThreadPoolExecutor( 16, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1024), new ThreadFactoryBuilder().setNameFormat("order-msg-biz-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() ); @KafkaListener(topics = "order-topic", concurrency = "8") public void onMessage(ConsumerRecord<String, String> record) { bizPool.execute(() -> processRecord(record)); } private void processRecord(ConsumerRecord<String, String> record) { // 业务处理逻辑 } }这里有两个 JVM 相关点:
- 消费线程如果直接做重量级业务操作,容易把 Kafka 消费组线程拖慢,导致 rebalance,进而引发更多线程创建。用独立线程池后,拉取和业务解耦。
CallerRunsPolicy在队列满时会让 Kafka 线程自己执行任务,天然实现背压,避免线程池无界增长。
7.3 Docker 容器部署 Java 程序的 JVM 参数
热词里还有“docker 容器部署的 java 程序”。容器环境最大的坑是:JVM 默认把宿主机内存当作可用内存。
旧版本 JDK 容易出现:容器内存限制 2G,但 JVM 看到宿主机 64G 内存,于是-Xmx没设置时默认分配了 32G,容器直接被 OOM Killer 杀掉。
JDK 8u191+ 引入了UseContainerSupport,默认开启,支持容器内存感知。稳妥的做法是,显式使用百分占比参数:
-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=50.0 -XX:MinRAMPercentage=50.0这样 JVM 按容器内存的 50% 设置堆,不会超限。
如果 Java 进程异常重启,日志位置要区分两种:
- Java 层面的致命错误日志:
hs_err_pid<pid>.log,一般生成在 JVM 工作目录。 - 系统层面被 OOM Killer 杀掉,需要看
dmesg | grep -i oom或容器编排事件。
7.4 高并发系统的一个 JVM 参数参考模板
这里给一个面向高并发交易场景的参考模板,前提是容器内存 4G、JDK 17:
-Xms2g -Xmx2g -XX:MaxRAMPercentage=50.0 -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/jvm/dump/ -Xlog:gc*:file=/data/logs/jvm/gc-%t.log:time,uptime,level,tags -XX:+ExitOnOutOfMemoryError注意:ExitOnOutOfMemoryError会在线程无法申请堆内存时直接退出进程。生产环境是否启用要看团队的运维策略,它能让异常快速暴露,但也意味着进程会退出,需要配合容器自动重启机制。
8. 大厂面试高频题拆解与回答思路
下面这些题目不是某个公司的“原题”,而是蚂蚁、阿里、京东等大厂 JVM 面试高频考点的还原式整理。重点看回答思路。
8.1 线上 CPU 飙升 100%,你怎么定位
推荐回答路径:
top找到 CPU 最高的 Java 进程 PID。top -Hp <pid>找到进程内 CPU 最高的线程 ID。- 把线程 ID 转成十六进制:
printf "%x\n" <tid>。 jstack <pid> | grep -A 50 <十六进制tid>查看线程栈。- 如果使用的是 Arthas,直接
thread -n 3。
定位到具体业务代码后,再分析是死循环、锁竞争、还是频繁 GC。
这个问题的加分点是:不要 jstack 一次就下结论,多次采样并观察线程状态变化,才能确认是持续占用还是偶发飙高。
8.2 一个 Java 服务频繁 Full GC,你怎么排查
推荐回答路径:
- 先看 GC 日志,确认 Full GC 的频率和触发原因。
- 用
jmap -dump:format=b,file=app.hprof <pid>导出堆转储。 - 用 MAT 分析 dominator tree,找到大对象和持有链。
- 结合代码排查:缓存是否无界、ThreadLocal 是否清理、外部数据是否被长期持有。
- 确认不是内存泄漏后,再考虑调整堆大小和收集器参数。
核心判断:频繁 Full GC 通常意味着对象无法及时回收,先找“谁占着内存不放手”,再谈“调多大堆”。
8.3 亿级流量下,你会怎么设计 JVM 调优方案
这道题考察的是全局观。
建议回答结构:
- 先做容量评估:接口 TPS、单请求对象大小、存活对象比例。
- 根据延迟要求选收集器:低延迟选 G1 或 ZGC,高吞吐选 Parallel。
- 设置合理的堆大小,并打开 GC 日志和堆转储参数。
- 做压测,用 Arthas 看 GC 频率、线程状态、方法耗时。
- 结合容器内存限制,用
MaxRAMPercentage避免 OOM Killer。 - 再配合应用层手段:限流、熔断、异步化、缓存,降低 JVM 压力。
最后一个判断很重要:JVM 调优不是改完参数就结束,它是一个“压测 -> 观察 -> 调整 -> 回归”的循环。
8.4 一道高频追问:G1 什么时候会触发 Full GC
G1 的 Full GC 一般出现在两种情况:
- 并发标记或混合回收来不及完成,导致老年代被占满,退化为 Full GC。
- 巨对象(Humongous Object)分配失败,且找不到足够连续 Region。
回答时补一句:G1 的 Full GC 是 STW 的,所以要重点关注-XX:MaxGCPauseMillis是否设置合理,以及大对象是否过多。如果大对象很多,G1 的性能优势会被削弱,可能需要转 ZGC 或调整对象设计。
9. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| GC overhead limit exceeded | 堆内存中对象堆积,GC 循环无法释放空间 | 查看 GC 日志、导出堆 dump 分析大对象 | 定位内存泄漏或大对象持有链,必要时调大堆 |
| Arthas 启动无法获取 jps 进程 | 用户权限、PID namespace 隔离、JDK 版本不匹配 | 使用 `ps -ef | grep java` 找到 PID 直接 attach |
| Docker 容器 Java 进程被 OOM Killer 杀掉 | JVM 未感知容器内存限制,堆分配过大 | 查看dmesg和容器事件 | 使用MaxRAMPercentage限制堆 |
| Gradle 报 gradle version incompatible with gradle jvm version | Gradle 版本和 JVM 版本不匹配 | 查看 Gradle 与 JDK 版本兼容矩阵 | 升级或降级 Gradle / JDK 版本 |
| 接口偶发超时,但 CPU 不高 | GC STW 或线程池队列排队 | 查看 GC 日志停顿时间、线程池状态 | 优化收集器停顿、调整线程池参数 |
| Java 异常重启后找不到日志 | 进程被异常终止,日志位置不明 | 搜索hs_err_pid文件、查看容器事件 | 统一日志目录,配置启动脚本收集 GC 与错误日志 |
10. 最佳实践与工程建议
以下是几个可以在真实项目里落地执行的建议。
10.1 参数模板化,按环境管理
不要把所有服务都扔同一份 JVM 参数。建议按服务类型分类:
- 在线交易服务:低延迟优先,G1,停顿目标 100ms 以内。
- 离线批处理:吞吐优先,Parallel,堆可以适当调大。
- 消息消费服务:注意消费线程与线程池配置。
参数统一放在配置中心或部署模板里,变更走 Git 评审,避免线上各服务参数漂移。
10.2 打开 GC 日志并集中采集
线上服务默认打开 GC 日志,并采集到 ELK 或 Prometheus。GC 日志是事后排查的第一手证据,没有日志,任何性能问题都只能靠猜。
10.3 监控指标要选对
不要只盯着堆内存使用率。更重要的指标是:
- Full GC 次数和耗时。
- GC 停顿 P99 和 P99.9。
- 老年代增长趋势。
- 线程池队列积压。
10.4 变更走灰度
JVM 参数调整不是普通配置变更。一次-XX:MaxGCPauseMillis改小,可能导致 G1 频繁混合回收,反而增加 CPU 开销。建议先在压测环境验证,再灰度上线。
10.5 把 Arthas 纳入标准工具箱
服务器安全策略允许的情况下,把 Arthas 部署到生产环境的独立目录。出现问题时要能在几分钟内 attach 上,而不是临时下载工具、临时申请权限。
10.6 警惕隐藏的内存持有
常见的内存持有问题:
- 静态 Map 缓存只增不减。
- ThreadLocal 未移除,配合线程池复用导致对象长期存活。
- 日志框架保留大对象引用。
- 数据库连接池或 HTTP 连接池配置过大。
这类问题靠调参解决不了,只能靠代码审查和堆 dump 分析。
11. 总结与后续学习方向
如果把这篇博客的内容压缩成一句话,那就是:JVM 面试不是在考记忆,而是在考“能不能把一个线上现象从内存模型一路解释到代码”。
你不需要成为 JVM 源码级专家才能通过大厂面试,但你必须具备一条完整的分析链路:看到 GC 日志能理解停顿,看到 CPU 飙高能快速定位线程,看到内存泄漏能导出堆并分析持有链,面对高并发场景能给出合理的参数和部署方案。
下一步建议分三步走:
- 找一台测试服务器,部署一个自己的 Spring Boot 服务,人工制造一次内存压力,用 Arthas 完整跑一遍 dashboard、thread、heapdump 流程。
- 打开该服务的 GC 日志,用 GCViewer 或在线工具分析一次调优前后的吞吐量和停顿时间。
- 把文章里的大厂面试题,用自己的项目背景真实回答一遍,不要背答案,而是模拟现场思考。
JVM 相关的深水区还有很多,比如类加载机制、JIT 编译参数-XX:CompileThreshold、锁优化、逃逸分析。建议先把这篇文章的排查链路跑熟练,再往底层扩展。收藏本文,下次遇到 JVM 故障,可以按章节顺序快速定位。