← 返回博客
2026-08-27 08:00:01

JVM 垃圾回收与调优学习笔记:先把理论立起来,再亲手排一次 OOM

JVM 垃圾回收与调优学习笔记:先把理论立起来,再亲手排一次 OOM

很多讲 GC 的文章上来就扔概念——"标记清除、标记复制、标记整理""CMS、G1、ZGC",背完也不知道它们在解决什么问题。这篇反过来:先用一块堆把 GC 要解决的问题讲清楚,再带你亲手造一次 OOM 排掉。看完你能自己推导出大部分面试题。

一、堆长什么样:Eden、Survivor、Old 各是干嘛的

JVM 把堆按"对象能活多久"分了几块,这就是分代。先看图,建立地图:

堆的分代模型地图:GC Roots → 新对象进 Eden → S0/S1 复制 → 晋升 Old

补充:上面三块的画法是传统收集器(Serial/Parallel/CMS)的物理分代——年轻代、老年代是两块连续的内存,边界固定。G1 完全不这么画(给 Region 贴标签,两代是动态的 Region 集合),第二节对比着看就明白了。

为什么分代?因为对象有"大多数早死、少数长寿"的统计规律。年轻代存活率低,复制算法划算(代价是浪费一半空间);老年代存活率高,复制不划算,用标记清除/整理。

什么算垃圾:从 GC Roots(栈帧本地变量、静态属性、常量、JNI 引用)出发,遍历得到的活,遍历不到的就是垃圾。为什么用可达性、不用引用计数?引用计数每次赋值都要改计数、代价高,还漏掉循环引用——A 引用 B、B 引用 A,两个对象彼此都不再可达,引用计数却各是 1,永远清不掉。可达性从 Roots 出发扫一遍,互相抱团的环整体判死,不需要维护计数。三种基础算法一句话:

三种垃圾回收算法:标记清除留碎片、标记复制浪费一半空间、标记整理消灭碎片

STW 从哪来:GC 需要知道栈上哪些位置是引用,HotSpot 用 OopMap 记录,但 OopMap 只在安全点(SafePoint)更新,所以 GC 必须等所有线程跑到安全点再动手——这就是 Stop The World(全线程停顿)的根源。

二、收集器进化史:CMS → G1 → ZGC(带 JDK 版本)

收集器是"用什么算法、怎么并发、怎么省停顿"的具体实现。按时间线看,每一代都在修上一代的病:

Serial(串行):最老的一代。单线程回收(新生代标记复制、老年代标记整理),回收时必须停掉所有业务线程(STW),只适合小堆、客户端或单核场景,今天基本淘汰——Parallel 就是给 Serial 加上了多线程回收。

Parallel(并行):JDK 8 的默认收集器。多线程回收(新生代复制、老年代整理,就是把 Serial 的单线程换成多线程),追求吞吐,但每次 GC 都 STW。

先搞清楚一个大脉络:Parallel 和 CMS 不是"一个接替一个",而是两条路线——Parallel 走吞吐(多线程回收,用停顿换吞吐);CMS 走低延迟(尽量并发,用 CPU 和碎片换停顿)。所以"JDK 8 默认是 Parallel、JDK 9 默认是 G1"这条主线上根本没有 CMS 的位置:它从没当过默认收集器,只是 JDK 6~8 时代做低延迟调优时的常用备选项,后来因为碎片问题被 G1 吸收,JDK 14 移除(看下面的时间线图一眼就懂)。

CMS(Concurrent Mark Sweep):想解决 STW,把标记阶段并发执行。算法上是新生代标记复制(ParNew)、老年代并发标记清除。但有两个硬伤——浮动垃圾(并发标记阶段新产生的垃圾,这轮清不掉,只能留到下次)和内存碎片(标记清除不整理,碎片多到放不下大对象时,会退化 Full GC)。JDK 9 废弃,JDK 14 移除

