← 返回列表

阅读 —下载 —

串讲思路: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

  1. java源码被javac(java编译器)编译为.class字节码文件
  2. .class字节码文件被类加载器读取,并加载到JVM内存
  3. 执行引擎读取内存中的字节码,通过解释器/JIT编译转化为机器码
  4. 最终机器码交给CPU执行

一段Java代码的加载过程

一句话记住:javac 只负责翻译成字节码,真正让同一份 .class 跑在不同机器上的,是后半个 JVM。

一、JVM内存结构

思路:一个堆存对象+两个栈存方法执行过程+一个方法区存类的元数据+一个程序计数器存当前线程执行到字节码行号

运行时数据区

线程共享区域:堆、方法区

线程私有区域:虚拟机栈、本地方法栈、程序计数器

JVM-图01

程序计数器

定义:PC,当前线程执行字节码的行号指示器,多线程切换时,能知道执行到哪一行代码

特点:线程私有;占用内存很小,是唯一不会发生OOM的内存区域

虚拟机栈

定义:JVM栈,描述Java方法执行的内存模型,每个线程都有一个虚拟机栈,每个方法执行都会创建一个栈帧

特点:线程私有,生命周期和线程同步,随线程创建而创建,死亡而死亡,不需要gc

异常:StackOverFlowError-递归调用层次太深 OOM-动态扩展可能内存溢出

栈帧结构

按照方法执行流程去记:

方法开始执行,入参——局部变量表,执行中——操作数栈,调用——动态链接,返回——方法返回地址。

栈由一个个栈帧组成,每个栈帧都有局部变量表、操作数栈、动态链接、方法返回地址。

方法执行,栈帧入栈;方法退出,栈帧弹栈。

JVM-图02

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

栈帧四件套

局部变量表

作用:存放方法入参+方法内局部变量

注意:

  1. 长度单位:slot变量槽,同数据类型槽长度不一样;
  2. 如果是实例方法,slot0存放的是this,即当前对象的引用;静态方法slot0不存this,直接从参数开始
操作数栈

作用:存放方法执行过程中的中间计算结果及临时变量。

关键点:

  1. 所有算术/运算都只能在操作数栈上做,局部变量表里的值必须先 load 压栈才能参与运算(见上面 iadd 的例子)。
  2. 深度上限由编译器算好写进 class 文件(stack=N),不是运行时动态增长,超过就会有校验问题。
  3. 栈帧里的操作数栈是32 位一格:int/float/reference 占 1 格,long/double 占 2 格。
动态链接

定义:每个栈帧中都保存着一个指向方法区中当前类运行时常量池(保存各种符号信息的地方)的引用,这就是动态链接。

作用:当方法执行时,JVM 会根据运行时常量池中的具体信息,解析出当前方法符号引用对应的方法地址,如study()——>0x123919。

注意:

  1. 对于静态方法、私有方法、构造方法等编译期就能确定目标的方法,可以在类加载的解析阶段完成静态链接
  2. 而对于实例方法,由于存在重写和多态,真正调用哪个方法只有运行时才能确定,因此需要动态链接。
方法返回地址

作用:存放方法退出后,程序继续执行的地址。

本地方法栈

同虚拟机栈,只不过是为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-图03

本地内存(补充)

定义

本地内存是 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

关键记忆点:谁真正找到,谁就负责定义这个类。所以一个类由哪个加载器加载,取决于委派链上第一个能搜到它的那一层。

好处
  1. 保证核心类不被篡改:自己写个 java.lang.String 也没用——请求委派到 Bootstrap,它在核心库里找到真正的 String 直接返回,你写的那个连定义的机会都没有
  2. 避免类重复加载:同一个类在父子链里只会被加载一次
  3. 同一个 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 的对象:

  1. 虚拟机栈中局部变量表引用的对象
  2. 方法区中的静态变量、常量引用的对象
  3. 本地方法栈中 JNI 引用的对象
  4. JVM 内部引用(基本数据类型对应的 Class 对象、常驻异常对象、系统类加载器)
  5. 被 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

定义:标记出所有存活的对象,完成后清除其他对象。

问题:产生大量内存碎片。

JVM-图04

标记-复制

mark-copy

定义:将内存分为大小相等的两块,每次使用完一块,把存活对象复制到另一块,然后清除其他对象。

