iOS为什么使用虚拟内存

·  阅读 2570

先说点题外话

我们都知道,苹果对APP占用硬件资源管理的很严,更不要说应用后台时候的资源占用了

正常情况下,使用应用时APP从硬盘加载到内存开始工作;当用户按下home键APP便被挂起,依然驻留在内存中,这种状态下不调用苹果已经开放的几种后台方法,程序便不会运行;如果这个时候,使程序继续运行,则为后台状态;如果当前内存将不够用时,系统会自动把之前挂起状态下的APP请出内存.所以我们看到,有些时候打开APP时,还是上次退出时的那个页面那些数据,有时则是重新从闪屏进入

什么是虚拟内存

虚拟内存是一种允许操作系统避开物理RAM限制的内存管理机制。虚拟内存管理器为每个进程创建一个逻辑地址空间或者虚拟内存地址空间,并且将它分配为相同大小的内存块,可称为.处理器与内存管理单元MMU维持一个页表来映射程序逻辑地址空间到计算机RAM的硬件地址.当程序的代码访问内存中的一个地址时,MMU利用页表将指定的逻辑地址转换为真实的硬件内存地址,这种转换自动发生并且对于运行的应用是透明的。

写到这,会有人问为什么要通过虚拟地址转换为物理地址?直接使用物理地址不是很好吗? 咱们先来看看CPU的寻址

CPU寻址方式

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

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

不过支持了分段机制的物理寻址,仍然有一些问题,最严重的问题之一就是地址空间缺乏保护。简单来说,因为直接暴露的是物理地址,所以进程可以访问到任何物理地址,用户进程想干嘛就干嘛,这是非常危险的。

为了解决上面的问题,现代处理器使用的是虚拟寻址的方式,CPU 通过访问虚拟地址(Virtual Address),经过翻译获得物理地址,才能访问内存。这个翻译过程由 CPU 中的内存管理单元(Memory Management Unit,缩写为 MMU)完成。

使用虚拟寻址方式的好处也显而易见

使用虚拟寻址的好处

  1. 使用虚拟寻址后,由于每次都会进行一个翻译过程,所以可以在翻译中增加一些额外的权限判定,对地址空间进行保护.所以对于每个进程来说,操作系统可以为其提供一个独立的,私有的,连续的地址空间,这就是所谓的虚拟内存
  2. 虚拟内存最大的意义就是保护了进程的地址空间,使得进程之间不能够越权进行相互地干扰.对于每个进程来说,操作系统可以通过虚拟内存进行"欺骗",进程只能够操作被分配的虚拟内存的部分.与此同时,进程可见的虚拟内存是一个连续的地址空间,这样也方便了程序员对内存进行管理
  3. 对于进程来说,它的可见部分只有分配给它的虚拟内存,而虚拟内存实际上可能映射到物理内存以及硬盘的任何区域。由于硬盘读写速度并不如内存快,所以操作系统会优先使用物理内存空间,但是当物理内存空间不够时,就会将部分内存数据交换到硬盘上去存储,这就是所谓的 Swap 内存交换机制。有了内存交换机制以后,相比起物理寻址,虚拟内存实际上利用硬盘空间拓展了内存空间

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

内存分页

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

在 masOS 和早版本的 iOS 中,分页的大小为 4kB。在之后的基于 A7 和 A8 的系统中,虚拟内存(64 位的地址空间)地址空间的分页大小变为了 16KB,而物理RAM上的内存分页大小仍然维持在 4KB;基于A9及之后的系统,虚拟内存和物理内存的分页都是16KB

在切分好页后,系统还在主存中用一个页表来记录下当前每个虚拟页的情况,包括这个虚拟页是否已被分配使用、是否已被缓存到物理内存中等。简化的页表如下图。

17022db508fffa11_赤兔图片转换器_20210518141823.jpg 页表中每一项称为 页表项(Page Table Entry) ,PTE 中最重要的两个信息是 有效位 和 地址。

  • 有效位 被设置,则表示此虚拟页当前已被缓存到物理内存中,此时地址将指向其缓存到的物理页起始位置;
  • 有效位 未被设置,如果该虚拟页已分配,则此时地址指向其虚拟页在磁盘中的起始位置,否则地址为 null。

缺页

虚拟内存和物理内存之间的数据传送正是发生于缺页

