iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+

Posted 字节跳动技术团队

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+相关的知识,希望对你有一定的参考价值。

ios OOM 崩溃在生产环境中的归因一直是困扰业界已久的疑难问题,字节跳动旗下的头条、抖音等产品也面临同样的问题。

在字节跳动性能与稳定性保障团队的研发实践中,我们自研了一款基于内存快照技术并且可应用于生产环境中的 OOM 归因方案——线上 Memory Graph。基于此方案,3 个月内头条抖音 OOM 崩溃率下降 50%+。

本文主要分享下该解决方案的技术背景,技术原理以及使用方式,旨在为这个疑难问题提供一种新的解决思路。

OOM 崩溃背景介绍

OOM

OOM 其实是Out Of Memory的简称,指的是在 iOS 设备上当前应用因为内存占用过高而被操作系统强制终止,在用户侧的感知就是 App 一瞬间的闪退,与普通的 Crash 没有明显差异。但是当我们在调试阶段遇到这种崩溃的时候,从设备设置->隐私->分析与改进中是找不到普通类型的崩溃日志,只能够找到Jetsam开头的日志,这种形式的日志其实就是 OOM 崩溃之后系统生成的一种专门反映内存异常问题的日志。那么下一个问题就来了,什么是Jetsam

Jetsam

Jetsam是 iOS 操作系统为了控制内存资源过度使用而采用的一种资源管控机制。不同于MacOSLinuxWindows等桌面操作系统,出于性能方面的考虑,iOS 系统并没有设计内存交换空间的机制,所以在 iOS 中,如果设备整体内存紧张的话,系统只能将一些优先级不高或占用内存过大的进程直接终止掉。

Jetsam 日志解读

