从LeakCanary探究线上内存泄漏检测方案

19年年末总结一篇《LeakCanary原理从0到1》,当时还比较满意,以为自己就比较了解这个框架了,Too young, Too Simple。

周五群里一个小伙伴问:“线上做内存泄漏检测大家有什么思路吗?”。

内存泄漏检测首先想到的是 LeakCanary,可以看看能从LeakCanary上找到一些思路吗?

本文并不是从0开始解释 LeakCanary 的工作原理,所以为了阅读体验更佳,还不太了解 LeakCanary 是怎样判定对象内存泄漏的读者,可以先从《LeakCanary原理从0到1》开始阅读。

本文将从内存泄漏后 LeakCanary 的后续工作开始讲起,分析 LeakCanary 是怎么找到泄漏对象的强引用链的,分析 LeakCanary 不能直接用于线上内存检测的原因,并尝试找出线上检测内存泄漏的一些思路。

生成Dump文件

在判定有内存泄漏后,「LeakCanary」调用将系统提供的Debug.dumpHprofData(File file)函数,改函数将生成一个虚拟机的内存快照,文件格式为 .hprof,这个dump文件大小通常有10+M。(本文中所说的dump文件都是指内存快照的.hprof文件)

//:RefWatcher.java 
    Retryable.Result ensureGone(final KeyedWeakReference reference, final long watchStartNanoTime)
      ...
      //内部调用Debug.dumpHprofData(File file)函数
      File heapDumpFile = heapDumper.dumpHeap(file);

      HeapDump heapDump = heapDumpBuilder.heapDumpFile(heapDumpFile)
          .referenceKey(reference.key)
          .build();

      heapdumpListener.analyze(heapDump);
      ....
      return DONE;
  }

生成dump文件后,LeakCanary 将被泄露对象的 referenceKeydump 文件 对象封装在 HeapDump 对象中,然后交给ServiceHeapDumpListener处理,在ServiceHeapDumpListener中创建 leakcanary 进程并启动服务 HeapAnalyzerService

解析Dump文件

dump 文件的解析工作是在HeapAnalyzerService中完成的,主要逻辑入下:

//HeapAnalyzerService.java

    //创建一个分析器
    HeapAnalyzer heapAnalyzer =
        new HeapAnalyzer(heapDump.excludedRefs, this, heapDump.reachabilityInspectorClasses);
    //使用分析器分析dump文件,得到分析结果
    AnalysisResult result = heapAnalyzer.checkForLeak(heapDump.heapDumpFile, heapDump.referenceKey,
        heapDump.computeRetainedHeapSize);
    //将分析结果交由listener组件处理
    AbstractAnalysisResultService.sendResultToListener(this, listenerClassName, heapDump, result);

下面继续跟踪分析器逻辑,主要查看 HeapAnalyzercheckForLeak 方法:

//: HeapAnalyer.java

  public AnalysisResult checkForLeak(File heapDumpFile, String referenceKey,
      boolean computeRetainedSize) {
      
      //读取dump文件,解析文件内容并生成一个Snapshot对象
      HprofBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);
      HprofParser parser = new HprofParser(buffer);
      Snapshot snapshot = parser.parse();
      
      //消除重复的GcRoots对象
      deduplicateGcRoots(snapshot);
      
      //通过referenceKey 在Snapshot对象中找到泄漏对象
      Instance leakingRef = findLeakingReference(referenceKey, snapshot);
      
      //找到泄漏路径
      return findLeakTrace(analysisStartNanoTime, snapshot, leakingRef, computeRetainedSize);
  }

checkForLeak方法中:

  1. 首先对dump文件的二进制数据进行解析,然后将文件内容信息存放在 Snapshot 对象当中,这种就可以从Snapshot中获得JVM的内存信息。(关于dump文件格式,有兴趣的可以点击这里,同时也可去看 square 的 com.squareup.haha:haha+,LeakCanary 使用的就是这个 dump 解析库)。
  2. 然后在 Snapshot 中类名为 KeyedWeakReferencereferenceKey 所对应的泄漏对象 Instence
  3. 最后在 Snapshot 中寻找泄漏对象 Instence 的泄漏强引用链

查找引用链

泄漏对象的引用链式如何被找到的呢?下面继续分析 findLeakTrace 方法:

//: HeapAnalyer.java

  private AnalysisResult findLeakTrace(long analysisStartNanoTime, Snapshot snapshot,
      Instance leakingRef, boolean computeRetainedSize) {
    //创建最短路径查找器
    ShortestPathFinder pathFinder = new ShortestPathFinder(excludedRefs);
    //使用查找器在snapshot中找到被泄露实例节点
    ShortestPathFinder.Result result = pathFinder.findPath(snapshot, leakingRef);
    
    //利用节点信息构造最短引用链
    LeakTrace leakTrace = buildLeakTrace(result.leakingNode);
    
    String className = leakingRef.getClassObj().getClassName();
    long retainedSize = AnalysisResult.RETAINED_HEAP_SKIPPED;
    //将泄漏实例的节点信息封装在一个AnalysisResult对象中并返回
    return leakDetected(result.excludingKnownLeaks, className, leakTrace, retainedSize,
        since(analysisStartNanoTime));
  }

