ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JVM面试真正考什么:运行时数据区、类加载、G1与OOM排查

JVM面试真正考什么:运行时数据区、类加载、G1与OOM排查

面试过不少人,简历上写着"熟悉JVM",聊到内存模型能背出七大区域,可一旦问到"你们线上堆多大、用的哪个收集器、Full GC一天几次、最近一次OOM是怎么定位的",话头立刻就断了。JVM面试题从来不是背诵题,它更像一道筛子,筛的是你有没有真正在生产环境里跟内存、线程、垃圾回收打过交道。我整理这套内容,目的不是给你一份标准答案清单,而是把那些高频问题背后的考察意图拆开,告诉你每道题面试官真正想听什么、答到什么颗粒度算过关、哪些细节是加分项。不管你是在准备面试,还是刚接手一个需要排查内存问题的服务,下面这些内容都能直接用上——从运行时数据区的划分逻辑,到类加载的双亲委派,再到G1的回收流程和线上OOM的完整排查链路,我会把原理、参数、工具和踩过的坑一并说清楚。

1. 面试官问JVM时,实际在考察什么

1.1 背概念和懂原理的分水岭

大多数人准备JVM面试的方式是找一份"JVM面试题大全",从"什么是JVM"开始往下背,背到"什么是逃逸分析"结束。这种方式能应付初级岗位的初筛,但稍微有点经验的面试官,两三个追问就能分辨出你是真懂还是临时抱佛脚。

分水岭通常出现在这些追问上:你说堆分为新生代和老年代,那Eden和两个Survivor的比例为什么默认是8:1:1?你说对象在Eden分配,那多大的对象会直接进老年代?你说CMS用标记清除,那它为什么会产生浮动垃圾?你说G1可以预测停顿,那它靠什么机制做到的?这些问题没有一个是靠背能答出来的,它们的共同点是——答案藏在"设计动机"里,而不是"结论"里。

我在实际面试别人时的判断标准很简单:能不能把一项技术的存在理由讲清楚。比如问"为什么需要双亲委派",答"为了保证类只被加载一次"只能算及格,能补上"防止核心类库被篡改,比如你自己写一个同名的String类,如果没有双亲委派,应用类加载器先加载了你的版本,整个运行时就乱了"才算过关。再往深一层,能说出"双亲委派在SPI、热部署、模块化场景下必须被打破,以及怎么打破"的,基本可以判定是真做过东西的。

准备JVM面试的正确顺序不是"从概念到概念",而是"从问题到机制再到概念"。先想清楚这项机制解决的是什么问题,概念自然就挂上去了,而且不容易忘。

1.2 三类问题的应答策略

把常见的JVM面试题归归类,其实就三类,每类的应答策略完全不同。

第一类是结构描述型,比如"讲讲JVM的内存区域划分"。这类题考察的是你的知识框架是否完整、边界是否清晰。答这类题最忌讳平铺直叙地报菜名,正确的做法是先给一个划分维度——按线程共享与否切一刀,共享的是堆和方法区,线程私有的是程序计数器、虚拟机栈、本地方法栈,然后再逐个展开每个区域存什么、会抛什么异常。有了维度,面试官会觉得你脑子里是有结构的,而不是背了一串名词。

第二类是机制解释型,比如"对象什么时候被回收""类加载的过程"。这类题考察的是因果链条是否闭合。答这类题要把"触发条件—执行过程—结果"讲完整。以对象回收为例,触发条件是可达性分析发现对象到GC Roots不可达,执行过程包括两次标记和finalize方法的自救机会(虽然现在基本不推荐依赖它),结果是对象内存被回收。链条完整,才算答清楚。

第三类是场景排障型,比如"线上服务频繁Full GC怎么排查""CPU 100%怎么定位"。这类题是最能拉开差距的,因为它没有标准答案,考察的是你的排查思路和工具熟练度。答这类题的核心是给出可执行的步骤序列,而不是罗列工具名。先说怎么确认现象(看GC日志、jstat),再说怎么缩小范围(是老年代增长快还是元空间增长快),然后说怎么定位根因(dump堆、MAT分析支配树),最后说怎么验证修复(改参数或改代码后观察指标)。

下面的表格把这三类问题的考察重点和常见失分点对照了一下,准备的时候可以拿来自查。

