打开网易新闻 查看精彩图片

你试过在32G内存的CI机器上给Gradle堆设16G,结果构建还是OOM吗?这不是配置没生效,是你盯错了指标。

Gradle官方文档里埋着一个冷知识:jvmargs里配的堆大小,只是Gradle主进程堆的容量。操作系统眼里,一个JVM进程占用的内存远不止这些。堆外内存、元空间、线程栈、JNI调用、JVM自身开销——这些全算在RSS(常驻内存集)里,却经常被开发者忽略。

更麻烦的是,一次构建往往同时跑多个JVM。Kotlin守护进程、测试JVM、Android构建里的Lint和R8,每个都有自己的堆和RSS。它们叠加起来的内存压力,远超你单看Gradle堆配置时的预期。

为什么RSS比堆更能说明问题

为什么RSS比堆更能说明问题

堆用满时,JVM会触发GC,给开发者留条活路。但RSS逼近物理上限时,操作系统直接杀进程,连遗言都不留。CI环境里尤其致命——GitHub Actions这类平台,构建被杀后你连事后分析的机会都没有。

Unix-like系统上查RSS很简单:

ps -o rss= -p "$PID"

打开网易新闻 查看精彩图片

输出单位是KB。比如654321,换算过来约639MB物理内存。但这串数字是静态快照,构建被杀的那一刻,数据就消失了。

作者Nelson Osacky为此写了个GitHub Action叫Process Watcher,思路很直接:在构建期间单独跑一个监控进程,持续采集RSS、堆用量和GC活动,把数据流实时归档。

监控数据长什么样

监控数据长什么样

典型输出是带时间戳的表格:

Elapsed_Time | PID | Name | Heap_Used_MB | Heap_Capacity_MB | RSS_MB
00:00:05 | 149 | GradleDaemon | 29.7MB | 86.0MB | 241.0MB
00:00:10 | 149 | GradleDaemon | 191.7MB | 338.0MB | 560.1MB

注意第三列RSS的增长节奏。堆从29MB涨到191MB,RSS却从241MB飙到560MB——RSS的膨胀系数接近堆的2倍。如果你只按堆的峰值配机器,RSS会在背后偷偷吃掉你预留的安全余量。

多进程场景下这个效应会叠加。Kotlin守护进程、测试JVM各自独立,它们的RSS不会显示在Gradle主进程的监控里,却共享同一台机器的物理内存。作者提到,Android构建里的Lint和R8是单独的隔离进程,内存画像完全独立。

打开网易新闻 查看精彩图片

从静态数字到动态压力

从静态数字到动态压力

传统调优看的是峰值配置:堆设多大、元空间上限多少。但OOM往往发生在内存压力的斜率最陡处——某个任务并发时,RSS的增速超过了GC的回收效率,操作系统在JVM还没反应过来之前就下了杀手。

Process Watcher的价值在于把"内存用了多少"变成"内存压力怎么变"。时间序列数据能帮你定位:是某个阶段的并发任务太多,还是某个进程的RSS泄漏式增长,抑或是GC频率跟不上分配速度。

GitHub Actions的限制在于,构建被杀后无法附加后置步骤。监控进程必须在构建存活期间就把数据送出去,或者写到持久化存储。作者的设计选择了后者,把数据归档成构建产物。

一个被低估的观测盲区

一个被低估的观测盲区

CI环境的OOM调试有个悖论:最需要数据的时候,数据最容易丢。本地复现往往因为环境差异而失败,线上环境又因为平台限制拿不到现场。

RSS监控把这个盲区补上了一角。它不能阻止OOM,但能让你下次遇到时,知道该把机器配多大、该削哪块并发、该给哪个进程单独设限。

作者最后放了个问题:你的CI构建最近一次OOM,是在堆满之前还是之后被杀的?