云端行笔
发布于 2026-01-23 / 14 阅读
0
0

Java垃圾回收深度解析:从对象判定到GC调优实战完全指南

摘要:本文系统解析Java垃圾回收机制,涵盖垃圾对象判定算法、三大核心回收算法、GC器演进历程(Serial→Parallel→CMS→G1→ZGC/Shenandoah),以及生产环境GC调优实战经验。适合Java开发者深入理解JVM内存管理。

一、垃圾回收的核心概念

1.1 什么是垃圾回收

垃圾回收(Garbage Collection,GC)是Java虚拟机自动管理内存的机制。它的核心任务是:

  • 识别垃圾对象:判断哪些对象不再被使用

  • 回收内存空间:清理垃圾对象占用的内存

  • 整理内存布局:减少碎片,提高分配效率

与C/C++手动管理内存相比,Java的自动GC大幅降低了内存泄漏和野指针的风险,但代价是运行时有一定的性能开销。

1.2 GC要解决的三个问题

哪些内存需要回收?

堆内存中不再被任何存活对象引用的内存需要回收。但判断"不再被引用"并不简单,需要专门的算法。

什么时候回收?

不同收集器有不同的触发时机:

  • 内存不足时触发(如新生代满、老年代满)

  • 系统空闲时触发(某些GC会在低负载时主动回收)

  • 定时触发(如某些场景下的周期性Full GC)

如何回收?

这是GC算法的核心,涉及标记、清理、整理、复制等多种技术。不同算法有不同的时间和空间效率权衡。

1.3 JVM内存区域划分

理解GC首先要理解JVM堆内存的划分:

新生代(Young Generation)

存放新创建的对象和存活时间较短的对象。大部分对象都在新生代创建并很快死亡(称为"朝生夕灭")。新生代分为:

  • Eden区:新对象首先分配在这里,占新生代的80%

  • Survivor区(S0和S1):存放经过一次或多次GC仍然存活的对象,各占10%

新生代GC称为Minor GC或Young GC,频率高但速度快。

老年代(Old Generation)

存放存活时间长的对象。对象从新生代晋升到老年代的几种情况:

  • 经历足够多次GC仍然存活(默认15次,可配置)

  • 对象太大,直接在老年代分配(大对象直接进入老年代)

  • Survivor区空间不足,提前晋升

老年代GC称为Major GC或Full GC,频率低但时间长,往往伴随整个堆的整理。

元空间(Metaspace)

存储类的元数据(类信息、方法信息、常量池等)。元空间不在堆内,使用本地内存。元空间满会触发Full GC。

1.4 对象分配流程

新对象创建时的内存分配流程:

  1. 对象大小判断
     → 如果是大对象(超过阈值),直接分配到老年代
     → 否则尝试在Eden区分配
  
  2. Eden区空间检查
     → 如果Eden区有足够空间,直接分配
     → 如果空间不足,触发Young GC
  
  3. Young GC后空间检查
     → 如果GC后Eden区有足够空间,分配
     → 如果仍然不足,尝试在老年代分配
     → 如果老年代也不足,触发Full GC
     → Full GC后如果仍不足,抛出OutOfMemoryError

二、如何判定垃圾对象

判定一个对象是否是垃圾,核心是判断这个对象是否还被引用。JVM使用两种方法:引用计数法和可达性分析。

2.1 引用计数法

最直观的方法:给每个对象加一个计数器,记录有多少引用指向它。

原理

  Object obj = new Object();  // 计数器 = 1
  Object ref = obj;           // 计数器 = 2
  obj = null;                 // 计数器 = 1
  ref = null;                 // 计数器 = 0,对象可回收

每次有引用指向对象,计数器+1;引用失效,计数器-1。计数器为0时,对象可回收。

优点

  • 实现简单

  • 回收及时(计数器为0立即回收)

致命缺陷:无法处理循环引用

  class Node {
      Node next;
  }
  
  Node a = new Node();
  Node b = new Node();
  a.next = b;  // a引用b
  b.next = a;  // b引用a
  
  a = null;  // a对象的计数器 = 1(b.next还在引用)
  b = null;  // b对象的计数器 = 1(a.next还在引用)
  // 此时a和b都无法回收,但实际上它们已经不可访问了