题型考察重点常见失分点建议答法
结构描述型知识框架完整性只报名称不讲用途,区域边界混淆先给划分维度,再逐区域展开
机制解释型因果链条闭合度只讲结论不讲触发条件,漏掉中间步骤按"条件—过程—结果"三段式
场景排障型实战思路与工具熟练度只报工具名不给步骤,不给验证方法按"确认现象—缩小范围—定位根因—验证修复"

1.3 别把JVM当成一个孤立的知识块

还有一个容易被忽略的点:很多面试官问JVM,其实是在借JVM考察你对Java语言特性的理解深度。比如问"String字符串常量池在哪",表面问内存区域,实际在考你对字符串不可变性和intern机制的理解;问"ThreadLocal会不会内存泄漏",表面问内存,实际在考你知不知道ThreadLocalMap的Entry是弱引用、key被回收后value为什么还挂着;问"反射为什么慢",表面问性能,实际在考你对类加载、方法调用链路和JIT的理解。

所以准备的时候不要只盯着"JVM"这三个字,要把JVM和语言特性、并发、框架原理串起来看。你在回答"元空间"的时候顺带提一句"JDK 8把永久代换成元空间,主要是永久代大小固定、容易OOM,而且GC效率低,元空间用本地内存,默认不设上限,但可以用-XX:MaxMetaspaceSize限制",这种带上下文延伸的回答,比干巴巴的定义有价值得多。

2. 运行时数据区:最容易背错的那几个角落

2.1 线程私有与共享区域的划分逻辑

运行时数据区是JVM面试的必考题,但很多人背了七大区域却说不清它们之间的关系。我用一个划分维度帮你把这块理顺:按生命周期和线程归属切。

线程私有的三个区域——程序计数器、虚拟机栈、本地方法栈——生命周期与线程相同,线程创建时分配,线程结束时销毁。这三个区域不需要考虑并发安全问题,因为它们不共享。线程共享的两个区域——堆和方法区(JDK 8之后是元空间)——生命周期与JVM进程相同,是所有线程共用的,所以才有并发和GC的问题。

这个划分不是随便定的,背后是设计上的必然。程序计数器记录当前线程执行到哪条字节码指令,每个线程的执行进度都不一样,必须私有,否则线程切换回来就找不到位置了。虚拟机栈存的是栈帧,栈帧里有局部变量表和操作数栈,方法调用是线程私有的行为,栈当然也得私有。而堆要存对象,对象是跨线程共享的,方法区存类的元信息,也是全局共享的。

下面这张表把各区域的细节整理清楚了,重点看"存放内容"和"异常"两列,这两列是面试高频追问点。

区域线程归属存放内容可能抛出的异常
程序计数器私有当前线程执行的字节码行号不会抛OOM
虚拟机栈私有栈帧(局部变量表、操作数栈、动态链接、返回地址)StackOverflowError、OOM
本地方法栈私有native方法调用信息StackOverflowError、OOM
堆共享对象实例、数组OutOfMemoryError: Java heap space
方法区/元空间共享类元信息、运行时常量池、静态变量(JDK 8后静态变量随Class对象在堆中)OutOfMemoryError: Metaspace
直接内存共享(堆外)NIO的DirectByteBufferOutOfMemoryError: Direct buffer memory

有几个细节特别容易答错,我单独提一下。程序计数器是唯一一个不会抛OOM的区域,这个结论很多人知道,但被问到"为什么"就卡住了——因为它只存一个行号,占用的内存是固定的,JVM规范甚至允许它在执行native方法时为空。虚拟机栈和本地方法栈在HotSpot里是合并实现的,如果你在面试时被问到"这两个栈有什么区别",可以坦白说在HotSpot中二者合二为一,参数都是-Xss控制,但规范上它们是两个独立的概念。方法区是规范,元空间是HotSpot在JDK 8之后的实现,把这两个概念混为一谈是常见的扣分点。

2.2 栈帧里到底装了什么

虚拟机栈这个区域,面试官最喜欢往下钻一层问"栈帧里有什么"。标准答案是四部分:局部变量表、操作数栈、动态链接、方法返回地址。但光答这四个名字不够,得说清楚每个是干什么的。

