iOS 的内核是 XNU,XNU 是 Darwin 的一部分,而 Darwin 又是基于 FreeBSD 和 NetBSD 开发,集成了 Mach 微内核,BSD 是基于 UNIX。虽然 Linux 也是基于 UNIX,但 Darwin 和 Linux 没有直接继承的关系。内核 Darwin 是 C 写的,中层框架和库时 C 和 Objective-C 写的。

本文先从一般桌面操作系统的内存机制入手;接着从 iOS 系统层进行分析 iOS 的内存机制及 iOS 系统运行时的内存占用情况;最后到 iOS 中单个 App 的内存管理。

一般操作系统的内存机制

在分析 iOS 内存机制前,先看下一般操作系统(这里的一般操作系统指桌面操作系统)的内存机制是怎样的。

冯·诺伊曼结构

冯·诺伊曼结构(Von Neumann architecture),也称冯·诺伊曼模型(Von Neumann model)或普林斯顿结构(Princeton architecture),是一种将程序指令存储器和数据存储器合并在一起的电脑设计概念结构。即将计算机指令进行编码后存储在计算机的存储器中,需要的时候可以顺序地执行程序代码,从而控制计算机运行,这就是冯.诺依曼计算机体系的开端。

冯·诺伊曼结构的设计概念

冯·诺依曼结构的优势。第一次将存储器和运算器分开,指令和数据都放在存储器中,为计算机的通用性奠定了基础。虽然在规范中计算单元依然是核心,但冯·诺依曼结构事实上导致了以存储器为核心的现代计算机的诞生。

冯·诺依曼结构的瓶颈。冯·诺依曼结构实现了计算机大提速,却也埋下了一个隐患:在内存容量指数级提升以后,CPU 和内存之间的数据传输带宽成为了瓶颈。简而言之,由于 CPU 的读写速率比存储器高,在每次去内存里取字节时,CPU 都需要等待存储器。这就造成了 CPU 性能的浪费。目前的解决办法是通过多核+多级缓存来缓解这一瓶颈问题。

存储器的多级缓存

冯·诺依曼结构瓶颈的解决方式之一是设置多级缓存。先来看下存储器的层级结构。

存储器的层级结构

如上存储器的层次结构图,能看到用到的存储器有 SRAM、DRAM、磁盘等。这样,操作系统中的存储器就构成了一个金字塔,越往上的存储器速度越快,价格越贵,容量也越小。当 CPU 接收到指令后,它会最先向 CPU 中的一级缓存(L1 Cache)去寻找相关的数据,然一级缓存是与 CPU 同频运行的,但是由于容量较小,所以不可能每次都命中。这时 CPU 会继续向下一级的二级缓存(L2 Cache)寻找,同样的道理,当所需要的数据在二级缓存中也没有的话,会继续转向 L3 Cache、内存(主存)和硬盘。

存储器分为两大类:

  • 易失性存储:
    读写速度快,但断电后数据会丢失,容量小价格高。随机访问存储器(RAM)就属于这一类,RAM 又分为 SRAM(静态)DRAM(动态)。如上图的 L1~L3 属于 SRAM,L4 属于 DRAM,通常 SRAM 主要集中在 CPU 芯片内部,价格昂贵,其中 L0 寄存器本身就是 CPU 的组成部分之一,读写速度快。

  • 非易失性存储:
    读写速度较慢,但断电后数据不会丢失,容量大价格相对低。计算机使用的硬盘就是 ROM 的一种,手机用的 Flash 也属于 Rom。这里的只读存储器 ROM,随着计算机发展已经支持了读写,只是沿用了之前的名称。

采用多级缓存提升效率,是用到了局部性原理(Principle of locality),即被使用过的存储器内容在未来可能被再次使用,它附近的数据项也大概率会被使用。当我们访问某个数据项是,将它周围数据项也放到对应缓存中,这样一定程度上节约了访问存储器的时间,提高了效率。

虚拟内存

在知道存储器分为多级缓存后,这里自然引出了一个概念:物理内存。这里的物理内存指物理存储器为运行时的操作系统及进程提供的存储空间,是真实的物理空间及地址。但如果将这些物理地址直接暴露出去,会存在很多的危险性。为了解决这个问题,随之出现了要介绍的虚拟内存

虚拟内存与物理内存的关系

对于每个进程来说,操作系统通过虚拟内存,为每个进程提供了一个连续并私有的地址空间,从而保护每个进程的地址空间不被其他进程干扰。如上图,有了虚拟内存后,进程访问的是分配给它的虚拟内存,而虚拟内存实际可能映射到物理内存及磁盘的任何区域。

CPU 寻址方式

在存储器里以字节为单位存储信息,为正确地存取信息,每个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address)。

物理地址之后拓展支持了分段和分页。内存的分段和分页管理方式都属于内存的不连续分配。什么是不连续分配?就是把程序分割成一块一块的装入内存,在物理上不用彼此相连,在逻辑上使用段表或页表将离散分布的这些小块串起来形成逻辑上连续的程序。

