前言
在上篇 浅谈 Android 内存监控(上) 中,我们聊了 LeakCanary,微信的 Matirx 和美团的 Probe,它们各自有不同的应用场景,例如,在开发测试环境,我们会偏向用 LeakCanary,因为它能提供最完善的内存泄露机制和最详细的日志,非常方便定位问题,但它的不足之处就是,对性能影响比较大,所以如果要应用于线上生产环境,我们通常会考虑 Matrix 和 Probe,Matrix 除了提供 Activity/Fragment 对象泄露检测,它还支持重复 Bitamp 检测,同时它还会裁剪 hprof 文件,大大提高上传的成功率。而 Probe 的亮点在于,它不是对源 hprof 文件进行裁剪,而是 hook 了生成 hprof 的 native 方法,直接生成裁剪后的 hprof,这个优化能降低分析 hprof 文件的内存占用,提供分析的成功率。可惜的是,Probe 是个闭源的项目,没办法拿来主义。
关于 dumpHprofData
不管是 Matrix 也好,Probe 也好,它们都有一个痛点,没有解决调用 Debug.dumpHprofData() 带来的卡顿阻塞问题,dumpHprofData 是用来调用生成 hprof 文件,在生成过程中,整个页面会卡住,用户是没办法进行操作,至于为什么会带来这样的影响,我们可以通过源码去一探究竟。
Debug.dumpHprofData() 最终会通过 JNI 调用到 native 方法:
// art/runtime/native/dalvik_system_VMDebug.cc
static void VMDebug_dumpHprofData(JNIEnv* env, jclass, jstring javaFilename, jint javaFd){
// Only one of these may be null.
// 忽略一些判断代码
hprof::DumpHeap(filename.c_str(), fd, false);
}
// art/runtime/hprof/hprof.cc
void DumpHeap(const char* filename, int fd, bool direct_to_ddms){
// 忽略一些判断代码
ScopedSuspendAll ssa(__FUNCTION__, true /* long suspend */);
Hprof hprof(filename, fd, direct_to_ddms);
// 开始执行 Dump 操作
hprof.Dump();
}
复制代码
从源码中,我们可以看到在进行 Dump 操作之前,会构造一个 ScopedSuspendAll 对象,用来暂停所有的线程,然后再析构方法中恢复:
// 暂停所有线程
ScopedSuspendAll::ScopedSuspendAll(const char* cause, bool long_suspend) {
Runtime::Current()->GetThreadList()->SuspendAll(cause, long_suspend);
}
// 恢复所有线程
ScopedSuspendAll::~ScopedSuspendAll() {
Runtime::Current()->GetThreadList()->ResumeAll();
}
复制代码
这个暂停操作,对用户体验是种极大的伤害,可以通过取巧的方式去规避,例如,新开个进程来显示 loading 页面,APP 退到后台去执行 dump 等等,但并没有真正解决这个问题。
KOOM
最近快手开源了一个内存监控库叫 KOOM,这个库有不少亮点:
实现了 Probe:Android线上OOM问题定位组件 中提出 hook 生成 hprof 的 native 方法
解决了 dumpHprofData 方法阻塞问题
使用了 LeakCanary2 中使用的 hprof 分析库 shark
裁剪 hprof
KOOM 通过 xhook 实现 PLT hook,通过 hook 两个虚拟机方法 open() 和 writ() 来实现裁剪 hprof:
JNIEXPORT void JNICALL
Java_com_kwai_koom_javaoom_dump_StripHprofHeapDumper_initStripDump(JNIEnv *env, jobject jObject){
hprofFd = -1;
hprofName = nullptr;
isDumpHookSucc = false;
xhook_enable_debug(0);
/**
*
* android 7.x,write方法在libc.so中
* android 8-9,write方法在libart.so中
* android 10,write方法在libartbase.so中
* libbase.so是一个保险操作,防止前面2个so里面都hook不到(:
*
* android 7-10版本,open方法都在libart.so中
* libbase.so与libartbase.so,为保险操作
*/
xhook_register("libart.so", "open", (void *)hook_open, nullptr);
xhook_register("libbase.so", "open", (void *)hook_open, nullptr);
xhook_register("libartbase.so", "open", (void *)hook_open, nullptr);
xhook_register("libc.so", "write", (void *)hook_write, nullptr);
xhook_register("libart.so", "write", (void *)hook_write, nullptr);
xhook_register("libbase.so", "write", (void *)hook_write, nullptr);
xhook_register("libartbase.so", "write", (void *)hook_write, nullptr);
xhook_refresh(0);
xhook_clear();
}
复制代码
可以看到在不同的 Android 版本中,要 hook 的位置都有所区别。
在 hook 方法后,我们实现对指定的 hprof 文件进行裁剪,所以,会先通过 hprofName() 这个 JNI 方法来标记:
JNIEXPORT void JNICALL Java_com_kwai_koom_javaoom_dump_StripHprofHeapDumper_hprofName(
JNIEnv *env, jobject jObject, jstring name){
hprofName = (char *)env->GetStringUTFChars(name, (jboolean *)false);
}
复制代码
接着,在 hook_open() 方法获取文件的 FD:
int hook_open(const char *pathname, int flags, ...){
va_list ap;
va_start(ap, flags);
int fd = open(pathname, flags, ap);
va_end(ap);
if (hprofName == nullptr) {
return fd;
}
if (pathname != nullptr && strstr(pathname, hprofName)) {
// 获取 FD
hprofFd = fd;
isDumpHookSucc = true;
}
return fd;
}
复制代码
最后,在 hook_write() 方法中过滤掉要裁剪的数据,这里的代码细节我们就不去讨论了,有兴趣可以去看看源码。
解决 Dump 阻塞问题
上面我们说到,在执行 dumpHprofData 方法时,会先暂停进程中所有的线程,后面再重新恢复,所以即使将这个操作放到异步线程也是没办法解决的。
KOOM 在解决这个问题上,用了一个非常赞的思路,通过 fork 子进程去执行 dumpHprofData 方法。 fork 进程采用的是 "Copy On Write" 技术,只有在进行写入操作时,才会为子进程拷贝分配独立的内存空间,默认情况下,子进程可以和父进程共享同个内存空间,所以,当我们要执行 dumpHprofData 方法时,可以先 fork 一个子进程,它拥有父进程的内存副本,然后在子进程去执行 dumpHprofData 方法,而父进程则可以正常继续运行,相关源码如下:
try {
int pid = trySuspendVMThenFork();
if (pid == 0) {
Debug.dumpHprofData(path);
KLog.i(TAG, "notifyDumped:" + dumpRes);
//System.exit(0);
exitProcess();
} else {
resumeVM();
dumpRes = waitDumping(pid);
KLog.i(TAG, "hprof pid:" + pid + " dumped: " + path);
}
} catch (Exception e) {
e.printStackTrace();
}
复制代码
trySuspendVMThenFork 是一个 JNI 方法,用于暂停线程,并执行 fork 操作:
JNIEXPORT jint JNICALL Java_com_kwai_koom_javaoom_dump_ForkJvmHeapDumper_trySuspendVMThenFork(
JNIEnv *env, jobject jObject){
if (suspendVM == nullptr) {
initForkVMSymbols();
}
if (suspendVM != nullptr) {
suspendVM();
}
return fork();
}
bool initForkVMSymbols(){
bool res = false;
void *libHandle = kwai::linker::DlFcn::dlopen("libart.so", RTLD_NOW);
if (libHandle == nullptr) {
return res;
}
suspendVM = (void (*)())kwai::linker::DlFcn::dlsym(libHandle, "_ZN3art3Dbg9SuspendVMEv");
if (suspendVM == nullptr) {
__android_log_print(ANDROID_LOG_ERROR, "KOOM", "suspendVM is null!");
}
resumeVM = (void (*)())kwai::linker::DlFcn::dlsym(libHandle, "_ZN3art3Dbg8ResumeVMEv");
if (resumeVM == nullptr) {
__android_log_print(ANDROID_LOG_ERROR, "KOOM", "resumeVM is null!");
}
kwai::linker::DlFcn::dlclose(libHandle);
return suspendVM != nullptr && resumeVM != nullptr;
}
复制代码
在执行之前,会先调用 initForkVMSymbols() 执行初始化,这里是为了获取 suspendVM 和 resumeVM 这两个方法引用,接着在执行 fork 之前,先调用 suspendVM 暂停父进程的所有线程。
当 fork 执行成功后,会返回两次,当 pid == 0 时,这时候是在 fork 创建的子进程中,这时候我们可以执行 dumpHprofData 方法:
if (pid == 0) {
Debug.dumpHprofData(path);
KLog.i(TAG, "notifyDumped:" + dumpRes);
//System.exit(0);
exitProcess();
}
复制代码
执行完 dumpHprofData 方法后,直接退出关闭子进程,节省资源。
当 pid != 0 是,这时候是在父进程中,这时候我们只需要恢复之前暂停的线程即可:
resumeVM();
dumpRes = waitDumping(pid);
KLog.i(TAG, "hprof pid:" + pid + " dumped: " + path);
复制代码
同时我们继续在异步线程中等待子进程 Dump 操作结束。
这里贴张 KOOM 团队给出的流程图:
这样,我们可以将主进程调用 dumpHprofData 方法阻塞的时候,优化到 fork 子进程耗时时间。这里我再贴张 KOOM 团队给出的基准测试结果:
使用 Shark
LeakCanary2 重写了一个解析 hprof 文件的库,叫做 shark,它是用来代替原来的 haha,根据官方的说法,相比于 haha,shark 内存减少了10倍,速度快了6倍。KOOM 除了使用 shark 来解析,还在这个的基础上做了一些优化,减少了内存的占用,具体可以看源码。
小结
KOOM 的出现,提供了解决线上使用内存监控工具的一大障碍,我们完全可以基于 KOOM,再综合 Matrix 和 Probe 的优点,开发一款属于自己的线上内存监控工具。