iOS 内存管理从理解到应用

一 从操作系统说起
1 计算机内存机制

从 1946 年第一台计算机 ENAIC 诞生, 计算机已经发展了 70 多年, 但是现在计算机结构仍然沿用了冯.诺依曼模型:

image.png

冯.诺依曼理念中把存储器独立出来, 将写好的程序放在存储器中, 由 CPU 执行程序指令并输出结果. 存储器和 CPU 的分离, 允许存储器中存放不同的程序, 增大了计算机应用的灵活性, "编程"的概念就由此诞生了.

冯·诺依曼结构中以存储器为逻辑中心, 存放着程序的指令和数据, 在程序运行时,根据需要提供给 CPU 使用, 占有十分重要的地位. 可以想象, 一个理想的存储器,应该是兼顾读写速度快、容量大、价格便宜等特点,但是鱼和熊掌不可兼得,读写速度越快的存储器也更贵、容量更小。

而且冯·诺依曼结构存在一个难以克服的问题,被称为冯·诺依曼瓶颈 —— 在目前的科技水平之下,CPU 与存储器之间的读写速率远远小于 CPU 的工作效率。简单来说就是 CPU 太快了,存储器读写速度不够快,造成了 CPU 性能的浪费。

既然没办法获得完美的存储器,那如何尽量突破冯·诺依曼结构的瓶颈呢?现行的解决方式就是采用多级存储,来平衡存储器的读写速率、容量、价格。

存储器主要分为两类:
1 易失性存储器, 也称为随机访问存储器(RAM), 包括SRAM/DRAM, 速度更快,断电之后数据会丢失.

2 非易失性存储, 也称为只读存储器(ROM), 包括 Flash Memery(U盘)/SSD(硬盘). ROM 只有最开始是只读的, 随着不断发展也可以进行读写了, 但是沿用了以前的名称. 容量更大、价格更低,断电也不会丢失数据。

SRAM 速度更快,通常用作高速缓存,DRAM 用作主存。

image.png

从硬件角度看, SRAM 一般放在CPU组成部分. 通常所说的内存, 指的是 DRAM , 称为物理内存. 将内存可以看做一定长度的一维数组, 每一个元素存放了一系列二进制数据, 元素的编号代表了内存的地址, 通常称为物理地址.

刚开始的程序直接操作物理内存, 对物理地址的错误操作, 很容易引起内存的问题, 最常见的就是越权访问. 而且在 CPU 同时运行不同进程时, 两者可能相互干扰. 后来的发展也有对物理内存进行分段, 但是程序直接操作物理内存的危险仍然存在.

2 虚拟内存

为了有效管理内存, 现代操作系统采用虚拟内存的方式, 为每个进程提供一个一致的、私有的地址空间, 让每个进程产生了一种独享主存的错觉. 简单理解就是进程运行时, 操作系统首先在硬盘上分配一系列地址, 称为虚拟地址(虚拟地址并不需虚, 指磁盘上的地址), 而虚拟地址到主存物理地址的转化, 由操作系统来决定.

一个典型的虚拟内存, 也是我们经常听到的内存构造, 如下图所示:

image.png

通过虚拟内存, 计算机为进程提供了如下能力:
(1) 每个进程只能在分配的硬盘地址中进行操作, 彼此相互隔离, 保护了进程的地址空间不会被其他进程破坏;
(2) 为每个进程提供了一个一致的地址空间,降低了程序员对内存管理的复杂性;
(3) 虚拟内存使用硬盘作为内存, 而将物理内存当做高速缓存, 增大了内存空间;

3 内存读取

虚拟内存中的地址, 需要映射到物理内存中, 才能在计算机执行被 CPU 调用. 为了方便管理虚拟内存和物理内存的映射关系, 计算机对虚拟内存和物理内存都进行了分割处理. 将虚拟内存和物理内存分割成大小相同块, 在虚拟内存称为页(Page), 在物理内存中称为帧(Frame), 两者映射的关系表称为页表(PageTable), 页表中每一个条目称为 PageTableEntry. 虚拟内存和物理内存简单的映射关系如下图:

image.png

为了加快 CPU 的读取速度, PT 常驻在物理内存中. PTE 中的有效位表示该页表是否已经缓存在物理内存中, 1代表缓存, 0代表未缓存; ADDRESS 表示虚拟地址中的空间已经被分配, NULL 表示虚拟地址中的空间还未被占用.

当 CPU 执行需要读取内存中的数据时, 需要经历如下步骤:

image.png

1 通过 PTE 查找虚拟地址, 如果查询到有效位为1, 说明虚拟地址已经在缓存中, 称为页命中, 此时内存返回PTE;
2 CPU 通过内存管理模块 (MMU) 从 PTE 中解析虚拟地址对应的物理地址;
3 访问 DRAM 中的物理地址;