打开 ShortestPathFinderfindPath 函数,很容易看出其作用就是对每个GcRoot的引用链的堆结构进行BFS遍历,然后将泄漏实例所在节点包装在一个 Result 中并返回。

//: ShortestPathFinder.java

  Result findPath(Snapshot snapshot, Instance leakingRef) {
    // 将所有的GcRoot节点加入队列中
    enqueueGcRoots(snapshot);

    LeakNode leakingNode = null;
    while (!toVisitQueue.isEmpty() || !toVisitIfNoPathQueue.isEmpty()) {
      LeakNode node;
      if (!toVisitQueue.isEmpty()) {
        node = toVisitQueue.poll();
      }

      // 找到实例,结束遍历
      if (node.instance == leakingRef) {
        leakingNode = node;
        break;
      }
      //重复检查
      if (checkSeen(node)) {
        continue;
      }
      //在visit中将节点与其父节点进行绑定
      if (node.instance instanceof RootObj) {
        visitRootObj(node);
      } else if (node.instance instanceof ClassObj) {
        visitClassObj(node);
      } else if (node.instance instanceof ClassInstance) {
        visitClassInstance(node);
      } else if (node.instance instanceof ArrayInstance) {
        visitArrayInstance(node);
      } else {
        throw new IllegalStateException("Unexpected type for " + node.instance);
      }
    }
    return new Result(leakingNode, excludingKnownLeaks);
  }

接着看,在拿到泄漏对象节点后如何创建最短路径引用链呢?

  private LeakTrace buildLeakTrace(LeakNode leakingNode) {
    List<LeakTraceElement> elements = new ArrayList<>();
    // We iterate from the leak to the GC root
    LeakNode node = new LeakNode(null, null, leakingNode, null);
    //从泄漏节点开始,自下而上将节点信息逆序加入list当中
    while (node != null) {
      LeakTraceElement element = buildLeakElement(node);
      if (element != null) {
        elements.add(0, element);
      }
      node = node.parent;
    }

    List<Reachability> expectedReachability =
        computeExpectedReachability(elements);
    return new LeakTrace(elements, expectedReachability);
  }

至此,泄漏对象的最短引用链已找出。最后程序使用 AnalysisResult对象对最短引用链的信息进行保存并返回。

Listener组件

在分析的第一步,我们已经看到分析结果 AnalysisResult 将交由一个 listener 组件处理,这个组件便是 DisplayLeakService ,在 DisplayLeakService 中有一段比较关键的代码:

  @Override protected final void onHeapAnalyzed(HeapDump heapDump, AnalysisResult result) {
    //对dump文件进行重命名
    heapDump = renameHeapdump(heapDump);
    //将AnalysisResult对象保存在xxx.hprof.result文件中
    resultSaved = saveResult(heapDump, result);
    ...
    PendingIntent = DisplayLeakActivity.createPendingIntent(this, heapDump.referenceKey);
    // New notification id every second.
    int notificationId = (int) (SystemClock.uptimeMillis() / 1000);
    showNotification(this, contentTitle, contentText, pendingIntent, notificationId);
}

  private boolean saveResult(HeapDump heapDump, AnalysisResult result) {
    File resultFile = new File(heapDump.heapDumpFile.getParentFile(),
        heapDump.heapDumpFile.getName() + ".result");
    FileOutputStream fos = null;
    try {
      fos = new FileOutputStream(resultFile);
      ObjectOutputStream oos = new ObjectOutputStream(fos);
      oos.writeObject(heapDump);
      oos.writeObject(result);
      return true;
    } catch (IOException e) {
      CanaryLog.d(e, "Could not save leak analysis result to disk.");
    } finally {
      if (fos != null) {
        try {
          fos.close();
        } catch (IOException ignored) {
        }
      }
    }
    return false;
  }

在服务组件中,AnalysisResult对象被写进了 xxx.hprof.result 文件中。同时服务将显示一个 Notification,在 Notification 点击后将通过 DisplayLeakActivity 显示泄漏信息。

泄漏引用链的显示

最后,看看我们平时看到的 DisplayLeakActivity 是如何显示泄漏对象的引用链的。(也许看到这里大家也能才出来了)

//:DisplayLeakActivity.java

  @Override protected void onResume() {
    super.onResume();
    LoadLeaks.load(this, getLeakDirectoryProvider(this));
  }

再看看 LoadLeaks#load();

