内存泄漏框架解读

2,830 阅读14分钟

在我们日常Android开发中,大多数情况下不会去过分考虑内存释放的问题,这得益于Java虚拟机优秀的内存管理。但是很多时候开发者会不小心new出Java虚拟机释放不了对象,这样如果持续使用应用会造成内存不断增长,频繁的GC,应用会变得很卡。

内存泄漏

用一句话总结内存泄漏:长生命周期持有短生命周期对象的引用,使短周期对象不得已释放。

常见泄漏原因
  • 单例造成泄漏
  • 非静态匿名内部类
  • 线程(异步任务)
  • 资源未释放 :File,Cursor,Stream,Bitmap 等资源
  • Context的不正确使用
  • 集合中对象没清理,尤其注意static的集合

泄漏分析框架解读

在团队开发中,出现内存泄漏问题的原因有很多,并不是我们时刻注意就能避免的,但是,在出现内存泄漏时我们能准确的定位排查问题,是比较重要的。在本文介绍两款内存泄漏监控框架来提高效率。

框架介绍

本文涉及的源码基于LeakCanary2.0、KOOM1.0.5:github.com/square/leak…github.com/KwaiAppTeam…

LeakCanary

LeakCanary是square公司开源的只限于debug时期内存泄漏检测工具,比起Android Studio自带的Profile 、MAT工具,LeakCannary泄漏时展示的数据更加直观。 在1.0的正式版中,需要我们编写一些代码用于注册app,而且在分析内存泄漏数据时也是比较慢。在2.0正式版中带来了全新的内存分析工具shark。更快更高效一些。LeakCanary触发dump堆栈hprof数据的条件是activity/fragment执行完成后本应该被回收的却没有被回收。

KOOM

KOOM快手开源的一款主攻线上内存泄漏的监控框架,KOOM触发dump堆栈hprof数据的条件是内存使用率的某个阈值,在dump数据时采用了Fork子进程的方式来采集。完美解决appd冻结问题。

源码学习

建议下载源码,更好的了解实现原理。

LeakCanary

LeakCanary利用了ContentProvider的随进程启动实例化的特性实现无感知初始化。整个初始化流程位于 InternalAppWatcher 的install方法:

fun install(application: Application) {
    ...
    ActivityDestroyWatcher.install(application, objectWatcher, configProvider)
    FragmentDestroyWatcher.install(application, objectWatcher, configProvider)
    onAppWatcherInstalled(application)
  }

可以看到,这一步完成了对activity和fragment的监听。在LeakCanary看来,只有activity和fragment这两个交互最频繁的组件发生泄漏才重要的。我们很清楚activity/fragment的生命周期发生中的ondestory执行意味着此组件的消亡,内存回收器就可以将其回收。是不是可以回收是objectWatcher去判断的,稍后再说。

onAppWatcherInstalled(application)是一个(Application) -> Unit接口,它的实现在internalLeakCanary的初始化时,通过反射拿到的。

 init {
    val internalLeakCanary = try {
      val leakCanaryListener = Class.forName("leakcanary.internal.InternalLeakCanary")
      leakCanaryListener.getDeclaredField("INSTANCE")
          .get(null)
    } catch (ignored: Throwable) {
      NoLeakCanary
    }
    @kotlin.Suppress("UNCHECKED_CAST")
    onAppWatcherInstalled = internalLeakCanary as (Application) -> Unit
  }

在InternalLeakCanary的invoke方法中,我们可以看到:职责主要是接收来自objectWatcher的 “存在尚未销毁的对象” 的消息并驱动HeapDumpTrigger 进行堆转储(dump heap),分析内存快照并发布最终的结果。在后面将会对内存结果分析做介绍。

verride fun invoke(application: Application) {
    // 注册监听对象可能泄漏的消息
    AppWatcher.objectWatcher.addOnObjectRetainedListener(this)
	...
    // 实例化核心类,
    heapDumpTrigger = HeapDumpTrigger(
        application, backgroundHandler, AppWatcher.objectWatcher, gcTrigger, heapDumper,
        configProvider
    )
    ...
    // 添加Shortcut快捷入口
    addDynamicShortcut(application)
	...
}

