LeakCanary(1)前传

序、慢慢来才是最快的方法。

背景

LeakCanary实现原理

LeakCanary是Square的开源库,通过弱引用方式侦查Activity或Fragment对象的生命周期,若发现内存泄漏自动 dump Hprof文件,通过HAHA库得到泄露的最短路径,最后通过Notification展示。

简单说就是在在Activity对象onDestory的时候,新建一个WeakReference对象指向Activity对象,如果Activity对象被垃圾回收的话,WeakReference对象就会进入引用序列的ReferenceQueue。

所以我们只需要在Activity对象OnDestory之后去查看ReferenceQueue序列是否有该WeakReference对象即可。

第一次观察是Activity的onDestory5秒后,如果发现ReferenceQueue对来还没有WeakReference对象,就进入第二次观察,如果有了,就证明没有泄漏,第二次观察跟第一次观察相比区别在于会先进行垃圾回收,在进行ReferenceQueue序列的观察。

LeakCanary版本1.x和2.x的区别

通过查看LeakCanary的文档,发现1.x和2.x的版本差异还是比较大的。算是一个比较大的升级。

  • 全部使用Kotlin重构
  • 分析堆文件从原先的HAHA库,被替换成速度快且内存占用低的Shark
  • 在1.x检测Anctivity和Fragment的基础上,还可以自定义观察内存对象
  • 2.x不需要手动初始化,直接一行依赖引入即可

涉及到的技术栈

内存泄漏:对象被持有导致无法释放或不能按照对象正常生命周期进行释放的。

内存泄露(Memory Leaks)指不再使用的对象或数据没有被回收,随着内存泄漏的堆积,应用性能会逐渐变差,甚至发生 OOM 奔溃。在 Android 应用中的内存泄漏可以分为 2 类:

  • Java 内存泄露: 不再使用的对象被生命周期更长的 GC Root 引用,无法被判定为垃圾对象而导致内存泄漏(LeakCanary 只能监控 Java 内存泄漏);
  • Native 内存泄露: Native 内存没有垃圾回收机制,未手动回收导致内存泄漏。

一:GC

1.回收哪里的垃圾

VM内存大致分为5个区,程序计数器,虚拟机栈,本地方法栈,堆,方法区。

程序计数器

顾名思义跟PC寄存器作用类似,每个线程独立存在,生命周期与线程一致。指示当前执行的方法,内存很小,忽略不计,没有垃圾。

虚拟机栈

栈空间,每个线程独立存在,保存方法参数或者方法内对象的引用。生命周期结束,比如方法执行完毕后内存会被释放,所以不需要垃圾管理。

本地方法栈

与虚拟机栈类似,对应native方法。不需要垃圾管理。

对象的实际存储区域,比如在方法内new一个局部变量,在堆开辟内存,引用保存在虚拟机栈。也是垃圾管理的最主要的区域。

方法区

class文件和常量(JDK7开始字符串常量池在堆区)存储区域,属于垃圾管理范围。

PS:其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,这几个区域内不需要过多考虑回收的问题。

堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

2.垃圾回收

引入计数法

记录每个引用的被引用个数,当引用个数为0时代表成为垃圾,应该被清理。

优点:

  • 实现简单
  • 引用减少至0时,实时回收内存,内存利用率高
  • 回收操作可并发运行,无需暂停应用线程

缺点:

  • 无法解决环状引用,如a引用b,b引用a。两个都是垃圾却不会被清理(过于致命)
  • Space overhead: 每个对象需要格外空间存储引用次数
  • Speed overhead: 每次引用修改需要增加指令,且多线程情况下需要加锁保证原子操作

可达性分析

JVM 把内存中所有的对象之间的引用关系看作一张图,通过一组名为”GC Root"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。

GC Root 对象

在 Java 中,有以下几种对象可以作为 GC Root:

  1. Java 虚拟机栈(局部变量表)中的引用的对象。
  2. 方法区中静态引用指向的对象。
  3. 仍处于存活状态中的线程对象。
  4. Native 方法中 JNI 引用的对象。

