首页 / 资讯中心 / 文章详情

JVM面试与线上调优:从内存区域到OOM排查

JVM面试与线上调优:从内存区域到OOM排查 ★ FEATURED ARTICLE
面 JVM 这块我这些年聊过的人没有一百也有八十最直观的感受是简历上写“熟悉 JVM”的人很多能把一句话讲清楚的没几个。大部分人背得滚瓜烂熟的是“堆、栈、方法区”一旦我追问一句“那你说说这个对象到底是怎么从 new 出来到被回收的”回答就开始散架。JVM 面试题看着像八股实际上它是一套能筛出“有没有真正处理过线上问题”的过滤器。参数怎么配、日志怎么读、OOM 怎么定位这些不是背书能解决的。这篇文章我打算把自己这些年答题、面试、以及实际调优中攒下来的东西完整拆一遍从考察逻辑、内存区域、类加载、垃圾回收到参数计算、工具链使用、故障复盘尽量把每道高频题背后的“为什么”讲透。不管你是刚准备面试的新人还是想系统梳理一遍的老手都能从里面找到能直接拿去用的东西。1. JVM 面试题的考察逻辑先弄明白对方在问什么1.1 三类题型的分层与应答策略JVM 相关的提问我习惯把它分成三层。第一层是概念题比如“JVM 内存区域有哪些”“GC Roots 是什么”这类题考察的是你脑子里有没有一张准确的地图。第二层是机制题比如“对象什么时候进入老年代”“双亲委派是怎么实现的”考察的是你知不知道这些机制设计出来是为了解决什么问题。第三层是场景题比如“线上 CPU 打满你怎么排查”“堆内存一直涨但不 OOM 是什么原因”考察的是你有没有真的动过手。这三层的应答策略完全不一样。概念题要的是准确宁可少说也不要瞎编比如程序计数器这个区域你只要说清楚它是线程私有的、记录当前线程执行字节码的行号、并且是唯一一个不会抛 OOM 的区域就已经比很多人强了。机制题要的是因果链你得把“因为什么所以这样设计带来什么代价”讲完整。场景题要的是顺序感先看什么、再看什么、怎么下结论有条理比有答案更重要。我见过不少人栽在第二层。概念背得很熟一问“为什么新生代要用复制算法”答“因为复制算法快”。这个回答不能说错但完全没有触及本质。本质是新生代的对象绝大多数活不过第一次 GC存活对象极少复制成本低同时复制算法不需要考虑内存碎片分配时用指针碰撞就够了。你把“对象朝生夕死”这个前提说出来面试官就知道你理解分代假设而不是在背结论。还有一点很关键面试官问 JVM很多时候不是真的要你精通虚拟机实现而是想确认你具不具备“遇到内存问题不慌”的底子。所以答题时适当带一点实际场景比如“这个参数我一般会跟 -Xms 设成一样避免堆反复伸缩”这种细节比背十条定义都管用。1.2 答题的三段式框架与常见减分项我自己总结了一个三段式结论先行、机制解释、落地验证。拿“什么情况下会触发 Full GC”举例先给结论——常见的有老年代空间不足、元空间不足、显式调用 System.gc()、晋升担保失败、CMS 的并发模式失败等再解释机制比如晋升担保失败是怎么回事为什么新生代 GC 前要检查老年代剩余空间最后补一句落地验证比如“我一般会开 -XX:PrintGCDetails 或者 JDK 9 之后的 -Xlog:gc*从日志里看是全量回收还是并发模式失败触发的”。减分项我列几个最常见的。第一个是概念混淆把方法区等同于永久代。永久代只是 JDK 8 之前 HotSpot 对方法区的一种实现JDK 8 之后换成了元空间用的是本地内存这个变化背后是因为永久代大小难调、容易 OOM、而且和堆一起管理效率不高。第二个是把“栈上分配”说成 HotSpot 真的把对象分配在栈上实际上主流实现靠的是逃逸分析加标量替换对象该在堆上还在堆上只是被拆成了若干局部变量。第三个是虚构调优数据说自己把某系统 GC 停顿从 500ms 降到 50ms一问怎么做的答“调大了堆”。这种回答一旦被追问细节就会露馅不如老老实实讲一个自己真处理过的小问题。面试里最忌讳的是“背得很顺但经不起追问”。每准备一个结论最好自己先往下问三层能答上来再往外说。2. 运行时数据区与对象创建送分题里的隐藏追问2.1 五个区域的职责边界与高频误区运行时数据区这块线程私有的有程序计数器、虚拟机栈、本地方法栈线程共享的是堆和方法区另外还有一块不属于 JVM 规范但实际很重要的直接内存。这五块加一块直接内存每一块的职责和异常类型都得拎清楚。程序计数器是唯一一个在 JVM 规范里没有规定任何 OOM 情况的区域它记录的是当前线程正在执行的字节码指令地址线程切换回来要靠它恢复现场。虚拟机栈描述的是方法执行的内存模型每个方法调用对应一个栈帧栈帧里有局部变量表、操作数栈、动态链接、方法返回地址。栈深度超过限制会抛 StackOverflowError栈扩展申请不到内存会抛 OutOfMemoryError。本地方法栈类似只不过服务的是本地方法。堆是对象实例分配的主要场所也是 GC 的主战场堆不够会抛 OutOfMemoryError: Java heap space。方法区存的是类元信息、运行时常量池、静态变量等JDK 8 之前用永久代实现受 -XX:MaxPermSize 限制JDK 8 之后改成元空间默认不设上限受本地内存约束对应参数是 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize。直接内存不是虚拟机运行时数据区的一部分但 NIO 里的 DirectByteBuffer 会用到它受 -XX:MaxDirectMemorySize 限制默认和最大堆差不多。高频误区有这么几个。一是把“方法区”和“永久代”画等号实际上前者是规范后者是实现。二是认为“字符串常量池一直在方法区”JDK 7 的时候字符串常量池就已经从永久代挪到了堆里这个变化经常被拿来问。三是把“运行时常量池”和“class 文件常量池”混为一谈前者是后者在运行时的落地而且运行时常量池具备动态性String 的 intern 方法就能往里面加内容。我通常会用一个类比帮自己记忆把 JVM 想成一个工厂堆是仓库所有材料成品都堆在那虚拟机栈是每个工人的操作台一人一个互不干扰方法区是工厂的图纸档案室存的是所有产品的设计图程序计数器是每个工人手里的工序单记录干到哪一步了。这个比喻不严谨但用来区分“哪些是线程私有、哪些是共享”足够了。2.2 对象从 new 到丢弃的完整链路“一个对象是怎么创建的”这道题能答完整的人不多。完整链路我拆成五步类加载检查、分配内存、零值初始化、设置对象头、执行构造方法。第一步虚拟机遇到 new 指令先去常量池找这个类的符号引用检查类有没有被加载、解析、初始化过没有的话先走类加载流程。第二步分配内存具体用哪种方式取决于堆是否规整如果用的收集器带压缩整理能力堆是规整的就用指针碰撞把指针往空闲方向挪一段如果堆有碎片就用空闲列表从列表里找一块够大的。第三步把分配到的内存空间都初始化为零值保证对象的实例字段即使不显式赋值也能直接用。第四步设置对象头包括 Mark Word 和类型指针Mark Word 里存哈希码、GC 分代年龄、锁状态标志这些。第五步执行构造方法按代码逻辑真正初始化。并发分配这个问题也常被问。两个线程同时 new指针还没挪完就被另一个线程抢了怎么办主流方案有两种一是 CAS 加失败重试保证更新指针的原子性二是给每个线程在 Eden 里预分一小块私有区域叫 TLAB线程在自己的 TLAB 里分配不需要同步用完了再申请新的。TLAB 默认是开启的参数是 -XX:UseTLAB大小可以通过 -XX:TLABSize 指定一般不需要动。对象在内存里长什么样也是加分项。对象头加实例数据加对齐填充。对象头里 Mark Word 在 64 位机器上占 8 字节类型指针默认开启指针压缩占 4 字节。实例数据按字段类型排列父类字段在前同宽度字段放一起目的是减少 padding。对齐填充保证对象大小是 8 字节的整数倍。指针压缩这个点值得多讲一句为什么堆超过 32GB 之后压缩指针就失效了因为压缩后的引用是 32 位能表示 2 的 32 次方个地址但 JVM 按 8 字节对齐寻址相当于能寻址 4G 乘以 8 字节也就是 32GB。堆超过这个数32 位引用就不够用了。这也是很多“堆越大越好”的想法需要打住的地方。2.3 逃逸分析、标量替换与栈上分配的真实边界“对象一定分配在堆上吗”这道题标准答案是“不一定随着逃逸分析技术的成熟对象有可能在栈上分配”。但这个答案只说对了一半容易被追问打穿。逃逸分析做的是分析对象的作用域有没有逃出方法或者线程方法返回了对象引用叫方法逃逸赋值给静态变量或者被其他线程访问叫线程逃逸。如果分析下来对象没有逃逸虚拟机可以做三件事栈上分配、标量替换、锁消除。重点在于HotSpot 里的“栈上分配”并不是真的在栈帧里划一块内存放完整对象而是通过标量替换把对象拆成若干个基本类型或者引用类型的局部变量直接放在栈上压根不创建对象。所以更准确的说法是逃逸分析是前提标量替换是手段效果上看起来像栈上分配。相关参数是 -XX:DoEscapeAnalysis 和 -XX:EliminateAllocationsJDK 8 之后默认都开着。这里有个坑很多人写测试代码验证栈上分配发现对象还是分配在堆上原因是方法里把对象返回了或者把对象放进了集合逃逸分析判定为逃逸自然就不会优化。想验证的话方法里只做局部计算、不返回引用再用 -XX:-DoEscapeAnalysis 关掉对比才能看出差别。还有一个经常被问的点大对象直接进老年代。相关参数是 -XX:PretenureSizeThreshold单位字节只对 Serial 和 ParNew 这类新生代收集器有效G1 下不生效。设这个参数的目的是避免大对象在 Eden 和 Survivor 之间反复复制复制成本高。实际调优里我很少主动设它因为阈值难定设小了会把生命周期不长的对象提前推进老年代反而加重老年代压力。3. 类加载机制双亲委派不是背出来的3.1 加载、链接、初始化的关键节点类从 class 文件到能用要经过加载、链接、初始化三个阶段链接里又分验证、准备、解析。这里面每个阶段的细节都是面试官喜欢挖的地方。加载阶段做三件事通过类的全限定名获取二进制字节流、把字节流代表的静态存储结构转成方法区的运行时数据结构、在堆里生成一个代表这个类的 Class 对象。注意“获取二进制字节流”没说必须从 class 文件来所以才有从网络、从数据库、从动态生成等各种来源这也是自定义类加载器的前提。验证阶段是为了保证字节流符合规范包括文件格式验证、元数据验证、字节码验证、符号引用验证。这一步在生产环境一般不会出问题但如果你用了字节码增强工具可能会遇到 VerifyError这时候就要回头检查生成的字节码是不是合法。准备阶段是给类变量分配内存并设零值。这里有个经典坑public static int value 123;在准备阶段 value 是 0到初始化阶段才变成 123。但如果是public static final int value 123;编译期就把它放进了常量池准备阶段直接就是 123。这个区别经常被拿来出题。解析阶段是把常量池里的符号引用替换成直接引用可以发生在初始化之前也可以推迟到第一次使用时目的是支持动态绑定。初始化阶段执行类构造器clinit也就是静态变量赋值和静态代码块顺序按源码顺序来。触发初始化的时机有且只有六种new、读取或设置静态字段、调用静态方法反射调用初始化子类时父类先初始化虚拟机启动时的主类MethodHandle 和 VarHandle 解析结果对应的类接口有默认方法时实现类初始化前接口要先初始化。被动引用不触发初始化这个点考得特别多。子类引用父类的静态字段只初始化父类定义数组不会初始化数组元素对应的类引用编译期常量不会触发定义常量的类初始化。这三个例子几乎年年出现。3.2 双亲委派的实现与三次“被破坏”的场景双亲委派的代码其实很短核心就在 ClassLoader 的 loadClass 方法里先检查自己有没有加载过没有就委托父加载器父加载器加载不了再自己调 findClass。这套机制保证了三件事同一个类不会被重复加载、核心类库不会被篡改、类之间有稳定的层次关系。三层加载器分别是启动类加载器、扩展类加载器JDK 9 之后叫平台类加载器、应用类加载器。启动类加载器用本地代码实现负责加载 Java 核心类库扩展类加载器加载扩展目录下的 jar应用类加载器加载 classpath 上的内容。再往下就是自定义加载器。“双亲委派被破坏过几次”是道老题答案一般是三次。第一次是在双亲委派规则成型之前也就是早期 JDK 里用户重写 loadClass 就能绕开。第二次是 SPI 场景核心类库里的接口要调用实现类但核心类库由启动类加载器加载找不到实现类于是引入线程上下文类加载器通过Thread.currentThread().getContextClassLoader()拿到应用类加载器去加载实现。第三次是模块化和热部署场景典型的是某个 Web 容器为了实现应用隔离每个应用一个类加载器优先加载自己目录下的类找不到再往上委托这就把“先父后子”的顺序倒过来了。这里提醒一句回答这道题的时候不要只背“三次”要把每次破坏的动机讲出来。动机都是“原有规则解决不了新问题”这个视角一说层次就上去了。3.3 手写类加载器的实操与注意事项自定义类加载器在面试里经常要求手写。核心是继承 ClassLoader重写 findClass把字节流读进来调 defineClass 生成 Class 对象。下面这段可以直接用public class FileClassLoader extends ClassLoader { private final String classPath; public FileClassLoader(String classPath) { this.classPath classPath; } Override protected Class? findClass(String name) throws ClassNotFoundException { try { String fileName classPath File.separator name.replace(., File.separatorChar) .class; byte[] bytes Files.readAllBytes(Paths.get(fileName)); return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } }这里有几个注意点。第一重写 findClass 而不是 loadClass。loadClass 里包含了双亲委派的逻辑直接重写会破坏规则除非你明确就是要打破它。第二defineClass 要求同一个类名不能重复定义同一个加载器实例里第二次定义同名类会抛 LinkageError。第三不同类加载器加载的同一个类在虚拟机里是两个不同的类互相强转会抛 ClassCastException这个问题在插件化、热部署场景里特别常见。如果想验证类加载器的隔离效果可以写一段代码用两个不同的加载器实例加载同一个 class 文件打印各自的 Class 对象再用比较结果是 false。这个实验我建议每个准备 JVM 面试的人都动手做一遍比看十篇文章都记得牢。4. 垃圾回收判定、算法与收集器选型4.1 可达性分析与引用类型的正确用法判断对象能不能回收主流虚拟机用的是可达性分析从一批叫 GC Roots 的对象出发向下搜索搜索不到的就可以回收。引用计数法虽然简单但解决不了循环引用所以主流实现不用它。GC Roots 包括哪些这道题答得越具体越好。虚拟机栈中引用的对象、本地方法栈中引用的对象、方法区里静态属性引用的对象、方法区里常量引用的对象、被同步锁持有的对象、虚拟机内部引用如基本类型对应的 Class 对象和常驻异常对象、反映虚拟机内部情况的 JMXBean 等。回答的时候可以补一句“实际排查时不用死记用工具看支配树更直接”。四种引用类型是必考题。强引用是最常见的只要引用还在对象就不会被回收。软引用在内存不足时会被回收适合做缓存但要注意软引用缓存也可能在被回收前把堆撑到接近上限触发频繁 GC。弱引用在下次 GC 时就会被回收典型应用是 ThreadLocalMap 里的 Entrykey 用弱引用这样线程池里的线程长时间存活时Entry 的 key 可以被回收但 value 仍然可能泄漏所以用完 ThreadLocal 一定要 remove。虚引用对生存时间没有影响唯一作用是对象被回收时收到一个通知配合引用队列可以做一些清理工作比如直接内存的释放。这里展开说下 ThreadLocal 的内存泄漏这是我真实排查过的问题。某个定时任务用线程池执行代码里往 ThreadLocal 塞了一个大对象做上下文用完忘了 remove。因为线程池里的线程长期存活ThreadLocalMap 的 Entry 数组一直挂在 Thread 对象上key 虽然被弱引用回收了变成 null但 value 还牢牢被 Entry 引用着时间一长老年代就涨上去了。后来加了 try-finally 里 remove问题消失。这个案例我一般会放在面试里讲因为它同时覆盖了引用类型、线程池、内存泄漏三个知识点。4.2 分代假设与常见收集算法分代收集的理论基础是弱分代假说绝大多数对象都是朝生夕死的。基于这个假设虚拟机把堆分成新生代和老年代新生代用复制算法老年代用标记-清除或标记-整理。复制算法把内存分成两块每次只用一块回收时把存活对象复制到另一块然后整块清空。优点是实现简单、没有碎片、分配快缺点是需要预留空间。新生代里不是简单的对半分而是分成 Eden 和两个 Survivor默认比例 8:1:1参数是 -XX:SurvivorRatio8。为什么是两个 Survivor如果只有一个Eden 回收后存活对象放进 Survivor下次回收时 Survivor 里的对象和 Eden 的对象都要处理而且 Survivor 空间会被占满没法继续接收。用两个 Survivor每次回收把 Eden 和一个 Survivor 里的存活对象复制到另一个空的 Survivor然后清空 Eden 和刚才那块 Survivor始终有一块是干净的。标记-清除算法先标记可回收对象然后统一清除缺点是效率不稳定、会产生碎片。标记-整理算法标记完把存活对象往一端挪然后清理边界外的内存没有碎片但移动成本高。老年代对象存活率高用复制算法不合适所以选后两种。对象晋升老年代的路径有四条一是年龄达到阈值参数 -XX:MaxTenuringThreshold默认 15在 Parallel 下是 15CMS 下也是 15注意这个值最大就是 15因为 Mark Word 里只有 4 位存年龄二是动态年龄判定Survivor 中所有同龄对象大小总和超过 Survivor 空间一半时年龄大于等于该年龄的对象直接进老年代三是大对象直接进老年代四是 Survivor 空间不足担保机制把对象转移到老年代。动态年龄判定这个点经常被忽略但它是很多“对象莫名其妙提前晋升”的元凶。举例来说Eden 1.6G、每个 Survivor 0.2G某次 Young GC 后年龄为 2 的对象总大小超过了 0.1G那么年龄大于等于 2 的对象就直接进老年代了根本等不到 15 岁。这也是为什么调优时不能只盯着 MaxTenuringThreshold还要看实际的年龄分布。4.3 主流收集器对比与选型依据收集器这块我整理了张表面试前过一遍基本够用收集器作用区域算法特点适用场景Serial新生代复制单线程STW客户端、小内存Serial Old老年代标记-整理单线程STW配合 Serial或 CMS 后备ParNew新生代复制多线程版 Serial配合 CMSParallel Scavenge新生代复制吞吐量优先自适应调节后台计算、批处理Parallel Old老年代标记-整理多线程吞吐量优先配合 Parallel ScavengeCMS老年代标记-清除并发收集低停顿响应优先JDK 9 后废弃G1整堆标记-整理加复制可预测停顿Region 化大堆、低延迟ZGC整堆并发标记整理停顿极低染色指针超大堆、极低延迟CMS 的四个阶段值得单独说初始标记、并发标记、重新标记、并发清除。初始标记只标记 GC Roots 直接关联的对象很快并发标记从这些对象往下遍历耗时长但和用户线程并发重新标记修正并发期间变化的引用会 STW并发清除和用户线程一起跑。CMS 的缺点有三个对 CPU 敏感并发阶段会占线程产生浮动垃圾并发清除时用户线程还在产生新垃圾只能留到下次标记-清除会产生碎片碎片多了可能触发 Concurrent Mode Failure退化成 Serial Old 做一次全量回收停顿反而更长。G1 的思路是把堆切成大小相等的 Region每个 Region 可以是 Eden、Survivor、Old 或者 Humongous回收时根据停顿预测模型优先回收价值高的 Region这也是它名字的由来。G1 的 Young GC 是全停顿的Mixed GC 会同时回收新生代和部分老年代。关键参数有 -XX:MaxGCPauseMillis 设目标停顿、-XX:G1HeapRegionSize 设 Region 大小、-XX:InitiatingHeapOccupancyPercent 设触发并发标记的堆占用比例默认 45。G1 维护 Remembered Set 来记录跨 Region 引用这个结构占内存通常占总堆的百分之几到十几这也是 G1 的隐性成本。ZGC 是这两年被问得越来越多的。它靠染色指针和读屏障实现并发标记整理停顿时间和堆大小基本无关官方目标是不超过 10ms。JDK 15 转正JDK 21 有了分代版本。回答 ZGC 的时候能把“染色指针把标记信息存在指针里”“读屏障在读取引用时做转发”这两点讲清楚基本就够用了。选型上我的经验是堆小于 4G、追求吞吐用 Parallel堆 4G 到 16G、想要停顿可控用 G1堆特别大、对停顿极其敏感考虑 ZGC。当然这不是绝对标准还要看具体业务的对象分配速率和存活率。4.4 GC 日志实测从日志读出问题面试里如果被问“你会看 GC 日志吗”能说出具体格式的人立刻就和别人拉开差距。JDK 8 常用参数是-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintGCTimeStamps -Xloggc:/path/to/gc.logJDK 9 之后统一日志框架-Xlog:gc*:file/path/to/gc.log:time,uptime,level,tags一条典型的 Young GC 日志大概是这个意思前面是时间戳和 GC 类型方括号里是 GC 前和 GC 后的新生代占用箭头后面是老年代占用最后是本次停顿耗时和总耗时。读日志我关注四个指标Young GC 频率、单次停顿、晋升速率、Full GC 次数。Young GC 每秒好几次说明 Eden 太小或者分配速率太高单次停顿从 20ms 涨到 200ms可能是存活对象变多或者老年代引用扫描变慢老年代占用只涨不降大概率有内存泄漏Full GC 频繁先看是不是元空间或者大对象问题。有个细节很容易被忽略GC 日志里的“Allocation Failure”只是表示分配失败触发了 GC不是错误。新人看到这个词容易慌其实它是最常见的 Young GC 触发原因。线上环境我一般会常开 GC 日志文件按大小滚动保留最近若干份。出事的时候有历史日志比事后猜有用得多。5. 调优与故障排查面试里的加分项5.1 参数体系与内存容量计算JVM 参数这东西面试官不太会考你背多少而是会问“为什么这么设”。我把常用参数分成四类容量类、收集器类、日志类、应急类。容量类里最核心的是 -Xms 和 -Xmx我习惯设成一样大避免堆动态伸缩带来的额外开销和抖动。新生代用 -Xmn 或者 -XX:NewRatio 控制-XX:NewRatio2 表示老年代是新生代的 2 倍也就是新生代占堆的 1/3。Eden 和 Survivor 的比例用 -XX:SurvivorRatio8。元空间用 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize前者是首次触发元空间 GC 的阈值不是初始大小这个点很多人搞错。直接内存用 -XX:MaxDirectMemorySize。应急类里最关键的是 -XX:HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPathOOM 的时候自动 dump这是排查内存问题的救命参数线上必开。还有 -XX:ExitOnOutOfMemoryErrorOOM 后直接退出交给容器拉起避免进程半死不活。来算一个具体例子。假设堆设 4GNewRatio 用默认 2那么新生代约 1.33G老年代约 2.67G。新生代里 Eden 和两个 Survivor 按 8:1:1 分Eden 约 1.09G每个 Survivor 约 136M。这时候大对象阈值怎么定如果想避免大对象在 Survivor 里来回复制可以把 PretenureSizeThreshold 设在 136M 以下但实际业务里很少有这么整的对象所以这个参数更多是理论上的。更实际的是看晋升速率如果 Young GC 后老年代每次涨 50M每秒一次那老年代 2.67G 大约 53 秒就会被填满这时候要么加堆要么查为什么有这么多对象活过新生代。换到 G1假设堆 8GRegion 大小默认是根据堆大小算的目标是约 2048 个 Region所以 8G 除以 2048 约等于 4MRegion 大小取 2 的幂就是 4M。Humongous 对象是超过 Region 一半的对象也就是超过 2M 的对象会占用连续多个 Region这类对象多的时候要留意容易造成碎片和提前触发并发标记。5.2 工具链使用与现场取证顺序排查问题工具用对顺序很重要。我一般的顺序是先看整体状态再看线程再看堆最后 dump。整体状态用 jps 加 jstat。jps -lvm 列出当前用户的 Java 进程和启动参数一眼能看出堆配了多大、用了什么收集器。jstat -gcutil 1000 10 每秒打一次看 Eden、Old、Metaspace 的使用率和 GC 次数、耗时。如果 Old 区使用率持续上涨且 Full GC 后不下降基本可以锁定内存问题。线程问题用 jstack。CPU 打满的排查流程是top -Hp 找到占 CPU 高的线程 idprintf %x 转成十六进制再到 jstack 输出里搜 nid0x那个值就能定位到具体代码行。死锁也是用 jstack 看输出里会直接告诉你 found one Java-level deadlock。堆的问题用 jmap。jmap -histo:live 看存活对象的直方图按类统计实例数和占用大小能快速发现哪个类的对象数量异常。jmap -dump:formatb,fileheap.hprof 导出堆快照然后用 MAT 之类的工具分析看支配树找最大的对象用泄漏 suspects 报告找可疑点。注意 dump 会 STW线上大堆要谨慎最好在低峰期做或者用 -XX:HeapDumpOnOutOfMemoryError 让它在 OOM 时自动 dump。参数查看用 jinfo -flags 能看到实际生效的参数值有时候启动脚本里配了但被后面的参数覆盖了用这个一查便知。这里分享一个实操心得不要等出事了才去学工具。平时在测试环境写个会 OOM 的小程序把整套流程走一遍包括加参数、复现、dump、用 MAT 打开、找泄漏点走一遍胜过看十篇教程。我当年第一次看 MAT 的支配树完全不知道从哪下手后来逼着自己分析了三个 dump 文件才慢慢有感觉。5.3 一次堆内存溢出排查的完整复盘说个我处理过的案例。某业务系统运行几天后堆内存持续上涨最后 OOM重启能撑几天然后继续。现象很典型老年代使用率只涨不降Full GC 频率越来越高但每次回收后 Old 区降不下来多少。第一步确认现象。用 jstat 观察了半小时Old 区从 40% 涨到 70%期间发生两次 Full GC回收后只降到 65%确认是内存泄漏而不是单纯的堆不够。第二步拿证据。因为之前配了 HeapDumpOnOutOfMemoryError等它再次 OOM 后拿到了 dump 文件。同时用 jmap -histo:live 看了下当时的对象分布发现某个业务实体类的实例数异常有几十万个而正常情况应该只有几千。第三步分析 dump。用 MAT 打开看支配树发现一个静态的 ConcurrentHashMap 持有了大量该实体对象key 是查询条件value 是查询结果。代码里本意是做本地缓存加速查询但没设过期时间也没设容量上限查询条件又千变万化缓存只增不减。第四步修复。把本地缓存换成带容量限制和过期策略的实现同时给关键缓存加了监控指标。上线后观察一周Old 区使用率稳定在 50% 左右Full GC 基本消失。这个案例里面试官如果追问“你怎么确定是这个 Map 的问题”我会回答MAT 的支配树里这个 Map 的 retained heap 占了总堆的 60% 以上点开看它的引用链一路指向那个静态字段再结合代码里确实有只 put 不 remove 的逻辑证据链就闭合了。排查内存问题最怕的是猜一定要有 dump 和引用链做支撑。6. 高频问题速查与表达避坑6.1 二十个高频问题的答题要点我把这些年被问得最多的问题整理成了一张速查表每个问题给一句核心要点展开的时候按三段式往里填就行。问题答题核心要点JVM 内存区域有哪些线程私有三块加共享两块加直接内存重点说异常类型对象创建过程类加载检查、分配、零值、对象头、构造方法五步对象一定在堆上吗不一定逃逸分析加标量替换不是真的栈上分配什么时候进老年代年龄阈值、动态年龄、大对象、担保机制四条路GC Roots 有哪些栈引用、静态属性、常量、JNI 引用、锁对象等四种引用区别强软弱虚回收时机和典型用途为什么新生代用复制算法对象朝生夕死存活少无碎片分配快为什么两个 Survivor保证总有一块干净空间接收存活对象类加载过程加载、验证、准备、解析、初始化重点说准备阶段零值双亲委派及破坏三次破坏的动机分别是规则未成型、SPI、模块化热部署如何自定义类加载器继承 ClassLoader 重写 findClass注意隔离和重复定义CMS 的缺点CPU 敏感、浮动垃圾、碎片导致并发模式失败G1 的特点Region 化、可预测停顿、RSet 成本、Mixed GCZGC 为什么低延迟染色指针加读屏障并发标记整理停顿与堆大小无关常用调优参数Xms/Xmx 相等、Metaspace、HeapDump、GC 日志怎么读 GC 日志看频率、停顿、晋升速率、Full GC 次数四个指标CPU 打满怎么排查top 找线程、转十六进制、jstack 定位代码内存泄漏怎么排查jstat 看趋势、jmap 直方图、dump 加 MAT 支配树线上 OOM 怎么预防HeapDumpOnOutOfMemoryError、监控、压测、容量评估你做过什么调优讲真实小案例说现象、证据、动作、结果不要编大数字这张表建议自己先遮住右列对着左列口述一遍卡壳的地方就是薄弱点。口述比默写有效因为面试是说出来不是写出来。6.2 面试表达的三个禁忌与自检清单第一个禁忌是绝对化表达。比如“对象一定分配在堆上”“Full GC 一定停顿很久”“G1 一定比 CMS 好”。JVM 里很少有绝对的事加个“通常”“在主流实现里”“取决于配置”会稳妥很多也显得你真的理解边界。第二个禁忌是只背结论不讲条件。比如被问“什么时候触发 Full GC”只答“老年代满了”是不够的至少要补上元空间不足、显式调用、晋升担保失败、并发模式失败这几种。条件越完整可信度越高。第三个禁忌是虚构数据。说自己把某系统 GC 停顿从 800ms 优化到 30ms面试官追问“原来用什么收集器、堆多大、对象分配速率多少、改了什么参数、为什么这个参数能带来这个效果”答不上来就是硬伤。不如讲一个真实的小优化比如“把 Xms 和 Xmx 设成一样减少了堆伸缩带来的抖动”虽然不惊艳但经得起问。最后给自己列个自检清单内存区域能不能画出图并说出每块的异常类型对象创建五步能不能不打磕巴地说完类加载五个阶段和初始化六个时机能不能说全四种引用能不能各举一个实际用途三种收集算法和主流收集器的对应关系能不能对上常用参数能不能说出至少八个并解释为什么这么设CPU 打满和内存泄漏的排查步骤能不能按顺序讲出来。这七条都过了JVM 这块的面试基本不会翻车。我在实际带人和被面的过程中体会最深的一点是JVM 知识的价值不在于面试那二十分钟而在于某天凌晨告警响起时你能不能比别人快十分钟定位到问题。那些参数和日志平时看着枯燥真用上一次就回本了。如果时间有限我建议优先把内存区域、对象生命周期、GC 日志阅读和 jstack/jmap 的使用这四块吃透投入产出比最高。剩下的收集器细节可以在遇到具体问题时再往深里挖带着问题学比从头到尾啃文档效率高得多。
阅读完成 · 觉得有帮助?
咨询建站