G1(Garbage First):JDK 9 起默认。堆不再分两大块,切成等大的 Region,每个 Region 独立标记、独立回收,优先回收"垃圾最多"的 Region——这就是 Garbage First 名字的由来。分代方式也和传统完全两样:Serial/Parallel/CMS 的两代是连续的物理大块(年轻代一整块、老年代一整块、边界固定);G1 的两代是给 Region 贴标签——每个 Region 有个类型标签(Eden/Survivor/Old/Humongous/Free),"年轻代/老年代"只是当前贴了对应标签的 Region 集合,它们在堆里可以不相邻,标签还能变:一个 Region 这轮是 Eden,回收后变 Free,下一轮可能变 Old。所以 G1 是逻辑分代、物理没有固定边界,年轻代大小也能按停顿目标动态伸缩。跨 Region 的引用用 Remembered Set 记录,避免全堆扫描。G1 还引入了 humongous(巨对象):超过半个 Region 的对象直接按巨对象分配,独占连续几个 Region,不走年轻代复制,也不参与 Mixed GC 的复制疏散——搬一个几十 MB 的大对象比留着它更贵,G1 只会在并发标记后、确认它彻底死亡时,在清理(Cleanup)阶段整块回收。所以巨对象是 G1 里最难缠的角色:只要还被引用着,它就永远不会被回收。下面实操你会亲眼看到它。算法上是 Region 之间的复制转移——回收一个 Region 时把存活对象搬进另一个 Region,边复制边整理,所以 G1 没有 CMS 那种碎片问题。

ZGC:JDK 11 实验性引入(需 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC),JDK 15 转正。目标是停顿不超过 10ms 且不随堆大小增长,用染色指针(把标记信息存进指针里)加读屏障,GC 线程和业务线程几乎完全并发。代价是 CPU 开销高,不是所有场景都值。ZGC 最初不分代(JDK 21+ 才有分代 ZGC),并且是 G1 之外的可选收集器——默认仍是 G1,不是换掉了默认。

收集器进化时间线:默认主线 Serial→Parallel→G1,CMS 低延迟旁支已废弃,ZGC 新一代旁支已转正

看完图再看算法,会发现一个规律——老年代用什么算法,基本决定了一个收集器的结局

收集器新生代算法老年代算法停顿吞吐结局
Serial标记复制标记整理高(每次 STW)淘汰(仅小堆/客户端)
Parallel标记复制标记整理高(每次 STW)最高JDK 8 默认
CMS标记复制(ParNew)标记清除(并发)中(部分并发)JDK 9 废弃、JDK 14 移除
G1Region 复制Region 复制转移低~中(可调)中高JDK 9+ 默认
ZGC不分代并发标记 + 并发转移<10ms中(CPU 高)JDK 11 实验、15 转正

怎么读这张表:横着看是每个收集器的"身世",竖着看老年代那一列——标记清除 → 留碎片 → 放不下大对象 → 退化 Full GC → 被废弃(CMS);Region 复制转移 → 无碎片 → 活成默认(G1)。算法和命运是绑在一起的。Serial 和 Parallel 老年代都用标记整理,所以都不碎片,差别只在单线程 vs 多线程(吞吐)。这张表的"算法"和上方时间线图的"路线"是一套体系——老年代算法就是收集器的基因

JDK 版本对照表(面试、实操都用得上):

事项版本解法
默认收集器JDK 8 = Parallel;JDK 9+ = G1
CMSJDK 9 废弃、JDK 14 移除(JDK 8 的 -XX:+UseConcMarkSweepGC 到新版失效)
ZGCJDK 11 实验(-XX:+UnlockExperimentalVMOptions -XX:+UseZGC)、JDK 15 转正
GC 日志JDK 8:-XX:+PrintGCDetails -XX:+PrintGCDateStamps;JDK 9+:-Xlog:gc*
看堆JDK 8 的 jmap -heap 走 SA、版本极敏感;推荐 jcmd GC.heap_info(官方替代)

三、先建立基准:正常情况长什么样

有了地图和收集器,先知道"健康"的堆长什么样,才能识别异常。

先分清 GC 有哪几种(全文到处出现的"最贵""频繁"都指它们):

GC 类型回收范围触发条件停顿
Young GC仅年轻代(Eden + Survivor)Eden 满了(Allocation Failure)短,快
Mixed GC(G1 专属)年轻代 + 停顿预算内的部分老年代完成一轮并发标记后,后续 Young GC 顺带回收垃圾最多的 Old Region中,可控
Full GC整个堆(年轻代 + 老年代)老年代满 / 晋升失败 / G1 并发标记失败(Concurrent Mode Failure)/ 巨对象找不到连续 Region最长,最贵

把这套对照记下来,再回头看第一节那句"Old 满了触发 Full GC(或 G1 的 Mixed GC)"就通了:老年代要清,Serial/Parallel 只能整堆 Full GC,G1 用 Mixed GC 增量慢慢清;清不过来了(或并发标记失败)才退化 Full GC。我们的 OOMDemo 里,巨对象把 free 吃光后,下一步就是这个退化的 Full GC——然后就是 OOM。