上述代码中,Node aNode a即为循环引用。这种情况下,Node aNode a 实际上已经“死”了,但由于它们的引用计数器皆不为 0,通过引用计数法判断的结果是,这两个对象还活着。因此,这种情况下就造成了内存泄露。

为了解决该问题,目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法

2.2 可达性分析(JVM实际使用的方法)

可达性分析法也被称之为根搜索法,可达性是指,如果一个对象会被至少一个在程序中的变量通过直接或间接的方式被其他可达的对象引用,则称该对象就是可达的。更准确的说,一个对象只有满足下述两个条件之一,就会被判断为可达的:

对象是属于根集中的对象

对象被一个可达的对象引用

根集,其是指正在执行的 Java 程序可以访问的引用变量(注意,不是对象)的集合,程序可以使用引用变量访问对象的属性和调用对象的方法。

在 JVM 中,会将以下对象标记为根集中的对象,具体包括(《深入理解Java虚拟机》):

· 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

· 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

· 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

· Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

· 所有被同步锁(synchronized关键字)持有的对象。

· 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

根集中的对象称之为GC Roots,也就是根对象。可达性分析法的基本思路是:将一系列的根对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,如果一个对象到根对象没有任何引用链相连,那么这个对象就不是可达的,也称之为不可达对象。

可达性分析(引用)

如上图所示,形象的展示了可达对象与不可达对象的示例,其中灰色的对象都是不可达对象,表示可以被垃圾收集的对象。

【多线程环境存在问题及解决】

在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,而我们的可达性分析线程却没有同步到最新的内容。那么就会造成误报或者漏报。

对于JVM来说漏报顶多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收了仍被引用的对象。怎么解决这个问题呢?

  • Stop-the-world以及安全点

在 Java 虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。

Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的(暂停应用线程以便 JVM 可以尽情地收拾家务的这种情况又被称之为安全点)。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才会停止所有线程,并允许请求Stop-the-world的那个线程进行独占的工作。

安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。

对于安全点,另一个需要考虑的问题就是如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方法:抢先式中断和主动式中断。

抢先式中断不需要线程的执行代码主动配合,在 GC 发生时,首先把所有的线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式。

主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

-- 更多内容请参考《深入理解Java虚拟机》

可达性分析的整个过程都需要STW,以避免对象的状态发生改变,这就导致GC停顿时长很长,大大影响应用的整体性能。CMS 回收器出现之前的所有回收器,都是用这种方式实现的。

2.3 三色标记算法

为了一次性解决循环引用、误标记、STW时间长等问题,引入了三色标记法。

三色标记算法指的是将所有对象分为白色、黑色和灰色三种类型。黑色表示从 GCRoots 开始,已扫描过它全部引用的对象,灰色指的是扫描过对象本身,还没完全扫描过它全部引用的对象,白色指的是还没扫描过的对象。

三色标记法的标记过程可以分为三个阶段:初始标记(Initial Marking)、并发标记(Concurrent Marking)和重新标记(Remark)。

  • 初始标记阶段,指的是标记 GCRoots 直接引用的节点,将它们标记为灰色,这个阶段需要 「Stop the World」。

  • 并发标记阶段,指的是从灰色节点开始,去扫描整个引用链,然后将它们标记为黑色,这个阶段不需要「Stop the World」。

  • 重新标记阶段,指的是去校正并发标记阶段的错误,这个阶段需要「Stop the World」。

  • 并发清除,指的是将已经确定为垃圾的对象清除掉,这个阶段不需要「Stop the World」。

对比一下「四阶段拆分」和「一段式」的实现方式,我们可以看出:通过将最耗时的引用链扫描剥离出来作为并发标记阶段,将其与用户线程并发执行,从而极大地降低了 GC 停顿时间。 但 GC 线程与用户线程并发执行,会带来新的问题:对象引用关系可能会发生变化,有可能发生多标和漏标问题。

2.4 引用的四种强度

Java中引用不是简单的"有或无",而是有强弱之分:

软引用示例

  // 缓存场景:希望数据在内存充足时保留,不足时释放
  SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024]);
  
  byte[] data = cache.get();  // 获取数据
  if (data == null) {
      // 内存不足时GC回收了,重新加载
      data = loadData();
      cache = new SoftReference<>(data);
  }

弱引用示例

  // ThreadLocal的实现使用弱引用,防止ThreadLocalMap内存泄漏
  WeakReference<Key> weakKey = new WeakReference<>(new Key());
  
  Key key = weakKey.get();  // 可能随时返回null(已被GC)