什么时候回收

同的虚拟机实现有着不同的 GC 实现机制,但是一般情况下每一种 GC 实现都会在以下两种情况下触发垃圾回收。

  1. Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次 GC。
  2. System.gc():在应用层,Java 开发工程师可以主动调用此 API 来请求一次 GC。

3.如何回收垃圾(三种基本的垃圾回收算法)

1.标记清除算法(Mark and Sweep GC)

从”GC Roots”集合开始,将内存整个遍历一次,保留所有可以被 GC Roots 直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,过程分两步。

  1. Mark 标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
  2. Sweep 清除阶段:当遍历完所有的 GC Root 之后,则将标记为垃圾的对象直接清除。

  • 优点:实现简单,不需要将对象进行移动。
  • 缺点:这个算法需要中断进程内其他组件的执行(stop the world),并且可能产生内存碎片,提高了垃圾回收的频率。

2.复制算法(Copying)

将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

  1. 复制算法之前,内存分为 A/B 两块,并且当前只使用内存 A:
  2. 标记完之后,所有可达对象都被按次序复制到内存 B 中,并设置 B 为当前使用中的内存。
  • 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
  • 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

3.标记-压缩算法 (Mark-Compact)

需要先从根节点开始对所有可达对象做一次标记,之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。因此标记压缩也分两步完成:

  1. Mark 标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
  2. Compact 压缩阶段:将剩余存活对象按顺序压缩到内存的某一端。
  • 优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
  • 缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上还是降低了效率。

4.JVM分代回收策略

Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代老年代,这就是 JVM 的内存分代策略。注意: 在 HotSpot 中除了新生代和老年代,还有永久代

分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将它们转移到老年代中。

年轻代(Young Generation)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的 GC 回收算法是复制算法。

新生代又可以继续细分为 3 部分:Eden、Survivor0(简称 S0)、Survivor1(简称S1)。这 3 部分按照 8:1:1 的比例来划分新生代。这 3 块区域的内存分配过程如下:

绝大多数刚刚被创建的对象会存放在 Eden 区。

当 Eden 区第一次满的时候,会进行垃圾回收。首先将 Eden区的垃圾对象回收清除,并将存活的对象复制到 S0,此时 S1是空的。

下一次 Eden 区满时,再执行一次垃圾回收。此次会将 Eden和 S0区中所有垃圾对象清除,并将存活对象复制到 S1,此时 S0变为空。

如此反复在 S0 和 S1之间切换几次(默认 15 次)之后,如果还有存活对象。说明这些对象的生命周期较长,则将它们转移到老年代中。

年老代(Old Generation)

一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或者大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。

我们可以使用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记压缩的回收算法。

永久代(Permanent Generation)或元空间(Metaspace)

永久代主要存放Java类和方法对象。从Java 8开始,永久代已被元空间替代。

二:四大引用

LeakCanary进行判断对象是否泄漏是通过新建一个WeakReference对象指向Activity对象,如果Activity对象被垃圾回收的话,WeakReference对象就会进入引用序列的ReferenceQueue。

所以我们只需要在Activity对象OnDestory之后去查看ReferenceQueue序列是否有该WeakReference对象即可。

1.强引用(StrongReference)

强引用是最常用的引用。如果一个对象具有强引用,那 GC(Gabage Collection,垃圾回收)绝不会回收它。

Object obj = new Object(); // 强引用

当内存空间不足,Java虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

2.软引用(SoftReference)

如果一个对象只具有软引用,在内存空间足够的时候,垃圾回收器是不会回收它的;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
PS:软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

3. 弱引用(WeakReference)

在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。

当你想引用一个对象,但是这个对象有自己的生命周期,你不想介入这个对象的生命周期,这时候你就可以使用弱引用。这个引用不会在对象的垃圾回收判断中产生任何附加的影响。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4.虚引用(PhantomReference)