局部变量表存放方法参数和方法内定义的局部变量,单位是槽(Slot),一个Slot在32位虚拟机里是4字节,64位的long和double占两个Slot。这里有个经典问题:"局部变量表里放的是对象本身还是引用?"答案是引用,对象在堆上,局部变量表里存的是指向堆的引用(准确说是对象的引用或能定位到对象的数据)。还有个小坑:局部变量表的大小在编译期就确定了,写在字节码的max_locals属性里,运行期不会变。

操作数栈是方法执行时的工作区,字节码指令从这里取操作数、把结果压回去。比如执行iadd,就是从栈顶弹出两个int相加,再把结果压回栈顶。每个方法的操作数栈最大深度也是编译期确定的,写在max_stack属性里。

动态链接存的是指向运行时常量池中该栈帧所属方法的引用。为什么叫"动态"?因为一个方法要调用另一个方法,这个调用关系在编译期只是一条符号引用,要等到运行期才解析成直接引用,这个解析过程就是动态链接。这也解释了为什么Java支持多态——方法调用的目标在运行期才能确定。

方法返回地址存的是方法退出后应该回到哪里。方法退出有两种方式:正常返回(return指令)和异常退出。正常返回时,返回值会压入调用者的操作数栈,返回地址指向调用者的下一条指令;异常退出时,返回地址要靠异常表来确定。

面试时如果被问到"栈溢出和堆溢出分别什么表现",可以这样答:栈溢出通常是递归太深或局部变量太多,抛StackOverflowError,报错信息里能看到方法调用栈;堆溢出是对象太多或太大,抛OutOfMemoryError: Java heap space,需要dump堆分析。两者一个发生在栈帧入栈时,一个发生在对象分配时,排查手段完全不同。

2.3 对象在堆里的创建与布局

"对象怎么创建的"也是高频题。完整流程分五步:类加载检查、分配内存、初始化零值、设置对象头、执行构造方法。每一步都有可追问的细节。

类加载检查:虚拟机遇到new指令,先去常量池找这个类的符号引用,检查类是否已经被加载、解析、初始化过,没有就先触发类加载。

分配内存:根据堆是否规整,分两种方式。规整的堆(比如用标记整理或复制算法的收集器)用指针碰撞,一个指针分隔已用和空闲内存,分配就是把指针挪一下;不规整的堆(比如CMS用标记清除)用空闲列表,维护一个可用内存块列表,分配时找一块够大的。这里还有个并发问题——多个线程同时分配内存,解法有两种:CAS重试保证原子性,或者每个线程预先在Eden分配一块私有内存(TLAB),分配时先在自己的TLAB里做,用完了再申请新的。TLAB这个点在面试里是加分项,能说出来说明你读过实现层面的东西。

初始化零值:内存分配完,虚拟机把这块内存全部置零(不包括对象头),所以对象的字段即使不赋初值也能直接用,因为已经有零值了。

设置对象头:对象头包含两部分,Mark Word和类型指针。Mark Word存哈希码、GC分代年龄、锁状态标志这些运行期数据;类型指针指向方法区里该类的元数据,用来确定这个对象是哪个类的实例。如果是数组,对象头里还要多一块存数组长度。

执行构造方法:这一步才真正按照代码里的赋值逻辑初始化对象,执行完<init>方法,一个可用的对象才算诞生。

对象的内存布局也是常考点,三部分:对象头、实例数据、对齐填充。对齐填充是因为HotSpot要求对象起始地址必须是8字节的整数倍,不够就补几个字节。"对象多大"这类问题,可以用JOL(Java Object Layout)工具实测,比背理论靠谱,比如一个只含一个int字段的对象,在64位虚拟机上开启压缩指针是16字节,不开是24字节,差别就在对象头和对齐填充上。

3. 类加载机制:双亲委派不是万能答案

3.1 加载到初始化的五个阶段,哪一步最容易答错

类加载的五个阶段——加载、验证、准备、解析、初始化——大多数人能背下来,但最容易答错的是"准备阶段"和"解析阶段"。

准备阶段的工作是给类变量(static变量)分配内存并设置零值。注意关键词:类变量不是实例变量,零值不是初始值。比如static int value = 123,在准备阶段value的值是0,到初始化阶段执行<clinit>时才变成123。但有个例外:如果类变量是final修饰的常量(static final int value = 123),编译器会把它放进常量池,准备阶段就直接赋成123了。这个例外是面试常考点。