正常形态:程序稳定运行时,Young GC 频繁——Eden 反复被打满又清空,一小部分存活对象经 Survivor 逐步晋升 Old;Old 缓慢增长,free 空间一直充足,堆使用率稳定在健康水位(比如 50%~70%),Full GC 很少。示意(非实测):

garbage-first heap   total reserved 65536K, committed 65536K, used 32640K
region size 1M, 18 eden (18M), 3 survivor (3M), 10 old (10M), 1 humongous (1M), 32 free (32M)

看几个关键数:free 还有 32M(一半堆闲着),humongous 只有 1M,Eden 有 18M 给新对象用——健康。

异常形态才值得警惕:free 趋近 0、堆长期 90%+、humongous 占大头且持续涨、Full GC 频繁。你按下面的 OOMDemo 跑出来的就是异常形态——因为它就是照着"撑爆堆"写的,而且待会儿你会发现,OOMDemo 本身就在制造巨对象。

四、手把手实操:造一次 OOM,亲手排掉

第一步:写一个必死程序

public class OOMDemo {
    static class BigObject {
        private byte[] bytes = new byte[1024 * 1024]; // 1MB
    }

    public static void main(String[] args) throws Exception {
        List<BigObject> list = new ArrayList<>();
        while (true) {
            list.add(new BigObject());
            Thread.sleep(10);
        }
    }
}

问题是什么:循环无限往 list 塞对象,list 一直持有引用,GC 永远清不掉,跑起来必 OOM。注意每个 BigObject 内部是 1MB 的 byte 数组——待会儿你会发现这个"1MB"很关键

第二步:编译并限制堆内存启动

先编译出字节码——命令末尾的 OOMDemo主类名java 靠它找到 main 方法,不能省:

javac OOMDemo.java
java -Xms64m -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/oom.hprof OOMDemo

关键点在 -XX:+HeapDumpOnOutOfMemoryError:OOM 时自动 dump 堆快照,后面排查全靠它。-Xms-Xmx 设成一样,避免堆扩容抖动,这是生产环境常用做法。命令格式是 java [JVM选项] 主类名 [程序参数]——前面的 -Xms/-XX:* 都是配置 JVM 的选项,最后那个 OOMDemo 是要执行的类(里面要有 main 方法);少了它 JVM 不知道该跑哪个类,会报"找不到或无法加载主类"。

第三步:用 jcmd 看堆现场

程序跑一会儿后,另开终端:

jps -l                  # 找到进程号
jcmd <pid> GC.heap_info # 看堆使用情况

为什么是 jcmd 而不是 jmap -heapjmap -heap 底层走 HotSpot Serviceability Agent(sun.jvm.hotspot 包),它要直接解析目标 JVM 内部的 VM 类型表,对版本极度敏感:jmap 必须和正在运行的 JVM 是同一个 JDK 版本(最好同一个 build),对不上就报 TypeDataBase.lookupOrCreateClass 这类错——比如用 JDK 8 的 jmap 去 attach 一个 JDK 26 的进程,必现。jcmd GC.heap_info 走标准 attach 机制,不解析 VM 类型表,跨版本宽容得多,是 jmap -heap 的官方替代。

排查工具有条通用法则:jcmd / jmap / jstat 必须用和启动应用相同版本的 JDK(同一个 JDK 的 bin 目录下的工具)。IDE 里跑的应用用的是 Project SDK,终端里执行工具用的是 PATH 上的 JDK,两边经常不是同一个——上面的报错十有八九就是这么来的。

按理论第一节的地图,正常情况下你会看到 Eden 反复被打满、Old 缓慢上涨——这就是"对象晋升"的直观证据。但亲手跑一次,你更可能先看到下面这个真实形态——注意这是 G1 的输出(JDK 9+ 默认是 G1,或 JDK 8 加 -XX:+UseG1GC)。如果用 JDK 8 默认的 Parallel GC 跑,输出是另一种格式,看完这节的「JDK 8 版」就明白了。程序跑一会儿后先看一次:

garbage-first heap   total reserved 65536K, committed 65536K, used 53943K
region size 1M, 2 eden (2M), 1 survivor (1M), 13 old (13M), 39 humongous (39M), 9 free (9M)

隔一会儿再看第二次:

