1. 新生代为什么值得单独设计:两个假说和一本真实的账
刚接触 JVM 内存模型的人,十有八九会卡在同一个疑问上:堆里明明还有一大半空间,为什么新生代那边已经 GC 了几百次?更让人困惑的是,有些服务把堆从 4G 扩到 16G,GC 次数不减反增,停顿还变长了。要回答这些问题,绕不开新生代的垃圾回收这套设计,而理解它的起点不是背参数,而是先承认一个现实:内存里的对象,绝大多数是活不长的。你把一次请求过程中产生的临时字符串、包装类型、List 中间结果、序列化缓冲区列出来,会发现它们从创建到失去引用可能只隔了几微秒。
1.1 弱代假说:绝大多数对象活不过第一轮回收
分代收集的理论依据有两条,业内叫弱代假说。第一条是:绝大部分对象都是朝生夕灭的。第二条是:熬过越多次垃圾回收的对象,就越难死掉。这两条听起来像废话,但它决定了整个回收器的资源分配方式。假设一个服务每秒要分配 300MB 对象,其中 99% 在几十毫秒内就变成了垃圾,那么真正需要长期保留的数据可能只有几 MB。如果每次回收都去扫描整个堆,等于为了几 MB 的活对象,把几百 MB 的死对象全部遍历一遍,成本完全不成比例。
新生代存在的意义就是把这件事切开:用极低的成本、极高的频率,处理掉那 99% 的短命对象;剩下的 1% 晋升到老年代,用更慢但更省空间的算法去管。所以你在监控上看到的 YGC 次数远多于 Full GC,不是设计缺陷,恰恰是设计生效的证据。反过来说,如果 YGC 次数很少,往往说明新生代开得过大,或者对象直接在老年代分配了,这反而值得警惕。
我见过一个后台任务的配置,堆 8G,新生代只给了 256M,结果每秒 YGC 三四次,每次回收几十兆,CPU 有相当一部分耗在 GC 线程上。把新生代调到 2G 之后,YGC 间隔从 300ms 拉到 4 秒,吞吐直接上去了。这个调整之所以有效,根子就在弱代假说上——短命对象的密度足够高,给它们一个足够大的“缓冲区”,回收频率自然就降下来了。
1.2 如果不分代:全堆标记的成本会吃掉什么
可以做个粗略的估算。一个 16G 的堆,如果每轮回收都做全堆可达性分析,即使标记阶段是并发的,光是把 16G 内存里的对象头挨个读一遍,就是百毫秒级别的工作量。而新生代回收只处理 Eden 加一块 Survivor,通常只占堆的几分之一,而且新生代的根扫描范围小得多——只需要扫 GC Roots 加上老年代指向新生代的跨代引用,老年代那一大片区域根本不用碰。
这也是为什么新生代回收的停顿能压到几毫秒到几十毫秒,而全堆回收经常上百毫秒甚至上秒。代价当然也有:为了知道“老年代里哪些对象引用了新生代”,JVM 需要维护记忆集,主流实现用的是卡表,把老年代切成固定大小的卡片,跨代引用发生时通过写屏障把对应卡片标记为脏。写屏障本身有开销,卡表也要占内存,这是分代收集必须付的税,天下没有白拿的性能。
1.3 三代人的分工:新生代、老年代各自管什么
新生代负责承接所有新对象的分配,用复制算法高频回收;老年代负责收留那些熬过多轮回收的对象和大对象,用标记-整理或标记-清除;元空间在 JDK 8 之后移到了本地内存,存放类元数据,和新生代回收基本是两套逻辑。这里要强调的是,三者不是完全隔离的独立王国,跨代引用会把它们串起来。一个新生代对象被老年代对象引用着,哪怕它在 Eden 里“看起来很孤立”,也不能被回收。
很多人调 GC 时只盯着新生代大小,忽略了对象晋升到老年代之后的行为。真实情况是,新生代回收的参数微调,影响的是晋升速率;而晋升速率又决定了老年代多久被填满、多久触发一次 Full GC。把新生代调大,YGC 少了,但每次 YGC 的存活对象更多,晋升的绝对量可能更大,老年代反而更快被撑爆。这个连锁反应,是后面所有调优讨论的主线。
2. 新生代的内存账本:Eden 和两块 Survivor 到底怎么分
打开任何一份新生代的内存布局图,都会看到 Eden、S0、S1 三块区域。默认比例是 8:1:1,很多人记住了这个数字,却说不清为什么偏偏是 8 而不是 4 或者 16。要弄明白这件事,得先看清楚新生代这块地是怎么被使用的,以及对象在里面的实际流转轨迹。
2.1 8:1:1 背后的空间利用率计算
新生代的可用空间由-Xmn或者NewRatio决定。假设堆 4G,-Xmx4g -Xms4g,默认-XX:NewRatio=2,也就是老年代:新生代 = 2:1,新生代约 1.33G。再按-XX:SurvivorRatio=8切,Eden 占 8 份,两个 Survivor 各占 1 份,总计 10 份。算下来 Eden 大约 1.09G,每个 Survivor 约 136M。
关键在于,任何时刻只有一个 Survivor 是“有内容的”,另一个是空的备用区。所以新生代的真实可用空间是 Eden + 一块 Survivor,约 9 份,而不是 10 份。复制算法的设计前提就是这样:牺牲 10% 的空间,换来回收时可以直接把存活对象搬到一个干净区域,不用原地整理碎片。
那为什么是 8:1 而不是 9:1 或者 4:1?这其实是个经验平衡点。Survivor 太小时,稍微有点存活对象就装不下,直接晋升老年代,等于把压力转嫁出去;Survivor 太大时,Eden 被挤压,YGC 频率上升,而且两块 Survivor 中始终有一块闲置,空间浪费明显。8:1:1 在经过大量真实负载验证后,被证明能覆盖大多数“短命对象占绝对多数”的场景。当然,如果你的业务有大量中等寿命对象(比如几秒钟的缓存、会话数据),这个比例未必合适,后面会讲怎么调。
注意:
SurvivorRatio表示的是 Eden 与单个Survivor 的比值,不是 Eden 与两个 Survivor 之和的比值。这一点经常被搞错,按 8 去算成 Eden 占 80%、两个 Survivor 各占 10% 是对的,但如果理解成 Survivo 总量占 1/8 就会算错容量。
2.2 两块 Survivor 的分工:为什么不能只留一块
只用一块 Survivor 行不行?理论上可以,但复制算法会退化成原地整理。回收时 Eden 和 Survivor 里的存活对象必须搬到同一个区域,而那个区域本身就有存活对象,搬过去会覆盖,只能先做整理,成本瞬间上升。两块 Survivor 的核心价值是双缓冲:回收时 From 区里的活对象统一搬到空的 To 区,Eden 直接整块清空,整个过程中不需要做任何碎片整理。
回收结束后,From 和 To 的角色互换,原来的 From 被清空,成为下一轮的 To。所以你在jstat输出里看到的 S0U 和 S1U,往往是一个有值一个为 0,或者大小交替变化,这不是故障,是角色轮换的正常表现。有一次同事看到 S1U 一直是 0,怀疑 Survivor 没生效,其实只是采样时间点刚好落在轮换的某一侧。
这里有个隐含约束:To Survivor 必须装得下 Eden 加 From 里所有要留下的对象。装不下怎么办?JVM 不会傻等,它会把装不下的部分直接晋升到老年代,这就是所谓的“分配担保”在起作用。所以 Survivor 大小的意义不只是“给对象一个多活几轮的机会”,它还决定了单次 YGC 的晋升溢出量。
2.3 对象分配的真实路径:TLAB、指针碰撞与分配失败
理解了布局,还要理解对象是怎么被放进去的。新建对象时,JVM 并不是每次都直接去 Eden 的共享区域抢位置,而是先看当前线程的 TLAB(Thread Local Allocation Buffer,本地线程分配缓冲)。每个线程在 Eden 里预先圈一块私有小区域,默认由-XX:+UseTLAB开启,分配时只需要移动一下指针,不需要任何锁。这就是所谓的指针碰撞——因为新生代用的是复制算法,内存天然连续,没有碎片,分配就是把指针往后推一格。
TLAB 用完了怎么办?线程会重新申请一块。如果 Eden 剩余空间不够,就先尝试在共享区做一次 CAS 分配;连共享区也放不下,就触发 YGC。这里有个容易被忽略的细节:TLAB 的剩余空间小于某个阈值时会被直接废弃,这个阈值由-XX:TLABWasteTargetPercent控制,默认 1%。也就是说,每个 TLAB 尾部最多浪费 1% 的空间。TLAB 设得太小,线程会频繁申请,增加管理开销;设得太大,浪费的空间变多,Eden 实际可用量下降。
和对象分配强相关的还有一个参数:-XX:PretenureSizeThreshold。超过这个大小的对象会跳过新生代,直接在老年代分配,避免大对象在 Eden 和 Survivor 之间来回复制。但这里有个大坑——这个参数只对 Serial 和 ParNew 收集器生效,在 Parallel Scavenge 下设置它是无效的,很多人设了半年发现没起作用,就是因为收集器不匹配。
3. 复制算法在新生代的落地:成本账和担保机制
新生代为什么不用标记-清除?答案藏在对象存活率上。标记-清除的标记阶段开销和存活对象数量、堆大小相关,清除阶段还要维护空闲列表,分配时得在列表里找合适大小的洞。当存活率只有百分之几的时候,这套机制的成本和收益完全失衡:你花了大力气标记和清理,最后只回收出一点点空间,还得面对碎片问题。
3.1 为什么新生代只适合复制算法
复制算法的逻辑简单粗暴:把 Eden 和 From Survivor 里的存活对象挑出来,整块复制到 To Survivor,然后把 Eden 和 From 整体清空。注意这里的重点——它不遍历死对象,只处理活对象。存活率 5% 意味着只需要搬 5% 的数据,剩下 95% 直接抛弃,这就是它的效率来源。标记-清除恰好相反,标记阶段必须遍历所有对象,包括那些马上要死的。
从数据上看,新生代一次 YGC 的耗时 ≈ 根扫描时间 + 存活对象复制时间 + 卡表处理时间。前两项都和存活对象数量强相关,所以新生代回收快不快,很大程度上取决于这一轮有多少对象活下来。这也解释了一个反直觉的现象:有时候业务量下降、QPS 减半,YGC 反而变慢了——因为创建的对象少了,但缓存里的对象寿命变长,存活率上升,复制成本反而增加。
复制算法也有短板:它要求有额外的空闲空间做腾挪,空间利用率天然不满。所以它只适合放在新生代这种“垃圾密度极高”的地方。放到老年代就是灾难,因为老年代存活率高,复制成本会爆炸。
3.2 一次复制的完整路径:从 Eden 到 To Survivor
对象从 Eden 搬到 To Survivor 的过程中,有几个信息会同步更新。第一是对象的年龄计数器,每熬过一次 YGC 就加一。第二是引用关系的修正,所有指向被搬动对象的引用都要改成新地址,这个修正过程依赖对象头里的转发指针。第三是卡表的更新,如果对象的地址变了,老年代里指向它的引用所在卡片可能需要重新标记。
搬过去之后,对象在 Survivor 之间还会来回倒腾。比如一个对象在 S0 里,下一轮 YGC 时 S0 是 From,它要么被复制到 S1,要么年龄到点直接晋升。所以一个对象可能经历这样一条路径:Eden → S0(年龄 1)→ S1(年龄 2)→ S0(年龄 3)→ 老年代。每轮都要被复制一次,这就是“熬”的代价。对于中等寿命对象,这个复制成本不容忽视,这也是为什么有些场景会刻意调小晋升阈值。
实操提醒:如果你在 GC 日志里看到
Desired survivor size ... bytes, new threshold 1,说明存活对象已经多到让 JVM 主动把晋升阈值降到了 1,对象刚活过一轮就直接进老年代。这种日志出现时,Survivor 基本已经失去意义了。
3.3 空间分配担保:老年代什么时候被“借”出来
担保机制是新生代回收里最容易被误解的部分。它的核心问题是:如果 To Survivor 装不下所有存活对象,多出来的往哪放?答案是老年代。但老年代也不是想借就能借,JVM 在做 YGC 之前会先做一次预判,判断的依据老年代最大连续可用空间和两个量比大小:
| 判断条件 | 含义 | 结果 |
|---|---|---|
| 老年代连续空闲 ≥ 新生代所有对象总大小 | 极端情况下全部对象晋升也放得下 | 直接执行 YGC,最安全 |
| 老年代连续空闲 < 新生代总大小,但 ≥ 历次晋升平均大小 | 按历史经验赌一把 | 尝试 YGC,失败再 Full GC |
| 老年代连续空闲 < 历次晋升平均大小 | 连经验值都放不下 | 直接 Full GC |
早期版本里有个-XX:HandlePromotionFailure开关控制是否允许担保失败,JDK 6 Update 24 之后这个参数的语义被简化了,规则变成:只要满足上面两条中的任意一条就做 YGC,不满足就 Full GC。所以你在新版 JVM 上设这个参数基本没有效果,看到旧文章还在讲它,知道历史背景就行。
担保失败(Promotion Failed)是真实会发生的故障。它的表现是 YGC 日志打印完之后紧接着一次 Full GC,而且 Full GC 之后老年代占用并没有明显下降,因为对象根本没死,只是换个地方住。这种情况的根因通常是 Survivor 太小或者晋升速率过高,导致大量对象在同一轮集中涌进老年代,把老年代挤爆。
4. 一次 Young GC 的完整时序复盘与日志逐行解读
光看原理容易飘,落到日志上才算真懂。下面这段是从一个使用 Parallel Scavenge 的服务上截下来的典型输出,我把每一段拆开讲。先看参数:-Xmx4g -Xms4g -Xmn1g -XX:SurvivorRatio=8,对应 Eden 约 819M,两个 Survivor 各约 102M,新生代总容量约 1G。
1024.335: [GC (Allocation Failure) [PSYoungGen: 838656K->61440K(917504K)] 2097152K->1421312K(4030464K), 0.0312567 secs] [Times: user=0.12 sys=0.00, real=0.03 secs]4.1 触发条件:Eden 分配失败怎么发生的
日志开头的GC (Allocation Failure)就是这个意思:某次对象分配没找到足够空间,触发了新生代回收。注意它说的是分配失败,不是内存不足,两者天差地别。分配失败是常态,说明 Eden 满了;内存不足是OutOfMemoryError,说明真的没地方放了。
触发点通常出现在三种情况:Eden 剩余空间放不下当前要分配的对象、TLAB 申请不到新块且共享区也满了、或者显式调用System.gc()(这种情况日志里会显示GC (System.gc()))。前两种是正常的,第三种应该在生产环境用-XX:+DisableExplicitGC关掉,避免有人误调用引起不必要的停顿。
触发之后,JVM 会进入安全点(Safepoint),所有应用线程停下来,这叫 STW。STW 的长度就是日志末尾的real=0.03 secs。这 30 毫秒里,GC 线程要做的事包括:扫描 GC Roots、遍历新生代对象、复制存活对象、修正引用、更新卡表、清理 Eden 和 From。
4.2 日志字段逐项拆解
把中间那串数字拆开看:
| 字段 | 含义 | 本次数值 |
|---|---|---|
838656K | 回收前新生代已用(Eden + From) | 约 819M,Eden 基本满了 |
->61440K | 回收后新生代已用(To Survivor 里留下的) | 约 60M |
(917504K) | 新生代总容量(Eden + 两块 Survivor) | 约 896M |
2097152K | 回收前整个堆已用 | 2G |
->1421312K | 回收后整个堆已用 | 约 1.35G |
(4030464K) | 堆总容量 | 约 3.84G |
0.0312567 secs | 本次 GC 的 STW 耗时 | 31ms |
从这组数能读出不少信息。堆回收前用了 2G,回收后 1.35G,减少的部分是 650M 左右,而新生代只减少了约 760M,两个数对不上。差额去了哪?一部分是新生代里被回收的对象中有少量被老年代引用的,回收后老年代占用上升了;另一部分是数字取整造成的误差。这提示我们一个排查思路:YGC 之后老年代占用上升了多少,就是这一轮的晋升量。如果这个增量在一段时间内持续走高,老年代迟早出问题。
再看Times那一行,user=0.12是所有 GC 线程消耗的 CPU 时间总和,real=0.03是墙钟时间。user 远大于 real,说明这次回收是多线程并行做的,0.12 / 0.03 = 4,大致对应 4 个 GC 线程。如果 real 明显大于 user,说明线程没跑满,可能有其他进程在抢 CPU。
4.3 晋升判定的三条路径:年龄、动态年龄、大对象
一个对象什么时候进老年代,不是单一规则决定的,至少有四条路可以走:
第一条是年龄达标。对象每熬过一次 YGC,年龄加一,达到-XX:MaxTenuringThreshold就晋升。这个值默认是 15,CMS 下默认是 6,因为 CMS 的 Survivor 空间紧张,早点送走更划算。年龄存在对象头的 Mark Word 里,只有 4 个 bit,所以最大只能是 15,设再大也没用。
第二条是动态年龄判定。这是很多人不知道的机制:JVM 在复制对象时会统计 Survivor 中各个年龄段的对象大小总和,从年龄 1 开始累加,当累加到某个年龄时,总和超过了 Survivor 空间的一半(由-XX:TargetSurvivorRatio控制,默认 50),就把这个年龄及以上的对象全部晋升,不再等到 15。这条规则的设计初衷是防止 Survivor 被挤爆,但它的副作用很致命——Survivor 越小,越容易触发,对象晋升越早,老年代压力越大。
第三条是大对象直接进老年代。超过-XX:PretenureSizeThreshold的对象跳过新生代,前面提过它只对 Serial 和 ParNew 有效。
第四条是担保溢出。To Survivor 装不下的对象被迫晋升。这条最不可控,因为它取决于单次回收时的瞬时存活对象量,波动可能很大。
这四条路径叠加在一起,解释了为什么很多人的调优看起来“没生效”:你调了MaxTenuringThreshold=15,但动态年龄判定在年龄 3 就把对象送走了,参数形同虚设。
5. 参数调优:新生代真正需要动手的几个旋钮
新生代的参数有十几个,但真正需要按业务特征去动的,不超过六个。剩下的要么已经被版本废弃,要么只在特定收集器下有影响。我按“容量类”和“晋升类”分开讲,因为这两类参数的作用方向是相反的,调错了会互相打架。
5.1 容量类参数与一次完整的容量计算
容量类主要是三个:-Xmn(直接指定新生代大小)、-XX:NewRatio(老年代与新生代比值)、-XX:SurvivorRatio(Eden 与单个 Survivor 的比值)。前两个是互斥的,-Xmn优先级更高,设了它NewRatio就失效。G1 下不建议用-Xmn,因为 G1 的新生代是动态伸缩的,硬设会限制它的自适应能力。
算一遍就清楚了。假设某服务堆 8G,-Xmx8g -Xms8g -Xmn3g -XX:SurvivorRatio=8:
- 新生代 3G = Eden + 2 × Survivor = 8 份 + 1 份 + 1 份 = 10 份,每份 307.2M
- Eden ≈ 2.4G,S0 ≈ 307M,S1 ≈ 307M
- 新生代某时刻可用 = 2.4G + 307M ≈ 2.7G
再估算 YGC 频率。假设通过jstat观察到 Eden 每秒增长 500M,那么两次 YGC 的间隔大约 2.4G / 500M ≈ 4.8 秒。如果实测间隔远小于这个值,说明要么 Eden 比算的小,要么分配速率比估的高,需要用数据核实,不能拍脑袋。
反过来,从 YGC 间隔倒推合适的新生代大小也很实用。业务要求 YGC 间隔不短于 10 秒,实测分配速率 400MB/s,那 Eden 至少要 4G,按 8:1:1 折算新生代总量约 5G。这时候就要权衡:新生代拿走 5G,老年代还剩多少,够不够撑。
提示:
-Xmn设得过大,还有一个隐性代价——单次 YGC 的复制量变大。因为 Eden 大意味着两次回收之间积累的对象多,存活下来的绝对值也可能变多,扫描和复制的耗时都会上升,STW 反而变长。这不是玄学,是实测中很常见的现象。
5.2 晋升类参数:三个值和一条隐藏规则
晋升类的核心是-XX:MaxTenuringThreshold、-XX:PretenureSizeThreshold、-XX:TargetSurvivorRatio。第一个控制年龄上限,第二个控制大对象门槛,第三个控制动态年龄判定的触发比例。
MaxTenuringThreshold的取值要结合 Survivor 大小看。如果 Survivor 只有 100M,就算你把阈值设成 15,动态年龄判定也会在对象总量超过 50M 时提前把它们送走。所以只调阈值不改 Survivor,等于没调。真正的组合是:调大 Survivor(把 SurvivorRatio 调小)+ 适当提高阈值,给对象更多在新生代“多活几轮”的机会。
TargetSurvivorRatio默认 50,调大意味着允许 Survivor 更满才触发批量晋升,但要小心超过 100 会导致 Survivor 溢出。一般不建议动,除非你非常确定存活对象的大小分布。
这里有个隐藏规则值得单独说:Parallel Scavenge 默认开启-XX:+UseAdaptiveSizePolicy,它会根据吞吐和停顿目标动态调整 Eden、Survivor 的大小比例,甚至修改晋升阈值。这意味着你手动设的SurvivorRatio可能在运行一段时间后被它悄悄改掉。我遇到过一次,配置里明明写着SurvivorRatio=8,实际跑起来 Survivor 比例只有 4 左右,查了半天才发现是自适应策略干的。要固定就得加-XX:-UseAdaptiveSizePolicy,代价是失去自动调优能力。
5.3 收集器差异:新生代参数在不同实现下的生效范围
不同收集器对参数的态度差别很大。
Serial 与 ParNew:年轻代用复制算法,Survivor 结构清晰,PretenureSizeThreshold有效,SurvivorRatio有效,参数行为最“老实”,适合作为理解模型的教学样本。
Parallel Scavenge:同为复制算法,但它是吞吐优先的,默认开启自适应。它不认PretenureSizeThreshold,MaxGCPauseMillis设了会牺牲吞吐,比较适合后台批处理这类不在乎单次停顿的场景。
G1:新生代不再是一整块连续区域,而是由若干 Region 拼成,Eden Region 数量动态变化。回收时存活对象被复制到空闲 Region 里,可能是新的 Survivor Region,也可能直接是老年代 Region。SurvivorRatio在 G1 下语义变弱,更常用的是-XX:G1NewSizePercent(默认 5)和-XX:G1MaxNewSizePercent(默认 60),配合-XX:MaxGCPauseMillis(默认 200)让 G1 自己去调节新生代规模。所以从 Parallel 迁到 G1 时,把-Xmn原封不动搬过去是最常见的错误之一。
6. 现场诊断:把一次 Young GC 看清楚的三种手段
参数怎么调,最终要靠数据说话。新生代的问题在现场排查时有三个基本工具:jstat看趋势,GC 日志看单次细节,堆转储看对象构成。三者配合,基本能定位 90% 的问题。
6.1 jstat 的读法与分配速率的估算方法
jstat -gc <pid> 1000每秒打一行,长期观察就能看出规律。关键列是这些:
| 列名 | 含义 | 关注点 |
|---|---|---|
S0C/S1C | 两个 Survivor 的容量 | 看是否与预期一致,能暴露自适应策略改动 |
S0U/S1U | 两个 Survivor 的使用量 | 有值的一侧是当前 From/To |
EC/EU | Eden 容量与使用量 | EU 增长速率就是分配速率 |
OC/OU | 老年代容量与使用量 | OU 的台阶式上涨对应晋升 |
YGC/YGCT | 新生代回收次数与总耗时 | 平均单次 = YGCT / YGC |
FGC/FGCT | 全堆回收次数与总耗时 | 任何上涨都要警惕 |
估算分配速率的方法很直接:取两次采样,用(EU2 - EU1) / 时间间隔得到 Eden 的增长速度。注意如果两次采样之间发生过 YGC,EU 会掉回接近 0,这时候要用 YGC 次数变化来校正。更稳的办法是直接看 Eden 容量除以 YGC 间隔,因为每次 YGC 基本意味着 Eden 被填满了一轮,这个值虽然略高估,但趋势判断够用。
jstat -gcutil <pid> 1000更直观,直接给百分比。但要注意它的S0和S1列在 G1 下的含义有细微差别,而且百分比会掩盖容量问题,比如 Survivor 使用率 100% 究竟是因为它就 50M 还是因为它有 500M,得配合-gc一起看。
6.2 日志参数配置与自动化分析
GC 日志必须开,这是底线。JDK 8 上的配置:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC -Xloggc:/data/logs/gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=50MJDK 9 之后换成了统一日志框架:
-Xlog:gc*,gc+heap=debug,gc+age=trace:file=/data/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=50Mgc+age=trace这个标签特别有用,它会打印每个年龄段的对象分布,能直接看出动态年龄判定在哪个年龄触发。JDK 8 上没有这个,只能靠PrintHeapAtGC输出的 Survivor 使用量反推。
日志量大的时候手工看不过来,用grep加awk做个粗统计就够:
grep "Allocation Failure" gc.log | awk -F'->' '{print $2}' | awk '{print $1}' | sort -n | tail -20这条命令能拉出晋升后老年代占用最高的若干次,快速定位异常轮次。更进一步,把每次的real=后面的数值抽出来排序,就能找到停顿最长的几次,针对性分析。
6.3 常见现象与排查方向速查表
| 现象 | 典型根因 | 验证方式 | 处理方向 |
|---|---|---|---|
| YGC 极频繁,每次回收量很小 | Eden 过小,或分配速率过高 | jstat看 EC 与 YGC 间隔 | 增大 Eden,或减少临时对象 |
| YGC 后老年代持续上涨 | 晋升过快,动态年龄判定提前触发 | 看 Survivor 是否需要扩容,用gc+age确认年龄分布 | 调小SurvivorRatio,或增大 Survivor |
| YGC 耗时明显变长 | 存活对象多,复制量大 | 日志里回收前后新生代差值 | 排查是否有大对象长期滞留新生代 |
| 频繁 Promotion Failed | To Survivor 装不下,担保失败 | 日志出现 Full GC 紧跟 YGC | 增大 Survivor,或提前让对象进老年代 |
| Survivor 使用率长期 100% | Survivor 过小,或被自适应策略压缩 | jstat对比 S0C 与预期值 | 关闭自适应,固定SurvivorRatio |
设了PretenureSizeThreshold无效 | 收集器不支持该参数 | 确认收集器类型 | 换 ParNew 或用其他方式限制大对象 |
7. 我在实际项目里踩过的坑和几条经验
盲目调大新生代,结果 YGC 变慢。有个服务原来新生代 512M,YGC 间隔 800ms,同事觉得太频繁,一口气把-Xmn调到 4G。间隔确实拉到了 6 秒,但单次 YGC 从 8ms 涨到了 45ms,尾延迟指标明显变差。原因很简单,Eden 变大之后两次回收之间积累的对象变多,存活绝对值上升,复制量跟着涨。后来折中到 1.5G,间隔 2.5 秒,单次 15ms 左右,才算平衡。这件事让我记住一条:YGC 间隔和单次停顿是跷跷板,很少能同时优化,得先看业务更在意哪个。
SurvivorRatio 调大反而害了新生代。这个参数名字很有欺骗性,很多人以为“比例调大就是给 Survivor 更多空间”,实际正好相反。SurvivorRatio=16意味着 Eden 是单个 Survivor 的 16 倍,Survivor 被压缩得很小。有个服务为了“让 Eden 更大”把它从 8 改成 16,结果 Survivor 只剩 50M,动态年龄判定在年龄 2 就触发,对象两轮就进老年代,老年代使用率肉眼可见地爬升,最后靠调回 8 再加-XX:-UseAdaptiveSizePolicy才稳住。
自适应策略悄悄改了配置。这个问题最隐蔽。配置文件里写得好好的参数,运行时通过jstat一看完全不是那么回事。排查思路是先加-XX:+PrintFlagsFinal | grep SurvivorRatio看 JVM 启动时实际接受的值,再看运行一段时间后的S0C是否变化。如果变了,基本就是UseAdaptiveSizePolicy在动,加了-XX:-UseAdaptiveSizePolicy就老实了。
关注晋升量的绝对值,而不只是比例。监控面板上“晋升速率”往往显示百分比,看起来不高,一看绝对值每秒 200MB,老年代撑不了多久。我的习惯是每次 YGC 之后记一下老年代的增量,连续观察十几轮,如果增量稳定在某个值附近,可以据此推算 Full GC 大概多久出现;如果增量在往上走,说明有对象寿命在变长,可能是缓存没设过期,也可能是连接池对象堆积,值得顺着查下去。
别在没数据的时候改参数。这话听着像废话,但我见过太多次“别人说-Xmn设成堆的 1/3 好”就直接照搬。新生代该给多少,取决于两件事:每秒分配速率和存活对象占比。前者靠jstat估,后者靠 GC 日志里回收前后的差值算。这两个数拿到手,参数区间基本就出来了,剩下的就是微调验证。没有这两个数,改参数和抽签没区别。