iOS堆内存碎片化及如何定位优化

Posted 想名真难

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS堆内存碎片化及如何定位优化相关的知识,希望对你有一定的参考价值。

常见的内存问题

常见的内存问题有哪些呢?

  • Heap size issues

  • Leaks

Heap size issues

堆是进程地址空间的一部分,用来存储动态生成的对象。所以 堆的大小也对内存占用起到了至关重要的影响。为了保证程序的运行,我们无法避免的要在堆上生成对象,那么这些对象该如何有效的治理呢?

那么首先我们需要确定堆上容易出现哪些问题?

  • Heap allocation regressions 堆分配回归

  • Fragmentation 碎片化

下面我们会分析这些问题的成因,以及对应的治理策略。

Heap allocation regressions

堆只是进程地址空间的一部分,用来存储动态生成的对象。堆分配回归会增加内存占用,因为进程在堆上比以前生成了更多对象。为了减少堆的回归,可以删除无用分配并缩小不必要的大内存分配。你也应该关注一下你一次持有多少内存。释放掉你不在使用的内存,并在你需要的时候才去分配内存。这将减少 app 的内存峰值。让它被终止的几率变得更小。

总结一下 Heap allocation regressions  对应的治理策略:

  • 移除无用内存分配。

  • 减少过大内存的分配。

  • 不再使用的内存需要释放。

  • 在你需要的时候,才去分配内存。

Fragmentation

碎片带来了碎片化的问题,那么碎片是如何产生的?首先让我们快速回顾一下 page 在 ios 中是怎样工作的。

page 是系统授予进程的固定大小、不可分割的最小内存块。因为 page 是不可分割的,当进程写入 page 的任意部分,整个 page 都会被认为是 dirty 的并且进程将会管理它,即使 page 的大部分没有被使用到。

当进程的 dirty page 没有被 100% 占用时,就会产生碎片化。为了理解为什么出现碎片,我们来看一个例子:

首先有三页 clean page。

当进程运行的时候,创建的对象会填满这些 page。此时 clean page 就变成了 dirty page。

当部分对象被释放,它们填充过得地方就会变成空槽,在上图中被标记为 free memory。因为依旧填充着对象,这两个 page 依旧被标记为 dirty。

系统想用将要创建的对象填充空槽,右侧蓝色方块是即将要创建的对象,不幸的是,即将创建的对象太大而不能插到空槽里,即使空槽的大小加起来足够大,但是空槽不是连续的。它们不能给一整个对象使用。

因为现有的空槽不能使用,系统就会启用一个新的 page 给即将要创建的对象。如上图最右侧的方格就是新的  page,现有的内存空槽依旧没有没填满, 这种情况我们就称之为碎片化内存

最好解决内存碎片化的方法就是创建内存相邻,生命周期相似的对象。这能帮助确保所有这些对象会被一起释放,这样进程就会得到一大块连续的空闲内存来为即将要被创建的对象服务。

总结一下解决内存碎片化的方法:

  • 创建内存相邻,生命周期相似的对象,这样在这些对象释放之后,我们就会得到一大块连续的空闲内存

Leaks

内存泄露是常见的内存问题,它还可以被细分成:

  • Allocated objects to which there are no active references 对象失去了引用,却还存活着

  • Retain cycles 循环引用

Allocated objects to which there are no active references

当进程创建了对象,在失去了所有指向该对象的指针的时候,并没有回收该对象。我们称这种情况为泄露 (Leak)。我们用灰色的箭头表示对象之间的引用,每个对象都有至少有一个引用。

注意 A 和 B 之间的虚线,这表示此时我们把 A 对 B 的引用置为 nil,并且移除 A 上的 B 对象。

当指针被移除时,B 对象就泄露了。已经没有任何对 B 的引用,但是 B 对象依旧被存储在 Dirty Memory 中。但是进程中已经没有引用指向它了,也就没有办法去释放它。当泄露的对象越多时,他们所占用的 Dirty Memory 就越多,所以我们需要修复泄露。