garbage-first heap   total reserved 65536K, committed 65536K, used 64824K
region size 1M, 1 eden (1M), 0 survivor (0M), 12 old (12M), 51 humongous (51M), 0 free (0M)

怎么读这行:

现在用理论解释这组实测:回看我们的 OOMDemo,每个 BigObjectbyte[1024*1024] 正好 1MB,大于 G1 的巨对象阈值 512K——所以它根本不是普通对象,而是个巨对象。你看到的 39M → 51M,就是循环里不停 new BigObject() 的结果:每个 1MB 都直接吞一个 Region,还绕过年轻代复制,于是 free 被吃光、年轻代被挤没。这不是巧合,是"巨对象分配"这一理论的现场演示。

先搞清 humongous 和 Old 的关系:G1 把 Region 按类型贴标签——Eden(E)、Survivor(S)、Old(O)、Humongous(H)、Free(F),上面 jcmd 输出里的 eden/survivor/old/humongous/free 就是这五个标签。逻辑上 humongous 和 Old 是一家:巨对象本质也是老年代对象,直接从老年代里划专用区域存放,所以不经过年轻代的复制晋升。只是 G1 在物理管理上把它们当"平级兄弟"分开管理:普通小对象(含从年轻代晋升上来的)进 O 区,超过 512K 的大对象直接划 H 区。所以看到 humongous 暴涨,实质就是老年代被大对象撑爆了——跟你之前学的"老年代满了触发 OOM"是同一个道理,G1 只是把这块细分出来,让你一眼定位是"大对象"在作怪,而不是普通的对象晋升失控。

这种形态怎么排查(free 趋近 0 + humongous 持续涨 + old 占比高):说明程序在大量分配超过 512K 的大对象——常见于大数组、缓存、一次性把大结果集装进内存的查询。用 MAT 打开堆快照看 Dominator Tree,按对象大小排序就能锁定是哪个对象。

JDK 8 下你会看到另一幅图(Parallel GC 版):上面整段是 G1 的输出。如果用 JDK 8 默认的 Parallel GC 直接跑 OOMDemo,jcmd GC.heap_info 出来的是 PSYoungGen / ParOldGen 格式——没有 Region、没有 humongous 标签:

PSYoungGen      total 18944K, used 13557K
 eden space 16384K, 67% used
 from space 2560K, 99% used
 to   space 2560K, 0% used
ParOldGen       total 44032K, used 14137K
 object space 44032K, 32% used

连着看 4 次快照(实测数据,去掉 pid 和内存地址,时间从早到晚):

快照ParOldGen(老年代)状态
114137K / 44032K ≈ 32%老年代刚开始积累
231926K / 44032K ≈ 72%一批大对象被挤进来
343762K / 44032K ≈ 99%老年代快满
443762K / 44032K ≈ 99%卡死在 99%,Full GC 清不掉

这组数据正好把前面"humongous 和 Old 是一家"讲实了:同样的 1MB 大对象,G1 给它贴了个 humongous 标签一眼可见,Parallel 里它就只是"老年代"。年轻代 from/to 各只有 2.5MB,塞不下几个 1MB 的 BigObject,一两轮就被挤进老年代,于是 ParOldGen 从 32% 一路涨到 99% 卡死——这就是"老年代被大对象撑爆"的完整过程,跟 G1 的 humongous 暴涨是同一个道理,G1 只是把它细分出来方便你定位。

第四步:用 jstack 看线程

jstack <pid> > thread.txt

如果程序卡死而不是 OOM,jstack 能告诉你每个线程卡在哪。死锁是最典型的一种——两个线程各自握着一把锁,又都在等对方手里那把,谁也走不动。拿这个小程序实测(JDK 8 的 25.401-b10 是 JDK 8 的内部版本号,别被 25 开头骗成 JDK 25):

// DeadlockDemo.java:两个线程按相反顺序抢两把锁,必然死锁
public class DeadlockDemo {
    static final Object LOCK_A = new Object();
    static final Object LOCK_B = new Object();

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK_A) {        // worker-1 先拿 A
                sleep(100);
                synchronized (LOCK_B) { }  // 再要 B,被 worker-2 占着
            }
        }, "worker-1");
        Thread t2 = new Thread(() -> {
            synchronized (LOCK_B) {        // worker-2 先拿 B
                sleep(100);
                synchronized (LOCK_A) { }  // 再要 A,被 worker-1 占着
            }
        }, "worker-2");
        t1.start(); t2.start();
        t1.join(); t2.join();
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { }
    }
}