回过头来,LeakCanary是如何判断泄漏的。

objectWatcher具体方案是利用弱引用 WeakReference 的特性来实现的。在Java四种引用方式中,除了强引用,其他三种引用方式都是可以关联一个ReferenceQueue来实现的,具体的原因是,这三种引用会被Java虚拟机自动回收掉,我们可以在在关联的ReferenceQueue中再一次将其赋值而保证此对象不被回收。而LeakCanary就是利用这个特性来确定对象是都真正的被回收。在这里使用弱引用的原因:比起软引用必须等到内存不够用时才释放对没有泄露的对象更友好(能在一次GC中回收),比起虚引用无法get()对象实体更有价值(可以获取泄露对象的一些属性)。

对于销毁的组件,如果能正常入队就表示确被回收反之可初步认为发生了泄漏。但是GC机制是主动的,因此对象什么时候被回收是无法确定的,LeakCanary采取了默认等待5s再判断所watch的对象是否被回收的方案来获得一个可能结果,虽然是一个佛系数字,但正常的GC运行还是使得在给定的时间内大部分情况下对象都得到了回收,有效减少了堆转储的必要,毕竟是一个耗费资源和影响体验的操作:

private val checkRetainedExecutor = Executor {// 主线程post一个5s延迟消息
    mainHandler.postDelayed(it, AppWatcher.config.watchDurationMillis)
}
// ObjectWatcher
@Synchronized fun watch(watchedObject: Any, name: String) {
    ...
    val key = UUID.randomUUID().toString()
    val reference = KeyedWeakReference(watchedObject, key, name, watchUptimeMillis, queue)
    watchedObjects[key] = reference//被观察者的对象
    checkRetainedExecutor.execute {// postDelayed
        moveToRetained(key)
    }
}

通过上述代码,我们可以看到watchedObjects存储的是被观察者对象的信息,每个被watch的对象每过5s就会发送一个moveToRetained事件去检查,引用队列中是不是有被watch的对象,如果有,将此对象从watchedObjects中移除,就认为此对象不从在泄漏。但是遗留在watchedObjects的对象就一定泄漏吗?其实也不然,我们无法保证系统GC刚好发生在moveToRetained时间之前。下面看看没有回收的对象是如何继续处理的。

在InternalLeakCanary中,对于这些尚未被回收的对象,转交给HeapDumpTrigger做进一步判定。

private fun checkRetainedObjects(reason: String) {
    ...
    if (retainedReferenceCount > 0) {
        gcTrigger.runGc() // 0: 内部Runtime.getRuntime().gc()并Thread.sleep(100)
        retainedReferenceCount = objectWatcher.retainedObjectCount
    }
    if (checkRetainedCount(retainedReferenceCount, config.retainedVisibleThreshold)) return
	... // 1
    val heapDumpFile = heapDumper.dumpHeap()
    ... // 2
    HeapAnalyzerService.runAnalysis(application, heapDumpFile)
}

在dump数据之前,先手动触发一次系统GC,这样就可以排除之前我们的顾虑。在GC以后,再次去检测,如果watchedObjects中对象数量还是大于0的,那么就说明存在泄漏。接下来就通过调用系统Debug.dumpHprofData(path)去dump内存,完成dump后分析数据。分析数据和KOOM类似,都是使用了Shark,具体操作可以看KOOM的分析过程。

KOOM

KOOM的初始化很简单,通过init方法我们就能开始学习探索。

public static void init(Application application) {
    KLog.init(new KLog.DefaultLogger());
    if (inited) {
      KLog.i(TAG, "already init!");
      return;
    }
    inited = true;

    if (koom == null) {
      koom = new KOOM(application);
    }

    koom.start();
  }

其实KOOM就是一个包装类,主要的start等方法都是交给KOOMInternal去实现的,包括我们设置的监听事件,dump的数据存储路劲等。接下来看KOOMInternal

 public KOOMInternal(Application application) {
    KUtils.startup();

    buildConfig(application);

    heapDumpTrigger = new HeapDumpTrigger();
    heapAnalysisTrigger = new HeapAnalysisTrigger();

    ProcessLifecycleOwner.get().getLifecycle().addObserver(heapAnalysisTrigger);
  }