在基本的分页概念中,把程序分成等长的小块。这些小块叫做页(Page),同样内存也被分成了和页面同样大小的页框(Frame),一个页可以装到一个页框里。在执行程序的时候,我们根据一个页表去查找某个页面在内存的某个页框中,由此完成了逻辑到物理的映射。

分段和分页有很多类似的地方,但是最大的区别在于分页对于用户来说是没什么逻辑意义的,分页是为了完成离散存储,所有的页面大小都一样,对程序员来说这就像碎纸机一样,出来的东西没有完整意义。但是分段不一样,分段不定长,分页由系统完成,分段有时在编译过程中会指定划分,因此可以保留部分逻辑特征,容易实现分段共享。iOS 下的每个进程空间先分段,每个段内再分页,所以物理地址是由段号 + 段内页号 + 页内地址组成。

上述内容,参考自《计算机操作系统》,更多可自行查看。

在早期计算机系统中,程序员都是直接访问物理地址进行编程,当程序出现错误时,整个系统都会瘫痪,或者在多进程系统中,当一个进程出现问题,对属于另外一个进程的数据或者指令区域进行写操作,会导致另外一个进程崩溃。于是虚拟地址就被提出,软件使用虚拟地址访问内存,而处理器负责虚拟地址到物理地址的映射工作,地址转换是靠 CPU 中的内存管理单元(Memory Management Unit,即 MMU)来完成。处理器采用多级页表来进行多次查找最终找到真正的物理地址。当处理器发现页表中找不到真正对应的物理地址时,就会发出一个异常,挂起寻址错误的进程,但是其他进程仍然可以正常工作。从虚拟地址到物理地址的转换过程可知:由于页表是存放在内存中的,使用一级页表进行地址转换时,每次读/写数据需要访问两次内存,第一次访问一级页表获得物理地址,第二次才是真正的读/写数据;使用两级页表时,每次读/写数据需要访问三次内存,访问两次页表(一级页表和二级页表)获得物理地址,第三次才是真正的读/写数据。

拿处理器访问两级页表举例说明,当处理器拿到一个需要访问内存的虚拟地址 A,首先查找 MMU 里面页表地址寄存器得到页表在内存中的物理地址,然后 MMU 通过访问内存控制器去访问内存中的两级页表得到 A1、A2 两个地址,A1 和 A2 按照一定规则组合得虚拟地址 A 的物理地址 B,然后处理器在通过访问物理地址 B 得到内存数据。

虚拟寻址过程

这一地址转换过程大大降低了CPU的性能,有什么改进办法?

程序执行过程中,所用到的指令、数据的地址往往集中在一个很小的范围内,其中的地址、数据经常多次使用,这称为程序访问的局部性。由此,通过使用一个高速、容量相对较小的存储器来存储近期用到的页表条目(段/大页/小页/极小页描述符),以避免每次地址转换时都到内存去查找,这样可以大幅度地提高性能。这个存储器用来帮助快速地进行地址转换,称为“转译查找缓存”(TLB Cache)

当 CPU 发出一个虚拟地址时,MMU 首先访问 TLB Cache,如果 TLB Cache 中含有能转换这个虚拟地址的描述符,则直接利用此描述符进行地址转换和权限检查;否则 MMU 访问页表(页表是在主存中)找到描述符后再进行地址转换和权限检查,并将这个描述符填入 TLB Cache 中(如果 TLB Cache 已满,则利用 round-robin 算法找到一个条目,然后覆盖它),下次再使用这个虚拟地址时就可以直接使用 TLB Cache 中的地址描述符了。

TLB 是一个内存管理单元用于改进虚拟地址到物理地址转换速度的缓存,位于 MMU 中。

Swap 内存交换机制(Swap In/Out)

物理内存是计算机的实际内存大小,由 RAM 芯片组成。虚拟内存则是虚拟出来的、使用磁盘代替内存。虚拟内存的出现,让机器内存不够的情况得到部分解决。当程序运行起来由操作系统做具体虚拟内存到物理内存的替换和加载(相应的页与段的虚拟内存管理)。这里的虚拟内存交换过程即所谓的 Swap。

当用户提交程序,然后产生进程在机器上运行。机器会判断当前物理内存是否还有空闲允许进程调入内存运行,如果有则直接调入内存进行;如果没有,则会根据优先级选择一个进程挂起,把该进程交换到 Swap Space 中等待,然后把新的进程调入到内存中运行。根据这种换入和换出,实现了内存的循环利用,让用户感觉不到内存的限制。从这也可以看出 Swap 扮演了一个非常重要的角色,就是暂存被换出的进程。

iOS 的内存机制

官方给出的关于内存的相关文档介绍:Memory Usage Performance Guidelines,看到最后的更新时间为 2013 年 04 月,可以在需要时拿来作为参考。文档主要介绍的几点内容:

  • 关于虚拟内存系统
  • 内存分配的技巧
  • 缓存和内存清理
  • 跟踪内存的使用情况
  • 查找内存泄露
  • 启用 malloc 调试功能
  • 查看虚拟内存的使用情况

