KOOM原理讲解(上)-JAVA内存分析

  • Post author:
  • Post category:java


前言:

KOOM是快手开源的一款针对线上OOM问题排查和解决的框架,其于2020年开源,有效的解决了LeakCanary无法用于线上的问题。

针对KOOM原理的讲解我准备分别两篇文章,分别为:

上篇:检测java内存状态的原理分析;

下篇:检测native内存状态的原理分析。

本篇是该系列文章的第一篇,主要讲解KOOM如何针对java层的内存问题如何发现并找出其泄漏路径。

一.使用入门

使用KOOM需要做进行三步操作:

1.app下的build.gradle文件进行相关依赖声明,如下:

implementation "com.kuaishou.koom:koom-native-leak:${VERSION_NAME}"
implementation "com.kuaishou.koom:koom-monitor-base:${VERSION_NAME}"
implementation "com.kuaishou.koom:koom-java-leak:${VERSION_NAME}"
implementation "com.kuaishou.koom:koom-thread-leak:${VERSION_NAME}"
PS:VERSION_NAME = 2.2.0

2.Application中进行相关代码的初始化:

这里是用的是默认的初始化方法,其实也可以自己进行参数上的定制,这里就不演示的,具体可以参考DefaultInitTask中的init方法进行相关初始化。

 DefaultInitTask.INSTANCE.init(this);

3.Activity或者Service中开启内存分析检测

相关初始化代码如下:

OOMMonitorInitTask.init(DemoApplication.getInstance())
OOMMonitor.startLoop(true, false, 5000L)

这里OOMMonitorInitTask类是我直接从官方demo中拷贝出来的,核心逻辑其实就是init方法。该方法中,主要也是对各种参数进行配置,然后通过以下方法进行设置。

MonitorManager.addMonitorConfig(config)

4.验证效果

我们构造一个内存泄漏的Activity,如下,然后启动这个Activity。

public class LeakedActivity extends Activity {

    public static Instance instance;
    static byte[] bytes;
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        instance = new Instance();
        bytes = new byte[1 * 1024 * 1024];
        instance.uselessObjectList.add(this);
        this.finish();
    }

    public static class Instance {
        public List<Activity> uselessObjectList = new ArrayList<>();
    }
}

测试代码如下:

startActivity(Intent(requireContext(), LeakedActivity::class.java))

这时候,理论上KOOM应该能够帮助我们检测到LeakActivity已经泄漏了,并且还泄漏了1M的内存,但是实际上,并没有任何提示。这是为何?

别急,这里就留一个伏笔,我们接下来讲解原理,讲解完原理之后,这里的答案也就有了。

二.内存检测流程

2.1 启动内存检测流程

OOMMonitor.startLoop():

override fun startLoop(clearQueue: Boolean, postAtFront: Boolean, delayMillis: Long) {
    
    ...上面的代码都是各种初始化检查,可以忽略
    //开启检查,
    super.startLoop(clearQueue, postAtFront, delayMillis)
    //分析上一次的内存文件
    getLoopHandler().postDelayed({ async { processOldHprofFile() } }, delayMillis)
  }

主要做了3件事:

1.各种初始化检测

2.开启检测流程

3.分析上一次的内存文件。因为很有可能因为OOM导致崩溃了,崩溃了自然无法分析,所以检查上一次的内存文件判断是否已经处理过。

2.2 LoopMonitor.startLoop中进行定时监测

LoopMonitor.startLoop方法中,则更简单了,根据postAtFront标记位,判断是否要延时执行。所以核心的检测逻辑在mLoopRunnable方法中。

 open fun startLoop(
      clearQueue: Boolean = true,
      postAtFront: Boolean = false,
      delayMillis: Long = 0L
  ) {
    if (clearQueue) getLoopHandler().removeCallbacks(mLoopRunnable)

    if (postAtFront) {
      getLoopHandler().postAtFrontOfQueue(mLoopRunnable)
    } else {
      getLoopHandler().postDelayed(mLoopRunnable, delayMillis)
    }

    mIsLoopStopped = false
  }