第一步设置了初始化开始的时间,并完成了相应的config配置,最主要的是初始化了两个对象,HeapDumpTrigger用来dump堆的监听的者,HeapAnalysisTrigger用来分析堆数据的监听者。接下来设置了一个application生命周期的监控,监控了应用是否处于前台,当处于前台时才出发HeapAnalysisTrigger,当处于后台时不触发。真正开始的地方是start方法。

 public void start() {
    HandlerThread koomThread = new HandlerThread("koom");
    koomThread.start();
    koomHandler = new Handler(koomThread.getLooper());
    startInKOOMThread();
  }

借助HandlerThread初始化了一个非主线程的Handler,通过这个handler发送了一个延迟了10s的开始运行的事件。这个事件主要是去检查是否需要且可以启动监控事件,不能启动的条件有:版本权限(大于21小于29),磁盘空间(大于5G),日期限制(大于15天),触发次数等。检查完以后进一步判断dump的文件是否还存在,如果存在那么就直接分析此文件,不存在的话开启监控者(name为MonitorThread的HandleThread),同时这是dump堆内存的监听事件。具体Runnable执行的代码如下:


    @Override
    public void run() {
      ...

      if (monitor.isTrigger()) {
        ...
      }

      if (!stop) {
        handler.postDelayed(this, monitor.pollInterval());
      }
    }

主要是判断是不是触发dump时间,触发的条件是:

@Override
  public boolean isTrigger() {
   ...
    HeapStatus heapStatus = currentHeapStatus();//获取当前heap信息
...
    if (heapStatus.isOverThreshold) {
      if (heapThreshold.ascending()) {
        if (lastHeapStatus == null || heapStatus.used >= lastHeapStatus.used) {
          currentTimes++;
        } else {
          currentTimes = 0;
        }
      } else {
        currentTimes++;
      }
    } else {
      currentTimes = 0;
    }

    lastHeapStatus = heapStatus;
    return currentTimes >= heapThreshold.overTimes();
  }

第一步:判断当前内存占用率是否超过我们设置的阈值,默认阈值是堆内存使用的85%。第二步:如果没有超过,将之前的超过次数恢复,如果超过,判断堆内存的使用率的变化,如果这次堆内存使用率大与等于上次使用率,并且超过次数累计(累计次数大与3(默认)次),可以dump数据。 了解了dump数据的触发条件,现在我们来开koom是如何dump堆hprof文件而不冷冻app的呢?回到HeapDumpTriggertrigger方法:

 @Override
  public void trigger(TriggerReason reason) {
    ...
    try {
      doHeapDump(reason.dumpReason);
    } catch (Exception e) {
      ...
    }
    ...
  }

trigger是dump数据的入口方法,设置了下次不再触发dump的标志位,并做了基本的触发回调。调用doHeapDump开始创建文件,设置dump原因等

boolean res = heapDumper.dump(KHeapFile.getKHeapFile().hprof.path);
@Override
  public boolean dump(String path) {
    if (!soLoaded) {
      return false;
    }

    if (!KOOMEnableChecker.get().isVersionPermit()) {
      return false;
    }

    if (!KOOMEnableChecker.get().isSpaceEnough()) {
      return false;
    }

    boolean dumpRes = false;
    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 (IOException e) {
      e.printStackTrace();
      KLog.e(TAG, "dump failed caused by IOException!");
    }
    return dumpRes;
  }

在看代码前先来了解linux中frok子进程的知识:Linux fork子进程有一个著名的COW(Copy-on-write,写时复制)机制,即为了节省fork子进程的内存消耗和耗时,fork出的子进程并不会copy父进程的内存,而是和父进程共享内存空间。父子进程的隔离是发生在运行期间,父子进程只在发生内存写入操作时,系统才会分配新的内存为写入方保留单独的拷贝,这就相当于子进程保留了fork瞬间时父进程的内存镜像,且后续父进程对内存的修改不会影响子进程。frok操作会返回一个pid看来标识进程号。pid==0标识当前运行进程为子进程,反之为父进程。