跑起来后两行输出打完就再没动静。jstack 末尾直接给出结论(实测,已去掉 tid/锁地址等机器信息):

Found one Java-level deadlock:
=============================
"worker-2":
  waiting to lock monitor (a java.lang.Object),  // worker-2 在等一把锁
  which is held by "worker-1"                      // 这把锁在 worker-1 手里
"worker-1":
  waiting to lock monitor (a java.lang.Object),  // worker-1 也在等一把锁
  which is held by "worker-2"                      // 被 worker-2 拿着

JVM 自己就把死锁抓出来了——Found one Java-level deadlock,然后逐对列出:worker-2 等的锁在 worker-1 手里,worker-1 等的锁在 worker-2 手里,构成循环等待,这就是死锁的本质,不用人肉去猜。两个线程各自的状态都是 BLOCKED (on object monitor)——在锁上阻塞等锁。顺带一提,这份 dump 里还有 8 条 GC task thread#0-7 (ParallelGC)——JDK 8 默认的 Parallel GC 会开多个 GC 工作线程,名字直接写着收集器名;不过平时它们只是 parked 等活,真正回收时才跑起来。

同一句 jstack,换台 JDK,GC 线程的名字就是收集器的身份证。 上面 JDK 8 的名字里直接写着 ParallelGC。用 JDK 26(默认 G1)跑同一个死锁小程序,线程列表末尾的 GC 线程长这样(实测,去掉了线程号等机器信息):

GC Thread#5
GC Thread#4
GC Thread#3
GC Thread#2
GC Thread#1
GC Thread#0
G1 Refinement Workers#0
G1 Service
G1 Refine Control
G1 Conc#0
G1 Main Marker

对比就一眼明白:JDK 8 叫 GC task thread#N (ParallelGC),JDK 26 全是前缀带 G1 的线程——G1 Conc#0G1 Main Marker 正是第二节说的并发标记阶段的线程,G1 Refinement Workers 是维护跨 Region 引用的专职线程。光看这份线程名单就知道跑的是哪个收集器,也侧面印证了第二节的 JDK 版本对照表(JDK 8 Parallel / JDK 9+ G1)。

第五步:分析堆快照

OOM 后,用 MAT 或 VisualVM 打开 /tmp/oom.hprof。重点看"Dominator Tree"(支配树),它会直接告诉你:哪个对象占了多少内存,谁在引用它。这个案例里,你会看到 ArrayList 内部数组占了几乎全部堆,里面全是 BigObject——和第三步的判断完全对上了。

这一步踩坑提醒:别在生产环境直接 jmap -dump 大堆,会 STW 卡死应用。用 -XX:+HeapDumpOnOutOfMemoryError 自动 dump 更安全。

五、验证方法:看懂 GC 日志,你就学会了

光会跑 jcmd 不算会。真正的验证是:你能从 GC 日志里读出收集器的行为和问题

启动时加参数(JDK 8 用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps,JDK 9+ 用 -Xlog:gc*,见第二节对照表):

java -Xms64m -Xmx64m -Xlog:gc* OOMDemo

日志长这样——这是 JDK 26(默认 G1)跑同一段 1MB 大对象程序的真实输出,挑四段看,信息量很大。

第一段:开头几行(gc,init),确认收集器和堆配置

[0.033s][info][gc     ] Using G1
[0.035s][info][gc,init] Heap Region Size: 1M
[0.035s][info][gc,init] Heap Max Capacity: 64M
[0.035s][info][gc,init] Parallel Workers: 8
[0.035s][info][gc,init] Concurrent Workers: 2

开头直接写死 Using G1、Region Size 1M——这就是第二节默认配置的实锤,也解释了"1MB 对象 = 巨对象"(超过半区 512K)这个阈值从哪来。

第二段:回收过程(gc / gc,heap),巨对象触发的 Young GC