iOS 对比桌面操作系统

基于前面对一般桌面操作系统的了解和官方提供的文档,来对比看下在 iOS 中的内存。

首先 iOS 也和其他操作系统一样使用了虚拟内存机制,但区别于桌面操作系统的是:iOS 不支持内存交换机制(Swap)。

iOS 不支持 Swap 机制主要的两个原因:

  • 一方面,因为 iPhone 使用的是闪存 Flash,频繁的读写会影响闪存的寿命
  • 另一方面,相比于桌面操作系统的电脑,手机的闪存空间很有限

iOS 在内存优化上也下了很多心思,用到了内存压缩机制(Compressed memory),后面会具体介绍。Stackoverflow 上面查找看到一份关于 iOS 中单应用可用最大内存的测试报告(iOS app maximum memory budget)。

device: (crash amount/total amount/percentage of total)

iPad1: 127MB/256MB/49%
iPad2: 275MB/512MB/53%
iPad3: 645MB/1024MB/62%
iPad4: 585MB/1024MB/57% (iOS 8.1)
iPad Mini 1st Generation: 297MB/512MB/58%
iPad Mini retina: 696MB/1024MB/68% (iOS 7.1)
iPad Air: 697MB/1024MB/68%
iPad Air 2: 1383MB/2048MB/68% (iOS 10.2.1)
iPad Pro 9.7": 1395MB/1971MB/71% (iOS 10.0.2 (14A456))
iPad Pro 10.5”: 3057/4000/76% (iOS 11 beta4)
iPad Pro 12.9” (2015): 3058/3999/76% (iOS 11.2.1)
iPad Pro 12.9” (2017): 3057/3974/77% (iOS 11 beta4)
iPad Pro 11.0” (2018): 2858/3769/76% (iOS 12.1)
iPad Pro 12.9” (2018, 1TB): 4598/5650/81% (iOS 12.1)
iPad 10.2: 1844/2998/62% (iOS 13.2.3)
iPod touch 4th gen: 130MB/256MB/51% (iOS 6.1.1)
iPod touch 5th gen: 286MB/512MB/56% (iOS 7.0)
iPhone4: 325MB/512MB/63%
iPhone4s: 286MB/512MB/56%
iPhone5: 645MB/1024MB/62%
iPhone5s: 646MB/1024MB/63%
iPhone6: 645MB/1024MB/62% (iOS 8.x)
iPhone6+: 645MB/1024MB/62% (iOS 8.x)
iPhone6s: 1396MB/2048MB/68% (iOS 9.2)
iPhone6s+: 1392MB/2048MB/68% (iOS 10.2.1)
iPhoneSE: 1395MB/2048MB/69% (iOS 9.3)
iPhone7: 1395/2048MB/68% (iOS 10.2)
iPhone7+: 2040MB/3072MB/66% (iOS 10.2.1)
iPhone8: 1364/1990MB/70% (iOS 12.1)
iPhone X: 1392/2785/50% (iOS 11.2.1)
iPhone XS: 2040/3754/54% (iOS 12.1)
iPhone XS Max: 2039/3735/55% (iOS 12.1)
iPhone XR: 1792/2813/63% (iOS 12.1)
iPhone 11: 2068/3844/54% (iOS 13.1.3)
iPhone 11 Pro Max: 2067/3740/55% (iOS 13.2.3)

由上数据,可以看到以 iPhone 11 Pro Max 为例,内存的最大空间为 3740MB,应用可使用的最大空间为 2067MB,占了 55%。iOS 的总内存空间虽然很有限,但 iOS 给每个进程分配的虚拟内存空间还是非常大的。

由上,iOS 对比桌面操作系统,同样使用了虚拟地址,没有使用 Swap 内存交换机制,而是通过内存压缩机制(Compressed memory)来最大化利用内存。

iOS 系统内存

以下分析参考自苹果在 WWDC 2018 上的 Session:416. iOS Memory Deep Dive

在前面关于操作系统 CPU 寻址方式中提到了内存采用了分段+分页的管理方式。具有 VM 机制的操作系统,会对每个运行的进程创建一个虚拟地址空间,该空间的大小有操作系统决定。虚拟地址空间会被分为相同大小的块,这些块被称为 内存页(Memory Page)。计算机处理器和它的内存管理单元(MMU)维护着一张将程序的虚拟地址空间映射到物理地址上的分页表(Page Table)。iOS 中虚拟内存和物理内存的分页大小都是 16KB。

内存大小的计算方式

iOS 的内存页(Memory Page)主要分两类:Clean Page 和 Dirty Page。

Clean Page

Clean Page

对于一般的桌面操作系统,Clean Memory 是能够 Page Out 的部分。Page Out 指将优先级低的内存数据交换到磁盘上,但 iOS 不支持 Swap,所以 Clean Page 在 iOS 是指只能够被系统清理出内存且在需要时能重新加载数据的 Page。包含的类型有:

  • 应用的二进制可执行文件
  • Memory mapped files:.jpg.data.modal 等文件。
  • Frameworks* :_DATA_CONST 字段。需要主意的是:这个字段在创建的时候是 Clean Page 类型的,但如果在程序运行起来时,我们对系统方法进行了 Swizzling,就会把这个内存页变成 Dirty Page。

