前言
本系列为 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