以上步骤 2 中, 如果有效位为0, 说明虚拟地址还未缓存到物理地址中, 称为缺页. 此时操作系统需要将缺页的虚拟内存读取到物理内存中. 如果此时物理内存已被占满, 首先需要选择页面移出; 如果移出的页面已经改变, 需要将改变的内存页先写回磁盘上的虚拟地址. 一个完整的 CPU 读取数据的流程如下图:

image.png

(1) 从物理读取 PTE ;
(2) 物理内存将移出的帧写回磁盘上的虚拟地址;
(3) 将虚拟地址读取到物理内存中, 更新 PTE;
(4) 从物理内存读取更新后的 PTE;
(5) CPU使用 MMU 翻译出物理内存地址, 读取数据.

以上流程中存在 2 个问题:
(1) CPU 每次从 DRAM 读取数据, 至少访问 DRAM 2 次;
(2) PTE 在内存中常驻, 占用 RAM 空间. 比如一个 32 位的操作系统, 可索引虚拟地址为 4G, 虚拟页 Page 大小为 4K, 一个Page Table Entry 的大小为 4 个字节 (Byte), 那么一共 1M 个 Page Table Entry, 需要占用内存 DRAM 4M空间;

现在的内存设计中, 在 CPU 中设置了 TLB(Translation Lookaside Buffer) 缓存, 将部分 PTE 直接缓存在 CPU 中, 可以直接供 CPU 读取; 对内存中的 PTE, 进行多次分级缓存, 只将第一级 PTE 常驻在内存中.

image.png

综上, CPU 寻址的流程如下:

image.png

首先从 CPU 内部的 TLB 缓存中直接寻找虚拟地址对应的物理地址; 如果没有命中, 再去 DRAM 中的 PT 中进行查找; 如果查询的虚拟地址没有在物理内存缓存, 则先在物理内存中清理出可用的空间, 再将虚拟地址置换到物理内存中, 更新 PTE; 最后通过 MMU 翻译出物理地址, 从物理地址中读取数据.

二 iOS 的内存机制

1 iOS 的内存特点

对移动设备来说,受限于客观条件,物理内存容量本身较小, 同样 iPhone 的 RAM 也是偏小的. 最新的 iPhone 11 Pro Max 和 2020 年的 iPhone SE, RAM(DRAM) 只有 4G. 而现在的小米 10 分为 8G/12G, 华为 P40 同样是 8G/12G. 但是, iOS 系统为单个 32 位的进程提供最高达 4G 的虚拟空间, 基本等于 RAM 的大小.

OSX 中的内存读取时, 存在物理地址和虚拟地址的交换. 而 iOS 设备中不存在内存交换. 一方面是因为相比 OSX, iOS 中的 RAM 容量小, 交换的次数会很多; 另一方面, 移动端采用体积更小的闪存作为存储设备, 使用寿命有限.

iOS 设备中内存读取是内存压缩的方式. 当物理内存完全被占用时, iOS 会根据帧的活跃程度, 对不活跃的内存进行压缩以腾出更多可用物理内存. 因为 CPU 的运算速度远远高于内存读写速度, 将内存压缩也可以看做用时间换取空间的方式, CPU 的高速度又能保证需要数据时, 对数据的快速解压缩.

iOS 中通过 Jetsam(船超重时的投弃物) 机制对进程进行管理. Jetsam 独立运作, 每一个进程都有一个 "high water mark"(HWM). 一旦进程使用的内存超过 HWM , Jetsam 就会直接把进程 kill 掉. 当内存不够用时, iOS 一般会向进程发出内存警告(主线程太忙时, 警告不会发出). 在 iOS 上, 一个进程就是一个 App, 发出内存警告时, 就会触发内存的 didReceiveMemoryWarning() 方法, 告知App 应该清理一些不必要的内存, 来释放一定的内存空间.

HWM 的大小和设备有关, stack overflow 上,有人对单个 app 能够使用的最大内存做了统计:iOS app max memory budget :

* 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)

以 iPhone11 Pro Max 为例, RAM 的大小为3740M, App 可以使用的内存大小为2067M, 占有RAM 的 55%. 当 App 使用的内存超过了这个大小, 就会发生 OOM 奔溃. 从临界值的大小来说, 如果发生 OOM, 肯定是程序本身有问题.

2 iOS App 内存占用

分析了 iOS 内存特点, 能够意识到合理控制 App 使用的内存是十分重要的一件事情. 尽量减少内存占用能够避免OOM, 加快运行速度, 进而提升用户体验.

那么具体来说, 需要减少的是哪些部分呢? 这就是 App 内存占用(Memory Footprint)部分.

