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 Old3.2 JDK 21默认 G13.3 两组日志告诉我们什么四、拿到一份 GC 日志该看哪几个数字五、收集器怎么选一张表说清六、三条最实用的调优结论七、小结上一篇讲了 JVM 运行时内存区域这篇接着往下走那些区域里的对象什么时候被回收、回收时会打印什么、日志里哪个数字说明该调参了。这篇文章的特点是所有日志都是本机跑出来的真实输出而且同一段代码分别在 JDK 8Parallel 收集器和 JDK 21G1上跑了一遍——两代默认收集器的日志格式、行为差异一眼就能对上。读完你会具备这个能力拿到一份线上 GC 日志知道该看哪几个数字以及哪些情况根本不用调。一、先建立判断标准对象什么时候算「垃圾」1.1 引用计数不行可达性分析才行引用计数Python 那套遇到循环引用就废了AanewA();BbnewB();a.bb;b.aa;anull;bnull;// 两个对象互相引用引用计数都是 1但已经不可达JVM 用的是可达性分析从一组根GC Roots出发能走到的对象才活着。GC Roots 包括虚拟机栈中引用的对象局部变量方法区中静态属性、常量引用的对象本地方法栈中 JNI 引用的对象活跃线程、同步锁持有者等其余全是可回收的。「不可达」的定义决定了一个对象只要还被任何一个 GC Root 引用链牵着就活不下来也死不了。这句话是后面所有内存泄漏问题的解释基础。1.2 引用类型四种决定「什么时候回收」引用类型回收时机典型用途强引用永远不回收除非不可达日常new出来的对象软引用内存不够时回收本地缓存图片、热点数据弱引用下次 GC 必回收ThreadLocal的 key、WeakHashMap虚引用只是回收通知堆外内存回收DirectByteBufferThreadLocal的内存泄漏问题根子就在这它的 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 (11) (默认 15 轮) 大对象直接进关键参数参数含义备注-Xmn新生代大小等价于-XX:NewSize-XX:MaxNewSize-XX:SurvivorRatio8Eden : 单个 Survivor8:1:1-XX:MaxTenuringThreshold15晋升老年代的年龄阈值每熬过一次 Minor GC 年龄 1-XX:PretenureSizeThreshold超过该大小直接进老年代Parallel/Serial 有效G1 下不生效两个容易被忽略的事实动态年龄判定。不是死板等 15 轮如果 Survivor 中同年龄对象总和超过 Survivor 空间的一半那么该年龄及以上的对象直接晋升。所以「我明明设了 15」却发现对象提前进老年代先看 Survivor 是不是太小。大对象直接进老年代。分配时直接在探针上判断避免大对象在 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{staticfinalintM1024*1024;publicstaticvoidmain(String[]args)throwsException{Listbyte[]holdnewArrayList();for(inti1;i30;i){hold.add(newbyte[2*M]);// 每次分配 2MBif(i%100){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.9050800: [GC (Allocation Failure) [PSYoungGen: 15975K-2544K(18944K)] 15975K-15220K(62976K), 0.0030688 secs] 第 10 轮后清空引用 2026-10-15T09:56:03.9100800: [GC (Allocation Failure) [PSYoungGen: 17197K-768K(18944K)] 29873K-21636K(62976K), 0.0014333 secs] 第 20 轮后清空引用 2026-10-15T09:56:03.9130800: [GC (Allocation Failure) [PSYoungGen: 17181K-2536K(18944K)] 38065K-37772K(62976K), 0.0025125 secs] 2026-10-15T09:56:03.9150800: [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 1056768K3.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:gcheapinfo看分区细节[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-273.3 两组日志告诉我们什么第一JDK 8 那组出现了Full GCJDK 21 一次都没有。我这个 2MB 的对象在 Parallel 下最终还是被挪进了ParOldGen用 35236K → 16913K 那一段老年代撑不住就触发 Full GC。G1 全程只做 Young GCPause 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*:filegc.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它是个软目标设得太激进比如 20msG1 会缩小年轻代来满足停顿结果 Young GC 变得极其频繁、总吞吐下降。先别设观察默认表现再动。六、三条最实用的调优结论第一先把日志开起来别猜。生产必备四个参数-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath/data/dump/ -Xlog:gc*:file/data/logs/gc-%t.log:time,uptime:filecount10,filesize50M-XX:HeapDumpBeforeFullGC# 排查问题时临时开会 STW别常开第二-Xms和-Xmx设成一样。堆动态伸缩会带来额外的 GC 与系统调用容器环境里还可能踩到「可用内存判断不准」的坑。另外容器里记得确认 JVM 有没有正确读到 cgroup 限制JDK 8u191 默认支持更早版本要手动指定-XX:MaxRAMPercentage。第三能通过代码解决的不要靠调参。优先排查顺序有没有无上限的本地缓存static Map只 put 不删有没有超大的集合一次性加载select *全表进内存ThreadLocal用完remove()了吗有没有频繁创建大数组/大字符串这四类问题占到线上内存问题的绝大多数。GC 参数是最后一道微调不是第一道解法。七、小结对象存活由可达性分析决定ThreadLocal泄漏的根子是「key 弱引用、value 强引用」。分代的理论基础是「朝生夕死」流动路径Eden → Survivor →N 轮→ Old大对象直接进老年代G1 下表现为 Humongous region。日志读法看 Minor GC 后残留判断 Survivor 是否够、看晋升速度、看 Full GC 后老年代降不降不降就是代码问题。收集器选型默认 G1 即可MaxGCPauseMillis别乱调堆到几十 G 且要求低停顿再上 ZGC。调优顺序永远是先开日志拿证据 → 先改代码 → 最后才调参数。