2.3 mLoopRunnable定时执行检测任务

mLoopRunnable如下,其主要逻辑是每隔固定时间进行一次检测,而检测的核心逻辑在call方法中。实现类是OOMMonitor,所以call方法也在这个类中。

private val mLoopRunnable = object : Runnable {
    override fun run() {
      if (call() == LoopState.Terminate) {
        return
      }

      if (mIsLoopStopped) {
        return
      }

      getLoopHandler().removeCallbacks(this)
      getLoopHandler().postDelayed(this, getLoopInterval())
    }
  }

2.4 单次检测

call方法如下,如果SDK不匹配或者已经开始dump内存了,则退出执行检测。KOOM如果发现泄漏后,只会执行一次内存DUMP和分析,执行完成中就会退出检测流程。

override fun call(): LoopState {
    if (!sdkVersionMatch()) {
      return LoopState.Terminate
    }

    if (mHasDumped) {
      return LoopState.Terminate
    }

    return trackOOM()
  }

2.5 检测流程

接着看一下trackOOM方法:

private fun trackOOM(): LoopState {
    SystemInfo.refresh()

    mTrackReasons.clear()
    for (oomTracker in mOOMTrackers) {
      if (oomTracker.track()) {
        mTrackReasons.add(oomTracker.reason())
      }
    }

    if (mTrackReasons.isNotEmpty() && monitorConfig.enableHprofDumpAnalysis) {
      if (isExceedAnalysisPeriod() || isExceedAnalysisTimes()) {
        MonitorLog.e(TAG, "Triggered, but exceed analysis times or period!")
      } else {
        async {
          MonitorLog.i(TAG, "mTrackReasons:${mTrackReasons}")
          dumpAndAnalysis()
        }
      }

      return LoopState.Terminate
    }

    return LoopState.Continue
  }

首先,通过SystemInfo.refresh()方法刷新当前的内存相关数据。

然后清空集合mTrackReasons,然后对mOOMTrackers集合中的所有类型进行相关的检测,如果监测到有问题,则加入到mTrackReasons集合中。

最后,如果mTrackReasons集合不为空,则说明已经满足了开启内存分析的条件,则调用dumpAndAnalysis方法去进行内存的dump和分析,同时返回LoopState.Terminate退出检测循环。

mOOMTrackers中共有5种类型,具体如何执行检测的我们下一章来讲解。

如何进行内存dump和分析的,我们第四章来讲解。

2.6 检测总结

所以总结一下,检测流程可以成下图所示:

三.5种检查类型

mOOMTrackers中有五种类型,分别为:HeapOOMTracker,ThreadOOMTracker,FdOOMTracker,PhysicalMemoryOOMTracker,FastHugeMemoryOOMTracker。

3.1.APP内存使用检查HeapOOMTracker

首先要获取几个数据,这几个数据其实是上面SystemInfo.refresh()方法中获取的,不过因为这里用到,我们就放到这里来讲了。几个数据如下

最大内存:javaHeap.max = Runtime.getRuntime().maxMemory()

总内存:javaHeap.total = Runtime.getRuntime().totalMemory()

空闲内存:javaHeap.free = Runtime.getRuntime().freeMemory()

使用内存=最大内存-空闲内存:javaHeap.used = javaHeap.total – javaHeap.free

使用占比=使用内存/最大内存:javaHeap.rate = 1.0f * javaHeap.used / javaHeap.max

判断条件如下:

override fun track(): Boolean {
    val heapRatio = SystemInfo.javaHeap.rate
    if (heapRatio > monitorConfig.heapThreshold
        && heapRatio >= mLastHeapRatio - HEAP_RATIO_THRESHOLD_GAP) {

      mOverThresholdCount++

      MonitorLog.i(TAG,
          "[meet condition] "
              + "overThresholdCount: $mOverThresholdCount"
              + ", heapRatio: $heapRatio"
              + ", usedMem: ${SizeUnit.BYTE.toMB(SystemInfo.javaHeap.used)}mb"
              + ", max: ${SizeUnit.BYTE.toMB(SystemInfo.javaHeap.max)}mb")
    } else {
      reset()
    }
    mLastHeapRatio = heapRatio
    return mOverThresholdCount >= monitorConfig.maxOverThresholdCount
  }