继续学习上述代码,可以看到除了一些基本的判断之后,KOOM为了防止dump数据而造成app的冻结,通过frok子进程去dump数据,而不影响父进程的正常执行。因为虚拟机内部实现是在dump数据时,会暂停所有的Java线程的操作。暂停Java线程主要目的是为了防止在dump过程中Java堆发生变化而造成数据不准确。 我们可以从上述代码中看到,子进程执行的是dump数据的操作,那么父进程通过 resumeVM()方法来唤醒JVM,这个操作其实是因为: dump前需要暂停所有java线程,而子进程只保留父进程执行fork操作的线程,在子进程中执行SuspendAll触发暂停是永远等不到其他线程返回结果,因此KOOM在frok子进程之前先暂停了父进程中的所有线程来保证子进程完全符合dump的条件。所以在父线程执行开始时需要唤醒所有的线程状态来执行。

等待dump数据后返回。dump成功后,通过触发HeapAnalysisTrigger来开始hprof文件的分析。HeapAnalysisTrigger是在分析hprof文件前初始化一些数据,并改变一些标志位等操作,真正的分析hprof文件交给了HeapAnalyzeService这个远程进程。

 /**
   * run in the heap_analysis process
   */
  private boolean doAnalyze() {
    return heapAnalyzer.analyze();
  }

 public boolean analyze() {
    KLog.i(TAG, "analyze");
    Pair<List<ApplicationLeak>, List<LibraryLeak>> leaks = leaksFinder.find();
    if (leaks == null) {
      return false;
    }

    //Add gc path to report file.
    HeapAnalyzeReporter.addGCPath(leaks, leaksFinder.leakReasonTable);

    //Add done flag to report file.
    HeapAnalyzeReporter.done();
    return true;
  }

在分析解析代码前,我们先阅读下文shark框架的实现原理:shark。LeakCanary2.0的数据分析大体结构和KOOM类似,都是通过使用shark框架来完成

上述代码主要分四步来看。 第一步buildIndex():初始化 初始化阶段也就对应的是Shark中将dump的Hprof文件转化为HprofGraph的阶段,在此同时也初始化了GCRoot。

private boolean buildIndex() {
    ...
    Hprof hprof = Hprof.Companion.open(hprofFile.file());
    KClass<GcRoot>[] gcRoots = new KClass[]{
        Reflection.getOrCreateKotlinClass(GcRoot.JniGlobal.class),
 ...
      Reflection.getOrCreateKotlinClass(GcRoot.JniMonitor.class)};
    heapGraph = HprofHeapGraph.Companion.indexHprof(hprof, null,
        kotlin.collections.SetsKt.setOf(gcRoots));

    return true;
  }

第二步initLeakDetectors 泄漏规则条件判断逻辑对象初始化 初始化activity、fragment、bitmap等泄漏判断规则。 第三步findLeaks() 泄漏判断 通过循环遍历heapGraph并使用第二步中初始化的规则进行判断,当前类型对象是否符合泄漏规则。

 public void findLeaks() {
    KLog.i(TAG, "start find leaks");
    //遍历镜像的所有instance
    Sequence<HeapObject.HeapInstance> instances = heapGraph.getInstances();
    Iterator<HeapObject.HeapInstance> instanceIterator = instances.iterator();

    while (instanceIterator.hasNext()) {
      HeapObject.HeapInstance instance = instanceIterator.next();
      if (instance.isPrimitiveWrapper()) {
        continue;
      }

      ClassHierarchyFetcher.process(instance.getInstanceClassId(),
          instance.getInstanceClass().getClassHierarchy());

      for (LeakDetector leakDetector : leakDetectors) {
        if (leakDetector.isSubClass(instance.getInstanceClassId())
            && leakDetector.isLeak(instance)) {
          ClassCounter classCounter = leakDetector.instanceCount();
          if (classCounter.leakInstancesCount <=
              SAME_CLASS_LEAK_OBJECT_GC_PATH_THRESHOLD) {
            leakingObjects.add(instance.getObjectId());
            leakReasonTable.put(instance.getObjectId(), leakDetector.leakReason());
          }
        }
      }
    }

    //关注class和对应instance数量,加入json
    HeapAnalyzeReporter.addClassInfo(leakDetectors);

    findPrimitiveArrayLeaks();
    findObjectArrayLeaks();
  }