Dirty Page

Dirty Page

Dirty Page 指不能被系统回收的内存占用。包含的类型有:

  • 所有堆上的对象(如 malloc、Array、NSCache、UIViews、String)
  • 图片解析缓冲(如 CGRasterData、ImageIO)
  • Frameworks(如 _DATA_DATA_DIRTY

可以看到 Framework 既有 Clean Page,也有 Dirty Page。

Compressed Memory

当内存紧张时,系统会将暂不访问的物理内存进行压缩,直到下一次访问的时候进行解压。例如当我们使用 Dictionary 去缓存数据的时候,假设现在使用了 3 页内存,当不访问的时候可能会被压缩为 1 页,再次使用到时候又会解压成 3 页。如下图:

Dictionary 压缩前后

Compressed Memory 是一种用 CPU 时间换空间的方式。

内存警告

当 App 收到内存警告时,苹果给出了一些关于内存警告的一些想法:

  • 并不是所有的内存警告都是 App 本身造成的。如使用 App 的过程中接听到电话,也可能触发内存警告。
  • 内存压缩机制使得内存释放变得比较复杂。如前面 Dictinary 的例子。假设我们收到内存警告,我们可能会决定将字典中的一些数据删除。在我们重新访问压缩后的 Page 时,系统会先解压这块内存,Dictionary Page 就会从一个变为 3 个;之后释放 Dictionary 所占的 Page;此时实际释放的 Page 还是 1 个。因为操作过程中有一个解压的过程,很容易造成内存紧张的状态。
  • 不要一味的缓存,要找到 CPU 计算和内存性能之间的平衡点。相比较使用字典缓存,苹果更推荐使用 NSCache。NSCache 分配的内存可以由系统自动释放,官方针对内存警告也做了优化。

我们平时关心的内存占用其实是 Dirty Size 和 Compressed Size 两部分,所以当我们想要优化内存时,尽量从这两部分入手。

App 中内存占用(Memory Footprint)有一定的限制:

  • 不同设备的内存限制不同
  • App 都具有相当高的占用空间限制
  • 提供给 Extensions 内存比较少
  • 如果内存超过了限制范围,App 会抛出 EXC_RESOURCE_EXCEPTION 异常

附带 Stackoverflow 上查找看到一份关于 iOS 中单应用可用最大内存的测试报告(iOS app maximum memory budget

iOS App 内存

iOS 系统层面的内存,大多由系统自动完成。通常开发者讨论的内存管理,实际上是进程内部语言层面的内存管理。iOS 中一个 App 对应一个进程。

App 内存空间

内存分区

内存分区按高地址到低地址依次为:

  • 栈区(Stack)
  • 堆区(Heap)
  • 静态存储区(Static)
  • 常量区
    在程序中使用的常量(如常量字符串)存储在该区域。在程序结束后,由系统释放。
  • 代码区
    存放函数体的二进制代码。运行程序实际上是执行代码,代码要执行就需要先加载入内存。

展开介绍下栈区(Stack)堆区(Heap)静态存储区(Static)

栈区(Stack)

栈区中的变量由编译器负责分配和释放,内存随着函数的运行分配,随着函数的结束而释放,由系统自动完成。

在执行函数时,函数内局部变量的存储单元(指非静态的局部变量,如:函数参数、在函数内所声明对象的指针等)都会在栈上进行创建,函数执行结束时(出作用域时),这些存储单元会被自动释放。栈区的内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量是有限的,当系统的栈区大小不够分配时,系统会提示栈溢出。官方也给出了 iOS 中栈空间的大小,子线程为 512KB,主线程为 1MB(官方链接),如下图:

iOS 中栈区空间限制

栈是向低地址扩展的,是一块连续的内存区域,且栈顶的地址和栈的最大容量是由系统预先规定的,遵循 FILO,不产生内存碎片。只要栈的剩余空间大于所申请空间,系统讲为程序提供内存;否则将报异常提示栈溢出。因此,能从栈获得的空间较小。开发过程中,需要留意的是:像大量的局部变量,深递归,函数循环调用都可能导致栈溢出而运行崩溃。

堆区(Heap)

堆区中的变量由开发者进行分配和释放。操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。大多系统,会在这块内存空间中的首地址处记录本次分配的大小,以使得内存空间释放时正确。另外,由于找到的空闲堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

堆是向高地址扩展的,是不连续的内存区域。这是由于系统使用了链表来存储空闲的内存地址,而链表的遍历方向是由低地址向高地址。堆的大小由系统中有效虚拟内存决定。因此,堆的空间比较灵活,也比较大,由于堆的特性,也容易产生内存碎片,但用起来较为方便。

静态存储区(Static)

这块内存在程序编译时就已经分配好,在程序的整个运行期间这块内存都会存在。它主要用来存放静态变量全局变量常量。事实上全局变量也是静态的,因此,也叫全局静态存储区

静态存储区分为两部分:

  • 数据区:全局变量静态变量的存储是放在一起的,初始化的全局变量静态变量存放在一块区域
  • BSS 区:未初始化的全局变量静态变量在相邻的另一块区域。

在程序结束运行后,这块内存由系统释放。

引用计数

移动端的内存管理技术,主要有 GC(Garbage Collection 垃圾回收)的标记清楚算法和苹果使用的引用计数方法。

早期的 iOS 开发通过手动引用计数(MRC - Mannul Reference Counting)的方式手动管理引用计数,由于 MRC 维护成本的原因,苹果在 2011 年的 WWDC 提出了自动引用计数(ARC - Automatic Reference Countin)。ARC 背后的原理是依赖编译器的静态分析能力,通过在编译时找出合理的位置插入引用计数管理代码。遵循谁申请谁释放的原则。虽然 ARC 帮助我们解决了引用计数大部分的问题,但开发过程中如果不留意会很容易出现类似循环引用而导致的内存泄露的问题。移动设备的内存资源是有限的,当 App 运行时占用的内存超过限制后,会被强制杀掉,用户体验会被极大降低。为了提升 App 质量,开发者需要重视应用的内存管理问题。

引用计数(Reference Count)是一种管理对象生命周期的方式。在创建一个新对象时,它的引用计数为 1;每当该被引用时,它的引用计数 +1;每当引用该对象的对象释放时,它的引用计数 -1。当该对象的引用计数为 0 时,说明该对象不再被任何对象使用,这时该对象会被销毁,内存回收。过程如下图:

对象的引用计数

一个需要主意的点:当对象被释放时,它的 retainCount 不一定为 0。如下代码:

- (void)testRetainCount {
NSObject *object = [[NSObject alloc] init];
NSLog(@"Reference Count = %u", [object retainCount]);
[object release];
NSLog(@"Reference Count = %u", [object retainCount]);
}

输出结果如下:

Reference Count = 1
Reference Count = 1

会发现 object 在 release 前后的引用计数都为 1。这是为什么?

是因为当最后一次执行 release 时,系统知道马上就要回收内存,没有必要再将 retainCount - 1。因为不管是否 -1,该对象都确定会被回收,而对象被回收后,所在的内存区域包括 retainCount 的值已经没有意义。这里不将 1 变为 0,是为了减少一次内存写操作,进而加速对象的回收。

ARC 虽然帮助开发者解决了 iOS 开发过程中绝大部分的内存管理问题,但底层 Core Foundation 对象的部分不在 ARC 的管理范围内,需要开发者自己维护这些对象的引用计数。

循环引用

引用计数管理内存的方式是:当对象自己被销毁时,其成员变量引用计数 -1。但如果出现下面的引用情况:

相互持有对象

上图中,对象 A 持有对象 B,同时对象 B 也持有对象 A。在外界对对象 A 和对象 B 没有其他任何引用的情况下,对象 A 若想释放,只能先释放对象 B;但对象 B 若想释放,同样需要先释放对象 A。这样就出现了循环引用(Reference Cycle)的问题。

解决循环引用问题主要有两种方式:

  • 第一种是主动断开循环,通过置 nil 主动释放的方式
  • 第二种是通过使用弱引用

弱引用虽然持有对象,但不会增加被持有对象的引用计数,这样就避免了循环引用的产生。弱引用用的比较多的场景比如:delegate 模式的使用。

弱引用 Delegate

如上图的例子,两个 ViewController。场景是 ViewController A 弹出 ViewController B,在 ViewController B 做完一些操作后,将一些数据返回给 ViewController A。这时,因为 delegate 是弱引用,不会变更引用计数,这样就避免了循环引用的产生。

弱引用的实现原理

系统对于每一个有弱引用的对象,都维护了一个表来记录它所有弱引用的指针地址。当一个对象的引用计数为 0 时,系统就通过这张表,找到所有的弱引用指针,继而把它们都置为 nil。

更细节的关于弱引用的实现原理,后面会有单独的一篇来分析。

OOM

OOM 是 Out of Memory 的缩写,指当 App 占用的内存达到了 iOS 系统对单个 App 占用内存上限后会被系统强杀掉的现象。这是一种由 iOS 的 JetSam 机制导致的一种“另类”崩溃,并且日志无法通过信号捕捉到。

JetSam 机制,指的是操作系统为了控制内存资源过度使用而采用的一种资源管控机制。

在面对 OOM 类问题时,会考虑到两个方面的问题。一方面是,如何知道系统对单个 App 允许占用内存的上限值?另一方面是,如何定位 OOM?依次来看下对应的解决方案。

如何获取内存上限值?

JetsamEvent 日志

我们可以从设置 - 隐私 - 分析与改进这条路径看到系统的日志,找到以 JetsamEvent 开头的系统日志,我们可以通过隔空投送到电脑或直接在手机上查看这些日志内容。

在这类系统日志中,查找崩溃原因时如果看到"reason" : "pre-process-limit",则表示崩溃是由于 App 占用的内存超过了系统对单个 App 的内存限制。对应查找"rpages" 对应的值,这个值表示 App 占用的内存页数量。

日志内容的结构如下:

"rpages" : 89600,
"reason" : "per-process-limit",

通过 JetsamEvent 日志获取了内存页数量 rpages 为 89600,只要再知道内存页大小的值,就可以计算出单个 App 的内存上限值。

继续在 JetsamEvent 日志中查找以 "pageSize" 对应的值,为 16384。通过下面的计算公式得到值为 1.4G。

  • 内存上限值 = pageSize * rpages / 1024 / 1024 MB

JetsamEvent 日志是系统在杀掉 App 后留在手机中的,属于系统级日志,存放在系统目录下,App 上线后开发者是没有权限获得的。

那么 iOS 是怎么监控内存压力的?

iOS 系统会开启优先级最高的线程 vm_pressure_monitor 来监控系统的内存压力情况,并通过一个堆栈来维护所有 App 的进程。此外,iOS 系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。

vm_pressure_monitor线程发现某 App 内存有压力了,会为该 App 发送通知,也就是 didReceiveMemoryWarnning 代理。通过这个代理,可以写需要的内存释放代码,以避免 App 被系统强制杀死。

iOS 系统内核有一个数组,专门用于维护线程的优先级。优先级由高到低依次是:内核用线程的优先级 > 操作系统 > 前台 App > 后台运行 App

苹果考虑到手持设备存储空间有限,在 iOS 中去掉了 Swap,这样虚拟内存就没办法记录到外部的存储上,进而苹果引入了 MemoryStatus 机制。

MemoryStatus 机制的主要思路是,在 iOS 上弹出尽可能多的内存共当前应用使用。把这个机制落到优先级上,就是先强杀后台应用;如果内存还不够就强杀掉当前应用。MemoryStatus 机制会开启一个 memorystatus_jetsam_thread 线程,这个线程和 vm_pressure_monitor 没有关系。memorystatus_jetsam_thread线程只负责强杀应用和记录日志,不会发送通知消息;vm_pressure_monitor线程也无法获取强杀应用的消息。

除了内存过大的原因会被系统强杀,还有三种内存问题也会被强杀:

  • 访问未分配的内存。XNU 会报 EXC_BAD_ACCESS 错误,发出 SIGSEGV Signal #11 信号。这类报错绝大多数是由于对一个已经释放的对象进行 release 操作造成的。
  • 访问已分配但未提交的内存。XNU 会拦截分配物理内存,出现问题的线程分配内存页时会被冻结。
  • 没有遵守权限访问内存。内存页的权限标准类似 UNIX 文件权限,如果对只读权限的内存页进行写入就会出错,XNU 发出 SIGSEGV Signal #7 信号。

第一种和第三种问题可以通过崩溃信息获取到,在收集崩溃信息时如果是这两类,可以把内存分配的记录同时收集,用于分析不合理内存分配和优化。

通过 XNU 获取

XNU 中有专门用于获取内存上限值的函数和宏,可以通过 memorystatus_priority_entry 结构体得到进程的优先级和内存上限值。结构体中 priority 表示进程的优先级;limit 表示进程的内存上限值。相关源码如下:

// 获取进程的 pid、优先级、状态、内存阈值等信息
typedef struct memorystatus_priority_entry {
pid_t pid;
int32_t priority;
uint64_t user_data;
int32_t limit;
uint32_t state;
} memorystatus_priority_entry_t;


// 基于下面这些宏可以达到查询内存阈值等信息,也可以修改内存阈值等
/* Commands */
#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST 1
#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES 2
#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT 3
#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS 4
#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK 5 /* Set active memory limit = inactive memory limit, both non-fatal */
#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT 6 /* Set active memory limit = inactive memory limit, both fatal */
#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES 7 /* Set memory limits plus attributes independently */
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES 8 /* Get memory limits plus attributes */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE 9 /* Set the task's status as a privileged listener w.r.t memory notifications */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE 10 /* Reset the task's status as a privileged listener w.r.t memory notifications */
/* Commands that act on a group of processes */
#define MEMORYSTATUS_CMD_GRP_SET_PROPERTIES 100

XNU 详细相关源码链接:

通过 XNU 宏获取内存限制,需要越狱来获取 root 权限,正常情况下开发者看不到这些信息。

通过内存警告获取

前面提到内存警告时,系统的内存监控线程会给相关 App 发送通知didReceiveMemoryWarnning。我们可以利用这个内存压力代理事件来动态获取内存上限值。系统在强制杀死 App 前会有 6s 的时间,这段时间足够我们获取记录内存信息。iOS 系统提供了一个 task_info 函数,我们可以在发生内存警告时,通过 task_info_t 结构内的 resident_size 字段获取当前 App 占用了多少内存。具体代码如下:

#import <mach/mach.h>
- (int64_t)memoryUsage {
int64_t memoryUsageInByte = 0;
struct task_basic_info taskBasicInfo;
mach_msg_type_number_t size = sizeof(taskBasicInfo);
kern_return_t kernelReturn = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t) &taskBasicInfo, &size);

if(kernelReturn == KERN_SUCCESS) {
memoryUsageInByte = (int64_t) taskBasicInfo.resident_size;
NSLog(@"Memory in use (in bytes): %lld", memoryUsageInByte);
} else {
NSLog(@"Error with task_info(): %s", mach_error_string(kernelReturn));
}

return memoryUsageInByte;
}