总结一下,就是连续3次(默认值,可配置)检测中,内存使用占比超过80%(不同内存大小占比不一样,可配置),并且内存状态没有呈明显下降趋势,则说明内存存在问题,需要进行检查。

3.2 线程数检查ThreadOOMTracker

每个进程中,对线程数量上限是有严格定义的,如果超出了线程数上限,也会报OOM问题。

同样需要先获取当前进程的线程数量,这个操作同样也是上面SystemInfo.refresh()方法中获取的,这里只是使用。(3.3,3.4,3.5下同,不赘述)

通过读取”/proc/self/status“文件来获取线程数量,文件内容如下,读取Threads这一行的值就是当前进程的线程数。

Name:	adbd
Umask:	0000
State:	S (sleeping)
Tgid:	1373
Ngid:	0
Pid:	1373
PPid:	1
TracerPid:	0
Uid:	2000	2000	2000	2000
Gid:	2000	2000	2000	2000
FDSize:	64
Groups:	1004 1007 1011 1015 1028 1078 1079 3001 3002 3003 3006 3009 3011 
VmPeak:	11080372 kB
VmSize:	11010628 kB
VmLck:	       0 kB
VmPin:	       0 kB
VmHWM:	    5860 kB
VmRSS:	    4740 kB
RssAnon:	    1972 kB
RssFile:	    2504 kB
RssShmem:	     264 kB
VmData:	   39480 kB
VmStk:	     132 kB
VmExe:	    1856 kB
VmLib:	    3388 kB
VmPTE:	     232 kB
VmPMD:	      44 kB
VmSwap:	     452 kB
Threads:	10    //线程数
...

判断方法如下:

override fun track(): Boolean {
    val threadCount = getThreadCount()

    if (threadCount > monitorConfig.threadThreshold
        && threadCount >= mLastThreadCount - THREAD_COUNT_THRESHOLD_GAP) {
      mOverThresholdCount++

      MonitorLog.i(TAG,
          "[meet condition] "
              + "overThresholdCount:$mOverThresholdCount"
              + ", threadCount: $threadCount")

      dumpThreadIfNeed()
    } else {
      reset()
    }

    mLastThreadCount = threadCount

    return mOverThresholdCount >= monitorConfig.maxOverThresholdCount
  }

和内存检查相似的逻辑,就是连续3次(默认值,可配置)检测中,线程数量超过450(不同机型和安卓版本不一样,可配置),并且线程数量没有呈明显下降趋势,则说明线程数量存在问题,需要进行检查。

3.3 FD数量检查FdOOMTracker

进程中,FD越多,资源消耗越大,自然FD也是要有数量限制的。

同样要获取FD数量,通过读取/proc/self/fd下文件数量来进行判断。如下图就是5个FD。

判断方法如下:

override fun track(): Boolean {
    val fdCount = getFdCount()
    if (fdCount > monitorConfig.fdThreshold && fdCount >= mLastFdCount - FD_COUNT_THRESHOLD_GAP) {
      mOverThresholdCount++

      MonitorLog.i(TAG,
          "[meet condition] "
              + "overThresholdCount: $mOverThresholdCount"
              + ", fdCount: $fdCount")

      dumpFdIfNeed()
    } else {
      reset()
    }

    mLastFdCount = fdCount

    return mOverThresholdCount >= monitorConfig.maxOverThresholdCount
  }

就是连续3次(默认值,可配置)检测中,FD数量超过1000(可配置),并且线程数量没有呈明显下降趋势(每次递减50),则说明FD数量存在问题,需要进行检查。

3.4 设备内存监控 PhysicalMemoryOOMTracker

