探索 iOS 内存分配

Posted by LML on July 27, 2020

前言

本系列为 iOS Memory 相关内容作为主题的第三篇。前两篇围绕 ARC/MRC、循环引用的原理、Block 内存管理展开,这些内容更准确的说应该是 ObjC 的内存管理,是语言层面带来的特性。而这篇文章主要介绍系统层次的内存管理,主要解决下面几个问题:

  • 一般操作系统如果管理内存的?
    • 虚拟内存的使用
  • iOS 系统如何管理内存的?
    • 如何管理内存?
    • 内存占用指的什么内存?
    • iOS 开发为啥要关注内存上涨?
    • 好用的内存工具

本系列文章后续还会介绍

  • 经典库的缓存原理
    • NSCache
    • NSDictionary
    • YYCache
    • SDwebimage
    • BDwebimage

后面若还有时间,还会谈及下数据持久化+磁盘相关的,比如

  • 典型的数据持久化几种方案的原理
  • mmap 与普通写文件
  • YYDisk
  • SDwebimage、BDwebimage中的磁盘缓存

一般操作系统怎么管理内存

内存和硬盘的发展速度远远不及 CPU,造成了高性能的内存和硬盘价格极其昂贵。然而 CPU 的高速运算需要高速的数据。为了解决这个问题,CPU 厂商在 CPU 中内置了少量的高速缓存以解决 I\O 速度和 CPU 运算速度之间的不匹配问题。

上图是存储器的层次结构,我们平时常说的内存,实际上就是指的 L4 主存。而 L1-L3 高速缓存和主存相比,速度更快,并且它们都已经集成在 CPU 芯片内部了。其中 L0 寄存器本身就是 CPU 的组成部分之一,读写速度最快,操作耗费 0 个时钟周期。

对于每个进程来说,操作系统可以为其提供一个独立的、私有的、连续的地址空间,这就是所谓的虚拟内存。

虚拟内存保护了进程的地址空间,使得进程之间不能够越权进行互相地干扰。对于每个进程来说,进程只能够操作被分配的虚拟内存的部分。与此同时,进程可见的虚拟内存是一个连续的地址空间,这样也方便了程序员对内存进行管理。

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

CPU都执行计算的流程:

  • 建立虚拟内存与内存的映射关系
  • 执行代码指令,即访问虚拟内存
  • 根据虚拟内存与内存的映射关系,访问内存,若在内存中返回内存中的数据;
  • 若不在,产生缺页中断,根据与磁盘的映射关系,将访问内容从磁盘家在在内存,在重复执行上步骤
  • 指令和数据被加载到CPU的高速缓存
  • CPU执行指令,把结果写到高速缓存
  • 高速缓存中的数据写回主内存

上述步骤我们关心的是虚拟内存与内存的映射,至于 CPU 高速缓存如何与地址映射的,也就是高速缓存中如何寻址的暂时还没有了解。

关于高速缓存的,还可以了解下内存屏障、缓存一致性,这里有介绍

当机器内存不够的时候会执行内存交换,以换出更多内存给新的进程。

内存交换:把处于等待(阻塞)状态(或在CPU调度原则下被剥夺运行权利)的程序(进程)从内存移到辅存(外存磁盘),把内存空间腾出来,这一过程又叫换出。把准备好竞争CPU运行的程序从辅存移到内存,这一过程又称为换入。

iOS 内存机制

iOS 如何管理内存

iOS 虚拟内存管理

iOS 和大多数操作系统一样,使用了虚拟内存机制,虚拟内存分布如下(注意其中的共享库的内存映射位置)。CPU都执行计算的流程与一般操作系统的流程是一样的:建立虚拟内存与物理内存的映射关系——代码执行时实际是虚拟内存地址,根据映射关系对应物理内存取——缺页——磁盘记载到物理内存——高速缓存——CPU计算。

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

