美文网首页
从LeakCanary探究线上内存泄漏检测方案

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

作者: d卡普b | 来源:发表于2021-04-11 13:05 被阅读0次

    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监控对象分配,等咱们有实力了再学。

    相关文章

      网友评论

          本文标题:从LeakCanary探究线上内存泄漏检测方案

          本文链接:https://www.haomeiwen.com/subject/vpeakltx.html