虚引用与其他几种引用都不同,它并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否存在该对象的虚引用,来了解这个对象是否将要被回收。可以用来作为GC回收Object的标志。
 

PS:LeakCanary里面使用的就是弱引用(WeakReference)来管理监控对象的回收情况

示例代码:

  class KeyedWeakReference extends WeakReference {
        public String key;

        public KeyedWeakReference(Object referent, String key,
                                  ReferenceQueue referenceQueue) {
            super(referent, referenceQueue);
            this.key = key;
        }
    }


private void removeWeaklyReachableReferences() {
        Activity activity = new Activity();
        ReferenceQueue queue = new ReferenceQueue<>();
        String key = UUID.randomUUID().toString();
        retainedKeys.add(key);
        // 弱引用
        KeyedWeakReference weakRef = new KeyedWeakReference(activity,key, queue); 


        //弱引用引用的对象被垃圾回收的时候,Java虚拟机会把这个引用加入到之前传入的引用队列,
        //我们可以来判断这个队列是否有弱引用,有的话就移除集合的key。说明没有泄漏。
        KeyedWeakReference ref;
        while ((ref = (KeyedWeakReference) queue.poll()) != null) {
            retainedKeys.remove(ref.key);
        }

    } 
  

三:Handler的IdleHandler回调

Handler的IdleHandler是Android开发中用来处理消息队列空闲时的回调接口。当消息队列中,没有新的消息要处理时,IdleHandler将被调用。

IdleHandler的主要作用

异步处理任务,当主线程空闲时,可以利用IdleHandler在后台线程中执行耗时操作,避免阻塞主线程,提高应用程序的并发性能和响应速度。

也就是说在主线程中没有可处理的Message(.next无messge handler时)回去判断是否有IdleHandelr,如果有就会调用IdleHandler接口实现的方法。

在LeakCanary中的使用

在源码中发现,LeakCanary需要GC来确认是否存在内存泄漏,而GC会阻塞线程,所以在IdleHandler的接口实现里面做GC以及后续操作。

源码:

private void ensureGoneAsync(final long watchStartNanoTime, final KeyedWeakReference reference) {
    watchExecutor.execute(new Retryable() {
      @Override public Retryable.Result run() {
        return ensureGone(reference, watchStartNanoTime);
      }
    });
  }


 @Override public void execute(Retryable retryable) {
    if (Looper.getMainLooper().getThread() == Thread.currentThread()) {
      waitForIdle(retryable, 0);
    } else {
      postWaitForIdle(retryable, 0);
    }
  }

  private void waitForIdle(final Retryable retryable, final int failedAttempts) {
    // This needs to be called from the main thread.
    Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
      @Override public boolean queueIdle() {
        postToBackgroundWithDelay(retryable, failedAttempts);
        return false;
      }
    });
  }

四:HAHA库的使用

LeakCanary里面会获取hprof文件。通过

Debug.dumpHprofData(String fileName)

然后使用haha库来分析Android heap dumps的库。通过它解析获取引用链。

HAHA库的作用

  • Hprof(堆转存)文件的解析
  • 支配树的构建和Retained Size的计算
  • 最短路径构建

Hprof文件

是一份存储了当前时刻内存信息的文件,即内存快照,可以方便我们进行内存分析。

里面包含了类信息、栈信息、堆信息。其中堆信息是我们重点关注的重点。

堆信息里面包含了所有对象:线程对象,类对象,实例对象,对象数组对象,原始类型数组对象。

其中类对象可以获取该类的常量,静态变量,实例变量等信息。

代码展示

从获取hprof文件开始:AndroidHeapDumper的dumpHeap方发调用了Debug的方法

      Debug.dumpHprofData(heapDumpFile.getAbsolutePath());

分析Hprof文件是从ServiceHeapDumpListener开始

 @Override public void analyze(HeapDump heapDump) {
    checkNotNull(heapDump, "heapDump");
    HeapAnalyzerService.runAnalysis(context, heapDump, listenerServiceClass);
  }