iOS 不支持内存交换机制,那么如何处理物理内存已经全部被占用,需要打开新的app的情景呢?
iOS 虚拟内存分页(Virtual Page, VP) 有三种类型:

  • Clean - Data that can be paged out of memory
    • 指的是能够被系统清理出内存且在需要时能重新加载的数据,包括:
      • Memory mapped files
      • Frameworks 中的 __DATA_CONST 部分
      • 应用的二进制可执行文件
  • Dirty - Any memory that has been written to by your app
    • 指的是不能被系统回收的内存占用,包括
      • 所有堆上的对象
      • 图片解码缓冲数据(Decoded image buffers)
      • Frameworks 中的 __DATA 和 __DATA_DIRTY部分
  • compressed
    • 当物理内存不够用时,iOS 会将部分物理内存(dirty)压缩,在需要读写时再解压,以达到节约内存的目的。

当 iOS 内存紧张的时候

  • 清理clean memory
  • 给app 发送内存告警,争取更多内存
  • Compressed dirty memory
  • 如果还需要更对内存,会直接杀死一些没有在前台但是占用内存较大的app

那我们内存优化的时候需要优化的是什么内存呢?
dirty size + compressed size ,是我们需要并且能够尝试去减少的内存占用。因为 clean memory 会被自动清理

还有一点需要注意,其实我们平时分析问题分析的都是虚拟内存,因为

  • 虚拟内存地址我们才认识、熟悉
  • 虚拟内存与物理内存存在对应关系,分析虚拟内存就可以了

iOS 虚拟内存相关的几个概念

VM Regions

一个 VM Region 是指一段连续的内存页(在虚拟地址空间里),这些页拥有相同的属性(如读写权限、是否是 wirted,也就是是否能被 page out),有多种type,比如

VM Object

每个 VM Region 对应一个数据结构,名为 VM Object。Object 会记录这个 Region 内存的属性 VMObject主要包含下面的属性

  • Resident pages - 已经被映射到物理内存的虚拟内存页列表
  • Size - 所有内存页所占区域的大小
  • Pager - 用来处理内存页在硬盘和物理内存中交换问题
  • Attributes - 这块内存区域的属性,比如读写的权限控制
  • Shadow - 用作(copy-on-write)写时拷贝的优化
  • Copy - 用作(copy-on-write)写时拷贝的优化

堆(heap)和 VM Region

堆区会被划分成很多不同的 VM Region,不同类型的内存分配根据需求进入不同的 VM Region。除了 MALLOC_LARGE 和 MALLOC_SMALL 外,还有 MALLOC_TINY, MALLOC metadata 等等

根据上面领个概念,我们在看下面两个图,其实可以得到结论

  • VM Region 关联了虚拟内存和物理内存
  • 不仅仅 heap 区,整个物理内存都与不同类型的 VM regin对应,比如
    • TEXT
    • DATA
    • Heap
      • MALLOC_LARGE
      • MALLOC_TINY
      • MALLOC_SMALL

VM Region Size

一个 VM Region 有4种size

  • Dirty Size
  • Swapped Size
    • Swapped Size 则是交换到硬盘上的大小,仅OSX可用
    • Swapped Size 在 iOS 上指的是 Compressed memory size 且其值表示压缩前的占用大小。
  • Resident Size
    • 指的是实际使用物理内存的大小
  • Virtual Size
    • Virtual Size 顾名思义,就是虚拟内存大小,将一个 VM Region 的结束地址减去起始地址就是这个值。

根据几个size 定义和 VM Region 定义,可以得出:
Virtual Size >= Resident Size + Swapped Size >= Dirty Size + Swapped Size, Resident size 包含 clean size