第四步findPath() 泄漏路径处理 路径处理同Shark的路径处理。

到目前KOOM的核心逻辑就处理完了,文件的存储是通过json的形式存储在项目的缓存路径下(如果没有改变文件存储路劲)思路相对比较简单,不展开讨论了。

Shark

shark是square团队开发的一款全新的分析hprof文件的工具,其官方宣布比Android Studio用于memory profiler的核心库perflib 要快8倍并且内存占用少10倍,更加适合手机端的分析工具。其目的就是提供快速解析hprof文件和分析快照的能力,并找出真正的泄漏对象以及对象到GcRoot的最短引用路径链,以便帮助开发者更加直观的找出泄漏的真正原因。 由此可知,先前初步分析得到的泄漏对象与这里的堆分析和得到的真正泄漏对象并没有依赖关系,先前的初步分析仅仅只是一个触发堆转储和分析操作的过滤。 hprof文件的标准协议主要由head和body组成,head包含一些元信息,例如文件协议的版本、开始的时间戳等。body则是由一系列不同类型的Record组成,Record主要用于描述trace、object(实例 / 数组 / 类 / …)、thread等信息,依次分为4个部分:TAG、TIMESTAMP、LENGTH、BODY,其中TAG就是表示Record类型,LENGTH用于指示BODY的长度。Record之间依次排列或嵌套,最终组成hprof文件。 在shark中,通过将hprof文件装换成HprofRecord对象然后进行后面的解析处理。封装的HprofRecord则是上述所提到的Record,解析过程代码如下:

fun readHprofRecords(
    recordTypes: Set<KClass<out HprofRecord>>, 
    listener: OnHprofRecordListener
  ) {
    ...
    while (!exhausted()) { 
	  ...
      when (tag) {
          STRING_IN_UTF8 -> {...}
          ...
          HEAP_DUMP, HEAP_DUMP_SEGMENT -> {
              val heapDumpTag = readUnsignedByte()
              when (heapDumpTag) {
                  ...
                  ROOT_THREAD_OBJECT -> {
                    if (readGcRootRecord) {
                      val recordPosition = position
                      val gcRootRecord = GcRootRecord(
                          gcRoot = ThreadObject(
                              id = readId(),
                              threadSerialNumber = readInt(),
                              stackTraceSerialNumber = readInt()
                          )
                      )
                      listener.onHprofRecord(recordPosition, gcRootRecord)
                    } else {
                      skip(identifierByteSize + intByteSize + intByteSize)
                    }
                  }
                  ...
              }
              ...
          }
          ...
      }
    }

readHprofRecords就是一个遵从协议实现的通用提取器,Record的类型有很多,通过指定关心的record类型和回调接口就可以将hprof文件转化为Record对象集合。解析得到的records被进一步抽象为HprofMemoryIndex,Index的作用就是就是将得到的record按类型进行归类和计数,并通过特定规则进行排序;最终Index和Hprof一起再组成HprofGraph,graph做为hprof的最上层描述,将所有堆中数据抽象为了 gcRoots、objects、classes、instances 4种集合,并提供了快速定位dump堆中具体对象的能力。接下来我们看findLeaks的具体流程:

第一步:findLeakingObjectIds

有了HprofGraph之后接下来就要去寻找泄漏对象,shark的实现逻辑是通过遍历graph提供的Object集合通过特定规则过滤之后得到一组对象id的set集合。

在Android源代码中其实我们可以看到当执行系统dump时是会将一些类的字段写入到hprof文件中,而我们可以通过写入的这些字段得值去查看查看当前对象是否泄漏,LeakCanary通过我开发经验和知识总结了可能出现泄漏的地方如下:

  • android.widget.View间接引用的Activity的mDestroyed字段;
  • android.widget.Editor内部的mTextView字段;
  • android.app.Activity的mDestoryed字段;
  • android.content.ContextWrapper间接引用的Activity的mDestroyed字段;
  • android.app.Dialog的mDecor字段;
  • Fragment的mFragmentManager字段;
  • android.os.MessageQueue的mQuitting字段;
  • android.view.ViewRootImpl的mView字段;
  • android.view.Window的mDestroyed字段;
  • android.widget.Toast中的mTN对象内部的mWM字段和mView字段;

当然我们可以通过LeakingObjectFinder接口实现我们自己的过滤逻辑,来完成自己对泄漏对象的过滤。

第二步:findPathsFromGcRoots 抽象GcRoot为node节点并入队,作为队列数据结构的根节点,开始从GcRoot为起点采用广度优先遍历算法向下查找相应的泄漏对象,针对Class、Instance、array类型采取不同处理模式。当找到目标泄漏对象时所经历的路径即为泄漏路径。

  private fun State.findPathsFromGcRoots(): PathFindingResults {
  enqueueGcRoots()
    ...
      when (val heapObject = graph.findObjectById(node.objectId)) {
        is HeapClass -> visitClassRecord(heapObject, node)
        is HeapInstance -> visitInstance(heapObject, node)
        is HeapObjectArray -> visitObjectArray(heapObject, node)
      }
    }
    return PathFindingResults(shortestPathsToLeakingObjects, dominatedObjectIds)
  }

