学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

iOS Memory 内存详解

iOS是基于BSD发展而来,本文将涉及一般桌面系统的内存机制,然后着重讲解iOS系统层面,单个App的内存管理策略

操作系统的内存机制

冯诺伊曼结构问题

冯诺伊曼瓶颈-在目前的科技水平之下,CPU与存储器之间的读写速度远远小于CPU的工作效率。现行的解决方式就是采用多级存储,来平衡存储器的读写速率、容量、价格。

存储器的层次结构

存储器主要分为两类:易失性存储器速度更快,断电之后数据会丢失;
非易失性存储器容量更大,价格更低,断电不会丢失数据

平时常说的内存,实际上就是指的L4主存。而L1-L3高速缓存都已经集成在CPU芯片内部。其中L0寄存器本身就是CPU的组成部分之一,读写速度最快,操作耗费0个时钟周期。

为什么采用缓存就能够提高效率呢?
因为存在局部性原理,被使用过的存储器内容在未来可能会被多次使用,以及它附近的内容大概率被使用

CPU寻址方式

物理寻址

内存可以被看作一个数组,数组元素是一个字节大小的空间,而数组索引则是所谓的物理地址。最简单最直接的方式,就是CPU直接通过物理地址去访问对应的内存,这样也被叫做物理寻址。

物理寻址后来也扩展支持了分段机制,通过在CPU中增加段寄存器,将物理地址变成“段地址”:“段内偏移量”的形式,增加了物理寻址的寻址范围。

地址空间缺乏保护?
因为直接暴露物理地址,进程可以访问到任何物理地址,用户进程想干嘛就干嘛,这是非常危险的。

虚拟寻址

现代处理器使用的是虚拟寻址的方式,CPU通过访问虚拟地址,经过翻译获取物理地址,才能访问内存,这个翻译过程由CPU中的内存管理单元MMU完成。

具体流程如下图:

首先会在 TLB(Translation Lookaside Buffer)中进行查询,它表位于 CPU 内部,查询速度最快;如果没有命中,那么接下来会在页表(Page Table)中进行查询,页表位于物理内存中,所以查询速度较慢;最后如果发现目标页并不在物理内存中,称为缺页,此时会去磁盘中找。当然,如果页表中还找不到,那就是出错了。

虚拟内存

如何解决直接使用物理地址,会有地址空间缺乏保护的问题?

在使用虚拟寻址之后,由于每次都会进行一个翻译过程,所以可以在翻译中增加一些额外的权限判定,对地址空间进行保护。所以,对每个进程来说,操作系统可以为其提供一个独立的、私有的、连续的地址空间,这就是所谓的虚拟内存。

虚拟内存最大的意义就是保护了进程的地址空间,使得进程之间不能够越权进行互相地干扰。

对于进程来说,它的可见部分只有分配给它的虚拟内存,而虚拟内存实际上可能映射到物理内存以及磁盘的任何区域。由于硬盘读写速度并不如内存快,所以操作系统会优先使用物理内存空间,但是当物理内存空间不够时,就会将部分内存数据交换到硬盘上去存储,这就是所谓的 Swap 内存交换机制。有了内存交换机制以后,相比起物理寻址,虚拟内存实际上利用硬盘空间拓展了内存空间。

虚拟内存有下面几个意义:保护了每个进程的地址空间、简化了内存管理、利用硬盘空间拓展了内存空间。

内存分页

虚拟内存和物理内存建立了映射的关系。为了方便映射和管理,虚拟内存和物理内存都被分割成相同大小的单位,物理内存的最小单位被称为帧(Frame),而虚拟内存的最小单位被称为页(Page)。

页和帧大小相同,有着类似函数的映射关系,前文提到的借助 TLB、页表进行的翻译过程,实际上和函数的映射非常类似。

内存分页最大的意义在于,支持了物理内存的离散使用。由于存在映射过程,所以虚拟内存对应的物理内存可以任意存放,这样就方便了操作系统对物理内存的管理,也能够可以最大化利用物理内存。同时,也可以采用一些页面调度(Paging)算法,利用翻译过程中也存在的局部性原理,将大概率被使用的帧地址加入到 TLB 或者页表之中,提高翻译的效率

