因文章单篇过长,按照 原理、分析工具 和 实战 拆分成上、中、下三部分,点击阅读。
近期有个同事遇到了内存泄露导致卡死的问题,具体原因是:view A监听了某个事件,但viewA本身因为block使用不当,出现了泄露问题,用户多次操作导致viewA泄露了72次。当事件发出通知时,72个被泄露的viewA同时去触发某个操作,导致手机卡死。这个问题挺经典的,平时如果不注意 block weakify/strongify ,内存泄露真出现时,影响颇大。
加上有同事在debug内存泄露时秀了一把 Xcode memory Debug,让我觉得是时候将过去所学的内存管理相关的知识进行一番汇总,本篇文章更多的工作在于汇总和黏连各个优秀的文章,如果您有精力,建议还是阅读文章末尾粘贴的原文链接,在此也感谢前辈们的分享。
本篇文章目录为:
在 stack overflow 上,有人对单个 App 能够使用的最大内存做了统计:iOS app max memory budget。以 iPhone XS Max 为例,总共的可用内存是 3735 MB(比硬件大小小一些,因为系统本身也会消耗一部分内存),而单个 App 可用内存达到 2039 MB,达到了 55%。当 App 使用的内存超过这个临界值,就会发生 OOM 崩溃。可以看出,单个 App 的可用物理内存实际上还是很大的,要发生 OOM 崩溃,绝大多数情况下都是程序本身出了问题。
OOM常见原因:
实战说明:最近我在优化图片发表的逻辑,debug发现在选择多张超高清大图进行压缩时,内存占用甚至会飙升到2G,按照 stack overflow 上的说法,2G已经触碰OOM红线了,所以我对图片压缩的逻辑进行了优化,感兴趣的可以到我博客查看最新的一篇文章。
先看看 Leaks,从苹果的开发者文档里可以看到,一个 app 的内存分三类:
其中 Leaked memory 和 Abandoned memory 都属于应该释放而没释放的内存,都是内存泄露,而 Leaks 工具只负责检测 Leaked memory,而不管 Abandoned memory。在 MRC 时代 Leaked memory 很常见,因为很容易忘了调用 release,但在 ARC 时代更常见的内存泄露是循环引用导致的 Abandoned memory,Leaks 工具查不出这类内存泄露,应用有限。
内存是由系统管理,一般以页为单位来划分。在 iOS 上,每一页包含 16KB 的空间。一段数据可能会占用多页内存,所占用页总数乘以每页空间得到的就是这段数据使用的总内存。
内存页按照各自的分配和使用状态,可以被分为 Clean 和 Dirty 两类。
以上面的代码为例,申请一块长度为 80000 字节的内存空间,按照一页 16KB 来计算,就需要 6 页内存来存储。
当 App 访问一个文件时,系统内核会负责调度,将磁盘上的文件加载并映射到内存中。如果这是只读的文件,它所占用到的内存页是 Clean 的。
如下图所示,一个 50KB 的图片被加载到内存中时,需要分配 4 页内存来存储。其中第四页中有 2KB 的空间会被用来存储这个图片的数据,剩余空间可能会被用来存储其它数据。
当内存不足的时候,系统会按照一定策略来腾出更多空间供使用,比较常见的做法是将一部分低优先级的数据挪到磁盘上,这个操作称为 Page Out。之后当再次访问到这块数据的时候,系统会负责将它重新搬回内存空间中,这个操作称为 Page In。
然而对于移动设备而言,频繁对磁盘进行IO操作会降低存储设备的寿命。从 iOS7 开始,系统开始采用压缩内存的办法来释放内存空间,被压缩的内存称为 Compressed Memory。下面依次介绍一下 iOS App 通常情况下的三种内存类型:Clean Memory 、Dirty Memory以及Compressed Memory。
Clean Memory 是指那些可以用以 Page Out 的内存,包括已被加载到内存中的文件,或者是 App 所用到的 frameworks。每个 frameworks 都有 _DATA_CONST 段,当 App 在运行时使用到了某个 framework,它所对应的 _DATA_CONST 的内存就会由 Clean 变为 Dirty。
Dirty Memory 是指那些被 App 写入过数据的内存,包括所有堆区的对象、图像解码缓冲区,同时,类似 Clean memory,也包括 App 所用到的 frameworks。每个 framework 都会有 _DATA 段和 _DATA_DIRTY 段,它们的内存是 Dirty 的。
值得注意的是,在使用 framework 的过程中会产生 Dirty Memory,使用单例或者全局初始化方法是减少 Dirty Memory 不错的方法,因为单例一旦创建就不会销毁,全局初始化方法会在 class 加载时执行。
当内存吃紧的时候,系统会将不使用的内存进行压缩,直到下一次访问的时候进行解压。
例如,当我们使用 Dictionary 去缓存数据的时候,假设现在已经使用了 3 页内存,当不访问的时候可能会被压缩为 1 页,再次使用到时候又会解压成 3 页。
官方对内存压缩的描述是:
With OS X Mavericks, Compressed Memory allows your Mac to free up memory space when you need it most. As your Mac approaches maximum memory capacity, OS X automatically compresses data from inactive apps, making more memory available.
大致上是在内存不够用的时候,把非活跃应用占用的内存进行压缩。可以看出相对于把dirty的内存换出到硬盘而言,这是一种折中的方案,本质上是用CPU时间换硬盘I/O时间。虽然压缩/解压会比换出/换入占用更多的CPU,但花在硬盘I/O上的时间会大大减小。
并非所有内存警告都是由 App 造成的,例如在内存较小的设备上,当你接听电话的时候也有可能发生内存警告。按照以往的习惯,你可能会在收到内存警告通知的时候去做一些释放内存的事情。然而内存压缩机制会使事情变得复杂。我们来看看这个例子:
假设代码中的 cache 已被压缩过
事实上,当你尝试去再次访问 cache 对象的时候,系统会先解压这块内存
这个过程中内存使用会增加,在内存吃紧的时候,这并不是我们想要的。随后,当我们会执行大量工作去清空 cache,最终得到的内存空间和内存压缩的结果一样
所以,相比以往的缓存手段,更加建议去调整策略,例如减少缓存使用,或者在收到内存警告的时候,将这类事情交由系统去处理。
我们对数据进行缓存的目的是想减少 CPU 的压力,但是过多的缓存又会占用过大的内存。由于内存压缩机制的存在,我们需要根据缓存数据大小以及重算这些数据的成本,在 CPU 和内存之间进行权衡。
在一些需要缓存数据的场景下,可以考虑使用 NSCache 代替 NSDictionary,因为 NSCache 可以自动清理内存,在内存吃紧的时候会更加合理。
通常情况下,我们所说的内存占用是指 Dirty Memory 和 Compressed Memory,Clean Memory 不需要过多关心。
——————————————
文章首发:问我社区