17022db508fffa11_赤兔图片转换器_20210518142058.jpg 如上图,当 CPU 首次访问 PTE 1,发现页表中的有效位仍 未被设置 ,即判断出对应的数据(VP1)仍未被缓存到物理内存中,从而触发了 缺页异常 ,将 VP1 从虚拟内存拷贝到物理内存中。而图中可以发现此时物理内存已经满了,则会通过LRU等算法将物理内存中的某个物理页(假设是VP3)替换掉。因此在缺页异常发生后,上面的页表将会更新为下图的样子。

17022db508dcd6e0_赤兔图片转换器_20210518142058.jpg 上面讲到了虚拟内存和物理内存的映射过程,咱们再从头看一下通过mmap读取文件的步骤:

  1. 在当前用户虚拟内存空间中分配一片指定映射大小的虚拟内存区域。
  2. 将磁盘中的文件映射到这片内存区域,等待后续按需进行页面调度。
  3. 当CPU真正访问数据时,触发缺页异常将所需的数据页从磁盘拷贝到物理内存,并将物理页地址记录到页表。
  4. 进程通过页表得到的物理页地址访问文件数据。

内存分页的状态

系统将内存页分为三个状态:

  • 活跃的内存页(active page):内存页已经被映射到物理内存中,而且近期被访问过,处于活跃状态。
  • 非活跃的内存页(inactive page):内存页已经被映射到物理内存中,但是近期没有被访问过。
  • 可用的内存页(free page):没有关联到虚拟内存页的物理内存页集合。

分页解决了什么问题

  • 解决空间浪费碎片化问题:由于将虚拟内存空间和物理内存空间按照某种规定的大小进行分配,这里我们称之为页(Page),然后按照页进行内存分配,也就克服了外部碎片的问题。
  • 解决程序大小受限问题:将当前需要的页面放在内存里,其他暂时不用的页面放在磁盘上,这样一个程序同时占用内存和磁盘,其增长空间就大大增加了。

当可用的内存页降低到一定的阀值时,系统就会采取低内存应对措施,在OSX中,系统会将非活跃内存页交换到硬盘上,而在iOS中,则会触发Memory Warning,如果你的App没有处理低内存警告并且还在后台占用太多内存,则有可能被杀掉。

iOS中的场景

当我们向系统申请内存时,系统并不会直接返回物理内存的地址,而是返回一个虚拟内存地址。从系统角度来说,每个进程都有一个自己私有的相同大小的虚拟内存空间。对于32位设备来说是4GB,而64位设备(5s以后的设备)是 18EB(1EB = 1000PB, 1PB = 1000TB),映射到物理内存空间。

#import <mach/mach.h>
//获取APP申请到的所有虚拟内存
- (int64_t)memoryVirtualSize {
    struct task_basic_info info;
    mach_msg_type_number_t size = (sizeof(task_basic_info_data_t) / sizeof(natural_t));
    kern_return_t ret = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t)&info, &size);
    if (ret != KERN_SUCCESS) {
        return 0;
    }
    return info.virtual_size;
}
复制代码

只有当进程开始使用申请到的虚拟内存时,系统才会将虚拟地址映射到物理地址上,从而让程序使用真实的物理内存.但这种映射不是一对一的,逻辑地址可能映射不到 RAM 上,也可能有多个逻辑地址映射到同一个物理 RAM 上。

  • 针对第一种情况,当进程要存储逻辑地址内容时会触发 page fault。
  • 而第二种情况就是多进程共享内存

对文件可以不用一次性读入整个文件,可以使用分页映射mmap()的方式读取.也就是把文件某个片段映射到进程逻辑内存的某个页上.当某个想要读取的页没有在内存中,就会触发 page fault,内核只会读入那一页,实现文件的懒加载(这里的懒加载可不是咱们oc中的懒加载)。也就是说 Mach-O 文件中的 __TEXT 段可以映射到多个进程,并可以懒加载,且进程之间共享内存

__DATA 段是可读写的。这里使用到了 Copy-On-Write 技术,简称 COW。也就是多个进程共享一页内存空间时,一旦有进程要做写操作,它会先将这页内存内容复制一份出来,然后重新映射逻辑地址到新的 RAM 页上。也就是这个进程自己拥有了那页内存的拷贝。这就涉及到了 clean/dirty page 的概念。dirty page 含有进程自己的信息,而 clean page 可以被内核重新生成(重新读磁盘)。所以 dirty page 的代价大于 clean page