首先,仍然是获取一些数据,这里获取的是设备内存使用占比。

方式是通过读取/proc/meminfo文件,文件内容如下,具体的解释这里不讲了,有兴趣的可以参考这一篇文章:

/proc/meminfo 解析_FoGoiN的博客-CSDN博客_proc/meminfo

MemTotal:        6391304 kB
MemFree:          719044 kB
MemAvailable:    2314468 kB
Buffers:          161840 kB
Cached:          1950736 kB
SwapCached:        77708 kB
Active:          2833588 kB
Inactive:        1407620 kB
Active(anon):    1709496 kB
Inactive(anon):   425888 kB
Active(file):    1124092 kB
Inactive(file):   981732 kB
Unevictable:        3044 kB
Mlocked:            3044 kB
SwapTotal:       1048572 kB
SwapFree:         846352 kB
Dirty:               188 kB
Writeback:             0 kB
AnonPages:       2131644 kB
Mapped:           666392 kB
Shmem:              4312 kB
Slab:             271600 kB
SReclaimable:     115712 kB
SUnreclaim:       155888 kB
KernelStack:       66592 kB
PageTables:        81600 kB
NFS_Unstable:          0 kB
Bounce:                0 kB
WritebackTmp:          0 kB
CommitLimit:     4244224 kB
Committed_AS:   84518516 kB
VmallocTotal:   263061440 kB
VmallocUsed:      183232 kB
VmallocChunk:          0 kB
CmaTotal:         401408 kB
CmaFree:          398172 kB

不过这里并没有进行相关的判断,应该是为了以后做准备吧,目前该方法返回的都是false。

3.5 快速增长大内存检测 FastHugeMemoryOOMTracker

仍然是先获取一些数据,内存占比,这个值3.1中已经讲过了。

这里判断的是如果内存使用率超过90%,或者内存增长两次之间超过350M(可配置),则触发内存检测。


3.6 检测总结

所以回顾我们第一章的问题,我们也就知道第一章中为什么Activity泄漏,或者泄漏1M数据没有触发检测了。因为KOOM本身就不适用于检测内存泄漏的,而是一个用来检查内存健康状态的工具。

举个例子,我的APP内存占比很少,只有十几M内存,这时候,假设我泄漏了很多Activity,也不会有什么问题,因为内存占比很少,并不会触发OOM了。而且Activity对象经过若干次GC之后会进入老年代,所以也不会导致频繁GC的问题。

再举一个反面例子,我的APP内存占比很多,虽然只泄漏了一个Activity,但是这个Activity内容很多,占用几百M内存,那么有可能就因为这一个Activity的泄漏导致程序OOM。

所以KOOM是用来保证我们程序可以在内存方面稳定安全运行的一款工具,而不是单纯用来检查内存泄漏的。

为了方便一些新手,在略微啰嗦一下,此时你们知道如何处罚KOOM的内存检查了吗?方式很简单,开启检查之后,瞬间创建超过450个线程,内存使用率提升到80%以上并且持续不释放,内存使用率提高到90%,再或者new一个超级大的对象(超过350M),这些就都会触发KOOM检查了。

四.如何dump内存快照

4.1为什么LeakCanary不能用于线上?

我们知道内存发生了问题后,那么如何处理呢?这就进入了dump并分析内存的流程(dumpAndAnalysis)。

一般来说,我们分析内存是通过如下操作进行的,比如LeakCanary就是这样的流程:

1.dump被fork出来的进程的内存;

2.分析内存文件hprof;

3.输出内存结果。

但是这样存在一个很大的问题,dump内存时,需要挂起对应进程中所有的线程,而且需要持续一段时间。在安卓中我们都知道,一旦线程(包含主线程)被挂起,那么自然就无法响应用户操作了,会发生ANR的问题。即使没有到ANR的阈值(5S),也会给用户一个卡顿的感受,严重影响用户的体验。

所以这就是为什么LeakCanary不能用于线上的原因。

4.2 KOOM是如何分析的?

