串讲思路:JVM基础概念→JVM内存结构 → 类加载 → 垃圾回收(GC) → JVM调优
零、JVM基础概念
什么是JVM
Java Virtual Machine(Java 虚拟机),通过**.class字节码文件**实现跨平台,一次编写,到处运行(write once run anywhere)。.java——>.class——>机器码
对比c,C是write once,compile everywhere。.c——>机器码
(本质是承担跨平台特性的是谁,java是jvm,c是编译器。也就是说,c是源码跨平台,java是字节码跨平台。)
一段Java代码的加载过程
java源码——javac编译器——>.class字节码——类加载器——>内存——>解释器/JIT——>机器码——>CPU
- java源码被javac(java编译器)编译为.class字节码文件
- .class字节码文件被类加载器读取,并加载到JVM内存
- 执行引擎读取内存中的字节码,通过解释器/JIT编译转化为机器码
- 最终机器码交给CPU执行

一句话记住:javac 只负责翻译成字节码,真正让同一份 .class 跑在不同机器上的,是后半个 JVM。
一、JVM内存结构
思路:一个堆存对象+两个栈存方法执行过程+一个方法区存类的元数据+一个程序计数器存当前线程执行到字节码行号
运行时数据区
线程共享区域:堆、方法区
线程私有区域:虚拟机栈、本地方法栈、程序计数器

程序计数器
定义:PC,当前线程执行字节码的行号指示器,多线程切换时,能知道执行到哪一行代码
特点:线程私有;占用内存很小,是唯一不会发生OOM的内存区域
虚拟机栈
定义:JVM栈,描述Java方法执行的内存模型,每个线程都有一个虚拟机栈,每个方法执行都会创建一个栈帧
特点:线程私有,生命周期和线程同步,随线程创建而创建,死亡而死亡,不需要gc
异常:StackOverFlowError-递归调用层次太深 OOM-动态扩展可能内存溢出
栈帧结构
按照方法执行流程去记:
方法开始执行,入参——局部变量表,执行中——操作数栈,调用——动态链接,返回——方法返回地址。
栈由一个个栈帧组成,每个栈帧都有局部变量表、操作数栈、动态链接、方法返回地址。
方法执行,栈帧入栈;方法退出,栈帧弹栈。

按方法执行流程记四件事:进方法 → 局部变量表接参数;算的时候 → 操作数栈倒腾;遇到调用 → 动态链接找目标;方法结束 → 返回地址回哪儿。

作用:存放方法入参+方法内局部变量
注意:
- 长度单位:slot变量槽,同数据类型槽长度不一样;
- 如果是实例方法,slot0存放的是this,即当前对象的引用;静态方法slot0不存this,直接从参数开始
作用:存放方法执行过程中的中间计算结果及临时变量。
关键点:
- 所有算术/运算都只能在操作数栈上做,局部变量表里的值必须先
load压栈才能参与运算(见上面iadd的例子)。 - 深度上限由编译器算好写进 class 文件(
stack=N),不是运行时动态增长,超过就会有校验问题。 - 栈帧里的操作数栈是32 位一格:
int/float/reference占 1 格,long/double占 2 格。
定义:每个栈帧中都保存着一个指向方法区中当前类运行时常量池(保存各种符号信息的地方)的引用,这就是动态链接。
作用:当方法执行时,JVM 会根据运行时常量池中的具体信息,解析出当前方法符号引用对应的方法地址,如study()——>0x123919。
注意:
- 对于静态方法、私有方法、构造方法等编译期就能确定目标的方法,可以在类加载的解析阶段完成静态链接
- 而对于实例方法,由于存在重写和多态,真正调用哪个方法只有运行时才能确定,因此需要动态链接。
作用:存放方法退出后,程序继续执行的地址。
本地方法栈
同虚拟机栈,只不过是为Native方法(C/C++)执行的内存模型,其余的完全一致
例:Object、System、Thread、UnSafe类下带native的方法
方法区
作用:存放类的各种元数据。
比如,类信息,**运行时常量池(字符串常量池+符号引用),**静态变量,类方法等
永久代和元空间
jdk7之前,永久代,PermGen,和堆一起在线程共享区域,容易造成OOM。
jdk8之后,元空间,Metaspace,在本地内存,直接向系统申请内存,不容易发生OOM。
堆
定义
存放对象(new)的共享内存区域,也是GC的区域。
内存结构
默认:新生代:老年代 = 1:2
新生代内:
Eden:s0:s1 = 8:1:1