但测试的时候,我们会发现计算出的值跟 Instruments 里看到的内存大小不一致,甚至相差及时 MB。resident_size(驻留内存)确实无法反映真实的物理内存,而且 Xcode 的 Debug Gauge 使用的也是 phys_footprint,这点从 WebKit 和 XNU 的源码可以佐证。

WebKit 相关源码

size_t memoryFootprint()
{
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
if (result != KERN_SUCCESS)
return 0;
return static_cast<size_t>(vmInfo.phys_footprint);
}

XNU 源码中 JetSam 判断应用内存是否使用过大也是使用的 phys_footprint。WWDC 2018 Session iOS Memory Deep Divefootprint 这块也有介绍。

贴近 JetSam 机制,更准确的内存计算方式应该是通过 phys_footprint:

#import <mach/mach.h>
- (int64_t)memoryUsage {
int64_t memoryUsageInByte = 0;
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kernelReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
if(kernelReturn == KERN_SUCCESS) {
memoryUsageInByte = (int64_t) vmInfo.phys_footprint;
NSLog(@"Memory in use (in bytes): %lld", memoryUsageInByte);
} else {
NSLog(@"Error with task_info(): %s", mach_error_string(kernelReturn));
}
return memoryUsageInByte;
}

如何定位 OOM?