解析阶段是把常量池里的符号引用替换成直接引用的过程。符号引用是一组字面量,比如类的全限定名;直接引用是直接指向目标的指针或偏移量。为什么要有这个转换?因为在编译期,类之间的依赖关系还只是名字,只有到运行期真正加载了类,才能知道具体的内存地址。解析的时机比较灵活,可以在初始化之前,也可以在初始化之后(为了支持动态绑定)。

初始化阶段是执行<clinit>方法的过程,这个方法由编译器自动生成,把类变量的赋值语句和静态代码块合并到一起执行。类的初始化有严格的触发条件,"主动引用"才触发初始化,"被动引用"不触发。这个区分是经典陷阱,后面单独说。

加载阶段本身反而没什么可深挖的,无非是通过类的全限定名获取二进制字节流、把字节流代表的静态存储结构转成方法区的运行时数据结构、在堆中生成一个Class对象作为访问入口。面试时如果有人问你"加载阶段干了什么",把这三件事说清楚就行。

3.2 双亲委派解决什么问题,什么时候必须打破

双亲委派的工作流程是:一个类加载器收到加载请求,先委派给父加载器,父加载器再委派给它的父加载器,一直到顶层的启动类加载器;只有当父加载器反馈自己无法完成加载(在自己的搜索范围内找不到这个类),子加载器才尝试自己去加载。

这个机制解决两个问题:一是保证类的唯一性,同一个类被不同的类加载器加载,得到的Class对象是不同的,instanceof和equals都会出问题;二是保证安全性,核心类库由顶层的启动类加载器加载,应用层的类加载器无法替换它们,这样别人就没法塞一个恶意的java.lang.String进来。

但双亲委派不是铁律,有些场景必须打破它,这也是高频追问点。典型的有三类:

第一类是SPI场景。JDK定义了一堆接口(比如JDBC的Driver),但实现类由第三方提供,启动类加载器肯定找不到这些实现类(它们不在rt.jar里)。解法是线程上下文类加载器(Thread Context ClassLoader),让父加载器通过子加载器去加载实现类,相当于"父委派子",反过来了。

第二类是热部署和模块化场景。比如一个应用服务器要支持多个Web应用各自独立的类库版本,不同应用的同名类必须隔离加载,这时候每个应用一个类加载器,各自加载自己的类,双亲委派在这里被弱化。

第三类是代码动态生成场景。有些框架需要在运行期生成字节码并加载类,这些类没有对应的文件路径,只能自定义类加载器重写findClass或loadClass方法。

打破双亲委派有两种方式:重写loadClass方法(改变委派逻辑)或重写findClass方法(在父加载器找不到时自己找)。前者更彻底,后者更安全,实际用哪种取决于你要不要保留默认的委派行为。面试时能讲清楚这两种方式的区别,基本就够了。

3.3 类初始化时机的经典陷阱

前面说类的初始化只在"主动引用"时触发,什么叫主动引用?规范列了五种情况:

  • 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时(也就是new对象、读写静态字段、调用静态方法)
  • 使用反射调用类时
  • 初始化一个类时,如果父类还没初始化,先初始化父类
  • 虚拟机启动时指定的主类
  • 使用动态语言支持时的方法句柄解析结果

被动引用的典型例子是通过子类访问父类的静态字段,此时只有父类被初始化,子类不会。再比如通过数组定义来引用类——MyClass[] arr = new MyClass[10]——不会触发MyClass的初始化,只会触发一个由虚拟机自动生成的数组类的初始化。还有引用类的常量(static final修饰的基本类型或String)也不会触发初始化,因为常量在编译期就被放进调用方的常量池了。

这些陷阱在实际编码中不太会踩,但面试时喜欢考,因为能区分出你是真的读过规范还是只看了博客。准备的时候建议把《Java虚拟机规范》里关于初始化的那节翻一遍,篇幅不长,但把边界条件讲得很清楚。

4. 垃圾回收:从"对象死了没"到"用哪个收集器"

4.1 可达性分析与GC Roots

判断对象是否存活,主流虚拟机用的是可达性分析,而不是引用计数。为什么要放弃引用计数?因为它解决不了循环引用的问题——两个对象互相引用,即使外部已经不可达,计数也不为零,内存就漏了。可达性分析的思路是从一组称为GC Roots的对象出发,沿着引用链往下遍历,能遍历到的对象就是存活的,遍历不到的就是可回收的。