但是我们如果用过KOOM,就会发现KOOM的内存健康检查是实时的,而且根据其官方说法是可以用于线上的,那么肯定是影响用户体验为前提的,所以,KOOM是如何解决卡顿问题的呢?

这里有一个核心思路就是进程fork。如果你知道APP启动流程的原理,就会知道所有APP的启动,其APP进程其实都是由zygote进程fork而来的。没看过的可以参考我的另外一篇文章:

android源码学习- APP启动流程(android12源码)_失落夏天的博客-CSDN博客_androidapp源码

所以安卓系统为什么要fork一个进程而不是完完全全创建一个呢?答案就是复制一个进程,要比重新创建一个进程资源消耗少的多。通常我们启动APP的话,你会感觉到启动流程一闪而过,实际上fork一个进程只需要几十毫秒,这么短的时间对用户的影响是极小的。

所以KOOM内存分析的核心就是这个原理,进行内存分析时,首先fork主进程,因为被fork的进程内存状态和主进程是一模一样的,所以对被fork进程的内存分析,就等同于分析主进程的内存状态。而fork完成的主进程后,则可以继续响应用户的操作,所以对用户的影响很小。

4.3 KOOM中dump内存流程

看完上一小节,我们知道了fork主进程的好处。那么这样实现,会有什么问题呢?我们先列一下KOOM的流程,然后慢慢来讲。

1.挂起所有子线程

2.fork当前主进程

3.恢复当前所有子线程

4.dump被fork出来的进程的内存

5.结束fork出来的进程

6.启动service对dump出来的内存文件进行分析

对应的部分代码在ForkJvmHeapDumper.java类的dump方法中,相关注释已添加

public synchronized boolean dump(String path) {
    MonitorLog.i(TAG, "dump " + path);
    if (!sdkVersionMatch()) {
      throw new UnsupportedOperationException("dump failed caused by sdk version not supported!");
    }
    init();//获取挂起线程相关的方法
    if (!mLoadSuccess) {
      MonitorLog.e(TAG, "dump failed caused by so not loaded!");
      return false;
    }

    boolean dumpRes = false;
    try {
      MonitorLog.i(TAG, "before suspend and fork.");
      int pid = suspendAndFork();//挂起线程,然后fork主进程
      if (pid == 0) {
        // Child process
        Debug.dumpHprofData(path);//返回0代表示新创建的进程,则进行内存dump
        exitProcess();
      } else if (pid > 0) {
        // Parent process
        dumpRes = resumeAndWait(pid);//返回>0代表仍然是原来的主进程,此时恢复被挂起线程
        MonitorLog.i(TAG, "dump " + dumpRes + ", notify from pid " + pid);
      }
    } catch (IOException e) {
      MonitorLog.e(TAG, "dump failed caused by " + e);
      e.printStackTrace();
    }
    return dumpRes;
  }

看完整个流程,如果基础比较弱的读者,也会有一点懵逼,会产生以下的疑问:

1.流程中说要先挂起所有子线程,为什么进程fork时需要提前挂起所有子线程呢?

2.exitProcess之后不就退出进程了,后面的代码如何执行的?

别急,我们下两小节依次来讲。

4.5 为什么fork进程前要挂起子线程?

其实fork进程前是不需要挂起子线程的,这里之所以挂起子线程,是因为后面需要DUMP内存。

JVM虚拟机在dump的时候,需要提前挂起所有的线程,才能进行内存的dump。那么直接让JVM虚拟机在dump的时候进行挂起不可以吗?

还真不行。这就不得不提到fork进程的原理了,进程的fork,本质上是linux提供的一种机制,但是这种机制有一些问题,linux的进程fork本身是为了单线程所准备的,多进程虽然也可以fork,但会存在一些问题。比如每个线程都存在内存地址的,fork了之后,被fork进程中的线程内存地址很有可能是错的,这时候再去执行dump操作挂起线程,就会导致无法正常挂起,无法挂起的话自然后面的dump操作就无法执行,从而导致dump内存时会一直卡住迟迟没有返回值。

