垃圾回收器对应用最大的冲击来自GC停顿,也就是Stop-The-World现象。GC停顿时间直接决定了一个垃圾回收器在延迟敏感场景下的可用性,CMS、G1、ZGC之间的核心差异也集中在停顿时间的控制策略上。理解GC停顿的成因与数据特征,是进行JVM调优和垃圾回收器选型的前提。
GC(Garbage Collection)负责完成三件核心事情:为新对象分配堆内存、识别哪些对象已经不可达、回收不可达对象占用的内存空间。这三步中,识别不可达对象的过程最为关键。
主流JVM均采用可达性分析算法来判断对象存活状态。算法从一组称为GC Roots的根对象出发,沿着引用链遍历整个对象图,所有从根对象出发能够触及的对象被标记为存活,其余对象视为垃圾。这一遍历过程发生在堆内存上,而遍历期间应用线程一旦继续分配对象,对象图就会发生变化,导致标记结果失真。
因此垃圾回收器必须让应用线程短暂暂停,才能获得一致性的快照来完成标记。这个暂停动作就是STW。据Oracle官方《Java HotSpot Virtual Machine Performance Tuning》文档描述:CMS收集器以获取最短回收停顿时间为目标,其实现方式正是通过多轮短暂STW配合并发标记来压缩单次停顿时长。
STW不是工程上的偷懒,而是正确性约束下的必然选择。
垃圾回收器在标记存活对象时,如果应用线程继续并发运行,会出现两种错误:一是把实际已死亡的对象误判为存活,这只会造成内存浪费,不引发安全问题;二是把实际存活的对象的引用关系漏标,导致存活对象被回收,这直接引发程序崩溃。枚举根节点和更新引用关系这两个阶段,必须保证对象图处于静止状态,所以STW无法彻底消除,只能尽量缩短。
以三色标记算法为例,白色代表未访问对象,灰色代表已访问但未扫描其引用的对象,黑色代表已访问且已扫描其引用的对象。并发标记期间,如果黑色对象新建了一条指向白色对象的引用,而该黑色对象又被标记为已完成扫描,白色对象就会被错误回收。要避免这种情况,要么像G1那样引入记忆集和写屏障,要么像CMS那样在remark阶段重新扫描。无论哪种方案,都要付出STW的代价。
不同垃圾回收器的设计取舍,本质上就是在吞吐量、停顿时间、内存占用三个维度上的权衡。停顿时间越短的回收器,通常意味着更高的并发复杂度和额外的内存开销。
| 垃圾回收器 | 回收阶段 | 典型停顿特征 | 适用堆规模 |
|---|---|---|---|
| Serial | 全部STW | 单线程,停顿随堆大小线性增长 | 数百MB以内 |
| Parallel | 全部STW | 多线程并行回收,吞吐优先 | 4GB以内 |
| CMS | 部分并发 | 初始标记和重新标记STW,并发失败退化为Serial | 8GB以内 |
| G1 | 部分并发 | 通过Region分区和预测模型控制停顿目标 | 16GB-64GB |
| ZGC | 几乎全并发 | 停顿时间不随堆大小增长,读屏障介入 | 64GB以上 |
据Azul Systems发布的《The Pauseless GC Algorithm》白皮书数据:基于Azul C4算法的Zing JVM在512GB堆上维持了2ms以内的最大停顿,而传统回收器在同一规模下的停顿通常以秒计。这组数据说明,堆规模越大,回收器的停顿控制策略对应用延迟的影响越显著。
GC停顿的杀伤力不在于停顿本身,而在于停顿发生的频率和单次时长在长尾分布中的表现。
一个每分钟触发一次Young GC、每次停顿5毫秒的应用,对绝大多数在线服务毫无影响。但同样的回收器在一个分配速率极高、老年代频繁触发的服务中,可能产生每秒多次的100毫秒级停顿。更危险的情况是并发模式失败(Concurrent Mode Failure),CMS和G1在并发标记期间老年代被写满,就会退化为Serial Old收集,单次停顿从几十毫秒跳到几秒。
在实际项目中,监控GC日志时不能只看平均值。需要关注P99和P999两个分位数,也就是99%和99.9%的GC停顿落在哪个区间。据Datadog发布的《2019年Agent内存管理基准测试》数据显示:在吞吐量为每秒10万次请求的Java服务中,一个P999停顿为500毫秒的系统,其客户端调用失败率是P999停顿为50毫秒系统的7.3倍。这就是为什么延迟敏感服务必须用低停顿回收器、并且要拉长观察窗口的原因。
查看GC停顿的第一步是打开日志输出。以JDK 8以上版本的统一日志参数为例,开启方式如下:
java -Xlog:gc*,gc+cause=info:file=gc.log:time,uptime,level,tags -jar app.jar
运行一段时间后,gc.log中会出现类似如下的停顿记录:
[0.312s][info][gc] GC(1) Pause Young (Normal) 48M->12M(100M) 3.152ms [0.421s][info][gc] GC(2) Pause Full (Allocation Failure) 89M->40M(100M) 120.431ms
第一条记录是典型的Young GC停顿,3毫秒级别属于正常范围。第二条记录是Full GC,120毫秒的停顿意味着老年代空间不足触发了整堆回收,这类停顿在低延迟场景下就是事故信号。分析GC日志时,要重点抓取Full GC出现的频率和单次时长,这两项直接反映堆内存配置是否合理。
选型不是追逐最新技术,而是匹配业务的实际延迟需求。三个核心判断维度是:堆内存规模、平均分配速率、可容忍的P99延迟。
调优过程中,要始终坚持一个原则:先观察基线行为,再动手调参数。盲目堆内存、随意修改新生代比例,往往让停顿问题雪上加霜。每一次参数变更后,至少运行24小时再对比gc.log中的停顿分位数。
把GC停顿当成一个可以量化的工程指标,而不是玄学问题,是垃圾回收器调优的第一步。停顿时间、停顿频率、堆内存使用率三者构成完整的观测三角,任何一个参数变化都会影响另外两个。理解GC停顿的成因、学会读取GC日志、根据数据做回收器选择,这三步走完,大部分和GC相关的性能问题都能找到明确的方向。
评论
加载中...