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


    前言:

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

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

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

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

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

    一.使用入门

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

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

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

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

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

     DefaultInitTask.INSTANCE.init(this);

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

    相关初始化代码如下:

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

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

    MonitorManager.addMonitorConfig(config)

    4.验证效果

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

    1. public class LeakedActivity extends Activity {
    2. public static Instance instance;
    3. static byte[] bytes;
    4. @Override
    5. protected void onCreate(@Nullable Bundle savedInstanceState) {
    6. super.onCreate(savedInstanceState);
    7. instance = new Instance();
    8. bytes = new byte[1 * 1024 * 1024];
    9. instance.uselessObjectList.add(this);
    10. this.finish();
    11. }
    12. public static class Instance {
    13. public List uselessObjectList = new ArrayList<>();
    14. }
    15. }

    测试代码如下:

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

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

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

    二.内存检测流程

    2.1 启动内存检测流程

    OOMMonitor.startLoop():

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

    主要做了3件事:

    1.各种初始化检测

    2.开启检测流程

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

    2.2 LoopMonitor.startLoop中进行定时监测

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

    1. open fun startLoop(
    2. clearQueue: Boolean = true,
    3. postAtFront: Boolean = false,
    4. delayMillis: Long = 0L
    5. ) {
    6. if (clearQueue) getLoopHandler().removeCallbacks(mLoopRunnable)
    7. if (postAtFront) {
    8. getLoopHandler().postAtFrontOfQueue(mLoopRunnable)
    9. } else {
    10. getLoopHandler().postDelayed(mLoopRunnable, delayMillis)
    11. }
    12. mIsLoopStopped = false
    13. }

    2.3 mLoopRunnable定时执行检测任务

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

    1. private val mLoopRunnable = object : Runnable {
    2. override fun run() {
    3. if (call() == LoopState.Terminate) {
    4. return
    5. }
    6. if (mIsLoopStopped) {
    7. return
    8. }
    9. getLoopHandler().removeCallbacks(this)
    10. getLoopHandler().postDelayed(this, getLoopInterval())
    11. }
    12. }

    2.4 单次检测

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

    1. override fun call(): LoopState {
    2. if (!sdkVersionMatch()) {
    3. return LoopState.Terminate
    4. }
    5. if (mHasDumped) {
    6. return LoopState.Terminate
    7. }
    8. return trackOOM()
    9. }

    2.5 检测流程

    接着看一下trackOOM方法:

    1. private fun trackOOM(): LoopState {
    2. SystemInfo.refresh()
    3. mTrackReasons.clear()
    4. for (oomTracker in mOOMTrackers) {
    5. if (oomTracker.track()) {
    6. mTrackReasons.add(oomTracker.reason())
    7. }
    8. }
    9. if (mTrackReasons.isNotEmpty() && monitorConfig.enableHprofDumpAnalysis) {
    10. if (isExceedAnalysisPeriod() || isExceedAnalysisTimes()) {
    11. MonitorLog.e(TAG, "Triggered, but exceed analysis times or period!")
    12. } else {
    13. async {
    14. MonitorLog.i(TAG, "mTrackReasons:${mTrackReasons}")
    15. dumpAndAnalysis()
    16. }
    17. }
    18. return LoopState.Terminate
    19. }
    20. return LoopState.Continue
    21. }

    首先,通过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

    判断条件如下:

    1. override fun track(): Boolean {
    2. val heapRatio = SystemInfo.javaHeap.rate
    3. if (heapRatio > monitorConfig.heapThreshold
    4. && heapRatio >= mLastHeapRatio - HEAP_RATIO_THRESHOLD_GAP) {
    5. mOverThresholdCount++
    6. MonitorLog.i(TAG,
    7. "[meet condition] "
    8. + "overThresholdCount: $mOverThresholdCount"
    9. + ", heapRatio: $heapRatio"
    10. + ", usedMem: ${SizeUnit.BYTE.toMB(SystemInfo.javaHeap.used)}mb"
    11. + ", max: ${SizeUnit.BYTE.toMB(SystemInfo.javaHeap.max)}mb")
    12. } else {
    13. reset()
    14. }
    15. mLastHeapRatio = heapRatio
    16. return mOverThresholdCount >= monitorConfig.maxOverThresholdCount
    17. }

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

    3.2 线程数检查ThreadOOMTracker

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

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

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

    1. Name: adbd
    2. Umask: 0000
    3. State: S (sleeping)
    4. Tgid: 1373
    5. Ngid: 0
    6. Pid: 1373
    7. PPid: 1
    8. TracerPid: 0
    9. Uid: 2000 2000 2000 2000
    10. Gid: 2000 2000 2000 2000
    11. FDSize: 64
    12. Groups: 1004 1007 1011 1015 1028 1078 1079 3001 3002 3003 3006 3009 3011
    13. VmPeak: 11080372 kB
    14. VmSize: 11010628 kB
    15. VmLck: 0 kB
    16. VmPin: 0 kB
    17. VmHWM: 5860 kB
    18. VmRSS: 4740 kB
    19. RssAnon: 1972 kB
    20. RssFile: 2504 kB
    21. RssShmem: 264 kB
    22. VmData: 39480 kB
    23. VmStk: 132 kB
    24. VmExe: 1856 kB
    25. VmLib: 3388 kB
    26. VmPTE: 232 kB
    27. VmPMD: 44 kB
    28. VmSwap: 452 kB
    29. Threads: 10 //线程数
    30. ...

    判断方法如下:

    1. override fun track(): Boolean {
    2. val threadCount = getThreadCount()
    3. if (threadCount > monitorConfig.threadThreshold
    4. && threadCount >= mLastThreadCount - THREAD_COUNT_THRESHOLD_GAP) {
    5. mOverThresholdCount++
    6. MonitorLog.i(TAG,
    7. "[meet condition] "
    8. + "overThresholdCount:$mOverThresholdCount"
    9. + ", threadCount: $threadCount")
    10. dumpThreadIfNeed()
    11. } else {
    12. reset()
    13. }
    14. mLastThreadCount = threadCount
    15. return mOverThresholdCount >= monitorConfig.maxOverThresholdCount
    16. }

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

    3.3 FD数量检查FdOOMTracker

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

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

     判断方法如下:

    1. override fun track(): Boolean {
    2. val fdCount = getFdCount()
    3. if (fdCount > monitorConfig.fdThreshold && fdCount >= mLastFdCount - FD_COUNT_THRESHOLD_GAP) {
    4. mOverThresholdCount++
    5. MonitorLog.i(TAG,
    6. "[meet condition] "
    7. + "overThresholdCount: $mOverThresholdCount"
    8. + ", fdCount: $fdCount")
    9. dumpFdIfNeed()
    10. } else {
    11. reset()
    12. }
    13. mLastFdCount = fdCount
    14. return mOverThresholdCount >= monitorConfig.maxOverThresholdCount
    15. }

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

    3.4 设备内存监控 PhysicalMemoryOOMTracker

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

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

    1. MemTotal: 6391304 kB
    2. MemFree: 719044 kB
    3. MemAvailable: 2314468 kB
    4. Buffers: 161840 kB
    5. Cached: 1950736 kB
    6. SwapCached: 77708 kB
    7. Active: 2833588 kB
    8. Inactive: 1407620 kB
    9. Active(anon): 1709496 kB
    10. Inactive(anon): 425888 kB
    11. Active(file): 1124092 kB
    12. Inactive(file): 981732 kB
    13. Unevictable: 3044 kB
    14. Mlocked: 3044 kB
    15. SwapTotal: 1048572 kB
    16. SwapFree: 846352 kB
    17. Dirty: 188 kB
    18. Writeback: 0 kB
    19. AnonPages: 2131644 kB
    20. Mapped: 666392 kB
    21. Shmem: 4312 kB
    22. Slab: 271600 kB
    23. SReclaimable: 115712 kB
    24. SUnreclaim: 155888 kB
    25. KernelStack: 66592 kB
    26. PageTables: 81600 kB
    27. NFS_Unstable: 0 kB
    28. Bounce: 0 kB
    29. WritebackTmp: 0 kB
    30. CommitLimit: 4244224 kB
    31. Committed_AS: 84518516 kB
    32. VmallocTotal: 263061440 kB
    33. VmallocUsed: 183232 kB
    34. VmallocChunk: 0 kB
    35. CmaTotal: 401408 kB
    36. 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方法中,相关注释已添加

    1. public synchronized boolean dump(String path) {
    2. MonitorLog.i(TAG, "dump " + path);
    3. if (!sdkVersionMatch()) {
    4. throw new UnsupportedOperationException("dump failed caused by sdk version not supported!");
    5. }
    6. init();//获取挂起线程相关的方法
    7. if (!mLoadSuccess) {
    8. MonitorLog.e(TAG, "dump failed caused by so not loaded!");
    9. return false;
    10. }
    11. boolean dumpRes = false;
    12. try {
    13. MonitorLog.i(TAG, "before suspend and fork.");
    14. int pid = suspendAndFork();//挂起线程,然后fork主进程
    15. if (pid == 0) {
    16. // Child process
    17. Debug.dumpHprofData(path);//返回0代表示新创建的进程,则进行内存dump
    18. exitProcess();
    19. } else if (pid > 0) {
    20. // Parent process
    21. dumpRes = resumeAndWait(pid);//返回>0代表仍然是原来的主进程,此时恢复被挂起线程
    22. MonitorLog.i(TAG, "dump " + dumpRes + ", notify from pid " + pid);
    23. }
    24. } catch (IOException e) {
    25. MonitorLog.e(TAG, "dump failed caused by " + e);
    26. e.printStackTrace();
    27. }
    28. return dumpRes;
    29. }

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

    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方法:

    1. void HprofDump::Initialize() {
    2. if (init_done_ || android_api_ < __ANDROID_API_L__) {
    3. return;
    4. }
    5. void *handle = kwai::linker::DlFcn::dlopen("libart.so", RTLD_NOW);
    6. KCHECKV(handle)
    7. if (android_api_ < __ANDROID_API_R__) {
    8. suspend_vm_fnc_ =
    9. (void (*)())DlFcn::dlsym(handle, "_ZN3art3Dbg9SuspendVMEv");
    10. KFINISHV_FNC(suspend_vm_fnc_, DlFcn::dlclose, handle)
    11. resume_vm_fnc_ = (void (*)())kwai::linker::DlFcn::dlsym(
    12. handle, "_ZN3art3Dbg8ResumeVMEv");
    13. KFINISHV_FNC(resume_vm_fnc_, DlFcn::dlclose, handle)
    14. } else if (android_api_ <= __ANDROID_API_S__) {
    15. // Over size for device compatibility
    16. ssa_instance_ = std::make_unique<char[]>(64);
    17. sgc_instance_ = std::make_unique<char[]>(64);
    18. ssa_constructor_fnc_ = (void (*)(void *, const char *, bool))DlFcn::dlsym(
    19. handle, "_ZN3art16ScopedSuspendAllC1EPKcb");
    20. KFINISHV_FNC(ssa_constructor_fnc_, DlFcn::dlclose, handle)
    21. ssa_destructor_fnc_ =
    22. (void (*)(void *))DlFcn::dlsym(handle, "_ZN3art16ScopedSuspendAllD1Ev");
    23. KFINISHV_FNC(ssa_destructor_fnc_, DlFcn::dlclose, handle)
    24. sgc_constructor_fnc_ =
    25. (void (*)(void *, void *, GcCause, CollectorType))DlFcn::dlsym(
    26. handle,
    27. "_ZN3art2gc23ScopedGCCriticalSectionC1EPNS_6ThreadENS0_"
    28. "7GcCauseENS0_13CollectorTypeE");
    29. KFINISHV_FNC(sgc_constructor_fnc_, DlFcn::dlclose, handle)
    30. sgc_destructor_fnc_ = (void (*)(void *))DlFcn::dlsym(
    31. handle, "_ZN3art2gc23ScopedGCCriticalSectionD1Ev");
    32. KFINISHV_FNC(sgc_destructor_fnc_, DlFcn::dlclose, handle)
    33. mutator_lock_ptr_ =
    34. (void **)DlFcn::dlsym(handle, "_ZN3art5Locks13mutator_lock_E");
    35. KFINISHV_FNC(mutator_lock_ptr_, DlFcn::dlclose, handle)
    36. exclusive_lock_fnc_ = (void (*)(void *, void *))DlFcn::dlsym(
    37. handle, "_ZN3art17ReaderWriterMutex13ExclusiveLockEPNS_6ThreadE");
    38. KFINISHV_FNC(exclusive_lock_fnc_, DlFcn::dlclose, handle)
    39. exclusive_unlock_fnc_ = (void (*)(void *, void *))DlFcn::dlsym(
    40. handle, "_ZN3art17ReaderWriterMutex15ExclusiveUnlockEPNS_6ThreadE");
    41. KFINISHV_FNC(exclusive_unlock_fnc_, DlFcn::dlclose, handle)
    42. }
    43. DlFcn::dlclose(handle);
    44. init_done_ = true;
    45. }

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

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

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

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

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

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

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

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

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

    5.1 传统调用方式

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

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

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

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

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

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

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

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

    1. ...
    2. 0011ae48 T _ZN3art3Dbg8ResumeVMEv
    3. 00109968 T _ZN3art3Dbg9StartJdwpEv
    4. 0011addc T _ZN3art3Dbg9SuspendVMEv
    5. ...

    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新的进程内存状态,并且恢复主进程的刮起状态了,相关代码如下:

    1. int pid = suspendAndFork();
    2. if (pid == 0) {
    3. // Child process
    4. Debug.dumpHprofData(path);
    5. exitProcess();
    6. } else if (pid > 0) {
    7. // Parent process
    8. dumpRes = resumeAndWait(pid);
    9. MonitorLog.i(TAG, "dump " + dumpRes + ", notify from pid " + pid);
    10. }

    5.2 内存镜像文件hprof分析

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

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

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

    1. override fun onHandleIntent(intent: Intent?) {
    2. val resultReceiver = intent?.getParcelableExtra<ResultReceiver>(Info.RESULT_RECEIVER)
    3. val hprofFile = intent?.getStringExtra(Info.HPROF_FILE)
    4. val jsonFile = intent?.getStringExtra(Info.JSON_FILE)
    5. val rootPath = intent?.getStringExtra(Info.ROOT_PATH)
    6. OOMFileManager.init(rootPath)
    7. kotlin.runCatching {
    8. buildIndex(hprofFile)
    9. }.onFailure {
    10. it.printStackTrace()
    11. MonitorLog.e(OOM_ANALYSIS_EXCEPTION_TAG, "build index exception " + it.message, true)
    12. resultReceiver?.send(AnalysisReceiver.RESULT_CODE_FAIL, null)
    13. return
    14. }
    15. buildJson(intent)
    16. kotlin.runCatching {
    17. filterLeakingObjects()
    18. }.onFailure {
    19. MonitorLog.i(OOM_ANALYSIS_EXCEPTION_TAG, "find leak objects exception " + it.message, true)
    20. resultReceiver?.send(AnalysisReceiver.RESULT_CODE_FAIL, null)
    21. return
    22. }
    23. kotlin.runCatching {
    24. findPathsToGcRoot()
    25. }.onFailure {
    26. it.printStackTrace()
    27. MonitorLog.i(OOM_ANALYSIS_EXCEPTION_TAG, "find gc path exception " + it.message, true)
    28. resultReceiver?.send(AnalysisReceiver.RESULT_CODE_FAIL, null)
    29. return
    30. }
    31. fillJsonFile(jsonFile)
    32. resultReceiver?.send(AnalysisReceiver.RESULT_CODE_OK, null)
    33. System.exit(0);
    34. }

    主要流程如下:

    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作者团队:@薛秋实 @李锐 @紫同

  • 相关阅读:
    C语言 switch语句
    Python基本功
    少年,你可知 Kotlin 协程最初的样子?
    Pytorch - 数据增广
    python 图像相减的不同方法
    MySQL 数据库开发中的 6 个“避免”
    【NODE.JS】多进程架构(二)—— 句柄传递
    AAAAAAAAA
    题目 1059: 二级C语言-等差数列
    软件测试环境讲解
  • 原文地址:https://blog.csdn.net/AA5279AA/article/details/126487993