GC Roots包括哪些?这是必背项:虚拟机栈中引用的对象(局部变量)、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象、虚拟机内部的引用(基本类型对应的Class对象、常驻异常对象等)、同步锁持有的对象。实际排查时,GC Roots就是"从线程栈和静态区出发能摸到的所有活对象"。

这里有个容易混淆的点:可达性分析出来的"不可达"对象不是立刻就被回收,而是要经过两次标记。第一次标记后筛选出需要执行finalize方法的对象,放进F-Queue队列;第二次标记时,如果对象在finalize方法里重新把自己挂到引用链上(比如赋值给某个静态变量),就能逃脱回收。不过finalize方法的执行时机不确定、性能差,现在基本没人依赖它,JDK 9之后甚至标记为deprecated了。

引用类型也是常考点,四种:强引用(普通的Object o = new Object(),只要引用还在就不回收)、软引用(内存不够时才回收,适合做缓存)、弱引用(下次GC一定回收,ThreadLocalMap的key用的就是它)、虚引用(唯一作用是回收时收到通知,配合引用队列使用)。面试里常问软引用和弱引用的实际用途,别只答定义,答软引用做图片缓存、弱引用配合WeakHashMap防止内存泄漏,会显得更有实战感。

4.2 分代假说与三种基础算法

垃圾回收的理论基础是分代假说,两条假设:绝大多数对象都是朝生夕灭的;熬过越多次回收的对象越难死。基于这两条,堆被分成新生代和老年代,新生代用复制算法(因为存活对象少,复制成本低),老代用标记清除或标记整理(因为存活对象多,不适合复制)。

三种基础算法各自的取舍要讲清楚:

标记清除:先标记所有需要回收的对象,再统一清除。优点是实现简单,缺点是产生内存碎片,碎片多了以后分配大对象时可能找不到连续空间,被迫再触发一次GC。CMS用的就是这个算法。

标记复制:把内存分成两块,每次只用一块,回收时把存活对象复制到另一块,然后把当前块整个清空。优点是没有碎片、分配简单(指针碰撞),缺点是浪费一半空间。新生代做了优化,不是1:1分,而是Eden和两个Survivor按8:1:1分,每次只用Eden和一个Survivor,回收时把存活对象复制到另一个Survivor,这样只浪费10%的空间。

标记整理:先标记,然后把存活对象往一端移动,最后清理边界外的内存。优点是没有碎片,缺点是要移动对象,成本高,而且移动过程中必须暂停用户线程。老年代如果碎片严重,会退化成用这个算法。

算法优点缺点典型使用者
标记清除实现简单,不需要移动对象产生碎片,分配效率低CMS
标记复制无碎片,分配快浪费空间,移动成本高新生代(Serial、ParNew、Parallel Scavenge)
标记整理无碎片移动成本高,停顿长老年代(Serial Old、Parallel Old)

4.3 收集器演进与选型对照

收集器的演进史其实就是停顿时间的压缩史。早期的Serial是单线程的,回收时必须Stop The World;Parallel把回收并行化,缩短了停顿但还是要STW;CMS第一次实现了回收线程和用户线程并发,把停顿压到了很低,但带来了浮动垃圾和碎片问题;G1把堆划分成Region,可以设定停顿目标;ZGC和Shenandoah用染色指针和读屏障把停顿压到了毫秒级甚至更低。

各个收集器的对照我整理成表,面试时被问到"你们用的什么收集器、为什么"可以对着说。

收集器工作区域算法是否并发适用场景
Serial新生代复制否客户端、小内存、单核
ParNew新生代复制是(多线程)配合CMS使用
Parallel Scavenge新生代复制是追求吞吐量,适合后台计算
Serial Old老年代标记整理否客户端、CMS降级备用
Parallel Old老年代标记整理是配合Parallel Scavenge
CMS老年代标记清除是(并发)追求低停顿,JDK 9后废弃
G1全堆复制+标记整理是大堆、可控停顿,JDK 9后默认
ZGC全堆标记复制(染色指针)是超大堆、极低停顿

