快手开源项目KOOM分析,一个高性能线上内存监控方案!
作者:
codelang
, 链接:https://juejin.cn/post/6982121209144016910
KOOM 相比较 LeakCanary
和 Matrix
来说有点不同,后俩者由于 dump 的整个过程会影响到主进程,所以基本应用与线下监控,而 KOOM 提出了 fork dump 的概念,能在 dump 分析内存泄漏的时候而不影响到主进程的应用运行,所以,非常适合使用在线上监控。
所有的内存泄漏监控工具都离不开这三点:
监控触发时机 dump 内存堆栈 分析 hprof 文件
1、监控触发时机
LeakCanary
和 Matrix
都是在 Activity.onDestroy
时触发泄漏检测,KOOM 有点另辟蹊径,KOOM 是用阈值检测法来触发,我们来看下核心逻辑:
MonitorThread.class
class MonitorRunnable implements Runnable {
...
@Override
public void run() {
if (stop) {
return;
}
// 是否触发检测
if (monitor.isTrigger()) {
// 检测回调触发
stop = monitorTriggerListener
.onTrigger(monitor.monitorType(), monitor.getTriggerReason());
}
if (!stop) {
// 间隔 5s 轮训检测
handler.postDelayed(this, monitor.pollInterval());
}
}
}
MonitorThread
是一个利用 HandlerThread
不停在轮训监控当前是否触发检测,isTrigger
是关键所在
HeapMonitor.class
@Override
public boolean isTrigger() {
...
// ①、获取当前的内存状态
HeapStatus heapStatus = currentHeapStatus();
// ②、当前使用内存是否达到最大阈值,内存使用占比超过 95%
if (heapStatus.isOverMaxThreshold) {
// 已达到最大阀值,强制触发 trigger,防止后续出现大内存分配导致 OOM 进程 Crash,无法触发 trigger
currentTimes = 0;
return true;
}
// ③、当前使用内存是否达到触发条件,内存使用占比超过 80、85、90
if (heapStatus.isOverThreshold) {
// 默认是 true
if (heapThreshold.ascending()) {
// ④、此时记录的内存占用比上次记录的高、达到最大阈值
if (lastHeapStatus == null || heapStatus.used >= lastHeapStatus.used || heapStatus.isOverMaxThreshold) {
currentTimes++;
} else {
currentTimes = 0;
}
} else {
currentTimes++;
}
} else {
currentTimes = 0;
}
// 将本地记录进行缓存
lastHeapStatus = heapStatus;
// ⑤、记录的次数超过 3 次,则触发条件
return currentTimes >= heapThreshold.overTimes();
}
private HeapStatus lastHeapStatus;
private HeapStatus currentHeapStatus() {
HeapStatus heapStatus = new HeapStatus();
heapStatus.max = Runtime.getRuntime().maxMemory();
heapStatus.used = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
float heapInPercent = 100.0f * heapStatus.used / heapStatus.max;
heapStatus.isOverThreshold = heapInPercent > heapThreshold.value();
heapStatus.isOverMaxThreshold = heapInPercent > heapThreshold.maxValue();
return heapStatus;
}
解释:
①: currentHeapStatus
方法是获取当前的内存状态,主要收集了当前最大内存、已使用的内存、已使用内存的占比、已使用的内存占比是否超过阈值,已使用的内存占比是否超过最大阈值。②:当前已使用内存是否达到最大阈值,内存使用占比超过 95%(常量值,可配置),如果超过的话,则直接触发 ③:当前已使用内存占比是否触发到阈值,该阈值会根据机型内存来进行变更,具体看 KConstants.getDefaultPercentRation
(常量值,可配置)④、如果本次记录的内存占比比上次记录的还要大,或是触发到了最大阈值,则记录一下次数 ⑤:记录的次数超过 3
次,则触发
对于第四点我开始是有点疑虑的,只有内存是在连续 3
次增长的时候才会迭代次数,并且我们的检测是轮训的 5s
,如果在增长的次数刚好 2
次,gc 回收又让内存重新回跌,然后次数又会被重置,下次再又增长上来,又要从 0
开始记录次数,这种会不会漏检?但又思考再三,如果内存泄漏的话,内存的趋势肯定是增长状态的,只不过是时间问题,他并不像 crash 检测那样,需要很高的时效性。
2、dump 内存堆栈
Dump hprof是通过虚拟机提供的 API dumpHprofData
实现的,这个过程会 “冻结” 整个应用进程,造成数秒甚至数十秒内用户无法操作,这也是LeakCanary 无法线上部署的最主要原因,如果能将这一过程优化至用户无感知,将会给 OOM
治理带来很大的想象空间。
正如 KOOM 所说的,解决 dump 无感知会是非常大的想象空间,因为他可以部署到线上监控。
KOOM 使用 fork dump 操作,从当前主进程 fork 出一个子进程,由于 linux 的 copy-on-write
机制,子进程和父进程共享的是一块内存,那么我们就可以在子进程中进行 dump 堆栈,不影响主进程的运行。当然其中还是有很多的坑,这里不展开讲,可以查看快手的文章 解决 Dump hprof 冻结 app 部分
HeapDumpTrigger.class
public void doHeapDump(TriggerReason.DumpReason reason) {
// 生成 dump 的 hprof 文件存储路径
KHeapFile.getKHeapFile().buildFiles();
...
// 开始 dump
boolean res = heapDumper.dump(KHeapFile.getKHeapFile().hprof.path);
...
}
heapDumper
实现类有三个,我们只看 ForkJvmHeapDumper
类
ForkJvmHeapDumper
@Override
public boolean dump(String path) {
...
// 适配 Android 11 ,和下面流程差不多
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
return dumpHprofDataNative(path);
}
...
try {
// ①、挂起主进程并 for 出子进程
int pid = trySuspendVMThenFork();
if (pid == 0) {
// ②、子进程开始 dump hprof
Debug.dumpHprofData(path);
// 结束子进程
exitProcess();
} else {
// ③、恢复挂起的主进程
resumeVM();
// ④、等待子进程的 dump
dumpRes = waitDumping(pid);
}
} catch (IOException e) {
e.printStackTrace();
}
return dumpRes;
}
解释:
①:调用 native
方法,挂起当前的主进程,并 for 出子进程,该挂起仅仅只是更改ThreadList
变量的线程状态味suspend
,主要目的的欺骗子进程的 dump②:子进程开始 dump hprof
文件③:恢复挂起的主进程,也是更改 ThreadList
变量状态④:等待子进程退出, 看到 issue #81
有人对这个等待过程提出了疑虑,作者也进行相应的解答,waitPid
只是暂停线程,而我们 dump 的过程是在HandlerThread
进行的,所以并不影响主线程
dump 出的堆栈已存放到了指定 path
中,接下来只需要继续回到 doHeapDump
方法,做接下来的解析操作。
3、分析 hprof 文件
分析的回调有点长,就直接写类和方法好了:
KOOMInternal.onHeapDumped
HeapAnalysisTrigger.startTrack
HeapAnalysisTrigger.trigger
HeapAnalysisTrigger.doAnalysis
HeapAnalyzeService.runAnalysis
: 启动一个 IntentService 服务HeapAnalyzeService.doAnalyze
KHeapAnalyzer.analyze
KHeapAnalyzer.class
public boolean analyze() {
// 查找泄漏的引用链
Pair<List<ApplicationLeak>, List<LibraryLeak>> leaks = leaksFinder.find();
if (leaks == null) {
return false;
}
//将 gc 引用链写入到 report 文件中
HeapAnalyzeReporter.addGCPath(leaks, leaksFinder.leakReasonTable);
// 标记当前 report 已完成
HeapAnalyzeReporter.done();
return true;
}
对于解析,KOOM 做了如下优化:
GC root 剪枝,由于我们搜索
Path to GC Root
时,是从 GC Root 自顶向下 BFS,如JavaFrame
、MonitorUsed
等此类 GC Root 可以直接剪枝。基本类型、基本类型数组不搜索、不解析。
同类对象超过阈值时不再搜索。
增加预处理,缓存每个类的所有递归 super class,减少重复计算。
将object ID的类型从
long
修改为int
,Android虚拟机的object ID大小只有32位,目前shark里使用的都是long来存储的,OOM时百万级对象的情况下,可以节省10M
内存。
4、总结
KOOM 将内存泄漏做到线上监控,已经是市面上几款内存泄漏框架中的一种创新了
参考文档:
快手客户端稳定性体系建设 KOOM README
---END---
更文不易,点个“在看”支持一下👇