2.5 对象的finalize机制

当对象被判定为垃圾后,如果它覆盖了finalize()方法,JVM会把它放入F-Queue队列,稍后由一个低优先级线程执行finalize方法。

finalize方法的特殊之处

在finalize方法中,对象可以"自救"——重新建立与GC Roots的引用链,避免被回收。

  public class FinalizeEscape {
  
      private static FinalizeEscape SAVE_ME;
  
      @Override
      protected void finalize() throws Throwable {
          super.finalize();
          // 自救:重新建立引用
          SAVE_ME = this;
          System.out.println("对象自救成功!");
      }
  
      public static void main(String[] args) throws InterruptedException {
          FinalizeEscape obj = new FinalizeEscape();
          obj = null;  // 第一次判定为垃圾
  
          System.gc();  // 触发GC
          Thread.sleep(1000);
  
          if (SAVE_ME != null) {
              System.out.println("对象还活着");
          }
  
          SAVE_ME = null;  // 再次判定为垃圾
          System.gc();  // 第二次GC
  
          // finalize方法只会执行一次,第二次无法自救
          Thread.sleep(1000);
          System.out.println(SAVE_ME == null ? "对象已死亡" : "对象还活着");
      }
  }

实际开发中的建议

  • 不要使用finalize:它不可靠(执行时机不确定)、性能差、可能导致对象复活

  • finalize已被废弃:JDK 9标记废弃,JDK 18移除

  • 替代方案:使用try-finally或Cleaner/PhantomReference来清理资源

三、垃圾回收算法

确定了垃圾对象后,接下来是回收它们占用的内存。主要有三种算法。

3.1 标记-清除算法(Mark-Sweep)

最基础的算法,分为两步:

标记阶段:从GC Roots遍历,标记所有存活对象

清除阶段:遍历整个堆,清除未被标记的对象

优点

  • 实现简单

  • 不需要移动对象

缺点

  • 执行效率不稳定:堆越大,标记和清除耗时越长

  • 内存碎片严重:清除后产生大量不连续的空闲区域,大对象可能无法分配

【CMS收集器使用此算法】

3.2 标记-整理算法(Mark-Compact)

标记阶段与标记-清除相同,但清除阶段不是直接删除,而是把存活对象向一端移动,然后清理边界外的内存。

优点

  • 无内存碎片,分配效率高

  • 适合大对象分配

缺点

  • 移动对象成本高(要更新所有引用)

  • 移动过程需要STW(Stop-The-World)

【Serial Old、Parallel Old、G1(对老年代)使用此算法】

3.3 复制算法(Copying)

把内存分成两块,每次只用其中一块。GC时把存活对象复制到另一块,然后清空当前块。比如将内存空间分为A,B两部分,A用来存放对象,B空着,回收的时候将A空间的根可达对象进行标记,将有用对象复制到B,再把A里面的垃圾对象清理掉。

  • 优点:

  1. 不存在内存碎片问题。

  2. 效率高,因为是批量清理一整块内存,而不是找到具体的内存地址一个一个的清理。

  3. 缺点:

  1. 内存的占用率低,有一部分内存会空着。

  2. 因为也会涉及到内存地址的移动,存活对象很多的情况下也会影响性能。

优化版:新生代的实际实现

新生代对象90%以上都是"朝生夕灭",所以不需要1:1划分空间。实际采用:

  • Eden区占80%

  • 两个Survivor区各占10%

  • 每次使用Eden和一个Survivor,另一个Survivor空闲

【Serial New、Parallel New、G1(对新生代)使用此算法】

3.4 算法对比

实际JVM对不同区域使用不同算法:

  • 新生代:复制算法(存活率低,效率高)

  • 老年代:标记-清除或标记-整理(存活率高,空间利用率重要)

四、垃圾回收器演进历程

理解算法后,来看实际收集器的演进。从最早的Serial到现在主流的G1、ZGC、Shenandoah,每代收集器都在解决上一代的问题。

4.1 演进路线图

4.2 Serial GC(串行收集器)

特点

  • 单线程执行所有GC工作

  • GC时暂停所有应用线程(STW)

  • 新生代用复制算法,老年代用标记-整理

适用场景

  • 客户端应用(内存小、单核)

  • 嵌入式设备

  • 简单的后台任务