这种情况的泄露,只会发生在 MRC 下,在 ARC 下当移除指针时,我们一般就会认为此对象已经被释放。

Retain cycles

循环引用也会引起泄露。Swift 中的最常见对象泄露就是循环引用引起的。在上图中,对象 A 和 B 就是循环引用。它们相互引用,但没有外部引用。这意味着进程不能访问或者释放他们。所以它们被认定为泄露。

幸运的是,大多数的 swift 对象都被  Swift 的自动引用计数系统 ( Swift's automatic reference counting system)或者 ARC 管理,这样可以阻止大部分的泄露。如果你用 ARC 管理对象,需要注意 unsafe 类型,确保你会在失去所有引用之前去释放它们。即使是 ARC 管理的对象,也容易变成循环引用。所以,避免创建循环强引用,如果这个循环引用是绝对必要的,考虑使用  weak  引用代替强引用,因为  weak  引用不会阻止对象被回收。

内存治理的工具

既然内存中会有这么多的问题,我们又不可能在开发代码的阶段就完全避免这些问题,苹果为了让我们可以有效的检测和诊断这些内存问题,开发了一系列的工具来帮助开发者,下面让我们来谈谈这些工具。

已有的内存治理工具

内存问题由来已久,苹果在今年之前就有很多工具可以帮我们来检测和诊断内存问题,我们简单的把已有工具在使用维度上分为:

  • 可视化工具

  • 命令行工具

下面我们会详细的列举这些工具,并且简单的阐述一下这些工具的优缺点,以及组合使用方案,因为一些工具存在的时间比较长,笔者并不能一定能找到对应工具组合的最优解,如果你知道,请在评论区留言,如果你的方案更好,我们会更新到文章中。

可视化工具

可视化工具又分为:

  • Xcode 集成的工具

  • instruments 相关工具

Xcode 集成的工具:

  • Memory Report

    Memory Report 存在 Debug navigator 中,当程序运行起来,切换到  Debug navigator 点击 memory 就可以查看 Memory Report , 这个报告只能粗略的查看内存状况,比如:通过 push 出一个 controller 查看对应的内存增长,pop 掉这个 controller 之后一般会有对应的内存减少。当然如果这个 controller 存在大量的网络图片展示,就比较特殊了,一般的网络图片下载和缓存框架为了减少磁盘 IO 以及提高多次访问图片的命中率,会对进行图片缓存,这时 push 的内存增长和 pop 内存减少就是不对称的状态。比如 SDWebImage 会在程序切换到后台的时候,会释放掉一部分缓存。你可以通过切换到后来来验证,当前的不对称是都由网络图片的缓存造成的。所以说 Memory Report 是一个更加整体的内存概况,比较适合查看内存概况,以及没有网络图片缓存的 controller 的释放情况。

    优势:快速查看整体内存预览。

    短板:内存概况不够详细,即使查看对应 controller 的创建以及释放都有一定的局限性。

  • Product->Analyze

Product 中的静态分析主要分析以下四种问题:

a.) 逻辑错误:访问空指针或未初始化的变量等

b.) 内存管理错误:如内存泄漏等

c.) 声明错误:从未使用过的变量

d.) Api调用错误:未包含使用的库和框架

注意使用静态分析是基于编译器的静态检查,而 Objective-C 是具有相当强大的动态性,所以静态分析能够检查出一些内存泄露问题,一些动态执行引起的内存泄露需要其他工具来检查。

优势:静态分析是基于编译器的静态检查,且检查会涵盖多种问题的检查。

