外观
大促 Full GC 避坑实录:G1 堆参数与 Region 推导
问题背景:流量翻倍后 Full GC 为什么被触发
大促开始后,一个订单查询服务的 P99 从 80ms 抖动到 2s 以上,监控显示每隔几分钟出现一次 Full GC,单次停顿接近 8s。服务堆内存为 8G,使用 G1 GC,最初参数只有 -Xmx8g -Xms8g,表面上看内存充足,但老年代在流量高峰快速上涨,并发标记来不及回收,最终退化为 Serial Old 式的 Full GC。
这类问题不能只看堆是否够大,而要同时判断四件事:对象晋升速度、Region 大小是否匹配大对象、老年代占用触发阈值是否过晚、Mixed GC 是否能在周期内回收足够老年代。
方案对比:盲目加堆为什么常常无效
常见处理方式有三种。第一种是直接把堆从 8G 扩到 16G,短期可以拉长 Full GC 间隔,但大堆的 Remark 和 Full GC 停顿会更痛;第二种是切换 CMS 或 Parallel GC,但 JDK 9 后 G1 是默认收集器,低停顿与大堆平衡更好;第三种是基于 GC 日志推导 G1 参数,让 Young GC、并发标记和 Mixed GC 形成稳定节奏。
G1 不是固定分代,而是把堆切分为多个 Region。Eden、Survivor、Old 和 Humongous 都是 Region 的逻辑角色。调优目标不是“完全不 GC”,而是避免年轻代回收失败、老年代无法分配、Humongous 分配失败以及疏散失败。
Region 推导:从对象大小而不是经验值出发
G1 的 Region 大小默认会根据堆自动选择,取值通常为 1MB、2MB、4MB、8MB、16MB、32MB。规则可以粗略理解为:让 Region 数量保持在约 2048 个左右。8G 堆默认通常是 4MB,16G 堆通常是 8MB。
但大促场景必须检查业务对象大小。G1 中超过 Region 一半的对象会被当作 Humongous 对象。例如 Region 为 4MB,那么超过 2MB 的订单聚合结果、序列化字节数组、本地缓存块都会进入 Humongous Region。Humongous 对象主要在并发标记周期清理,若分配过于频繁,会造成老年代区域紧张。
推导公式如下:
text
RegionSize = 堆大小 / 期望 Region 数量,建议 Region 数量保持 1024~2048
Humongous 阈值 = RegionSize / 2
Eden 单次回收量 ≈ 年轻代 Region 数 × RegionSize
GC 间隔 ≈ Eden 单次回收量 / 每秒分配速率以 12G 堆为例,选择 8MB Region 后约有 1536 个 Region,Humongous 阈值为 4MB。压测观测到每秒分配 420MB,若年轻代维持 16 个 Region,则一次 Young GC 回收约 128MB,间隔只有 300ms,停顿会过于频繁。此时应让 G1 根据停顿目标自适应扩展年轻代,而不是固定死年轻代大小。
可运行压测代码:模拟大促短命对象与缓存晋升
下面代码使用 JDK 17 可直接运行,通过两个线程池模拟接口请求产生的短命对象,以及被本地缓存持有、晋升到老年代的长生命周期对象。
java
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
/**
* 运行方式:
* javac G1Pressure.java
* java -Xms4g -Xmx4g -XX:+UseG1GC -Xlog:gc*,gc+age=trace:file=gc.log:time,uptime,level,tags G1Pressure
*/
public class G1Pressure {
// 模拟本地缓存:这部分对象会被长期引用,不能靠 Young GC 回收
private static final Map<String, OrderAggregate> CACHE = new ConcurrentHashMap<>();
record OrderItem(String sku, String name, int price) {}
static class OrderAggregate {
final String orderId;
final List<OrderItem> items;
final byte[] payload;
final long createdAt;
OrderAggregate(String orderId, List<OrderItem> items, int payloadSize) {
this.orderId = orderId;
this.items = items;
this.payload = new byte[payloadSize];
this.createdAt = System.currentTimeMillis();
}
}
public static void main(String[] args) throws Exception {
ScheduledExecutorService cacheWriter = Executors.newSingleThreadScheduledExecutor();
ExecutorService requestPool = Executors.newFixedThreadPool(8);
// 每 200ms 写入一个缓存对象,观察老年代增长
cacheWriter.scheduleAtFixedRate(() -> {
String id = UUID.randomUUID().toString();
// 约 512KB,配合不同 Region 大小观察是否成为 Humongous 对象
CACHE.put(id, new OrderAggregate(id, List.of(new OrderItem(id, "promotion", 100)), 512 * 1024));
if (CACHE.size() > 3000) {
// 防止实验程序无限增长;真实服务应使用有界缓存和 TTL
Iterator<String> it = CACHE.keySet().iterator();
if (it.hasNext()) {
it.next();
it.remove();
}
}
}, 0, 200, TimeUnit.MILLISECONDS);
// 模拟高并发请求,每个请求创建大量短期对象
for (int i = 0; i < 8; i++) {
int workerId = i;
requestPool.submit(() -> {
long count = 0;
while (!Thread.currentThread().isInterrupted()) {
List<OrderAggregate> local = new ArrayList<>(128);
for (int j = 0; j < 128; j++) {
local.add(new OrderAggregate(
workerId + "-" + count + "-" + j,
List.of(new OrderItem("sku", "item", j), new OrderItem("coupon", "discount", j)),
4096
));
}
// 防止 JIT 将对象创建全部优化掉
if (local.size() != 128) {
throw new IllegalStateException("unexpected size");
}
count++;
}
});
}
System.out.println("pressure started at " + Instant.now());
Thread.sleep(TimeUnit.MINUTES.toMillis(5));
requestPool.shutdownNow();
cacheWriter.shutdownNow();
}
}运行后重点看 gc.log 中的 Pause Young、Concurrent Mark、Pause Mixed、To-space exhausted 和 Humongous regions。如果出现 to-space exhausted,说明年轻代 GC 或疏散阶段没有足够空闲 Region,继续只调大 -Xmx 往往不能解决问题。
JVM 参数配置:给 G1 留出并发回收余量
下面是一组适合 12G 堆、JDK 11/17 大促服务的基线参数。注意它不是通用模板,需要用压测数据校准。
bash
java \
-server \
-Xms12g -Xmx12g \
-XX:+UseG1GC \
-XX:G1HeapRegionSize=8m \
-XX:MaxGCPauseMillis=150 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1ReservePercent=15 \
-XX:G1HeapWastePercent=5 \
-XX:G1MixedGCCountTarget=8 \
-XX:G1MixedGCLiveThresholdPercent=85 \
-XX:+ParallelRefProcEnabled \
-Xlog:gc*,gc+age=trace,safepoint:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100m \
-jar order-service.jar参数含义要结合现象理解。InitiatingHeapOccupancyPercent 简称 IHOP,表示老年代、Humongous 等占用达到整堆比例后启动并发标记。默认 45% 在平稳流量下合理,但大促流量爬升快,降到 30%~35% 可以提前启动。G1ReservePercent 保留空闲内存,降低疏散失败概率,通常从 10% 提到 15%,不建议一上来设到 25%,否则可用堆明显变少。
G1MixedGCCountTarget 控制 Mixed GC 分多少次回收候选老年代 Region。值越小,单次回收越多,停顿越长;值越大,回收更分散,但可能追不上分配速度。G1MixedGCLiveThresholdPercent=85 表示存活率低于 85% 的 Region 更值得回收,避免把几乎满是存活对象的 Region 放入回收集。
监控与验证:用日志确认参数是否生效
可以使用下面命令快速统计 Full GC、疏散失败和各阶段停顿:
bash
# 查看 Full GC 次数
grep -c "Pause Full" gc.log
# 查看是否发生疏散空间耗尽
grep "To-space exhausted" gc.log
# 查看 Mixed GC 停顿时间
grep "Pause Mixed" gc.log
# 查看堆中 Region 角色变化
grep "Eden regions\|Survivor regions\|Old regions\|Humongous regions" gc.log如果环境允许,也可以临时执行:
bash
# 每 1 秒输出一次堆和各代容量,观察老年代是否持续单调上涨
jstat -gcutil -h 5 <pid> 1000验收标准建议量化:Full GC 次数为 0;Young GC 平均停顿低于 100ms、P99 停顿低于目标值;Mixed GC 后 Old 区占用有明显下降;30 分钟压测后老年代不呈单调增长;接口 P99 不出现秒级毛刺。
避坑总结:三个高频错误与修正方法
第一,不要固定过小的年轻代。曾有服务配置 -Xmn2g,大促分配速率升高后 Young GC 间隔不足 1 秒,应用线程虽然单次停得不长,但累计停顿和 CPU 消耗很高,对象还更容易提前晋升。修正方法是删除固定年轻代,让 G1 根据 -XX:MaxGCPauseMillis 自适应调整,只在必要时通过 G1NewSizePercent 和 G1MaxNewSizePercent 设置边界。
第二,不要看到 Full GC 就只把 -Xmx 加倍。错误现象是扩容后 Full GC 间隔变长,但一旦发生停顿更久,且日志仍有 to-space exhausted。这通常说明疏散失败、Metaspace 不足、显式 System.gc() 或 Humongous 分配问题被掩盖。应先在日志中搜索 Pause Full (Allocation Failure)、Metadata GC Threshold、Humongous allocation,再决定是加堆、调 Region,还是限制大对象。
第三,不要把 IHOP 当作越低越好。IHOP 降到 20% 后,并发标记会非常频繁,旧周期还没结束新周期又被请求,GC 线程抢占业务 CPU,吞吐量明显下降。建议从默认 45% 开始,按流量爬升速度压测到 35% 或 30%;如果 Mixed GC 后老年代能稳定下降,就不要继续下调。
第四,不要忽略引用处理和本地缓存。现象是 Young GC 中 Ref Proc 耗时很高,或者老年代持续上涨后触发 Full GC。修正方法是开启 -XX:+ParallelRefProcEnabled,将 Guava、Caffeine 等本地缓存设置最大容量与 TTL,避免把完整订单聚合结果长期放入无界 Map。
最后,G1 调优要遵循“日志定位、单变量修改、压测验证、灰度观测”的节奏。大促参数的价值不在于复杂,而在于让并发标记早于老年代危险线启动,让 Mixed GC 跟得上晋升速度,并让大对象不再持续挤压 Humongous Region。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。