iOS中的内存布局与管理

内存——它的硬件实体称作RAM(随机存储器),它的基本特点就是和CPU数据交换速度快、断电易失。在iPhone这类嵌入式设备上通常被认为是资源有限(目前最大的容量也才4GB),而iOS中又该如何高效的利用这一块存储器呢?

让我们从官方文档:

开始深入学习iOS的程序在运行时候关于内存的一些话题。

内存相关基本概念

在深入学习内存相关的话题之前有必有先明白:”虚拟内存“和”物理内存“这个两个基本概念。

首先我们知道在32位计算机系统里面它的寻址空间是2的32次方约为4GB,意味着在32位的计算机系统中理论上支持的内存物理最大寻址空间是4GB,然而现实中一台设备的内存可能达不到4GB,但从程序设计的角度上来说都希望有4GB这么大的空间。

所以衍生了一个虚拟(逻辑)地址的概念,程序内记录的都是虚拟(逻辑)地址,而从逻辑地址到物理地址最终将由CPU中的一个硬件单元MMU进行映射,这样子每个程序都能够有0x00000000~0xffffffff的寻址空间。

在iOS系统中虚拟内存(使用逻辑地址表示的内存空间)到物理内存映射流程如下图:

在应用程序中在用户空间使用API调用生成的堆Heap转换成App VM再转成内核空间中的VM Object,然后以4K对齐的方式一个个排列到物理空间中。

程序对象内存基本布局

根据下图可知在iOS中一个对象的内存基本布局中包含的数据空间,可不仅仅是由用户申请的堆空间,还包含一些其他的信息。主要包括代码部分(TEXT)以及全局数据(DATA),动态库(Dynamic Libraries),GPU驱动内存(GPU Driver Memory),malloc堆(malloc heap)以及其他空间。

在iOS设备上的内存空间是有限的,而这些对象所占用的有效内存空间又被归类为两类:

  • 脏内存(Dirty Memory):无法被系统主动回收的内存空间,主要包括:

    • 所有堆上的对象
    • 图片解码缓冲数据(Decoded image buffers)
    • Frameworks 中的 DATA 和 DATA_DIRTY部分
  • 干净内存(Clean Memory):可以被系统主动回收的内存空间且在需要时能重新加载的数据,主要包括:

    • Memory mapped files
    • Frameworks 中的 __DATA_CONST 部分
    • 应用的二进制可执行文件

比如在iOS中常见的字符串对象NSString可能由于生成方式不同就归属为不同空间类型:

1
2
NSString *welcomeMessage = @”Welcome to WWDC!”;// Clean
NSString *welcomeMessage = [NSString stringWithString:@”Welcome to WWDC!”];// Dirty

而无法被检索或者重新创建的内存空间被称为无效内存,回收它唯一的办法只有终止它的所属进程。其中App产生的大部分空间内存属于Dirty Memory,一个高效的操作系统必然会有一套高效的内存管理方案。接下来我们从两个层次上来学习iOS中如何管理内存空间的。

系统级的内存管理

根据文档上的描述,在iOS系统中App的基本内存管理原则是保证前台App的运行,可能回收后台App内存(这也是iOS中常常被说到的假后台机制)。首先当一个App在登录的时候,它所占用的Virtual Memory基本机构如下图:

在运行一段时间后这个App的内存占用大幅度上涨(主要是Dirty Memory),这个时候让系统感受到了内存压力较大,它就会触发内存回收,把后台应用的内存空间回收回来给前台应用使用。


内存管理的总体行为如上面,在查找各种文档后找到其中几种回收管理的具体技术:

对于iOS中的Compressed Memory技术的由来引用一段话:

由于闪存容量和读写寿命的限制,iOS 上没有Disk swap机制,取而代之使用 Compressed memory。 Disk swap 是指在 macOS 以及一些其他桌面操作系统中,当内存可用资源紧张时,系统将内存中的内容写入磁盘中的backing store (Swapping out),并且在需要访问时从磁盘中再读入 RAM (Swapping in)。与大多数 UNIX 系统不同的是,macOS 没有预先分配磁盘中的一部分作为 backing store,而是利用引导分区所有可用的磁盘空间。