本地内存(补充)
定义
本地内存是 JVM 向操作系统申请的一块内存,不属于 Java 堆,由操作系统负责管理。
jvm运行时除了运行时数据区,还可以使用本地内存,比如元空间和NIO(new IO)使用的直接内存。NIO用了IO多路复用的思想。
直接内存
作用:提高 IO 效率,本质是少一次数据拷贝。
数据从磁盘到你的 Java 数组,物理上永远要过内核页缓存(page cache),这一步谁都免不了。差异只在于「内核怎么把数据交给你的程序」:
| 拷贝次数 | 路径 | |
|---|---|---|
堆内 ByteBuffer.allocate(n) |
3 次 | 磁盘 →(DMA) 内核页缓存 →[★ 额外一次] 临时堆外 → JVM 拷 → Java 堆 |
直接 ByteBuffer.allocateDirect(n) |
2 次 | 磁盘 →(DMA) 内核页缓存 →(DMA) 直接内存,程序直接读 |

二、类加载
类加载生命周期
类加载就是类加载器把字节码文件加载到内存中的过程。包括加载、连接、初始化三个步骤。
加载有三步:
(1)通过类的全限定名,获取类的二进制流 (2)把二进制流的静态结构转换为方法区的运行结构(理解:类、方法的元信息都会放到方法区) (3)在堆中生成该类的class对象
连接有三步:验证、准备、解析
(1)验证:验证该class文件中的字节流符合jvm的要求,不会威胁到jvm的安全
(2)准备:为class对象的静态变量分配内存,初始化初始值
(3)解析:将符号引用转换为直接引用
初始化:到了初始化阶段,才开始执行类中的代码,初始化阶段是调用类构造器的过程
类加载器
分类
类加载器(ClassLoader)能通过类的全限定类名获取该类的二进制字节流。它是分层的,越往上管的越核心。

BootstrapClassLoader,C++ 实现,加载 JDK 核心类库(java.lang.* / java.util.* 等)。
⚠️ 它在 Java 里拿不到对象,getClassLoader() 返回 null。所以判断一个类是不是它加载的,就看 cl == null。
这里分 JDK 版本,答错了很扣分:
| JDK 8 | JDK 9+ | |
|---|---|---|
| 名字 | Extension ClassLoader |
Platform ClassLoader |
| 实现类 | sun.misc.Launcher$ExtClassLoader |
jdk.internal.loader.ClassLoaders$Platform |
| 加载范围 | $JAVA_HOME/jre/lib/ext/ 下的扩展 jar |
$JAVA_HOME/lib/modules 里的 Java SE 平台模块 |
为什么换:JDK 9 引入 JPMS 模块化,JDK 自身被拆成一个个模块,不再有 rt.jar / ext 目录那种「按 jar 归属」的方式,所以中间层从「扩展 jar」变成「平台模块」。
Application ClassLoader,加载 classpath 上我们自己写的业务类。
继承 ClassLoader,重写 findClass()(想换查找方式)或 loadClass()(想破坏双亲委派)。
用途:早期防反编译(加密 class + 自定义解码)、热部署、插件隔离、同一类多版本共存(Tomcat / OSGi)。
本机 JDK 17 实测:
CLDemo.class → AppClassLoader
java.lang.String → null(Bootstrap)
java.sql.DriverManager → Platform
加载器链只有三层:App → Platform → null
双亲委派模型
定义一个类加载器收到加载请求时,先不自己加载,而是把请求委派给父加载器;父加载器在自己的搜索范围内找不到,子加载器才尝试自己加载。