所以为了避免这种卡住的情况,我们就提前把进程中的所有子线程挂起,这样fork之后再去dump内存时,因为线程本身已经挂起了,自然就不需要再次执行挂起操作,从而可以顺利的进行内存dump操作了。

说到这,继续扩展一下,为什么APP启动流程中,AMS通知Zygote使用的是socket而不是Binder呢?其原因也和这个特性有一定关系,binder在server端是会有单独线程去处理的。感兴趣的可以看一下这篇文章:


android中AMS通知Zygote去fork进程为什么使用socket而不使用binder?_失落夏天的博客

我们看一下在KOOM中的代码:

hprof_dump.cpp类中Initialize方法:

void HprofDump::Initialize() {
  if (init_done_ || android_api_ < __ANDROID_API_L__) {
    return;
  }

  void *handle = kwai::linker::DlFcn::dlopen("libart.so", RTLD_NOW);
  KCHECKV(handle)

  if (android_api_ < __ANDROID_API_R__) {
    suspend_vm_fnc_ =
        (void (*)())DlFcn::dlsym(handle, "_ZN3art3Dbg9SuspendVMEv");
    KFINISHV_FNC(suspend_vm_fnc_, DlFcn::dlclose, handle)

    resume_vm_fnc_ = (void (*)())kwai::linker::DlFcn::dlsym(
        handle, "_ZN3art3Dbg8ResumeVMEv");
    KFINISHV_FNC(resume_vm_fnc_, DlFcn::dlclose, handle)
  } else if (android_api_ <= __ANDROID_API_S__) {
    // Over size for device compatibility
    ssa_instance_ = std::make_unique<char[]>(64);
    sgc_instance_ = std::make_unique<char[]>(64);

    ssa_constructor_fnc_ = (void (*)(void *, const char *, bool))DlFcn::dlsym(
        handle, "_ZN3art16ScopedSuspendAllC1EPKcb");
    KFINISHV_FNC(ssa_constructor_fnc_, DlFcn::dlclose, handle)

    ssa_destructor_fnc_ =
        (void (*)(void *))DlFcn::dlsym(handle, "_ZN3art16ScopedSuspendAllD1Ev");
    KFINISHV_FNC(ssa_destructor_fnc_, DlFcn::dlclose, handle)

    sgc_constructor_fnc_ =
        (void (*)(void *, void *, GcCause, CollectorType))DlFcn::dlsym(
            handle,
            "_ZN3art2gc23ScopedGCCriticalSectionC1EPNS_6ThreadENS0_"
            "7GcCauseENS0_13CollectorTypeE");
    KFINISHV_FNC(sgc_constructor_fnc_, DlFcn::dlclose, handle)

    sgc_destructor_fnc_ = (void (*)(void *))DlFcn::dlsym(
        handle, "_ZN3art2gc23ScopedGCCriticalSectionD1Ev");
    KFINISHV_FNC(sgc_destructor_fnc_, DlFcn::dlclose, handle)

    mutator_lock_ptr_ =
        (void **)DlFcn::dlsym(handle, "_ZN3art5Locks13mutator_lock_E");
    KFINISHV_FNC(mutator_lock_ptr_, DlFcn::dlclose, handle)

    exclusive_lock_fnc_ = (void (*)(void *, void *))DlFcn::dlsym(
        handle, "_ZN3art17ReaderWriterMutex13ExclusiveLockEPNS_6ThreadE");
    KFINISHV_FNC(exclusive_lock_fnc_, DlFcn::dlclose, handle)

    exclusive_unlock_fnc_ = (void (*)(void *, void *))DlFcn::dlsym(
        handle, "_ZN3art17ReaderWriterMutex15ExclusiveUnlockEPNS_6ThreadE");
    KFINISHV_FNC(exclusive_unlock_fnc_, DlFcn::dlclose, handle)
  }
  DlFcn::dlclose(handle);
  init_done_ = true;
}

