← 返回文章列表

一次 Full GC 排查:没有淘汰策略的本地缓存

2018年6月12日Java
JVMFull GC线上排查性能优化

这个系列的开篇是《Java 并发:锁、AQS 与 CAS 的边界》,看的是机制:线程怎么排队,锁在什么条件下才升到重量级。隔了四年再写第二篇,想说的是另一类问题——告警真的响起来的时候,这些知识按什么顺序用。

笔者对这一段的记忆来自一次并不复杂的告警。

曾经遇到一个平时运行正常的系统,国庆回来不到一周,开始报 Full GC 告警。最后找到的原因很普通:一个配置模块用 HashMap 做本地缓存,新旧版本的配置都往里放,没有淘汰。

平时发布频繁,进程经常重启,问题不容易暴露。国庆前封版,节后也没有立即上线,运行时间变长,历史配置便一直积累。重启清空了缓存,却没有修复缓存生命周期。

那次之后,笔者再看 Full GC(Full Garbage Collection,整堆回收)告警,顺序就固定下来了:先决定救火还是留证,再看这一次回收的结果说明什么,然后回到引用链和代码,最后交代修复怎么验证。这个案例让笔者更留意 GC 之后仍然存活的对象,而不是看到 Full GC 就先调整堆大小。

告警响了,先决定救火还是留证

告警进群的时候,人容易先做两件事:翻监控找原因,或者直接重启止血。这两件事谁先谁后,应该由业务此刻的状态决定:想知道根因的心情再急,也不能拿核心链路去换证据。

遇到告警,先确认业务是否受影响、哪些实例异常、有没有可用容量承接流量。如果核心链路已经在掉,按预案摘流、扩容或重启都不算错。保留现场很重要,但不能要求线上一定等到大堆转储完成才恢复。

扩容和摘流可以同时做:新实例顶住流量,旧实例里留一台不重启,专门用来采证据。留一台的成本是一份容量,丢掉它的成本是下一次告警时手里还是空的。

“保留现场”的可行版本是:在重启之前,把带得走的证据带走。重启会清掉堆里的对象,而 GC 日志写在文件里,重启抹不掉它——前提是启动参数里本来就开着。

这件事得在告警之前做。JDK 9 之后 GC 日志并入统一日志,写法是 -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags,见 JEP 271;更早的版本是 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:...。有一点容易漏:日志目录要预先存在,否则 JVM 直接起不来,报 Invalid -Xlog option。等告警来了再补参数,就得先重启一次,而那次重启正好把要留的东西清掉了。

按“重启就没了”和“重启也还在”分一下,采集顺序自然就出来了:

  • 重启就没了:堆里的对象分布、线程栈、各内存区域当前占用,以及 FGC 计数,都要在重启前采。
  • 重启也还在:GC 日志文件、JVM 版本与启动参数、发布记录、监控里的流量和堆占用曲线,可以事后慢慢看。

采集用 jcmd 就够,它的好处是每条命令自己写着开销有多大。较新 JDK 可以先查看本机 jcmd 支持的诊断命令:

jcmd <pid> help
jcmd <pid> VM.flags          # 进程实际生效的参数,不是你以为的那份
jcmd <pid> GC.heap_info      # 各区域当前的容量与占用
jcmd <pid> Thread.print      # 线程栈,看有没有卡在批量查询或大对象构造上
jcmd <pid> help GC.heap_dump
jstat -gcutil <pid> 1000     # 每秒一行:O 老年代占比、FGC 次数、FGCT 累计停顿

这里的 <pid> 需要替换为目标 JVM 进程号。具体参数和影响以对应 JDK 为准;jcmd 的手册逐条标注了开销。不要把诊断命令当成没有代价的查询。

代价有多大,jcmd <pid> help GC.heap_dump 会直接写在脸上:默认请求一次 Full GC,加 -all 才跳过。堆转储也一样分两套默认值,只是方向相反——jmap -dump:format=b,file=heap.hprof <pid> 默认 dump 全部对象且不触发 GC,写成 -dump:live 才触发。堆直方图更隐蔽:jcmd <pid> GC.class_histogram 和 jmap -histo:live <pid> 都会先做一次整堆回收,GC 日志里记下的原因是 Heap Inspection Initiated GC。

在几十 GB 的堆上,这一下的差别是停顿量级上的,而且它会计进 FGC。事后拿到 jstat 输出的人如果不知道这件事,很可能把排查时自己采的两次当成业务在频繁 Full GC,然后顺着一个不存在的问题往下查。

jstat -gcutil 给的是百分比,要看绝对值用 jstat -gc(单位 KB):OU 是老年代已用,OC 是它的容量,FGC 是整堆回收次数。堆转储可以帮助分析对象引用,但可能带来停顿(Stop-The-World,STW)、磁盘压力,还可能包含敏感数据——写出来的 hprof 文件和堆差不多大,落盘前先确认磁盘余量。采集方式、时间和存放位置要按现场条件选择。

GC 日志本身也要会读,判断所需的要素都压在一行里。去掉时间戳前缀之后,一行整堆回收长这样:

[15.176s][info][gc] GC(1) Pause Full (Heap Dump Initiated GC) 76M->76M(194M) 1.171ms

GC(1) 是回收序号,紧跟着的括号是触发原因,76M->76M 是回收前后的堆占用,(194M) 是当前堆容量,末尾是这次停顿的时长。回收前后两个数几乎一样,就是“回收器没能释放任何东西”的直接证据;原因字段则说明这次回收是不是自找的。

线程栈回答的是另一个问题。用 top -H -p <pid> 找到占 CPU 最高的线程号,再到栈里找对应线程,就能看到它此刻卡在哪一行——批量查询、JSON 序列化,还是某个大对象的构造。这里有个细节:栈里用来匹配的字段是 nid,它的进制跟平台有关,Linux 上常见十六进制写法,搜之前先看一眼格式。栈只说明“此刻在干什么”,说明不了“什么东西在涨”,两份证据不能互相替代。

现场清单不必长,但要在告警群里先落到人头上:谁去摘流,谁去拷 GC 日志,堆转储采不采、采完放哪。这几句话没定下来,工具再熟也会卡在第一步。

一次 Full GC 之后,先看它回收了多少

手里有了几行 jstat 和一段 GC 日志,接下来要回答的是最常见的那句“是不是内存泄漏”。判据不在“有没有 Full GC”,而在回收之后老年代降到了哪里。

一种是回收后老年代几乎不动,每次只清掉一点点,水位像台阶一样往上走。另一种是每次都能降下去一大截,但很快又涨回来。前者的嫌疑在存活对象,后者的嫌疑在分配速度。

不过先别急着下结论。Full GC 后占用仍然很高,可以先查存活对象及引用链,但对象还被引用,不一定是泄漏;也可能是业务确实需要更大的工作集。要结合对象数量是否持续增长、生命周期是否合理来判断——一个进程装着几百 MB 本地缓存、水位却长期稳定,那是容量规划要处理的事;只有数量随时间单调上涨,才说明生命周期出了问题。

第三种形态最容易被忽略:单次回收确实降了,但存活集本身在慢慢涨。这时该盯的指标要换一个——每次 Full GC 之后的最低水位有没有抬高。单次降了多少说明不了问题,因为回收器已经尽力之后剩下的那部分,才是这个进程真实的存活集。

泄漏和分配过快,看的指标也不一样:前者盯的是回收后的最低水位,后者盯的是回收频率——YGC、FGC 的增速和 GCT 累计停班会跟着分配速率一起动,而水位本身并不持续抬高。

老年代的增长速率可以用 jstat -gc 的两行数据自己算:OU 两行相减再除以采样间隔,就是进程此刻的净增长速率。这个数比百分比有用,它能直接换算成“离堆满还剩多久”,也就是下一次告警大概什么时候来。

分配过快这一类,具体找起来有这么几种常见形态:

  • 数据库查询一次拉太多:select * 捞出几十万行塞进 List,或者分页接口被传了 pageSize=100000。
  • 大文件整个读进内存,没有分片或流式处理。
  • 定时任务、缓存预热一口气加载全量数据。
  • 动态生成类的路径:CGLib 代理、反射、运行时编译脚本,每来一次就多一批类。

大对象处理方式与收集器有关,不能把 G1 的 Humongous 分配套到所有 JVM 配置上——G1 里超过 Region 一半的对象走单独的分配路径,别的收集器不一定这样。

堆空间看起来充足时,还要根据 GC 原因检查类加载、元空间、显式 GC 等因素。仅凭“老年代没满”不能断定是元空间问题。不同收集器的事件名称和退化路径也不相同,先看日志里实际发生了什么;如果 jstat -gcutil 的 M 列和类加载计数一起涨,再用 jmap -clstats <pid> 看看是哪些类加载器在长。

回收之后的形态它说明什么下一步看哪里
回收后几乎不降,每次的最低水位还在抬高存活集在增长,生命周期出了问题采堆转储,走支配树,回到引用链
每次都能降下去,但很快涨回来分配速率高,或者工作集本来就大批量查询、报文体、缓存预热、定时任务
老年代不紧张,M 列和类加载计数一起涨与类加载或元空间有关动态代理、反射、运行时编译出来的类

堆转储里怎么找到还在被引用的对象

堆转储(heap dump)拿到手,问题就从“内存去哪儿了”变成“谁还引用着它”。分析路径在 MAT(Memory Analyzer Tool)里是固定的三步。

第一步用直方图(histogram)确认哪几类对象占了大头,顺便看数量——泄漏的特征是同一类对象的数量和运行时长一起涨。第二步切到支配树(dominator tree),它回答的是“把这个对象拿掉能连带释放多少”,这个数字叫保留大小(retained size),比对象自身的浅堆(shallow size)有用得多。第三步沿“Path to GC Roots”往回走,并记得剔除弱引用和软引用。

一个 HashMap$Node 自己的浅堆只有几十字节,真正的大头是它支配的那些配置对象。让对象活下来的通常是强引用链上的一环:一个 static 字段、一个还在运行的线程、一个忘了注销的监听器。