一次 loadClass() 的真实流程:
0. 查自己已加载的缓存(ConcurrentHashMap)→ 有就直接返回
1. 没有 → 委派给 parent.loadClass(),父加载器递归重复 0 和 1
2. 一路到 Bootstrap(它没有 parent 了)→ 由它自己尝试查找
3. 父都找不到 → 回到发起方 findClass() 自己找
4. 还找不到 → 抛 ClassNotFoundException
关键记忆点:谁真正找到,谁就负责定义这个类。所以一个类由哪个加载器加载,取决于委派链上第一个能搜到它的那一层。
好处- 保证核心类不被篡改:自己写个
java.lang.String也没用——请求委派到 Bootstrap,它在核心库里找到真正的 String 直接返回,你写的那个连定义的机会都没有 - 避免类重复加载:同一个类在父子链里只会被加载一次
- 同一个 Class 对象:父子加载器看到的是同一份,
instanceof判断才成立
三种典型场景:
① SPI / JDBC(最常考)
DriverManager 在核心库(Platform / Bootstrap)
MySQL 驱动实现 在 classpath(App)
父加载器看不见子加载器能看到的类,所以 DriverManager 没法直接 new 驱动。解法是借用线程上下文类加载器反向往下拿:
Thread.currentThread().getContextClassLoader(); // 默认就是 AppClassLoader
⚠️ 纠正常见说法:老资料常说「DriverManager 由启动类加载器加载」。在 JDK 17 实测,它是 Platform 加载的(java.sql.DriverManager → platform)。所以更准确的说法是「DriverManager 在核心模块里,由 Platform 加载」,机制本身不变。
② 重写 loadClass()
不想走委派,自己决定加载顺序。典型是 Tomcat 的 WebAppClassLoader:为了 Web 应用之间隔离,它优先加载自己应用下的类,而不是先委派给父。
③ 热部署 / OSGi
同一个类要能同时存在多个版本 → 用不同的自定义加载器实例加载,得到两个「同名但不同源」的 Class 对象。
⚠️ 注意:loadClass() 是 protected 的,破坏了双亲委派就得自己处理缓存和锁(parallelLockMap),别随手重写。只想换查找方式、不想破坏委派,就只重写 findClass()。
三、垃圾回收
1. 如何判断对象能否回收

引用计数法
定义:给对象加一个引用计数器,被引用 +1,引用失效 -1,计数为 0 就代表可回收。
优点:实现简单、回收及时(计数归零立刻就能回收,不用等扫描)。
致命问题:无法处理循环引用。
A.b = B; B.c = C; C.a = A; // 三个互相引用
a = null; b = null; c = null; // 外部引用全断开
// 计数:A=1、B=1、C=1,谁都不为 0 → 永远回收不掉 → 内存泄漏
另外维护计数器本身有开销,并发场景还要同步。所以 JVM 不用它(Python、早期 Obj-C 用过)。
可达性分析
做法:从 GC Roots 出发,沿引用链向下搜索。被遍历到的对象标记为存活;遍历结束后仍然没被走到的 = 不可达 = 垃圾。JVM 采用的就是这个。
⚠️ 注意说法:不是"遍历所有对象看谁没被遍历",而是从 Roots 正向搜索,走不到的自然就是垃圾。
可以作为 GC Root 的对象:
- 虚拟机栈中局部变量表引用的对象
- 方法区中的静态变量、常量引用的对象
- 本地方法栈中 JNI 引用的对象
- JVM 内部引用(基本数据类型对应的 Class 对象、常驻异常对象、系统类加载器)
- 被
synchronized持有的对象(重量级锁的 monitor)
循环引用在这里不再是问题:哪怕 A↔B 互相引用,只要整体和 GC Roots 断开,两个都会被回收。
⚠️ 不可达 ≠ 立刻被回收
这一点面试常追问。真正"判死刑"要经过 两轮标记:
| 步骤 | 做什么 |
|---|---|
| 第 1 步 | 可达性分析:走不到 → 第一次标记为不可达 |
| 第 2 步 | 筛选:检查是否重写了 finalize(),没重写的直接回收 |
| 第 3 步 | F-Queue 执行:重写了且没执行过的,放入 F-Queue,由 Finalizer 线程执行(不保证执行完,可能卡死) |
| 第 4 步 | 第二次标记:在 finalize() 里重新把自己挂回引用链 → 自救成功;否则 → 真正回收 |
// 自救示例(不建议在生产用)
static Obj SAVE_ME;
protected void finalize() { SAVE_ME = this; } // 把自己挂回去
⚠️ finalize() 只保证被调用一次,且时机不确定、可能拖慢 GC。线上永远不要用它释放资源,用 try-with-resources。 JDK 9 起它已被标记 @Deprecated。
2. 垃圾回收算法
标记-清除
mark-sweep
定义:标记出所有存活的对象,完成后清除其他对象。
问题:产生大量内存碎片。

标记-复制
mark-copy
定义:将内存分为大小相等的两块,每次使用完一块,把存活对象复制到另一块,然后清除其他对象。
问题:总有一半内存空闲,浪费空间;不适合存活对象过多的情况,复制成本高。

