浅谈 Android 内存监控(中)

5,928 阅读6分钟

前言

在上篇 浅谈 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,这个库有不少亮点:

  1. 实现了 Probe:Android线上OOM问题定位组件 中提出 hook 生成 hprof 的 native 方法
  2. 解决了 dumpHprofData 方法阻塞问题
  3. 使用了 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() 执行初始化,这里是为了获取 suspendVMresumeVM 这两个方法引用,接着在执行 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 团队给出的流程图:

img

这样,我们可以将主进程调用 dumpHprofData 方法阻塞的时候,优化到 fork 子进程耗时时间。这里我再贴张 KOOM 团队给出的基准测试结果:

img

使用 Shark

LeakCanary2 重写了一个解析 hprof 文件的库,叫做 shark,它是用来代替原来的 haha,根据官方的说法,相比于 haha,shark 内存减少了10倍,速度快了6倍。KOOM 除了使用 shark 来解析,还在这个的基础上做了一些优化,减少了内存的占用,具体可以看源码。

小结

KOOM 的出现,提供了解决线上使用内存监控工具的一大障碍,我们完全可以基于 KOOM,再综合 Matrix 和 Probe 的优点,开发一款属于自己的线上内存监控工具。