选型的核心是吞吐量和停顿时间的权衡。吞吐量是用户代码运行时间占总时间的比例,停顿时间是单次GC的最长暂停。批处理任务追求吞吐量,选Parallel;在线服务追求低停顿,选G1或ZGC。经验值是堆小于4G用Parallel或G1都行,4G到32G用G1,超过32G或者对停顿极度敏感考虑ZGC。当然这只是粗粒度参考,实际要看业务指标。

4.4 G1的Region、RSet与SATB

G1是现在最常被问的收集器,因为它从JDK 9开始就是默认的。它的核心设计是把堆划分成大小相等的Region(1MB到32MB,2的幂次),每个Region可以是Eden、Survivor、Old或Humongous(存放大对象,大小超过Region一半的算大对象)。这种划分方式让G1可以按Region回收,每次挑垃圾最多的Region优先回收,这就是名字里"Garbage First"的由来。

RSet(Remembered Set)是G1解决跨代引用问题的关键。每个Region有一个RSet,记录"谁引用了我这个Region里的对象"。做可达性分析时,如果只扫描GC Roots,可能要扫全堆才能找到所有存活对象;有了RSet,扫描某个Region时先看它的RSet里记录了哪些外部引用,就能跳过大量不相关的对象。这个设计让G1可以只回收部分Region,而不必每次都扫全堆。

SATB(Snapshot-At-The-Beginning)是G1处理并发标记期间对象变化的方法。并发标记时用户线程还在跑,引用关系一直在变,G1的做法是在标记开始时拍一个快照,快照之外新产生的引用不管,快照之内被删除的引用则记录下来(通过写屏障),保证标记结果至少覆盖快照时刻的存活对象。这样做的后果是会产生浮动垃圾,但保证了正确性。

G1的回收流程分几个阶段:Young GC(只回收Eden和Survivor)、并发标记周期(初始标记、根区域扫描、并发标记、最终标记、清理)、Mixed GC(回收全部Young Region加上部分垃圾多的Old Region)。面试时如果被问到"G1怎么做到可预测停顿",答"通过-XX:MaxGCPauseMillis设定目标,G1根据历史回收数据估算每个Region的回收成本,然后挑选出一个成本不超过目标值的Region集合来回收"就够了。

5. 线上问题的排查链路:从告警到定位

5.1 CPU飙高与频繁GC的区分

线上告警最常见的就是CPU高和接口变慢,这两个现象背后可能是完全不同的原因,第一步是区分开。

CPU高不一定和GC有关,可能是业务线程在死循环、可能是正则回溯、可能是锁竞争导致的自旋。判断方法很简单:用top -Hp <pid>看是哪个线程在吃CPU,把线程ID转成十六进制,再用jstack <pid> | grep -A 20 <十六进制ID>看这个线程在干什么。如果线程在跑业务代码,那就是业务问题;如果频繁出现GC相关的线程,那才往GC方向查。

频繁GC的判断要更细一点。用jstat -gcutil <pid> 1000每秒打印一次,看几个指标:Young GC的频率和耗时、Full GC的频率和耗时、老年代使用率的增长趋势。正常情况下,Young GC应该几秒到几十秒一次,每次几十毫秒;如果Young GC一秒好几次,说明Eden太小或者对象分配太快;如果Full GC频繁触发,说明老年代增长快,可能是内存泄漏或者大对象直接晋升。

这里有个特别容易误判的场景:老年代使用率居高不下但Full GC后能降下来,这可能是正常的,因为老年代本来就该有一些长寿对象;但如果Full GC后老年代使用率还是很高,甚至有上升趋势,那就是泄漏的信号。我一般会连续观察几轮Full GC前后的老年代占用,如果每次回收后的基线上涨,基本可以确定有泄漏。

5.2 堆dump与MAT分析

确认有可能是内存问题之后,下一步就是抓堆快照。用jmap -dump:format=b,file=heap.hprof <pid>,或者更推荐用-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path让JVM在OOM时自动dump,这样能保留现场。如果是容器环境,还要注意dump文件可能很大,要确认磁盘空间够。

dump出来之后用MAT(Memory Analyzer Tool)打开,第一步看Leak Suspects报告,它会自动给出几个可疑点。但不要盲信自动报告,很多时候它会给出"某个集合占了60%内存"这种结论,而这个集合本身就是业务需要的缓存,不是泄漏。真正的分析要结合业务逻辑。