看到这些代码是不是又有一些懵了?为什么没有调用挂起的方法,而是调用_ZN3art3Dbg9SuspendVMEv呢?另外为什么安卓10以上(含)和以下有区别呢?

这是因为安卓10开始,限制APP使调用私有API方法,所以需要使用黑科技的时候去调用挂起方法。这个我们第五章来专门讲,这里只要知道是挂起线程就好。

4.6 为什么exitProcess之后还能有返回值?

如果有这个疑问的,说明对安卓掌握不深,不过没关系,我们细细来讲,本篇文章其目的就是为了让所有读者都清楚其原理。

进程fork的示意图大体如下所示:

也是是说,进程fork的操作,在操作之后会有两次返回值,而不是正常理解的一个。

//执行下面这行代码后,会有两次返回。一次是返回pid=0,另外一次返回pid>0。
int pid = suspendAndFork();
if (pid == 0) {

}else{

}

在这两次的返回中,其进程中内存空间是完全一样的,唯一的区别就是PID不一样,一个仍然是原进程ID的PID,而另外被fork的进程B则是0。需要注意的是,返回值0并不是进程B的PID,而只是说明子进程fork成功而已。

五.如何调用native挂起线程的方法

5.1 传统调用方式

挂起线程的方法在debugger.cc中,比如在9.0中是Dbg::SuspendVM方法(该小节都以9.0为例),在libart.so中,方法如下:

void Dbg::SuspendVM() {
  // Avoid a deadlock between GC and debugger where GC gets suspended during GC. b/25800335.
  gc::ScopedGCCriticalSection gcs(Thread::Current(),
                                  gc::kGcCauseDebugger,
                                  gc::kCollectorTypeDebugger);
  Runtime::Current()->GetThreadList()->SuspendAllForDebugger();
}

正常情况下,我们可以通过dlopen,dlsys的方式进行调用,代码如下:

void *handle = dlopen("libart.so", RTLD_NOW);
suspend_vm_fnc_ =(void (*)())dlsym(handle, "_ZN3art3Dbg9SuspendVMEv");
dlclose(handle);

这样就可以挂起进程中所有的线程了。

dl的用法这里就不扩展了,读者可以自行百度,调用流程类似于java中的反射。

另外,这里为什么是_ZN3art3Dbg9SuspendVMEv而不是Dbg::SupsendVM呢?这个操作类似于java反射的调用方式,最后调用时需要使用的是最终生成的地址名。我们可以拷贝libart.so文件出来,使用

nm -a libart.so > show.txt命令查看该so下所有的方法名,部分方法名如下:

...
0011ae48 T _ZN3art3Dbg8ResumeVMEv
00109968 T _ZN3art3Dbg9StartJdwpEv
0011addc T _ZN3art3Dbg9SuspendVMEv
...

5.2 KOOM实现方式

我们看KOOM中的实现,用的并不是dlopen,dlsys这样的函数,而是使用的kwai::linker::DlFcn::dlopen,kwai::linker::DlFcn::dlsym这样的方法,这是为何?

在安卓7.0之后,安卓限制了APP对私有API的调用,强行调用会导致崩溃,而libart.so中的这些方法都属于私有API,所以就必须想办法绕开正常的调用方式。

这个绕开限制的方案美团有一个描述比较详细的方案,主要看:“突破7.0动态链接的限制”这一章,地址如下:


Android远程调试的探索与实现 – 美团技术团队

为了方便读者连续阅读,我这里简单也描述一下。正常的方式是使用dlopen,dlsys的方案行不通了,但是我们可以把libart.so映射到内存中,然后按照按照ELF文件结构计算目标方法和头地址的偏移,然后使用内存中真实的libart.so的地址+偏移来计算出目标方法在内存中的位置,从而通过访问这块内存来实现相关方法的调用,而KOOM用的也是这一套原理。

相关代码在KOOM的kwai_dlfcn.cpp类中,这里也就不扩展了,感兴趣的读者可以自行阅读。