JVM参数

  -XX:+UseSerialGC

实际体验

本人曾在小内存服务器(1GB堆)上测试过Serial GC。虽然每次GC时间不长,但单线程的STW对用户体验有影响。只适合不太重要的后台任务。

4.3 Parallel GC(并行收集器)

特点

  • 多线程并行执行GC(新生代和老年代)

  • 关注吞吐量(GC时间占总时间的比例)

  • 新生代用复制算法,老年代用标记-整理

适用场景

  • 后台计算任务

  • 批处理系统

  • 对延迟不敏感但对吞吐量要求高的系统

JVM参数

  -XX:+UseParallelGC
  -XX:ParallelGCThreads=8  # GC线程数
  -XX:MaxGCPauseMillis=200  # 最大停顿时间目标
  -XX:GCTimeRatio=99  # 吞吐量目标(GC时间不超过1/(1+99)=1%)

实际应用

数据分析平台、报表生成系统这类后台任务适合用Parallel GC。吞吐量优先意味着可以接受较长的GC停顿,换取更多的工作时间。

4.4 CMS GC(并发标记清除)

特点

  • 目标:最短停顿时间

  • 并发执行大部分GC工作,与用户线程并行

  • 老年代用标记-清除算法

  • 新生代用ParNew收集器

工作阶段

  1. 初始标记(STW,短暂)
     ─── 标记GC Roots直接关联的对象
  
  2. 并发标记(并发,耗时)
     ─── 从GC Roots遍历整个引用链
  
  3. 重新标记(STW,短暂)
     ─── 修正并发标记期间引用变化的对象
  
  4. 并发清除(并发,耗时)
     ─── 清除标记为垃圾的对象

优点

  • 停顿时间短(大部分工作并发)

  • 适合对延迟敏感的应用

缺点

  • 对CPU敏感:并发线程占用CPU,影响应用性能

  • 无法处理浮动垃圾:并发标记期间新产生的垃圾要等下次GC

  • 内存碎片:标记-清除不整理,可能导致大对象分配失败

  • 失败时触发Full GC:预留空间不足时退化为Serial Old

JVM参数

  -XX:+UseConcMarkSweepGC  # JDK 9废弃,JDK 14移除
  -XX:CMSInitiatingOccupancyFraction=75  # 老年代使用75%时触发CMS
  -XX:+UseCMSCompactAtFullCollection  # Full GC时压缩(默认开启)
  -XX:CMSFullGCsBeforeCompaction=5  # 每5次Full GC压缩一次

历史地位

CMS是第一款真正意义上的并发收集器,开创了"低延迟GC"的时代。虽然在现代 JVM 中不再推荐使用,但它的设计思想影响了后来的G1、ZGC等收集器。

4.5 G1 GC(Garbage First)

特点

  • Region分区设计:堆分成多个大小相等的Region

  • 可预测停顿:用户设定停顿时间目标,G1尽量达成

  • 无碎片:整体看是标记-整理,局部看是复制

  • JDK 9起成为默认GC

Region设计

  堆被划分为多个Region(默认2048个)
  每个Region可以是:Eden、Survivor、Old、Humongous
  
  ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
  │E │S │O │E │O │S │E │H │O │E │S │O │
  └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
  E=Eden, S=Survivor, O=Old, H=Humongous(大对象)

工作原理

  1. Young GC
     ─── 回收所有Eden和Survivor Region中的垃圾对象
     ─── 存活对象复制到新的Survivor或Old Region
  
  2. Mixed GC(并发周期)
     ─── 初始标记(STW,借助Young GC)
     ─── 并发标记(并发)
     ─── 最终标记(STW)
     ─── 筛选回收(STW):根据停顿目标,选择垃圾最多的几个Region回收
  
  3. Full GC
     ─── 退化为单线程标记-整理(G1尽量避免Full GC)

核心优势

  • 可预测停顿:用户设定MaxGCPauseMillis,G1在目标范围内选择Region回收

  • 无碎片:Region级别使用复制算法,整体使用整理算法

  • 灵活:不需要物理上连续的新生代和老年代