我的分析顺序一般是:先看Histogram,按实例数和占用内存排序,找出异常多的对象;再看Dominator Tree,找支配内存最多的对象;然后对可疑对象查它的GC Roots引用链,看是谁在持有它。引用链是定位泄漏的关键,比如你发现某个Map持有大量对象,沿着引用链往上找,发现是一个静态的本地缓存没有设置过期和上限,问题就找到了。

有个实战经验值得说:分析堆dump时,不要只看对象数量,要看对象的类型和内容。同样是100万个对象,如果是业务实体,那可能是正常的;如果是String,而且内容都很短很相似,可能是日志拼接或者重复的配置字符串导致的;如果是ThreadLocalMap的Entry,而且key已经为null但value还在,那就是典型的ThreadLocal泄漏。类型和内容能告诉你对象是从哪来的。

5.3 元空间与直接内存的隐性OOM

有一种OOM特别容易被忽略:堆内存看起来很正常,但进程还是OOM了。这时候要怀疑两个区域:元空间和直接内存。

元空间溢出的典型场景是动态生成类。比如大量使用CGLIB代理、动态编译JSP、或者用字节码工具在运行期生成类,这些类加载之后元数据会一直保留在元空间,如果没有及时卸载,就会把元空间撑爆。报错信息是OutOfMemoryError: Metaspace。排查方法是在启动参数里加-XX:MetaspaceSize和-XX:MaxMetaspaceSize,把上限限制住,同时用jstat -gc <pid>看MC(Metaspace Capacity)和MU(Metaspace Used)的增长趋势。如果MU一直涨不回落,说明类没有被卸载。

直接内存溢出更隐蔽,报错是OutOfMemoryError: Direct buffer memory。NIO的DirectByteBuffer用的就是直接内存,它不受-Xmx控制,受-XX:MaxDirectMemorySize控制(不设的话默认等于堆最大值)。直接内存的回收靠Cleaner机制,依赖GC触发,如果堆内存一直很充裕但直接内存分配很多,就可能出现"堆没满但直接内存满了"的情况。排查的方法是看进程的RSS和堆使用之间的差额,差额持续增大就说明堆外内存有问题。

溢出类型报错信息主要成因排查手段
堆溢出Java heap space对象过多或过大、泄漏jmap dump + MAT
栈溢出StackOverflowError递归过深、栈帧过大看异常栈、调-Xss
元空间溢出Metaspace动态生成类过多、类加载器泄漏jstat看MC/MU、限制MaxMetaspaceSize
直接内存溢出Direct buffer memoryNIO缓冲区过多、Cleaner未触发对比RSS与堆用量、限制MaxDirectMemorySize

6. 参数与调优:那些常问却常错的点

6.1 常用参数与含义

JVM参数这块,面试官问"你常用的启动参数有哪些",很多人能报出一堆,但被追问"这个参数设了会有什么副作用"就说不清了。我挑几个最常用、也最容易被误解的讲。

-Xms和-Xmx是初始堆和最大堆。常见的错误做法是把两个值设成不一样,觉得这样可以"弹性伸缩"。实际上堆扩容会触发Full GC,而且扩容本身有开销,线上服务一般把两者设成相等,避免运行期伸缩。至于设多大,没有万能公式,一个经验是观察稳定运行时的老年代占用,堆大小设为它的3到4倍,留出足够的Young GC空间。

-Xmn设新生代大小,或者用-XX:NewRatio设新生代和老年代的比例。新生代设大一点,Young GC次数少但单次时间长,因为要复制的区域大;设小一点,单次时间短但次数多。这里有个反直觉的点:新生代不是越大越好,太大可能导致单次Young GC的停顿超过预期,而且会挤压老年代空间。

-XX:SurvivorRatio设Eden和一个Survivor的比例,默认8,也就是8:1:1。如果业务中对象在Young GC后存活率较高,可以调小这个值,给Survivor更多空间,避免对象过早晋升到老年代。

-XX:MaxTenuringThreshold设对象晋升老年代的年龄阈值,默认15。如果发现老年代增长快、很多对象其实活不过几次Young GC,可以调大这个值;但如果调太大,Survivor里的对象会来回复制多次,也有开销。

