WWDC 2018 session 416: iOS Memory Deep Dive
首先设备硬件资源是固定的,所以app的内存资源是有限的。较低的内存占用可以提高用户体验以及性能。如果内存占用过大,可能会被系统杀掉。所以每个开发者都应该注意内存问题。本session主要分为以下几方面:
答案很简单,为了更好的用户体验。减少内存占用能同时减少其对CPU时间维度上的消耗,从而不仅使您所开发的App,其他App以及整个系统也都能表现的更好。
并非所有的内存占用都是相等的。而要减少的内存占用其实指的是虚拟内存(Virtual Memory
) 占用。
内存是由系统管理,一般以页为单位来划分。
在iOS 上,每一页包含16KB的空间。系统会按照页来分配内存,堆上可能会有多个对象在一页上,也可能一个对象占用多页。
所占用页总数乘以每页空间得到的就是这段数据使用的总内存。
iOS 以及 macOS 都采用了虚拟内存技术来突破物理内存(RAM) 的大小限制,每个进程都拥有一段由多个大小相同的page 所构成的逻辑地址空间。处理器和内存管理单元MMU(Memory Management Unit) 维护着由逻辑地址空间到物理地址的 page映射表,当程序访问逻辑内存地址时由MMU根据映射表将逻辑地址转换为真实的物理地址。在早期的苹果设备中,每个page的大小为4KB;基于A7和A8处理器的系统为64位程序提供了16KB的虚拟内存分页和4KB的物理内存分页;而在A9之后,虚拟内存和物理内存的分页大小都达到了16KB。
内存页按照各自的分配和使用状态,可以分为Clean
和Dirty
两类。
举个例子,如果我申请了一个20000个整型的数组(80000个字节)。系统可能会分配给我6页内存。
Clean
的Dirty
了Dirty
了。Clean
的,因为他们还未被写入。 当 App 访问一个文件时,系统内核会负责调度,将磁盘上的文件加载并映射到内存中。如果这是只读的文件,它所占用到的内存页是Clean
的。
如下图所示,一个50KB的图片被加载到内存中时,需要分配4页内存来存储。其中第四页中有2KB的空间会被用来存储这个图片的数据,剩余空间可能会被用来存储其它数据。前三页总是可以被系统清除的。
当内存不足的时候,系统会按照一定策略来腾出更多空间供使用,比较常见的做法是将一部分低优先级的数据挪到磁盘上,这个操作称为Page Out
。之后当再次访问到这块数据的时候,系统会负责将它重新搬回内存空间中,这个操作称为Page In
。
Clean Memory是指那些可以用以Page Out
的内存,只读的内存映射文件,或者是App所用到的frameworks。每个frameworks都有_DATA_CONST
段,通常他们都是Clean
的,但如果用runtime进行swizzling,那么他们就会变Dirty
。
Dirty Memory是指那些被App写入过数据的内存,包括所有堆区的对象、图像解码缓冲区,同时,类似Clean memory
,也包括App所用到的frameworks。每个framework都会有_DATA
段和_DATA_DIRTY
段,它们的内存是Dirty
的。
值得注意的是,在使用framework的过程中会产生Dirty Memory
,使用单例或者全局初始化方法是减少Dirty Memory
不错的方法,因为单例一旦创建就不会销毁,全局初始化方法会在类加载时执行。
由于闪存容量和读写寿命的限制,iOS 上没有Disk swap
机制,取而代之使用Compressed memory
。
Disk swap 是指在 macOS 以及一些其他桌面操作系统中,当内存可用资源紧张时,系统将内存中的内容写入磁盘中的backing store (Swapping out),并且在需要访问时从磁盘中再读入 RAM (Swapping in)。与大多数 UNIX 系统不同的是,macOS 没有预先分配磁盘中的一部分作为 backing store,而是利用引导分区所有可用的磁盘空间。
Compressed memory
是在内存紧张时能够将最近使用过的内存占用压缩至原有大小的一半以下,并且能够在需要时解压复用。它在节省内存的同时提高了系统的响应速度,特点总结起来如下:
* Shrinks memory usage 减少了不活跃内存占用
* Improves power efficiency 改善电源效率,通过压缩减少磁盘IO带来的损耗
* Minimizes CPU usage 压缩/解压十分迅速,能够尽可能减少 CPU 的时间开销
* Is multicore aware 支持多核操作
例如,当我们使用Dictionary
去缓存数据的时候,假设现在已经使用了3页内存,当不访问的时候可能会被压缩为1页,再次使用到时候又会解压成3页。
本质上,Compressed memory 也是 Dirty memory。
因此, memory footprint = dirty size + compressed size ,这也就是我们需要并且能够尝试去减少的内存占用。
内存警告,不一定总是应用自身导致的
如果您使用的设备内存较小,那么在接电话时,可能会触发内存警告。
内存压缩技术使得释放内存变得复杂
假设一个App的
Dirty Memory
中有一个NSDictionary
对象占了三页空间,当我们不访问它的时候,就会被压缩成一页。这样我们就多了2页的可用空间。
但此时如果收到内存警告,我们决定要将整个字典中的内容移除,这时我们就需要访问压缩后的字典,它就会被解压-释放对象-然后内存占用又回到了一页。这也就是说,我们努力释放了一些对象,但却没有增加可用空间,甚至可能加剧内存紧张的态势,也增加了CPU的开销。
缓存策略
我们对数据进行缓存的目的是想减少 CPU 的压力,但是过多的缓存又会占用过大的内存。在一些需要缓存数据的场景下,可以考虑使用
NSCache
代替NSDictionary
,NSCache
分配的内存实际上是Purgeable Memory
,可以由系统自动释放。这点在Effective Objective 2.0一书中也有推荐NSCache
与NSPureableData
的结合使用既能让系统根据情况回收内存,也可以在内存清理的同时移除相关对象。
通常情况下,我们所说的内存占用是指Dirty Memory
和Compressed Memory
,Clean Memory
不需要过多关心。
App 能使用比较多的内存空间,但是上限会根据设备不同而不同。Extension
能使用的最大内存则要低很多,所以当你在开发Extension
的时候尤其要注意内存使用。当使用的内存超出限制的时候,系统会抛出EXC_RESOURCE_EXCEPTION
异常。
在Xcode中,你可以通过Memory Gauge
工具,很方便快速的查看App运行时的内存情况,包括内存最高占用、最低占用,以及在所有进程中的占用比例等。如果想要查看更详细的数据,就需要用到Instruments
了。
在 Instruments 中,你可以使用Allocations
、Leaks
、VM Tracker
和 Virtual Memory Trace
对App进行多维度分析。
Allocations
追踪程序的虚拟内存占用和堆信息,提供对象的类名、大小以及调用栈等信息
Leaks
用于检测程序运行过程中的内存泄露,并记录对象的历史信息
第三方内存泄漏检测工具MLeaksFinder
VM Tracker
能够区分程序运行时前文所述的
Dirty Memory
、Compressed Memory
占用情况(swapped代表Compressed)。
Virtual Memory Trace
隐藏在System Trace中的Virtual Memory Trace工具能够从page层面更深层次剖析应用程序的虚拟内存操作。WWDC 2016中Syetem Trace in Depth中给出了详细的介绍。
当你使用 Xcode 10以前的版本进行调试时,在内存过大时,debug session会直接终止,并且在控制台打印出异常。从Xcode 10开始,debugger会自动捕获EXC_RESOURCE RESOURCE_TYPE_MEMORY
异常,并断点在触发异常抛出的地方,十分方便定位问题。
Xcode Memory Debugger
的内存调试器是在Xcode 8中提供的,它可以帮助您跟踪对象依赖性,周期和泄漏。在Xcode 10中,优化了界面布局。
你也可以点击File->Export Memory Graph
将其导出为memgraph
文件,通过命令行对其进行分析。下面说下几个命令行工具
vmmap 能够打印出进程信息,所有分配给该进程的 VM区域以及VM区域的种类、内存占用信息等内容。
系统中将一系列连续的内存页关联到一个
VMObject
进行管理,VMRegion
即VMObject
所管理IDE区域。 Finding iOS Memory中对每种VMRegion
作出了详细的解释。
利用--summary
则能够根据不同的区域类型打印出详细的内存占用类型和信息。这里需要注意的是 SWAPPED SIZE
在iOS上指的是Compressed memory size
且其值表示压缩前的占用大小。
vmmap --summary App.memgraph
如果您希望查看更多的信息,那么直接调用即可。您将获得所有区域的内容。
vmmap App.memgraph
配合管道命令查看所有动态库的Ditry Pages的总和
vmmap -pages xxx.memgraph | grep '.dylib' | awk '{sum += $6} END { print "Total Dirty Pages:"sum}'
更多使用方式请查看vmmap的文档
man vmmap
顾名思义,就是查看内存泄漏的。
leaks xx.memgraph
更多使用方式也可以查看man手册。
查看堆区内存
heap xx.memgraph
默认情况下,是按照数量排序的,当然也可以通过参数-sortBySize
让其来按照大小排序。
heap xx.memgraph -sortBySize
排列之后,我们发现了一些巨大的NSConcreteData
对象,通过下面的命令,就可以得到每个对象的内存地址。
heap xx.memgraph -addresses 'NSConcreteData'
#得到全部对象的内存地址
#heap xx.memgraph -addresses all
有了这些地址呢,我们就可以知道他们是从哪里来的。有了这些对象的内存地址之后,我们还需要另一样工具帮助我们做下一步分析。
在Product->Scheme->Edit Scheme->Diagnostics
中,开启 Malloc Stack 功能,建议使用Live Allocations Only
选项。
之后lldb会记录调试过程中对象创建的堆栈,配合malloc_history
工具,就可以定位到那些占用了过大内存的对象是哪里创建的。
查看内存分配的历史,使用方法如下
malloc_history xx.memgraph [address]
malloc_history xx.memgraph --fullStacks [address]
以上讲了很多工具,当遇到内存问题时,那我们要如何进行选择呢?
这里有三种方法来考虑。您是否想查看对象的创建?您是否想查看内存中对象的引用或者地址内容?或者您是否想查看一个实例有多大?
可以根据上图所示,按照不同情况,来使用不同的工具。
图片所占内存的大小与图片的尺寸有关,而不是图片的文件大小。
举个例子,我们这里有一张590KB图片,而它的分辨率是2048px * 1536px。它实际使用的内存不是590KB,而是2048 * 1536 * 4 = 12 MB
。
图片为什么会占这么多的内存?这还要从图片在iOS上显示的原理说起。一张图片文件从磁盘到展示需要经过三步:
加载
将被压缩的图片文件加载到内存中(590KB的图片)
解压缩
将图片文件转换成GPU可以读取的格式。(解压缩后耗费10M)
渲染
解压缩后,就可以渲染到屏幕上了。
更多关于图像以及如何优化图像的信息,请查看WWDC 2018 Image and Graphics Best Practices,也可以直接阅读前几天我们小伙伴发布的文章图像和图形的最佳实践)。
sRGB格式
这个是目前比较通用的全色彩图像色域,每个像素占4个字节,分别表示红、绿、蓝通道以及alpha通道。
Wide格式
iOS硬件设备支持的更生动的色域的渲染格式。每像素占用8字节,每个通道占用2字节。iPhone 7及以上的设备可以拍摄这类照片,他们可以栩栩如生地还原美好。但是因为其较大的内存开销需要谨慎使用。
亮度和alpha 8格式
每像素占用2字节,分别表示灰度和透明度。这通常在着色器中使用,例如Metal Apps等等。一般不是很常见。
alpha 8格式
每像素只占用1字节,用于单色图片,如阴影、无Emoji的文字等。比sRGB小75%。
简单的回答是:不需要你来选择格式,而是应该让格式选择你。
使用UIGraphicsBeginImageContextWithOptions
生成的图片,每个像素需要4个字节表示。建议使用UIGraphicsImageRenderer
,这个方法是从iOS 10引入,在iOS 12上会自动选择最佳的图像格式,可以减少很多内存。UIGraphicsImageRenderer可以创建UIImage对象或者进行JPEG/PNG格式的编码。
此外,如果想修改颜色,可以直接修改tintColor,不会有额外的内存开销。
当你缩小一幅图像的时候,会按照取平均值的办法把多个像素点变成一个像素点,这个过程称为下采样(Downsampling)。
UIImage在设置和调整大小的时候,需要将原始图像加压到内存中,然后对内部坐标空间做一系列转换,整个过程会消耗很多资源。我们可以使用ImageIO,它可以直接读取图像大小和元数据信息,不会带来额外的内存开销。
这样处理,不但内存占用的更低了,而且执行速度也快了50%左右。
假设在 App 里展示了一张很大图片,当我们切换到后台去做其它的操作时,这个图片还在占用内存。我们应该考虑在合适的时机去回收这类占用过大的数据。
监听UIApplicationWillEnterForeground
和UIApplicationDidEnterBackground
通知
适用于正在显示的view对象,因为退到后台或者进入前台时,不会调用ViewController生命周期的回调函数。
viewWillAppear
和viewDidDisappear
方法
适用于
UITabBarController
和UINavigationController
。因为虽然拥有多个控制器,但只能有一个在屏幕上展示。
略过,基本上就是用上面说的命令去调试一个问题及优化方案去调试图片的内存问题
内存是一个有限的共享资源,要学会使用Xcode分析内存工具,从而了解应用程序内存占用情况,并使用一些缩减应用程序内存占用空间的技巧和窍门。