iOS 中将 App 占用的内存分为 3 种: DirtyCompressedClean.

Clean 指能够被系统 Page Out 的部分, 由于 iOS 不存在内存交换, Page Out 指可以重新创建的部分, 包括二进制可执行文件、训练的 models, 未写入数据的虚拟内存, 以及 Framework 中的 DATA CONST 段.
Dirty 对于 Framework, 当被 App 链接后使用, 比如 App 在运行时进行了方法交换, 那么就会变成 Dirty. Dirty 指被 app 写入数据, 无法重建的部分. 比如 malloc 后写入内容的部分, 包括 strings, arrays 和所有对象.
Compressed iOS 在内存使用紧张时, 对不活跃的内存会进行压缩节省空间, 当使用时, 会快速解压. Compressed 本质上也属于 Dirty .

当谈论 iOS 内存占用时, 指的是 Dirty 和 Compressed 的部分.

3 OOM 的处理

当 App 收到内存警告时, 一般通过释放缓存腾出内存空间, 但是因为 Compressed 的原因, 可能会导致意想不到的结果.

image.png

比如上面的情况, 使用 Dictionary 对数据进行了缓存, 当收到内存警告将 Dictionary 进行释放时, 因为访问了 Dictionary, 首先需要对 Dictionary 进行解压缩, 会多占用 2 个 clean 的内存空间. 将对象释放之后, Dictionary 变为 1 个空间大小. 对 Dictionary 解压的操作和释放内存的初衷是相违背的, 可能导致 Jetsam 直接杀死 App 进程.

所以, iOS 建议使用 NSCache 代替 NSDictionary 作为缓存, NSCache 是 actually purgeable, 可以避免上面的错误.

对 App 进程来说, HWM 的限制比较高, 但是该限制和不同的设备有关, 如果想要让 App 在所有的设备上正常运行, 不发生 OOM, 就需要合理控制内存占用(Memory Footprint). 而且对于 Extention 来说, HWM 的限制更低.

4 如何检测内存占用?

检测内存的利器当属 Instrument中的 Allocation 工具. 在Build Setting 中, 设置DebugDWARF with dsym file 选项, 在 Allocation 中, 可以查看到代码执行情况. 下面是一段关联代码占用的内存使用情况:

image.png

能够看到 self.iconView.image = image 消耗了该代码段 59% 的内存, 是因为 UIImageView 显示图片时, 首先需要对图片进行 decode.

2018 年的 WWDC 大会上 Memory Dive, 介绍了另外一种查看内存的方式: Memory Graph, 配合命行工具, 能够将 App 内存一览无遗. 例如, 在某一时刻, 导出内存描述文件 TestMemory3.memgraph.

image.png
(1) vmmap

Terminal中使用 vmmap TestMemory3.memgraph --summary 查看内存情况:

Memory1

Memory2

从图Memory2的结果中, 能够看到一共占用了 1.9G 的虚拟空间; 其中交换到物理内存中的 Resident Size 为 536.7M; Dirty Size 为 212.5M, 这个数据和 Xcode Navigator 中的数据最相近.

Memory1 中的 Region TYPE 代表占用内存的对象类型.下面是搜集到的对不同内存对象类型的资料(欢迎不断完善):
(1) CG raster data:光栅数据,即为UIImage的解码数据;
(2) CoreAnimation: UIView/CALayer;
(3) Image IO: 能够在不产生 dirty memory 的情况下读取到图片尺寸和元信息,其内存损耗等于缩减后的图片尺寸产生的内存占用. Apple 开放了使用这种方式处理图片的API, 后面会详细说明.

使用 AWK 参数, 可以对vmmap显示的结果进行筛选:

vmmap TestMemory3.memgraph --pages | grep '.dylib' | awk '{ sum+= $6} END {print "Total Dirty Pages: " sum }'查看 dylib 所占的 dirty 页总数:

Total Dirty Pages: 118.07

vmmap -verbose TestMemory3.memgraph | grep "Image IO"查看类型每段详情:

image.png
(2) leaks

使用leaks命令, 能够很详尽的查询内存泄漏(修复了以前instrument 中上报的红点难以定位的缺陷). leaks TestMemory3.memgraph:

image.png

上图显示一共出现 6 个内存泄漏对象, 共耗费虚拟内存 224bytes. 定位内存泄漏对象, 可以参考显示的根节点信息:

image.png

上图显示Person类的一个实例, 虚拟内存地址为ox101f00280处形成了循环引用.

(3) heap

需要开发者管理的内存问题, 集中在虚拟内存的堆上, 使用heap命令可以详细查看堆内存分配, heap TestMemory3.memgraph:

image.png

查看占用最大内存的对象, heap TestMemory3.memgraph -sortBySize:

image.png