G1相关参数里最常被问的是-XX:MaxGCPauseMillis,设停顿目标,默认200毫秒。这个参数不是硬约束,G1只是尽力而为,设得太小(比如20毫秒)会导致G1频繁只回收很少的Region,反而让吞吐量下降。设的时候要根据SLA来,能接受200毫秒停顿就别设成50。

6.2 调优的取舍逻辑

调优不是调参数,是调权衡。JVM调优本质上是在三个指标之间做取舍:吞吐量、停顿时间、内存占用。这三个不可能同时最优,你总得牺牲一个。

如果我调的是一个离线批处理任务,我会把堆设大、新生代设小、用Parallel收集器、把停顿时间放到几百毫秒甚至秒级,换最大的吞吐量。如果调的是在线接口服务,我会优先保证P99延迟,用G1或ZGC,把停顿压在几十毫秒以内,宁可牺牲一点吞吐量和内存。如果调的是一个内存受限的嵌入式服务,那就要在内存占用和停顿之间权衡,可能需要把堆压到很小,接受更频繁的Young GC。

调优之前必须先有指标,没有指标的调优就是瞎调。要采集的指标包括:Young GC频率和耗时、Full GC频率和耗时、GC后的老年代占用、接口的P99延迟、系统的CPU和内存使用率。有了这些基线,你再改参数,才能对比出效果。我见过最常见的错误是"看到Full GC就加堆",如果Full GC是内存泄漏引起的,加堆只能延缓问题,不能解决,迟早还会爆。

6.3 我踩过的几个坑

说几个真实的教训,都是文档里不会写、但实际会遇到的问题。

第一个坑是容器环境的堆大小感知。早期版本的JDK在容器里读不到容器的内存限制,会按宿主机的内存来算默认堆大小,结果就是堆设得远超容器限额,容器直接被OOM Killer干掉,日志里连JVM的OOM都没有。解法是升级到支持容器感知的JDK版本,或者显式设置-Xmx和-XX:MaxRAMPercentage。现在很多团队直接用-XX:MaxRAMPercentage=70,让JVM按容器内存的70%来设堆,这样镜像部署到不同规格的机器上都不用改配置。

第二个坑是GC日志没有开启,出问题后无从下手。现在虽然推荐用统一的日志框架,但GC日志还是要单独开,参数是-Xlog:gc*:file=/path/gc.log:time,uptime,level,tags(JDK 9之后的写法)。日志里至少要有时间戳、GC类型、回收前后各代的使用量、停顿时间。没有这些,你连"是不是GC导致的延迟"都判断不了。

第三个坑是误把正常的Young GC当成问题。有些同学看到监控上Young GC次数多就紧张,实际上Young GC本来就很频繁,只要单次耗时短、回收效果好,就不是问题。真正要关注的是Full GC和单次停顿时间超长的GC。判断标准应该是延迟指标,而不是GC次数——如果接口P99没受影响,GC次数多一点完全无所谓。

第四个坑是用System.gc()来"帮助"回收。显式调用System.gc()会触发Full GC(除非加了-XX:+DisableExplicitGC),而Full GC的停顿很长,在线上环境这可能直接导致一次超时。有些第三方库会在代码里调System.gc(),所以线上一般都会加上-XX:+DisableExplicitGC把这个口子堵上。这个参数值得加到你的默认启动模板里。

最后一个经验是关于压测和调优的顺序。很多人是先调参数再压测,这是反的。正确的顺序是:先按经验值给一套参数跑起来,压测采集基线,然后针对瓶颈调一到两个参数,再压测对比。一次只改一个变量,才能知道是哪个改动起了作用。同时改五个参数,效果好了你不知道为什么好,效果差了也不知道是哪个拖的后腿。

一个实用的小习惯:把每次调优的参数变更、压测数据、观察到的现象记在一个表格里,积累几次之后你会发现,同类型的服务其实有相对固定的参数模板,调优速度会快很多。

准备JVM相关的内容,我的体会是:面试题是入口,不是终点。把每道题当成一个线索,顺着它去读源码、看规范、在测试环境里实际跑一遍,你对这块的理解会完全不一样。比如你真的用jmap抓一次dump、用MAT找出一个泄漏点、改参数观察GC日志的变化,这个过程走一遍,比背一百道题都管用。而且这个过程里积累的排查思路,才是面试官真正想从你嘴里听到的东西。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进