第三步:buildLeakTraces 一个对象被多个对象引用是很常见的行为,前面得到的引用路径集合泄漏对象和GcRoot之间可能存在多条路径,为了更利于分析,还需要进行裁剪。裁剪的过程并不复杂,首先先将路径链反转为从GcRoot到泄漏对象的方向,然后通过updateTrie方法转化为一个以无效node为根节点的树,最后再通过 深度优先遍历 算法得到从根节点(无效node的children)到叶子节点的所有路径,即为最终的最短路径。其中裁剪的逻辑就发生在构造树的过程中:

private fun deduplicateShortestPaths(inputPathResults: List<ReferencePathNode>): List<ReferencePathNode> {
    val rootTrieNode = ParentNode(0)
    for (pathNode in inputPathResults) {
        ...// 这里的path是已经反转后的路径
        updateTrie(pathNode, path, 0, rootTrieNode)
    }
    ...
}
private fun updateTrie(pathNode: ReferencePathNode, path: List<Long>, pathIndex: Int, parentNode: ParentNode) {
    val objectId = path[pathIndex]
    if (pathIndex == path.lastIndex) {
        // 1
        parentNode.children[objectId] = LeafNode(objectId, pathNode)
    } else {
        val childNode = parentNode.children[objectId] ?: {
            val newChildNode = ParentNode(objectId)
            parentNode.children[objectId] = newChildNode
            newChildNode
        }()
        if (childNode is ParentNode) {
            updateTrie(pathNode, path, pathIndex + 1, childNode)
        }
    }
}

ok,shark的主要逻辑处理完成。

总结

通过阅读和学习上述两个框架,清楚的看出两者之间的不同:

  • KOOM 并没有正对某个对象或者类去实现监控,而是在某个特定的时机就进行系统的dump 然后完成数据的解析分析,这种处理优点在于:可以监控多种不同的内存泄漏类型。其次它采用frok子进程的方式,对性能影响极小,可用于线上内存泄漏的监控
  • LeakCanary 采用对activity和fragment的onDestory方法的监控,局限性较大,需要出发系统GC性能影响较大,但是可以做到较为实时,一旦出现泄漏可以及时上报。

如果将KOOM和Leancanary思想结合,对activity/fragment进行监控,和对堆内存大小同时监控,如果监控到activity/fragment的泄漏或内存数值到达阈值采用frok子进程dump堆hprof文件分析上报,那样就可以达到更好的效果。