上图是截取一份Jetsam日志中最关键的一部分。关键信息解读:

  • pageSize:指的是当前设备物理内存页的大小,当前设备是iPhoneXs Max,大小是 16KB,苹果 A7 芯片之前的设备物理内存页大小则是 4KB。
  • states:当前应用的运行状态,对于Heimdallr-Example这个应用而言是正在前台运行的状态,这类崩溃我们称之为FOOM(Foreground Out Of Memory);与此相对应的也有应用程序在后台发生的 OOM 崩溃,这类崩溃我们称之为BOOM(Background Out Of Memory)。
  • rpages:是resident pages的缩写,表明进程当前占用的内存页数量,Heimdallr-Example 这个应用占用的内存页数量是 92800,基于 pageSize 和 rpages 可以计算出应用崩溃时占用的内存大小:16384 * 92800 / 1024 /1024 = 1.4GB。
  • reason:表明进程被终止的的原因,Heimdallr-Example这个应用被终止的原因是超过了操作系统允许的单个进程物理内存占用的上限。
  • Jetsam机制清理策略可以总结为下面两点:

    1.  单个 App 物理内存占用超过上限
    2.  整个设备物理内存占用收到压力按照下面优先级完成清理:
      1. 后台应用>前台应用
      2. 内存占用高的应用>内存占用低的应用
      3. 用户应用>系统应用

    Jetsam的代码在开源的XNU代码中可以找到,这里篇幅原因就不具体展开了,具体的源码解析可以参考本文最后第 2 和第 3 篇参考文献。

    为什么要监控 OOM 崩溃

    前面我们已经了解到,OOM 分为FOOMBOOM两种类型,显然前者因为用户的感知更明显,所以对用户的体验的伤害更大,下文中提到的 OOM 崩溃仅指的是FOOM。那么针对 OOM 崩溃问题有必要建立线上的监控手段吗?

    答案是有而且非常有必要的!原因如下:

    1. 重度用户也就是使用时间更长的用户更容易发生FOOM,对这部分用户体验的伤害导致用户流失的话对业务损失更大。
    2. 头条,抖音等多个产品线上数据均显示FOOM量级比普通崩溃还要多,因为过去缺乏有效的监控和治理手段导致问题被长期忽视。
    3. 内存占用过高即使没导致FOOM也可能会导致其他应用BOOM的概率变大,一旦用户发现从微信切换到我们 App 使用,再切回微信没有停留在之前微信的聊天页面而是重新启动的话,对用户来说,体验是非常糟糕的。

    OOM 线上监控

    Jetsam 强杀代码截图

    翻阅XNU源码的时候我们可以看到在Jetsam机制终止进程的时候最终是通过发送SIGKILL异常信号来完成的。

    #define SIGKILL 9 kill (cannot be caught or ignored)

    从系统库 signal.h 文件中我们可以找到SIGKILL这个异常信号的解释,它不可以在当前进程被忽略或者被捕获,我们之前监听异常信号的常规 Crash 捕获方案肯定也就不适用了。那我们应该如何监控 OOM 崩溃呢?

    正面监控这条路行不通,2015 年的时候Facebook提出了另外一种思路,简而言之就是排除法。具体流程可以参考下面这张流程图:

    排除法判定OOM崩溃的流程

    我们在每次 App 启动的时候判断上一次启动进程终止的原因,那么已知的原因有:

  • App 更新了版本
  • App 发生了崩溃
  • 用户手动退出
  • 操作系统更新了版本
  • App 切换到后台之后进程终止
  • 如果上一次启动进程终止的原因不是上述任何一个已知原因的话,就判定上次启动发生了一次FOOM崩溃。

    曾经Facebook旗下的Fabric也是这样实现的。但是通过我们的测试和验证,上述这种方式至少将以下几种场景误判:

  • WatchDog 崩溃
  • 后台启动
  • XCTest/UITest 等自动化测试框架驱动
  • 应用 exit 主动退出
  • 在字节跳动 OOM 崩溃监控上线之前,我们已经排除了上面已知的所有误判场景。需要说明的是,因为排除法毕竟没有直接的监控来的那么精准,或多或少总有一些 bad case,但是我们会保证尽量的准确。

    自研线上 Memory Graph,OOM 崩溃率下降 50%+

    OOM 生产环境归因

    目前在 iOS 端排查内存问题的工具主要包括 Xcode 提供的 Memory Graph 和 Instruments 相关的工具集,它们能够提供相对完备的内存信息,但是应用场景仅限于开发环境,无法在生产环境使用。由于内存问题往往发生在一些极端的使用场景,线下开发测试一般无法覆盖对应的问题,Xcode 提供的工具无法分析处理大多数偶现的疑难问题。

    对此,各大公司都提出了自己的线上解决方案,并开源了例如MLeaksFinderOOMDetectorFBRetainCycleDetector等优秀的解决方案。

    在字节跳动内部的使用过程中,我们发现现有工具各有侧重,无法完全满足我们的需求。主要的问题集中在以下两点:

  • 基于 Objective-C 对象引用关系找循环引用的方案,适用范围比较小,只能处理部分循环引用问题,而内存问题通常是复杂的,类似于内存堆积,Root Leak,C/C++层问题都无法解决。
  • 基于分配堆栈信息聚类的方案需要常驻运行,对内存、CPU 等资源存在较大消耗,无法针对有内存问题的用户进行监控,只能广撒网,用户体验影响较大。同时,通过某些比较通用的堆栈分配的内存无法定位出实际的内存使用场景,对于循环引用等常见泄漏也无法分析。
  • 为了解决头条,抖音等各产品日益严峻的内存问题,我们自行研发了一款基于内存快照技术的线上方案,我们称之为——线上 Memory Graph。上线后接入了集团内几乎所有的产品,帮助各产品修复了多年的历史问题,OOM 率降低一个数量级,3 个月之内抖音最新版本 OOM 率下降了 50%,头条下降了 60%。线上突发 OOM 问题定位效率大大提升,彻底告别了线上 OOM 问题归因“两眼一抹黑”的时代。

    线上 Memory Graph 核心的原理是扫描进程中所有 Dirty 内存,通过内存节点中保存的其他内存节点的地址值建立起内存节点之间的引用关系的有向图,用于内存问题的分析定位,整个过程不使用任何私有 API。这套方案具备的能力如下:

    1. 完整还原用户当时的内存状态。
    2. 量化线上用户的大内存占用和内存泄漏,可以精确的回答 App 内存到底大在哪里这个问题。
    3. 通过内存节点符号和引用关系图回答内存节点为什么存活这个问题。
    4. 严格控制性能损耗,只有当内存占用超过异常阈值的时候才会触发分析。没有运行时开销,只有采集时开销,对 99.9%正常使用的用户几乎没有任何影响。
    5. 支持主要的编程语言,包括 OC,C/C++,Swift,Rust 等。
    线上 Memory Graph 采集及上报流程示意图

    内存快照采集

    线上 Memory Graph 采集内存快照主要是为了获取当前运行状态下所有内存对象以及对象之间的引用关系,用于后续的问题分析。主要需要获取的信息如下:

  • 所有内存的节点,以及其符号信息(如OC/Swift/C++ 实例类名,或者是某种有特殊用途的 VM 节点的 tag 等)。
  • 节点之间的引用关系,以及符号信息(偏移,或者实例变量名),OC/Swift成员变量还需要记录引用类型。
  • 由于采集的过程发生在程序正常运行的过程中,为了保证不会因为采集内存快照导致程序运行异常,整个采集过程需要在一个相对静止的运行环境下完成。因此,整个快照采集的过程大致分为以下几个步骤:

    1. 挂起所有非采集线程。
    2. 获取所有的内存节点,内存对象引用关系以及相应的辅助信息。
    3. 写入文件。
    4. 恢复线程状态。

    下面会分别介绍整个采集过程中一些实现细节上的考量以及收集信息的取舍。

    内存节点的获取

    程序的内存都是由虚拟内存组成的,每一块单独的虚拟内存被称之为VM Region,通过 mach 内核的vm_region_recurse/vm_region_recurse64函数我们可以遍历进程内所有VM Region,并通过vm_region_submap_info_64结构体获取以下信息:

  • 虚拟地址空间中的地址和大小。
  • Dirty 和 Swapped 内存页数,表示该VM Region的真实物理内存使用。
  • 是否可交换,Text 段、共享 mmap 等只读或随时可以被交换出去的内存,无需关注。
  • user_tag,用户标签,用于提供该VM Region的用途的更准确信息。
  • 大多数 VM Region 作为一个单独的内存节点,仅记录起始地址和 Dirty、Swapped 内存作为大小,以及与其他节点之间的引用关系;而 libmalloc 维护的堆内存所在的 VM Region 则由于往往包含大多数业务逻辑中的 Objective-C 对象、C/C++对象、buffer 等,可以获取更详细的引用信息,因此需要单独处理其内部节点、引用关系。

    在 iOS 系统中为了避免所有的内存分配都使用系统调用产生性能问题,相关的库负责一次申请大块内存,再在其之上进行二次分配并进行管理,提供给小块需要动态分配的内存对象使用,称之为堆内存。程序中使用到绝大多数的动态内存都通过堆进行管理,在 iOS 操作系统上,主要的业务逻辑分配的内存都通过libmalloc进行管理,部分系统库为了性能也会使用自己的单独的堆管理,例如WebKit内核使用bmallocCFNetwork也使用自己独立的堆,在这里我们只关注libmalloc内部的内存管理状态,而不关心其它可能的堆(即这部分特殊内存会以VM Region的粒度存在,不分析其内部的节点引用关系)。

    我们可以通过malloc_get_all_zones获取libmalloc内部所有的zone,并遍历每个zone中管理的内存节点,获取 libmalloc 管理的存活的所有内存节点的指针和大小。

    符号化

    获取所有内存节点之后,我们需要为每个节点找到更加详细的类型名称,用于后续的分析。其中,对于 VM Region 内存节点,我们可以通过 user_tag 赋予它有意义的符号信息;而堆内存对象包含 raw buffer,Objective-C/Swift、C++等对象。对于 Objective-C/Swift、C++这部分,我们通过内存中的一些运行时信息,尝试符号化获取更加详细的信息。

    Objective/Swift 对象的符号化相对比较简单,很多三方库都有类似实现,Swift在内存布局上兼容了Objective-C,也有isa指针,objc相关方法可以作用于两种语言的对象上。只要保证 isa 指针合法,对象实例大小满足条件即可认为正确。

    C++对象根据是否包含虚表可以分成两类。对于不包含虚表的对象,因为缺乏运行时数据,无法进行处理。

    对于对于包含虚表的对象,在调研 mach-o 和 C++的 ABI 文档后,可以通过 std::type_info 和以下几个 section 的信息获取对应的类型信息。

  • type_name string - 类名对应的常量字符串,存储在__TEXT/__RODATA段的__const section中。
  • type_info - 存放在__DATA/__DATA_CONST段的__const section中。
  • vtable - 存放在__DATA/__DATA_CONST段的__const section中。
  • C++实例以及 vtable 的引用关系示意图

    在 iOS 系统内,还有一类特殊的对象,即CoreFoundation。除了我们熟知的CFStringCFDictionary外等,很多很多系统库也使用 CF 对象,比如CGImageCVObject等。从它们的 isa 指针获取的Objective-C类型被统一成__NSCFType。由于 CoreFoundation 类型支持实时的注册、注销类型,为了细化这部分的类型,我们通过逆向拿到 CoreFoundation 维护的类型 slot 数组的位置并读取其数据,保证能够安全的获取准确的类型。

    CoreFoundation 类型获取
    引用关系的构建

    整个内存快照的核心在于重新构建内存节点之间的引用关系。在虚拟内存中,如果一个内存节点引用了其它内存节点,则对应的内存地址中会存储指向对方的指针值。基于这个事实我们设计了以下方案:

    1. 遍历一个内存节点中所有可能存储了指针的范围获取其存储的值 A。
    2. 搜索所有获得的节点,判断 A 是不是某一个内存节点中任何一个字节的地址,如果是,则认为是一个引用关系。
    3. 对所有内存节点重复以上操作。

    对于一些特定的内存区域,为了获取更详细的信息用于排查问题,我们对栈内存以及 Objective-C/Swift 的堆内存进行了一些额外的处理。

    其中,栈内存也以VM Region的形式存在,栈上保存了临时变量和 TLS 等数据,获取相应的引用信息可以帮助排查诸如 autoreleasepool 造成的内存问题。由于栈并不会使用整个栈内存,为了获取 Stack 的引用关系,我们根据寄存器以及栈内存获取当前的栈可用范围,排除未使用的栈内存造成的无效引用。

    栈使用范围

    而对于Objective-C/Swift对象,由于运行时包含额外的信息,我们可以获得Ivar的强弱引用关系以及Ivar的名字,带上这些信息有助于我们分析问题。通过获得Ivar的偏移,如果找到的引用关系的偏移和Ivar的偏移一致,则认为这个引用关系就是这个Ivar,可以将Ivar相关的信息附加上去。

    数据上报策略

    我们在 App 内存到达设定值后采集 App 当时的内存节点和引用关系,然后上传至远端进行分析,可以精准的反映 App 当时的内存状态,从而定位问题,总的流程如下:

    线上 Memory Graph 整体工作流程

    整个线上 Memory Graph 模块工作的完整流程如上图所示,主要包括:

    1. 后台线程定时检测内存占用,超过设定的危险阈值后触发内存分析。
    2. 内存分析后数据持久化,等待下次上报。
    3. 原始文件压缩打包。
    4. 检查后端上报许可,因为单个文件很大,后端可能会做一些限流的策略。
    5. 上报到后端分析,如果成功后清除文件,失败后会重试,最多三次之后清除,防止占用用户太多的磁盘空间。
    后台分析

    这是字节监控平台 Memory Graph 单点详情页的一个 case:

    线上 Memory Graph 详情页概览

    我们可以看到这个用户的内存占用已经将近 900MB,我们分析时候的思路一般是:

    1. 从对象数量和对象内存占用这两个角度尝试找到类列表中最有嫌疑的那个类。
    2. 从对象列表中随机选中某个实例,向它的父节点回溯引用关系,找到你认为最有嫌疑的一条引用路径。
    3. 点击引用路径模块右上角的Add Tag来判断当前选中的引用路径在同类对象中出现过多少次。
    4. 确认有问题的引用路径之后再判断究竟是哪个业务模块发生的问题。

    当前引用路径在同类型对象中出现频率统计

    通过上图中引用路径的分析我们发现,所有的图片最终都被TTImagePickController这个类持有,最终排查到是图片选择器模块一次性把用户相册中的所有图片都加载到内存里,极端情况下会发生这个问题。

    整体性能和稳定性采集侧优化策略

    由于整个内存空间一般包含的内存节点从几十万到几千万不等,同时程序的运行状态瞬息万变,采集过程有着很大的性能和稳定性的压力。

    我们在前面的基础上还进行了一些性能优化:

  • 写出采集数据使用mmap映射,并自定义二进制格式保证顺序读写。
  • 提前对内存节点进行排序,建立边引用关系时使用二分查找。通过位运算对一些非法内存地址进行提前快速剪枝。
  • 对于稳定性部分,我们着重考虑了下面几点:

  • 死锁
  • 由于无法保证 Objective-C 运行时锁的状态,我们将需要通过运行时 api 获取的信息在挂起线程前提前缓存。同时,为了保证libmalloc锁的状态安全,在挂起线程后我们对 libmalloc 的锁状态进行了判断,如果已经锁住则恢复线程重新尝试挂起,避免堆死锁。

  • 非法内存访问
  • 在挂起所有其他线程后,为了减少采集本身分配的内存对采集的影响,我们使用了一个单独的malloc_zone管理采集模块的内存使用。

    性能损耗

    因为在数据采集的时候需要挂起所有线程,会导致用户感知到卡顿,所以字节模块还是有一定性能损耗的,经过我们测试,在iPhone8 Plus设备上,App 占用 1G 内存时,采集用时 1.5-2 秒,采集时额外内存消耗 10-20MB,生成的文件 zip 后大小在 5-20MB。

    为了严格控制性能损耗,线上 Memory Graph 模块会应用以下策略,避免太频繁的触发打扰用户正常使用,避免自身内存和磁盘等资源过多的占用:

    性能损耗控制策略
    稳定性

    该方案已经在字节全系产品线上稳定运行了 6 个月以上,稳定性和成功率得到了验证,目前单次采集成功率可以达到 99.5%,剩下的失败基本都是由于内存紧张提前 OOM,考虑到大多数应用只有不到千分之一的用户会触发采集,这种情况属于极低概率事件。

    试用路径

    目前,线上 Memory Graph 已搭载在字节跳动火山引擎旗下应用性能管理平台(APMInsight)上赋能给外部开发者使用。

    APMInsight 的相关技术经过今日头条、抖音、西瓜视频等众多应用的打磨,已沉淀出一套完整的解决方案,能够定位移动端、浏览器、小程序等多端问题,除了支持崩溃、错误、卡顿、网络等基础问题的分析,还提供关联到应用启动、页面浏览、内存优化的众多功能。目前 Demo 已开放大部分能力,欢迎各位注册账号试用:https://www.volcengine.cn/product/apminsight

    最后的最后,小编还为大家争取到独一无二的福利——抽取 5 名幸运用户,姓名 - 工作年限 - APM 中台 - 技术栈方向(如 iOS/android/Web/后端)

    抖音基础技术

    我们是负责抖音客户端基础能力研发和新技术探索的团队。我们在工程/业务架构,研发工具,编译系统等方向深耕,支撑业务快速迭代的同时,保证超大规模团队的研发效能和工程质量。在性能/稳定性等方面不断探索,努力为全球数亿用户提供最极致的基础体验。

    如果你对技术充满热情,欢迎加入抖音基础技术团队,让我们共建亿级全球化 App。目前我们在上海、北京、杭州、深圳均有招聘需求,内推可以联系邮箱:;邮件标题: 姓名 - 工作年限 - 抖音 - 基础技术 - iOS/Android 

    参考文献

    [1] https://zhuanlan.zhihu.com/p/49829766

    [2] http://satanwoo.github.io/2017/10/18/abort/

    [3] https://jinxuebin.cn/2019/07/OOM底层原理探究/

    [4] https://engineering.fb.com/ios/reducing-fooms-in-the-facebook-ios-app/

    更多分享

    字节跳动全链路压测(Rhino)的实践

    Fastbot:行进中的智能 Monkey

    今日头条品质优化 - 图文详情页秒开实践

    Android Camera内存问题剖析





    欢迎关注「 字节跳动技术团队 

    简历投递联系邮箱「 tech@bytedance.com 


     点击阅读原文,快来加入我们吧!

    抖音 Android 性能优化系列:Java 内存优化篇

    内存作为计算机程序运行最重要的资源之一,需要运行过程中做到合理的资源分配与回收,不合理的内存占用轻则使得用户应用程序运行卡顿、ANR、黑屏,重则导致用户应用程序发生 OOM(out of memory)崩溃。抖音作为一款用户使用广泛的产品,需要在各种机器资源上保持优秀的流畅性和稳定性,内存优化是必须要重视的环节。

    本文从抖音 Java OOM 内存优化的治理实践出发,尝试给大家分享一下抖音团队关于 Java 内存优化中的一些思考,包括工具建设、优化方法论。

    抖音 Java OOM 背景

    在未对抖音内存进行专项治理之前我们梳理了一下整体内存指标的绝对值和相对崩溃,发现占比都很高。另外,内存相关指标在去年春节活动时又再次激增达到历史新高,所以整体来看内存问题相当严峻,必须要对其进行专项治理。抖音这边通过前期归因、工具建设以及投入一个双月的内存专项治理将整体 Java OOM 优化了百分之 80。

    Java OOM Top 堆栈归因

    在对抖音的 Java 内存优化治理之前我们先根据平台上报的堆栈异常对当前的 OOM 进行归因,主要分为下面几类:

    图 1. OOM 分类

    其中 pthread_create 问题占到了总比例大约在百分之 50,Java 堆内存超限为百分之 40 多,剩下是少量的 fd 数量超限。其中 pthread_create 和 fd 数量不足均为 native 内存限制导致的 Java 层崩溃,我们对这部分的内存问题也做了针对性优化,主要包括:

    • 线程收敛、监控
    • 线程栈泄漏自动修复
    • FD 泄漏监控
    • 虚拟内存监控、优化
    • 抖音 64 位专项

    治理之后 pthread_create 问题降低到了 0.02‰以下,这方面的治理实践会在下一篇抖音 Native 内存治理实践中详细介绍,大家敬请期待。本文重点介绍 Java 堆内存治理。

    堆内存治理思路

    从 Java 堆内存超限的分类来看,主要有两类问题:

    1. 堆内存单次分配过大/多次分配累计过大。

    触发这类问题的原因有数据异常导致单次内存分配过大超限,也有一些是 StringBuilder 拼接累计大小过大导致等等。这类问题的解决思路比较简单,问题就在当前的堆栈。

    2.  堆内存累积分配触顶。

    这类问题的问题堆栈会比较分散,在任何内存分配的场景上都有可能会被触发,那些高频的内存分配节点发生的概率会更高,比如 Bitmap 分配内存。这类 OOM 的根本原因是内存累积占用过多,而当前的堆栈只是压死骆驼的最后一根稻草,并不是问题的根本所在。所以这类问题我们需要分析整体的内存分配情况,从中找到不合理的内存使用(比如内存泄露、大对象、过多小对象、大图等)。

    工具建设

    工具思路

    工欲善其事,必先利其器。从上面的内存治理思路看,工具需要主要解决的问题是分析整体的内存分配情况,发现不合理的内存使用(比如内存泄露、大对象、过多小对象等)。

    我们从线下和线上两个维度来建设工具:

    线下

    线下工具是最先考虑的,在研发和测试的时候能够提前发现内存泄漏问题。业界的主流工具也是这个思路,比如 Android Studio Memory Profiler、LeakCanary、Memory Analyzer (MAT)。

    我们基于 LeakCanary 核心库在线下设计了一套自动分析上报内存泄露的工具,主要流程如下:

    抖音 Android 性能优化系列:Java 内存优化篇

    图 2.线下自动分析流程

    抖音在运行了一段线下的内存泄漏工具之后,发现了线下工具的各种弊端:

    1. 检测出来的内存泄漏过多,并且也没有比较好的优先级排序,研发消费不过来,历史问题就一直堆积。另外也很难和业务研发沟通问题解决的收益,大家针对解决线下的内存泄漏问题的 ROI(投入产出比)比较难对齐。
    2. 线下场景能跑到的场景有限,很难把所有用户场景穷尽。抖音用户基数很大,我们经常遇到一些线上的 OOM 激增问题,因为缺少线上数据而无从查起。
    3. Android 端的 HPORF 的获取依赖原生的  Debug.dumpHporf,dump 过程会挂起主线程导致明显卡顿,线下使用体验较差,经常会有研发反馈影响测试。
    4. LeakCanary 基于 Shark 分析引擎分析,分析速度较慢,通常在 5 分钟以上才能分析完成,分析过程会影响进程内存占用。
    5. 分析结果较为单一,仅仅只能分析出 Fragment、Activity 内存泄露,像大对象、过多小对象问题导致的内存 OOM 无法分析。

    线上

    正是由于上述一些弊端,抖音最早的线下工具和治理流程并没有起到什么太大作用,我们不得不重新审视一下,工具建设的重心从线下转成了线上。线上工具的核心思路是:在发生 OOM 或者内存触顶等触发条件下,dump 内存的 HPROF 文件,对 HPROF 文件进行分析,分析出内存泄漏、大对象、小对象、图片问题并按照泄露链路自动归因,将大数据问题按照用户发生次数、泄露大小、总大小等纬度排序,推进业务研发按照优先级顺序来建立消费流程。为此我们研发了一套基于 HPORF 分析的线下、线上闭环的自动化分析工具 Liko(寓意 ko 内存 Leak 问题)。

    Liko 介绍

    Liko 整体架构

    抖音 Android 性能优化系列:Java 内存优化篇图 3. Liko 架构图


    整体架构由客户端、Server 端和核心分析引擎三部分构成。

    • 客户端

    在客户端完成 HPROF 数据采集和分析(针对端上分析模式),这里线上和线下策略不同。

    线上:主要在 OOM 和内存触顶时通过用户无感知 dump 来获取 HPROF 文件,当 App 退出到后台且内存充足的情况进行分析,为了尽量减少对 App 运行时影响,主要通过裁剪 HPROF 回传进行分析,为减轻服务器压力,对部分比例用户采用端上分析作为 Backup。

    线下:dump 策略配置较为激进,在 OOM、内存触顶、内存激增、监测 Activity、Fragment 泄漏数量达到一定阈值多种场景下触发 dump,并实时在端上分析上传至后台并在本地自动生成 html 报表,帮助研发提前发现可能存在的内存问题。

    • Server 端

    Server 端根据线上回传的大数据完成链路聚合、还原、分配,并根据用户发生次数、泄露大小、总大小等纬度促进研发测消费,对于回传分析模式则会另外进行 HPORF 分析。

    • 分析引擎

    基于 MAT 分析引擎完成内存泄露、大对象、小对象、图片等自动归因,同时支持在线下自动生成 Html 报表。

    Liko 流程图

    抖音 Android 性能优化系列:Java 内存优化篇

    图 4. Liko 流程图

    整体流程分为:

    1. Hprof 收集
    1. 分析时机
    1. 分析策略

    Hprof 收集

    收集过程我们设置了多种策略可以自由组合,主要有 OOM、内存触顶、内存激增、监测 Activity、Fragment 泄漏数量达到一定阈值时触发,线下线上策略配置不同。

    为了解决 dump 挂起进程问题,我们采用了子进程 dump+fileObsever 的方式完成 dump 采集和监听。

    在 fork 子进程之前先 Suspend 获取主进程中的线程拷贝,通过 fork 系统调用创建子进程让子进程拥有父进程的拷贝,然后 fork 出的子进程中调用 Hprof 的 DumpHeap 函数即可完成把耗时的 dump 操作在放在子进程。由于 suspend 和 resume 是系统函数,我们这里通过自研的 native hook 工具对 libart.so hook 获取系统调用。由于写入是在子进程完成的,我们通过 Android 提供的 fileObsever 文件写入进行监控获取 dump 完成时机。

    抖音 Android 性能优化系列:Java 内存优化篇

    图 5.子进程 dump 流程图

    Hprof 分析时机

    为了达到分析过程对于用户无感,我们在线上、线下配置了不同的分析时机策略,线下在 dump 分析完成后根据内存状态主动触发分析,线上当用户下次冷启退出应用后台且内存充足的情况下触发分析。

    分析策略

    分析策略我们提供了两种,一种在 Android 客户端分析,一种回传至 Server 端分析,均通过 MAT 分析引擎进行分析。

    端上分析
    分析引擎

    端上分析引擎的性能很重要,这里我们主要对比了 LeakCanary 的分析引擎 Shark 和 Haha 库的 MAT。

    抖音 Android 性能优化系列:Java 内存优化篇

    图 6. Shark VS MAT

    我们在相同客户端环境对 160M 的 HPROF 多次分析对比发现 MAT 分析速度明显优于 Shark,另外针对 MAT 分析后仍持有统治者树占用内存我们也做了主动释放,对比性能收益后采用基于 MAT 库的分析引擎进行分析,对内存泄漏引用链路自动归并、大对象小对象引用链自动分析、大图线下自动还原线上过滤无用链路,分析结果如下:

    内存泄漏

    抖音 Android 性能优化系列:Java 内存优化篇

    图 7. 内存泄漏链路

    对泄漏的 Activity 的引用链进行了聚合分析,方便一次性解决该 Activity 的泄漏链释放内存。

    大对象

    抖音 Android 性能优化系列:Java 内存优化篇

    图 8. 大对象链路

    大对象不止分析了引用链路,还递归分析了内部 top 持有对象(InRefrenrece)的 RetainedSize。

    小对象

    抖音 Android 性能优化系列:Java 内存优化篇

    图 9. 小对象链路

    小对象我们对 top 的外部持有对象(OutRefrenrece)进行聚合得到占有小对象最多的链路。

    图片

    抖音 Android 性能优化系列:Java 内存优化篇

    图 10. 图片链路

    图片我们过滤了图片库等无效引用且对 Android 8.0 以下的大图在线下进行了还原。

    抖音 Android 性能优化系列:Java 内存优化篇

    回传分析

    为了最大限度的节省用户流量且规避隐私风险,我们通过自研 HPROF 裁剪工具 Tailor 在 dump 过程对 HPROF 进行了裁剪。

    裁剪过程

    抖音 Android 性能优化系列:Java 内存优化篇

    图 11. Tailor 裁剪流程

    去除了无用信息

    • 跳过 header
    • 分 tag 裁剪

      • 裁剪无用信息:char[]; byte[]; timestamp; stack trace serial number; class serial number;
      • 压缩数据信息

    同时对数据进行 zlib 压缩,在 server 端数据还原,整体裁剪效果:180M--->50M---->13M

    优化实践

    内存泄漏

    除了通过后台根据 GCROOT+ 引用链自动分配研发跟进解决我们常见的内存泄漏外,我们还对系统导致一些内存泄漏进行了分析和修复。

    系统异步 UI 泄漏

    根据上传聚合的引用链我们发现在 Android 6.0 以下有一个 HandlerThread 作为 GCROOT 持有大量 Activity 导致内存泄漏,根据引用发现这些泄漏的 Activity 都被一个 Runnable(这里是 Runnable 是一个系统事件 SendViewStateChangedAccessibilityEvent)持有,这些 Runnable 被添加到一个 RunQueuel 中,这个队列本身被 TheadLocal 持有。

    抖音 Android 性能优化系列:Java 内存优化篇

    图 12. HandlerThread 泄露链路

    我们从 SendViewStateChangedAccessibilityEvent 入手对源码进行了分析发现它在 notifyViewAccessibilityStateChangedIfNeeded 中被抛出,系统的大量 view 都会在自身的一些 UI 方法(eg: setChecked)中触发该函数。

    抖音 Android 性能优化系列:Java 内存优化篇

    SendViewStateChangedAccessibilityEvent 的 runOrPost 方法会走到我们常用的 View 的 postDelay 方法中,这个方法在当 view 还未被 attched 到根 view 的时候会加入到一个 runQueue 中。

    抖音 Android 性能优化系列:Java 内存优化篇

    这个 runQueue 会在主线程下一次的 performTraversals() 中消费掉。

    抖音 Android 性能优化系列:Java 内存优化篇

    如果这个 runQueue 不在主线程那就没有消费的机会。

    根据上面的分析发现造成这种内存泄漏需要满足一些条件:

    1. view 调用了  postDelay 方法 (这里是  notifyViewAccessisbilityStateChangeIfNeeded 触发)
    1. view 处于 detached 状态
    2. 上述过程是在非主线程里面操作的,ThreadLocal 非 UIThread,持有的 runQueue 不会走  performTraversals 消费掉。

      抖音这边大量使用了异步 UI 框架来优化渲染性能,框架内部由一个 HandlerThread 驱动,完全符合上述条件。 针对该问题,我们通过反射获取非主线程的 ThreadLocal,在每次异步渲染完主动清理内部的 RunQueue。

      抖音 Android 性能优化系列:Java 内存优化篇

      图 13. 反射清理流程

      另外,Google 在 6.0 上也修复了 notifyViewAccessisbilityStateChangeIfNeeded 的判断不严谨问题。

      抖音 Android 性能优化系列:Java 内存优化篇

      内存泄漏兜底

      大量的内存泄漏,如果我们都靠推进研发解决,经常会出现生产大于消费的情况,针对这些未被消费的内存泄漏我们在客户端做了监控和止损,将 onDestory 的 Activity 添加到 WeakRerefrence 中,延迟 60s 监控是否回收,未回收则主动释放泄漏的 Activity 持有的 ViewTree 的背景图和 ImageView 图片。

      大对象

      主要对三种类型的大对象进行优化

      • 全局缓存:针对全局缓存我们按需释放和降级了不需要的缓存,尽量使用弱引用代替强引用关系,比如针对频繁泄漏的 EventBus 我们将内部的订阅者关系改为弱引用解决了大量的 EventBus 泄漏。
      • 系统大对象:系统大对象如 PreloadDrawable、JarFile 我们通过源码分析确定主动释放并不干扰原有逻辑,在启动完成或在内存触顶时主动反射释放。
      • 动画:用原生动画代替了内存占用较大的帧动画,并对 Lottie 动画泄漏做了手动释放。

      抖音 Android 性能优化系列:Java 内存优化篇

      图 14. 大对象优化点

      小对象

      小对象优化我们集中在字段优化、业务优化、缓存优化三个纬度,不同的纬度有不同的优化策略。

      抖音 Android 性能优化系列:Java 内存优化篇

      图 15. 小对象优化思路

      通用类优化

      在抖音的业务中,视频是最核心且通用的 Model,抖音业务层的数据存储分散在各个业务维护了各自视频的 Model,Model 本身由于聚合了各个业务需要的属性很多导致单个实例内存占用就不低,随着用户使用过程实例增长内存占用越来越大。对 Model 本身我们可以从属性优化和拆分这两种思路来优化。

      • 字段优化:针对一次性的属性字段,在使用完之后及时清理掉缓存,比如在视频 Model 内部存在一个 Json 对象,在反序列完成之后 Json 对象就没有使用价值了,可以及时清理。
      • 类拆分:针对通用 Model 冗杂过多的业务属性,尝试对 Model 本身进行治理,将各个业务线需要用到的属性进行梳理,将 Model 拆分成多个业务 Model 和一个通用 Model,采用组合的方式让各个业务线最小化依赖自己的业务 Model,减少大杂烩 Model 不必要的内存浪费。

      业务优化

      • 按需加载:抖音这边 IM 会全局保存会话,App 启动时会一次性 Load 所有会话,当用户的会话过多时相应全局占用的内存就会较大,为了解决该问题,会话列表分两次加载,首次只加载一定数量到内存,需要时再加载全部。
      • 内存缓存限制或清理:首页推荐列表的每一次 Loadmore 操作,都不会清理之前缓存起来的视频对象,导致用户长时间停留在推荐 Feed 时,缓存起来的视频对象过多会导致内存方面的压力。在通过实验验证不会对业务产生负面影响情况下对首页的缓存进行了一定数量的限制来减小内存压力。

      缓存优化

      上面提到的视频 Model,抖音最早使用 Manager 来管理通用的视频实例。Manager 使用 HashMap 存储了所有的视频对象,最初的方案里面没有对内存大小进行限制且没有清除逻辑,随着使用时间的增加而不断膨胀,最终出现 OOM 异常。为了解决视频 Model 无限膨胀的问题设计了一套缓存框架主要流程如下:

      抖音 Android 性能优化系列:Java 内存优化篇

      图 16. 视频缓存框架


      使用 LRU 缓存机制来缓存视频对象。在内存中缓存最近使用的 100 个视频对象,当视频对象从内存缓存中移除时,将其缓存至磁盘中。在获取视频对象时,首先从内存中获取,若内存中没有缓存该对象,则从磁盘缓存中获取。在退出 App 时,清除 Manager 的磁盘缓存,避免磁盘空间占用不断增长。

      图片

      关于图片优化,我们主要从图片库的管理和图片本身优化两个方面思考。同时对不合理的图片使用也做了兜底和监控。

      图片库

      针对应用内图片的使用状况对图片库设置了合理的缓存,同时在应用 or 系统内存吃紧的情况下主动释放图片缓存。

      图片自身优化

      我们知道图片内存大小公式 = 图片分辨率 * 每个像素点的大小。

      图片分辨率我们通过设置合理的采样来减少不必要的像素浪费。

      //开启采样
      ImagePipelineConfig config = ImagePipelineConfig.newBuilder(context)
          .setDownsampleEnabled(true)
          .build();
      Fresco.initialize(context, config);

      //请求图片时,传入resize的大小,一般直接取View的宽高
      ImageRequest request = ImageRequestBuilder.newBuilderWithSource(uri)
          .setResizeOptions(new ResizeOptions(5050))
          .build();mSimpleDraweeView.setController(
          Fresco.newDraweeControllerBuilder()
              .setOldController(mSimpleDraweeView.getController())
              .setImageRequest(request)
              .build());

      而单个像素大小,我们通过替换系统 drawable 默认色彩通道,将部分没有透明通道的图片格式由 ARGB_8888 替换为 RGB565,在图片质量上的损失几乎肉眼不可见,而在内存上可以直接节省一半。

      图片兜底

      针对因 activity、fragment 泄漏导致的图片泄漏,我们在 onDetachedFromWindow 时机进行了监控和兜底,具体流程如下:

      抖音 Android 性能优化系列:Java 内存优化篇

      图 17. 图片兜底流程

      图片监控

      关于对不合理的大图 or 图片使用我们在字节码层面进行了拦截和监控,在原生 Bitmap or 图片库创建时机记录图片信息,对不合理的大图进行上报;另外在 ImageView 的设置过程中针对 Bitmap 远超过 view 本身超过大小的场景也进行了记录和上报。

      抖音 Android 性能优化系列:Java 内存优化篇

      图 18. 图片字节码监控方案

      更多思考

      是不是解决了 OOM 内存问题就告一段落了呢?作为一只追求极致的团队,我们除了解决静态的内存占用外也自研了 Kenzo(Memory Insight)工具尝试解决动态内存分配造成的 GC 卡顿。

      Kenzo 原理

      Kenzo 采用 JVMTI 完成对内存监控工作,JVMTI(JVM Tool Interface)是 Java 虚拟机所提供的 native 编程接口。JVMTI 开发时,应用建立一个 Agent 使用 JVMTI,可以使用 JVMTI 函数,设置回调函数,并从 Java 虚拟机中得到当前的运行态信息,并作出自己的业务判断。

      抖音 Android 性能优化系列:Java 内存优化篇

      图 19. Agent 时序图


      Jvmti SetEventCallbacks 方法可以设置目标虚拟机内部事件回调,可以根据 jvmtiCapabilities 支持的能力和我们关注的事件来定义需要 hook 的事件。

      Kenzo 采用 Jvmti 完成如下事件回调:
      • 类加载准备事件 -> 监控类加载

        • ClassPrepare:某个类的准备阶段完成。
      • GC -> 监控 GC 事件与时间

        • GarbageCollectionStart:GC 启动时。
        • GarbageCollectionFinish:GC 结束后。
      • 对象事件 -> 监控内存分配

        • ObjectFree:GC 释放一个对象时。
        • VMObjectAlloc:虚拟机分配一个对象的时候。

      框架设计

      Kenzo 整体分为两个部分:

      生产端

      • 采集内存数据
      • 以 sdk 形式集成到宿主 App

      消费端

      • 处理生产端的数据
      • 输入 Kenzo 监控的内存数据
      • 输出可视化报表

      抖音 Android 性能优化系列:Java 内存优化篇

      图 20. kenzo 框架

      生产端主要以 Java 进行 API 调用,C++完成底层检测逻辑,通过 JNI 完成底层逻辑控制。

      消费端主要以 Python 完成数据的解析、视图合成,以 HTML 完成页面内容展示。

      工作流

      抖音 Android 性能优化系列:Java 内存优化篇

      图 21. kenzo 框架

      可视化展示

      抖音 Android 性能优化系列:Java 内存优化篇图 22. kenzo 聚合展示

      启动阶段内存归因

      基于动态内存监控我们对最为核心的启动场景的内存分配进行了归因分析,优化了一些头部的内存节点分配:

      抖音 Android 性能优化系列:Java 内存优化篇

      图 23.启动阶段内存节点归因


      另外我们也发现启动阶段存在大量的字符串拼接操作,虽然编译器已经优化成了 StringBuider append,但是深入 StringBuider 源码分析仍在存在大量的动态扩容动作(System.copy),为了优化高频场景触发动态扩容的性能损耗,在 StringBuilder 在 append的时候,不直接往 char[]里塞东西,而是先拿一个 String[]把它们都存起来,到了最后才把所有 String 的 length 加起来,构造一个合理长度的 StringBuilder。通过使用编译时字节码替换的方式,替换所有 StringBuilder 的 append 方法使用自定义实现,优化后首次安装首页 Feed 滑动 1min 的 FPS 提升 1 帧/S,非首次安装启动,滑动 1min 的 FPS 提升 0.6 帧/S。

      加入我们

      我们是负责抖音客户端基础技术能力研发和前沿技术探索的客户端团队,我们专注于性能、架构、稳定性、研发工具、编译构建等方向的深耕,保障超大规模团队的研发效率和工程质量,将 6 亿人使用的抖音打造成极致用户体验的产品。

      如果你对技术充满热情,欢迎加入抖音基础技术团队,让我们共建亿级全球化 App。目前我们在上海、北京、杭州、深圳均有招聘需求,内推可以联系邮箱: tech@bytedance.com ;邮件标题: 姓名 - 工作年限 - 抖音 - 基础技术 - Android / iOS 

      更多分享





      欢迎关注「 字节跳动技术团队 

      简历投递联系邮箱「 tech@bytedance.com 



      以上是关于iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+的主要内容,如果未能解决你的问题,请参考以下文章

      全网首发《Android性能优化—实战》解析一线大厂性能优化之路(支付宝抖音百度今日头条网易携程....)

      华为开发者大会主题演讲:抖音短视频网络性能优化实践

      华为开发者大会主题演讲:抖音短视频网络性能优化实践

      抖音 Android 性能优化系列:启动优化实践

      来一起看看抖音对于功耗优化是怎么做的;抖音Android性能优化实战

      Android内存优化之OOM