短板:静态检查本事是基于静态的检查,对应 Objective-C 这种动态性语言的检查具有一定的局限性。

  • Schemes 的诊断工具中的 Memory Management

    • Malloc Scribble

      申请内存后在申请的内存上填 0xAA,内存释放后在释放的内存上填 0x55;再就是说如果内存未被初始化就被访问,或者释放后被访问,就会引发异常,这样就可以使问题尽快暴漏出来。

      Scribble 其实是 malloc 库 libsystem_malloc.dylib 自身提供的调试方案

    • Malloc Guard Edges

      申请大片内存的之前或者之后都会在 page 上加保护

    • Guard Malloc

      使用 libgmalloc 捕获常见的内存问题,比如越界、释放之后继续使用。

      由于 libgmalloc 在真机上不存在,因此这个功能只能在模拟器上使用.

      Guard edge 和 Guard Malloc 可以帮助你发现内存溢出,并在通过对申请的大块内存保护和延迟释放来使你的程序在误用内存时产生更明确地崩溃。

    • Zombie Objects

      Zombie 的原理是用生成僵尸对象来替换 dealloc 的实现,当对象引用计数为 0 的时候,将需要dealloc 的对象转化为僵尸对象。如果之后再给这个僵尸对象发消息,则抛出异常,并打印出相应的信息,调试者可以很轻松的找到异常发生位置。

    • Malloc Stack logging

      Malloc Stack logging 可以结合 Debug Memory Graph 进行使用,我们会在 Debug Memory Graph 处更加详细的说明 Malloc Stack logging 的作用。

      诊断工具 Memory Management 总结

      优势:诊断工具 Memory Management  更加聚焦于最基础的内存使用,包括涂鸦,page 边界保护,越界以及对已经释放的地址进行访问等。

      短板:部分会存在模拟器的限制,因为这块比较聚焦基础的内存,部分功能对开发者的要求也比较高。

      注意:Memory Management 的这五个工具是在对应的 scheme 上生效的,如果你不想 dirty 公共的工程配置,一般可以 选择 Duplicate Scheme 并且取消 share 选项的勾选。而且 Malloc Stack logging 会在你使用 Debug Memory Graph 之后,记录很多日志,增大 app 的沙盒占用,会耗掉手机很多的磁盘空间。建议使用完成之后及时关闭 Malloc Stack logging 。

  • Debug Memory Graph

    基础使用

    Xcode 运行起 app 之后,在调试栏点击 Debug Memory Graph ,这是 Xcode 会捕获当前 app 的内存快照,此时你可以很方便的查看内存中的存活对象,以及从 app 启动到此刻产生的内存泄露(紫色的叹号代表内存泄露),你可以灵活的选择展示当前内存内所有的存活对象,内存泄露的对象,也可以屏蔽系统的存活对象只关注当前工程调用产生的对象,或者是基于上述的选择,筛选指定类型对象。筛选之后,你可以看到当前类型对象有多少个,点击某个对象可以查看它的引用关系,右侧的 inspectors 还会展示当前对象的详细信息,比如占用大小,调用堆栈等。

    进阶使用

    如果你开启 Malloc Stack logging,选择 All Allocation and Free History 选项,你则可以通过调用堆栈直接锚定到具体的代码了。

    如果你需要记录当前内存以备后续分析,你可以在 Xcode 的 File 选项下,导出 memgraph 。Xcode 使用 memgraph 的文件格式来储存应用程序的占用信息,导出 memgraph 文件可以结合命令行工具进行分析。

instruments 相关工具:

  • leaks

用于检测程序运行过程中的内存泄露,并记录对象的历史信息。

  • Allocations

    追踪程序的虚拟内存占用和堆信息,提供对象的类名、大小以及调用栈等信息。

  • Zombies

    用于检测程序运行过程中的僵尸对象,并记录对象的产生过程,调用堆栈及位置。

  • VM Tracker

    能够区分程序运行时前文所述的各种内存类型占用情况,Instruments User Guide 中给出了各个参数的具体定义。

    Tips: 在使用上述工具时,如果看不到类和方法名称,绝大部分原因是你的打包模式没有开启dSYM或者debug symbols。

    因为 instruments 相关工具的使用解释起来需要很长的篇幅,这里我推荐几篇文章方便大家了解这几个工具的使用:leaks[7]    Allocations[8]    Zombies[9]    VM Tracker[10]  想要了解更加详细的信息,请参阅 WWDC19 Getting Started with Instruments[11] 。