OOM 分为两大类,Foreground OOM / Background OOM,即 FOOM 和 BOOM。其中 FOOM 是指 App 在前台因消耗内存过多引起系统强杀。对用户而言,表现跟 crash 一样。

现在主流的 OOM 检测库有两个:

Facebook 早在 2015 年 8 月提出 FOOM 检测办法,大致原理是排除各种情况后,剩余的情况是 FOOM,原文:Reducing FOOMs in the Facebook iOS app。可以使用 Facebook 的 FBAllocationTracker 工具监控 OC 对象分配,用 fishhook 工具 hook malloc/free 等接口监控堆内存分配,每隔 1 秒,把当前所有 OC 对象个数、TOP 200 最大堆内存及其分配堆栈,用文本 log 输出到本地。

这个方案的不足点:

  • 监控粒度不够细,像大量分配小内存引起的质变无法监控,另外 fishhook 只能 hook 自身 app 的 C 接口调用,对系统库不起作用;
  • 打 log 间隔不好控制,间隔过长可能丢失中间峰值情况,间隔过短会引起耗电、I/O 频繁等性能问题;
  • 上报的原始 log 靠人工分析,缺少好的页面工具展现和归类问题。

在这之后微信开源了 OOMDetector,使用了更底层的 malloc_logger_t 记录当前存活对象的内存分配信息(包括分配大小和分配堆栈)。分配堆栈可以用 backtrace 函数捕获,但捕获到的地址是虚拟内存地址,不能从符号表 dsym 解析符号。所以还要记录每个 image 加载时的偏移 slide,这样符号表地址=堆栈地址-slide。另外,还做了数据归类。具体的实现方案可以查看原文:iOS微信内存监控