内存占用

  • APP 占用内存
    • 获取app 正确的内存占用 http://www.samirchen.com/ios-app-memory-usage/
      int64_t memoryUsageInByte1 = 0;
      task_vm_info_data_t vmInfo;
      mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
      kern_return_t kernelReturn1 = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
      if(kernelReturn1 == KERN_SUCCESS) {
          memoryUsageInByte1 = (int64_t) vmInfo.phys_footprint;
          NSLog(@"Memory in use (in bytes): %lld", memoryUsageInByte1);
      } else {
          NSLog(@"Error with task_info(): %s", mach_error_string(kernelReturn1));
      }
    

    • 但是这个内存占用到底指的是什么内存呢?在官方文档 Minimizing your app’s Memory Footprint 里有说明: Refers to the total current amount of system memory that is allocated to your app. + 其实这里还是有点疑问的:根据上面公式计算出的 Memory FootPrint 到底是 cleansize + dirtysize + compressed size 呢?还是 dirtysize + compressedsize 呢?还没有找到实锤的资料。有没有命令只获取 Dirty size + compressed sized 呢?因为这部分size 才是需要我们优化的部分,获取这部分size的占用感觉更有助于我们分析问题。这个问题暂时没有找到答案
  • 设备总内存

      u_int64_t totalMemory = 0;
      size_t size1 = sizeof(totalMemory);
      int mib[2] = {CTL_HW, HW_MEMSIZE};
      if (sysctl(mib, 2, &totalMemory, &size1, NULL, 0) == 0) {
          int64_t a1 = 0;
      }
    
  • 机器已经使用的内存

      //fill host vm info
      HMD_HOST_STATISTICS_DATA_T host_vm;
      mach_msg_type_number_t host_vm_count = HMD_HOST_VM_INFO_COUNT;
      kr = HMD_HOST_STATISTICS(mach_host_self(), HMD_VM_INFO, (HMD_HOST_INFO_T)&host_vm, &host_vm_count);
      if (kr != KERN_SUCCESS) {
          return memory;
      }
      // 由于64位系统memory的计算方式中有32位vm_statistics_data_t不包含的成员,故分开处理
    #ifdef __LP64__
      // https://github.com/llvm-mirror/lldb/blob/c77a32de1c24775634181d5890567379a3b201aa/tools/debugserver/source/MacOSX/MachTask.mm 搜索’scanType & eProfileMemory‘
      memory.usedMemory = ((memory.totalMemory / vm_kernel_page_size) - (host_vm.free_count - host_vm.speculative_count) - host_vm.external_page_count - host_vm.purgeable_count) * vm_kernel_page_size;
    #else
      // 部分博客提示在32位用此方法计算,尚未找到有力的官方证明,日后找到补上
      memory.usedMemory = (host_vm.active_count + host_vm.wire_count + host_vm.inactive_count) * vm_kernel_page_size;
    #endif
    
  • 可用内存

      uint64_t availableMemory = memory.totalMemory - memory.usedMemory;
      bool limitBytesRemainingEnable = false;
      #if defined(TASK_VM_INFO_REV4_COUNT) && !TARGET_OS_SIMULATOR
      // 通过task_vm_count来判断limit_bytes_remaining是否有效
      // limit_bytes_remaining 可参考 os_proc_available_memory 解释
      // min(设备可用内存, 单个app进程最大剩余可用内存)
      if (task_vm_count >= TASK_VM_INFO_REV4_COUNT) {
          availableMemory = MIN(availableMemory, task_vm.limit_bytes_remaining);
          limitBytesRemainingEnable = true;
      }
      #endif
    

iOS 开发为啥要关注内存上涨

内存是有限且系统共享的资源,一个程序占用更多,系统和其他程序所能用的就更少。程序启动前都需要先加载到内存中,并且在程序运行过程中的数据操作也需要占用一定的内存资源。减少内存占用也能同时减少其对 CPU 时间维度上的消耗,从而使不仅你所开发的 App,其他 App 以及整个系统也都能表现的更好

而且分给一个app的可用物理内存是有限制的,超过这个内存会造成OOM,更加影响用户体验

### OOM 触发流程与原理

正常 OOM 的触发方式有2种,一种是同步触发,一种是异步触发,下面的流程是异步触发;同步触发比较简单粗暴,直接根据pid,kill 掉相应的进程。

  • 判断 kill_under_pressure_cause值为kMemorystatusKilledVMThrashing,kMemorystatusKilledFCThrashing,kMemorystatusKilledZoneMapExhaustion时,或者当前可用内存 memorystatus_available_pages 小于阈值memorystatus_available_pages_pressure,进入OOM逻辑
  • 遍历每个进程,跟据phys_footprint,判断每个进程是否高于阈值,如果高于阈值,以high-water类型kill进程,触发OOM
  • 如果JETSAM_PRIORITY_IDLE,JETSAM_PRIORITY_AGING_BAND1,JETSAM_PRIORITY_IDLE优先级队列中还存在进程,则kill一个最低优先级的进程,再次走1的判断逻辑
  • 当所有低优先级进程被kill掉后,如果memorystatus_available_pages仍然小于阈值,先kill掉后台进程,每kill一个进程,判断一下memorystatus_available_pages是否还小于阈值,如果已经小于阈值,则结束流程,走到1
  • 当所有后台优先级进程都被kill后,调用memorystatus_kill_top_process_aggressive,kill掉前台的进程。再次回到1