命令行工具

在上面我们已经了解了 Xcode 内置的可视化工具,虽然可视化工具已经能够直观的表现我们想要了解的内存占用信息,但是在终端中不仅可以灵活地利用各种命令和 flag 突出我们想要的内容,更可以快速的实现信息查找和文本化交互。在了解内存问题分类之前我们先简单的了解下四种常用的命令行工具

  • vmmp

    vmmap 能够打印出进程信息,所有分配给该进程的 VMRegions 以及 VMRegion 的种类、内存占用信息等内容。利用 --summary 则能够根据不同的 region type 打印出详细的内存占用类型和信息。这里需要注意的是 SWAPPED SIZE 在 iOS 上指的是 Compressed memory size 且其值表示压缩前的占用大小。

  • leaks

    leaks 追踪堆中的对象,打印出进程中内存泄露情况、调用堆栈以及循环引用信息。利用 --traceTree 和指定对象的地址,leaks 还能以树形结构打印出对象的相关引用。

  • heap

    heap 会打印出所有在堆上的对象信息,默认按类数量排序,也可以通过 -sortBySize 按大小排序,对于追踪堆中较大的对象十分有帮助。找到目标对象后,通过 -address 获得所有/指定类的地址,继而可以利用 malloc_history 寻找其调用堆栈信息。

  • malloc_history

    malloc_history App.memgraph --fuStacks [address]
    

    使用上述命令能够获得我们知道地址的对象的调用堆栈信息,它能够得到的比 memory inspector 中 Backtrace 更加详细。但是需要开启 Dignostics 中的 Malloc Stack 选项,才能通过 malloc_history 获得 memgraph 记录的调用堆栈信息。

检测和诊断碎片化

我手动创建了一些对象,被标记为 my object,由于我没有太关注我的代码,系统最终交错安插了我的对象和其他对象,

现在我释放掉了我的对象,出现了四个空闲的空槽,因为 allocated object 的存在,它们都没有连续,这将导致50%的碎片化和四个 dirty page。

假如我写的代码一起创建了所有 my object, 它们最终就会只会占用两个 page。

当我释放掉所有的 my object,进程就会空出两个 clean page 给系统。结果就会得到两个 clean page 两个 dirty page 以及0%的碎片化。

注意碎片化是怎样成为占用空间的倍增器的。50%的碎片化就会让我们的内存占用翻倍。从两个 dirty page 变成 4 个 dirty page。

在大多数真实的场景中,一些碎片是不可避免的, 所以作为经验法则,把我们需要把碎片化降低到25%或者更少

使用 autorelease pool 是一种减少碎片的方式,自动释放池会在执行超出释放池范围时告诉系统释放在它内部分配的所有对象,这有助于确保创建所有在释放池内的对象具有相似的生命周期。

尽管碎片化可能是所有进程的问题,长时间运行的进程尤其容易产生碎片化。因为他们有许多创建和销毁的对象,分割地址空间的可能性会更大。比如:如果你的 app 使用长时间运行 extensions,一定要看一看这些进程的碎片化。

下面来快速的看一下我的进程碎片,我使用 vmmap -sunmmary,并且滚动到输出的最下面。

高亮的部分按照 malloc zone 进行划分,每个 zone 包含不同类型的创建,通常我只需要关心 DefaultMallocZone,因为那是我的堆分配默认结束的地方。然而因为这个 memgraph 启用了 MSL,我真正关心的是 MallocStackLoggingLiteZone,只要启用了 MSL,这个区域就是所有堆分配结束的地方。

% FRAG 这一列展示了我的内存在分配的所有 zone 上因为碎片产生浪费的百分比。他们中的一些数值真的比较大。但是我只需关心 MallocStackLoggingLiteZone,这个因为 MallocStackLoggingLiteZone 有最多的脏内存份额。脏内存总共5 MB,MallocStackLoggingLiteZone 占用4.3 MB。所以这种情况下,我可以忽略其他 zone。dirty + swap frag size 这一列精确的展示了因为碎片每一个 malloc zone 有多少内存被浪费了 。