[0.977s][info][gc,start    ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[0.988s][info][gc,task     ] GC(0) Using 2 workers of 8 for evacuation
[0.988s][info][gc,heap     ] GC(0) Eden regions: 17->0(9)
[0.988s][info][gc,heap     ] GC(0) Humongous regions: 26->25
[0.988s][info][gc          ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 43M->36M(64M) 10.994ms

GC 原因直接写着 (G1 Humongous Allocation)——"巨对象在分配"是铁证,不用靠"1MB>512K"去推断。再看 Humongous regions 一路涨还清不掉(26→49→51→53),Eden 一直在 0~1M 打转——堆几乎全被巨对象占着,和第四步 jcmd 看到的完全一致。顺带看 [gc,task] 那行:Using 2 workers of 8 for evacuation——G1 不是每次回收都用满 8 个并行线程,而是按停顿目标自适应决定用几个,这跟第三节说的守 MaxGCPauseMillis 是同一套机制。

第三段:高潮——Evacuation Failure,退化成 Full GC

[0.997s][info][gc          ] GC(2) Pause Young (Normal) (G1 Humongous Allocation) (Evacuation Failure: Allocation) 61M->61M(64M) 4.538ms
[0.997s][info][gc,ergo     ] Attempting full compaction
[0.997s][info][gc,start    ] GC(3) Pause Full (G1 Compaction Pause)
[1.012s][info][gc          ] GC(3) Pause Full (G1 Compaction Pause) 61M->59M(64M) 14.984ms

这就是第三节说的"清不动就退化 Full GC"的实况:61M->61M 说明这次 Young GC 一分没回收(Evacuation Failure),G1 果断 Attempting full compaction 进入 Pause Full (G1 Compaction Pause)——整堆停顿压缩。之后还有一串 Full GC,每轮 5 个阶段(Mark live objects / Prepare compaction / Adjust pointers / Compact heap / Reset Metadata),因为对象全被引用着,一直腾不出空间。

第四段:结尾(gc,exit),VM 退出前的最终堆摘要 + OOM

[1.106s][info][gc,exit        ]  garbage-first heap   total reserved 65536K, committed 65536K, used 14049K
[1.106s][info][gc,exit        ]   region size 1M, 1 eden (1M), 0 survivor (0M), 11 old (11M), 3 humongous (3M), 49 free (49M)
[1.106s][info][gc,exit        ]  Metaspace       used 2196K, committed 2368K, reserved 1114112K
[1.106s][info][gc,exit        ]   class space    used 181K, committed 256K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

[gc,exit] 表示这是 VM 退出时的最终堆摘要,两个点值得注意:

读法核心43M->36M(64M) 10.994ms = GC 前 43M / GC 后 36M / 堆总大小 64M / 停顿 10.994ms。如果 Pause Young 后面跟着 Pause Full,说明年轻代已经清不动了——这是最危险的情况。

验证标准:你能解释每次 GC 的原因、回收前后内存变化、停顿时间是否合理。比如 G1 Humongous Allocation 说明是巨对象分配触发的,如果频繁出现且回收后很快又满,说明堆太小或大对象生命周期太长。

再验证一步:用 jstat -gcutil 1000 每秒打印一次 GC 情况,看 FGC(Full GC 次数)和 FGCT(Full GC 总耗时)。如果 FGC 持续增长,说明老年代在快速膨胀,你的程序有内存泄漏或对象不合理晋升。

六、面试速答:怎么把这一路演进讲清楚

面试官问"CMS 和 G1 有什么区别",别背参数,按第二节的思路答:每代收集器都在修上一代的病。CMS 修的是 Parallel 的 STW(并发标记),但留下浮动垃圾和碎片;G1 用 Region 解决碎片、用 Garbage First 策略优先清垃圾多的区;ZGC 再用染色指针把停顿压到 10ms 以内。病、药、新病、新药——这就是演进逻辑,能自圆其说就比背八股强。

追问场景:"你的系统该选哪个收集器?"答法:先看堆大小和停顿要求。堆小于 4G,用 G1 默认就行;堆几十 G 且要求低延迟,考虑 ZGC;如果只是跑批任务不在乎停顿,Parallel GC 吞吐量最高。别背参数,说清楚你的场景和取舍。

七、核心收获与下一步

GC 调优不是调参数,是先建立理论地图,再看懂日志,最后才动参数。这篇给你三步走:第一节建立堆的模型(Eden/Survivor/Old + 三种算法),第二节摸清收集器演进和 JDK 版本,第三四节亲手看到"正常 vs 巨对象抢 Region"的真实对比,第五节会读日志。下一步:把上面的 OOMDemo 跑一遍,用 jcmd 和 jstat 观察堆变化,再用 MAT 分析 dump 文件。跑通了,你才算真正入门。

本文关键词:GC Roots、STW、标记复制、G1、巨对象、OOM排查