OOM 常见问题

UIGraphicsEndImageContext

UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必须成双出现,不然会造成 context 泄漏。另外 Xcode 的 Analyze 也能扫出这类问题。

UIWebView

无论是打开网页,还是执行一段简单的 js 代码,UIWebView 都会占用 App 大量内存。而 WKWebView 不仅有出色的渲染性能,且有自己独立进程,一些网页相关的内存消耗移到自身进程里,最适合取替 UIWebView。

autoreleasepool

通常 autoreleased 对象是在 runloop 结束时才释放。如果在循环里产生大量 autoreleased 对象,内存峰值会猛涨,甚至出现 OOM。适当的添加 autoreleasepool 能及时释放内存,降低峰值。

互相引用

比较容易出现互相引用的地方是 block 里使用了 self,而 self 又持有这个 block,只能通过代码规范来避免。另外 NSTimer 的 target、CAAnimation 的 delegate,是对 Object 强引用。

大图片压缩

当我们在缩小一幅图像的时候,会按照取平均值的办法把多个像素点变成一个像素点,这个过程称为 Downsampling。通常图片缩放接口可以如下写法:

但处理大分辨率图片时,往往容易出现 OOM,原因是 -[UIImage drawInRect:] 在绘制时,先解码图片,再生成原始分辨率大小的 bitmap,这是很消耗内存。解决方法是使用更底层的 ImageIO 接口,它可以直接读取图像大小和元数据信息,不会带来额外的内存开销。