iOS的内存机制

iOS使用了虚拟内存机制

内存有限,但单应用可用内存大

iOS系统给每个进程分配的虚拟内存空间非常大,据官方文档的说法,iOS为每个32位的进程都会提供高达4G的可寻址空间。

没有内存交换机制

iOS不支持内存交换机制,大多数移动设备都不支持内存交换机制。移动设备上的大容量存储器通常都是闪存(flash),它的读写速度远远小于电脑所使用的硬盘,这就导致了在移动设备就算使用内存交换机制,也并不能提升性能。其次,移动设备的容量本身就经常短缺、闪存的读写寿命也是有限的,所以这种情况下还拿闪存来做内存交换,就有点太过奢侈了。

内存警告

当内存不够用时,iOS 的处理是会发出内存警告,告知进程去清理自己的内存。代码中的 didReceiveMemoryWarning() 方法就是在内存警告发生时被触发,app 应该去清理一些不必要的内存,来释放一定的空间。

OOM 崩溃

如果 app 在发生了内存警告,并进行了清理之后,物理内存还是不够用了,那么就会发生 OOM 崩溃,也就是 Out of Memory Crash

以 iPhone XS Max 为例,总共的可用内存是 3735 MB(比硬件大小小一些,因为系统本身也会消耗一部分内存),而单个 app 可用内存达到 2039 MB,达到了 55%。当 app 使用的内存超过这个临界值,就会发生 OOM 崩溃。可以看出,单个 app 的可用物理内存实际上还是很大的,要发生 OOM 崩溃,绝大多数情况下都是程序本身出了问题。

iOS系统内存占用

合理控制 app 使用的内存是非常重要的一件事。那么具体来说,我们需要减少的是哪些部分呢?实际上这就是所谓的 iOS 内存占用(Memory Footprint)的部分。

内存页也有分类,一般来说分为 clean memorydirty memory 两种,iOS 中也有 compressed memory 的概念。

Clean memory & dirty memory

对于一般的桌面操作系统,clean memory 可以认为是能够进行 Page Out 的部分。Page Out 指的是将优先级低的内存数据交换到磁盘上的操作,但 iOS 并没有内存交换机制,所以对 iOS 这样的定义是不严谨的。那么对于 iOS 来说,clean memory 指的是能被重新创建的内存,它主要包含下面几类:

  1. app 的二进制可执行文件
  2. framework 中的 _DATA_CONST 段
  3. 文件映射的内存
  4. 未写入数据的内存

内存映射的文件指的是当 app 访问一个文件时,系统会将文件映射加载到内存中,如果文件只读,那么这部分内存就属于 clean memory。另外需要注意的是,链接的 framework 中 _DATA_CONST 并不绝对属于 clean memory,当 app 使用到 framework 时,就会变成 dirty memory。
未写入数据的内存也属于 clean memory

所有不属于 clean memory 的内存都是 dirty memory。这部分内存并不能被系统重新创建,所以 dirty memory 会始终占据物理内存,直到物理内存不够用之后,系统便会开始清理

Compressed memory

当物理内存不够用时,iOS 会将部分物理内存压缩,在需要读写时再解压,以达到节约内存的目的。而压缩之后的内存,就是所谓的 compressed memory。

很多桌面操作系统早已经应用了内存压缩技术,比如 Windows 中的 memory combining 技术。这本质上来说和内存交换机制类似,都是是一种用 CPU 时间换内存空间的方式,只不过内存压缩技术消耗的时间更少,但占用 CPU 更高。不过在文章最开始,我们就已经谈到由于 CPU 算力过剩,在大多数场景下,物理内存的空间相比起 CPU 算力来说显然更为重要,所以内存压缩技术非常有用。

使用 compressed memory 能在内存紧张时,将目标内存压缩至原有的一半以下,同时压缩和解压消耗的时间都非常小。对于 OS X,compressed memory 也能和内存交换技术共用,提高内存交换的效率,毕竟压缩后再进行交换效率明显更高,只是 iOS 没有内存交换,也就不存在这方面的好处了。
本质上来讲,compressed memory 也属于 dirty memory。

