news 2026/10/15 15:00:02

JVM 垃圾回收实战:从一段真实 GC 日志看懂分代、收集器与调优(JDK 8 与 JDK 21 对比实测)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JVM 垃圾回收实战:从一段真实 GC 日志看懂分代、收集器与调优(JDK 8 与 JDK 21 对比实测)

文章目录

    • 一、先建立判断标准:对象什么时候算「垃圾」
      • 1.1 引用计数不行,可达性分析才行
      • 1.2 引用类型:四种,决定「什么时候回收」
    • 二、分代回收:为什么分代,以及对象怎么流动
      • 2.1 分代结构与对象流动路径
      • 2.2 一张图解释「为什么 Minor GC 快、Full GC 慢」
    • 三、实测:同一段代码,JDK 8 与 JDK 21 的 GC 日志对比
      • 3.1 JDK 8(默认 Parallel Scavenge + Parallel Old)
      • 3.2 JDK 21(默认 G1)
      • 3.3 两组日志告诉我们什么
    • 四、拿到一份 GC 日志,该看哪几个数字
    • 五、收集器怎么选:一张表说清
    • 六、三条最实用的调优结论
    • 七、小结

上一篇讲了 JVM 运行时内存区域,这篇接着往下走:那些区域里的对象什么时候被回收、回收时会打印什么、日志里哪个数字说明该调参了。

这篇文章的特点是所有日志都是本机跑出来的真实输出,而且同一段代码分别在 JDK 8(Parallel 收集器)和 JDK 21(G1)上跑了一遍——两代默认收集器的日志格式、行为差异,一眼就能对上。

读完你会具备这个能力:拿到一份线上 GC 日志,知道该看哪几个数字,以及哪些情况根本不用调。


一、先建立判断标准:对象什么时候算「垃圾」

1.1 引用计数不行,可达性分析才行

引用计数(Python 那套)遇到循环引用就废了:

Aa=newA();Bb=newB();a.b=b;b.a=a;a=null;b=null;// 两个对象互相引用,引用计数都是 1,但已经不可达

JVM 用的是可达性分析:从一组根(GC Roots)出发能走到的对象才活着。GC Roots 包括:

  • 虚拟机栈中引用的对象(局部变量)
  • 方法区中静态属性、常量引用的对象
  • 本地方法栈中 JNI 引用的对象
  • 活跃线程、同步锁持有者等

其余全是可回收的。「不可达」的定义决定了:一个对象只要还被任何一个 GC Root 引用链牵着,就活不下来也死不了。这句话是后面所有内存泄漏问题的解释基础。

1.2 引用类型:四种,决定「什么时候回收」

引用类型回收时机典型用途
强引用永远不回收(除非不可达)日常new出来的对象
软引用内存不够时回收本地缓存(图片、热点数据)
弱引用下次 GC 必回收ThreadLocal的 key、WeakHashMap
虚引用只是回收通知堆外内存回收(DirectByteBuffer)

ThreadLocal的内存泄漏问题根子就在这:它的 key 是弱引用,value 是强引用。key 被回收后,value 变成「key 为 null 的 Entry」,只能等下次操作时被动清理——用完不remove(),在池化线程(比如 Tomcat 线程池)里就会一直攒着。


二、分代回收:为什么分代,以及对象怎么流动

分代的理由是「绝大多数对象朝生夕死」。实测数据是 90% 以上的对象活不过第一轮 GC。既然新对象多、存活少,那就给它们划一块小区域(新生代)频繁扫,扫起来快;熬过几轮的对象才升到老年代,那边扫描频率低。

2.1 分代结构与对象流动路径

┌───────── 新生代(约 1/3 堆)─────────┐ ┌── 老年代(约 2/3 堆)──┐ 新对象分配 → Eden ──── 满了触发 Minor GC ────► Survivor ── 熬过 N 轮 ──► Old (8) 存活对象复制到 S0/S1 (1+1) (默认 15 轮) (大对象直接进)

关键参数:

参数含义备注
-Xmn新生代大小等价于-XX:NewSize+-XX:MaxNewSize
-XX:SurvivorRatio=8Eden : 单个 Survivor8:1:1
-XX:MaxTenuringThreshold=15晋升老年代的年龄阈值每熬过一次 Minor GC 年龄 +1
-XX:PretenureSizeThreshold超过该大小直接进老年代Parallel/Serial 有效,G1 下不生效

