文章目录
- 一、先建立判断标准:对象什么时候算「垃圾」
- 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=8 | Eden : 单个 Survivor | 8:1:1 |
-XX:MaxTenuringThreshold=15 | 晋升老年代的年龄阈值 | 每熬过一次 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{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 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: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->273.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)。
第三,能通过代码解决的,不要靠调参。优先排查顺序:
- 有没有无上限的本地缓存(
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。 - 调优顺序永远是:先开日志拿证据 → 先改代码 → 最后才调参数。