JVM参数

  -XX:+UseG1GC  # JDK 9+默认开启
  -XX:MaxGCPauseMillis=200  # 最大停顿时间目标(默认200ms)
  -XX:G1HeapRegionSize=4m  # Region大小(1MB-32MB,默认自动计算)
  -XX:InitiatingHeapOccupancyPercent=45  # 整堆占用45%触发并发标记
  -XX:G1NewSizePercent=5  # 新生代最小占比
  -XX:G1MaxNewSizePercent=60  # 新生代最大占比

实际调优经验

本人在多个项目中使用G1 GC,总结几点经验:

1. MaxGCPauseMillis的设置

不是设得越小越好。太小会导致:

  • 每次回收的Region数量少,回收效率低

  • GC频率增加,反而总停顿时间更长

建议:根据业务SLA设置,200ms-500ms比较合理。

2. InitiatingHeapOccupancyPercent调整

默认45%,如果堆增长快可以调低(如30%),提前触发并发标记,避免Full GC。

3. 监控Full GC

G1理想情况不发生Full GC。如果频繁Full GC,说明堆太小或对象晋升太快,需要调整堆大小或新生代比例。

4.6 ZGC(Z Garbage Collector)

特点

  • 目标:停顿时间不超过10ms(JDK 17后不超过1ms)

  • 支持TB级别堆内存

  • 停顿时间与堆大小无关

  • JDK 15正式可用,JDK 21分代ZGC性能飞跃

核心技术

着色指针

传统GC需要在对象头存储标记信息,ZGC把标记信息直接编码到指针中:

  64位指针:
  ┌─────────────────────────────────────────┐
  │ 0-41位:对象地址 │ 42-45位:标记信息  │ ... │
  └─────────────────────────────────────────┘

这样读取指针时就能知道对象的状态,无需额外的标记表。

读屏障

  Object o = obj.field;  // 读取引用
  
  // 读屏障会在读取后执行检查:
  // if (指针颜色 != 当前颜色) {
  //     修正指针,指向新地址(如果对象被移动)
  //     更新引用
  // }

读屏障让ZGC可以并发整理对象:移动对象后,通过读屏障自动修正所有指向它的引用。

工作流程

  1. 初始标记(STW,短暂)
     ─── 标记GC Roots直接关联的对象
  
  2. 并发标记(并发)
     ─── 遍历对象图,使用读屏障
  
  3. 再标记(STW,短暂)
     ─── 处理少量残留
  
  4. 并发转移准备(并发)
     ─── 分析需要整理的Region
  
  5. 初始转移(STW,短暂)
     ─── 转移部分Region
  
  6. 并发转移(并发)
     ─── 转移其余Region

整个过程中STW阶段都非常短暂,主要工作并发执行。

JVM参数

  -XX:+UseZGC  # 开启ZGC
  -XX:ZCollectionInterval=5  # 主动GC间隔(秒),0为仅内存不足时触发
  -Xmx16g  # 堆大小(ZGC适合大堆)

JDK 21分代ZGC

分代ZGC把堆分为新生代和老年代,结合分代收集的优势:

  • 新生代对象"朝生夕灭",单独收集效率高

  • 减少整堆扫描频率

  • 吞吐量显著提升(相比不分代ZGC提升约30%)

  -XX:+UseZGC -XX:+ZGenerational  # JDK 21开启分代ZGC

适用场景

  • 超大堆内存(64GB+)

  • 对延迟极其敏感的应用(如交易系统)

  • 云原生环境(动态伸缩)

4.7 Shenandoah GC

特点

  • 目标:停顿时间与堆大小无关

  • 低延迟(通常<10ms)

  • 与ZGC类似,但实现方式不同

核心技术

转发指针

访问对象时:先检查转发指针,如果指向新位置,就访问新位置。

JVM参数

  -XX:+UseShenandoahGC  # 开启Shenandoah
  -XX:ShenandoahGCHeuristics=compact  # 策略:compact/passive/aggressive

ZGC vs Shenandoah对比

选择建议

  • Linux平台、超大堆、极致低延迟 → ZGC

  • 需要稳定低延迟、堆中等偏大 → Shenandoah

  • 通用场景 → G1

4.8 收集器对比总表

五、GC实战:问题排查与调优

5.1 GC日志分析

开启GC日志

  # JDK 8及之前
  -XX:+PrintGCDetails
  -XX:+PrintGCDateStamps
  -XX:+PrintGCTimeStamps
  -Xloggc:/path/to/gc.log
  
  # JDK 9+(统一日志框架)
  -Xlog:gc*:file=/path/to/gc.log:time,level,tags