标记-整理
mark-compact
定义:过程和标记-复制一样,但是每次让存活对象向一端移动,然后清理掉其他对象。
问题:整理时需要STW(Stop The World)。

分代收集
核心思想根据对象存活周期将内存分区,不同区域采用不同的回收算法
新生代Young Generation
对象朝生夕死,存活率低,使用复制算法,复制成本低,minor gc频繁,速度快。
Minor GC触发条件eden区满,触发
Minor GC过程(新对象首先在Eden区分配,当Eden区满时触发Minor GC)
- 将Eden和From Survivor中存活的对象复制到To Survivor
- 清空Eden和From Survivor
- 交换From和To的角色

注意:minor gc中的eden+from:to并不是严格的1:1,而是9:1,即使存活对象超过to容量,也可以通过分配担保进入老年代,但也属于标记复制算法。理论上要求1:1,是为了保证极端情况(都可达)下也能复制。
老年代Old Generation
对象存活率高,使用清除/整理算法,major/full gc少,速度慢。

- 长期存活的对象(15轮minor gc,最大年龄阈值,可以动态调整)。
- 大对象(直接进入老年代,防止标记-复制开销)。
- minor gc后survivor存放不了的对象直接进入老年代。
- 动态对象年龄判定:此时不遵守15的最大年龄阈值,而是动态判断
在survivor中某年龄以上的所有对象大小的总和大于survivor空间的一半,则>=该年龄的对象直接进入老年代(实际上是根据 Survivor 中不同年龄对象的累计存活空间,动态调整晋升年龄阈值,使部分对象提前进入老年代)。
Full GC触发条件- 调用System.gc(),建议虚拟机gc(jvm可能gc,可能不gc)
- 老年代空间不足
- minor gc前空间担保失败

要回答的问题:Minor GC 前先评估一次——万一 Survivor 装不下要晋升的对象,老年代兜得住吗?兜得住才敢做 Minor GC。
3. 垃圾回收器
版本与默认收集器对照表
| 收集器 | 引入版本 | 状态 | 是否默认 | 回收范围 | 算法 | 特点 |
|---|---|---|---|---|---|---|
| Serial | JDK 1.3 | 仍在 | 客户端模式默认 | 新生代+老年代 | 新生代复制 / 老年代整理 | 单线程,简单,适合小堆 |
| ParNew | JDK 1.3 | 仍在(少用) | — | 新生代 | 复制 | Serial 的多线程版,曾是 CMS 搭档 |
| Parallel Scavenge | JDK 1.4 | 仍在 | JDK 8 默认 | 新生代+老年代 | 复制 / 整理 | 多线程,追求吞吐量 |
| CMS | JDK 1.5 | JDK 14 已移除 | — | 仅老年代 | 标记-清除 | 并发,低停顿,但有碎片 |
| G1 | JDK 7(实验)/ JDK 9 转正 | 仍在 | JDK 9+ 默认 | 全堆(Region) | 复制(整体归纳为标记-整理) | Region 化,可预测停顿,无碎片 |
| ZGC | JDK 11 实验 / JDK 15 转正 | 仍在 | 非默认 | 全堆(不分代*) | 并发标记-复制 | <1ms 停顿,TB 级堆 |
| Shenandoah | JDK 12 | 仍在 | 非默认(非 Oracle 版默认有) | 全堆 | 并发标记-复制 | 与 ZGC 类似,低延迟 |
* JDK 21 起提供分代 ZGC(-XX:+ZGenerational),JDK 23 起为默认形态。
本机 JDK 17 实测验证:
$ java -XX:+UseG1GC -version # OK → 默认就是它
$ java -XX:+UseConcMarkSweepGC -version
Unrecognized VM option 'UseConcMarkSweepGC' # ← CMS 已被删除
$ java -XX:+UseZGC -version # OK,但 UseZGC=false(非默认)
几个关键记忆点:
- JDK 8 → JDK 9 默认收集器从 Parallel 换成 G1
- CMS 在 JDK 9 标记 deprecated,JDK 14 正式移除(JEP 363)—— 在新 JDK 上谈 CMS 只能谈历史
- G1 是「逻辑分代、物理不分代」:Region 里仍分 Eden/Survivor/Old,但空间上不连续
- 选型口诀:看吞吐 → Parallel;通用均衡 → G1;极致低延迟 → ZGC
Serial
单线程垃圾回收器,新生代使用复制算法,老年代使用整理算法,GC时stop the world,阻塞用户线程。
Parallel
多线程垃圾回收器,并行吞吐量高,算法同serial,也会stop the world。
CMS
只负责老年代。并发 C-MS(mark sweep)算法。
为了减少 STW 阻塞用户线程的时间(gc 属于守护线程),让扫描的过程和用户线程并发。