内存占用组成

对于 app 来说,我们主要关心的内存是 dirty memory,当然其中也包含 compressed memory。

按照正常的思路,app 监听到内存警告时应该主动清理释放掉一些优先级低的内存,这本质上是没错的。不过由于 compressed memory 的特殊性,所以导致内存占用的实际大小考虑起来会有些复杂。

比如上面这种情况,当我们收到内存警告时,我们尝试将 Dictionary 中的部分内容释放掉,但由于之前的 Dictionary 由于未使用,所以正处于被压缩状态;而解压、释放部分内容之后,Dictionary 处于未压缩状态,可能并没有减少物理内存,甚至可能反而让物理内存更大了。
所以,进行缓存更推荐使用 NSCache 而不是 NSDictionary,就是因为 NSCache 不仅线程安全,而且对存在 compressed memory 情况下的内存警告也做了优化,可以由系统自动释放内存

iOS内存管理

一个iOS App对应的进程地址空间大概如下图所示:

代码段(text) 通常用于存放程序执行代码(CPU执行的机器指令)
数据段(Data) 存放程序中已初始化且初始值不为0的全局变量和静态局部变量。数据段属于静态内存分配(静态存储区),可读可写。
BSS段:

  1. 未初始化的全局变量和静态局部变量
  2. 初始值为0的全局变量和静态局部变量(依赖于编译器实现)
  3. 未定义且初值不为0的符号(该初值即common block的大小)

堆(heap)
堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。

内存映射段(mmap)

内核将硬盘文件的内容直接映射到内存, 任何应用程序都可通过Linux的mmap()系统调用请求这种映射。内存映射是一种方便高效的文件I/O方式,。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read()/write()等操作。 因而被用于装载动态共享库。用户也可创建匿名内存映射,该映射没有对应的文件, 可用于存放程序数据

mmap/munmap是常用的一个系统调用,使用场景是:分配内存、读写大文件、连接动态库文件、多进程间共享内存。

malloc申请内存的大小超过128K就会使用mmap分配内存,在堆和栈之间找一块空闲内存分配(对应独立内存,而且初始化为0)

栈(stack)
由编译器自动分配释放
Linux中ulimit -s命令可查看和设置堆栈最大值,当程序使用的堆栈超过该值时, 发生栈溢出(Stack Overflow),程序收到一个段错误(Segmentation Fault)。

每个区域实际上都存储相应的内容,其中代码区、常量区、静态区这三个区域都是自动加载,并且在进程结束之后被系统释放,开发者并不需要进行关注。

栈区一般存放局部变量、临时变量,由编译器自动分配和释放,每个线程运行时都对应一个栈。而堆区用于动态内存的申请,由程序员分配和释放。一般来说,栈区由于被系统自动管理,速度更快,但是使用起来并不如堆区灵活。

OOM崩溃

Jstsam机制

iOS 是一个从 BSD 衍生而来的系统,其内核是 Mach。其中内存警告,以及 OOM 崩溃的处理机制就是 Jetsam 机制,也被称为 Memorystatus。Jetsam 会始终监控内存整体使用情况,当内存不足时会根据优先级、内存占用大小杀掉一些进程,并记录成 JetsamEvent

如何检测 OOM

OOM 分为两大类,Foreground OOM / Background OOM,简写为 FOOM 以及 BOOM。而其中 FOOM 是指 app 在前台时由于消耗内存过大,而被系统杀死,直接表现为 crash

Facebook 开源的 FBAllocationTracker

腾讯开源的 OOMDetector

OOM 常见原因

内存泄漏

UIWebview 缺陷

无论是打开网页,还是执行一段简单的 js 代码,UIWebView 都会占用大量内存,同时旧版本的 css 动画也会导致大量问题,所以最好使用 WKWebView

大图片、大视图
缩放、绘制分辨率高的大图片,播放 gif 图,以及渲染本身 size 过大的视图(例如超长的 TextView)等,都会占用大量内存,轻则造成卡顿,重则可能在解析、渲染的过程中发生 OOM

参考

iOS Memory 内存详解
linux进程地址空间划分