如果条件允许,隔一段时间再采第二份转储,把两份放在一起比较对象数量。增长的那几类就是嫌疑对象,这比在一份静态快照里猜要可靠得多。

手边没有 MAT 的时候,jcmd <pid> GC.class_histogram 的输出也能按类看数量和字节。先用它判断这次值不值得采转储,毕竟采一次是要付停顿和磁盘的。

沿这条链回到代码,问题往往变得很朴素:谁创建它、谁持有它、原本应该在什么时候释放。

配置缓存的寿命为什么没有尽头

回到国庆那次。这次通过公司的诊断平台查看内存占用,定位到配置模块的本地 HashMap。业务需要保存配置版本,代码便把新老版本都留在缓存里。真正的问题在于缓存没有区分“需要持久保存的历史”与“当前进程需要频繁访问的数据”。

键里带着配置版本,每发布一次就多一批新键,旧版本没有任何人负责删除。HashMap 不会自己淘汰东西,put 进去的条目要等到进程退出才消失。于是这个本该只装“当前进程需要频繁访问的数据”的本地缓存,事实上变成了“进程启动以来见过的所有配置版本”的归档。

这两种数据的存储需求完全不同:当前配置只有几十条,访问频繁,丢了可以回源重建;历史版本只增不减,需要的是持久存储和明确的保留策略。把它们塞进同一个没有上限的 Map,就等于把归档的生命周期挂在了进程的寿命上。这也是为什么换收集器治不了这一类的增长:收集器回收的是没人再引用的对象,而缓存里的键一直有人引用着,换哪个都只是把故障往后推。

为什么偏偏是国庆?平时发布频繁,进程经常重启,每次发布塞进去的那批版本还没堆到显眼的位置就被清掉了。封版之后重启停了,积累没有中断,运行时间一拉长,条目数就按发布历史单调增长。

增长的斜率一直没变,变的是没有人再把它清零。

判断一个本地缓存有没有真正修好,就看它有没有把四件事交代清楚:

  • 容量上限:装满了淘汰谁,按什么规则淘汰。
  • 过期策略:写入后多久失效,还是最后一次访问后多久失效。
  • 主动失效:配置变更时谁通知本地缓存删掉旧键,通知没送到又怎么办。
  • 回源:未命中时从哪里取,取失败的结果要不要也缓存一份。

这四条缺一条,缓存的生命周期就又交回给了进程——而进程的下一次重启,未必发生在问题暴露之前。

重启不算修复,多久才算验证通过

重启能立刻把水位压回去,但它动的是曲线上的一个点,不是斜率。当时通过重启恢复,后续把本地缓存改为 Caffeine——这里不能把修复理解成换了一个类名:容量、过期、失效和回源策略仍然需要明确。历史版本如果应当长期保存,就由持久存储承担,本地缓存按访问需求保留有限数据。

重启之后告警会消失,但那只是水位被清掉。要看修复有没有生效,第一件该确认的是代码变了;告警停不停,本来就不算证据——同样的天数跑满,它还会回来。

平时每次发布带来的重启把内存水位清零,封版之后同一个增长率没有人再打断,直到越过告警线

用一个更短的运行周期去验证,结论是没有意义的。这次的暴露窗口是“封版到告警”那一段,验证至少要跑过同样长的时间,还要覆盖发布过程,因为新版本进缓存正是增长发生的时刻。短时压测很难触发:压测通常跑几十分钟,而这个问题的自变量是运行天数。

实例多的话,可以先让一台上新版本,用同一套指标和其余实例对照。前提是流量分配大致均匀,否则对照出来的差异说明不了什么。

观察的指标也要换一换。回收一次降下去了,只能说明当前压力缓解;修复后要看的是缓存条目数、命中率、淘汰次数、回源压力,以及与业务负载对应的堆占用。

这些计数在 Caffeine 里由 Cache.stats() 提供,但统计默认是关的,要在构造时打开 recordStats()(参见 Caffeine 的统计说明)。忘了这一步,stats() 会一路返回 0,看起来就像一个从不淘汰的缓存,恰好把要看的现象盖住。区分起来也简单:命中率同样是 0,多半是统计没开;命中率正常、唯独淘汰次数长期为零,那才是容量上限从来没生效过,水位还是只能靠重启来压。

堆变大可以推迟下一次告警,但只有对象生命周期得到约束,问题才算有了解决依据。

原始记录没有保留这次故障的完整 GC 日志、堆大小和淘汰参数,所以不补写性能数字,也不把它概括成某个百分比的通用根因。这个案例能支持的结论是:频繁重启掩盖了缓存持续增长,运行周期拉长后才暴露出来。

从并发那篇里线程怎么排队,到这篇里一次告警按什么顺序查,看的是同一件事的两头:前一头弄清楚机制,后一头才知道手里有哪些手段可用。

至于堆外内存、直接缓冲区和元空间那一侧的增长,走的不是这条路,笔者还没在真实告警里碰过,先留个坑。

← 返回文章列表