第一章 JVM 运行时数据区(核心数据结构)
JVM 运行时数据区是 Java 程序运行时内存管理的核心,JVM 在启动时会将内存划分为不同的逻辑区域,各区域有明确的职责、创建销毁时机和内存特性。根据线程隔离性,可分为线程私有区域 和线程共享区域两大类,同时补充直接内存(JVM 可管理的堆外内存)。
一、线程私有区域(随线程创建而创建,线程结束而销毁)
1. 程序计数器(Program Counter Register)
- 核心作用:记录当前线程正在执行的字节码指令的地址(行号),是字节码解释器的指示器,负责选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖它完成。
- 核心特性 :
- 线程私有:每个线程都有独立的程序计数器,保证多线程切换后能恢复到正确的执行位置。
- 唯一不会抛出
OutOfMemoryError(OOM)的区域:程序计数器只存储指令地址,内存空间固定且极小,不存在内存溢出问题。 - 执行 Native 方法时,程序计数器的值为空(Undefined),因为 Native 方法由底层 C/C++ 实现,JVM 无法追踪其执行地址。
2. Java 虚拟机栈(Java Virtual Machine Stack)
- 核心作用 :为 Java 方法的执行提供内存支持,每个方法执行时都会同步创建一个栈帧(Stack Frame),方法从调用到执行完成的过程,对应栈帧在虚拟机栈中入栈到出栈的过程。
- 核心结构(栈帧) :栈帧是方法执行的最小单元,包含 4 个核心部分:
- 局部变量表 :存储方法参数和方法内定义的局部变量,数据类型包括基本数据类型(boolean/byte/char/short/int/float/long/double)、对象引用(reference 类型,指向对象的内存地址)、returnAddress 类型(指向字节码指令的地址)。
- 局部变量表的容量以 ** 变量槽(Slot)** 为最小单位,64 位的 long 和 double 占用 2 个 Slot,其余类型占用 1 个 Slot。
- 局部变量表在编译期就确定了最大容量,运行期不会改变。
- 操作数栈 :字节码指令的执行栈,用于计算过程中临时存储操作数和计算结果。比如执行
i+j时,会先将 i 和 j 压入操作数栈,再执行加法指令,弹出两个数计算后将结果压回栈中。- 操作数栈的最大深度也在编译期确定,32 位数据占用 1 个栈深度,64 位数据占用 2 个栈深度。
- 动态链接:将 Class 文件中方法的符号引用,在运行期转换为直接内存地址的引用。一部分符号引用在类加载阶段就解析为直接引用(静态解析),另一部分在每次运行期间才解析(动态链接,支持 Java 的多态特性)。
- 方法返回地址:方法执行结束后的返回位置,分为正常返回(执行到 return 指令)和异常返回(抛出未捕获的异常)。方法退出后,会根据返回地址恢复上层方法的执行状态,将返回值压入上层方法的操作数栈中。
- 局部变量表 :存储方法参数和方法内定义的局部变量,数据类型包括基本数据类型(boolean/byte/char/short/int/float/long/double)、对象引用(reference 类型,指向对象的内存地址)、returnAddress 类型(指向字节码指令的地址)。
- 异常情况 :
StackOverflowError:线程请求的栈深度超过 JVM 允许的最大深度(比如无限递归调用方法)。OutOfMemoryError:JVM 栈内存支持动态扩展时,扩展时无法申请到足够的内存(HotSpot 虚拟机的栈不支持动态扩展,只要申请栈空间成功就不会出现 OOM,申请失败直接 OOM)。
- 核心参数 :
-Xss,设置每个线程的虚拟机栈大小,默认值和平台相关(Linux/x64 默认 1MB)。
3. 本地方法栈(Native Method Stack)
- 核心作用:和虚拟机栈功能完全一致,区别在于虚拟机栈为 Java 方法服务,本地方法栈为 Native 方法(C/C++ 实现的本地方法)服务。
- 特性 :HotSpot 虚拟机直接将本地方法栈和虚拟机栈合二为一,没有做区分;同样会抛出
StackOverflowError和OutOfMemoryError异常。
二、线程共享区域(随 JVM 启动而创建,JVM 关闭而销毁)
1. Java 堆(Heap)
- 核心作用:Java 内存中最大的一块,唯一目的是存放对象实例和数组,Java 中几乎所有的对象实例都在这里分配内存(JIT 编译的逃逸分析优化后,对象可栈上分配,不再进入堆)。
- 核心结构(分代设计,经典分代模型) :堆内存基于分代回收思想,分为年轻代(Young Gen)和老年代(Old Gen) ,JDK8 默认年轻代:老年代 = 1:2(可通过
-XX:NewRatio调整)。- 年轻代 :存放新创建的对象,绝大多数对象都是朝生夕灭,回收频率高、速度快。分为 1 个 Eden 区和 2 个 Survivor 区(From Survivor/S0、To Survivor/S1),默认比例 Eden:S0:S1=8:1:1(可通过
-XX:SurvivorRatio调整)。- 对象优先在 Eden 区分配,Eden 区满时触发 Minor GC(年轻代 GC),存活的对象会复制到其中一个 Survivor 区;
- 每次 Minor GC 后,存活对象的年龄 + 1,当年龄达到阈值(默认 15,可通过
-XX:MaxTenuringThreshold调整),会晋升到老年代; - 动态年龄判定:Survivor 区中相同年龄的所有对象大小总和超过 Survivor 区的 50%,年龄大于等于该年龄的对象直接晋升老年代,无需等到阈值。
- 老年代:存放长期存活的对象、大对象,回收频率低、速度慢,老年代满时触发 Full GC(整堆 GC)。
- 年轻代 :存放新创建的对象,绝大多数对象都是朝生夕灭,回收频率高、速度快。分为 1 个 Eden 区和 2 个 Survivor 区(From Survivor/S0、To Survivor/S1),默认比例 Eden:S0:S1=8:1:1(可通过
- 特殊设计:TLAB(Thread Local Allocation Buffer)
- 线程本地分配缓冲区,是 Eden 区中为每个线程划分的私有分配区域,默认占用 Eden 区的 1%。
- 解决对象分配时的线程安全问题:堆是线程共享的,多个线程同时分配对象时,需要加锁保证线程安全,TLAB 让每个线程在自己的缓冲区分配对象,无需加锁,提升对象分配效率。
- 异常情况 :
OutOfMemoryError: Java heap space,堆中没有足够内存完成对象实例分配,且堆无法再扩展时抛出。 - 核心参数 :
-Xms:堆的初始内存大小,默认是物理内存的 1/64;-Xmx:堆的最大内存大小,默认是物理内存的 1/4;-Xmn:设置年轻代的大小,等价于同时设置-XX:NewSize和-XX:MaxNewSize;-XX:+UseTLAB:开启 TLAB(默认开启)。
2. 方法区(Method Area)
- 核心作用:存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据,是所有线程共享的区域。
- 关键实现演进(HotSpot 虚拟机) :
- JDK8 之前 :HotSpot 用 ** 永久代(Permanent Generation)** 实现方法区,永久代属于 JVM 堆内存的一部分,有固定的内存上限,无法动态扩展,容易出现
OutOfMemoryError: PermGen space异常。 - JDK8 及之后:永久代被彻底移除,改用 ** 元空间(Metaspace)** 实现方法区,元空间不再使用 JVM 堆内存,而是直接使用本地操作系统内存,默认只受本地物理内存限制,大幅降低了方法区 OOM 的概率。
- JDK8 之前 :HotSpot 用 ** 永久代(Permanent Generation)** 实现方法区,永久代属于 JVM 堆内存的一部分,有固定的内存上限,无法动态扩展,容易出现
- JDK8 替换永久代的核心原因 :
- 永久代有固定的内存上限,很难精准设置,调优复杂,容易出现 OOM;
- 永久代的 GC 和老年代绑定,只要其中一个满了就会触发 Full GC,回收效率低;
- 类和方法的元数据信息在程序运行中很难确定回收时机,放在本地内存更灵活,可由操作系统自动管理。
- 核心子区域:运行时常量池
- 是方法区的一部分,Class 文件中每个类都有一个常量池(存放编译期的字面量、符号引用),类加载后,这个常量池会被放入运行时常量池中。
- 核心特性:具备动态性,不仅能存储编译期生成的常量,运行期也能将新的常量放入池中(最典型的就是
String.intern()方法)。
- 异常情况 :
- JDK8 之前:
OutOfMemoryError: PermGen space,永久代内存不足; - JDK8 及之后:
OutOfMemoryError: Metaspace,元空间内存不足,类加载过多时出现(比如反射、动态代理、热部署场景)。
- JDK8 之前:
- 核心参数 :
-XX:MetaspaceSize:元空间初始大小,达到该值会触发 GC;-XX:MaxMetaspaceSize:元空间最大内存,默认无上限(受本地内存限制)。
三、字符串常量池(String Table)
- 核心作用:专门存储字符串对象的引用,避免重复创建字符串对象,节省内存,是 Java 对 String 类型的优化设计。
- 位置演进(重点,面试高频) :
- JDK6 及之前:字符串常量池存放在永久代中,和运行时常量池绑定;
- JDK7:字符串常量池从永久代移到了 Java 堆中,可被堆的 GC 回收;
- JDK8 及之后:永久代被元空间替换,字符串常量池依然存放在 Java 堆中。
- 核心特性 :
- 编译期确定的字符串字面量,会直接放入字符串常量池;
- 运行期通过
new String()创建的字符串对象,会在堆中分配内存,不会自动进入常量池,可通过intern()方法手动将字符串的引用放入常量池。
四、直接内存(Direct Memory)
- 核心定义:不属于 JVM 运行时数据区的一部分,也不是 JVM 规范中定义的内存区域,但被 JVM 频繁使用,且会抛出 OOM 异常。
- 核心作用:JDK1.4 引入的 NIO(New Input/Output),使用 Channel+Buffer 的 IO 模型,可通过 Native 函数直接分配堆外的本地内存,然后通过堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,避免了 Java 堆和 Native 堆之间来回拷贝数据,大幅提升 IO 效率。
- 特性 :
- 直接内存的分配不受 Java 堆大小限制,但受本地物理内存和操作系统进程内存限制;
- 直接内存的 GC 回收效率低,只能通过 Full GC 触发回收(System.gc ()),因为 JVM 只能通过 DirectByteBuffer 对象的引用管理这块内存,对象被回收后,对应的直接内存才会被释放。
- 异常情况 :
OutOfMemoryError: Direct buffer memory,直接内存达到上限时抛出。 - 核心参数 :
-XX:MaxDirectMemorySize,设置直接内存的最大大小,默认和 Java 堆的-Xmx值一致。
第二章 JVM 垃圾回收机制与垃圾回收器
垃圾回收(Garbage Collection,GC)是 JVM 的核心能力之一,负责自动回收堆内存中不再使用的对象,避免手动内存管理的内存泄漏、野指针等问题,提升开发效率。
一、垃圾回收核心基础
1. 什么是垃圾?
垃圾是指堆内存中已经死亡、不会再被任何途径使用的对象,GC 的核心就是找到这些垃圾对象,释放其占用的内存空间。
2. 如何判断对象是垃圾?
(1)引用计数法
- 原理:给每个对象添加一个引用计数器,每当有一个地方引用它,计数器 + 1;引用失效时,计数器 - 1;任何时刻计数器为 0 的对象,就是可回收的垃圾对象。
- 优点:实现简单,判断效率高。
- 致命缺点 :无法解决循环引用问题。比如两个对象互相引用,除此之外没有任何其他引用,它们的引用计数器都不为 0,无法被回收,造成内存泄漏。
- 使用情况:主流 JVM(HotSpot)没有采用该算法。
(2)可达性分析算法(主流 JVM 采用)
- 原理 :以一系列名为GC Roots的根对象作为起点,从这些起点开始向下遍历,遍历的路径称为引用链;如果一个对象到 GC Roots 之间没有任何引用链相连,说明该对象不可达,就是可回收的垃圾对象。
- 核心:GC Roots 包含哪些对象?(面试高频)
- 虚拟机栈(局部变量表)中引用的对象(方法中定义的局部变量、参数);
- 本地方法栈中 JNI(Native 方法)引用的对象;
- 方法区中类静态属性引用的对象(static 修饰的静态变量);
- 方法区中常量引用的对象(final 修饰的常量);
- 同步锁(synchronized)持有的对象;
- JVM 内部的基础对象(比如系统类加载器、核心异常对象、常驻的字符串对象)。
- 优点:解决了循环引用问题,判断精准,是 HotSpot 的核心算法。
3. Java 中的 4 种引用类型
JDK1.2 之后,Java 将引用分为 4 种类型,不同引用类型的回收策略不同,可灵活控制对象的生命周期。
表格
| 引用类型 | 定义 | 回收时机 | 核心使用场景 |
|---|---|---|---|
| 强引用(Strong Reference) | 最常见的引用,Object obj = new Object()就是强引用 |
只要强引用存在,垃圾回收器永远不会回收该对象,哪怕 OOM 也不回收 | 绝大多数的对象创建,日常开发的默认引用 |
| 软引用(SoftReference) | 用 SoftReference 包装的对象,非必需的引用 | 当 JVM 内存不足,即将发生 OOM 时,会回收软引用对象;内存充足时不会回收 | 内存敏感的缓存(比如图片缓存、网页缓存),内存不足时自动释放,避免 OOM |
| 弱引用(WeakReference) | 用 WeakReference 包装的对象,比软引用更弱 | 无论内存是否充足,只要触发垃圾回收,就会立即回收弱引用对象 | 临时缓存、ThreadLocal 的 key 实现,避免内存泄漏 |
| 虚引用(PhantomReference) | 也叫幽灵引用,用 PhantomReference 包装,最弱的引用,无法通过虚引用获取对象实例 | 完全无法决定对象的生命周期,对象被回收时会收到一个系统通知 | 跟踪对象的垃圾回收过程,管理堆外内存(比如 DirectByteBuffer 的回收) |
4. 对象的回收过程(两次标记机制)
一个对象被判定为不可达后,不会立即被回收,需要经历两次标记过程:
- 第一次标记 :对象经过可达性分析后,发现没有和 GC Roots 相连的引用链,会被第一次标记,同时判断该对象是否重写了
finalize()方法。- 如果对象没有重写
finalize()方法,或者finalize()方法已经被 JVM 执行过,直接进入回收队列,等待回收; - 如果对象重写了
finalize()方法,且从未被执行过,会被放入F-Queue队列中,等待 JVM 的 Finalizer 线程执行该方法。
- 如果对象没有重写
- 第二次标记 :JVM 会执行
F-Queue队列中对象的finalize()方法,执行过程中,如果对象重新和 GC Roots 建立了引用(比如把 this 赋值给某个静态变量),就会被移出回收队列,完成对象自救;如果没有建立引用,会被第二次标记,最终被回收。
- 关键注意点 :
finalize()方法只会被 JVM 执行一次,且执行时间不确定,不推荐使用,JDK9 已被标记为过时,推荐使用 try-with-resources 替代。
5. 垃圾回收核心算法
(1)标记 - 清除算法(Mark-Sweep)
- 执行过程 :分为两个阶段,标记阶段 (标记出所有需要回收的垃圾对象)、清除阶段(统一回收所有标记的对象,释放内存空间)。
- 优点:实现简单,不需要移动对象,执行效率高。
- 缺点 :
- 会产生大量不连续的内存碎片,碎片太多会导致后续分配大对象时,无法找到足够的连续内存,提前触发 GC;
- 标记和清除两个阶段的效率都会随着对象数量的增加而降低。
- 适用场景:老年代,对象存活时间长、回收频率低的场景(比如 CMS 收集器)。
(2)标记 - 复制算法(Mark-Copy)
- 执行过程:将可用内存分为大小相等的两块,每次只使用其中一块;当这块内存用完了,就将存活的对象复制到另一块内存中,然后一次性清空当前使用的整块内存。
- 优化版本(HotSpot 年轻代实现):年轻代中 98% 的对象都是朝生夕灭,不需要 1:1 划分内存,而是分为 Eden 区和两个 Survivor 区(8:1:1),每次使用 Eden 区和其中一个 Survivor 区,GC 时将存活的对象复制到另一个空的 Survivor 区,清空 Eden 和使用过的 Survivor 区。
- 优点 :
- 不会产生内存碎片,分配对象时只需要指针移动,实现简单,分配效率高;
- 只需要复制存活对象,回收效率高,适合存活对象少的场景。
- 缺点:可用内存被缩小了一部分,内存利用率低;如果存活对象过多,复制的开销会大幅增加。
- 适用场景:年轻代,对象存活时间短、存活率低的场景(所有年轻代收集器的核心算法)。
(3)标记 - 整理算法(Mark-Compact)
- 执行过程 :分为三个阶段,标记阶段 (标记所有存活对象)、整理阶段 (将所有存活对象向内存的一端移动,按顺序排列)、清除阶段(一次性清理掉边界以外的所有内存)。
- 优点:不会产生内存碎片,内存利用率高,后续分配大对象时不会因为碎片问题提前触发 GC。
- 缺点:需要移动存活对象,更新所有引用地址,执行过程中必须 STW,开销大,效率比标记 - 清除低。
- 适用场景:老年代,对象存活时间长、存活率高的场景(比如 Parallel Old、Serial Old 收集器)。
(4)分代回收算法(Generational Collection)
- 核心思想 :根据对象的存活周期,将堆内存分为年轻代和老年代,不同代采用不同的回收算法,兼顾回收效率和内存利用率。
- 年轻代:对象存活周期短,每次 GC 只有少量对象存活,采用标记 - 复制算法,只需要复制少量存活对象,效率极高;
- 老年代:对象存活周期长,存活率高,没有额外的内存空间做复制担保,采用标记 - 清除 或标记 - 整理算法。
- 是当前所有主流 JVM 的默认回收思想。
6. GC 核心概念
(1)Stop-The-World(STW)
- 定义:垃圾回收执行过程中,暂停所有用户线程,直到 GC 执行完成,这个暂停的过程就是 STW。
- 为什么必须有 STW:可达性分析过程中,如果用户线程还在运行,会导致对象的引用关系不断变化,无法保证可达性分析结果的准确性,甚至会导致 GC 出错。
- 核心优化目标:所有垃圾回收器的优化核心,就是尽可能缩短 STW 的时间,减少对用户线程的影响,提升系统响应速度。
(2)安全点(Safepoint)
- 定义:用户线程执行过程中,特定的位置,只有到达这些位置,线程才可以暂停,响应 GC 的 STW 请求,这些位置就是安全点。
- 安全点的选取原则:不能太少,导致 GC 等待线程时间过长;也不能太多,导致用户线程执行开销过大。通常会在方法调用、循环跳转、异常跳转等指令位置设置安全点。
(3)安全区域(Safe Region)
- 定义:一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置,都可以安全地响应 GC 的 STW 请求,这个区域就是安全区域。
- 解决的问题:对于处于 Sleep、Blocked 状态的线程,无法响应 GC 的安全点请求,无法走到安全点暂停,安全区域可以让这些线程在不执行的时候,也能安全地完成 GC。
(4)记忆集与卡表
- 解决的问题:跨代引用问题。比如老年代的对象引用了年轻代的对象,Minor GC 时,为了判断年轻代的对象是否存活,需要扫描整个老年代,开销极大。
- 记忆集:用于记录非收集区域指向收集区域的指针集合的抽象数据结构,避免全量扫描非收集区域。
- 卡表:记忆集的具体实现,HotSpot 采用卡表实现记忆集。将堆内存划分为一系列大小固定的卡页(默认 512 字节),用一个字节数组维护每个卡页的状态,卡页中只要有一个对象存在跨代引用,就标记为脏卡,Minor GC 时只需要扫描脏卡即可,无需扫描整个老年代,大幅降低扫描开销。
(5)写屏障
- 定义:在对象引用赋值的前后,加入的特定处理逻辑,类似 AOP 的切面。
- 作用:维护卡表的状态,当对象的引用发生赋值时,通过写屏障将对应的卡页标记为脏卡,保证卡表数据的准确性。
二、主流垃圾回收器详解
垃圾回收器是垃圾回收算法的具体实现,不同的回收器适用于不同的场景,核心优化方向分为两类:吞吐量优先 和低延迟优先。
吞吐量 = 用户代码执行时间 /(用户代码执行时间 + GC 执行时间),吞吐量越高,CPU 利用率越高,适合后台计算型任务;低延迟优先,就是尽可能缩短 STW 时间,适合用户交互、接口服务等对响应时间敏感的场景。
1. 串行回收器(单线程)
(1)Serial 收集器
- 作用区域:年轻代
- 核心算法:标记 - 复制算法
- 核心特性:单线程执行 GC,执行 GC 时,必须暂停所有用户线程(STW),直到 GC 完成。
- 优点:实现简单,内存开销小,没有线程交互的开销,单线程执行效率极高,是客户端模式下的默认年轻代收集器。
- 缺点:STW 时间长,多核 CPU 下无法利用多核优势,不适合服务端应用。
(2)Serial Old 收集器
- 作用区域:老年代
- 核心算法:标记 - 整理算法
- 核心特性:Serial 收集器的老年代版本,单线程执行,同样会触发全量 STW。
- 使用场景:客户端模式下的默认老年代收集器;JDK5 之前和 Parallel Scavenge 配合使用;作为 CMS 收集器发生 Concurrent Mode Failure 时的后备预案。
2. 并行回收器(多线程,吞吐量优先)
(1)ParNew 收集器
- 作用区域:年轻代
- 核心算法:标记 - 复制算法
- 核心特性:Serial 收集器的多线程版本,GC 时使用多线程执行,同样会触发 STW,除了多线程,其余行为和 Serial 完全一致。
- 优点:多核 CPU 下,回收效率远高于 Serial,能充分利用多核优势;是唯一能和 CMS 收集器配合的年轻代收集器。
- 缺点:单核 CPU 下,效率不如 Serial,因为有线程切换的开销。
(2)Parallel Scavenge 收集器
- 作用区域:年轻代
- 核心算法:标记 - 复制算法
- 核心特性 :多线程并行回收,核心目标是达到可控制的吞吐量,也被称为 "吞吐量优先收集器"。
- 核心优势 :
- 可精准控制吞吐量:提供
-XX:MaxGCPauseMillis(最大 GC 停顿时间)、-XX:GCTimeRatio(吞吐量占比)两个参数,精准控制 GC 的行为; - 自适应调节策略:开启
-XX:+UseAdaptiveSizePolicy(默认开启)后,JVM 会自动监控系统的运行状态,动态调整年轻代大小、Eden 和 Survivor 的比例、晋升老年代的年龄阈值,无需手动调优,只需要设置堆的最大内存和吞吐量目标即可。
- 可精准控制吞吐量:提供
- 和 ParNew 的核心区别:核心优化目标不同,ParNew 关注缩短 STW 时间,Parallel Scavenge 关注吞吐量;Parallel Scavenge 有自适应调节策略,ParNew 没有。
(3)Parallel Old 收集器
- 作用区域:老年代
- 核心算法:标记 - 整理算法
- 核心特性:Parallel Scavenge 收集器的老年代版本,多线程并行回收,吞吐量优先。
- 使用场景:JDK8 默认的垃圾回收器组合(Parallel Scavenge + Parallel Old),适合后台计算、大数据处理等吞吐量优先的场景。
3. 并发标记清除回收器(CMS,低延迟优先)
CMS(Concurrent Mark Sweep)是 HotSpot 虚拟机中第一款真正意义上的并发收集器,实现了 GC 线程和用户线程同时工作,核心目标是最短回收停顿时间,适合对响应时间要求高的互联网应用。
- 作用区域:老年代
- 核心算法:标记 - 清除算法
- 配合的年轻代收集器:ParNew
- 核心执行过程(4 个阶段,面试高频) :
- 初始标记(Initial Mark) :STW,只标记 GC Roots 能直接关联到的对象,速度极快,停顿时间非常短。
- 并发标记(Concurrent Mark):和用户线程并发执行,从初始标记的对象出发,遍历整个对象图,标记所有可达的存活对象,这个过程耗时较长,但不需要暂停用户线程。
- 重新标记(Remark):STW,修正并发标记期间,用户线程运行导致的标记变动的对象,停顿时间比初始标记长,但远短于并发标记的时间。
- 并发清除(Concurrent Sweep):和用户线程并发执行,清理所有标记的垃圾对象,释放内存空间,耗时较长,但不需要暂停用户线程。
- 优点 :
- 低延迟:核心耗时的并发标记和并发清除阶段,都和用户线程并发执行,只有两个短暂的 STW 阶段,极大缩短了停顿时间,用户体验好;
- 并发执行:充分利用多核 CPU 的优势,GC 和用户线程同时运行,CPU 利用率高。
- 缺点 :
- CPU 资源敏感:并发阶段会占用一部分 CPU 资源,导致用户线程的执行速度下降,CPU 核心数越少,影响越明显;
- 无法处理浮动垃圾:并发清除阶段,用户线程还在运行,会产生新的垃圾对象,这些对象只能等到下一次 GC 才能处理,称为浮动垃圾;因此 CMS 不能等到老年代完全满了再触发 GC,需要预留一部分内存给用户线程使用;
- Concurrent Mode Failure:CMS 运行期间,预留的内存无法满足用户线程创建新对象的需求,会触发该失败,此时 JVM 会降级使用 Serial Old 收集器,单线程执行 Full GC,导致长时间的 STW;
- 内存碎片 :基于标记 - 清除算法,会产生大量不连续的内存碎片,碎片过多会导致分配大对象时,无法找到足够的连续内存,提前触发 Full GC。CMS 提供了
-XX:+UseCMSCompactAtFullCollection参数,在 Full GC 时开启内存碎片整理(STW),还有-XX:CMSFullGCsBeforeCompaction参数,设置多少次 Full GC 后执行一次碎片整理。
- 使用场景:JDK8 及之前,对响应时间要求高的互联网服务、Web 应用,JDK9 之后逐渐被 G1 替代,JDK14 中被彻底移除。
4. G1 收集器(Garbage-First,兼顾吞吐量和低延迟)
G1 是 JDK9 默认的垃圾回收器,面向服务端应用,兼顾吞吐量和低延迟,打破了传统的分代物理划分,采用 Region 化的内存布局,支持可预测的停顿时间模型。
- 核心内存布局 :不再将堆分为物理上隔离的年轻代和老年代,而是将整个堆划分为多个大小相等的独立区域(Region),每个 Region 的大小为 1~32MB,必须是 2 的幂次,可通过
-XX:G1HeapRegionSize设置。 - 逻辑分代 :每个 Region 可以根据需要,扮演 Eden 区、Survivor 区、老年代区,JVM 会动态调整每个角色的 Region 数量;同时新增了Humongous Region,专门存放大对象(对象大小超过 Region 容量的 50%),大对象直接存放在 Humongous Region,不会进入年轻代,避免了大对象在年轻代频繁复制的开销。
- 核心算法:整体上是标记 - 整理算法,每个 Region 之间是标记 - 复制算法,不会产生内存碎片。
- 核心目标 :在延迟可控的情况下,实现高吞吐量,支持设置最大停顿时间目标(
-XX:MaxGCPauseMillis,默认 200ms),JVM 会根据停顿时间模型,优先回收垃圾最多的 Region,保证 GC 的效率和停顿时间可控。 - 核心执行过程(4 个阶段) :
- 初始标记(Initial Mark):STW,标记 GC Roots 直接关联的对象,修改 TAMS 指针,为下一阶段用户线程在 Region 中分配对象做准备,停顿时间极短,借助 Minor GC 同步完成,几乎没有额外停顿。
- 并发标记(Concurrent Mark):和用户线程并发执行,从 GC Roots 出发,遍历整个堆的对象图,标记存活对象,耗时较长,可被用户线程中断;同时计算每个 Region 的存活对象占比、回收价值。
- 最终标记(Final Mark):STW,修正并发标记期间用户线程导致的标记变动,停顿时间比初始标记长,但远短于并发标记。
- 筛选回收(Live Data Counting and Evacuation):STW,根据设置的最大停顿时间,排序各个 Region 的回收价值和成本,优先选择回收收益最高的 Region 组成回收集,将存活对象复制到空的 Region 中,清空原 Region 的内存。这个阶段因为只处理一部分 Region,停顿时间可控,可多线程并行执行。
- Mixed GC(混合 GC):G1 的核心回收模式,不是 Full GC,回收范围包括整个年轻代和一部分垃圾多的老年代 Region,根据停顿时间目标,动态调整回收的老年代 Region 数量,绝大多数场景下,G1 都是执行 Mixed GC,只有堆内存极度不足时,才会触发 Full GC(单线程 Serial Old,STW,需要避免)。
- 优点 :
- 可预测的停顿时间模型:用户可设置最大停顿时间,JVM 会自动调整回收范围,保证停顿时间不超过目标;
- 无内存碎片:整体采用标记 - 整理,Region 之间采用复制算法,不会产生内存碎片,避免了 CMS 的碎片问题;
- 大对象优化:专门的 Humongous Region 处理大对象,避免了大对象频繁复制和提前进入老年代的问题;
- 兼顾吞吐量和低延迟:既可以实现接近 CMS 的低延迟,也可以实现接近 Parallel 的高吞吐量,适用场景更广。
- 缺点 :
- 内存占用和执行开销比 CMS 高:需要为每个 Region 维护卡表,内存占用更高;写屏障的执行逻辑更复杂,用户线程运行时的开销更大;
- 小内存场景下,表现不如 CMS,堆内存大于 8G 时,优势才会明显体现。
- 使用场景:JDK9 + 默认回收器,适合堆内存较大、对响应时间有要求的服务端应用,是当前企业中使用最广泛的回收器。
5. 超低延迟回收器(ZGC/Shenandoah)
(1)ZGC
- 发布历程:JDK11 作为实验特性引入,JDK15 正式发布,JDK17 成为服务端模式默认回收器之一。
- 核心目标:实现 TB 级别的堆内存下,最大停顿时间不超过 1ms,且停顿时间不会随着堆内存的增大而增加,极致的低延迟。
- 核心技术 :染色指针、读屏障、Region 化布局、不分代(JDK21 开始支持分代 ZGC)、并发整理。
- 染色指针:将对象的标记信息直接存储在指针的高地址位中,不需要在对象头中设置标记位,实现了并发标记和并发整理,无需 STW;
- 读屏障:在读取对象引用时,执行少量的额外逻辑,保证并发整理过程中,对象引用的正确性,无需暂停用户线程。
- 核心特性:几乎所有阶段都和用户线程并发执行,只有初始标记和最终标记有短暂的 STW,停顿时间不超过 1ms,支持 8MB~16TB 的堆内存,无内存碎片。
- 使用场景:对延迟要求极高的金融交易、实时计算、互联网高并发接口服务,JDK11 + 版本可使用。
(2)Shenandoah
- 由 RedHat 开发,和 ZGC 目标一致,都是超低延迟回收器,核心差异是通过转发指针和读屏障实现并发整理,JDK12 引入,OpenJDK 支持,OracleJDK 不支持。
三、垃圾回收器选型建议
表格
| 业务场景 | 推荐回收器组合 | JDK 版本 |
|---|---|---|
| 客户端应用、单核 CPU、内存小 | Serial + Serial Old | 全版本 |
| 后台计算、大数据处理、吞吐量优先 | Parallel Scavenge + Parallel Old | JDK8 默认 |
| 互联网服务、响应时间要求高、堆内存 8G 以上 | G1 | JDK9 + 默认,JDK8 可手动开启 |
| 金融交易、实时系统、极致低延迟要求 | ZGC | JDK17+ |
第三章 JVM 内存调优全攻略
JVM 调优的核心不是盲目调整参数,而是基于业务场景,定位内存和 GC 问题,针对性优化,最终实现降低 GC 频率、减少 Full GC 次数、缩短 STW 时间、避免 OOM、提升系统吞吐量和稳定性的目标。
一、调优核心原则
- 优先优化业务代码,而非 JVM 参数:80% 以上的 JVM 问题都是业务代码导致的(比如内存泄漏、创建过多大对象、频繁调用 System.gc ()),优先排查和优化代码,再考虑 JVM 调优。
- 不盲目调优,先监控后优化:没有监控数据,就没有调优的依据,必须先收集 GC 日志、内存监控数据,定位问题根源,再针对性调整参数。
- 优先让 JVM 自适应,避免过度调优:JVM 有完善的自适应调节策略,比如 G1 的停顿时间模型、Parallel 的自适应大小调整,不要手动设置过多参数,限制 JVM 的自适应能力。
- 调优是迭代过程,必须有基准测试:每次调优后,必须通过压测验证效果,对比调优前后的吞吐量、响应时间、GC 情况,确认优化有效,避免调优后出现新的问题。
- Minor GC 优先,避免频繁 Full GC:Full GC 的 STW 时间远长于 Minor GC,调优的核心目标之一,就是减少 Full GC 的次数,让对象尽可能在年轻代被回收。
二、调优前置准备:核心 JVM 参数分类
1. 堆内存核心参数
表格
| 参数 | 含义 | 调优建议 |
|---|---|---|
-Xms |
堆初始内存大小 | 建议和-Xmx设置为相同值,避免堆内存动态扩容和缩容带来的性能损耗 |
-Xmx |
堆最大内存大小 | 建议设置为物理内存的 50%~70%,预留足够的内存给操作系统、元空间、直接内存和其他进程 |
-Xmn |
年轻代大小 | 经典分代模型下,建议设置为堆内存的 30%~50%;IO 密集型业务可适当调大,CPU 密集型业务可适当调小;G1 不建议手动设置,由 JVM 自适应调整 |
-XX:NewRatio |
老年代和年轻代的比例 | 默认 2,即老年代:年轻代 = 2:1;Parallel 回收器可根据业务调整,G1 不建议设置 |
-XX:SurvivorRatio |
Eden 区和单个 Survivor 区的比例 | 默认 8,即 Eden:S0:S1=8:1:1;如果 Survivor 区经常满,对象提前晋升老年代,可适当调大 Survivor 比例 |
-XX:MaxTenuringThreshold |
对象晋升老年代的最大年龄阈值 | 默认 15,Parallel 回收器默认 6;如果对象存活时间长,可适当调大,避免对象提前进入老年代 |
-XX:+UseTLAB |
开启线程本地分配缓冲区 | 默认开启,无需关闭,提升对象分配效率 |
-XX:PretenureSizeThreshold |
大对象直接进入老年代的阈值 | 只对 Serial 和 ParNew 有效,默认 0,即所有对象优先在 Eden 分配;可设置该值,让超过阈值的大对象直接进入老年代,避免年轻代频繁复制 |
2. 元空间参数
表格
| 参数 | 含义 | 调优建议 |
|---|---|---|
-XX:MetaspaceSize |
元空间初始大小,达到该值触发 GC | 建议根据应用加载的类数量设置,默认 21MB,类加载多的应用可适当调大,减少元空间 GC 次数 |
-XX:MaxMetaspaceSize |
元空间最大大小 | 建议设置一个上限(比如 512MB),避免元空间无限占用本地内存,默认无上限 |
3. 栈内存参数
表格
| 参数 | 含义 | 调优建议 |
|---|---|---|
-Xss |
每个线程的栈内存大小 | Linux/x64 默认 1MB;如果线程数过多,可适当调小(比如 256KB/512KB),避免内存不足;如果方法递归深度大,可适当调大 |
4. 直接内存参数
表格
| 参数 | 含义 | 调优建议 |
|---|---|---|
-XX:MaxDirectMemorySize |
直接内存最大大小 | 默认和-Xmx一致;如果使用 NIO、Netty 等框架,可根据业务设置,避免直接内存 OOM |
5. 垃圾回收器相关参数
(1)通用回收器参数
表格
| 参数 | 含义 |
|---|---|
-XX:+UseSerialGC |
开启 Serial+Serial Old 回收器 |
-XX:+UseParallelGC |
开启 Parallel Scavenge+Parallel Old 回收器(JDK8 默认) |
-XX:+UseParNewGC |
开启 ParNew 回收器,配合 CMS 使用 |
-XX:+UseConcMarkSweepGC |
开启 CMS+ParNew 回收器 |
-XX:+UseG1GC |
开启 G1 回收器(JDK9 + 默认) |
-XX:+UseZGC |
开启 ZGC 回收器(JDK11+) |
-XX:MaxGCPauseMillis |
GC 最大停顿时间目标,G1、ZGC 有效 |
-XX:+DisableExplicitGC |
禁用显式的 System.gc () 调用,避免手动触发 Full GC |
(2)CMS 专属参数
表格
| 参数 | 含义 |
|---|---|
-XX:CMSInitiatingOccupancyFraction |
老年代内存占用达到该比例时,触发 CMS GC,默认 68% |
-XX:+UseCMSCompactAtFullCollection |
Full GC 时开启内存碎片整理 |
-XX:CMSFullGCsBeforeCompaction |
多少次 Full GC 后执行一次碎片整理,默认 0,每次 Full GC 都整理 |
-XX:+CMSConcurrentMTEnabled |
并发阶段开启多线程执行 |
(3)G1 专属参数
表格
| 参数 | 含义 |
|---|---|
-XX:G1HeapRegionSize |
设置每个 Region 的大小,1~32MB,必须是 2 的幂次 |
-XX:G1MixedGCCountTarget |
一次并发标记后,执行 Mixed GC 的次数,默认 8 |
-XX:G1HeapWastePercent |
堆内存允许的浪费比例,默认 5%,当可回收内存低于该比例,停止 Mixed GC |
-XX:InitiatingHeapOccupancyPercent |
堆内存占用达到该比例时,触发并发标记周期,默认 45% |
6. GC 日志与 OOM 诊断参数
表格
| 参数 | 含义 | 调优建议 |
|---|---|---|
-XX:+HeapDumpOnOutOfMemoryError |
OOM 时自动生成堆 dump 文件 | 必须开启,用于排查 OOM 问题 |
-XX:HeapDumpPath=/path/heapdump.hprof |
dump 文件的保存路径 | 建议设置到有足够磁盘空间的目录 |
-Xloggc:/path/gc.log |
输出 GC 日志到指定文件(JDK8 及之前) | 必须开启,用于分析 GC 情况 |
-XX:+PrintGCDetails |
打印 GC 详细信息(JDK8 及之前) | 配合 GC 日志使用 |
-XX:+PrintGCDateStamps |
打印 GC 的时间戳(JDK8 及之前) | 配合 GC 日志使用 |
-Xlog:gc*:file=/path/gc.log |
JDK9 + 统一日志框架,输出 GC 详细日志 | JDK9 + 使用 |
三、JVM 调优全流程
步骤 1:确定业务指标与基准线
调优前必须明确业务的核心指标,建立性能基准线,否则无法判断调优效果:
- 核心指标 :
- 吞吐量:系统每秒能处理的请求数(QPS/TPS);
- 响应时间:接口的平均响应时间、P95/P99 响应时间;
- 可用性:系统的错误率、超时率;
- GC 指标:Minor GC 的频率和平均停顿时间、Full GC 的频率和停顿时间。
- 压测获取基准线:使用 JMeter、Gatling 等压测工具,模拟线上流量,获取系统在正常负载和峰值负载下的基准数据,同时收集 GC 日志、内存使用情况。
步骤 2:监控与数据收集
通过监控工具收集 JVM 运行数据,定位问题,核心收集两类数据:
- 实时运行数据:堆内存各区域的使用情况、GC 的实时频率和停顿时间、线程状态、CPU 和内存占用;
- 离线日志数据:完整的 GC 日志、OOM 时的堆 dump 文件、线程 dump 文件。
步骤 3:问题定位与根因分析
根据收集的数据,定位核心问题,常见的 JVM 问题分为以下几类:
表格
| 问题现象 | 常见根因 |
|---|---|
| 频繁 Minor GC | 新生代设置过小、Eden 区比例不合理、业务代码频繁创建大量短生命周期对象、Survivor 区过小,对象频繁晋升老年代 |
| 频繁 Full GC | 老年代内存不足、元空间溢出、显式调用 System.gc ()、内存泄漏、大对象过多、CMS 的 Concurrent Mode Failure、老年代内存碎片过多 |
| OOM 异常 | 堆内存溢出(对象创建过多、内存泄漏)、元空间溢出(类加载过多)、直接内存溢出、栈内存溢出、堆外内存泄漏 |
| STW 时间过长、系统卡顿 | GC 收集器选型不合理、堆内存过大、Full GC 频繁、安全点设置不合理、YGC 停顿时间过长、内存碎片过多 |
| 系统吞吐量低 | GC 占用过多 CPU 资源、频繁 GC 导致用户线程执行时间不足、自适应策略被限制、内存分配效率低 |
步骤 4:针对性优化
根据问题根因,先优化业务代码,再调整 JVM 参数:
- 业务代码优化(优先) :
- 避免频繁创建大量临时对象,复用对象,减少垃圾生成;
- 避免创建大对象(比如超大 byte 数组、大字符串),减少大对象对内存的影响;
- 排查内存泄漏,释放无效的对象引用(比如静态集合类只添加不删除、ThreadLocal 未手动 remove、流资源未关闭);
- 移除代码中显式的 System.gc () 调用,避免手动触发 Full GC;
- 优化循环、递归,减少方法调用深度,避免栈溢出。
- JVM 参数优化 :
- 内存大小优化:调整堆内存、年轻代、元空间的大小,匹配业务场景;
- 回收器选型优化:根据业务的吞吐量 / 延迟需求,更换合适的回收器;
- 回收策略优化:调整 Survivor 比例、晋升阈值、GC 触发阈值等参数,优化 GC 行为;
- 日志与诊断优化:开启 GC 日志和 OOM 自动 dump,方便后续排查。
步骤 5:压测验证与迭代
- 调整参数后,使用相同的压测场景,重新执行压测,对比调优前后的核心指标;
- 确认指标是否达到预期,GC 情况是否改善,是否出现新的问题;
- 如果未达到预期,重新分析数据,调整优化方案,重复上述步骤,直到达到业务目标。
步骤 6:线上监控与持续优化
- 调优完成后,上线应用,通过 Prometheus+Grafana、SkyWalking、Pinpoint 等 APM 工具,持续监控线上 JVM 的运行状态;
- 关注业务高峰期的 GC 情况、内存使用情况,及时发现和解决潜在问题;
- 随着业务迭代,流量和代码发生变化,定期重新评估 JVM 参数,持续优化。
四、常用 JVM 监控与故障排查工具
1. 命令行工具(JDK 自带,线上必备)
表格
| 工具 | 核心作用 | 常用命令 |
|---|---|---|
| jps | 查看当前系统中所有的 Java 进程,获取进程 PID | jps -l:输出进程 PID 和主类全限定名 |
| jstat | 实时监控 JVM 的 GC 情况、类加载情况、内存使用情况 | jstat -gc <pid> 1000 10:每秒输出一次 GC 统计信息,共输出 10 次 |
| jmap | 生成堆 dump 文件,查看堆内存使用情况、对象统计信息 | jmap -dump:format=b,file=heapdump.hprof <pid>:生成堆 dump 文件;jmap -histo <pid>:输出堆中对象的统计信息 |
| jstack | 生成线程 dump 文件,查看线程状态、死锁、阻塞问题 | jstack <pid> > threaddump.txt:输出线程栈信息到文件 |
| jcmd | JDK 全能工具,整合了 jps、jstat、jmap、jstack 的所有功能 | jcmd <pid> GC.heap_dump heapdump.hprof:生成堆 dump;jcmd <pid> Thread.print:输出线程栈;jcmd <pid> GC.class_histogram:输出对象统计 |
| jhat | 分析堆 dump 文件,生成 HTML 页面,查看对象信息 | 现在基本被 MAT 替代,很少使用 |
2. 可视化分析工具
表格
| 工具 | 核心作用 | 适用场景 |
|---|---|---|
| MAT(Memory Analyzer Tool) | Eclipse 出品的堆 dump 分析工具,功能强大,可快速定位内存泄漏、大对象占用、对象引用关系 | 线下分析 OOM 的堆 dump 文件,定位内存泄漏根因 |
| VisualVM | JDK 自带的可视化工具,可实时监控 JVM 的内存、GC、线程、CPU 情况,生成和分析堆 dump | 本地开发、测试环境监控和问题排查 |
| JConsole | JDK 自带的轻量级可视化监控工具,可实时查看内存、线程、类加载、MBean 信息 | 简单的实时监控,入门级工具 |
| JProfiler | 商业级性能分析工具,功能全面,可监控内存、CPU、线程、GC,支持线上远程监控 | 复杂的性能问题排查,深度调优 |
| Arthas | 阿里开源的 Java 线上诊断神器,无需重启应用,可实时查看 JVM 状态、方法执行耗时、类加载信息、修改日志级别,支持在线热更新代码 | 线上生产环境问题排查,无需停机,功能极其强大 |
| GCEasy | 在线 GC 日志分析工具,上传 GC 日志,自动生成可视化分析报告,给出优化建议 | 快速分析 GC 日志,定位 GC 问题,无需安装工具 |
五、常见问题调优实战案例
案例 1:频繁 Young GC 优化
- 现象:压测时,QPS 上不去,CPU 占用高,Minor GC 每秒触发 1 次以上,每次停顿时间 10ms 以上,Survivor 区使用率经常达到 100%,对象频繁提前晋升老年代。
- 根因分析:新生代设置过小,Eden 区容量不足,业务代码频繁创建大量临时对象,导致 Eden 区快速填满,触发 YGC;Survivor 区设置过小,存活对象无法容纳,直接晋升老年代。
- 优化方案 :
- 调大新生代大小,
-Xmn从 2G 调整到 4G,堆总内存-Xms/-Xmx从 8G 调整到 12G; - 调整
-XX:SurvivorRatio从 8 调整到 6,增大 Survivor 区的容量,避免对象提前晋升; - 优化业务代码,复用对象,减少循环中临时对象的创建,降低垃圾生成速度。
- 调大新生代大小,
- 优化效果:YGC 频率降低到每 10 秒 1 次,每次停顿时间缩短到 5ms 以内,对象晋升老年代的频率大幅降低。
案例 2:频繁 Full GC 优化
- 现象:线上系统每隔 1 小时触发 1 次 Full GC,每次停顿时间超过 1s,系统卡顿,老年代内存占用在 Full GC 后快速上涨。
- 根因分析:通过 MAT 分析堆 dump 文件,发现静态 HashMap 中缓存了大量的业务对象,只添加不删除,没有过期策略,导致内存泄漏,老年代被占满,触发 Full GC;Full GC 只能回收少量无效对象,很快又会被填满。
- 优化方案 :
- 修复业务代码,给缓存添加过期时间和最大容量限制,使用 Guava Cache 替代手动实现的 HashMap,避免内存泄漏;
- 开启
-XX:+DisableExplicitGC,禁用代码中显式的 System.gc () 调用; - 调整老年代大小,优化 CMS 的触发阈值
-XX:CMSInitiatingOccupancyFraction从 68% 调整到 75%,减少 CMS GC 的频率。
- 优化效果:内存泄漏问题解决,Full GC 频率降低到每天 1 次以内,系统卡顿问题消失。
案例 3:OOM 问题排查与优化
- 现象 :线上系统频繁抛出
OutOfMemoryError: Java heap space异常,服务宕机。 - 排查步骤 :
- 开启
-XX:+HeapDumpOnOutOfMemoryError,OOM 时自动生成堆 dump 文件; - 用 MAT 打开 dump 文件,查看占用内存最大的对象,发现是一个超大的 List 集合,占用了 80% 的堆内存;
- 查看引用链,找到该集合的创建位置,发现是业务代码中,一次性从数据库查询了全表 1000 万条数据,加载到内存中处理,导致堆内存溢出。
- 开启
- 优化方案 :
- 优化业务代码,全表查询改为分页查询,分批处理数据,避免一次性加载全量数据到内存;
- 调整堆内存大小,
-Xms/-Xmx从 4G 调整到 8G,预留足够的内存空间; - 增加参数校验,限制查询的最大条数,避免非法请求导致的全表查询。
- 优化效果:OOM 问题彻底解决,系统运行稳定。
六、JVM 调优最佳实践
- 堆内存设置 :
-Xms和-Xmx必须设置为相同值,避免堆动态扩容的性能损耗;堆最大内存不要超过物理内存的 70%,预留足够的内存给操作系统和其他进程。 - 新生代设置:经典分代模型下,新生代大小建议为堆的 30%~50%,避免新生代过小导致频繁 YGC,也避免新生代过大导致老年代空间不足;G1 回收器不建议手动设置新生代大小,让 JVM 自适应调整。
- 回收器选型:JDK8 优先使用 Parallel(吞吐量优先)或 CMS(低延迟优先);JDK9 + 优先使用 G1;JDK17 + 对延迟要求极高的场景使用 ZGC。
- 日志与诊断:线上环境必须开启 GC 日志和 OOM 自动 dump,方便问题排查;日志路径要设置到有足够磁盘空间的目录,避免磁盘占满。
- 线上操作规范 :线上环境不要随便执行
jmap -dump、jstack等命令,尤其是大堆内存场景,可能会触发长时间 STW,导致系统卡顿;优先使用 Arthas 等非侵入式工具排查问题。 - 避免过度调优:不要盲目复制网上的参数配置,每个业务场景的特性不同,必须基于自己的监控数据调优;优先使用 JVM 的默认自适应策略,只调整核心参数。
第四章 全网高频 JVM 面试题详解
一、JVM 基础篇
1. 什么是 JVM?JVM 的主要作用是什么?
标准答案:JVM 是 Java Virtual Machine(Java 虚拟机)的缩写,是一个虚构出来的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现,是 Java 跨平台特性的核心。核心作用:
- 执行 Java 字节码 :将编译后的
.class字节码文件,通过解释器或即时编译器(JIT)翻译成机器码,在操作系统上执行; - 内存管理:自动管理 Java 程序的内存分配和回收,实现垃圾自动回收,避免手动内存管理的泄漏和野指针问题;
- 跨平台实现:屏蔽了底层操作系统和硬件的差异,Java 程序只需要编译成字节码,就可以在任意安装了对应平台 JVM 的设备上运行,实现 "一次编写,到处运行";
- 安全保障:提供了字节码验证、安全沙箱等机制,限制 Java 程序的非法操作,保障执行安全。
2. JDK、JRE、JVM 的区别和联系?
标准答案 :三者是包含关系,范围从大到小:JDK > JRE > JVM。
- JVM:Java 虚拟机,核心负责执行字节码,是 Java 程序能够运行的核心,本身无法单独使用,需要配合类库等资源。
- JRE :Java Runtime Environment(Java 运行时环境),是 Java 程序运行的最小环境,包含JVM + Java 核心类库(rt.jar 等)、运行时的工具和资源。如果只需要运行已经编译好的 Java 程序,安装 JRE 即可。
- JDK :Java Development Kit(Java 开发工具包),是 Java 开发的完整环境,包含JRE + 编译工具(javac)、调试工具(jdb)、监控工具(jps/jstat 等)、开发所需的类库和文档。Java 开发必须安装 JDK。
3. 一个 Java 程序从编写到执行的全过程是什么?
标准答案 :Java 程序从编写到执行,分为编译期 和运行期两个核心阶段:
- 编译期 :
- 开发者编写
.java后缀的 Java 源代码文件; - 通过 JDK 的
javac编译器,将 Java 源代码编译成 JVM 可识别的.class字节码文件,编译过程中会进行语法校验、语义分析,生成字节码; - 字节码文件不面向任何特定的操作系统,只面向 JVM,是跨平台的核心。
- 开发者编写
- 运行期 :
- 通过
java命令启动 JVM,JVM 的类加载器(ClassLoader)将字节码文件加载到 JVM 的方法区中; - 类加载完成后,JVM 会通过字节码解释器,将字节码逐行翻译成机器码,交给操作系统执行;
- 对于热点代码(频繁执行的方法、循环体),JVM 的即时编译器(JIT)会将其编译成本地机器码,进行优化,直接执行,提升执行效率;
- 程序运行过程中,JVM 自动进行内存分配和垃圾回收,程序执行结束后,JVM 退出。
- 通过
4. 什么是字节码?字节码的好处是什么?
标准答案 :字节码是 Java 源代码经过javac编译后生成的.class文件中的内容,由一系列 JVM 可识别的字节指令组成,每个指令占 1 个字节,因此称为字节码。字节码的核心好处:
- 实现跨平台:字节码不面向特定的操作系统和硬件,只面向 JVM,不同平台的 JVM 都可以识别和执行相同的字节码,实现了 Java 的 "一次编写,到处运行";
- 屏蔽了底层硬件和操作系统的差异:开发者无需关注底层平台的细节,只需要编写一次代码;
- 提升了执行效率:相比源代码,字节码是经过编译优化的中间格式,JVM 执行字节码的效率远高于直接解释执行源代码;
- 提供了安全保障:字节码在加载时会经过 JVM 的字节码验证,校验格式、语义、安全性,避免恶意代码执行,保障了程序的安全。
5. JVM 的主要组成部分有哪些?
标准答案:JVM 主要分为 4 大核心组成部分,各司其职,共同完成 Java 程序的执行和管理:
- 类加载子系统 :负责从文件系统或网络中加载
.class字节码文件,完成类的加载、验证、准备、解析、初始化全流程,将类信息加载到方法区中。 - 运行时数据区:也就是 JVM 内存结构,分为程序计数器、虚拟机栈、本地方法栈、堆、方法区,负责存储 JVM 运行过程中的数据、对象、指令等信息。
- 执行引擎 :JVM 的核心执行单元,负责执行字节码指令,分为 3 个部分:
- 解释器:逐行解释字节码,翻译成机器码执行,启动快,执行效率低;
- 即时编译器(JIT):将热点代码编译成本地机器码,进行深度优化,执行效率高;
- 垃圾回收器:自动回收堆内存中不再使用的对象,释放内存空间。
- 本地方法接口:负责调用 Native 本地方法(C/C++ 实现),通过本地方法栈管理 Native 方法的执行,扩展 JVM 的能力,与底层操作系统交互。
二、JVM 内存结构篇
1. 详细介绍 JVM 运行时数据区?
标准答案 :JVM 运行时数据区是 JVM 在运行时对内存的逻辑划分,根据线程隔离性,分为线程私有区域 和线程共享区域两大类,具体如下:
- 线程私有区域 :随线程创建而创建,线程结束而销毁,线程之间互不影响。
- 程序计数器:记录当前线程执行的字节码指令的地址,是线程恢复执行的基础,唯一不会抛出 OOM 的区域。
- Java 虚拟机栈:为 Java 方法执行服务,每个方法执行都会创建一个栈帧,栈帧包含局部变量表、操作数栈、动态链接、方法返回地址,方法调用对应入栈,执行完成对应出栈,会抛出 StackOverflowError 和 OOM。
- 本地方法栈:和虚拟机栈功能一致,为 Native 本地方法服务,HotSpot 将其与虚拟机栈合二为一。
- 线程共享区域 :随 JVM 启动而创建,JVM 关闭而销毁,所有线程共享。
- Java 堆:JVM 中最大的内存块,唯一目的是存放对象实例和数组,是垃圾回收的核心区域,分为年轻代(Eden+2 个 Survivor)和老年代,会抛出 OOM。
- 方法区:存储已加载的类信息、常量、静态变量、JIT 编译后的代码缓存,JDK8 之前用永久代实现,JDK8 之后用元空间实现,会抛出 OOM;运行时常量池是方法区的一部分,存储类加载后的常量信息。
- 直接内存:不属于 JVM 运行时数据区,是堆外的本地内存,被 NIO 频繁使用,通过 DirectByteBuffer 引用管理,避免堆内和堆外的数据拷贝,会抛出 OOM。
2. 程序计数器为什么是线程私有的?为什么不会出现 OOM?
标准答案:
(1)为什么是线程私有的?
JVM 的多线程是通过 CPU 时间片轮转实现的,同一时刻,一个 CPU 核心只会执行一个线程中的指令,当线程切换时,需要记录每个线程当前执行到的字节码指令地址,切换回来后才能从正确的位置继续执行。如果程序计数器是线程共享的,多个线程切换时,会互相覆盖指令地址,无法保证线程执行的正确性,因此程序计数器必须是线程私有的,每个线程独立维护,互不干扰。
(2)为什么不会出现 OOM?
程序计数器只存储当前执行的字节码指令的地址,这个地址的大小是固定的,在 JVM 启动时就确定了内存占用,不会随着程序的运行而动态分配和扩展内存,也不会存储大量的动态数据,因此永远不会出现内存不足的情况,也就不会抛出 OutOfMemoryError 异常。
3. 方法区、永久代、元空间的区别和联系?JDK8 为什么用元空间替换永久代?
标准答案:
(1)三者的区别和联系
- 方法区:是 JVM 规范中定义的内存区域,是一个抽象的规范,用于存储已加载的类信息、常量、静态变量、JIT 编译后的代码等数据,所有 JVM 实现都必须遵守这个规范。
- 永久代:是 HotSpot 虚拟机在 JDK8 之前,对方法区规范的具体实现,位于 JVM 的堆内存中,有固定的内存上限,和老年代的 GC 绑定,满了就会触发 Full GC,容易出现 OOM。
- 元空间:是 HotSpot 虚拟机在 JDK8 及之后,对方法区规范的新实现,彻底替代了永久代,元空间不再使用 JVM 堆内存,而是直接使用操作系统的本地内存,默认只受物理内存限制,大幅降低了 OOM 的概率。
简单来说:方法区是接口规范,永久代和元空间是这个规范的两个不同实现版本,永久代是 JDK8 之前的实现,元空间是 JDK8 之后的实现。
(2)JDK8 用元空间替换永久代的核心原因
- 永久代的内存上限难以精准设置,容易出现 OOM :永久代的大小受
-XX:MaxPermSize限制,设置过小会频繁触发永久代 OOM,设置过大会浪费堆内存;而元空间使用本地内存,默认无上限,只要物理内存足够,就不会出现类加载导致的 OOM。 - 永久代的 GC 效率极低:永久代的 GC 和老年代绑定,只要其中一个满了就会触发 Full GC,而类的元数据回收条件苛刻,很难被回收,导致 Full GC 频繁且效率低;元空间的 GC 独立,只在达到元空间初始阈值时触发,回收效率更高。
- 便于 JVM 的维护和扩展:永久代的代码和 GC 代码耦合度高,维护难度大;元空间使用本地内存,由操作系统管理内存,降低了 JVM 的内存管理复杂度,同时可以更灵活地扩展元数据的存储。
- 避免了类加载器的内存泄漏问题:动态代理、反射、热部署等场景会频繁加载类,永久代中这些类的元数据很难被回收,容易出现内存泄漏;元空间使用本地内存,回收更彻底,大幅降低了内存泄漏的概率。
4. Class 常量池、运行时常量池、字符串常量池的区别?
标准答案:三者是完全不同的概念,存储的内容、位置、生命周期都不同,具体区别如下:
-
Class 常量池(静态常量池)
- 位置 :存在于
.class字节码文件中,是每个 Class 文件自带的常量池,编译期就确定了内容。 - 存储内容 :存放编译期生成的两大类常量:
- 字面量:比如字符串字面量、final 修饰的常量、基本数据类型的值;
- 符号引用:比如类和方法的全限定名、字段的名称和描述符、方法的名称和描述符。
- 生命周期:编译后就固定在 Class 文件中,类加载时会被读取到运行时常量池中。
- 位置 :存在于
-
运行时常量池
- 位置:JDK8 之前位于永久代,JDK8 之后位于元空间,属于方法区的一部分。
- 存储内容 :Class 常量池加载到内存后,就会放入运行时常量池中,同时具备动态性,运行期也可以将新的常量放入池中(比如
String.intern()方法)。 - 生命周期:随类的加载而创建,随类的卸载而销毁,每个类对应一个运行时常量池。
-
字符串常量池(String Table)
- 位置:JDK6 及之前位于永久代,JDK7 及之后移到了 Java 堆中,和运行时常量池完全分离。
- 存储内容 :专门存储字符串对象的引用,底层是哈希表结构,避免重复创建字符串对象,节省内存。编译期的字符串字面量会自动放入池中,运行期可通过
String.intern()手动将字符串引用放入池中。 - 生命周期:随 JVM 启动而创建,全局唯一,所有线程共享,可被 GC 回收。
5. 字符串常量池在 JDK6、7、8 中的位置变化?为什么这么调整?
标准答案:
(1)位置变化
- JDK6 及之前:字符串常量池存放在永久代中,和运行时常量池绑定,属于方法区的一部分。
- JDK7:字符串常量池从永久代中移出,放到了 Java 堆中,可被堆的垃圾回收器回收。
- JDK8 及之后:永久代被元空间替换,字符串常量池依然存放在 Java 堆中,位置没有变化。
(2)调整的核心原因
- 永久代的 GC 效率极低,容易出现 OOM:永久代的 GC 只有在 Full GC 时才会触发,而字符串是开发中使用最频繁的对象,大量的字符串对象存放在永久代中,很难被回收,很容易导致永久代内存溢出;放到堆中后,Minor GC 和 Full GC 都可以回收字符串常量池中的无效对象,大幅降低了 OOM 的概率。
- 永久代有固定的内存上限,无法灵活调整 :永久代的大小受
-XX:MaxPermSize限制,开发中经常会创建大量的字符串对象,很容易达到永久代的上限,出现 OOM;放到堆中后,字符串常量池可以使用整个堆的内存,空间更大,调整更灵活。 - 提升字符串的回收效率:堆中的 GC 频率远高于永久代,字符串对象如果没有引用,会被快速回收,释放内存,避免内存泄漏,提升内存利用率。
三、垃圾回收篇
1. 可达性分析算法的原理?GC Roots 包含哪些对象?
标准答案:
(1)可达性分析算法的原理
可达性分析算法是当前主流 JVM 判断对象是否存活的核心算法,原理是:以一系列名为GC Roots的根对象作为起点,从这些起点开始,按照引用关系向下遍历,遍历的路径称为引用链;如果一个对象到 GC Roots 之间没有任何引用链相连,也就是这个对象不可达,就说明该对象已经死亡,是可回收的垃圾对象。该算法解决了引用计数法无法解决的循环引用问题,判断精准,是 HotSpot 虚拟机的核心实现。
(2)GC Roots 包含的核心对象
GC Roots 必须是当前肯定存活的对象,不会被回收,具体包括:
- 虚拟机栈(局部变量表)中引用的对象:方法中定义的局部变量、方法参数,这些对象正在被线程执行,肯定存活;
- 本地方法栈中 JNI(Native 方法)引用的对象:Native 方法中引用的对象,由底层代码持有,肯定存活;
- 方法区中类静态属性引用的对象:static 修饰的静态变量,属于类,类加载后就一直存在,作为根对象;
- 方法区中常量引用的对象:final 修饰的常量,一旦赋值就不会改变,引用的对象肯定存活;
- 同步锁(synchronized)持有的对象:被加锁的对象,正在被线程持有,不会被回收;
- JVM 内部的基础对象:比如系统类加载器、核心异常类对象、常驻的字符串对象等,是 JVM 运行的基础,肯定存活。
2. Java 中的四种引用类型?分别的特点和使用场景?
标准答案:JDK1.2 之后,Java 将引用分为强引用、软引用、弱引用、虚引用 4 种类型,不同类型的回收策略不同,具体如下:
-
强引用(Strong Reference)
- 特点 :最常见的引用类型,代码中
Object obj = new Object()就是强引用;只要强引用关系存在,垃圾回收器永远不会回收该对象,哪怕 JVM 即将发生 OOM,也不会回收;如果强引用的对象不再使用,没有手动置为 null,就会导致内存泄漏。 - 使用场景:日常开发中绝大多数的对象创建,是 Java 的默认引用类型。
- 特点 :最常见的引用类型,代码中
-
软引用(SoftReference)
- 特点 :通过
SoftReference类包装的对象,属于非必需的引用;当 JVM 内存充足时,不会回收软引用对象;当 JVM 内存不足,即将发生 OOM 时,会回收软引用对象,如果回收后内存还是不足,才会抛出 OOM。 - 使用场景:内存敏感的缓存场景,比如图片缓存、网页缓存、大数据查询结果缓存,内存充足时缓存可用,提升性能;内存不足时自动释放缓存,避免 OOM。
- 特点 :通过
-
弱引用(WeakReference)
- 特点 :通过
WeakReference类包装的对象,引用强度比软引用更弱;无论 JVM 内存是否充足,只要触发垃圾回收,就会立即回收弱引用对象,生命周期只到下一次 GC 之前。 - 使用场景:临时缓存、ThreadLocal 的 key 实现,避免内存泄漏;比如 ThreadLocal 的 key 是弱引用,当 ThreadLocal 对象没有强引用时,会被 GC 回收,避免 ThreadLocalMap 的 key 一直存在,导致内存泄漏。
- 特点 :通过
-
虚引用(PhantomReference)
- 特点 :也叫幽灵引用,通过
PhantomReference类包装,是最弱的引用类型;无法通过虚引用获取对象实例,对对象的生命周期完全没有影响;唯一的作用是,当对象被垃圾回收器回收时,会收到一个系统通知,必须配合引用队列(ReferenceQueue)使用。 - 使用场景:跟踪对象的垃圾回收过程,管理堆外内存;比如 JDK 的 DirectByteBuffer,就是通过虚引用跟踪对象的回收,当 DirectByteBuffer 对象被回收时,释放对应的堆外直接内存,避免内存泄漏。
- 特点 :也叫幽灵引用,通过
3. 详细介绍 CMS 收集器的回收过程?优缺点?
标准答案:CMS(Concurrent Mark Sweep)是 HotSpot 第一款真正意义上的并发收集器,核心目标是最短回收停顿时间,基于标记 - 清除算法实现,作用于老年代,配合 ParNew 年轻代收集器使用。
(1)核心回收过程(4 个阶段)
CMS 的回收过程分为 4 个阶段,其中 2 个阶段会 STW,2 个阶段和用户线程并发执行:
-
初始标记(Initial Mark)
- 会触发 STW,停顿时间极短;
- 核心工作:只标记 GC Roots 能直接关联到的对象,不需要遍历整个对象图,执行速度极快,对用户线程影响极小。
-
并发标记(Concurrent Mark)
- 和用户线程并发执行,不会 STW;
- 核心工作:从初始标记的对象出发,遍历整个老年代的对象图,标记所有可达的存活对象,这个过程耗时较长,但因为和用户线程并发执行,不会影响用户线程的运行。
-
重新标记(Remark)
- 会触发 STW,停顿时间比初始标记长,但远短于并发标记的时间;
- 核心工作:修正并发标记期间,用户线程运行导致的对象引用关系变动,重新标记这些变动的对象,保证标记结果的准确性。
-
并发清除(Concurrent Sweep)
- 和用户线程并发执行,不会 STW;
- 核心工作:清理所有标记为垃圾的对象,释放对应的内存空间,耗时较长,和用户线程并发执行,不会暂停用户线程。
(2)核心优点
- 低延迟,停顿时间短:核心耗时的并发标记和并发清除阶段,都和用户线程并发执行,只有两个短暂的 STW 阶段,极大缩短了 GC 的停顿时间,对用户交互的影响极小,适合对响应时间要求高的互联网应用。
- 并发执行,充分利用多核 CPU:并发阶段会利用多核 CPU 的优势,GC 线程和用户线程同时运行,CPU 利用率高,在多核环境下表现优异。
(3)核心缺点
- CPU 资源敏感:并发阶段会占用一部分 CPU 核心和资源,导致用户线程的执行速度下降,CPU 核心数越少,影响越明显;比如单核 CPU 下,CMS 的并发执行会导致用户线程的执行效率下降 50% 以上。
- 无法处理浮动垃圾:并发清除阶段,用户线程还在运行,会持续产生新的垃圾对象,这些对象在本次标记阶段已经结束,无法被本次 GC 回收,只能等到下一次 GC 才能处理,这些垃圾称为浮动垃圾;因此 CMS 不能等到老年代完全满了再触发 GC,必须预留一部分内存给用户线程使用,内存利用率较低。
- Concurrent Mode Failure 风险:CMS 运行期间,如果预留的内存无法满足用户线程创建新对象的需求,就会触发 Concurrent Mode Failure,此时 JVM 会立即降级,使用 Serial Old 单线程收集器执行 Full GC,导致长时间的 STW,严重影响系统性能。
- 内存碎片问题:CMS 基于标记 - 清除算法实现,回收后会产生大量不连续的内存碎片,碎片过多会导致后续分配大对象时,无法找到足够的连续内存,提前触发 Full GC,影响系统稳定性。
4. G1 收集器的核心特点?和 CMS 的核心区别?
标准答案:
(1)G1 收集器的核心特点
G1(Garbage-First)是 JDK9 默认的垃圾回收器,面向服务端应用,兼顾吞吐量和低延迟,核心特点如下:
- Region 化的内存布局:打破了传统分代回收的物理隔离,将整个堆划分为多个大小相等的 Region,每个 Region 可以根据需要,动态扮演 Eden 区、Survivor 区、老年代区、大对象区(Humongous Region),无需固定的分代大小,内存管理更灵活。
- 可预测的停顿时间模型 :用户可通过
-XX:MaxGCPauseMillis设置最大 GC 停顿时间目标,G1 会根据每个 Region 的回收价值(垃圾占比、回收耗时),优先选择回收收益最高的 Region 组成回收集,保证 GC 的停顿时间不超过用户设置的目标,实现了停顿时间的可控性。 - Mixed GC 混合回收模式:G1 的核心回收模式,不是整堆的 Full GC,回收范围包括整个年轻代和一部分垃圾占比高的老年代 Region,根据停顿时间目标,动态调整回收的老年代 Region 数量,既保证了 GC 效率,又控制了停顿时间。
- 无内存碎片:整体上基于标记 - 整理算法,Region 之间基于标记 - 复制算法,回收后会将存活对象复制到空的 Region 中,清空原 Region,不会产生内存碎片,避免了 CMS 的碎片问题,减少了提前触发 Full GC 的概率。
- 大对象优化:专门设计了 Humongous Region,存放超过 Region 容量 50% 的大对象,大对象直接存放在老年代的 Humongous Region,不会在年轻代频繁复制,也不会提前晋升老年代,大幅降低了大对象对 GC 的影响。
- 兼顾吞吐量和低延迟:既可以实现接近 CMS 的低延迟,也可以实现接近 Parallel 的高吞吐量,适用场景更广,堆内存越大,优势越明显。
(2)G1 和 CMS 的核心区别
表格
| 对比维度 | G1 收集器 | CMS 收集器 |
|---|---|---|
| 内存布局 | Region 化逻辑分代,无物理隔离的年轻代和老年代 | 物理隔离的年轻代和老年代,固定分代结构 |
| 核心算法 | 整体标记 - 整理,Region 间标记 - 复制,无内存碎片 | 标记 - 清除算法,会产生大量内存碎片 |
| 回收模式 | Mixed GC 为主,优先回收垃圾多的 Region,停顿时间可控 | 整堆的老年代回收,无法选择回收范围 |
| 停顿时间控制 | 支持可预测的停顿时间模型,用户可设置最大停顿目标 | 无明确的停顿时间控制,只能优化参数降低停顿 |
| 大对象处理 | 专门的 Humongous Region 存放大对象,优化大对象回收 | 大对象直接进入老年代,无专门优化,容易触发 Full GC |
| 适用场景 | 堆内存 8G 以上,兼顾吞吐量和低延迟的服务端应用 | 堆内存较小,对延迟要求高的应用,大堆下表现不佳 |
| 内存开销 | 每个 Region 都需要维护卡表,内存占用和执行开销更高 | 卡表维护简单,内存开销更低 |
| Full GC 触发 | 堆内存极度不足时才会触发,日常以 Mixed GC 为主 | 老年代占比达到阈值就会触发,碎片过多也会提前触发 |
5. 什么是 STW?为什么必须有 STW?
标准答案:
(1)STW 的定义
STW 是 Stop-The-World 的缩写,指的是垃圾回收器执行 GC 的过程中,暂停 JVM 中所有的用户线程,直到 GC 执行完成,这个暂停的过程就是 STW。无论是哪种垃圾回收器,都无法完全避免 STW,只是停顿时间的长短不同,STW 是影响 Java 程序响应时间的核心因素之一,也是垃圾回收器优化的核心目标。
(2)必须有 STW 的核心原因
STW 的核心目的,是保证垃圾回收器的可达性分析结果的准确性,具体原因如下:
- 避免对象引用关系动态变化:可达性分析是判断对象是否存活的核心,分析过程中,如果用户线程还在运行,会持续修改对象的引用关系,比如新建对象、删除对象引用、对象赋值等,会导致可达性分析的结果不准确,出现已经标记为存活的对象被回收,或者垃圾对象没有被标记的情况,导致 JVM 崩溃。
- 保证垃圾回收的原子性:垃圾回收的标记、复制、整理过程中,对象的内存地址会发生变化,如果用户线程同时访问这些对象,会出现访问到错误的内存地址,导致程序异常,STW 可以保证垃圾回收的原子性,避免这种情况发生。
- 安全点的要求:用户线程只有到达安全点才能暂停,STW 可以保证所有用户线程都到达安全点,暂停执行,不会出现部分线程还在运行的情况,保证 GC 的顺利执行。
简单来说,如果没有 STW,用户线程和 GC 线程同时运行,会导致 GC 的标记结果混乱,对象的内存地址变动无法同步,最终导致 GC 出错,程序崩溃,因此 STW 是必须的。
四、JVM 调优篇
1. JVM 调优的核心目标是什么?调优的原则是什么?
标准答案:
(1)JVM 调优的核心目标
JVM 调优不是盲目调整参数,而是基于业务场景,实现以下核心目标,优先级从高到低:
- 避免 OOM 和内存泄漏:这是最基础的目标,保证系统稳定运行,不会因为内存问题宕机;
- 减少 Full GC 的次数:Full GC 的 STW 时间远长于 Minor GC,是导致系统卡顿的核心原因,调优的核心就是尽可能减少 Full GC 的频率,让对象尽可能在年轻代被回收;
- 降低 GC 的停顿时间:缩短 Minor GC 和 Full GC 的 STW 时间,降低对用户线程的影响,提升系统的响应速度,降低接口的 P99 响应时间;
- 降低 GC 的频率:减少 Minor GC 的次数,降低 GC 对 CPU 资源的占用,提升系统的吞吐量;
- 提升系统的吞吐量和稳定性:最终目标是让系统在峰值流量下,依然保持高吞吐量、低响应时间、高可用性,稳定运行。
(2)JVM 调优的核心原则
- 优先优化业务代码,而非 JVM 参数:80% 以上的 JVM 问题都是业务代码导致的,比如内存泄漏、频繁创建大量对象、大对象加载等,优先排查和优化代码,再考虑 JVM 参数调优,不要本末倒置。
- 先监控后调优,无监控不调优:调优必须有明确的监控数据支撑,比如 GC 日志、堆内存使用情况、压测指标,没有监控数据,就无法定位问题,盲目调优只会带来新的问题。
- 优先使用 JVM 默认的自适应策略,避免过度调优:JVM 有完善的自适应调节策略,比如 G1 的停顿时间模型、Parallel 的新生代大小自适应,不要手动设置过多参数,限制 JVM 的自适应能力,大多数场景下,默认参数已经足够优秀。
- 调优是迭代过程,必须有基准测试:每次调优后,必须通过压测验证效果,对比调优前后的核心指标,确认优化有效,不达标则继续迭代,避免调优后性能反而下降。
- Minor GC 优先,避免频繁 Full GC:调优的核心是让对象在年轻代被回收,减少对象进入老年代,从而减少 Full GC 的次数,而不是一味调大堆内存。
- 线上环境谨慎操作:线上环境不要随便执行 dump、jstack 等命令,避免触发 STW,导致系统卡顿;不要盲目复制网上的参数配置,每个业务场景的特性不同,必须适配自己的业务。
2. 频繁 Full GC 的常见原因和解决方案?
标准答案:频繁 Full GC 是线上最常见的 JVM 问题,会导致系统长时间卡顿、CPU 占用高、响应时间变长,常见原因和对应的解决方案如下:
(1)内存泄漏,老年代被无效对象占满
- 现象:Full GC 后老年代内存占用下降很少,很快又会上涨,Full GC 频率越来越高,最终 OOM;堆 dump 分析发现大量无效对象被引用,无法回收。
- 常见根因:静态集合类只添加不删除、ThreadLocal 未手动 remove、流资源未关闭、缓存没有过期策略、监听器注册后未注销。
- 解决方案 :
- 用 MAT 分析堆 dump 文件,定位内存泄漏的对象和引用链,修复业务代码,释放无效引用;
- 替换手动实现的缓存为 Guava Cache、Redis 等,添加过期时间和最大容量限制;
- 线程池使用 ThreadLocal 时,任务执行完成后必须手动 remove,避免线程复用导致的内存泄漏。
(2)老年代内存设置过小,无法容纳长期存活的对象
- 现象:Full GC 后老年代内存占用正常,但很快就被占满,触发 Full GC,Survivor 区频繁满,对象提前晋升老年代。
- 解决方案 :
- 调大堆总内存,同时调大老年代的占比,调整
-XX:NewRatio,给老年代预留足够的空间; - 调整
-XX:SurvivorRatio和-XX:MaxTenuringThreshold,增大 Survivor 区,提高对象晋升老年代的年龄阈值,让对象在年轻代多存活一段时间,避免提前进入老年代。
- 调大堆总内存,同时调大老年代的占比,调整
(3)大对象过多,频繁直接进入老年代
- 现象:业务代码频繁创建超大对象(比如全表查询的 List、超大 byte 数组),大对象直接进入老年代,快速占满老年代,触发 Full GC。
- 解决方案 :
- 优化业务代码,全表查询改为分页查询,分批处理数据,避免一次性加载全量数据到内存;
- 避免创建超大字符串、超大数组,拆分大对象为多个小对象;
- G1 收集器会自动处理大对象,大对象场景优先使用 G1,避免大对象频繁触发 Full GC。
(4)显式调用 System.gc (),手动触发 Full GC
- 现象:GC 日志中频繁出现 System.gc () 触发的 Full GC,时间和业务操作对应。
- 解决方案 :
- 排查代码,移除所有显式的 System.gc () 调用;
- 开启 JVM 参数
-XX:+DisableExplicitGC,禁用显式的 System.gc () 调用,避免手动触发 Full GC。
(5)元空间溢出,触发 Full GC
- 现象:Full GC 频繁,元空间内存占用持续上涨,GC 日志中出现 Metadata GC Threshold 触发的 Full GC。
- 常见根因:动态代理、反射、热部署、框架动态生成类,导致类加载过多,元空间占满。
- 解决方案 :
- 调大元空间的初始大小和最大大小,设置
-XX:MetaspaceSize和-XX:MaxMetaspaceSize,减少元空间 GC 的频率; - 排查频繁生成动态类的代码,优化类加载逻辑,避免重复加载类。
- 调大元空间的初始大小和最大大小,设置
(6)CMS 的 Concurrent Mode Failure,触发 Full GC
- 现象:CMS GC 过程中,出现 Concurrent Mode Failure,随后触发 Serial Old 单线程 Full GC,长时间 STW。
- 根因:CMS 并发清除阶段,用户线程创建的新对象超过了预留的内存,导致老年代不足。
- 解决方案 :
- 调大老年代内存,降低 CMS 的触发阈值,调整
-XX:CMSInitiatingOccupancyFraction,提前触发 CMS GC,预留更多的内存; - 开启
-XX:+UseCMSInitiatingOccupancyOnly,只按设置的阈值触发 CMS GC,避免 JVM 自动调整导致的 GC 不及时; - 替换 CMS 为 G1 收集器,避免 Concurrent Mode Failure 问题。
- 调大老年代内存,降低 CMS 的触发阈值,调整
3. OOM 的常见类型、排查步骤和解决方案?
标准答案:OOM(OutOfMemoryError)是 JVM 中最严重的内存问题,会导致服务宕机,常见的 OOM 类型、排查步骤和解决方案如下:
(1)OOM 的常见类型和根因
-
java.lang.OutOfMemoryError: Java heap space
- 最常见的 OOM 类型,堆内存不足,无法分配新的对象实例。
- 根因:堆内存设置过小、内存泄漏、一次性加载大量数据到内存(比如全表查询)、频繁创建大对象。
-
java.lang.OutOfMemoryError: Metaspace
- 元空间内存不足,JDK8 之前是 PermGen space 永久代溢出。
- 根因:动态代理、反射、热部署等场景加载了过多的类,元空间设置过小,类无法卸载。
-
java.lang.OutOfMemoryError: Direct buffer memory
- 直接内存溢出,堆外内存不足。
- 根因:NIO、Netty 等框架频繁分配直接内存,直接内存设置过小,堆外内存泄漏,DirectByteBuffer 对象被回收后,堆外内存未释放。
-
java.lang.StackOverflowError
- 虚拟机栈溢出,线程栈深度超过 JVM 允许的最大值。
- 根因:无限递归调用、方法调用链过长、
-Xss设置的栈内存过小。
-
java.lang.OutOfMemoryError: unable to create new native thread
- 无法创建新的线程,操作系统内存不足。
- 根因:创建了过多的线程,超过了操作系统允许的最大线程数,
-Xss设置过大,导致每个线程占用内存过多,操作系统内存不足。
(2)OOM 的标准排查步骤
-
保留现场,获取诊断数据
- 开启 JVM 参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/,OOM 时自动生成堆 dump 文件,这是排查的核心; - 收集 OOM 时的 GC 日志、线程 dump 文件、系统和 JVM 的监控数据,记录 OOM 的时间和业务场景。
- 开启 JVM 参数
-
定位 OOM 的类型
- 查看异常日志,确定 OOM 的具体类型,不同类型的排查方向完全不同。
-
分析堆 dump 文件,定位根因
- 使用 MAT、JProfiler 等工具打开堆 dump 文件,查看占用内存最大的对象,分析对象的引用链;
- 确定是内存泄漏(对象无效但被引用,无法回收)还是内存溢出(对象确实太多,堆内存不足);
- 找到占用内存的对象对应的业务代码,定位问题位置。
-
结合业务代码和监控数据,复现问题
- 结合业务场景,分析问题代码的执行逻辑,在测试环境复现 OOM 问题,验证根因的准确性。
(3)通用解决方案
- 内存泄漏导致的 OOM:核心是修复业务代码,释放无效的对象引用,解决内存泄漏问题,比如给缓存添加过期策略、ThreadLocal 手动 remove、关闭流资源、移除无效的静态引用。
- 内存溢出导致的 OOM :
- 优化业务代码,避免一次性加载大量数据到内存,比如分页查询、分批处理;
- 避免频繁创建大对象和临时对象,复用对象,减少垃圾生成;
- 调大对应的内存区域,比如堆内存、元空间、直接内存,给 JVM 预留足够的内存。
- 栈溢出 :优化递归逻辑,避免无限递归,拆分过长的方法调用链,适当调大
-Xss栈内存大小。 - 无法创建线程 :优化线程池,避免无限制创建线程,设置线程池的最大线程数,适当调小
-Xss栈内存大小,降低每个线程的内存占用。
五、进阶篇
1. 什么是双亲委派模型?好处是什么?哪些场景破坏了双亲委派?
标准答案:
(1)双亲委派模型的定义
双亲委派模型是 JVM 的类加载机制的核心模型,用于保证类加载的安全性,核心规则是:当一个类加载器收到类加载的请求时,首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求最终都会传送到顶层的启动类加载器(Bootstrap ClassLoader)中;只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到这个类)时,子类加载器才会尝试自己去加载。
(2)JVM 的三层类加载器
- 启动类加载器(Bootstrap ClassLoader) :顶层类加载器,由 C++ 实现,属于 JVM 的一部分,负责加载
JAVA_HOME/lib目录下的核心类库(比如 rt.jar),无法被 Java 程序直接引用。 - 扩展类加载器(Extension ClassLoader) :由 Java 实现,负责加载
JAVA_HOME/lib/ext目录下的扩展类库,开发者可以直接使用。 - 应用程序类加载器(Application ClassLoader):也叫系统类加载器,负责加载用户类路径(ClassPath)下的类库,开发者日常开发中默认使用的类加载器。
(3)双亲委派模型的核心好处
- 保证类的全局唯一性:避免同一个类被多个类加载器重复加载,保证全限定名相同的类,最终都是由顶层的启动类加载器加载,保证类的唯一性。
- 保证 Java 核心类库的安全性:防止开发者恶意编写和核心类库同名的类(比如自定义 java.lang.String),替换核心类库,导致安全问题;因为双亲委派模型下,核心类永远由启动类加载器加载,自定义的同名类不会被加载,避免了恶意代码的注入。
- 类加载的层级性:父类加载器加载的类,子类加载器都可以使用,保证了 Java 类库的基础类在所有的类加载器环境中都是统一的。
(4)破坏双亲委派模型的场景
- SPI 机制(Service Provider Interface):比如 JDBC、JNDI 等 SPI 机制,核心接口由启动类加载器加载,但是实现类由厂商提供,在 ClassPath 下,启动类加载器无法加载实现类,因此通过线程上下文类加载器(Thread Context ClassLoader),反向委托应用程序类加载器加载实现类,破坏了双亲委派模型。
- 热部署 / 热加载:比如 Tomcat、Spring Boot DevTools、JRebel 等热部署工具,每个 Web 应用都有自己独立的类加载器,优先加载自己目录下的类,不委托给父类加载器,实现了不同应用之间的类隔离,以及热更新,破坏了双亲委派模型。
- 自定义类加载器,重写 loadClass 方法 :双亲委派模型的核心逻辑在
ClassLoader.loadClass()方法中,如果开发者自定义类加载器,重写了这个方法,不遵循双亲委派的规则,就会破坏双亲委派模型。 - JDK9 的模块化系统:JDK9 引入了模块化系统,类加载的规则发生了变化,启动类加载器可以加载模块路径下的类,打破了原有的双亲委派的层级结构,对双亲委派模型做了修改。
2. 什么是 JIT 即时编译器?分层编译是什么?
标准答案:
(1)JIT 即时编译器的定义
JIT 是 Just-In-Time Compiler(即时编译器)的缩写,是 JVM 执行引擎的核心组成部分,用于提升 Java 程序的执行效率。Java 程序默认是通过解释器逐行解释字节码执行,执行效率较低;JIT 即时编译器会将热点代码(频繁执行的方法、循环体)在运行时直接编译成对应平台的本地机器码,并且进行深度的优化,之后执行这段代码时,直接执行编译后的机器码,无需解释,大幅提升执行效率。
(2)JIT 的核心编译对象:热点代码
热点代码分为两类:
- 被多次调用的方法;
- 被多次执行的循环体。JVM 通过热点探测机制判断热点代码,HotSpot 使用的是基于计数器的热点探测:给每个方法和循环体设置调用计数器,每调用一次,计数器 + 1,当计数器达到阈值(默认方法调用阈值 10000 次,循环体阈值 10700 次),就会被判定为热点代码,提交给 JIT 编译器编译。
(3)HotSpot 的两种 JIT 编译器
- C1 编译器(Client Compiler):客户端编译器,编译速度快,优化程度较低,注重启动速度,适合客户端应用、桌面程序。
- C2 编译器(Server Compiler):服务端编译器,编译速度慢,优化程度极高,注重执行效率,适合服务端应用,是服务端模式下的默认编译器。
(4)分层编译
分层编译是 JDK7 之后默认开启的编译模式,JDK8 默认开启,将 JVM 的编译过程分为 5 个层次,结合了解释器、C1 编译器、C2 编译器的优势,兼顾启动速度和执行效率,具体分层如下:
- 第 0 层:纯解释执行,不开启任何性能监控,解释器直接执行字节码。
- 第 1 层:C1 简单编译,C1 编译器将字节码编译成机器码,不开启性能监控,只做简单的优化,编译速度快。
- 第 2 层:C1 受限编译,C1 编译器编译,开启简单的性能监控(比如方法调用次数、循环回边次数),优化程度比第 1 层高。
- 第 3 层:C1 完全编译,C1 编译器编译,开启全部性能监控,优化程度最高,为 C2 编译提供数据支撑。
- 第 4 层:C2 完全编译,C2 编译器编译,进行极致的优化,比如方法内联、逃逸分析、标量替换、循环展开、空值消除等,优化程度最高,执行效率最高,编译速度慢。
(5)分层编译的核心优势
- 兼顾启动速度和执行效率:程序启动初期,用解释器和 C1 编译器快速编译执行,提升启动速度;程序运行一段时间后,热点代码用 C2 编译器深度优化,提升执行效率。
- 降低编译的资源占用:避免 C2 编译器编译大量非热点代码,浪费 CPU 资源,只对核心热点代码进行深度优化。
- 优化更精准:通过 C1 编译器的性能监控,收集代码的运行数据,给 C2 编译器提供更精准的优化依据,优化效果更好。
3. 什么是逃逸分析?标量替换?栈上分配?
标准答案:逃逸分析、标量替换、栈上分配,是 JIT 即时编译器的核心优化技术,JDK6 之后开始支持,JDK8 默认开启,用于减少对象在堆中的分配,降低 GC 的压力,提升程序执行效率。
(1)逃逸分析
- 定义:逃逸分析是 JIT 编译器的一种动态分析技术,用于分析一个对象的引用范围,判断这个对象是否会逃逸出方法体或者线程,从而决定是否对这个对象进行优化。
- 逃逸的两种类型 :
- 方法逃逸:对象在方法内部创建,被方法外部的其他方法引用,比如作为方法返回值返回、作为参数传递给其他方法。
- 线程逃逸:对象在方法内部创建,被其他线程访问,比如赋值给其他线程可以访问的静态变量、实例变量。
- 逃逸程度从低到高:不逃逸 < 方法逃逸 < 线程逃逸;对象的逃逸程度越低,优化的空间越大。
- 开启参数 :
-XX:+DoEscapeAnalysis,JDK8 默认开启。
(2)栈上分配
- 定义:对于没有发生逃逸的对象,JIT 编译器会将这个对象直接分配在虚拟机栈中,而不是分配在 Java 堆中。
- 核心优势:对象分配在栈中,方法执行结束后,栈帧出栈,对象会随着栈帧一起被销毁,不需要垃圾回收器回收,大幅降低了 GC 的压力;同时栈的分配效率远高于堆,访问速度更快。
- 注意:HotSpot 虚拟机没有真正实现完整的栈上分配,而是通过标量替换实现了栈上分配的效果,对象并没有直接分配在栈中,而是被拆解为标量分配在栈中。
(3)标量替换
- 定义:标量是指无法再拆解的基本数据类型(比如 int、long、float 等),而聚合量是指可以拆解的对象(比如 Java 对象)。对于没有发生逃逸的对象,JIT 编译器不会创建这个对象的完整实例,而是将这个对象拆解为多个成员变量,这些成员变量被分配到虚拟机栈的局部变量表中,和普通的局部变量一样,这个过程就是标量替换。
- 核心优势 :
- 不需要在堆中分配对象的内存,避免了对象的内存分配和 GC 回收的开销;
- 对象的成员变量分配在栈中,访问速度更快,还可以进行进一步的优化;
- 是 HotSpot 实现栈上分配效果的核心方式。
- 开启参数 :
-XX:+EliminateAllocations,JDK8 默认开启,依赖逃逸分析。
(4)额外的优化:同步消除
- 定义:对于没有发生线程逃逸的对象,这个对象只会被当前线程访问,不会出现多线程竞争,JIT 编译器会消除掉这个对象上的同步锁(synchronized),这个过程就是同步消除,也叫锁消除。
- 核心优势:消除了不必要的同步锁,避免了加锁和解锁的开销,提升程序执行效率。
- 开启参数 :
-XX:+EliminateLocks,JDK8 默认开启,依赖逃逸分析。