GC日志示例(G1)

  [2024-01-15T10:30:45.123+0800][gc,start] GC(123) Pause Young (G1 Evacuation Pause)
  [2024-01-15T10:30:45.125+0800][gc,heap] GC(123) Eden regions: 120->0(120)
  [2024-01-15T10:30:45.125+0800][gc,heap] GC(123) Survivor regions: 15->15(15)
  [2024-01-15T10:30:45.125+0800][gc,heap] GC(123) Old regions: 50->52(200)
  [2024-01-15T10:30:45.126+0800][gc,metaspace] GC(123) Metaspace: 128MB->128MB(256MB)
  [2024-01-15T10:30:45.126+0800][gc,end] GC(123) Pause Young (G1 Evacuation Pause) 3.245ms
  
  [2024-01-15T10:31:00.456+0800][gc,start] GC(124) Pause Mixed (G1 Evacuation Pause)
  ...

关键指标解读

5.2 常见GC问题排查

问题1:频繁Full GC

现象:GC日志中频繁出现Full GC,系统卡顿严重。

原因分析

  • 堆太小,内存不足

  • 大对象过多,直接进入老年代

  • 对象晋升过快(Survivor区太小)

  • 内存泄漏(对象无法回收)

排查步骤

  # 1. 查看堆使用情况
  jstat -gcutil <pid> 1000 10
  
  # 2. 导出堆转储
  jmap -dump:format=b,file=heap.hprof <pid>
  
  # 3. 分析堆转储(使用MAT或VisualVM)
  # 找出占用最大的对象、是否存在内存泄漏
  
  # 4. 检查大对象
  jmap -histo <pid> | head -20

解决方案

  • 增大堆内存:-Xmx

  • 调整新生代比例:-XX:NewRatio-Xmn

  • 增大Survivor区:-XX:SurvivorRatio

  • 调整大对象阈值:-XX:PretenureSizeThreshold

  • 排查代码中的内存泄漏点

问题2:Young GC频繁且耗时

现象:Young GC每几秒一次,每次耗时几十毫秒。

原因分析

  • 新生代太小,频繁触发GC

  • Eden区分配速率高(对象创建多)

  • Survivor区太小,对象过早晋升

解决方案

  • 增大新生代:-Xmn或调整-XX:G1NewSizePercent(G1)

  • 检查是否有不必要的对象创建

  • 分析热点代码,减少临时对象

问题3:GC停顿时间过长

现象:G1或ZGC的停顿时间超出预期。

原因分析

  • MaxGCPauseMillis设置不合理

  • 堆过大(对传统GC器)

  • CPU资源不足(并发GC线程竞争CPU)

解决方案

  • G1:调整MaxGCPauseMillis和Region选择策略

  • 考虑切换到ZGC/Shenandoah(大堆场景)

  • 增加GC线程:-XX:ParallelGCThreads-XX:ConcGCThreads

5.3 GC调优实战案例

案例1:电商服务优化

背景:电商服务,8GB堆,使用G1 GC,高峰期Young GC频繁,偶发Full GC导致卡顿。

问题诊断

  # GC日志分析
  # Young GC平均30ms,但频率高(每2秒)
  # 偶发Full GC,耗时3-5秒

调优步骤

  # 原配置
  -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
  
  # 调整1:增大新生代占比
  -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
  -XX:G1NewSizePercent=30  # 新生代最小30%(默认5%)
  -XX:G1MaxNewSizePercent=50  # 新生代最大50%(默认60%)
  
  # 效果:Young GC频率降低,但Full GC仍有
  
  # 调整2:提前触发并发标记
  -XX:InitiatingHeapOccupancyPercent=35  # 降低阈值(默认45%)
  
  # 效果:Full GC消失,Mixed GC频率增加但停顿可控

最终配置

  -Xmx12g  # 增大堆
  -XX:+UseG1GC
  -XX:MaxGCPauseMillis=300
  -XX:G1NewSizePercent=30
  -XX:InitiatingHeapOccupancyPercent=40
  -XX:ParallelGCThreads=8
  -XX:ConcGCThreads=2

效果

  • Young GC频率从每2秒降到每5秒

  • Full GC消失

  • 平均停顿时间30ms,最大200ms

案例2:数据分析平台优化

背景:数据分析平台,64GB堆,使用G1 GC,Full GC频繁,每次耗时10秒以上。

