最近在整理 JVM 相关的学习笔记和面试资料时,我发现一个现象:网上的 JVM 文章非常多,但大多数要么只讲概念,不讲落地;要么只贴代码,不讲原理。真正能让人一篇文章读下来,既明白了 JVM 是什么,又知道怎么排查问题、怎么调优、怎么应对面试的“典藏版”内容,其实很少。
这也就是我写这篇内容的原因。它围绕 JVM 的核心知识体系展开,从内存模型、类加载机制、垃圾回收器,到常用诊断工具、调优参数、面试高频问题,一次性把 JVM 的脉络串起来。不仅仅是“背八股”,更重要的是理解每个知识点背后的 Why 和 How,让你看完之后能直接用在项目排查里,也能用在面试复盘里。无论你是准备面试的 Java 工程师,还是正在处理线上 OOM、GC 频繁问题的开发者,这篇内容都值得认真读一遍。
1. JVM 整体运作逻辑与核心概念
1.1 JVM 到底是什么:从字节码到操作系统的翻译官
JVM(Java Virtual Machine,Java 虚拟机)本质上是一台运行在操作系统之上的“抽象计算机”。它自己定义了一套指令集(字节码指令)、寄存器、内存模型和异常处理机制。你用 Java 写的.java文件,编译后变成.class字节码文件,JVM 负责把这些字节码“翻译”成当前操作系统能理解的机器指令去执行。
这也是 Java 能“一次编写,处处运行”的根本原因。我在实际项目中感受最深的一点是:JVM 这一层抽象,让 Java 应用完全屏蔽了底层操作系统和硬件架构的差异。同样的一个 Spring Boot 应用,我可以直接从开发机(Windows)打包后丢到 Linux 服务器上跑,只要装了对应版本的 JDK,基本上不需要改任何代码。
JVM 还有一个容易被忽略的身份:它是一个操作系统进程。所以你在ps -ef | grep java时能看到它的进程号;它也要遵守操作系统的内存管理规则,所以 JVM 堆内存不是你指定多大就一定能申请到多大,它受限于物理内存和虚拟内存。理解了这一点,后面看 JVM 内存模型时会有更“落地”的感觉。
1.2 JVM 与 JRE、JDK 的关系:别再搞混这三层
很多人在面试时会遇到类似“JDK、JRE、JVM 的区别”的问题。三者的关系从外到内依次是:JDK 包含 JRE,JRE 包含 JVM。
- JDK(Java Development Kit):面向开发者的完整工具包,除了 JRE 之外,还包含
javac(编译器)、javap(反编译/字节码查看工具)、jmap、jstat、jstack等诊断工具。它是开发 Java 应用的最小环境。 - JRE(Java Runtime Environment):面向运行时的环境,包含 JVM 和 Java 标准库(
rt.jar、jce.jar等)。它只负责“跑”已经编译好的.class文件,不能编译源码。 - JVM(Java Virtual Machine):JRE 中最核心的部分,负责加载字节码、解释/编译执行、内存管理、垃圾回收。JRE 去掉了 JVM,就剩一堆类库;JVM 去掉了类库,也跑不起任何 Java 程序。
个人实践经验是,生产环境如果只是部署应用,装一个精简的 JRE 就够,甚至可以用jlink工具生成自定义最小化运行时,把几十 MB 的 JRE 精简到十几 MB,这在容器化部署时很有价值。这也是我从“知道概念”到“真正能落地”的一个转变。
1.3 JVM 的生命周期:启动、执行与退出
JVM 的生命周期大体分三个阶段:启动、运行、退出。
- 启动:通过
java -jar app.jar或java -cp com.example.Main方式启动。JVM 会先创建引导类加载器(Bootstrap ClassLoader),加载核心类库,然后创建初始线程(通常称为 main 线程),执行入口类的main方法。 - 运行:所有应用线程(包括 main 线程)并发运行。此时 JVM 的各个子系统——类加载子系统、运行时数据区、执行引擎、垃圾回收器——协同工作。
- 退出:当所有非守护线程执行完毕,或者某个线程调用了
System.exit()、Runtime.halt(),或者出现未捕获异常导致进程退出,JVM 就会终止。有一点需要注意,即使 main 线程提前结束,只要还有其他非守护线程(比如定时任务线程)在运行,JVM 就不会退出。
这个生命周期概念对理解线程和 JVM 的关系很重要。我曾经遇到过一个定时任务应用“明明主流程走完了,进程却不退”的问题,排查下来就是一个ScheduledExecutorService的线程池没有关闭,它创建的工作线程是非守护线程,导致 JVM 一直不退出。后来在代码里显式调用了executor.shutdown(),问题才解决。
2. JVM 内存模型与运行时数据区详解
2.1 运行时数据区:程序计数器、栈、堆、方法区
JVM 的内存模型是理解一切 GC、OOM、调优的基础。JVM 规范把运行时数据区分为以下 5 块(外加一块直接内存):
- 程序计数器(Program Counter Register):每个线程私有。记录当前线程正在执行的字节码指令地址。如果正在执行的是 native 方法,程序计数器值为空(Undefined)。它唯一的区域是没有任何 OutOfMemoryError 的区域,所以基本不用关注。
- 虚拟机栈(VM Stack):每个线程私有。每调用一个方法,JVM 就会在这个栈中压入一个栈帧(Stack Frame)。栈帧里存了局部变量表、操作数栈、动态链接、方法出口等信息。方法调用结束,栈帧弹出。如果线程请求的栈深度超过 JVM 允许的深度(比如死循环递归),就会抛
StackOverflowError;如果栈容量不足,就会抛OutOfMemoryError。 - 本地方法栈(Native Method Stack):每个线程私有。服务于 native 方法的调用(底层 C/C++ 实现)。
- 堆(Heap):所有线程共享。几乎所有的对象实例和数组都在这里分配(JIT 优化后可能栈上分配,后面细说)。它是 GC 管理的重点区域,也是调优的重点区域。
- 方法区(Method Area):所有线程共享。存储类元数据、静态变量、常量池、方法字节码等信息。在 JDK 8 之前,方法区通常被称为“永久代”(PermGen,但它并不完全等价);JDK 8 之后,方法区被元空间(Metaspace)取代,直接使用本地内存。
还有一个常被忽视的区域——直接内存(Direct Memory)。它不在 JVM 堆内,受操作系统内存限制。NIO 使用DirectByteBuffer时就会直接在这块区域分配。如果没控制好,会出现OutOfMemoryError: Direct buffer memory的报错。
2.2 堆内存的分代划分:新生代、老年代与元空间
JVM 堆默认采用分代设计,是因为大量研究对象发现:大部分对象“朝生夕死”,熬过几次 GC 的对象会倾向于长期存活。把堆分成不同代,就可以对不同代采用不同的 GC 策略,提高回收效率。
- 新生代(Young Generation):再细分为 Eden 区和两个 Survivor 区(from、to)。绝大多数新对象在 Eden 区分配。Eden 区满时触发 Minor GC(Young GC),存活对象复制到 Survivor 区。每次 Minor GC,对象的年龄加 1,默认年龄到 15(可设置
-XX:MaxTenuringThreshold)就会进入老年代。 - 老年代(Old Generation):存放长期存活的对象和大对象。Minor GC 后如果存活对象太多放不进 Survivor 区,会提前晋升到老年代。老年代空间不足时触发 Major GC(Full GC)。
- 元空间(Metaspace):JDK 8 开始,代替永久代。它使用本地内存,默认无上限(可以用
-XX:MaxMetaspaceSize限制),主要存放类元数据。之前遇到过的OutOfMemoryError: Metaspace问题,多半是动态代理或热部署不停地创建新类,导致元空间膨胀。
默认情况下,新生代和老年代比例为 1:2(通过-XX:NewRatio设置)。Eden 和两个 Survivor 的比例为 8:1:1(通过-XX:SurvivorRatio设置)。注意,这个 8:1:1 是理论值,实际申请内存时如果整块区域不够,JVM 还可以通过“分配担保”机制,让对象直接进入老年代。
2.3 对象创建与内存分配过程
一个 Java 对象的创建过程,比表面上的new一句话要复杂得多:
- 类加载检查:JVM 在
new时先检查常量池中是否有这个类的符号引用,如果没有,先执行类加载过程。 - 分配内存:根据类信息确定对象大小,从堆中划分一块内存。分配方式有两种:指针碰撞(Bump the Pointer)和空闲列表(Free List),取决于堆是否规整。当使用标记-整理算法的 GC(如 Serial、Parallel)后,堆规整,用指针碰撞;当使用标记-清除算法(如 CMS)时,堆不规整,用空闲列表。
- 内存空间初始化:将分配到的内存空间(除对象头外)都初始化为零值,这样可以在未赋初值的字段上直接看到默认值(0、null、false 等)。
- 设置对象头:在对象头中存储类元数据指针、哈希码(延迟计算)、GC 分代年龄、锁状态标记等信息。
- 执行
<init>方法:真正执行构造方法,按代码逻辑初始化对象实例字段。
在并发环境下,内存分配不是线程安全的。JVM 有两种解决方案:一种是对分配动作加锁(CAS + 失败重试),另一种是使用线程本地分配缓冲(Thread Local Allocation Buffer,TLAB)。实际 JVM 默认开启了-XX:+UseTLAB,每个线程在 Eden 区划分一小块专属缓存区域,分配对象时优先在自己的 TLAB 里分配,减少线程竞争。
我这里还有一条实践经验:大对象(比如很长的字节数组、大集合)不会在 Eden 区正常分配,而是直接进入老年代(取决于-XX:PretenureSizeThreshold参数)。这是为了避免在 Eden 区和两个 Survivor 区之间复制大对象的高额开销。所以如果你经常创建大对象,老年代会有很高的 GC 压力,这类现象在白名单查询、文件解析等场景很常见。
2.4 内存溢出与内存泄漏的本质区别
内存溢出(OutOfMemoryError)是内存不够用了,内存泄漏(Memory Leak)是对象本应该被回收,却因为错误的引用被一直持有,导致内存逐渐耗尽。线上最常见的情况是:内存泄漏累积到一定成都,最终表现为内存溢出。
区分两者最有效的方式是看内存监控曲线的趋势:内存泄漏会持续缓慢上升,GC 回收效果不佳;内存溢出往往是曲线快速冲高后直接报 OOM。常用的排查思路是通过 heap dump(堆转储)文件分析对象引用链。
典型的 OOM 类型包括:
java.lang.OutOfMemoryError: Java heap space:堆空间不足,绝大多数 OOM 都属此类。java.lang.OutOfMemoryError: GC overhead limit exceeded:GC 连续回收但回收不到 2% 内存,JVM 认为是死循环式 GC,主动抛出。java.lang.OutOfMemoryError: Metaspace:元空间不足,类元数据过多。java.lang.OutOfMemoryError: Direct buffer memory:直接内存不足,NIO 场景高发。java.lang.OutOfMemoryError: unable to create new native thread:线程数达到系统上限。
接到每个 OOM 问题时,第一步不是看代码,而是先去拿现场信息:GC 日志、堆转储文件、线程快照、系统日志。没有现场信息,排查效率会低一半以上。后面我会单独用一节来讲具体的排查命令和分析过程。
3. 垃圾回收机制:从算法到经典收集器
3.1 判断对象存活的两种方法
垃圾回收器要先知道“哪些对象死了”,才能进行回收。主流判定方法有两种:
- 引用计数法:给对象加一个引用计数器,有引用加 1,引用失效减 1。计数器为 0 的对象表示不可达。这种方法实现简单,但解决不了循环引用问题:A 引用 B,B 引用 A,外部没有任何引用指向它们,但各自计数器都不为 0,于是永远无法回收。
- 可达性分析算法:从一组称为 GC Roots 的根对象出发,通过引用链向下搜索。搜索路径称为 Reference Chain。如果一个对象到 GC Roots 没有任何引用链相连,那么这个对象不可达,可以被回收。GC Roots 包括:栈帧中的局部变量表引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI 引用的对象、活跃线程等。
现代主流 JVM 全部使用可达性分析。这也很考验你对代码的理解:一个对象被局部变量引用时,它在 GC Root 路径上是可达的;但局部变量生命周期结束后,即使对象还有成员变量引用它,这个对象也会变成不可达。
3.2 三大基础回收算法:标记-清除、标记-复制、标记-整理
- 标记-清除(Mark-Sweep):先标记不可达对象,再统一回收。这是最基础的方法,但有两个明显缺点:一是内存碎片化严重,产生大量不连续内存,后续分配大对象困难;二是标记和清除的效率不高,需要扫描全堆。
- 标记-复制(Mark-Copy):将可用内存按容量划分为大小相等的两块(通常用 Eden 和一块 Survivor 模拟),每次只使用其中一块。某一分区内存用完时,把存活对象复制到另一块空分区,然后清理原分区。优点是简单高效、无碎片;缺点是内存利用率只有一半左右,存活对象较多时复制开销大。新生代默认 8:1:1 的划分方式,就是利用这个原理,借助一块 Survivor 作为复制目标,实际可达到约 90% 的使用率。
- 标记-整理(Mark-Compact):标记存活对象,然后让所有存活对象向一端移动,再清理掉边界以外的内存。它避免了碎片化,但移动对象需要更新引用地址,会造成停顿(Stop The World,STW)。老年代存活率高的场景适用此算法。
3.3 主流的垃圾回收器横向对比与选择
JVM 中的垃圾收集器,是从“单线程”到“多线程”、从“追求吞吐量”到“追求低延迟”的演进过程。核心参数是-XX:+UseSerialGC、-XX:+UseParallelGC、-XX:+UseConcMarkSweepGC、-XX:+UseG1GC、-XX:+UseZGC。
以下是我对不同收集器的理解:
- Serial 串行收集器:单线程执行 Minor GC 和 Full GC,工作时必须暂停所有工作线程。适合单核 CPU、内存较小(比如几百 MB)、个人开发环境。JDK 8 默认的 64 位服务器模式下不是它。
- Parallel Scavenge 并行收集器(JDK 8 默认):多个线程并行收集,追求高吞吐量。适合对停顿时间不敏感的后台计算任务。吞吐量指标可控:
-XX:MaxGCPauseMillis可以设置期望停顿时间,-XX:GCTimeRatio可以设置吞吐量大小。这个“目标是设置期望停顿,实际上 JVM 会动态调整堆大小来尽量达到目标”,这点初学时容易误解。 - CMS 并发标记-清除收集器:以获取最短回收停顿为目标,适用于互联网站 B/S 服务端。基于标记-清除算法,会产生碎片,且在并发阶段存在“浮动垃圾”问题,JDK 9 开始废弃,JDK 14 已移除。
- G1 收集器(Garbage First):JDK 9 之后的默认收集器。把堆划分为多个等大小的 Region,可以做到可预测的停顿时间模型。它会优先回收垃圾最多的 Region,这也是“Garbage First”名字的由来。适合堆内存较大、服务端多核机器。几乎成了现代微服务应用的主流选择。
- ZGC 收集器(以及 Shenandoah):追求极低延迟,停顿时间可控制在 10ms 以内。与 G1 相比,它采用染色指针、读屏障等新技术,是面向大堆、高并发、低延迟场景的下一代收集器。不过它也有一定代价:CPU 开销更高,对 JDK 版本(JDK 11+)有要求。
实际工作中,JDK 8 我一般用默认的 Parallel,配置合理的堆大小和 GC 日志;JDK 17 之后直接使用默认 G1,根据业务调整 Region 大小和目标停顿时间,极少需要切换收集器。
3.4 三色标记与并发收集的底层问题
G1 和 CMS 在并发标记阶段都采用三色标记法来跟踪对象访问状态:
- 白色:对象未被标记,回收算法认为对象不可达(或还未访问)。
- 灰色:对象已标记,但其引用的对象还未全部标记。
- 黑色:对象及其所有引用的对象都已被标记。
在实际并发标记过程中,因为用户线程同时在修改对象引用,可能出现两种问题:
- 浮动垃圾:并发标记时用户线程修改了引用,导致某些对象变为垃圾,但在本轮的标记过程中还没被识别,只能留到下一轮回收。这是可接受的,只要不超过下一轮可用空间即可。
- 对象丢失:更严重。如果某个黑色对象从一开始就指向了一个白色对象,而用户线程又把该白色对象的唯一引用改成了另一个方向,白色对象会变成“事实上不可达”,但标记时却被忽略了。要避免丢失,需要有写屏障(Write Barrier)或读屏障(Read Barrier)来维护引用关系。
这也是理解 G1 和 CMS 复杂性的关键点。日常开发中,不一定需要你手动去干预这些底层机制,但在调优时你会看到日志中的concurrent-mark-start、remark、cleanup等阶段,知道每个阶段在干什么,定位问题会快很多。
4. 类加载机制与双亲委派模型
4.1 类加载的完整生命周期
Java 类从文件到可执行,要经过加载、验证、准备、解析、初始化 5 个阶段。如果编译期没做特殊优化,可能还会经历使用和卸载阶段。
- 加载(Loading):找到类的全限定名,读取二进制字节流,把字节流中的静态数据结构转换为方法区中的运行时数据结构,并在堆中生成一个
java.lang.Class对象。 - 验证(Verification):确保字节流符合 JVM 规范,不会被恶意篡改导致 JVM 崩溃。包括文件格式验证、元数据验证、字节码验证、符号引用验证。
- 准备(Preparation):为类的静态变量分配内存,并设置默认零值(不是给用户赋的初始值)。比如
public static int age = 29;在准备阶段,age 是 0,只有在初始化阶段才会被赋成 29。final 修饰的常量会直接在准备阶段赋值。 - 解析(Resolution):将常量池内的符号引用替换为直接引用。类中引用的其他类、方法、字段,都会在这里被解析为实际的内存地址或句柄。
- 初始化(Initialization):执行
<clinit>()方法,按代码顺序执行静态变量赋值和静态代码块。父类的初始化会先于子类。JVM 规范规定,只有当一个类被主动使用才会触发初始化:new对象、访问静态成员、调用静态方法、反射调用、初始化子类触发父类初始化、虚拟机启动时指定的主类等。
4.2 双亲委派模型:为什么要“先问爸爸”
双亲委派模型指的是:当一个类需要被加载时,它首先不会自己尝试加载,而是委派给父加载器,最终传到最顶层的启动类加载器。只有父加载器无法完成加载时,子加载器才会尝试自己加载。
类加载器层次:
- 启动类加载器(Bootstrap ClassLoader):加载
JAVA_HOME/lib下的核心类库,如rt.jar,是所有加载器的父亲,但没有父加载器。 - 扩展类加载器(Extension ClassLoader):JDK 9 后被平台类加载器(Platform ClassLoader)取代,加载
JAVA_HOME/lib/ext下的扩展包。 - 应用类加载器(App ClassLoader):加载 classpath 上的用户类。
双亲委派的好处主要有三点:
- 避免核心类库被随意覆盖。比如你写了一个
java.lang.String,理论上永远不应该被应用加载器加载,因为启动类加载器已经加载了官方版本,保证核心类的一致性。 - 防止重复加载相同类,不同加载器加载同一个类会生成不同的“类实例”甚至导致类型不匹配。
- 安全考虑,从源头防止自定义代码冒充核心 API。
实际工作中,大部分业务代码不需要打破双亲委派。但有两个常见场景需要打破:一个是 JDBC 等 SPI 机制(ServiceLoader)需要使用线程上下文类加载器加载驱动实现;另一个是 Tomcat 等 Web 容器,为了实现不同 Web 应用之间的类隔离,会自定义 WebAppClassLoader,打破“父先加载”的顺序,优先加载应用WEB-INF/classes下的类。
4.3 热部署与类卸载:动态性的双刃剑
类加载机制最实际的应用之一是热部署。修改了某个类后,不用重启进程,用一个新的类加载器重新加载。但有个坑:旧的类加载器和旧类不会被垃圾回收(因为被引用),频繁热部署会导致元空间占用持续增长,最终报Metaspace OOM。
所以在设计热加载方案时,不仅要考虑业务逻辑,还要考虑老加载器的释放策略。比如在 Tomcat 中热部署一个应用后,如果看到元空间持续升高,务必要关注是否产生了类加载器泄漏。这种情况在高频迭代发布、不断 reload 的系统中很典型。
5. 性能诊断与调优实战:从工具到参数
5.1 JDK 自带诊断工具速查:jps、jstat、jmap、jstack、jinfo
排查 JVM 问题时,命令行工具是即时可用、信息量最稳定的工具。我强烈建议每个 Java 开发者至少熟练使用以下 5 个命令。
- jps:查看当前系统中有哪些 Java 进程,输出进程 ID 和主类名。相当于 Linux 的
ps的 Java 专属版本。示例:jps -l -v,-v能看到启动时传给 JVM 的完整参数,非常有用。 - jstat:查看 JVM 统计信息,包括类加载、编译统计和 GC 统计。最常用的形式是
jstat -gcutil <pid> 1000 10,每秒打印一次 GC 信息,共打印 10 次。它列出 YGC(Young GC 次数)、YCT(Young GC 耗时)、FGC(Full GC 次数)、FCT(Full GC 耗时)、S0/S1/E/O/M(各内存区域占用比例)。 - jmap:生成堆转储快照,也可查看堆内存汇总信息。常用命令:
jmap -dump:format=b,file=heap.hprof <pid>。注意,在多数生产环境上,使用jmap -dump会触发一段时间的 STW,不要频繁执行。排查对象溢出时,建议用jmap -histo:live <pid>先看类对象直方图,再决定是否 dump。 - jstack:导出线程快照,查看当前所有线程状态、调用栈。排查死锁、线程卡死时最常用。示例:
jstack <pid> > thread_dump.txt,然后搜索BLOCKED、DEADLOCK、RUNNABLE等关键词。 - jinfo:查看和动态修改 JVM 参数。可以实时查看运行中的 JVM 参数,部分可管理参数可用
jinfo -flag +PrintGCDetails <pid>动态开启。
5.2 可视化工具:JConsole、VisualVM、MAT 实战对比
命令行工具是“战士”,可视化工具是“参谋”。根据不同场景,我会选择不同工具:
- JConsole:JDK 自带,轻量级,可以查看内存、线程、类、CPU 等基础信息。适合快速看一眼系统整体负载。
- VisualVM:功能比 JConsole 丰富,支持插件扩展,可以查看堆 dump、线程 dump,还能对 CPU 和内存抽样,适合开发阶段性能分析。
- MAT(Memory Analyzer Tool):独立工具,专门分析 heap dump 文件。它能自动计算对象保留大小(Retained Size),分析泄漏嫌疑(Leak Suspects),定位“哪个对象占了多少内存、被谁引用”。排查 OOM 时,这是我最常用的分析工具。
实际使用中,线上多数问题用jstat和jmap已经能定位大方向;如果真的遇到棘手的对象泄漏问题,再考虑用 MAT 做深度分析。
5.3 常用 JVM 调优参数清单与含义
调优不能靠“乱试参数”,需要先明确业务指标:是追求高吞吐还是低延迟,是内存受限还是 CPU 受限。以下是我在服务端项目里比较常用的一组参数:
-Xms2g -Xmx2g -Xmn512m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/app_heap.hprof -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log -XX:+PrintHeapAtGC -XX:+UseCompressedOops -XX:+DisableExplicitGC逐一说明我的设置考量:
-Xms2g -Xmx2g:堆初始大小和最大大小保持一致,避免运行期堆扩容引起的性能抖动。-Xmn512m:指定新生代大小。对于 G1,这个参数会被自动管理,但设置后可以给一个预期;对于 Parallel,这个参数非常有效。-XX:MaxGCPauseMillis=100:G1 的目标停顿时间,不保证一定达到,但 JVM 会尽量调整各个 Region 回收计划来接近目标。-XX:+HeapDumpOnOutOfMemoryError:应用 OOM 时自动生成堆转储文件。这一条一定要开,不然 OOM 时你连事发地点的现场都没有。-XX:+PrintGCDetails和-Xloggc:输出 GC 详细日志,排查 GC 频率和耗时。-XX:+DisableExplicitGC:禁止System.gc()触发 Full GC。防止某些框架在代码里显式调用 GC 导致线上 Full GC 高频。-XX:+UseCompressedOops:压缩普通对象指针,减少对象引用占用的内存,JDK 8 默认开启。
这里也提醒一句:JVM 调优本质是在“有限资源下找一个平衡点”,不是参数越多越好。我见过最典型的反面案例,是把几十个高级参数全部堆在启动命令里,结果每个参数之间互相干扰,反而更难排查。
5.4 从 GC 日志定位性能瓶颈的实操演示
GC 日志是 JVM 最真实的“体检报告”。以下是一个典型的 G1 日志片段:
[GC pause (G1 Evacuation Pause) (young), 0.0253169 secs] [Parallel Time: 8.8 ms, GC Workers: 8] [GC Worker Start (ms): Min: 1.2, Avg: 1.2, Max: 1.2, Diff: 0.1] [Ext Root Scanning (ms): Min: 0.2, Avg: 0.3, Max: 0.5, Diff: 0.3] ... [Code Root Fixup: 0.2 ms] [Clear CT: 0.3 ms] [Other: 1.4 ms] [Choose CSet: 0.1 ms] [Ref Proc: 0.5 ms] [Free CSet: 0.8 ms] [Eden: 2048.0K(2048.0K)->0.0B(2048.0K) Survivors: 0.0B->2048.0K Heap: 2080.1K(8192.0K)->52.0K(8192.0K)]看 GC 日志的核心方法有四个:
- 看 GC 暂停频率:如果每分钟都有大量的 Young GC,说明新生代容量太小,对象频繁分配和晋升。
- 看暂停时间:单次 YGC 如果超过 50ms,对高实时性业务影响就很大;FGC 超过 1 秒就要高度警惕。
- 看各区域变化:Eden 每次回收后是否清空、Survivor 是否放不下,这决定了对象晋升速率。
- 看 GC 种类:G1 的 Mixed GC 是老年代的回收,如果 Mixed GC 太频繁,说明老年代对象非常多,考虑优化代码或增大堆空间。
我之前优化过一个数据上报服务,日志里显示 Young GC 每 3 秒一次,频率很高。分析后确认是每次上报都创建了大量临时对象,最后通过对象复用和参数调整,把 YGC 降到了每 20 秒一次,接口 RT 明显下降。
5.5 一次真实的 OOM 排查过程:从报错到定位
结合热词里的报错线索,例如cannot collect jvm options caused by: 0: cannot read:"d:v作业实训 vjetbrain_,这其实是 Windows 环境下常见的一个问题:IDEA 或某些 Java 工具在读取.vmoptions配置文件时,路径中包含空格、冒号、中文或特殊符号导致解析失败。
特别是路径出现d:v作业实训 vjetbrain_时,可能是把d:\作业实训\vjetbrain_\...中的反斜杠转义丢了,冒号和空格被误判为参数分隔符。解决方法是:确保 IDE 的 vmoptions 文件路径中不要出现空格和中文,或者使用绝对路径并加引号。这类问题虽然不是 OOM 排查,但看起来是 JVM 启动失败的一大类原因。
真正的 OOM 排查流程,我一般这样做:
- 拿到日志,确定 OOM 类型(是 heap space 还是 Metaspace,或是 native thread)。
- 找到自动 dump 的 hprof 文件(前提是配置了
-XX:+HeapDumpOnOutOfMemoryError)。 - 用 MAT 打开 hprof,看 Leak Suspects 和 Dominator Tree。
- 定位占用内存最多的对象,查看 GC Root 引用链,确定谁持有这些对象。
- 回到代码,检查是缓存集合无界增长、连接池泄漏,还是大对象过多。
有一个经典案例:一个订单服务每天下午 4-5 点准时 OOM。原因不是高峰期流量大,而是定时任务在下午 4 点把当天的全量订单数据分批查出来做统计,每一批都缓存到了一个静态 List 里,最后 List 无限膨胀,最终堆内存耗尽。用 MAT 查看时,java.util.ArrayList对象和订单对象占比极高,引用链追回去就是一个静态变量。这给很多人的启示是:凡是“定时+全量+静态集合”组合的代码,一定要特别小心。
5.6 JVM 调优中的常见误区
很多工程师调优时容易走入几个误区:
- 误区一:调优一定能显著提升性能。其实大多数应用的性能瓶颈并不在 JVM 层,而在于 IO、SQL、网络、锁等。JVM 参数调优只是“把土建做好”,而不是“把装修做好”。
- 误区二:堆越大越好。堆越大,GC 时间越长,STW 可能更长,对低延迟业务反而不利。一般建议在稳定运行并观察 GC 后确定合理堆大小。
- 误区三:看到 Full GC 就觉得必须调。如果 Full GC 频率很低(比如几小时才一次),且停顿时间可接受,那就不需要动。频繁 Full GC 才是真正的警示信号。
- 误区四:照搬别人的参数。每台服务器的 CPU 核数、内存、部署应用、并发模型都不一样,一套参数不能放之四海而皆准。我的做法是:先设置基础参数,稳定跑一段时间看 GC 日志,再微调。
6. JVM 面试高频问题与避坑速查
6.1 十道必背 JVM 面试题及回答思路
JVM 是 Java 面试中的“必考科目”。结合热词中的高频搜索词,我整理了一份面试问题清单和回答要点。
1. 什么是 JVM?JVM、JRE 和 JDK 的关系?
回答思路:JVM 是 Java 虚拟机,负责执行字节码。JDK 包含 JRE,JRE 包含 JVM。重点强调 JVM 的实现目标是跨平台和内存安全。
2. JVM 内存模型(运行时数据区)是什么?
回答思路:画出 5 个区域,说出哪些是线程私有(程序计数器、虚拟机栈、本地方法栈),哪些是线程共享(堆、方法区/元空间)。补充 JDK 8 前后方法区的变化。
3. 对象的创建过程是怎样的?
回答思路:类加载检查、分配内存、初始化零值、设置对象头、执行构造方法。可顺手提到 TLAB、指针碰撞、空闲列表、CAS。
4. 怎么判断一个对象可以被回收?
回答思路:可达性分析。描述 GC Roots 有哪些,简单提一下引用计数法的局限性。
5. GC 有哪些算法?分别用在哪些区域?
回答思路:标记-清除、标记-复制、标记-整理。新生代用标记-复制,老年代用标记-整理或标记-清除(CMS)。
6. 什么是 Minor GC、Major GC、Full GC?
回答思路:Minor GC 是新生代回收,Major GC 是老年代回收,Full GC 是堆+方法区/元空间的整体回收。解释 promotion failed 和 concurrent mode failure。
7. G1 收集器相比 CMS 有什么优势?
回答思路:G1 把堆分成 Region,可以实现可预测的停顿时间模型,优先垃圾比例最高的 Region;CMS 基于标记-清除,会产生碎片,JDK 14 开始被彻底移除。
8. 双亲委派模型是什么?为什么要这样设计?
回答思路:层级加载链,从上到下依次为 Bootstrap、Platform/Extension、App。核心目的是避免核心类被篡改,防止类重复加载。补充 JDBC SPI 打破双亲委派的例子。
9. 什么情况下会触发 Full GC?
回答思路:老年代空间不足(晋升失败)、元空间不足、调用System.gc()(被-XX:+DisableExplicitGC禁用时除外)、CMS promotion failed 等。
10. 如果线上出现 CPU 100%,如何排查?
回答思路:top -Hp <pid>或jstack拿到线程栈,找到占用 CPU 最高的线程 ID,转成十六进制,在线程 dump 中搜索,定位到具体业务代码。这是实际场景题,答得好会加分。
6.2 几个最容易被忽略的“认知误区”提醒
面试里除了背答案,“踩坑点”也很重要。我说几个我见过高频翻车的认知误区:
- 误区一:元空间就是永久代。不完全等价。永久代在堆内,元空间在本地内存;字符串常量池在 JDK 7 后被挪到堆中,而不是永久代/元空间。
- 误区二:
-Xmx设置多大,JVM 就占用多大的物理内存。实际还包括元空间、线程栈、直接内存、JIT 代码缓存等,堆只是其中一块。 - 误区三:
System.gc()一定会立即触发 Full GC。只是“建议”JVM 执行垃圾回收,是否执行以及执行时机由 JVM 决定。 - 误区四:老年代对象一定比新生代对象“老”。如果一个对象太大,
PretenureSizeThreshold参数会直接把它放到老年代,导致老年代里出现“年纪轻轻但体型很大”的对象。
6.3 给准备面试的同学的一个建议:用“排查链路”串联知识
死记硬背很容易忘,最有效的记忆方式是“以点带面”:尝试把一个完整的问题场景串起来。
比如你准备“Full GC 频繁”这个面试题,可以用这条链路来组织答案:
观察 GC 日志 → 确认 FGC 频率和停顿时间 → 用 jstat 看各区域占用 → 用 jmap dump 堆快照 → 用 MAT 定位大对象 → 回到代码分析引用链 → 调整参数(堆大小、收集器、晋升阈值)→ 验证效果。
这样一讲,面试官会觉得你有实战经验,而不是只会背书。而且这条链路用到的问题,几乎覆盖了 JVM 内存模型、GC 算法、收集器、工具使用和调优参数的一半考点。
7. 一条实用的 JVM 学习与实践路线
如果你现在还是初学者,不知道怎么搭建自己的知识体系,我推荐按以下顺序进行:
- 先搭环境:本地装 JDK 8 和 JDK 17(不同版本参考默认收集器差异),写一个简单的 Spring Boot 项目。
- 跑起来再看监控:用 JConsole 或 VisualVM 连接本地进程,观察 Eden、Survivor、Old 区域的变化。
- 人为制造 OOM:写一个无限添加元素的
List或无限递归方法,触发不同 OOM,观察日志和 dump 文件。只有亲手制造过问题,才谈得上真正的排查能力。 - 学习调优参数:把
-Xms、-Xmx、-Xmn、GC 日志参数都配置到启动脚本中,通过日志观察效果。 - 进入源码层:阅读《深入理解 Java 虚拟机》关键章节,理解类加载和垃圾收集器实现。
- 做真实案例:把公司线上服务的内存曲线、GC 日志拿来看,尝试分析并给出优化建议。
这套路线不需要多高深的基础,只需要你会 Java 基本语法,愿意动手。
最后再分享一个小技巧:我在本地开发和排查问题时,通常会在启动脚本里把 GC 日志打开,路径固定,这样应用一旦有问题,日志自然就是现成的第一手证据。很多线上事故排查困难、耗时良久,往往不是因为问题有多复杂,而是因为提前没有留好“现场”。JVM 调优和排查一样,功夫都在平时。