//: LoadLeaks.java
//LoadLeaks是runnable的子类
    
    static final List<LoadLeaks> inFlight = new ArrayList<>();
    static final Executor backgroundExecutor = newSingleThreadExecutor("LoadLeaks");
    
    static void load(DisplayLeakActivity activity, LeakDirectoryProvider leakDirectoryProvider) {
      LoadLeaks loadLeaks = new LoadLeaks(activity, leakDirectoryProvider);
      inFlight.add(loadLeaks);
      backgroundExecutor.execute(loadLeaks);
    }
    
      @Override public void run() {
      final List<Leak> leaks = new ArrayList<>();
      List<File> files = leakDirectoryProvider.listFiles(new FilenameFilter() {
        @Override public boolean accept(File dir, String filename) {
          return filename.endsWith(".result");
        }
      });
      for (File resultFile : files) {
        FileInputStream fis = new FileInputStream(resultFile);
        ObjectInputStream ois = new ObjectInputStream(fis);
        HeapDump heapDump = (HeapDump) ois.readObject();
        AnalysisResult result = (AnalysisResult) ois.readObject();
        leaks.add(new Leak(heapDump, result, resultFile));
     
        mainHandler.post(new Runnable() {
        @Override public void run() {
          inFlight.remove(LoadLeaks.this);
          if (activityOrNull != null) {
            activityOrNull.leaks = leaks;
            activityOrNull.updateUi();
          }
        }
      });

DisplayLeakActivityonResume 方法中,使用线程池读取所有的 xxx.prof.result 文件中的 AnalysisResult 对象,并使用 handler#post() 在主线程将它们加入到 Activity的成员变量 leaks 中,同时刷新 Activity 界面。

在点击删除按钮时.hprof文件与.hprof.result文件将被删除;

  void deleteVisibleLeak() {
    final Leak visibleLeak = getVisibleLeak();
    AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() {
      @Override public void run() {
        File heapDumpFile = visibleLeak.heapDump.heapDumpFile;
        File resultFile = visibleLeak.resultFile;
        boolean resultDeleted = resultFile.delete();
        if (!resultDeleted) {
          CanaryLog.d("Could not delete result file %s", resultFile.getPath());
        }
        boolean heapDumpDeleted = heapDumpFile.delete();
        if (!heapDumpDeleted) {
          CanaryLog.d("Could not delete heap dump file %s", heapDumpFile.getPath());
        }
      }
    });
    visibleLeakRefKey = null;
    leaks.remove(visibleLeak);
    updateUi();
  }

  void deleteAllLeaks() {
    final LeakDirectoryProvider leakDirectoryProvider = getLeakDirectoryProvider(this);
    AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() {
      @Override public void run() {
        leakDirectoryProvider.clearLeakDirectory();
      }
    });
    leaks = Collections.emptyList();
    updateUi();
  }

为了达到较好的显示效果,显示时会对引用链当中的节点信息进行格式上的美化,将字符串拼接成 html 格式显示, 具体逻辑可查看 DisplayLeakAdapter类。

总结

LeakCanary在判定有内存泄漏时,首先会生成一个内存快照文件(.hprof文件),这个快照文件通常有10+M,然后根据 referenceKey 找出泄漏泄漏实例,再在快照堆中使用BFS找到实例所在的节点,并以此节点信息反向生成最小引用链。在生成引用链后,将其保存在 AnalysisResult 对象当中,然后将AnalysisResult对象写入.hporf.result文件,此时的.hprof.result文件只有几十KB大小。最后,在 DisplayLeakActivityonResume 中读取所有的 .hprof.result文件并显示在界面上。

拓展

LeakCanary 直接在线上使用会有什么样的问题,如何改进呢?

理解了 LeakCanary 判定对象泄漏后所做的工作后就不难知道,直接将 LeakCanary 应用于线上会有如下一些问题:

  1. 每次内存泄漏以后,都会生成一个.hprof文件,然后解析,并将结果写入.hprof.result。频繁增加,引起手机卡顿等问题。
  2. 同样的泄漏问题,会重复生成 .hprof 文件,重复分析并写入磁盘。
  3. .hprof文件较大,信息回捞成问题。

那应该如何解决上述问题呢?

  1. 可以根据手机信息来设定一个内存阈值 M ,当已使用内存小于 M 时,如果此时有内存泄漏,只将泄漏对象的信息放入内存当中保存,不生成.hprof文件。当已使用大于 M 时,生成.hprof文件;当然,也可以判断泄漏对象数量大于某个规定的数值时,生成并分析.hprof文件并分析,此时的分析结果应当包含一个或多个泄漏对象引用链的信息。
  2. 当引用链路相同时,根据实际情况看去重。
  3. 不直接回捞.hprof文件,可以选择回捞.hprof.result文件,或者在本地对.hprof.result进行进一步的整合、去重与优化后再回捞。

袋鼠水平有限,如果看官们较好的思路可以在评论中进行讨论。文章的不足多多包含,希望各位不吝赐教。
另外内存优化上,高级的JVMTI监控对象分配,等咱们有实力了再学。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,123评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,031评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,723评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,357评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,412评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,760评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,904评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,672评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,118评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,456评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,599评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,264评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,857评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,731评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,956评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,286评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,465评论 2 348

推荐阅读更多精彩内容