问题:总有一半内存空闲,浪费空间;不适合存活对象过多的情况,复制成本高。

JVM-图05

标记-整理

mark-compact

定义:过程和标记-复制一样,但是每次让存活对象向一端移动,然后清理掉其他对象。

问题:整理时需要STW(Stop The World)。

JVM-图06

分代收集

核心思想

根据对象存活周期将内存分区,不同区域采用不同的回收算法

新生代

Young Generation

对象朝生夕死,存活率低,使用复制算法,复制成本低,minor gc频繁,速度快。

Minor GC触发条件

eden区满,触发

Minor GC过程

(新对象首先在Eden区分配,当Eden区满时触发Minor GC)

  • 将Eden和From Survivor中存活的对象复制到To Survivor
  • 清空Eden和From Survivor
  • 交换From和To的角色

JVM-图07

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

老年代

Old Generation

对象存活率高,使用清除/整理算法,major/full gc少,速度慢。

JVM-图08

存放什么对象
  1. 长期存活的对象(15轮minor gc,最大年龄阈值,可以动态调整)。
  2. 大对象(直接进入老年代,防止标记-复制开销)。
  3. minor gc后survivor存放不了的对象直接进入老年代。
  4. 动态对象年龄判定:此时不遵守15的最大年龄阈值,而是动态判断

在survivor中某年龄以上的所有对象大小的总和大于survivor空间的一半,则>=该年龄的对象直接进入老年代(实际上是根据 Survivor 中不同年龄对象的累计存活空间,动态调整晋升年龄阈值,使部分对象提前进入老年代)。

Full GC触发条件
  1. 调用System.gc(),建议虚拟机gc(jvm可能gc,可能不gc)
  2. 老年代空间不足
  3. 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(非默认)

几个关键记忆点:

  1. JDK 8 → JDK 9 默认收集器从 Parallel 换成 G1
  2. CMS 在 JDK 9 标记 deprecated,JDK 14 正式移除(JEP 363)—— 在新 JDK 上谈 CMS 只能谈历史
  3. G1 是「逻辑分代、物理不分代」:Region 里仍分 Eden/Survivor/Old,但空间上不连续
  4. 选型口诀:看吞吐 → Parallel;通用均衡 → G1;极致低延迟 → ZGC

Serial

单线程垃圾回收器,新生代使用复制算法,老年代使用整理算法,GC时stop the world,阻塞用户线程。

Parallel

多线程垃圾回收器,并行吞吐量高,算法同serial,也会stop the world。

CMS

只负责老年代。并发 C-MS(mark sweep)算法。

为了减少 STW 阻塞用户线程的时间(gc 属于守护线程),让扫描的过程和用户线程并发。

CMS 四阶段

过程:

  1. 初始标记(STW):只扫描 GC Roots 的直接关联对象,短暂阻塞。
  2. 并发标记:从初始标记的对象开始,遍历整个对象图,标记所有存活对象。同时通过写屏障记录标记时被修改的引用关系,不阻塞。
  3. 重新标记(STW):修正并发标记阶段由于用户线程运行产生的引用变化,短暂阻塞。
  4. 并发清除:清理未被标记的垃圾对象,释放内存空间。

问题:产生内存碎片

G1

JDK9以后默认垃圾回收器,解决了内存碎片的问题,同时保证stw时间短。

特点:

  • 将堆划分为多个 Region
  • 空间上不再严格绑定新生代和老年代,每个 Region 都可能是 Eden、Survivor、Old,且各自的大小可以动态变化

G1 四阶段

流程:

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

优势:可预测的停顿时间——通过记录每个 Region 的回收成本,优先回收收益最高的 Region,从而把停顿控制在设定的目标内。

ZGC

ZGC 阶段

JDK11引入,在超大堆(TB级)情况下,把 GC 停顿时间控制在毫秒级。

传统GC 要完成两件事:

  1. 找到哪些对象还存活。
  2. 整理内存,把存活对象移动到新的位置,回收垃圾对象占用的空间。

问题在于:如果 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 可以在移动对象的同时,用户线程仍然安全地访问对象,实现并发转移。

流程:

  1. 初始标记(STW)
  2. 并发标记
  3. 最终标记(STW)
  4. 并发转移——这一步不再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 后年轻代占用明显回落。