在这个 case 中,我因为碎片浪费了大约 800 KB。这个看起来很多,但是我们之前提过,一些碎片化问题是无法避免的,所以只要我还在 25% 碎片化以下,我就认为这个浪费是可以接受的。

目前MallocStackLoggingLiteZone 的碎片化还在 19% 左右,这显然低于 25% 的经验法则,所以我还不用担心。

如果我真的有碎片化问题,我可以使用 instruments 的工具 Allocations  去追踪这个问题,具体来说,我希望查看分配列表视图,看看在我感兴趣的区域中哪些对象被持久化和销毁了。

在碎片化的背景下,被销毁的对象创建了内存空槽,而持久化对象是剩余的对象负责保持 dirty page, 当你研究碎片化的时候,它们都值得研究。想知道怎样使用 instruments 工具的更多信息请参阅:WWDC19 Getting Started with Instruments[17]

总结一下如何解决碎片化问题:

  • 尽量保证连续创建生命周期相似的对象

  • 碎片化尽量降低到 25% 或者更少

  • 使用 autorelease pool 是一种减少碎片的方式

  • 长时间运行的进程尤其容易产生碎片化,多关注一下这些进程的碎片化。

  • 也可以使用 instruments 的 allocations 工具来诊断碎片化问题。

总结

  1. 如果你使用 unsafe 类型,确保你会释它

  2. 同时也要注意代码中的循环引。

  3. 找到一种方式来减少你的堆分配,你可以缩小它们, 并且尽量把持有它们的时间变的更短。或者完全取消不必要的分配

  4. 确保把碎片化问题牢记在心,创建的对象要尽量相邻并且具有相似的生命周期。

作者总结: 

本 session 主要介绍如何使用 XCTest 写性能测试来检测内存问题(泄露和碎片化),这是一种可重用的,更加系统性的检测内存性能的方式,因为是通过命令行工具对文件进行分析,你可以通过脚本快速检测泄露和堆的问题,简化一些无用信息的输出。但是每个 app 都有自己的情况,可能因为种种原因,目前还不能使用 XCTest 来对 app 进行测试,或者开发者目前对内存问题查找及命令工具不熟悉的时候,Xcode  的 Debug Memory Graph 就是一个很好的入门工具。它可以很方便的帮我们查找泄露,并且这个工具是可视化的。你只需要运行工程,用手点一遍自己的新功能,然后点击 Debug Memory Graph 来捕获内存图,通过筛选来看内存中的泄露,或者查看目前的存活对象及其创建堆栈和引用关系, Debug Memory Graph 是一种轻量级的,更方便、更快速、更直观的方式来让你了解自己 app 内存的使用情况。如果需要,你还可以在 Debug Memory Graph 时,导出当前捕获的内存图的 memgraph 文件,可以多次导出然后就可以使用命令行工具 heap 新增的 diffFrom 功能了哦,你可以结合上面学到的命令工具,帮你最大程度的了解内存问题。想要更详细的了解  Debug Memory Graph ,建议观看 WWDC18 iOS Memory Deep Dive[18] 或者阅读 深入解析iOS内存 iOS Memory Deep Dive[19] 来获取更多信息。

原文太长, 有删减: 检测和诊断 App 内存问题

以上是关于iOS堆内存碎片化及如何定位优化的主要内容,如果未能解决你的问题,请参考以下文章

iOS进程内存分配(页、栈、堆)

有没有办法处理 AVR/Arduino 微控制器中的堆内存碎片?

如何在我的 C++ 程序中检测和估计堆碎片?

堆碎片和 Windows 内存管理器

基本优化实践1.2索引优化——查看堆表查看索引使用情况查看索引碎片率

JAVA性能优化