#摘要
Tailor[1]是西瓜视频安卓团队开发的内存快照切割压缩工具,广泛应用于字节跳动各大应用的OOM中。
治理和异常排查,效益显著,西瓜视频中OOM降低95%以上。Tailor工具现在是开源的。本文将通过原则、方案和实践来分析Tailor。
的相关详细信息。
#背景
治理一直是一个老生常谈的话题。在过去,我们仅仅依靠堆栈和源代码来研究稳定性问题,但在很多情况下,堆栈远远不够。对于依赖度较高的数据,我们只能暂时添加埋点,然后再次在线采集。在此期间,我们会遇到是否收集以及如何收集的问题,这使得我们在处理稳定性问题时往往过于局限和被动。探索一种通用、高效、便捷的异常数据采集方案,一直是我们在治理实践中努力的方向。
Video西瓜安卓团队构建了一个相对完整的基于Java堆内存快照的通用异常数据收集系统,在异常发生时可以尝试转储。
创建一个相对完整的内存快照文件,必要时借助云控系统实现快照钓鱼,最后借助内存快照调查那些棘手的稳定性问题,提高稳定性问题的治理效率。如何高效、安全、便捷地获取内存快照是整个通用异常数据采集系统的关键环节。
#内存快照的作用
OOM 治理
我们知道内存快照是解决OOM问题和其他类型内存问题的重要数据源,其重要性可以简单理解为:内存快照是对常规堆内存OOM的解决方案。
问题的充分条件。同时,内存快照中保存的对象信息和依赖关系也是静态分析内存泄漏的关键,是所有内存泄漏检测工具的基石。
Crash 治理
存储在快照中的数据通常是调查其他类型异常(如活动、碎片、视图状态和Framework.)的重要参考
图层和第三方对象数据等。必要时可用于分析异常问题。作为通用数据,大大减少了定向掩埋的麻烦,也覆盖了很多无法穿透的场景。
#为什么要做裁剪
为了在需要时为各种异常提供数据支持,需要保证数据的稳定性,这就需要解决快照的问题
垃圾场、存储和传输之间可能存在的问题不仅包括存储,的空间和交通消耗,还包括隐私和安全。
存储
以大型堆应用程序为例。OOM的内存快照大小通常在512M左右。没有剪辑,存储只能在App或sd卡之外的存储空间。
其实这样会遇到sd卡空间或者权限不够的问题(安卓11 vs. App
外部存储空间也受到限制)。如果没有足够稳定的存储空间,快照转储的成功率将大大降低。
传输
传输进程对数据量非常敏感,流量消耗是首要问题。其次,快照越小传输花费的时间越少,退货的成功率会大大提高。
隐私
内存快照是虚拟机堆内存数据的完整拷贝,可能包含账号、Token、联系人、密钥等可能有隐私,等的图片/字符串。隐私的数据必须删掉。
#内存快照裁剪方案
目前已知的裁剪方案有两种:一种是开源的Matrix方案,另一种是我在2018年提出的hprof流裁剪方案。矩阵方案分为两步:第一遍。
Debug.dumpHprofData直接转储一个完整的hprof文件;然后通过分析hprof文件,剪切出具有相同数据的位图。
和字符串对象。切割方案存在以下问题:
*原生接口直接转储的hprof文件过大,存储问题不易解决;
*裁剪过程涉及到大文件的I/O和hprof分析,对App的性能有不好的影响。
*裁剪不完整,快照中仍有大量无用数据和可能的隐私。
>问题。hprof 流裁剪则是基于 hprof 文件格式,在 hprof 文件写入过程中进行裁剪压缩,存储空间问题大幅改善,也没有大文件 I/O 和 hprof
分析过程带来的性能问题。该方案源于实际的 OOM 治理需求,并参考了hprof 文件的格式定义,相关考虑如下:
# 治理需要
* 对于 OOM 问题,只有对象大小和引用关系是必须的,其余信息都是次要的;
* OOM 时占比最大的对象通常是 Bitmap/String,这些对象的数据主要消耗在 byte[] 、 char[];
* Java 堆中的明文隐私信息通常以 Bitmap/String 的形式存在。
# hprof格式
hprof [2]文件有明确定义,其数据组织形式比较简单,整体可分为 Header和 Record 数组两部分,相关数据组织定义如下:
* Header: "JAVA PROFILE 1.0.2" \+ indetifiers + timestamp (13byte + 4byte + 8byte)
* Record:tag + time + length + body(1byte + 4byte + 4byte + byte[$length])
Android 上 dump 出的 hprof 文件虽然也遵循 hprof
格式,但也有所不同,典型的是其一级TAG只有:STRING、LOAD_CLASS、HPROF_TAG_STACK_TRACE、HEAP_DUMP_SEGMENT、HEAP_DUMP_END。HEAP_DUMP_SEGMENT
又分了很多二级 TAG ,这些二级 TAG 中既有标准 hprof 定义的,也有 Android 自定义的 TAG。跟裁剪关系比较紧密的二级 TAG 是
PRIMITIVE_ARRAY_DUMP,存放的是诸如 byte[] 、char[] 、int[]等类型的数据,其格式如图所示:
通过 hprof 格式定义可以发现,直接裁剪掉所有的 byte[]和 char[]就可以实现对 Bitmap/String
对象的裁剪。同时其数据格式定义中还存在大量的无用数据,比如 timestamp、class-serial-number、stack-serial-
number、reserved 数据等等,4byte 的 length/number 等也可以压缩成 3byte 或者 2byte 等等。
# Tailor 裁剪压缩实现
如果只为了治理
OOM,可以进行最大化裁剪(如byte[]、char[]、boolean[]、short[]、float[]、int[]、double[]、long[]、hprof格式裁剪),甚至可以只保留
app-heap。但作为通用异常数据,西瓜视频也会在必要的时候,通过回捞快照来分析非 OOM 类的异常,甚至是 native
异常。随着稳定性治理的深入,快照更多是用来分析非 OOM 异常。对于非 OOM 异常,快照的完整性尤为重要,同时非 OOM 的 crash
堆内存通常较小,最大化裁剪没有必要,综合考虑之后 Tailor 只保留了 byte[]、char[] 和 hprof 格式裁剪。
快照 dump 的过程大致可以分为 5 步,Tailor 只关注 open 和 write 环节。通过 xHook [3](针对 Android
平台 ELF 的 PLT hook库)在 native 层 hook dump 过程必然会调用到的 open/write 函数,以此实现对hprof
文件写入流的代理,进而进行 hprof 流裁剪。为了进一步降低写入后的文件体积,Tailor 会在裁剪之后直接进行 zlib 流压缩。流程大致如下:
* 调用 Tailor.dumpHprofData() 时,会依次调用 nOpen()、Debug.dumpHprofData()、nClose();
* nOpen 在 native 层开启对 int open( const char * __path, int __flags, ...)和 ssize_t write_proxy( int fd, const char *buffer, size_t count) 的 hook 代理;
* Debug.dumpHprofData 执行中会先调到 open 函数,hook 代理逻辑会过滤出目标文件的 fd;调到 write 函数时 hook 代理逻辑通过 fd 过滤出目标文件的写入数据进行裁剪压缩;
* nClose 逻辑清除之前的 hook 代理。
// isGzip 是否在裁剪之后进行zip压缩public static synchronized void dumpHprofData(String fileName, boolean isGzip) throws IOException { nOpen(fileName, isGzip); Debug.dumpHprofData(fileName); nClose();}
# Tailor 裁剪压缩效果
实际的裁剪效果取决于具体现场,OOM 现场的快照通常比较大(LargeHeap/非 LargeHeap 的差异也很大),非 OOM
的则要小很多,根据西瓜视频(LargeHeap)的实践经验可以得出以下数据:
* 体积
* OOM: 约 50% 可以裁剪压缩到 10M 以内。
* 非 OOM: 约 60% 可以裁剪压缩到 5M 以内,约 90% 可以裁剪压缩到 10M 以内。
* 耗时
* 同原生 dump 耗时相差很小:dump 过程的耗时主要集中在两次 ProcessHeap 调用和文件写入上。
* 稳定性
* 基本没有稳定性问题:此开源版本已运行半年以上,未发现有 Tailor 相关的 crash。
# 西瓜视频治理实践
西瓜视频汇集了短、中、长各类视频资源,人均使用时长超过 100 分钟,同时启动次数又相对较少,导致内存问题会被放大,进而导致治理难度加大。以西瓜视频
Android v4.0.0 为例,这期间 Java crash 约为 6.5 左右(影响用户的 DAU 占比),而其中 OOM 就高达
3.4,占比过半 。
OOM 问题常见的治理思路,基本都是通过内存泄漏检测工具实现的,这类工具的局限性在于其输出的是孤立的内存泄漏
case,缺少对整体堆内存影响的评估,无法从泄漏中看出 OOM 的直接原因,还存在比较严重的误报行为。虽然后续很多新的工具在性能上有所提升,但本质仍属于
LeakCanary 这一体系,并未从根本上解决工具在治理 OOM 时所面临的问题。
针对这种情况,西瓜视频 Android 完全抛弃了线上内存泄漏检测机制,开发完善了 Tailor
内存快照裁剪压缩工具,并以此为核心制定了线上线下同步治理的长效策略:
* 线下开发、回归、Monkey、压测等环节自动集成 LeakCanary 检测内存泄漏;
* 线上 OOM 时通过 Tailor 主动 dump 内存快照,通过回捞快照精准分析 OOM 问题。
该策略将治理防范的重点放到了线下,在建设完善内存问题前置发现能力的同时,也避免线上分析带来的性能影响和问题规模爆炸。同时,通过 Tailor
内存快照裁剪压缩工具和回捞机制,使得整个内存优化治理形成闭环,以线下防范为主,线上精准治理为辅,线上反哺线下,既可以精准高效地治理线上所有的堆内存 OOM
问题,又补充完善了线下监控体系。
经过一段时间的治理,西瓜视频 Android 新版本的 Java 堆内存 OOM 问题从 3.5 降低到了
0.03,直接降低了两个数量级,并能长期以极低的人力投入保持下去。与此同时,我们也通过内存快照解决了大量迭代过程中遇到的其他类型的棘手的异常,不仅拓展了稳定性治理的思路,也沉淀出了新的稳定性治理的方法论。
在实际治理过程中,很多时候对于堆栈无法直接定位的问题,我们只能通过分析业务代码、分析增量代码、AB
实验等方法来定位。当第二次遇到时,即便知道原因,仍然需要重复之前繁琐的调查,治理经验太过主观,很难传承。而通过内存快照则不会有此类问题,快照的分析过程是客观可重复的,每解决一类问题,后续再遇到是完全可以复制之前的分析过程的。
# 堆内存 OOM 治理
事实上由于泄漏直接导致的 OOM 问题相对较少,能直接导致 OOM
或者内存水位比较高的,更多的是业务逻辑、缓存逻辑等,这些很多是现有检测工具覆盖不到的。事实上对于大多数 App 而言,实际能够导致 OOM
的原因十分有限,通过快照可以很直观的发现问题。
上图所示的是一个 OOM 现场,通过内存泄漏检测工具,的确可以找出多处泄漏,但都不是导致 OOM 的根本原因。即便修复了这些泄漏,显然也无法解决此类 OOM
问题(Android 硬件加速逻辑的漏洞,导致大量 byte[] 被 JNI Global 持有而泄漏)。
# 其他Crash治理
内存快照也是及其重要的数据现场,对于调查数据状态相关稳定性问题,是极为重要的数据补充。如果我们在非 OOM 类的 crash
时,也能获取内存快照,那么就获取了crash 时完整的内存状态。对于堆栈无法定位的问题,可以结合源码和快照数据来辅助调查问题,以下是三个典型的案例:
案例1
上图是一个比较常见的 Java crash 堆栈,堆栈中没有业务相关的信息,对于业务比较复杂的
App,传统手段很难快速定位。通过快照来调查此问题,就变得非常简单了:MAT 里先筛选出 mRecycled 为 true 的 Bitmap
对象,再通过「Path to GC Roots」即可定位。
案例2
上图同样是没有任何业务信息的 crash 堆栈,通过源码判断是在 mListener.onSurfaceTextureAvailable 回调里间接将
mLayer 置空导致的。由于置空代码位于 Framework 层,无法通过打点拿到相关 trace。
最后我们通过快照过滤出 crash时的 TextureLayer 实例,发现其 mAttachInfo 为 null,断定是在回调里执行
removeView 而最终导致 mLayer 被置空的,再通过这个 TextureLayer 实例逐层向上找到 mParent 为 null
的节点,最终找定位到被 remove 的上层节点,进而定位到了问题场景。
案例3
西瓜视频里经常遇到 OutOfMemoryError: pthread_create (1040KB stack) failed 类型的 native
OOM,有一类明确因为播放器实例过多,导致 native 层缓存占用过大而 OOM
的。究竟是播放器自身的问题,还是业务层的问题很难判断。如果通过针对性的埋点来搜集数据太被动,而通过快照里 Java 层 player
对象的状态、引用关系来判断则非常简单,此类问题前后有三类:业务层未正确释放 player、player 的异步 release 被
block、standard 的 Activity 过多导致 player 实例过多等。
根据西瓜视频团队的实践,大量无法通过堆栈来定位的问题,通过快照则可以很轻松的定位到原因。那些即便不能直接定位到问题原因的
case,内存快照也可以提供必要的数据支持。以下是西瓜视频团队实践中总结出的典型的可以通过内存快照来辅助调查的问题分类:
1. Framework :完整的 Activity、Fragment、View 状态,完整的 Framework 层数据&状态。
2. 插件类问题 :有完整的插件&状态信息、Class、Classloader 及 dex 信息等等。
3. 业务层问题
4. 第三方问题
# 内存快照裁剪后续
作为一个立足于提升稳定性治理效率的基础工具,能在必要的时候为任何可能的异常提供完整通用的数据现场,是其当仁不让的职责。能否提供完整的数据现场,核心集中在
dump、存储、传输三个环节,因而 dump 速度、体积、完整性也就成为了核心优化方向。基于目前的成果,对比 Android 原生的快照 dump
逻辑,内存快照裁剪压缩工具在以下方面还有进一步的优化提升空间:
# 裁剪压缩比
在保证快照数据尽可能完整的前提下,怎样进一步裁剪体积是个矛盾的问题,基于 hprof
格式裁剪仍有很大空间。同时,也可以探索其他高效的裁剪方案,以裁剪掉最终分析时用不到的数据。
# 裁剪压缩速度
目前 Tailor 的裁剪压缩耗时跟原生 dump 耗时比较接近,这是因为裁剪压缩的过程耗时有限,主要时间消耗在两次调用 ProcessHeap
和文件写入上,直接干掉第一次调用将会大幅减少整个 dump 耗时。
# Dump内存消耗
Android 快照 dump 是在 native 层完成的,dump 过程中每个 Record 都是通过 std::vectorint8_t>
先缓存之后,再写入到文件里的,实际 dump 过程中 Record 可能会非常大,这时就需要额外申请内存。而当我们是在 native 内存不足的 crash
现场,dump Java 堆内存快照时会大概率失败(大多数 native 内存不足都是由于 Java 层的业务逻辑导致的,必要的时候可以通过 Java
堆现场来定位问题)。如何保证在 native 内存不足时,也能成功 dump 内存快照,是值得思考的。
通过分析相关源码可以发现,实际只需要 hook 下列接口,就可以代理 Record 的缓存过程,直接对拦截到的数据进行裁剪压缩,就不会有 Record
缓存空间的问题,也可以提升快照 dump 的速度。
# 总结
Android
稳定性治理发展至今,相关的监控工具和方法论并不完善。基于内存快照的治理思路和分析方法,将会是传统稳定性治理体系的重要补充,其分析过程更客观、直接、高效,有效减少数据埋点的同时也净化了代码逻辑,将内存快照作为通用异常数据进行搜集可以一劳永逸。
内存快照裁剪压缩是通用异常数据搜集系统里至关重要的一环,是关系到整个技术路线是否通用的核心和关键。Tailor
只是迈开了其中的一小步,方案还有很大的优化空间。开源不是终点,我们希望集思广益、共同探索完善,在 Android 稳定性治理上走的更快更远。
接下来我们会逐步开源并详细介绍西瓜 Android 性能稳定性团队的其他核心监控体系建设,这其中主要有:Raphael(Native 内存泄漏监控工具)和
Sliver(高性能 Trace 工具)等,覆盖 Native 内存泄漏检测、ANR 治理、卡顿治理、基础性能优化等方向,敬请关注!
# 相关资料
1. Tailor 开源地址: https://github.com/bytedance/tailor
2. HPROF 协议: http://hg.openjdk.java.net/jdk6/jdk6/jdk/raw-file/tip/src/share/demo/jvmti/hprof/manual.html#mozTocId848088
3. xHook 链接: https://github.com/iqiyi/xHook
4. Android Camera内存问题剖析 (基于 Tailor 和内存快照的实战案例)
# 更多分享
基于有限状态机与消息队列的三方支付系统补单实践
UME - 丰富的Flutter调试工具
一例 Go 编译器代码优化 bug 定位和修复解析
* * *
欢迎关注「 字节跳动技术团队 」
简历投递联系邮箱「 tech@bytedance.com 」