可能原因:

  • 业务对象分配速率过高。
  • 年轻代空间不足。
  • 存在大量短生命周期对象。

调优方案:

  1. 优化业务代码: 减少不必要的临时对象、重复转换和集合创建,从源头降低对象分配速率。
  2. 调整年轻代容量: 如果确实是年轻代空间不足,可以在整体堆内存允许的情况下调整容量,降低 GC 频率。

Full GC 频繁

现象:

  • Full GC 频繁发生。
  • 停顿时间可能较长。
  • 老年代占用较高,GC 后回收效果可能不明显。

可能原因:

  • 老年代空间不足。
  • 大量对象晋升到老年代。
  • 大对象分配带来内存压力。
  • 大量对象被长期引用,无法回收。
  • 元空间不足,或出现特定收集器的 Full GC 触发条件。

调优方案:

  1. 分析 GC 日志: 确认 Full GC 的触发原因,以及 GC 前后老年代占用变化。
  2. 分析存活对象: 如果 Full GC 后老年代仍然很高,获取 Heap Dump,通过 MAT 分析大对象、对象数量和引用链。
  3. 优化对象生命周期: 修复缓存无限增长、集合未清理、对象被不合理长期引用等问题。
  4. 合理调整堆大小: 如果确认是正常存活对象需要更多空间,可以在资源允许的情况下适当增大堆。

核心原则:如果存在内存泄漏,单纯增大堆只能延后问题出现,不能解决根因。

GC 停顿时间过长

现象:

  • GC 停顿超过业务可接受的范围。
  • GC 期间接口延迟可能明显升高。

可能原因:

  • 单次回收涉及较多对象。
  • 堆配置与实际负载不匹配。
  • 对象存活率较高。
  • 收集器不适合当前业务的延迟目标。
  • CPU 资源不足,影响 GC 执行效率。

调优方案:

  1. 分析 GC 日志: 确认停顿发生在哪类 GC,分析停顿时间、堆占用和回收效果。
  2. 优化对象分配与存活: 减少不必要的对象创建,排查长期存活对象。
  3. 调整堆配置: 根据实际负载和 GC 表现判断是否需要调整堆大小。
  4. 评估 GC 收集器:
    • ParallelGC:更关注吞吐量,适合能够接受一定停顿的场景。
    • G1:兼顾吞吐量和停顿控制,适合许多通用服务场景。
    • ZGC:适合对低停顿有较高要求的场景。

不能简单认为 ZGC 在所有场景下都更好,需要结合 JDK 版本、堆大小、CPU 资源及压测结果判断。

CPU 使用率接近 100%

现象:

  • CPU 使用率持续偏高。
  • 接口响应变慢,甚至出现超时。

可能原因:

  • GC 线程消耗大量 CPU。
  • 业务代码存在死循环或高计算量操作。
  • 线程竞争或其他高消耗操作。

调优方案:

  1. 使用 top 等工具确认高 CPU 的进程和线程。

  2. 结合 Arthas 或线程栈定位具体线程及执行方法。

  3. 如果是 GC 线程,结合 GC 日志分析是否存在频繁 GC 或过高的回收开销。

  4. 如果是业务线程,修复异常循环或高消耗逻辑,而不是盲目修改 GC 参数。

内存泄漏/内存溢出OOM

内存溢出 OOM, Out Of Memory 结果:内存真的不够用了,申请不到新内存
内存泄漏 Memory Leak 原因:对象该被回收却没有被回收,所以占用的内存一直在增长

内存泄漏会持续累积,最终导致内存溢出;但内存溢出不一定都是内存泄漏造成的。

调优方案:

  1. 先确定内存类型: 区分 Java 堆 OOM、直接内存不足和容器内存超限。

  2. 分析 Java 堆: 获取 Heap Dump,使用 MAT(分析 Heap Dump 的主流工具) 分析大对象、对象数量和引用链。

  3. 分析 Native Memory: 如果堆占用正常但进程内存持续增长,需要进一步检查 Native Memory 和线程数量。

  4. 修复根因: 控制缓存容量、清理无用集合、修复不合理的对象引用或调整内存配置。

  5. 保留故障现场: 可配置 -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 减少了,但容器内存接近上限,就不能简单认定调优成功。最终要看目标指标是否改善,以及是否引入新的问题。