苹果最初只是公开了从 OS X Mavericks 开始使用 Compressed memory 技术,但 iOS 系统也从 iOS 7 开始悄悄地使用。从 OS X Mavericks Core Technology Overview 文档中可以了解到该技术在内存紧张时能够将最近使用过的内存占用压缩至原有大小的一半以下,并且能够在需要时解压复用。它在节省内存的同时提高了系统的响应速度,其特点可以归结为:

  • Shrinks memory usage 减少了不活跃内存占用
  • Improves power efficiency 改善电源效率,通过压缩减少磁盘IO带来的损耗
  • Minimizes CPU usage 压缩/解压十分迅速,能够尽可能减少 CPU 的时间开销
  • Is multicore aware 支持多核操作

程序运行时所需的内存空间是一个动态的过程。在内存可用空间紧张的时候桌面系统利用Disk Swap机制搬回磁盘中增大物理内存空间可用量,iOS系统则利用压缩内存技术将一部分Dirty Memory压缩增加当前物理内存空间可用量。

在当压缩脏内存依然不解决问题,导致内存压力过大则会触发内存警报(有可能还会先尝试先把后台App的内存回收回来,未找到详细细节资料表明这一点),可通过以下四种途径获取警报:

  • applicationDidReceiveMemoryWarning协议方法
  • [UIViewController didReceiveMemoryWarning] 实例方法
  • UIApplicationDidReceiveMemoryWarningNotification 内存警告通知
  • DISPATCH_SOURCE_TYPE_MEMORYPRESSURE Dispatch触发源

这时候开发者收到内存警报后可以主动回收程序的内存空间,以达到缓解系统内存空间使用压力。假如App的占用内存还是控制不住的时候最终将导致OOM(Out of Memory)崩溃。这个内存崩溃依赖于系统提供的低内存回收机制——Jetsam。

对于Jetsam的设计细节探究文档还比较少,从MemoryPressureiOS内存abort(Jetsam) 原理探究这两篇文章大致可以理解在iOS系统中存在一种系统级的内存回收服务(Jetsam)。它是一个常驻在系统中的进程,它在内核中的线程执行优先级比其他进程的执行优先级都要高,这也是它能够强制回收内存空间的缘故。

程序级的内存管理

上面提到了在iOS系统中是如何保证一个App正常运行所需的内存空间,而显然它的能力也是有上限的,不可能整出一个无限大的运行空间。即需要我们在程序设计上主动管理内存创建、释放过程从而保证这个程序能够运行正常、高效。

内存中的对象只有两种状态:存活和释放,在文档中官方建议避免出现长时间占用内存而不及时回收它的情况:

那么在iOS系统中应该如何通过程序设计来实现这一目的呢?

首先在iOS系统中使用引用计数来管理对象内存,它的原理也很简单,当一个对象的计数器不为0的时候表示这个对象需要被使用,故而是需存活的,会占用一定的内存空间。当计数器为0的时候则表示这个对象不需要被使用了,故而是释放的,内存会在某个时机被系统回收掉。而依赖于这个技术原理开发者可以选择使用MRC或者ARC去设计程序。

ps:当然其他平台上可能还有其他管理对象内存的手段如:垃圾回收机制。

手动时代——MRC

MRC全称Manual Reference Counting即手工引用计数。在iOS早期版本中开发者们需要利用引用计数这个管理对象内存手段主动去管理内存空间。主动管理内存需要遵循四个基本原则:

  1. You own any object you create
    You create an object using a method whose name begins with “alloc”, “new”, “copy”, or “mutableCopy” (for example, alloc, newObject, or mutableCopy).
  1. You can take ownership of an object using retain
    A received object is normally guaranteed to remain valid within the method it was received in, and that method may also safely return the object to its invoker. You use retain in two situations: (1) In the implementation of an accessor method or an init method, to take ownership of an object you want to store as a property value; and (2) To prevent an object from being invalidated as a side-effect of some other operation (as explained in Avoid Causing Deallocation of Objects You’re Using).
  1. When you no longer need it, you must relinquish ownership of an object you own
    You relinquish ownership of an object by sending it a release message or an autorelease message. In Cocoa terminology, relinquishing ownership of an object is therefore typically referred to as “releasing” an object.
  1. You must not relinquish ownership of an object you do not own
    This is just corollary of the previous policy rules, stated explicitly.
  1. 自己创建自己持有
  2. 非自己生成的对象,自己也可以持有
  3. 不需要的自己持有的对象时释放
  4. 非自己持有的对象无法释放

从目的考虑就是要保证对象的计数器在被使用的时候应该是不为0,当不需要被使用的时候为0,达到及时回收内存空间的目的。在MRC中引起对象计数器变化的动作如下:

接下来我们来简单看几个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 对象使用完回收内存空间
{
Person *aPerson = [[Person alloc] init];// 计数器+1
// ...
NSString *name = aPerson.fullName;
// ...
[aPerson release];// 计数器-1,aPerson.counter == 0将执行内存释放函数
}

// 使用autorelease标记为延迟释放对象等[autoReleasePool drain]的时候再回收内存空间
- (NSString *)fullName {
NSString *string = [[[NSString alloc] initWithFormat:@"%@ %@",
self.firstName, self.lastName] autorelease];
return string;
}

// 这个stringWithFormat方法自动使用了autorelease标记
- (NSString *)fullName {
NSString *string = [NSString stringWithFormat:@"%@ %@",
self.firstName, self.lastName];
return string;
}

自动时代——ARC

ARC全称Auto Reference Counting即自动引用计数。Apple历来是一个对开发者友好的公司,它通过在编译器层级设计了一套能够自动插入对象引用计数管理代码的系统,将开发者们从繁琐的对象内存管理工作中解放出来。这里根据官方Transitioning to ARC Release Notes具体学习一下ARC的设计细节。

自动引用计数(ARC)是一种编译器功能,可提供对Objective-C对象的自动内存管理。ARC无需考虑保留和释放操作,而是使您可以专注于有趣的代码,对象图以及应用程序中对象之间的关系。

在Xcode4.2之后新建的代码源文件默认是使用ARC机制的,如果想要使用MRC可在工程的编译选项中target -> Build Phases -> Compile Sources将对应的源文件加上-fno-objc-arc选项才能开启MRC手动管理这个源码文件中的对象内存。

即在ARC环境下是禁止调用[object dealloc]/[object autorelease]/[object retainCount]/[object retain]/[object release]这些MRC环境下的对象内存管理方法函数的。

首先呢,ARC环境的作用范围是OC对象,而CoreFoundation或者其他C类型的指针对象/结构体需要转换成OC对象才能够享受到ARC。可以通过以下三种桥接方式来利用ARC:

  • __bridge可以桥接OC对象和Core Foundation,而不改变被转换对象的持有状态,当ARC控制的OC对象释放时对应的Core Foundation对象指针也会被释放。
  • __bridge_retainedCFBridgingRetain也能够桥接两者,不同的是它会变更被转换对象的持有状态,即会进行一步retain让它的计数器+1,编译器不会自动管理Core Foundation对象的内存,需要调用CFRelease()手动释放。
  • __bridge_transferCFBridgingRelease也能够桥接两者,不同的是它会变更被转换对象的持有状态,即会进行一步release,被转换的对象在赋值给目标对象后随之释放。

那么在ARC环境下对象是什么状态的呢?目前iOS中定义了四种修饰符来说明它们的状态:

  • __strong这是默认的动作,表明着这个对象是存活状态,持有强引用的变量在超出其作用域时被废弃,随着强引用失效,引用的对象会随之释放。
  • __weak弱引用一个对象,即不改变被引用对象的持有状态(计数器不变),同时当这个对象被释放后这个使用__weak修饰的变量会自动置为nil
  • __unsafe_unretained与weak类似不会修改被引用对象的持有状态(计数器不变),但这个对象释放后这个使用它修饰的变量的指针会悬空不会自动置为nil
  • __autoreleasing相当于MRC下[object autorelease]或者ARC下使用@autorelease{}管理的变量,将这个变量标记为自动释放。

在常规的编码实践中,我们最常用到的应该就是使用__weak去修饰一个变量,防止它在一个Block中被循环引用的这种场景了。因为在Objective-C的Block与Swift的闭包均能够捕获外部变量,假如我们使用默认的__strong强引用一个对象的时候,同时又循环引用了这个Block的时候变会造成内存泄漏。

绕不开的Autorelease与AutoreleasePool

前面提到了引用计数为0的时候对象会在某个时机被系统回收掉,那么具体是什么时候呢?这里要分为两种情况:立即执行内存释放自动执行内存释放

立即释放:使用[object release]将计数器-1,当对象计数器为0的时候会触发这个对象的内存回收dealloc。

自动(延迟)释放:当一个实例对象被我们使用[object autorelease]或者@autoreleasepool{}、NSAutoreleasePool标记为需要自动(延迟)释放的时候,则它会在距离它最近一层的自动释放池被清空(Pool drain)后才会进行[object release]将计数器-1。

那么一个空工程中我们貌似还未添加任意一行@autoreleasepool{}代码,那么目前存在的对象是如何被自动释放的呢?

