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 将被泄露对象的 referenceKey 与 dump 文件 对象封装在 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);
下面继续跟踪分析器逻辑,主要查看 HeapAnalyzer
的 checkForLeak
方法:
//: 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
方法中:
- 首先对dump文件的二进制数据进行解析,然后将文件内容信息存放在
Snapshot
对象当中,这种就可以从Snapshot
中获得JVM的内存信息。(关于dump文件格式,有兴趣的可以点击这里,同时也可去看 square 的com.squareup.haha:haha+
,LeakCanary 使用的就是这个 dump 解析库)。 - 然后在
Snapshot
中类名为KeyedWeakReference
且referenceKey
所对应的泄漏对象Instence
。 - 最后在
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));
}
打开 ShortestPathFinder
的 findPath
函数,很容易看出其作用就是对每个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();
}
}
});
DisplayLeakActivity
在 onResume
方法中,使用线程池读取所有的 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大小。最后,在 DisplayLeakActivity
的 onResume
中读取所有的 .hprof.result
文件并显示在界面上。
拓展
LeakCanary 直接在线上使用会有什么样的问题,如何改进呢?
理解了 LeakCanary 判定对象泄漏后所做的工作后就不难知道,直接将 LeakCanary 应用于线上会有如下一些问题:
- 每次内存泄漏以后,都会生成一个
.hprof
文件,然后解析,并将结果写入.hprof.result
。频繁增加,引起手机卡顿等问题。 - 同样的泄漏问题,会重复生成
.hprof
文件,重复分析并写入磁盘。 -
.hprof
文件较大,信息回捞成问题。
那应该如何解决上述问题呢?
- 可以根据手机信息来设定一个内存阈值 M ,当已使用内存小于 M 时,如果此时有内存泄漏,只将泄漏对象的信息放入内存当中保存,不生成
.hprof
文件。当已使用大于 M 时,生成.hprof
文件;当然,也可以判断泄漏对象数量大于某个规定的数值时,生成并分析.hprof
文件并分析,此时的分析结果应当包含一个或多个泄漏对象引用链的信息。 - 当引用链路相同时,根据实际情况看去重。
- 不直接回捞
.hprof
文件,可以选择回捞.hprof.result
文件,或者在本地对.hprof.result
进行进一步的整合、去重与优化后再回捞。
袋鼠水平有限,如果看官们较好的思路可以在评论中进行讨论。文章的不足多多包含,希望各位不吝赐教。
另外内存优化上,高级的JVMTI监控对象分配,等咱们有实力了再学。