触发前台OOM的可能性有3个:

  • 直接触发同步kill,比如kMemorystatusKilledPerProcessLimit类型的OOM,这个解释起来还需要一篇文章,暂时不在本文的讨论范围之类
  • footprint_in_bytes > memlimit_in_bytes,触发high-water类型的OOM,目前我在自己手机上,暂时没有看到这个类型的OOM
  • 当后台线程都被kill后,依然memorystatus_available_pages <= memorystatus_available_pages_pressure,进而系统kill掉我们的App

从某种程度来说,OOM是另类的Crash事件,那么为什么OOM没有上报堆栈呢? 因为OOM上报堆栈是没有意义的,OOM的时候执行的代码不一定是OOM的根源,所以抓取堆栈没有意义

iOS 分析内存占用的工具

Allocations + VM Tracker

建立一个 Demo,我们用一下几个方式测试内存占用:

  • Instruments 中Allocations
    • All Heap & Anonymous VM: 6.07MB
    • All Heap 5.56M
    • All Anonymous Vm 524 K
    • All VM Regions 142M

  • Instruments 中VM Tracker
    • Resident 322.14M
    • Dirty 77.77M
    • Virtual size 1.22GB

  • Xcode: 15.5MB

  • task_vm_info.phys_footprint: 15.4MB
  • task_info.resident_size: 89.37MB

为什么上面几种测试工具的到的记录不一样的?仔细分析发现:

  • Allocations 中
    • All VM regions 会被包含在 VM Tracker 中的 VM regions,都可以一一对应
    • ALL heap allocations 会被包含在 VM Tracker 中的 VM regions,都可以一一对应
    • All Heap & anonymous vm 与 All VM regions + ALL heap allocations 对应不上的原因是 anonymous vm 只包含All VM regions 的一部分
  • VM Tracker 中的 size 更大的原因是
    • 不仅包含 app 本身二进制 mapped 产生的 size、malloc 的size 还包括使用到的动态库的mapped size(动态库在链接的时候也会产生dirty size)。
    • 应该说 VM Traker 描述的内存占用更符合实际的app允许中的内存占用,且区分了 clean size、 dirty dize,且区分了不同的 VM region,但是这里面有很多比如 mapped size等不是我们能干涉的;动态库不是只有本app使用,所以其他的方式的内存占用更小一些。

前文也提到,目前以task_vm_info.phys_footprint:衡量内存占用,到底包含了哪部分,没有找到明确的定义。

Leaks

用于检测程序运行过程中的内存泄露,并记录对象的历史信息。

在检测内存泄露方面,三方库 MLeaksFinder 较为流行,能够不入侵代码且不用打开 Instruments,自动检测 UIViewController 和 UIView 对象的内存泄露,而且也可以扩展以检测其它类型的对象。

Virtual Memory Trace

Syetem Trace in Depth-WWDC 2016

memory graph

File->Export Memory Graph 导出 memgraph

  • Vmmap
vmmap App.memgraph
vmmap --summary App.memgraph

可以得到 不同 VM region 虚拟内存的相关信息,和VM tracker 得到的信息相似

  • Heap
heap App.memgraph
heap App.memgraph -sortBySize
heap App.memgraph -address all | <classes-pattern>

malloc_history App.memgraph --fullStacks [address]

heap 会打印出所有在堆上的对象信息,默认按类数量排序,也可以通过 -sortBySize 按大小排序,对于追踪堆中较大的对象十分有帮助。找到目标对象后,通过 -address 获得所有/指定类的地址,继而可以利用 malloc_history 寻找其调用堆栈信息。

  • malloc_history
    • 可以具体参考https://www.jianshu.com/p/a6c673678f36

参考

  • 探索iOS内存分配:
    • https://juejin.im/post/5a5e13c45188257327399e19#heading-4
  • VM Regions:
    • https://www.jianshu.com/p/f82e2b378455
  • 内存管理:
    • https://zhuanlan.zhihu.com/p/49829766
    • https://www.jianshu.com/p/a6c673678f36
    • https://study-tech.bytedance.net/articles/6812