多进程加载 Mach-O 镜像

20200119105025.jpeg

  • 所以在多个进程加载 Mach-O 镜像时 __TEXT 和 __LINKEDIT 因为只读,都是可以共享内存的,读取速度就会很快。
  • 而 __DATA 因为可读写,就有可能会产生 dirty page,如果检测到有 clean page 就可以直接使用,反之就需要重新读取 DATA page。一旦产生了 dirty page,当 dyld 执行结束后,__LINKEDIT 需要通知内核当前页面不再需要了,当别人需要的使用时候就可以重新 clean 这些页面。

20200119105037.jpeg

概念

clean Memory

可以简单理解为能够被写入数据的干净内存。对开发者而言是read-only,而iOS系统可以写入或移除。

  • System Framework、Binary Executable占用的内存
  • 可以被释放(Page Out,iOS上是压缩内存的方式)的文件,包括内存映射文件Memory mapped file(如image、data、model等)。内存映射文件通常是只读的。
  • 系统中可回收、可复用的内存,实际不会立即申请到物理内存,而是真正需要的时候再给。
  • 每个framework都有_DATA_CONST段,当App运行时使用到了某个framework,该framework对应的_DATA_CONST的内存就由clean变为dirty了。 注意:如果通过文件内存映射机制memory mapped file载入内存的,可以先清除这部分内存占用,需要的时候再从文件载入到内存。所以是Clean Memory。

20200119105037.jpeg

Dirty Memory

主要强调不可被重复使用的内存。对开发者而言,可以写入数据。

  • 被写入数据的内存,包括所有heap中的对象、图像解码缓冲(ImageIO, CGRasterData,IOSurface)。
  • 已使用的实际物理内存,系统无法自动回收。
  • heap allocation、caches、decompressed images。
  • 每个framework的_DATA段和_DATA_DIRTY段。 iOS中的内存警告,只会释放clean memory。因为iOS认为dirty memory有数据,不能清理。所以,应尽量避免dirty memory过大。

Clean和Dirty示例

int *array = malloc(20000 * sizeof(int)); // 第1步
array[0] = 32                             // 第2步
array[19999] = 64                         // 第3步
复制代码
  • 第一步,申请一块长度为80000 字节的内存空间,按照一页 16KB 来计算,就需要 6 页内存来存储。当这些内存页开辟出来的时候,它们都是 Clean 的;
  • 第二步,向处于第一页的内存写入数据时,第一页内存会变成 Dirty;
  • 第三步,当向处于最后一页的内存写入数据时,这一页也会变成 Dirty;

Resident Memory

已经被映射到虚拟内存中的物理内存。存在一些“非代码执行开销”,如系统和应用二进制加载的内存。

Resident Memory = Dirty Memory + Clean Memory that loaded in pysical memory。 这里存在两种Resident Memory,系统的(dyld_shared_cache,即动态库共享缓存)和我们APP的,下面的代码得到的是我们APP的。

获取App消耗的Resident Memory:
#import <mach/mach.h>
- (int64_t)memoryResidentSize {
    struct task_basic_info info;
    mach_msg_type_number_t size = sizeof(task_basic_info_data_t) / sizeof(natural_t);
    kern_return_t ret = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t)&info, &size);
    if (ret != KERN_SUCCESS) {
        return 0;
    }
    return info.resident_size;
}
复制代码

注意: Resident Memory包含Memory Footprint。

Memory Footprint

App消耗的实际物理内存,苹果推荐用 footprint 命令来查看一个应用进程的内存占用

我们会发现这个跟我们在 Instruments 里面看到的内存大小不一样,有时候甚至差别很大。其实Footprint主要就是Dirty部分,也就是我们可以控制优化的部分。Xcode Navigator里记录的大致也差不多是这个值。

获取App的Footprint:
#import <mach/mach.h>
- (int64_t)memoryPhysFootprint {
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t ret = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vmInfo, &count);
    if (ret != KERN_SUCCESS) {
        return 0;
    }
    return vmInfo.phys_footprint;
}
复制代码
分类:
iOS
标签:
收藏成功!
已添加到「」, 点击更改