HeapAnalyzerService是IntentService的子类。IntentService的特点是当启动IntentService多次时,每个耗时操作将以队列的方式在IntentService的onHandleIntent回调方法中依次执行,执行完自动结束。


  @Override protected void onHandleIntentInForeground(@Nullable Intent intent) {
    if (intent == null) {
      CanaryLog.d("HeapAnalyzerService received a null intent, ignoring.");
      return;
    }
    String listenerClassName = intent.getStringExtra(LISTENER_CLASS_EXTRA);
    HeapDump heapDump = (HeapDump) intent.getSerializableExtra(HEAPDUMP_EXTRA);

    HeapAnalyzer heapAnalyzer =
        new HeapAnalyzer(heapDump.excludedRefs, this, heapDump.reachabilityInspectorClasses);

    AnalysisResult result = heapAnalyzer.checkForLeak(heapDump.heapDumpFile, heapDump.referenceKey,
        heapDump.computeRetainedHeapSize);
    AbstractAnalysisResultService.sendResultToListener(this, listenerClassName, heapDump, result);
  }

heapAnalyzer.checkForLeak

 public AnalysisResult checkForLeak(File heapDumpFile, String referenceKey,
      boolean computeRetainedSize) {
    long analysisStartNanoTime = System.nanoTime();

        //.....省略部分代码
    try {
        //使用 MemoryMappedFileBuffer 和 HprofParser 分别来读取解析生成快照
        //
      HprofBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);
      HprofParser parser = new HprofParser(buffer);
      Snapshot snapshot = parser.parse();
      deduplicateGcRoots(snapshot);
      Instance leakingRef = findLeakingReference(referenceKey, snapshot);

      // False alarm, weak reference was cleared in between key check and heap dump.
      if (leakingRef == null) {
        return noLeak(since(analysisStartNanoTime));
      }
      return findLeakTrace(analysisStartNanoTime, snapshot, leakingRef, computeRetainedSize);
    } catch (Throwable e) {
      return failure(e, since(analysisStartNanoTime));
    }
  }

findLeakReference是查找引用调用链

private AnalysisResult findLeakTrace(long analysisStartNanoTime, Snapshot snapshot,
      Instance leakingRef, boolean computeRetainedSize) {

    ShortestPathFinder pathFinder = new ShortestPathFinder(excludedRefs);
    ShortestPathFinder.Result result = pathFinder.findPath(snapshot, leakingRef);

   
    LeakTrace leakTrace = buildLeakTrace(result.leakingNode);

    String className = leakingRef.getClassObj().getClassName();

    long retainedSize;
    if (computeRetainedSize) {

      listener.onProgressUpdate(COMPUTING_DOMINATORS);
      // Side effect: computes retained size.
      snapshot.computeDominators();

      Instance leakingInstance = result.leakingNode.instance;

      retainedSize = leakingInstance.getTotalRetainedSize();

      // TODO: check O sources and see what happened to android.graphics.Bitmap.mBuffer
      if (SDK_INT <= N_MR1) {
        listener.onProgressUpdate(COMPUTING_BITMAP_SIZE);
        retainedSize += computeIgnoredBitmapRetainedSize(snapshot, leakingInstance);
      }
    } else {
      retainedSize = AnalysisResult.RETAINED_HEAP_SKIPPED;
    }

    return leakDetected(result.excludingKnownLeaks, className, leakTrace, retainedSize,
        since(analysisStartNanoTime));
  }

最终leakTrace.toString 就是泄漏对象的调用链。

PS:之前在极客时间张绍文老师的课程中做过一个检测大Bitmap对象的练习。查找Bitmap对象和获取调用链的时候,仿照的就是LeakCanary分析获取调用链的逻辑。

 参考

Android 开源库 #7 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为它是真强啊!

被问到:如何检测线上内存泄漏,通过 LeakCanary 探究!
快手KOOM高性能线上解决方案

04 | 内存优化(下):内存优化这件事,应该从哪里着手?

Chapter04 Bitmap检测

你可能感兴趣的:(LeakCanary,android)