五.内存文件分析

5.1生成内存镜像文件hprof

fork生成新的进程后,就可以dump新的进程内存状态,并且恢复主进程的刮起状态了,相关代码如下:

      int pid = suspendAndFork();
      if (pid == 0) {
        // Child process
        Debug.dumpHprofData(path);
        exitProcess();
      } else if (pid > 0) {
        // Parent process
        dumpRes = resumeAndWait(pid);
        MonitorLog.i(TAG, "dump " + dumpRes + ", notify from pid " + pid);
      }

5.2 内存镜像文件hprof分析

获取到了内存文件之后,就会开启一个service进行分析,这个service自然可以跑在单独的进程中,避免影响主进程的正常运行:

 <service
      android:name=".monitor.analysis.HeapAnalysisService"
      android:process=":heap_analysis" />

启动service后,主流程在onHandleIntent方法中。KOOM中内存分析使用的工具和LeakCanary都是shark,但是KOOM中的shark有一些自己的改造,主流程代码如下:

override fun onHandleIntent(intent: Intent?) {
    val resultReceiver = intent?.getParcelableExtra<ResultReceiver>(Info.RESULT_RECEIVER)
    val hprofFile = intent?.getStringExtra(Info.HPROF_FILE)
    val jsonFile = intent?.getStringExtra(Info.JSON_FILE)
    val rootPath = intent?.getStringExtra(Info.ROOT_PATH)

    OOMFileManager.init(rootPath)

    kotlin.runCatching {
      buildIndex(hprofFile)
    }.onFailure {
      it.printStackTrace()
      MonitorLog.e(OOM_ANALYSIS_EXCEPTION_TAG, "build index exception " + it.message, true)
      resultReceiver?.send(AnalysisReceiver.RESULT_CODE_FAIL, null)
      return
    }

    buildJson(intent)

    kotlin.runCatching {
      filterLeakingObjects()
    }.onFailure {
      MonitorLog.i(OOM_ANALYSIS_EXCEPTION_TAG, "find leak objects exception " + it.message, true)
      resultReceiver?.send(AnalysisReceiver.RESULT_CODE_FAIL, null)
      return
    }

    kotlin.runCatching {
      findPathsToGcRoot()
    }.onFailure {
      it.printStackTrace()
      MonitorLog.i(OOM_ANALYSIS_EXCEPTION_TAG, "find gc path exception " + it.message, true)
      resultReceiver?.send(AnalysisReceiver.RESULT_CODE_FAIL, null)
      return
    }

    fillJsonFile(jsonFile)

    resultReceiver?.send(AnalysisReceiver.RESULT_CODE_OK, null)

    System.exit(0);
  }

主要流程如下:

1.OOMFileManager.init方法中初始化root路径

2.buildIndex方法中加载hprof文件,构建HeapGraph对象

3.buildJson中初始化返回值json,清空历史缓存

4.filterLeakingObjects方法中对第二步构建的HeapGraph对象进行分析,遍历镜像中所有class查找可能泄漏的点。

5.findPathsToGcRoot方法中对上面可能泄漏的点寻找其泄漏路径。

6.fillJsonFile,生成对应的JSON报告。

7.resultReceiver?.send,通知APP进程已经分析好了,json和hprof文件路径为双方提前约定好的路径。

8.System.exit(0); 结束当前分析的service进程。

至于shark是如何对内存镜像文件进行分析的,文本就不扩展了,这个要讲的的话就太多了,建议读者自行百度。

六.声明

1.KOOM项目地址


GitHub – KwaiAppTeam/KOOM

2.原理分析过程中使用的demo项目地址如下:


https://github.com/aa5279aa/android_all_demo

3.本文参考的链接及咨询人员


https://github.com/KwaiAppTeam/KOOM/blob/master/README.zh-CN.md


Android远程调试的探索与实现 – 美团技术团队

KOOM作者团队:@薛秋实 @李锐 @紫同



版权声明:本文为AA5279AA原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。