问题诊断

大堆使用G1 GC,整堆标记和整理耗时过长。

调优方案:切换到ZGC

  # 原配置
  -Xmx64g -XX:+UseG1GC
  
  # 新配置(JDK 21)
  -Xmx64g -XX:+UseZGC -XX:+ZGenerational

效果

  • GC停顿从10秒降到<10ms

  • 吞吐量提升20%

  • 内存利用率无明显下降

5.4 GC参数调优建议

通用建议

G1建议

ZGC建议

5.5 GC监控工具

命令行工具

  # 实时监控GC
  jstat -gc <pid> 1000
  
  # 查看堆详情
  jmap -heap <pid>
  
  # 导出堆转储
  jmap -dump:format=b,file=heap.hprof <pid>
  
  # 查看线程栈(分析GC线程)
  jstack <pid>

可视化工具

  • VisualVM:JDK自带,监控堆、线程、GC

  • JConsole:JDK自带,实时监控

  • MAT(Memory Analyzer Tool):分析堆转储,找出内存泄漏

  • GCViewer:分析GC日志,可视化展示GC频率、耗时趋势

  • JProfiler:商业工具,功能全面

线上监控

  • Prometheus + Grafana:通过JMX采集GC指标,可视化展示

  • Elastic APM:全链路监控,包含GC指标

  • 阿里云ARMS:应用实时监控服务

六、常见问题

Q1:如何选择合适的GC器?

根据场景选择:

Q2:GC调优能提升多少性能?

实际效果因场景而异。我的经验:

  • 配置不当的GC调优:可能提升20%-50%

  • 已经合理配置的GC:进一步优化可能提升5%-15%

  • 切换到更先进的GC器(如G1→ZGC):停顿可能降低90%+

调优不是万能的,根本解决方案往往是优化代码(减少对象创建、解决内存泄漏)。

Q3:为什么CMS被废弃了?

CMS的致命问题:

  • 内存碎片:无法整理,大对象分配可能失败

  • CPU敏感:并发线程占用CPU资源

  • 浮动垃圾:并发标记期间产生的新垃圾无法回收

  • 失败退化:预留空间不足时退化为Serial Old(单线程Full GC)

G1解决了这些问题,ZGC/Shenandoah进一步优化,CMS自然被淘汰。

Q4:分代ZGC比不分代有什么优势?

分代收集利用了"弱分代假说":

  • 新生代对象大部分很快死亡,单独收集效率高

  • 老年代对象存活久,不需要频繁扫描

分代ZGC:

  • 吞吐量提升约30%(相比不分代)

  • 减少整堆扫描频率

  • 适合对象创建频繁的场景

Q5:如何判断是否存在内存泄漏?

排查方法:

  1. 观察老年代是否持续增长(GC后不下降)

  2. 导出堆转储,分析对象数量和大小

  3. 使用MAT查看是否有对象被意外持有(如静态集合、ThreadLocal未清理)

  4. 分析代码中的长生命周期对象持有短生命周期对象的引用

Q6:GC参数越多越好吗?

不是。关键参数几个就够了:

  # 大部分场景只需要这些
  -Xms4g -Xmx4g  # 堆大小
  -XX:+UseG1GC  # GC器
  -XX:MaxGCPauseMillis=200  # 停顿目标

过多的参数:

  • 难以维护

  • 可能相互冲突

  • 理解成本高

建议:先用默认配置,有问题再针对性调整。

七、总结

垃圾回收是Java内存管理的核心机制。理解GC,需要从三个层面入手:

理论基础

  • 垃圾对象判定(可达性分析)

  • 回收算法(标记-清除、标记-整理、复制)

  • 分代收集思想(新生代、老年代)

收集器演进

  • 从Serial到Parallel,追求吞吐量

  • 从CMS到G1,追求可控停顿

  • 从G1到ZGC/Shenandoah,追求极致低延迟

实战应用

  • GC日志分析,发现问题

  • 参数调优,优化性能

  • 问题排查,解决内存泄漏和异常GC

实际项目中,GC调优往往是"治标",代码优化才是"治本"。减少不必要的对象创建、避免内存泄漏、合理设计数据结构,这些工作比调整GC参数更重要。

但理解GC原理,能在遇到问题时快速定位和解决,这是每个Java开发者应该具备的能力。


评论