如果发现某个类占用的内存异常, 可以查看该类所有实例的虚拟地址, heap -addresses WBLog TestMemory3.memgraph:

Active blocks in all zones that match pattern 'WBLog':
0x1149a0c30: WBLog (64 bytes)
(4) malloc_history

打开 schema 中的 log 后, 可以根据地址追踪类实例化的过程.

image.png

查看实例对象的初始化过程
malloc_history TestMemory3.memgraph 0x103100c20 -callTree
malloc_history TestMemory3.memgraph 0x103100c20 -fullStacks

image.png

有以上 vmmap/leaks/heap/malloc_history 4 个工具可以分析内存文件memgraph, 遇到问题该如何选择这 4 工具呢? 苹果工程师给出的建议: 查看对象创建的过程使用 malloc_history; 查看对象引用使用 leaks ; 查看各对象大小使用 vmmap/heap.

5 image
(1) 内存占用

最常见的占用内存比较大的对象就是 image. 对 image 的深入了解, 对开发者管理 iOS 内存很有帮助. 首先需要明确一张 image 所占用的内存大小和它的像素有关, 而不是其文件大小.

将一张大小用于上传 AppStore 尺寸为 1024x1024 文件大小为 59kb 的图片显示在 App 页面中, 通过allocation工具查看内存占用情况, 能够看出占用的虚拟内存大小为4M.

image.png

image占用的内存, 除了和尺寸大小有关, 还和其文件格式有关. 例如前面的图片为png格式, 1个像素占用 4 个字节来描素 RGBA 信息; sRGB格式, 为目前比较通用的全色彩图像色域, 一个像素也占 4 个字节; Wide 格式, 相比 sRGB 能表示的颜色更多, 一个像素占 8 个字节; 而灰度格式, 一个像素占用 1 个字节.

(2) 使用

开发者该如何选择正确的图片格式呢? Apple 准备好了处理图片的API, 让系统自己去选择最佳格式. 使用 UIGraphicsImageRenderer 替换 UIGraphicsBeginImageContextWithOptions, 会自动选择最佳图像格式, 减少内存占用.

2.1) 获取自定义图片:

let bounds = CGRect(x: 0, y: 0, width:300, height: 100)
let renderer = UIGraphicsImageRenderer(size: bounds.size)
let image = renderer.image { context in
    // Drawing Code
    UIColor.black.setFill()
    let path = UIBezierPath(roundedRect: bounds,
                            byRoundingCorners: UIRectCorner.allCorners,
                            cornerRadii: CGSize(width: 20, height: 20))
    path.addClip()
    UIRectFill(bounds)
}
let imageV = UIImageView(frame: CGRect(x: 10, y: 300, width: 300, height: 100))
imageV.image = image.withRenderingMode(.alwaysTemplate)
imageV.tintColor = .blue
view.addSubview(imageV)

2.2) 处理图片缩放

image 在设置和调整大小的时候,需要将原始图像加压到内存中,然后对内部坐标空间做一系列转换,整个过程会消耗很多资源。可以使用 ImageIO,它可以直接读取图像大小和元数据信息,不会带来额外的内存开销。

let path = Bundle.main.path(forResource: "icon_big.png", ofType: nil)!
let image = UIImage(contentsOfFile: path)!
// Resizing image
let scale:CGFloat = 0.2
let size = CGSize(width: image.size.width * scale, height: image.size.height *
scale)
let renderer = UIGraphicsImageRenderer(size: size)
let resizedImage = renderer.image { context in
    image.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
}

2.3) 获取缩略图:

let url = NSURL(fileURLWithPath: path)
let imageSource = CGImageSourceCreateWithURL(url, nil)!
//使用ImageIO获取图片元数据
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
let options: [NSString: Any] = [
    kCGImageSourceThumbnailMaxPixelSize: 100,
    kCGImageSourceCreateThumbnailFromImageAlways: true
]
let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0,
options as CFDictionary)
(3) 及时释放大图片

在 App 里展示了一张很大图片时, 会占用大量内存. 当 App 切换到后台或者离开页面时, 应该及时释放掉该图片. 比如在页面ViewWillDiappear方法, 监听App进入后台的通知 UIApplication.willEnterForegroundNotification. 在需要时, 再次加载. 另外使用 imageNamed: 创建的图片会一直停留在内存中.

喜欢和关注都是对我的鼓励和支持~

参考:
1 iOS Memory 内存详解 (长文)
2 WWDC 2018:iOS 内存深入研究
3 深入理解虚拟内存机制
4 虚拟内存的那点事儿
5 深入虚拟内存(Virtual Memory,VM)
6 iOS Memory Deep Dive
7 iOS Memory Deep Dive

你可能感兴趣的:(iOS 内存管理从理解到应用)