答案在于当一个自动释放池被释放或者清空的时候同时会将存在于这个池子中的对象进行释放。而根据官方文档的描述,系统会为主线程在Runloop的每个事件循环(Event Loop)开始前创建一个AutoreleasePool,在这个事件循环结束的时候将它释放同时清空池子中的对象。同时其他线程都会维护自己的AutoreleasePool堆栈。

根据文档NSAutoreleasePool的描述中在MRC环境下我们可以使用NSAutoreleasePool对象来创建一个自动释放池:

1
2
3
4
5
6
7
8
9
10
NSArray *urls = <# An array of file URLs #>;
for (NSURL *url in urls) {
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];

NSError *error;
NSString *fileContents = [NSString stringWithContentsOfURL:url encoding:NSUTF8StringEncoding error:&error];
/* Process the string, creating and autoreleasing more objects. */

[pool release];
}

等价于在ARC环境下使用@autoreleasepool{}创建一个自动释放池:

1
2
3
4
5
6
7
8
9
NSArray *urls = <# An array of file URLs #>;
for (NSURL *url in urls) {
@autoreleasepool {
NSError *error;
NSString *fileContents = [NSString stringWithContentsOfURL:url
encoding:NSUTF8StringEncoding error:&error];
/* Process the string, creating and autoreleasing more objects. */
}
}

这么做的好处在于ARC环境下由于内层@autorlepool的存在for循环中的对象会在这个自动释放池release的时候同时也被释放,而不是等到外层(系统默认维护的那个池子)释放的时候再进行内存对象回收,这样就达到了避开可能存在的内存峰值问题。

原理应用:侦测App运行时内存情况

这里从MTHawkeye的Allocation模块开始学习我们可以从哪些方面与层次去记录iOS程序在运行时候的内存情况。在这个功能模块中实现的核心功能就是记录App的内存运行情况,它的侦测粒度精确到每个对象的内存生命周期。

1
2
3
4
5
6
7
8
这个模块暴露给外部以下两个控制的接口用于开启malloc/vmalloc的内存记录:
- (BOOL)startMallocLogging:(BOOL)mallocLogOn vmLogging:(BOOL)vmLogOn;
- (BOOL)stopMallocLogging:(BOOL)mallocLogOff vmLogging:(BOOL)vmLogOff;

而在内部处理之后中需要主动调用下面三个接口才能生成相关的序列化记录报告
- (void)generateReportAndSaveToFile;
- (void)generateReportAndSaveToFileInJSON;
- (void)generateReportAndFlushToConsole;

首先再回顾一下最开始在谈虚拟内存物理内存那里个小节里面,iOS的用户态通过malloc申请的内存空间(Heap)需要经过一系列的转换到内核空间的VM Object,这里内核空间中使用vmalloc申请虚拟内存。

所以上面接口中的mallocLogOn用于开启程序中用户态内存记录,vmLogging用于开启程序中内核态内存记录。

获取内存分配记录流程

首先在MTHawkeye Allocation中通过Hook [NSObject alloc]方法来实现内存分配记录:


同时在这个模块开启5秒后再加载程序中的镜像用于之后的符号化,这样子我们就拿到了运行过程中原始内存分配记录了。

接着对于侦测App运行中的内存情况这个行为本身产生的数据,如果不设计合理的数据结构和存储方式都将对整个模块性能带来极大的负担,显而易见的因为[NSObject alloc]是一个调用频率超级高的方法。这一部分该模块采用Sply Tree(伸展树)做为存储这些记录的数据结构,而通过mmap方式进行数据io存储方式(与微信内存监控方案一致)。

当需要的时候最终调用上方的三个数据报告生成函数,这部分工作里面无非是将数据格式化和符号化。

最后

对于性能监控,内存是一个永恒的话题,理解这些细节有助于我们更好的搬砖~。近期发布的iOS 13提供了一个全新的框架——MetricKit用来衡量iOS设备的性能指标,官方建议通过这个框架获取一些App运行的性能情况并根据这份报告去优化App的性能表现。

参考

WWDC2018-416
WWDC2012-242
Improving Your App’s Performance
WWDC2018 - 深入解析iOS内存 iOS Memory Deep Dive
理解iOS中的内存结构
iOS 内存管理研究
iOS 内存探秘
iOS中的内存管理

What is the iOS jetsam, and how does it exactly work?
iOS内存abort(Jetsam) 原理探究
OOM问题小记
iOS底层探索 - ARC下的dealloc
iOS 中的 Autorelease Pool

Hawkeye - Allocations