准备JVM面试时,最大的问题往往不是单个知识点记不住,而是资料太碎。类加载、运行时数据区、GC原理、垃圾回收器、性能调优和MySQL延展题,看起来是六个独立板块,面试官却经常把它们串成一条完整的链路来问。比如“一个对象从 new 出来到被回收,JVM 到底做了哪些事”,这个问题就会同时涉及类加载、对象分配、GC Roots、三色标记和收集器选型。与其零散背题,不如先建立一条主线:类加载机制 -> 运行时数据区 -> 对象分配 -> GC算法与三色标记 -> 调优参数与工具 -> 高频MySQL面试题。这篇文章按这条线整理,每个部分都尽量给出面试答题要点、验证命令和实际项目中的排查思路,适合作为面试复盘清单,也适合在本地JDK环境里对照练习。
1. 先建立JVM面试知识地图,避免死记硬背
1.1 JVM面试真正在考察什么
很多JVM题目表面问的是理论,实际考察的是候选人能不能从“一段Java代码”推导出“JVM内部行为”。比如问“类加载过程有哪些阶段”,不是要你背出五个阶段名称,而是想确认你理解验证、准备、解析、初始化之间的先后关系,以及静态变量在准备阶段和初始化阶段分别是什么值。再比如问“CMS和G1的区别”,背后考察的是你知道不知道并发标记阶段存在漏标问题,以及不同收集器用什么手段解决漏标。
所以JVM面试复习不应该以“背下45问”为目标,而应该以“能回答一条完整的问题链”为目标。这条链通常长这样:
- JVM和JDK、JRE的关系是什么。
- 一个 class 文件是怎样被加载到JVM里的。
- 加载后的类放在JVM的哪个区域。
- 运行时数据区怎么划分,哪些线程共享,哪些线程私有。
- new 出来的对象放在哪里,对象头里有什么。
- 对象什么时候变成垃圾,GC怎么判断对象是否存活。
- 并发标记时怎么避免误回收存活对象,三色标记有什么用。
- 不同收集器怎么配合分代模型工作。
- 线上GC频繁或OOM时,用什么命令排查。
- 如果接口慢了,如何在JVM线程栈和MySQL慢查询之间快速定位。
顺着这条链去准备,会比单独背“双亲委派”“可达性分析”“G1的RSet”更有体系。
1.2 一条主线串起所有高频问题
推荐用“一次对象生命周期”来串知识点。从下面这张对应关系可以看出JVM知识并不是孤立的:
| 生命周期阶段 | 涉及JVM知识点 | 常见面试题 |
|---|---|---|
| 编译并生成class文件 | JDK、JRE、JVM区别,javac | JRE和JVM之间是什么关系 |
| 类加载 | 加载、验证、准备、解析、初始化 | 类加载有哪些阶段,静态变量何时赋值 |
| 类加载器 | 启动类加载器、平台/扩展类加载器、应用类加载器 | 双亲委派模型是怎样的 |
| 对象分配 | 运行时数据区、TLAB、对象头 | new出来的对象放在哪里 |
| 对象使用 | 虚拟机栈、栈帧、局部变量表 | 栈溢出和堆溢出分别什么表现 |
| 对象回收 | 可达性分析、引用类型、分代收集 | 怎么判断对象可以被回收 |
| 并发标记 | 三色标记、增量更新、SATB | 什么是三色标记,G1怎么解决漏标 |
| 回收执行 | Serial、Parallel、CMS、G1、ZGC | 垃圾收集器怎么选 |
| 线上排障 | jps、jstat、jmap、jstack、jcmd | 线上Full GC频繁怎么排查 |
| 数据访问 | MySQL索引、事务、MVCC、锁 | SQL慢、死锁、索引失效怎么处理 |
面试时如果能把某个问题放回这条链路里回答,比直接背结论更容易让面试官认可。
1.3 面试准备清单和自测问题
开始正式复习前,可以先做一次自测。下面这份清单不需要一次写满,但每个问题至少要在脑中说出一版答案:
- 类加载的五个阶段分别做了什么,有没有哪个阶段可以被延迟。
- 双亲委派模型是什么,为什么JVM要这样设计。
- NoClassDefFoundError 和 ClassNotFoundException 有什么区别。
- 运行时数据区哪些区域是线程私有的,哪些是线程共享的。
- 一个对象从new出来到进入老年代,经历了什么。
- 对象可以被回收的话,为什么还要分代。
- 三色标记中的白色、灰色、黑色分别代表什么。
- CMS和G1分别是如何解决并发标记漏标问题的。
- 线上老年代持续增长,应该先看jstat还是先看jmap。
- MySQL索引为什么用B+树,什么情况下索引会失效。
如果这些问题现在都能答出具体层次,后面的内容可以快速浏览;如果答不出来,建议按下面章节继续往下看。
2. 类加载机制:从字节码到Class对象
2.1 类加载的五个阶段,各阶段做了什么
类从字节码变成可以被JVM使用的Class对象,要经历加载、验证、准备、解析、初始化五个阶段。这里要先记住一个原则:初始化阶段才是真正执行类中Java代码的阶段,前面的验证和准备阶段主要是校验和分配内存。
加载阶段要做三件事:通过类的全限定名获取定义此类的二进制字节流;把字节流中的静态存储结构转化为方法区的运行时数据结构;在堆中生成一个代表该类的Class对象,作为访问方法区入口。加载阶段不一定从本地class文件读取,也可以从JAR包、网络、动态代理生成、JSP编译结果中读取。
验证阶段主要保证class文件的字节流符合JVM规范,并且不会危害JVM自身安全。包括文件格式验证、元数据验证、字节码验证、符号引用验证。这个阶段在面试中不需要展开太深,但要能说出“验证是安全的第一道防线”。
准备阶段是很多面试题的隐蔽考点。这个阶段会为类变量分配内存并设置零值。例如private static int count = 10;在这步count的值是0而不是10,真正的10需要在初始化阶段执行<clinit>方法后才会赋值。如果是private static final int COUNT = 10;,在准备阶段就可能直接赋值为10,因为编译期已经确定了常量值。
解析阶段是把常量池内的符号引用替换为直接引用。符号引用就是“org.example.User.sayHello”这种字面描述,直接引用则是指向目标对象的指针、偏移量或句柄。解析可能在初始化之后才开始,因为JVM规范允许在第一次使用某个符号引用时才触发解析。
初始化阶段是执行类构造器<clinit>方法的过程。JVM会保证一个类的<clinit>在多线程环境下被正确加锁同步。类初始化的触发条件包括:遇到new、getstatic、putstatic、invokestatic字节码指令,或反射调用类时,如果类没有初始化就先初始化;初始化子类时,如果父类还没初始化就先初始化父类。
用一张表可以快速记忆:
| 阶段 | 核心工作 | 最容易考的点 |
|---|---|---|
| 加载 | 读取字节流,生成Class对象 | 可以从哪些来源加载类 |
| 验证 | 校验字节码是否符合规范 | 防止恶意字节码 |
| 准备 | 为静态变量分配内存并设零值 | count此时是0不是10 |
| 解析 | 符号引用换成直接引用 | 可延迟到使用阶段 |
| 初始化 | 执行<clinit> | 父类先初始化,线程安全 |
2.2 类加载器与双亲委派模型
类加载阶段的工作是由类加载器完成的。先厘清三者的关系:JVM是执行Java字节码的虚拟机,JRE是JVM加上Java标准类库,JDK是JRE加上编译器和其他开发工具。面试中问“JRE和JVM之间是什么关系”,答案就是JVM只是JRE的一部分,JRE还包含运行Java程序所需的类库和其他资源文件。
JVM内置了三个重要的类加载器。启动类加载器负责加载JAVA_HOME/lib下的核心类库,例如rt.jar或JDK9模块化后的java.base模块,它不是一个普通的Java类,在Java代码中通常表现为null。平台类加载器在JDK9之前叫扩展类加载器,负责加载扩展目录下的类,JDK模块化后叫平台类加载器。应用类加载器负责加载classpath或模块路径上的类,也是默认的应用程序类加载器。
双亲委派模型的流程是:当一个类加载器收到加载请求时,先不自己加载,而是把请求委派给父加载器,每一层都往上抛,直到启动类加载器。只有父加载器加载不到时,子加载器才尝试自己加载。
这样做最直接的好处是避免核心类被重复加载和篡改。比如我们也可以写一个java.lang.String类放到classpath里,但由于双亲委派机制,加载String的工作最终由启动类加载器完成,JVM运行时使用的是核心类库里的String,而不是自定义的那个。
下面这段代码可以打印出各类加载器之间的关系:
public class ClassLoaderDemo { public static void main(String[] args) { ClassLoader cl = ClassLoaderDemo.class.getClassLoader(); System.out.println("应用类加载器: " + cl); System.out.println("平台/扩展类加载器: " + cl.getParent()); System.out.println("启动类加载器: " + cl.getParent().getParent()); ClassLoader strCl = String.class.getClassLoader(); System.out.println("String的类加载器: " + strCl); } }运行后会看到,String的类加载器输出为null,说明它由启动类加载器加载。应用类加载器的父是平台类加载器,再往上的parent为null。这个输出能直接用于面试现场演示。
2.3 双亲委派不是唯一答案:什么时候要打破
常见的打破双亲委派场景有三个:SPI机制、Tomcat等Web容器、OSGi或模块化系统。
SPI是典型的例子。JDBC驱动接口定义在java.sql中,由启动类加载器加载;但具体驱动实现比如MySQL驱动却在classpath中,启动类加载器加载不到。为了解决这个问题,JDK引入了线程上下文类加载器,让启动类加载器“反向”使用应用类加载器来加载实际实现类。这就是所谓“父加载器请求子加载器加载”的场景。
Tomcat打破双亲委派的原因是不同的Web应用可能包含同名但不同版本的类库,如果统一由应用类加载器加载,会互相冲突。所以Tomcat为每个Web应用提供独立的WebAppClassLoader,优先加载当前Web应用下的类,加载不到再交给父加载器。
如果需要自己写一个类加载器,通常只需要继承ClassLoader并重写findClass方法。下面是简化版实现:
public class SimpleClassLoader extends ClassLoader { @Override protected Class<?> findClass(String name) throws ClassNotFoundException { String path = name.replace('.', '/') + ".class"; try (InputStream in = getResourceAsStream(path)) { if (in == null) { throw new ClassNotFoundException(name); } byte[] bytes = in.readAllBytes(); return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } }这个示例说明自定义类加载器的核心是把字节数组交给defineClass,由JVM完成后续验证和Class对象生成。实际项目中还需要考虑缓存已加载类、关闭资源、处理父子加载器可见性等问题。
2.4 类加载相关的高频报错与常见误区
面试和实战中都容易遇到类加载相关异常。两个高频异常经常被混淆:
| 异常 | 含义 | 典型场景 |
|---|---|---|
| ClassNotFoundException | 运行时找不到指定类,抛出在classpath加载阶段 | 依赖缺失、JAR未引入、全限定名写错 |
| NoClassDefFoundError | 类在编译期存在,运行时初始化失败或链接失败 | 静态初始化块抛异常、依赖版本冲突、类加载器不一致 |
例如Eclipse中启动Tomcat报找不到或无法加载主类 org.apache.catalina.startup.Bootstrap,多数情况下不是Tomcat本身坏了,而是classpath没有包含Tomcat的bootstrap.jar和tomcat-juli.jar,或者启动配置里的MainClass被改错。排查时先检查项目的Run Configuration,再看依赖JAR是否完整,最后看是否多个Tomcat版本同时存在。
另一个常见误区是认为“父加载器加载过的类子加载器一定可见”。实际上可见性方向是单向的,子加载器加载的类对父加载器不可见。双亲委派保证的是“向上委派”,不是“向下可见”。
3. JVM运行时数据区与对象创建过程
3.1 运行时数据区怎么划分
面试题里经常出现“JVM的三大区域”,这个说法并不统一。更规范的回答是:运行时数据区可以分成线程共享区域和线程私有区域。线程共享的是堆和方法区;线程私有的是虚拟机栈、本地方法栈、程序计数器。
程序计数器是当前线程所执行字节码的行号指示器,是唯一不会出现OutOfMemoryError的区域。虚拟机栈保存栈帧,每个方法从调用到结束对应一次入栈出栈。栈帧里包含局部变量表、操作数栈、动态连接、方法返回地址等。本地方法栈为JVM使用到的Native方法服务,HotSpot直接把本地方法栈和虚拟机栈合并实现。
堆是JVM管理最大的一块内存区域,也是垃圾回收的重点区域。几乎所有对象实例都在这里分配。JDK8开始,方法区由元空间实现,直接使用本地内存,不再是JVM堆的一部分。运行时常量池是方法区的一部分,用于保存class文件中的常量池数据。字符串常量池在JDK7以后移到了堆中,这导致字符串相关的OOM表现可能出现在堆里,而不是永久代。
可以用表格快速整理:
| 区域 | 线程共享 | 是否可能OOM | 典型参数 |
|---|---|---|---|
| 程序计数器 | 否 | 不会 | 无 |
| 虚拟机栈 | 否 | StackOverflowError / OOM | -Xss |
| 本地方法栈 | 否 | StackOverflowError / OOM | -Xss |
| 堆 | 是 | Java heap space | -Xms、-Xmx、-Xmn |
| 方法区/元空间 | 是 | Metaspace | -XX:MetaspaceSize、-XX:MaxMetaspaceSize |
堆可以再细分为新生代和老年代。新生代默认包含Eden区和两个Survivor区。在HotSpot里,Eden与Survivor的默认比例是8:1,可用-XX:SurvivorRatio调整。大多数对象在Eden区创建后很快变成垃圾,Minor GC会把这些存活对象复制到Survivor区,经过多次回收后仍有存活的,才有机会晋升到老年代。
3.2 一个对象new出来,JVM做了什么
很多面试题会问“new一个对象的过程是怎样的”。完整回答要覆盖五步。
第一步是类加载检查。虚拟机遇到new指令时,先检查常量池能否定位到这个类的符号引用,并检查这个类是否已经完成加载、解析和初始化。如果没有,先触发类加载流程。
第二步是分配内存。对象所需内存大小在类加载完成后就可以确定。如果堆内存是规整的,使用指针碰撞分配;如果不规整,使用空闲列表分配。并发环境下为了保证分配安全,在新生代通常会使用TLAB,即每个线程在Eden区预先分配一块本地缓冲,减少线程竞争。
第三步是把分配到的内存空间初始化为零值。这一步保证了实例字段不赋值也能直接读取默认值,比如int是0、boolean是false、引用是null。
第四步是设置对象头。对象头包含Mark Word、指向类元数据的指针,如果数组对象还包括数组长度。Mark Word中存储了对象的哈希码、GC分代年龄、锁状态等信息。
第五步是执行<init>方法。也就是执行类中定义的构造函数,按程序员的意图初始化实例字段。到这一步,一个可用的对象才算真正创建完成。
这个流程回答了“对象还在栈上还是堆上”的问题:绝大多数情况下对象在堆中分配,栈上分配只是JIT在逃逸分析后做的优化。
3.3 从内存异常反推配置问题
线上应用最常见的几类内存异常,适合用表格对照排查:
| 异常信息 | 问题区域 | 排查方向 |
|---|---|---|
| java.lang.StackOverflowError | 虚拟机栈递归过深 | 检查递归方法、深层次JSON序列化 |
| java.lang.OutOfMemoryError: Java heap space | 堆内存不足 | 检查大对象、对象泄漏、堆参数 |
| java.lang.OutOfMemoryError: Metaspace | 元空间不足 | 检查动态生成类、CGLib/反射场景 |
| java.lang.OutOfMemoryError: GC overhead limit exceeded | 堆回收效率过低 | 说明GC后几乎回收不到空间,优先dump堆 |
| java.lang.OutOfMemoryError: unable to create new native thread | 线程或系统资源不足 | 检查线程数、ulimit、操作系统限制 |
如果需要在本地复现堆OOM,可以用下面的参数启动一个Java程序并不断向集合中添加对象:
java -Xms128m -Xmx128m -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/logs/app.hprof -jar oom-demo.jarHeapDumpOnOutOfMemoryError是生产环境强烈建议打开的参数,这样OOM发生时能自动留下堆快照。要注意HeapDumpPath要指向持久化目录,如果应用运行在Docker容器里,必须挂载到宿主机目录,否则容器一重建,堆快照就丢了。
学习环境里可以临时把堆调大,生产环境调整堆大小前必须评估机器内存、容器限制和GC停顿。容器场景下还要注意JVM是否能正确识别容器内存限制,JDK8u191之后默认支持容器感知,老版本可能需要显式配置-XX:MaxRAMPercentage等参数。
4. GC原理与三色标记:垃圾回收不能只靠背
4.1 怎么判断一个对象是否存活
引用计数法的思路是给对象维护一个计数器,被引用一次加1,引用失效减1,计数器为0时认为可回收。它实现简单,但解决不了循环引用问题。比如A对象持有B对象引用,B对象持有A对象引用,外部已经没有引用指向它们时,两个对象计数器仍然不为0,永远不会被回收。
HotSpot主流的做法是可达性分析。从一组GC Roots出发,沿着引用链向下搜索,能到达的对象就是存活对象,不能到达的对象会被标记为可回收。GC Roots包括虚拟栈帧中的局部变量表引用的对象、静态属性引用的对象、JNI中引用的对象、活跃线程等。
这里要理解“可达”不等于“一定存活”,因为对象可能重写finalize方法实现一次自我拯救,但在实际项目中不建议依赖finalize,它执行时机不确定,而且会影响GC效率。面试时能说出“可达性分析 + GC Roots”就足够了。
4.2 四种引用类型和使用场景
Java提供了四种引用类型,它们在GC中的处理方式完全不同:
| 引用类型 | 回收时机 | 典型场景 |
|---|---|---|
| 强引用 | 只要强引用存在,永远不会回收 | 普通对象引用 |
| 软引用 | 内存不足时回收 | 本地缓存、图片缓存 |
| 弱引用 | 下一次GC时回收 | ThreadLocalMap中的Entry、缓存框架 |
| 虚引用 | 随时可能回收,必须配合引用队列 | 堆外内存回收通知、对象销毁跟踪 |
软引用适合用来做内存敏感的缓存。比如一个图片加载组件,内存充足时保留图片对象,内存紧张时可以先回收图片而不是直接OOM。弱引用常见于ThreadLocal里的Entry,这也是ThreadLocal内存泄漏问题讨论的起点。
一个问题值得单独记忆:ThreadLocal中Entry的key是弱引用,value是强引用。如果ThreadLocal外部不再持有key,但线程仍然存活,那么key会被回收而value不会,最终导致内存泄漏。规范做法是在使用完ThreadLocal后调用remove清理。
4.3 分代收集理论与GC算法
分代收集说法是当前JVM的主流设计思路。新生代对象死亡率高,适合用标记-复制算法,每次GC只保留少量存活对象,复制到空闲Survivor区,成本低。老年代对象存活率高,不适合频繁复制,一般用标记-清除或标记-整理算法。
标记-清除算法先标记出所有需要回收的对象,然后统一回收。优点是实现简单,缺点是会产生大量内存碎片,后续分配大对象时可能找不到连续空间。标记-整理算法在标记之后让所有存活对象向一端移动,解决碎片问题,但移动对象需要更新所有引用,成本更高。标记-复制算法把可用内存分成两块,每次只使用一块,GC时把存活对象复制到另外一块,然后清空原来那半块,实现简单但空间利用率偏低。
HotSpot的年轻代采用Eden和两个Survivor的设计,本质就是标记-复制算法的优化版本。Survivor区空间比较小,所以默认Eden与Survivor比例是8:1,也说明复制算法牺牲了一部分空间来换效率。
对象晋升到老年代的常见条件包括:在Survivor区熬过一定次数的Minor GC,默认15次,可用-XX:MaxTenuringThreshold调整;大对象直接进入老年代,可用-XX:PretenureSizeThreshold设置。晋升条件不是单一固定的,如果Survivor区中相同年龄对象总大小超过Survivor空间一半,则年龄大于等于该年龄的对象也会直接晋升。
4.4 三色标记:并发标记阶段的底层原理
CMS和G1这类收集器在并发标记阶段不会暂停所有业务线程,这就带来一个问题:一边标记,一边业务线程还在修改对象引用,怎么保证不把存活对象漏掉。三色标记就是解决这个问题的抽象模型。
三色标记把对象分成三种状态。白色对象表示尚未被垃圾回收器访问过的对象,在标记结束后仍然为白色的对象会被视为不可达。灰色对象表示当前已被访问,但它的引用字段还没有全部扫描完。黑色对象表示自身和它的引用字段都已经被扫描完毕,不会再被重复扫描。
标记开始前,所有对象都是白色。从GC Roots直接可达的对象变为灰色,然后逐个扫描灰色对象的引用,把引用到的白色对象标成灰色,当灰色对象的所有引用都扫描完后,这个对象变成黑色。重复这个过程直到没有灰色对象,剩下的白色对象就是不可达对象。
并发标记最大的隐患是漏标,也就是把一个本应存活的白色对象误判成垃圾。漏标必须同时满足两个条件:插入了一条或多个黑色对象到白色对象的引用;删除了一条或多个灰色对象到该白色对象的引用。
CMS使用增量更新解决漏标,做法是在写屏障中记录黑色对象新增的引用,然后在重新标记阶段再次扫描这些引用,把黑色对象变成灰色重新处理。G1使用SATB,全称Snapshot At The Beginning,它在并发标记开始时记录一个对象快照,通过写前屏障记录引用被覆盖前的对象,保证在标记开始时存活的旧对象不会被漏标。增量更新侧重“记录新增引用”,SATB侧重“保留标记开始时点”。
这个知识点是JVM面试中区分度最高的问题之一。回答时不要只说“G1用SATB”,要能画逻辑过程:白色->灰色->黑色,说明漏标两个条件,再说明两种收集器的处理差异。
4.5 常用垃圾收集器对比与选型
不同收集器适合不同场景,面试时不要只背名称,还要能解释为什么选它。
| 收集器 | 线程模式 | 特点 | 适用场景 |
|---|---|---|---|
| Serial | 单线程 | 简单,Client模式默认 | 单核小内存、学习环境 |
| Parallel | 多线程 | 吞吐量优先 | 后台计算、批处理任务 |
| CMS | 多线程并发 | 低延迟,标记-清除,会产生碎片 | 重视响应时间的Web应用 |
| G1 | 多线程并发 | 分区堆,可预测停顿,JDK9后默认 | 多核、大堆、服务端通用 |
| ZGC | 多线程并发 | 停顿极短,吞吐可能略低 | 超大堆、要求毫秒级停顿 |
CMS在JDK9开始标记废弃,JDK14中移除,所以新项目不建议继续选CMS。G1把堆划分成多个Region,逻辑上仍然区分年轻代和老年代,通过跟踪每个Region的回收价值来优先回收收益最大的区域。因此G1能以-XX:MaxGCPauseMillis为参考目标控制GC停顿。
使用G1时可以显式指定:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jarMaxGCPauseMillis不是硬性指标,G1会尝试通过调整Region回收数量、晋升阈值等方式接近目标。设置太小会导致G1频繁调整,反而影响吞吐量。ZGC的延迟能力更强,但在JDK版本之间的成熟度不同,选择前要结合自己使用的JDK版本确认特性支持。
5. JVM性能调优:从定位问题到生产参数
5.1 调优目标到底是什么
JVM调优不是把堆调大这么简单。调优目标通常有三个方向:低延迟、高吞吐、低内存占用。这三个目标经常互相冲突,比如为了降低延迟,可能需要更多内存缓存;为了提升吞吐量,GC停顿时间可能变长。所以调优前必须先定义指标,例如“TP99小于200ms”“每小时Full GC不超过1次”“堆使用率峰值不超过70%”。
没有指标的调优很容易变成参数赌博。正确顺序是:先收集现状数据,再定位瓶颈,最后用最小参数变更做验证。每次只改一个参数,改完观察一段时间,不要同时把-Xms、-Xmx、GC日志、收集器全部换一遍,否则出问题后无法判断是哪个改动引起的。
5.2 调优前置:先采集数据,再改参数
JDK自带了很多命令行工具,下面是高频使用的五个:
| 命令 | 作用 | 典型用法 |
|---|---|---|
| jps | 查看Java进程 | jps -l |
| jstat | 查看GC和类加载统计 | jstat -gcutil 1000 10 |
| jmap | 查看堆信息和导出堆快照 | jmap -histo |
| jstack | 导出线程快照 | jstack |
| jcmd | 综合诊断命令 | jcmd VM.flags |
实际排查时推荐先用jstat看GC情况,再决定是否导出堆快照。下面这组命令适合先用起来:
# 查看Java进程 jps -l # 每1秒打印一次GC统计,打印10次 jstat -gcutil <pid> 1000 10 # 查看堆中对象数量排行,取前30行 jmap -histo <pid> | head -30 # 导出线程栈,排查死锁、线程阻塞 jstack <pid> > thread_dump.txt # 查看JVM启动参数 jcmd <pid> VM.flagsjstat输出中的E、O、M分别代表Eden、老年代和元空间的使用率,FGC表示Full GC次数,FGCT表示Full GC耗时。如果Old区持续增长且Full GC越来越频繁,说明可能存在内存泄漏或老年代空间不足。
这里要特别提醒:生产环境执行jmap前要先确认业务可以接受短暂停顿。jmap -histo:live和jmap -dump:live会触发Full GC,风险更高。如果应用有多个节点,先摘除一个节点再dump。
5.3 常用JVM参数的语义和坑
很多面试题会直接问“你常用哪些JVM参数”。重点不是背参数,而是说出参数对内存布局和GC行为的实际影响。
| 参数 | 含义 | 调大影响 | 调小影响 |
|---|---|---|---|
| -Xms | 堆初始大小 | 启动时占用更多内存 | 启动后频繁扩容 |
| -Xmx | 堆最大大小 | 降低OOM概率 | 可能出现Heap OOM |
| -Xmn | 新生代大小 | 降低Minor GC频率 | 年轻代对象过早晋升 |
| -XX:MetaspaceSize | 元空间初始大小 | 启动期更稳 | 触发多次类加载扩容 |
| -XX:MaxMetaspaceSize | 元空间最大大小 | 支持更多动态生成类 | 动态类场景易OOM |
| -XX:SurvivorRatio | Eden与Survivor比例 | Survivor变大 | 晋升可能更快 |
| -XX:MaxTenuringThreshold | 对象晋升年龄阈值 | 对象更久留在新生代 | 对象更快晋升老年代 |
| -XX:MaxGCPauseMillis | G1停顿目标 | 更易满足停顿 | 回收更激进 |
| -XX:HeapDumpOnOutOfMemoryError | OOM时导出堆快照 | 推荐开启 | 不开启无法定位 |
GC日志参数需要区分JDK版本。JDK8及以前常用-XX:+PrintGCDetails,JDK9以后建议使用-Xlog:gc*。下面两种写法是不同版本对应的生产方式:
# JDK8示例 java -Xloggc:/data/logs/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar app.jar# JDK9及以上示例 java -Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags -jar app.jar保留GC日志是排查GC问题的前提。生产环境应确保日志路径在持久化磁盘上,并且配置日志轮转,否则长时间运行会占用大量磁盘。没有GC日志,很多线上GC问题只能靠猜。
另一个容易被问到的参数是-XX:CompileThreshold。它控制方法触发JIT编译的调用次数阈值,默认值在不同编译策略下并不相同。这个参数在实际生产中很少需要调整,盲目调小会让JIT过早介入,增加编译开销;盲目调大又会让热点方法长时间解释执行。面试时能说出“它是JIT编译阈值,受编译层级影响,不要随便改”就足够了。
5.4 一个OOM排查思路:从现象到代码
假设线上接口突然变慢,JVM的Old区使用率接近100%,Full GC频繁。推荐按下面顺序排查。
第一步用jstat确认是否真的频繁Full GC:
jstat -gcutil <pid> 1000 10如果看到O列持续接近100,FGC数量持续增长,且每次FGCT时间较长,可以确定老年代回收压力大。
第二步用jmap查看对象占用量,判断是否存在明显的大对象或泄漏对象:
jmap -histo <pid> | head -30这个命令会输出类名、实例数量和字节数。如果某个业务对象实例数量异常高,比如订单对象、日志对象、缓存对象数量远超预期,就要去代码里查这个对象的引用来源。
第三步导出堆快照:
jmap -dump:format=b,file=/data/logs/app.hprof <pid>然后使用内存分析工具打开hprof文件,查看Dominator Tree或Leak Suspects,找到GC Roots到占用对象的引用链。常见原因包括静态集合只增不减、ThreadLocal未清理、连接池泄漏、大批量数据一次性加载到内存。
第四步修复后观察指标。不要在高峰期直接对线上执行dump,优先在压测环境或备用节点上操作。
5.5 一个生产环境参数基线示例
实际项目中的参数会因业务不同而调整,但可以给出一版常见基线作为起点。假设应用运行在4核8G的容器中,堆大小约4G:
java -Xms4g -Xmx4g -Xmn1g \ -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/logs/app.hprof \ -Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags \ -jar app.jar学习环境可以追求简单,直接设置-Xms256m -Xmx256m并打开GC日志即可。生产环境要额外考虑容器内存限制、最大线程数、文件句柄数和监控告警。如果使用JDK8,gc日志参数要换成上一节提到的写法。
参数调整遵循“小步快跑”原则。每次发布只改一个关键参数,并同时变更监控大盘和告警阈值。回滚计划也要提前准备,否则参数上线后出现异常,会很难分辨是代码问题还是JVM配置问题。
5.6 调优中的常见误区
第一个误区是只调堆大小。堆调大只是延缓OOM出现的时间,如果代码存在泄漏,调大堆反而会让问题更难暴露。正确做法是先分析对象占用,再决定是否需要调堆。
第二个误区是动辄使用jmap。线上应用执行jmap可能会导致Full GC或长时间卡顿,尤其是大堆应用。更安全的路径是先看jstat和GC日志,再在低峰期或备用节点执行dump。
第三个误区是生产环境不保留GC日志。很多项目直到出现Full GC才发现没开GC日志,只能靠事后猜。GC日志是垃圾回收问题最重要的现场证据,必须默认开启。
第四个误区是盲目把Parallel换G1或ZGC。收集器切换不是只看一两个衡量指标,G1在超大堆和低延迟场景有优势,但吞吐量场景下Parallel可能更合适。没有数据支撑的切换,等于把线上稳定性交给运气。
6. 与JVM问题经常同台的MySQL高频面试题
6.1 MySQL索引为什么常用B+树
很多Java岗位面试中,MySQL题目会紧跟着JVM出现,因为两者都是后端性能问题的共同来源。常见的第一问是:为什么InnoDB索引选B+树而不是B树、红黑树或哈希表。
B+树的非叶子节点不保存数据记录,只保存索引键,这样每个节点可以容纳更多键值,整棵树更矮更宽,减少磁盘IO次数。叶子节点之间通过双向链表连接,对于范围查询非常友好,比如where id between 100 and 200只需要从定位到的叶子节点顺序扫描。B树的非叶子节点也会保存数据,树更高,范围查询需要反复回溯,效率不如B+树。
红黑树虽然能保持树平衡,但高度通常远高于B+树,在磁盘场景下多次IO是不合适的。哈希索引能做到O(1)等值查找,但不支持范围查询和排序。而InnoDB聚簇索引的叶子节点直接保存主键和整行数据,二级索引的叶子节点保存主键值,回表时根据主键到聚簇索引中再查一次。
回答时如果能补充“覆盖索引可以避免回表”会更好。比如select id, name from user where name = 'tom',如果存在(name, id)联合索引,二级索引里已经有id和name,查询就不需要回表。
6.2 事务隔离级别与MVCC
事务的四个特性ACID不需要多解释,重点要能说出InnoDB如何保证隔离性。SQL标准定义了四种隔离级别:读未提交、读已提交、可重复读、可串行化。MySQL默认是可重复读。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不会 | 可能 | 可能 |
| 可重复读 | 不会 | 不会 | 可能(InnoDB通过间隙锁解决大部分) |
| 可串行化 | 不会 | 不会 | 不会 |
MVCC是多版本并发控制,InnoDB每行隐藏两个关键字段:事务ID和回滚指针。undo log里保存历史版本,事务执行快照读时根据ReadView判断当前事务能看到哪个版本。
ReadView在事务第一次执行快照读时生成,包含活跃事务列表等信息。读已提交级别每次快照读都生成新的ReadView,所以能看到其他事务已提交的数据。可重复读级别同一个事务内使用同一个ReadView,所以多次读取结果一致。这解释了为什么MySQL默认隔离级别下普通select不会出现幻读,但当前读比如select ... for update或update语句会加锁,需要靠间隙锁和临键锁解决幻读。
这里有一个容易混淆的点:MVCC本身不解决当前读下的幻读,它只保证快照读的一致性。面试时不要只说“MVCC解决了幻读”,要补充“快照读由MVCC保证一致性,当前读需要依赖行锁和间隙锁”。
6.3 MySQL索引失效和Explain排查
接口慢时,除了查JVM线程,还要看SQL有没有走索引。下面这些场景经常导致索引失效:
| 错误写法 | 原因 | 推荐写法 |
|---|---|---|
where name like '%tom' | 前导通配符导致无法使用普通索引 | 改为where name like 'tom%' |
where id + 1 = 5 | 索引列参与计算 | 改为where id = 5 - 1 |
where date(create_time) = '2025-01-01' | 索引列使用函数 | 改为区间查询 |
where mobile = 13812345678 | 字符列隐式类型转换 | 改为字符串比较'13812345678' |
where name = 'tom' or age = 18 | OR可能使索引合并优化失效 | 优先用UNION或分别查询后合并 |
| 联合索引不满足最左前缀 | 无法使用联合索引 | 按联合索引字段顺序设计SQL |
排查SQL是否走索引时,使用EXPLAIN:
EXPLAIN SELECT id, order_no FROM order_info WHERE user_id = 123 AND status = 1;EXPLAIN输出中主要看几列。type列从好到坏常见有const、eq_ref、ref、range、index、ALL。ALL表示全表扫描,需要优先优化。key列表示实际使用的索引,rows表示预估扫描行数,Extra中如果出现Using filesort或Using temporary,说明在排序或去重时使用了临时文件,常见于order by和group by没有利用索引。
判断标准很简单:rows越小越好,key不为空,type尽量达到range或ref以上。
6.4 一个容易答错的MySQL题:OR能不能去重
网上经常出现“MySQL的OR能去重吗”这类题。这个问题的正确理解是:OR只是条件连接逻辑,不是去重逻辑。select * from table where a = 'x' or b = 'y'返回的是满足任意一个条件的行集合并集,不会因为某一行同时满足两个条件就只返回一次。
如果预期“同一行的重复出现”是问题,需要先搞清楚重复来自哪里。如果是多表JOIN产生的重复,应该优化JOIN条件;如果是数据本身有重复,应该使用DISTINCT或GROUP BY。但DISTINCT影响多列时,去重范围是“所有查询列组合”,不是只对某个字段去重。
例如:
SELECT DISTINCT user_id FROM order_info WHERE status = 1 OR pay_type = 2;这里DISTINCT去重的是user_id相同的结果。OR条件是不是能走索引合并,取决于MySQL优化器和表上的索引。不能假设OR一定会走全表扫描,也不能假设OR具备去重能力。
6.5 JVM线程与MySQL慢查询联合排查
后端接口变慢时,只查JVM或只查MySQL都容易漏掉根因。实际项目中推荐两边同时取证。
JVM侧查看当前线程在干什么:
jstack <pid> > thread_dump.txt重点看线程状态是RUNNABLE还是BLOCKED,是否大量线程卡在数据库驱动的JDBC调用上。如果线程栈里能看到大量at com.mysql.cj.jdbc.ConnectionImpl方法,说明瓶颈大概率在数据库侧。
MySQL侧查看当前连接状态:
SHOW FULL PROCESSLIST;如果大量连接处于Waiting for table metadata lock、Lock wait timeout exceeded或在执行全表扫描的大SQL,说明问题在SQL或表锁。再用EXPLAIN看执行计划,结合慢查询日志确认执行时长。
还有一种典型情况:JVM参数没问题,MySQL索引也正常,但接口仍然慢。这时要检查数据库连接池配置是否过小,比如默认连接池只有10个连接,而高峰期并发请求超过100,线程会排队等待连接。排查时要同时看线程栈里的连接获取等待,以及连接池监控指标。
JVM和MySQL的联合排查并没有多高深,核心是保留现场:JVM保留线程栈、GC日志和堆快照,MySQL保留慢查询日志、processlist和explain结果。数据齐全后再定位根因,不建议凭经验直接改参数或改SQL。