Java 服务跑着跑着就卡顿,监控一看 Minor GC 每秒好几次,时不时再来一次 Full GC 直接让接口超时。这几乎是每个做服务端开发的人都会遇到的场景,而所有这些问题,最终都要回到 JVM 垃圾回收最基础的一条设计思路上来:分代收集理论。可以说,不理解分代收集,你看到的 GC 日志就是一堆无意义的数字,理解了之后,日志里每一行都在告诉你堆内存里发生了什么。
这篇文章不只是给你讲理论,我会结合自己在实际项目里排查 GC 问题、调优参数的真实过程,把分代收集的来龙去脉、每个区域的作用、Minor GC 和 Full GC 的完整流程,以及那些“不写在文档里”的坑都拆开讲一遍。无论你是刚接触 JVM 的初学者,还是正在被线上 GC 问题折磨的开发者,这篇文章都应该能帮你把这块知识补完。
1. 分代收集到底在解决什么问题
1.1 强分代假说与弱分代假说
分代收集不是凭空发明的,它建立在两个被长期观察验证的统计假说之上。
强分代假说说的是:绝大多数对象都是“朝生夕死”的,存活时间极短。IBM 曾做过专门研究,发现超过 98% 的对象在第一次垃圾回收时就已经变成不可达的垃圾。你可以回想一下自己的代码,大部分对象都是方法里的临时变量、循环里的中间结果,用完一次就不再需要了。真正能活到最后的对象少之又少。
弱分代假说说的是:熬过越多次垃圾回收的对象,越倾向于继续存活。这就好比一个新员工入职,能不能干满一年往往在头几个月就能看出来——挺过了试用期,后面留下来的概率就大很多。
这两个假说听起来简单,却直接决定了垃圾回收器的设计方向:既然绝大多数对象活不长,那我们可以把堆分成不同区域,对不同区域采用不同策略。新生代里大部分对象都是垃圾,回收时只需要复制那少量存活对象,成本极低;老年代里对象存活率高,不适合频繁回收,那就降低扫描频率,用更适合长生命周期对象的算法。
如果放弃分代,每次 GC 都必须扫描整个堆的全部对象,每次回收要承担全部的遍历成本,还要对付全堆碎片化问题。这个成本对高并发服务来说是灾难性的。
1.2 分代设计带来的核心收益
分代收集最直接的收益是降低了每次 GC 的扫描范围和暂停时间。假设堆是 4GB,新生代占 2GB。一次 Minor GC 只需要在新生代内部做可达性分析和对象复制,扫描范围只有全堆的一半,而且存活对象通常只有几个 MB,复制开销非常小。全堆扫描可能在几十毫秒以上,而新生代回收可以做到几毫秒内完成。
第二个收益是算法选择的自由度。新生代存活率低,适合用复制算法——把存活对象挪到另一块空间,一次性清理整个区域,没有碎片;老年代存活率高,用复制算法会复制大量对象、成本太高,更适合标记-清除或标记-整理。分代让不同区域可以各自选择最合适的算法组合。
第三个收益是分配效率。绝大多数对象直接分配在新生代的 Eden 区,而 Eden 区的对象分配只需要指针碰撞就行,不需要遍历空闲链表找内存块。这也是为什么 Java 对象分配速度极快、几乎免费的原因之一——它在 Eden 里就是挪一下指针的事。
实话讲,分代收集的设计思路在几十年后的今天依然没有被推翻,只是实现粒度发生了变化。从 Serial 到 CMS,再到 G1、ZGC,分代的思想一直都在,只不过“代”的物理边界越来越模糊了。
2. 堆内存区域划分与参数配置
2.1 新生代的内部三块区域
分代收集中的“代”不是笼统的一分为二,新生代内部又划分成了三块:一个 Eden 区和两个 Survivor 区(From 和 To)。默认比例是 8:1:1。
这个结构很关键。Eden 区是所有新对象分配的入口,绝大多数对象在这里诞生,也在这里被回收。两块 Survivor 区则承担了“暂存幸存者”的角色。为什么要两块而不是一块?因为复制算法要求一个安全的空区域作为目标。Minor GC 时,把 Eden 和 From Survivor 中存活的对象一起复制到 To Survivor,复制结束后,Eden 和 From 被清空,然后 From 和 To 互换身份,保证下一次 GC 时依然有一个空的 To 区。
很多初学者不理解:为什么不能让存活对象直接留在原地,非要复制来复制去?你要记住一点——复制算法是为了换取内存的连续性。把存活对象集中复制到连续空间,回收后的 Eden 和 From 天然是一整块干净区域,后续分配对象直接指针碰撞就行,不需要处理碎片。付出的代价是需要浪费一小块 To 区作为临时周转空间,这完全是值得的。
实际调优中,Survivor 区太小是一个高频问题。默认 8:1:1 的比例,如果新生代只有 200MB,每个 Survivor 只有 20MB。一旦某个瞬时流量高峰导致存活对象超过 20MB,多出来的对象就会直接晋升到老年代——哪怕它们只活了几秒钟。长此以往老年代被垃圾填满,Full GC 越来越频繁。这块内容后面常见问题部分我再细讲。
2.2 老年代与空间分配担保
能熬过多次 Minor GC 的对象最终会进入老年代。但除了正常晋升,还有两条特殊通道:
第一条是大对象直接进入老年代。JVM 有个参数-XX:PretenureSizeThreshold,超过阈值的大对象直接在老年代分配。这么做的原因是,大对象在新生代里来回复制开销太大,而且大对象在 Eden 里占空间容易触发 Minor GC。对于特别大的对象,直接在老年代分配反而更省。这个阈值默认是 0,表示不启用,很多场景下其实需要显式设置。
第二条是空间分配担保机制。Minor GC 之前,JVM 会检查老年代最大可用连续空间是否大于新生代全部对象总大小。如果大于,说明这次 Minor GC 即使全部对象都存活也能放下,可以直接做 Minor GC;如果小于,则会检查老年代可用连续空间是否大于历次晋升到老年代对象的平均大小。这就涉及一个历史参数HandlePromotionFailure,在 JDK 6u24 之后这个开关的影响已经被简化——只要老年代连续空间大于新生代总大小或历次晋升的平均水平,就允许 Minor GC,否则会改为进行一次 Full GC。
这个机制的核心目的是:让垃圾回收器在不确定存活对象数量的情况下,敢于冒险做一次高效的 Minor GC。但“冒险”是有代价的,一旦实际存活对象超过预期,就会触发promotion failed,这个后面会专门说。
GC Roots 我简单提一句——所有可达性分析都要从一组根对象出发。根包括虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、JNI 引用等。理解了 Roots,你就知道为什么 Minor GC 虽然只回收新生代,但依然需要扫描一部分老年代对象——万一老年代里的对象引用了新生代对象呢?这正是跨代引用问题的由来,第 3 部分会展开。
2.3 关键参数速查与调优建议
这里把堆内存相关的最核心参数整理出来,方便你对照检查自己线上服务的配置。
| 参数 | 作用 | 默认值 | 建议 |
|---|---|---|---|
-Xms | 初始堆大小 | 物理内存 1/64 | 与-Xmx保持一致 |
-Xmx | 最大堆大小 | 物理内存 1/4 | 根据服务内存预算设置 |
-Xmn | 新生代大小 | 视收集器而定 | 堆的 1/3 到 1/2 之间 |
-XX:NewRatio | 老年代/新生代比值 | 2 | 一般用默认即可 |
-XX:SurvivorRatio | Eden/Survivor 比值 | 8 | 观察晋升量后再调整 |
-XX:MaxTenuringThreshold | 晋升年龄阈值 | 15(CMS 是 6) | 配合 Survivor 大小调整 |
-XX:PretenureSizeThreshold | 大对象阈值 | 0 表示不启用 | 根据业务对象大小设置 |
几个我踩过坑之后的经验:
第一,-Xms和-Xmx务必设成一样。如果初始堆远小于最大堆,JVM 就会在运行过程中不断扩容,扩容过程伴随一次 Full GC,而且堆容量变化会影响 GC 行为的一致性,导致你调参时看到的日志数据不可复现。
第二,新生代不要贪大。虽然新生代越大 Minor GC 次数越少,但老年代相应地被压缩了,频繁 Full GC 的代价比频繁 Minor GC 大得多。我见过有人把 8GB 堆分给新生代 5GB,结果老年代只剩 3GB,Full GC 每几分钟一次,接口响应时间惨不忍睹。
第三,-Xmn和-XX:NewRatio不要同时设置,二者会互相覆盖,配置混乱时排查问题很痛苦。团队里最好统一口径,要么都用绝对大小,要么都用比值。
3. 分代收集的工作原理与算法拆解
3.1 Minor GC:复制算法的完整流程
Minor GC(也叫 Young GC)是分代收集里发生频率最高的回收过程,它的完整流程可以拆成六步:
- 新对象创建时统一分配在 Eden 区。
- Eden 空间耗尽,触发 Minor GC,此时从 GC Roots 出发做可达性分析。
- 从 Eden 区和 From Survivor 区扫描所有存活对象,一次性复制到 To Survivor 区。
- 复制过程中,对象的年龄 +1。如果年龄达到阈值(
MaxTenuringThreshold),或者 To Survivor 区空间不足,对象直接晋升老年代。 - 清空 Eden 区和 From Survivor 区。
- 交换 From 和 To 的角色,保证下一次 GC 时依然有空的 To 区。
整个过程看起来简单,但有几个细节容易被忽略。
一是**“复制”和“晋升”是一体决策的**。复制对象之前,JVM 要判断 To Survivor 剩余空间是否容得下这个对象,容不下就走晋升逻辑。这个判断发生在每次复制前,而不是先复制完再统一检查,因为复制过程中随时可能发现空间不够。
二是Minor GC 的“Stop The World”时间主要花在可达性分析和对象复制上。复制算法的特点是,存活对象越少,GC 越快。新生代 98% 对象都是垃圾,复制量极小,所以 Minor GC 才敢频繁做。如果哪天发现 Minor GC 时间突然变长,大概率是存活对象暴涨——比如有人把大 List 或缓存对象放在方法里没释放,又或者线程局部变量忘了解除引用。
三是复制后对象的地址变了,所有指向它的引用都要更新。这意味着遍历整个 Java 线程栈、方法区、老年代里可能引用该对象的区域。这里就引入了跨代问题——为了不扫描整个堆,JVM 必须维护跨代引用的索引,也就是后面要讲的卡表和写屏障。
3.2 Major GC 与 Full GC:标记阶段的差异
先说一个概念区分:Major GC一般是清理老年代,Full GC则是清理整个堆外加方法区(元空间)。很多文章把二者混着用,但严格意义上 Full GC 影响范围更大,暂停时间也长得多。
老年代不能像新生代那样用复制算法,因为存活对象太多,复制成本太高。主流的老年代 GC 有两个方向:
标记-清除(Mark-Sweep):先标记所有存活对象,然后统一清除未被标记的对象。优点是实现简单、不需要移动对象,缺点是会产生大量内存碎片。碎片化严重时,即使老年代还有几百 MB 空闲,也可能找不到一块连续空间装下一个大对象,直接诱发一次 Full GC——这就是碎片害人的典型场景。CMS 用的就是这个思路,所以 CMS 有个参数-XX:UseCMSCompactAtFullCollection用于在 Full GC 时做碎片整理。
标记-整理(Mark-Compact):标记存活对象后,不直接清除,而是将所有存活对象向一端移动,然后清理边界外的空间。移动对象意味着要更新所有引用,过程中必须全程 STW,暂停时间更长,但整理后内存是连续的,后续分配高效。Parallel Old 收集器用的就是标记-整理。
这里要理解一个本质矛盾:要么忍受碎片,要么承受移动对象的开销。新生代适合复制(移动)是因为存活对象少,移动成本低;老年代适合清除或整理则是因为对象多,移动成本高,所以宁可让 GC 频率低一点、每次多花点时间扫全一点,也不要频繁做。
3.3 卡表与写屏障——跨代引用的关键机制
这是分代收集里最容易被忽略、却又最精巧的一部分。
问题是这样的:Minor GC 回收新生代时,必须知道老年代里有哪些对象引用了新生代对象。如果不做任何处理,就只能全堆扫描,那分代的意义就消失了——每次 Minor GC 都要遍历老年代所有对象,跟 Full GC 还有什么区别?
JVM 的解决方案是卡表(Card Table) + 写屏障(Write Barrier)。
原理是这样:把老年代内存按 512 字节划分成一个个卡片,对应一个卡表(字节数组)。每当发生引用赋值,比如oldObj.field = youngObj,JVM 会通过写屏障在卡表里把老年代对象所在区域对应的卡标记为“脏”(dirty)。Minor GC 时,回收器只需要扫描卡表中标记为 dirty 的区域,就能找到那些引用了新生代的老年代对象,而不用扫描整个老年代。
用生活场景类比:卡表就像一个小区的栋号索引牌。平时你不需要知道每家每户住了谁,只需要知道哪栋楼里有人可能找楼下的快递站帮忙。快递站收快递时只要把那栋楼标记一下,之后整理时只去标记过的楼敲门即可。
写屏障的时机也很有讲究。JVM 通常采用“写后”屏障——即引用赋值完成后再标记卡表。这样做有个隐患:标记动作发生在一条热路径上,频繁的引用赋值会产生大量卡表写操作。为了优化,JVM 会先判断卡片是否已经是 dirty 状态,如果是就不再重复标记。但这又引出了**伪共享(False Sharing)**问题——多个线程同时标记相邻的卡片时可能因为 CPU 缓存行冲突而互相拖慢。HotSpot 通过-XX:+UseCondCardMark参数来缓解,这个参数会先检查卡是否已脏再写,牺牲一点性能换取并发下的缓存友好性。
理解卡表机制后,你会明白为什么“老年代引用新生代对象”是分代收集的关键瓶颈之一。大量跨代引用会导致 Minor GC 需要扫描的老年代区域变大,GC 时间也会变长。如果你的代码有大量把新生代对象塞进老年代缓存/全局 Map 的行为,观察 GC 日志时要注意 Minor GC 时间的异常上涨。
3.4 几种代表性分代收集器的取舍
分代理论虽然是公共基础,但不同收集器对分代的实现方式差异很大。我按使用场景给你梳理一遍:
| 收集器 | 新生代回收方式 | 老年代回收方式 | 适用场景 |
|---|---|---|---|
| Serial | 复制算法 | 标记-整理 | 单 CPU、Client 模式、小堆 |
| ParNew | 复制算法(并行) | 配合 CMS | 多 CPU 服务端、追求低延迟 |
| Parallel Scavenge | 复制算法(并行) | 标记-整理(并行) | 多 CPU、追求吞吐量 |
| CMS | 复制算法 | 标记-清除(并发) | 低延迟场景,如 Web 服务 |
| G1 | 基于 Region 的复制 | 基于 Region 的标记-整理 | 大堆、可预测暂停时间 |
我把 ParNew 和 Parallel 单独拎出来说。ParNew 是 Serial 的多线程版本,只做了新生代并行回收,老年代可以搭配 Serial Old 或者 CMS。Parallel 收集器更看重吞吐量,可以自适应调节,是 JDK 8 之前的默认组合(Parallel Scavenge + Parallel Old)。
CMS 在很长一段时间里是低延迟服务的主流选择,但它有三个硬伤:CPU 资源敏感、无法处理浮动垃圾、碎片化严重——每个问题都足以把线上服务拖垮。我经历过一次 CMS 并发模式失败(Concurrent Mode Failure),老年代碎片化到插入一个大对象直接触发 Full GC,全程 STW 好几秒钟。后来才理解,CMS 追求并发收集,但它的老年代回收从来没有真正做到“不停机”,而是在并发阶段和 STW 阶段之间反复切换。
G1 则在 JDK 9 之后成为默认收集器,它的核心思路是把堆划分为多个 Region,逻辑上继续保留新生代和老年代的概念,但物理上不再要求老年代必须连续。所以 G1 本质上还在贯彻分代理论,只是用更细粒度的 Region 来平衡回收效率和暂停时间。
4. 实操:GC 日志分析、参数调整与真实案例
4.1 怎么用 GC 日志把现场还原出来
纸上谈兵没有用,直接上日志。我建议线上服务至少开启这几个日志参数:
-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC如果你用的是 JDK 11 及以上版本,建议统一用统一日志体系:
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags下面是我从真实线上环境摘的一段 Minor GC 日志(JDK 8 格式):
2024-05-12T15:30:01.123+0800: 28453.123: [GC (Allocation Failure) [ParNew: 2097152K->335598K(2359296K), 0.0421234 secs] 4234096K->2465390K(8298496K), 0.0424321 secs] [Times: user=0.15 sys=0.01, real=0.04 secs]逐段解读:ParNew表示新生代用的多线程回收;2097152K->335598K(2359296K)意思是 GC 前新生代占用 2GB,GC 后剩余 335MB,新生代总容量 2.25GB;4234096K->2465390K(8298496K)是对整个堆而言,GC 前 4.2GB,GC 后 2.4GB,堆总容量 8.3GB;0.0424321 secs是整个 Minor GC 的耗时。
值得关注的核心指标是新生代回收后的存活量和整个堆的回收净收益。如果每次 Minor GC 后堆内存不减反增,说明分配速率超过回收速率——这是要出事的信号。另外注意Allocation Failure,这个原因是 Eden 区不够分配新对象了,属于正常触发。如果看到的是System.gc()调用触发的 Full GC,那就得检查是不是代码里显式调用了。
再给一段 Full GC 日志做对比:
2024-05-12T16:00:00.456+0800: 30412.456: [Full GC (Ergonomics) [PSYoungGen: 10240K->0K(2359296K)] [ParOldGen: 5925889K->5242880K(6291456K)] 8288496K->5242880K(8298496K), [Metaspace: 18453K->18453K(1064960K)] 0.3123456 secs]Full GC 会把新生代清成 0,老年代内存大幅下降。这里Ergonomics表示是由 JVM 自适应调节策略触发的 Full GC——通常意味着老年代使用率超过了-XX:GCTimeLimit等阈值,JVM 认为再不回收系统就撑不住了。
分析日志一定要抓两个维度:频率和单次耗时。Minor GC 每秒好几次但单次 5ms 以内,说明分配压力大;Minor GC 一次 100ms,说明存活对象太多或跨代引用扫描范围太大。两种情况的调优方向完全相反。
4.2 一次线上 Full GC 频发的排查调优实录
分享一个真实案例。之前我负责的一个订单服务突然出现 Full GC 频繁,告警群里每隔几分钟就报一次老年代使用率超过 90%。
第一件事是拉 GC 日志,发现有大量这种记录:
[ParNew: 2097152K->2097152K(2359296K), 0.1234567 secs]新生代回收前和回收后占用几乎没变——97% 的对象没被回收掉。这说明新生代里的对象全是存活的。当时第一反应是怀疑有全局缓存或者 ThreadLocal 泄漏,但查了一遍代码发现并没有明显的长生命周期对象。
后来又看了一行日志让我恍然大悟:
[Full GC (Allocation Failure) 4G->4G(4G), 2.3456789 secs]Full GC 后堆内存占用完全没有下降,这已经不是 GC 效率问题,而是分配速率超过了回收速率。于是我加了-XX:+PrintTenuringDistribution参数,重启服务后再观察,发现年龄为 1 的对象大量直接进入老年代:
Desired survivor size 33554432 bytes, new threshold 15 (max 15) - age 1: 19658712 bytes, 19658712 total - age 2: 10234567 bytes, 29893279 total问题比预想的更严重:年龄 1 的对象就占用了近 20MB,而整个 Survivor 区只有 32MB。这意味着每次 Minor GC 都有大量只活了一轮的对象无法留在 Survivor,直接晋升老年代——老年代每天都在被短期对象填满,自然频繁 Full GC。
根因找到了:Survivor 空间设置太小,叠加业务高峰期有大量大对象短生命周期对象产生。最终调整方案如下:
-Xms8g -Xmx8g -Xmn5g -XX:SurvivorRatio=6 -XX:MaxTenuringThreshold=10 -XX:+UseConcMarkSweepGC把新生代从默认的 2.7GB 加大到 5GB,SurvivorRatio 从 8 改为 6,每个 Survivor 从 300MB 左右增加到约 700MB。这样即使高峰期有 100MB 的存活对象,也能在 Survivor 周转两周以上。调整后 Full GC 频率从每 10 分钟一次降到一天多一次,接口 99 分位耗时从 800ms 降到 120ms。
这个案例标准地展示了一个调参链路:看日志 -> 找异常指标 -> 分析根因 -> 调整参数 -> 压测验证。不要一上来就改参数,一定要先搞清楚到底是什么对象占用了内存,存活时间多长,再决定调大哪块区域。
4.3 调参示范:从默认参数到定制方案
调参不能纸上谈兵,我按两类典型服务给出配置参考。
第一类,IO 密集型 Web 服务。特点是请求量大、对象创建销毁频繁、请求响应要求低延迟。这种服务适合用偏向低延迟的组合,同时新生代要足够大来承接高分配速率:
-Xms6g -Xmx6g -Xmn3g -XX:SurvivorRatio=8 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnlyCMSInitiatingOccupancyFraction=70表示老年代使用率达到 70% 就开始并发 CMS 回收,而不是等到几乎满了才开始,这样能减少 Concurrent Mode Failure 的风险。
第二类,计算密集型的批处理服务。特点是任务周期长、对象存活久、追求最大化吞吐量。这种服务适合用 Parallel 收集器:
-Xms8g -Xmx8g -Xmn4g -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+UseParallelGC -XX:+UseParallelOldGC不管哪类服务,调参之后一定要做压测对比。我习惯的做法是把压测分为三档:正常流量的 50%、100%、200%,观察每一档下 Minor GC 频率、单次 GC 耗时、Full GC 次数和应用的 T99 延迟。有几个信号可以判断参数是否合适:Minor GC 频率不超过每秒 2 次;单次 Minor GC 平均耗时不大于 20ms;Full GC 频率不高于每 10 分钟 1 次。如果超出这些范围,说明参数还没调到点子上。
另外强烈建议把 GC 日志接入你的监控体系。线上调优最怕的是“事后再分析”,GC 日志文件保留时间有限,等发现问题时日志已经滚动没了。用日志采集工具把 gc.log 实时收集到日志中心,再做关键词告警,才能避免重复踩坑。
5. 常见问题与避坑技巧实录
5.1 对象晋升失败与 HandlePromotionFailure
promotion failed是分代收集下最经典的问题之一。它发生在 Minor GC 过程中要晋升对象到老年代,但老年代没有足够的连续空间时。这个错误虽然发生在 Minor GC 过程中,结果却往往以一次 Full GC 收场。
日志典型长这样:
[ParNew (promotion failed): 2097152K->2097152K(2359296K), 0.1534567 secs]晋升失败的根源有两类。第一种是老年代真的不够用了——-Xmx太小或者老年代占比太低。这种情况最简单,直接把堆调大或调整新生代比例。第二种是老年代有空间但不是连续空间,碎片化严重。CMS 收集器下碎片化尤其常见,因为标记-清除天生会产生碎片。
应对方案分三个层次:
- 排查是不是
MaxTenuringThreshold设置过高,导致一堆本该早晋升的对象堵在 Survivor 里来回复制,拖到某一次 Minor GC 集体晋升,瞬间撑爆老年代。 - 检查
SurvivorRatio,Survivor 太小会迫使存活对象提前晋升,老年代被短期对象污染。这是最常见的原因。 - 确认老年代收集是 CMS 时,开启
-XX:+UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction(比如 5 次 Full GC 后做一次碎片整理),降低碎片影响。
最重要的一条经验:晋升失败从来不是单一参数的问题,而是新生代、老年代、晋升阈值三个要素共同失配。调参时一定要整体看,不要只改一个。
5.2 动态年龄判定的“隐形坑”
很多文章只提MaxTenuringThreshold,但实际决定对象是否晋升的还有一条动态规则:如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象就直接进入老年代,无需等到最大阈值。
这条规则的本意是:当 Survivor 空间已经装不下足够多活对象时,与其让它们反复复制浪费资源,不如直接晋升到老年代。它是一个自我保护机制。但问题恰恰出在这里——如果你设置的MaxTenuringThreshold=15,以为对象会在新生代多呆一阵子,实际上动态判定可能在第 3 次 GC 就把大部分对象赶到老年代。
排查方法就是前面案例里用到的-XX:+PrintTenuringDistribution。这个参数会输出每个年龄的对象占用字节数,看到类似age 1: 19658712 bytes这种数据,再对照 Survivor 区总容量,你就能算出动态判定什么时候生效。
我给你的实操建议是:不要把晋升阈值设得太大,尤其是 CMS 下默认只有 6。高分配速率的服务,15 的阈值只会让对象在 Survivor 里反复复制更多次,平白增加 Minor GC 耗时,最后该晋升的还是要晋升。主流服务把阈值设在 5~8 之间往往就够了。关键是让短命对象有时间死掉,而不是让它们赖在新生代里占用 Survivor 空间。
5.3 分代收集在 G1 之后:思路并未过时
G1 成为默认收集器之后,有不少人误以为“分代收集已经是老古董了”。其实 G1 还是分代的——它只是在实现上换了思路。G1 把整个堆划分成大小相等的 Region,通过-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent动态决定新生代占用的 Region 数量,新生代和老年代不再是一块连续的物理内存。
ZGC 刚出来时确实做了不分代的设计,因为它的回收阶段平均耗时极短,不需要靠分代来压缩扫描范围。但有意思的是,后来的 ZGC 在 JDK 21 里又加入了分代支持(分代 ZGC),原因很简单——大部分对象本来就是短命的,你让长命对象和短命对象一起经过并发标记、迁移,纯粹是浪费 CPU。分代思想绕了一圈又回来了。
所以站在开发者的角度,我的建议是:不管你的 JDK 版本默认收集器是谁,理解分代收集理论始终是分析一切 GC 问题的基础。无论是看 GC 日志里的PSYoungGen、ParOldGen,还是分析堆转储里的对象分布,你脑子的坐标系依然是新生代、老年代、存活区这三层。G1 的日志里依然有young、old的标记,ZGC 分代版日志里也依然有young gen、old gen的概念。
另外一个实操提醒:JDK 8 升级到 JDK 11 或 17 时,不要直接把旧的 GC 参数原样搬过去。G1 的很多参数名字跟 CMS 完全不同,CMSInitiatingOccupancyFraction在 G1 下对应的是-XX:InitiatingHeapOccupancyPercent,而-XX:MaxGCPauseMillis是 G1 特有的目标暂停时间参数。迁移之前先跑一轮对比压测,收集器和参数一起评估,而不是只换 JDK 版本。
分代收集理论最核心的价值,就是你用再多的现代垃圾回收器,最后发现分析问题时的思考路径还是“对象活多久、该留在哪、什么时候晋升”。我自己排查过的绝大多数 GC 问题,最终都落在 Survivor 空间不足、晋升阈值不当、老年代碎片化这三个坑里,而这三个坑的底层逻辑全部来自分代设计。把这块基础打牢,你在遇到 GC 相关问题时就不会再一头雾水,而是能顺着日志一步步定位到真正的瓶颈。