过程:
- 初始标记(STW):只扫描 GC Roots 的直接关联对象,短暂阻塞。
- 并发标记:从初始标记的对象开始,遍历整个对象图,标记所有存活对象。同时通过写屏障记录标记时被修改的引用关系,不阻塞。
- 重新标记(STW):修正并发标记阶段由于用户线程运行产生的引用变化,短暂阻塞。
- 并发清除:清理未被标记的垃圾对象,释放内存空间。
问题:产生内存碎片
G1
JDK9以后默认垃圾回收器,解决了内存碎片的问题,同时保证stw时间短。
特点:
- 将堆划分为多个 Region
- 空间上不再严格绑定新生代和老年代,每个 Region 都可能是 Eden、Survivor、Old,且各自的大小可以动态变化

流程:
- 初始标记(STW):标记从 GC Roots 直接可达的对象。
- 并发标记:从 GC Roots 出发并发遍历对象引用,识别老年代中的存活对象;同时统计 Region 的存活信息,估算候选 Region 的回收成本和收益,为后续回收提供依据。
- 最终标记(STW):处理并发标记期间发生变化的对象引用,完成标记工作。
- 筛选回收与复制(STW):按「回收收益 / 成本」对 Region 排序,优先回收性价比高的 Region,并将存活对象复制到新 Region。需要区分两种情况:
- Young GC:回收 Eden 和 Survivor Region,存活对象复制到新的 Survivor Region 或晋升到 Old Region。
- Mixed GC:在回收年轻代的同时,选择部分老年代 Region 加入回收集合,将存活对象复制到其他 Region 并释放被回收的 Region(老年代只筛选部分性价比高的 Region)。
优势:可预测的停顿时间——通过记录每个 Region 的回收成本,优先回收收益最高的 Region,从而把停顿控制在设定的目标内。
ZGC