大图加载显示

WWDC 2018 Session 416:iOS Memory Deep Dive提出建议使用 UIGraphicsImageRenderer 代替 UIGraphicsBeginImageContextWithOptions
该方法从 iOS 10 引入了,在 iOS 12 上会自动选择最佳的图片格式,可以减少很多内存。如果想修改颜色,可以直接修改 tintColor,不会有额外的内存开销。

图片在 iOS 上的显示原理:WWDC 2018 Session 219:Image and Graphics Best Practices
对应的翻译文稿:《WWDC2018 图像最佳实践》

大图切换前后台时的优化

假设在 App 里展示了一张很大的图片,当我们切换到后台去做其它的操作时,这个图片还在占用内存。我们应该考虑在合适的时机去回收这类占用过大的数据。

大视图

大视图是指 View 的 size 过大,自身包含要渲染的内容。超长文本如常见的炸群消息,通常几千甚至几万行。如果把它绘制到同一个 View 里,那将会消耗大量内存,同时造成严重卡顿。最好做法是把文本划分成多个 View 绘制,利用 TableView 的复用机制,减少不必要的渲染和内存占用。

内存检测工具

列出一些内存分析的工具:

Xcode Memory Gauge

在 Xcode 中,你可以通过 Memory Gauge 工具,快速查看 App 运行时的内存情况,包括内存最高占用、最低占用,以及在所有进程中的占用比例等。如果想要查看更详细的数据,就需要用到 Instruments 了。

Instruments

在 Instruments 中,你可以使用 Allocations、Leaks、VM Tracker 和 Virtual Memory Trace 对 App 进行多维度分析。

  • Allocations:可以查看虚拟内存占用、堆信息、对象信息、调用栈信息,VM Regions 信息等。可以利用这个工具分析内存,并针对地进行代码优化。

  • Leaks:用于检测内存泄漏。

  • VM Tracker:可以查看内存占用信息,查看各类型内存的占用情况,比如 dirty memory 的大小等等,可以辅助分析内存过大、内存泄漏等原因。

  • Virtual Memory Trace:有内存分页的具体信息,具体可以参考 WWDC 2016 - Syetem Trace in Depth

Debug Debugger - Memory Resource Exceptions

当使用 Xcode 10 以前的版本进行调试时,在内存过大时,debug session 会直接终止,并且在控制台打印出异常。从 Xcode 10 开始,debugger 会自动捕获 EXC_RESOURCE RESOURCE_TYPE_MEMORY 异常,并断点在触发异常抛出的地方,十分方便定位问题。

Xcode Memory Debugger

通过这个工具,可以直观地查看内存中所有对象的内存使用情况,对象相互间的依赖关系,对定位那些因为循环引用导致的内存泄露问题十分有帮助。
我们也可以点击 File -> Export Memory Graph 将其导出为 memgraph 文件,在命令行中使用 Developer Tool 对其进行分析。使用这种方式,我们可以在任何时候对过去某时的 App 内存使用进行分析。

vmmap

用于查看虚拟内存。

# 查看详细报告
vmmap App.memgraph

# 查看摘要报告
vmmap --summary App.memgraph

# vmmap and AWK 查看所有动态库的Ditry Pages的总和
vmmap -pages PlanetPics.memgraph | grep '.dylib' | awk '{sum += $6} END { print "Total Dirty Pages:"sum}'

# 查看vmmap的文档
man vmmap

vmmap App.memgraph

vmmap --summary App.memgraph

vmmap -pages PlanetPics.memgraph | grep '.dylib' | awk '{sum += $6} END { print "Total Dirty Pages:"sum}'

leaks

用于查看泄露的内存。

# 查看是否有内存泄露
leaks MyApp.memgraph

# 查看 leaks 的文档
man leaks

leaks MyApp.memgraph

heap

查看堆区内存。

# 查看所有堆区对象的内存使用
heap App.memgraph

# 默认情况下是按照对象数量进行排序,通常情况下它们不会造成什么内存问题。
# 我们更关心的那些为数不多但占用大量内存的对象
# 参数 -sortBySize,按照内存占用大小顺序来查看所有堆区对象的内存使用
heap App.memgraph -sortBySize

# 当确定是哪个类型的对象占用了太多内存之后,可以得到每个对象的内存地址
heap App.memgraph -addresses all | <classes-pattern>

# 查看 heap 的文档
man heap

heap App.memgraph -sortBySize

heap App.memgraph -addresses all | <classes-pattern>

Enabling Malloc Stack Logging

Product -> Scheme -> Edit Scheme -> Diagnostics 中,开启 Malloc Stack 功能,建议使用 Live Allocations Only 选项。之后 lldb 会记录调试过程中对象创建的堆栈,配合 malloc_history 工具,就可以定位到那些占用了过大内存的对象是哪里创建的。

malloc_history

查看内存分配历史。

# 查看内存分配历史
malloc_history App.memgraph [address]

# 查看文档
man malloc_history

malloc_history App.memgraph [address]

参考文章:

评论