两个容易被忽略的事实:

  1. 动态年龄判定。不是死板等 15 轮:如果 Survivor 中同年龄对象总和超过 Survivor 空间的一半,那么该年龄及以上的对象直接晋升。所以「我明明设了 15」却发现对象提前进老年代,先看 Survivor 是不是太小。
  2. 大对象直接进老年代。分配时直接在探针上判断,避免大对象在 Eden/Survivor 之间反复复制。

2.2 一张图解释「为什么 Minor GC 快、Full GC 慢」

  • Minor GC(新生代回收):只扫新生代,存活对象少,用的是复制算法(把活的复制到 Survivor,整块清空 Eden)——活的对象少,效率极高。
  • Full GC(整堆回收):要扫老年代,老年代存活率高,用标记-清除/标记-整理,而且往往会Stop The World(暂停所有用户线程)。

调优的核心目标因此可以概括成一句话:让 Full GC 尽量少、尽量短。线上告警的常规阈值是:Full GC 频率 > 1 次/分钟,或单次停顿 > 1 秒,就该看了。


三、实测:同一段代码,JDK 8 与 JDK 21 的 GC 日志对比

实验代码很朴素——循环分配 2MB 数组,每 10 轮清空一次引用让它们变成垃圾:

publicclassGcDemo{staticfinalintM=1024*1024;publicstaticvoidmain(String[]args)throwsException{List<byte[]>hold=newArrayList<>();for(inti=1;i<=30;i++){hold.add(newbyte[2*M]);// 每次分配 2MBif(i%10==0){hold.clear();// 每 10 轮清空,制造垃圾System.out.println(">>> 第 "+i+" 轮后清空引用");}}System.out.println(">>> 结束");}}

堆固定 64MB,这样 GC 会频繁发生,便于观察:-Xms64m -Xmx64m。

3.1 JDK 8(默认 Parallel Scavenge + Parallel Old)

开启老式 GC 日志:-XX:+PrintGCDetails -XX:+PrintGCDateStamps

真实输出(截取):

2026-10-15T09:56:03.905+0800: [GC (Allocation Failure) [PSYoungGen: 15975K->2544K(18944K)] 15975K->15220K(62976K), 0.0030688 secs] >>> 第 10 轮后清空引用 2026-10-15T09:56:03.910+0800: [GC (Allocation Failure) [PSYoungGen: 17197K->768K(18944K)] 29873K->21636K(62976K), 0.0014333 secs] >>> 第 20 轮后清空引用 2026-10-15T09:56:03.913+0800: [GC (Allocation Failure) [PSYoungGen: 17181K->2536K(18944K)] 38065K->37772K(62976K), 0.0025125 secs] 2026-10-15T09:56:03.915+0800: [Full GC (Ergonomics) [PSYoungGen: 2536K->0K(18944K)] [ParOldGen: 35236K->16913K(44032K)] 37772K->16913K(62976K), [Metaspace: 2528K->2528K(1056768K)], 0.0037606 secs] >>> 第 30 轮后清空引用 >>> 结束 Heap PSYoungGen total 18944K, used 4737K eden space 16384K, 28% used from space 2560K, 0% used to space 2560K, 0% used ParOldGen total 44032K, used 16913K Metaspace used 2535K, capacity 4486K, committed 4864K, reserved 1056768K

3.2 JDK 21(默认 G1)

新版用统一日志:-Xlog:gc

真实输出(截取):

[0.008s][info][gc] Using G1 [0.053s][info][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 30M->28M(64M) 1.808ms [0.053s][info][gc] GC(1) Concurrent Undo Cycle 0.075ms >>> 第 10 轮后清空引用 [0.055s][info][gc] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 32M->0M(64M) 0.828ms ... >>> 第 30 轮后清空引用 >>> 结束

再加上-Xlog:gc+heap=info看分区细节:

[0.057s][info][gc,heap] GC(6) Eden regions: 1->0(37) [0.057s][info][gc,heap] GC(6) Survivor regions: 1->1(3) [0.057s][info][gc,heap] GC(6) Old regions: 0->0 [0.057s][info][gc,heap] GC(6) Humongous regions: 30->0 [0.060s][info][gc,heap] GC(8) Humongous regions: 27->27

3.3 两组日志告诉我们什么

第一,JDK 8 那组出现了Full GC,JDK 21 一次都没有。我这个 2MB 的对象在 Parallel 下最终还是被挪进了ParOldGen(用 35236K → 16913K 那一段),老年代撑不住就触发 Full GC。G1 全程只做 Young GC(Pause Young)就消化掉了。这就是换收集器带来的最直接的收益,不用改一行业务代码。

第二,G1 日志里的G1 Humongous Allocation是理解 G1 的钥匙。G1 把堆切成等大的 region(本机 64MB 堆下 region 约 1MB),超过 region 一半大小的对象叫「大对象」(Humongous),单独占连续 region,直接跳过新生代。日志里Humongous regions: 30->0、27->27就是它在工作。所以「大对象直接进老年代」这条在 G1 下的表现形式是 Humongous region,参数-XX:PretenureSizeThreshold在 G1 上是无效的——这一点很多老文档没说清。

第三,日志格式换代了。JDK 9 起统一日志框架,老参数(-XX:+PrintGCDetails、-XX:+PrintGCDateStamps)全部作废。对照表:

老参数(JDK 8)新参数(JDK 9+)
-XX:+PrintGCDetails-Xlog:gc*
-XX:+PrintGCDateStamps-Xlog:gc*:file=gc.log:time,uptime
-XX:+PrintGCApplicationStoppedTime-Xlog:safepoint
-Xloggc:/path/gc.log-Xlog:gc*:file=/path/gc.log

四、拿到一份 GC 日志,该看哪几个数字

Minor GC 行(以PSYoungGen: 15975K->2544K(18944K)为例)读法是:

[PSYoungGen: 回收前 -> 回收后 (新生代总容量)] 全堆: 回收前 -> 回收后 (堆总容量)

三个判断:

现象说明什么怎么办
Minor GC 后新生代残留很小(如 2544K)正常,存活对象少不用动
Minor GC 后残留接近总容量Survivor 太小或对象存活率过高加大新生代/Survivor,或查是否缓存无上限
晋升老年代速度很快,老年代持续增长要么对象确实该长寿,要么晋升阈值太小看-XX:MaxTenuringThreshold,同时查业务是否有长生命周期集合
Full GC 后老年代几乎没降真泄漏或者真有大量长命对象导堆转储(-XX:+HeapDumpOnOutOfMemoryError),用 MAT 找大对象

最后一行是重点:Full GC 之后老年代还高位不动,就别调参数了,那是代码问题。调参只能改变「什么时候撞墙」,改不了「墙在那里」。


五、收集器怎么选:一张表说清

收集器分代方式特点适用
Serial / Serial Old分代单线程,简单,停顿明显客户端、小内存(百 MB 级)
Parallel Scavenge / Parallel Old分代多线程,吞吐量优先JDK 8 默认;后台批处理、不在意单次停顿
G1分区(逻辑分代)可设停顿目标-XX:MaxGCPauseMillis,兼顾吞吐与停顿JDK 9+ 默认;绝大多数服务端的默认答案
ZGC / Shenandoah分区停顿降到亚毫秒级(ZGC 靠染色指针 + 并发整理)超大堆(几十 GB 以上)、对停顿极敏感
CMS分代并发标记清除,已废弃(JDK 14 移除)老项目维护时见到,别在新项目用

选择逻辑可以简化成一个判断:堆小于 8G、停顿要求一般 → 用默认的 G1,不要动。堆到几十 G 且要求停顿个位数毫秒 → 考虑 ZGC。追求吞吐量的离线计算 → Parallel 仍然合适。

G1 最常被误用的一个参数是-XX:MaxGCPauseMillis:它是个软目标,设得太激进(比如 20ms),G1 会缩小年轻代来满足停顿,结果 Young GC 变得极其频繁、总吞吐下降。先别设,观察默认表现再动。


六、三条最实用的调优结论

第一,先把日志开起来,别猜。生产必备四个参数:

-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/data/dump/ -Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime:filecount=10,filesize=50M-XX:+HeapDumpBeforeFullGC# 排查问题时临时开,会 STW,别常开

第二,-Xms和-Xmx设成一样。堆动态伸缩会带来额外的 GC 与系统调用,容器环境里还可能踩到「可用内存判断不准」的坑。另外容器里记得确认 JVM 有没有正确读到 cgroup 限制(JDK 8u191+ 默认支持,更早版本要手动指定-XX:MaxRAMPercentage)。

第三,能通过代码解决的,不要靠调参。优先排查顺序:

  1. 有没有无上限的本地缓存(static Map只 put 不删)?
  2. 有没有超大的集合一次性加载(select *全表进内存)?
  3. ThreadLocal用完remove()了吗?
  4. 有没有频繁创建大数组/大字符串?

这四类问题占到线上内存问题的绝大多数。GC 参数是最后一道微调,不是第一道解法。


七、小结

  • 对象存活由可达性分析决定,ThreadLocal泄漏的根子是「key 弱引用、value 强引用」。
  • 分代的理论基础是「朝生夕死」,流动路径:Eden → Survivor →(N 轮)→ Old,大对象直接进老年代;G1 下表现为 Humongous region。
  • 日志读法:看 Minor GC 后残留(判断 Survivor 是否够)、看晋升速度、看 Full GC 后老年代降不降(不降就是代码问题)。
  • 收集器选型默认 G1 即可,MaxGCPauseMillis别乱调;堆到几十 G 且要求低停顿再上 ZGC。
  • 调优顺序永远是:先开日志拿证据 → 先改代码 → 最后才调参数。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 14:59:32

XHEDITOR集成MathJax实现LaTeX公式网页渲染

1. 项目背景与需求解析在半导体行业的技术文档编写中&#xff0c;数学公式是不可或缺的组成部分。传统方案通常面临以下痛点&#xff1a;研发人员习惯使用LaTeX编写技术文档&#xff0c;但企业内部知识管理系统需要HTML格式直接粘贴LaTeX源码会导致网页显示乱码截图插入公式方案…

作者头像 李华
网站建设 2026/10/15 14:59:27

STM32F103 DAC音频输出实战:时序、滤波与寄存器避坑指南

简介&#xff1a;本资源是一份基于STM32F103ZET6芯片的DAC音频输出实战项目&#xff0c;面向嵌入式初学者与STM32进阶开发者&#xff0c;解决数字音频信号生成与模拟输出的核心问题&#xff0c;适用于课程设计、毕业设计及音频类小系统开发场景。压缩包共181个文件&#xff0c;…

作者头像 李华
网站建设 2026/10/15 14:58:37

NX二次开发必会:UF_VEC3_distance两点距离计算详解

简介&#xff1a;面向NX二次开发初学者的工程源码包&#xff0c;围绕UFUN库中的UF_VEC3_distance接口&#xff0c;演示如何在UG/NX环境里用C计算三维空间中两点距离。包内提供完整工程源代码、VS工程配置与编译产物&#xff0c;从源文件编写、库依赖配置到动态库生成的完整脉络…

作者头像 李华
网站建设 2026/10/15 14:58:34

IP地址与子网划分:网络运维核心技能详解

1. IP地址基础概念解析IP地址就像互联网世界的门牌号码&#xff0c;每台联网设备都需要一个独特的标识。我在网络运维岗位上摸爬滚打十年&#xff0c;见过太多因为IP基础知识不扎实导致的网络故障。这次系统梳理IP地址的核心概念&#xff0c;希望能帮助刚入行的朋友少走弯路。I…

作者头像 李华
网站建设 2026/10/15 14:58:30

jQuery进度条插件详解:百分比显示与动态更新实战

简介&#xff1a;面向Web前端开发者的jQuery progressbar进度条插件资源包&#xff0c;基于jQuery UI实现页面中的动态进度展示&#xff0c;适合文件上传、数据处理、加载状态等需要直观反馈任务完成比例的场景。压缩包内共9个文件&#xff0c;包括可直接运行的HTML示例、jQuer…

作者头像 李华
网站建设 2026/10/15 14:58:29

Metalsmith 文件匹配指南:用 match() 按 Glob 模式精确筛选源文件

开发工具CLI前端 【免费下载链接】metalsmith An extremely simple, pluggable static site generator for Node.js 项目地址&#xff1a; https://gitcode.com/gh_mirrors/me/metalsmith 点击查看 免费下载 导读 本文聚焦 Metalsmith&#xff08;Node.js 极简可插拔静态站点…

作者头像 李华