JDK11引入,在超大堆(TB级)情况下,把 GC 停顿时间控制在毫秒级。
传统GC 要完成两件事:
- 找到哪些对象还存活。
- 整理内存,把存活对象移动到新的位置,回收垃圾对象占用的空间。
问题在于:如果 GC 移动了一个对象,应用线程手里的引用可能还指向旧地址。
这时候就需要STW,防止引用旧地址失效。
那ZGC的思路就是,尽量让对象标记、移动、引用修正这些工作与应用线程并发进行,而不是等所有工作做完再恢复应用,不再STW。
ZGC 通过着色指针(Colored Pointer)和读屏障(Load Barrier),让应用线程即使拿到了过期引用,也能够在真正访问对象之前识别并修正它。实现并发转移,减少STW时间。
着色指针:
主要表达一件事:对象在什么地址?
把 GC 需要知道的状态信息放到对象引用里,让 GC 能够快速判断这个引用是否仍然有效。
G1存地址,ZGC存地址 + GC状态信息,表示是否转移/标记,而不需要像G1一样记录mark bitmap表示是不是标记,减少内存占用。
读屏障:
JVM 在读取对象引用时插入的一段 GC 相关处理逻辑。如果对象移动,通过修正引用可以自动寻址新地址,从而避免整理的时候STW。
Java 应用读取对象引用
│
▼
读屏障检查
│
▼
引用状态是否正常?
│
┌────┴────┐
│ │
正常 异常
│ │
▼ ▼
直接使用 修正引用
│ │
└────┬────┘
▼
访问对象
所以,ZGC 利用着色指针在对象引用中记录 GC 状态(如是否标记、是否已转移),用户线程访问对象时会经过读屏障。读屏障检查着色指针,如果对象已经迁移,则自动定位到新的地址,因此 GC 可以在移动对象的同时,用户线程仍然安全地访问对象,实现并发转移。
流程:
- 初始标记(STW)
- 并发标记
- 最终标记(STW)
- 并发转移——这一步不再STW
选择需要回收的Region,将存活对象复制到新的Region,GC线程和用户线程同时运行,不需要长时间暂停用户线程。
G1 是“选择哪些更优的 Region 回收”控制停顿时间,ZGC 是“怎么让对象移动也不需要停顿”。
四、JVM调优
调优目标——分析问题——常见问题+解决方案——验证调优效果
调优目标
我们究竟要优化什么?
JVM 调优主要围绕三个目标:吞吐量、响应延迟、内存占用。不同业务的侧重点不同,调优前需要先明确目标。
吞吐量
指单位时间内 JVM 完成的有效业务工作量。
- 常见问题: GC 消耗过多 CPU 时间,导致业务线程执行时间减少,吞吐量下降。
响应延迟
指请求从发起到完成所需的时间,通常关注平均响应时间以及 P99、P999 等分位延迟。
- 常见问题: 长时间 GC 停顿可能导致请求延迟突然升高。
例如,高并发交易服务通常对接口延迟更加敏感,需要重点关注长尾延迟。
内存占用
指 JVM 运行过程中使用的内存资源,包括 Java 堆、元空间、线程栈和直接内存等。
- 常见问题: 堆空间不足、对象持续积累、直接内存占用过高。
注意:-Xmx 只是 Java 堆的最大容量,不代表 JVM 进程的总内存上限。
分析问题
如何判断 JVM 出了什么问题?
调优前,先通过监控、GC 日志和诊断工具分析运行状态,重点看以下四个方面。
堆内存
内存使用是否正常?
重点观察:
- 年轻代使用情况: 是否频繁被占满并触发 Young GC。
- 老年代使用情况: 是否持续增长,是否接近容量上限。
- GC 前后内存变化: GC 后占用是否明显回落。
- 堆内存增长趋势: 在相似业务负载下,内存是否持续增长。
判断思路:
- GC 后内存明显回落: 说明大量对象被回收,可能存在较多短生命周期对象;还需要结合 GC 频率判断是否需要优化。
- GC 后内存仍然很高: 说明大量对象仍然存活,需要进一步分析是否属于正常业务数据、缓存积累或内存泄漏。
- 老年代持续增长: 需要排查对象晋升、长期存活对象和缓存是否持续积累。
注意:GC 后内存占用高不等于内存泄漏,必须结合对象引用关系和增长趋势判断。
GC 日志
垃圾回收是否频繁、停顿是否过长?
可以通过统一日志参数开启 GC 日志:
-Xlog:gc*
该参数适用于支持统一日志的 JDK 版本,例如 JDK 11 及之后的版本。
重点观察:
| 指标 | 代表什么 | 异常时可能意味着什么 |
|---|---|---|
| Young GC 频率 | 年轻代回收频率 | 可能是对象分配速率高或年轻代空间不足 |
| GC 停顿时间 | GC 对应用线程造成的暂停 | 停顿超过业务目标,可能影响接口延迟 |
| GC 前后堆占用 | 每次 GC 的回收效果 | 回收后占用仍然很高,需要分析存活对象 |
| Full GC 次数 | Full GC 发生频率 | 频繁发生时,需要分析老年代压力及触发原因 |
| 老年代增长趋势 | 老年代对象积累情况 | 持续增长可能与对象晋升、长期引用或缓存有关 |
| GC 时间占比 | GC 消耗的时间比例 | 占比过高可能影响业务吞吐量 |
判断时需要注意:
- Young GC 频繁不一定是问题。如果每次停顿很短,整体延迟满足要求,可能是正常的高对象分配速率。
- Full GC 频繁且回收效果差,需要重点分析老年代中的存活对象。
- GC 停顿过长时,不能直接认定是收集器选择不当,还要考虑堆大小、对象存活情况和 CPU 资源。
常见问题及调优方案
Young GC 频繁
现象:
- Young GC 发生频繁。
- 每次停顿可能较短。
- GC 后年轻代占用明显回落。
可能原因:
- 业务对象分配速率过高。
- 年轻代空间不足。
- 存在大量短生命周期对象。
调优方案:
- 优化业务代码: 减少不必要的临时对象、重复转换和集合创建,从源头降低对象分配速率。
- 调整年轻代容量: 如果确实是年轻代空间不足,可以在整体堆内存允许的情况下调整容量,降低 GC 频率。
Full GC 频繁
现象:
- Full GC 频繁发生。
- 停顿时间可能较长。
- 老年代占用较高,GC 后回收效果可能不明显。
可能原因:
- 老年代空间不足。
- 大量对象晋升到老年代。
- 大对象分配带来内存压力。
- 大量对象被长期引用,无法回收。
- 元空间不足,或出现特定收集器的 Full GC 触发条件。
调优方案:
- 分析 GC 日志: 确认 Full GC 的触发原因,以及 GC 前后老年代占用变化。
- 分析存活对象: 如果 Full GC 后老年代仍然很高,获取 Heap Dump,通过 MAT 分析大对象、对象数量和引用链。
- 优化对象生命周期: 修复缓存无限增长、集合未清理、对象被不合理长期引用等问题。
- 合理调整堆大小: 如果确认是正常存活对象需要更多空间,可以在资源允许的情况下适当增大堆。
核心原则:如果存在内存泄漏,单纯增大堆只能延后问题出现,不能解决根因。
GC 停顿时间过长
现象:
- GC 停顿超过业务可接受的范围。
- GC 期间接口延迟可能明显升高。
可能原因:
- 单次回收涉及较多对象。
- 堆配置与实际负载不匹配。
- 对象存活率较高。
- 收集器不适合当前业务的延迟目标。
- CPU 资源不足,影响 GC 执行效率。
调优方案:
- 分析 GC 日志: 确认停顿发生在哪类 GC,分析停顿时间、堆占用和回收效果。
- 优化对象分配与存活: 减少不必要的对象创建,排查长期存活对象。
- 调整堆配置: 根据实际负载和 GC 表现判断是否需要调整堆大小。
- 评估 GC 收集器:
- ParallelGC:更关注吞吐量,适合能够接受一定停顿的场景。
- G1:兼顾吞吐量和停顿控制,适合许多通用服务场景。
- ZGC:适合对低停顿有较高要求的场景。
不能简单认为 ZGC 在所有场景下都更好,需要结合 JDK 版本、堆大小、CPU 资源及压测结果判断。
CPU 使用率接近 100%
现象:
- CPU 使用率持续偏高。
- 接口响应变慢,甚至出现超时。
可能原因:
- GC 线程消耗大量 CPU。
- 业务代码存在死循环或高计算量操作。
- 线程竞争或其他高消耗操作。
调优方案:
-
使用
top等工具确认高 CPU 的进程和线程。 -
结合 Arthas 或线程栈定位具体线程及执行方法。
-
如果是 GC 线程,结合 GC 日志分析是否存在频繁 GC 或过高的回收开销。
-
如果是业务线程,修复异常循环或高消耗逻辑,而不是盲目修改 GC 参数。
内存泄漏/内存溢出OOM
| 内存溢出 | OOM, Out Of Memory | 结果:内存真的不够用了,申请不到新内存 |
|---|---|---|
| 内存泄漏 | Memory Leak | 原因:对象该被回收却没有被回收,所以占用的内存一直在增长 |
内存泄漏会持续累积,最终导致内存溢出;但内存溢出不一定都是内存泄漏造成的。
调优方案:
-
先确定内存类型: 区分 Java 堆 OOM、直接内存不足和容器内存超限。
-
分析 Java 堆: 获取 Heap Dump,使用 MAT(分析 Heap Dump 的主流工具) 分析大对象、对象数量和引用链。
-
分析 Native Memory: 如果堆占用正常但进程内存持续增长,需要进一步检查 Native Memory 和线程数量。
-
修复根因: 控制缓存容量、清理无用集合、修复不合理的对象引用或调整内存配置。
-
保留故障现场: 可配置
-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath,便于事后分析。
参数调整与效果验证
常见参数
堆内存:
-Xms4g -Xmx4g
-Xms:初始堆大小。-Xmx:最大堆大小。
将两者设置为相同值,可以减少堆动态扩容带来的波动,但需要考虑容器内存限制及 Native Memory 开销。
GC 收集器:
-XX:+UseG1GC
用于选择 G1 GC。具体收集器和参数需要结合 JDK 版本与业务需求选择。
GC 日志:
-Xlog:gc*
用于观察垃圾回收过程,辅助分析 GC 频率、停顿时间和回收效果。
如何验证调优是否有效?
在相同或可比的业务负载下,对比优化前后的指标:
- GC 指标: Young GC 频率、Full GC 次数、停顿时间。
- 业务指标: 吞吐量、接口 P99/P999、请求超时率。
- 内存指标: GC 后堆占用、进程 RSS、容器内存使用率。
- 稳定性指标: 是否出现新的 OOM、超时或资源压力。
例如,增大堆内存后 Full GC 减少了,但容器内存接近上限,就不能简单认定调优成功。最终要看目标指标是否改善,以及是否引入新的问题。