iOS内存管理学习

2019-07-05 » iOS性能优化

一、为什么要管理iOS内存

有了ARC之后为什么还需要学习内存管理?

对于我们的 App 所依赖的设备而言,内存资源是有限的。降低 App 所使用的内存可以提高性能和体验,相反,过大的内存占用可能会导致 App 被系统强制退出。所以每个 iOS 开发者都应该关注内存问题。特别是涉及到App的崩溃以及性能优化的时候就逃不开内存管理的学习。

为什么要减少内存

可以有更好的用户体验:更快的启动速度,不会因为内存过大而导致 Crash,可以让 App 存活更久等。

二、认识iOS开发中的内存知识

iphone使用芯片的RAM大小

内存是由系统管理,一般以页为单位来划分。在 iOS 上,每一页包含 16KB (A7芯片以后,早期是4KB) 的空间。一段数据可能会占用多页内存,所占用页总数乘以每页空间得到的就是这段数据使用的总内存。

1、iOS中的内存分类

通常情况下,我们所说的内存占用是指 Dirty MemoryCompressed MemoryClean Memory,不需要过多关心。

Dirty Memory: Memory written by an app  /  以下一些操作都会产生Dirty Memory

alloc、decoded image buffers、frameworks、singletons、global initializers ...

2、低内存通知

在可用物理内存较少时,iOS 会给各应用发出低内存广播通知,如果此后可用内存仍然低于特定值,则会杀死优先级较低的进程。

3、没有内存交换机制、iOS 压缩内存机制

当内存不足的时候,系统会按照一定策略来腾出更多空间供使用,比较常见的做法是将一部分低优先级的数据挪到磁盘上,这个操作称为 Page Out。之后当再次访问到这块数据的时候,系统会负责将它重新搬回内存空间中,这个操作称为 Page In

然而对于移动设备而言,频繁对磁盘进行IO操作会降低存储设备的寿命。从 iOS7 开始,系统开始采用压缩内存的办法来释放内存空间,被压缩的内存称为 Compressed Memory

当内存吃紧的时候,系统会将不使用的内存进行压缩,直到下一次访问的时候进行解压。

例如:当我们使用 Dictionary 去缓存数据的时候,假设现在已经使用了 3 页内存,当不访问的时候可能会被压缩为 1 页,再次使用到时候又会解压成 3 页。

4、使用虚拟机内存机制

虚拟内存

1、当我们向系统申请内存时,系统并不会直接返回物理内存的地址,而是返回一个虚拟内存地址。从系统角度来说,每一个进程都有相同大小的虚拟内存空间。 只有当进程开始使用申请到的虚拟内存时,系统才会将虚拟地址映射到物理地址上,从而让程序使用真实的物理内存。

2、当A进程占用了大部分内存,此时B进程需要内存时发现内存不足,系统则会通知App,让App清理内存,既我们熟知的Memory Warning。

3、虚拟内存也有同样的缺点:硬盘的容量比内存大,但也只是相对的,速度却非常缓慢,如果和硬盘之间的数据交换过于频繁,处理速度就会下降,表面上看起来就像卡住了一样,这种现象称为抖动(Thrushing)

内存分页

iOS系统会对虚拟内存和物理内存进行分页,虚拟内存到物理内存的映射都是以页为最小粒度的

在 iOS 上,每一页包含 16KB (A7芯片以后,早期是4KB) 的空间。

VMObject、虚拟内存和堆(heap)

每次申请内存都必须以页为单位。然而这样一来,如果只是申请几个 byte,却不得不分配一页(16kb),是非常大的浪费。因此在用户态我们有 “heap” 的概念。

堆区会被划分成很多不同的VM Region,不同类型的内存分配根据需求进入不同的VM Region。

VM Region: 一个 VM Region 是指一段连续的内存页(在虚拟地址空间里)

Resident Page : 当前正在物理内存中的页

更多

每个进程都有一个自己私有的虚拟内存空间。对于32位设备来说是 4GB,而64位设备(5s以后的设备)是 18EB(1EB = 1000PB, 1PB = 1000TB),映射到物理内存空间。

三、iOS开发中可以通过哪些方式检查内存状态

1、Xcode Memory Gauge

在 Xcode 中,你可以通过 Memory Gauge 工具,很方便快速的查看 App 运行时的内存情况,包括内存最高占用、最低占用,以及在所有进程中的占用比例等。如果想要查看更详细的数据,就需要用到 Instruments 了。

2、Xcode Instruments

Instruments 中,你可以使用 AllocationsLeaksVM TrackerVirtual Memory Trace 对 App 进行多维度分析。

3、Xcode Debug Memory Graph / 内存图

通过这个工具,可以很直观地查看内存中所有对象的内存使用情况,以及相互之间的依赖关系,对定位那些因为循环引用导致的内存泄露问题十分有帮助。

你也可以通过 File->Export Memory Graph 将其导出为 memgraph 文件,在命令行中使用 Developer Tool 对其进行分析。使用这种方式,你可以在任何时候对过去某时的 App 内存使用进行分析。

vmmap - 查看虚拟内存

leaks - 查看泄漏的内存

heap - 查看堆区内存

malloc_history - 查看内存分配历史

vmmap xx.memgraph

//查看文档
man vmmap

四、开发过程中的内存问题

1、当我们收到内存警告的时候去释放内存

并非所有内存警告都是由 App 造成的,例如在内存较小的设备上,当你接听电话的时候也有可能发生内存警告。按照以往的习惯,你可能会在收到内存警告通知的时候去做一些释放内存的事情。然而内存压缩机制会使事情变得复杂。

当我们内存警告的时候如果去释放一份压缩状态的内存,会先展开内存,就会导致释放之前内存反而占用更多的情况。

2、Caching

我们对数据进行缓存的目的是想减少 CPU 的压力,但是过多的缓存又会占用过大的内存。由于内存压缩机制的存在,我们需要根据缓存数据大小以及重算这些数据的成本,在 CPU 和内存之间进行权衡。

在一些需要缓存数据的场景下,可以考虑使用 NSCache 代替 NSDictionary,因为 NSCache 可以自动清理内存,在内存吃紧的时候会更加合理。

3、Extension 拥有更小的内存限额

4、内存对齐

字节对其

最简单的,当你定义Model的时候,小内存属性放一起、大内存属性放一起,或者按内存页对齐

5、图片渲染开销

我们知道,解压后的图片是由无数像素数据组成。每个像素点通常包括红、绿、蓝和 alpha 数据,每个值都是 8 位(0–255),因此一个像素通常会占用 4 个字节(32 bit per pixel。少数专业的 app 可能会用更大的空间来表示色深,消耗的内存会相应线性增加)。

下面我们来计算一些通常的图片开销:

  • 普通图片大小,如 500 * 600 * 32bpp = 1MB
  • 跟 iPhone X 屏幕一样大的:1125 * 2436 * 32bpp = 10MB
  • 即刻中允许最大的图片,总像素不超过1500w:15000000 * 32bpp = 57MB

有了大致的概念,以后看到一张图能简单预估,大概会吃掉多少内存。

缩放

  • 内存开销多少与图片文件的大小(解压前的大小)没有直接关系,而是跟图片分辨率有关。举个例子:同样是 100 * 100,jpeg 和 png 两张图,文件大小可能差几倍,但是渲染后的内存开销是完全一样的——解压后的图片 buffer 大小与图片尺寸成正比,有多少像素就有多少数据。
  • 通常我们下载的图片和最终展示在界面上的尺寸是不同的,有时可能需要将一张巨型图片展示在一个很小的 view 里。如果不做缩放,那么原图就会被整个解压,消耗大量内存,而很多像素点会在展示前被压缩掉,完全浪费了。所以把图片缩放到实际显示大小非常重要,而且解码量变少的话,速度也会提高很多。
  • 如果在网上搜索图片缩放方案的话,一般都会找到类似“新建一个 context ,把图画在里面,再从 context 里读取图片”的答案。此时如果原图很大,那么即使缩放后的图片很小,解压原图的过程仍然需要占用大量内存资源,一不小心就会 OOM。但是如果换用 ImageIO 情况就会好很多,整个过程最多只会占用缩放后的图片所需的内存(通常只有原图的几分之一),大大减少了内存压力。

irGfqH.md.png

解码

图片解码是每个开发者都绕不过去的话题。图片从压缩的格式化数据变成像素数据需要经过解码,而解码对 CPU 和内存的开销都比较大,同时解码后的数据如何管理,如何显示都是需要我们注意的。

  • 通常我们把一张图片设置到 UIImageView 上,系统会自动处理解码过程,但这样会在主线程上占用一定 CPU 资源,引起卡顿。使用 ImageIO 解码 + 后台线程执行是 WWDC(18 session 219) 推荐的做法。
  • ImageIO 功能很强大,但是不支持 webp
  • AsyncDisplayKit 的一大思想是拿空间换时间,换取流畅的性能,但是内存开销会比 UIKit 高。同样用一个全屏的 UIImageView 测试,直接用UIImage(named:)来设置图片,虽然不可避免要在主线程上做解压,但是消耗的内存反而较小,只有4MB(正常需要10MB)。猜测神秘的 IOSurface 对图片数据做了某些优化。苹果有这么一段话描述 IOSurface:
Share hardware-accelerated buffer data (framebuffers and textures) across multiple processes. Manage image memory more efficiently.
复制代码

渲染

1、一旦涉及到 offscreen rendering,就可能会需要多消耗一块内存/显存。那到底什么是离屏渲染?不管是 CPU 还是 GPU,只要不能直接在 frame buffer 上画,都属于offscreen rendering。在 Core Animation: Advanced Techniques 书里有 offscreen rendering 的一段说明:

Offscreen rendering is invoked whenever the combination of layer properties that have been specified mean that the layer cannot be drawn directly to the screen without pre- compositing. Offscreen rendering does not necessarily imply software drawing, but it means that the layer must first be rendered (either by the CPU or GPU) into an offscreen context before being displayed.

2、layer mask 会造成离屏渲染,猜想可能是由于涉及到”根据 mask 去掉一些像素“,无法直接在 frame buffer 中做

3、圆角要慎用,但不是说完全不能用 — — 只有圆角和 clipsToBounds 结合的时候,才会造成离屏渲染。猜想这两者结合起来也会造成类似 mask 的效果,用来切除圆角以外的部分

4、backgroundColor 可以直接在 frame buffer 上画,因此并不需要额外内存

6、代码层面要注意的内存管理

内存问题

内存问题主要包括两个部分,一个是iOS中常见循环引用导致的内存泄露 ,另外就是大量数据加载及使用导致的内存警告。

mmap

虽然苹果并没有明确每个App在运行期间可以使用的内存最大值,但是有开发者进行了实验和统计,一般在占用系统内存超过20%的时候会有内存警告,而超过50%的时候,就很容易Crash了,所以内存使用率还是尽量要少,对于数据量比较大的应用,可以采用分步加载数据的方式,或者采用mmap方式。mmap 是使用逻辑内存对磁盘文件进行映射,中间只是进行映射没有任何拷贝操作,避免了写文件的数据拷贝。 操作内存就相当于在操作文件,避免了内核空间和用户空间的频繁切换。之前在开发输入法的时候 ,词库的加载也是使用mmap方式,可以有效降低App的内存占用率,具体使用可以参考链接第一篇文章。

循环引用

循环引用是iOS开发中经常遇到的问题,尤其对于新手来说是个头疼的问题。循环引用对App有潜在的危害,会使内存消耗过高,性能变差和Crash等,iOS常见的内存主要以下三种情况:

Delegate

代理协议是一个最典型的场景,需要你使用弱引用来避免循环引用。ARC时代,需要将代理声明为weak是一个即好又安全的做法:

@property (nonatomic, weak) id <MyCustomDelegate> delegate;
复制代码
NSTimer

NSTimer我们开发中会用到很多,比如下面一段代码

- (void)viewDidLoad {
    [super viewDidLoad];

    self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self
                                            selector:@selector(doSomeThing)
                                            userInfo:nil
                                            repeats:YES];
}

- (void)doSomeThing {
}

- (void)dealloc {
     [self.timer invalidate];
     self.timer = nil;
}
复制代码

这是典型的循环引用,因为timer会强引用self,而self又持有了timer,所有就造成了循环引用。那有人可能会说,我使用一个weak指针,比如

__weak typeof(self) weakSelf = self;
self.mytimer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(doSomeThing) userInfo:nil repeats:YES];
复制代码

但是其实并没有用,因为不管是weakSelf还是strongSelf,最终在NSTimer内部都会重新生成一个新的指针指向self,这是一个强引用的指针,结果就会导致循环引用。那怎么解决呢?主要有如下三种方式:

  • 使用类方法
  • 使用weakProxy
  • 使用GCD timer

具体如何使用,我就不做具体的介绍,网上有很多可以参考。

Block

Block的循环引用,主要是发生在ViewController中持有了block,比如:

@property (nonatomic, copy) LFCallbackBlock callbackBlock;
复制代码

同时在对callbackBlock进行赋值的时候又调用了ViewController的方法,比如:

self.callbackBlock = ^{
    [self doSomething];
}];
复制代码

就会发生循环引用,因为:ViewController->强引用了callback->强引用了ViewController,解决方法也很简单:

__weak __typeof(self) weakSelf = self;
self.callbackBlock = ^{
  [weakSelf doSomething];
}];
复制代码

原因是使用MRC管理内存时,Block的内存管理需要区分是Global(全局)、Stack(栈)还是Heap(堆),而在使用了ARC之后,苹果自动会将所有原本应该放在栈中的Block全部放到堆中。全局的Block比较简单,凡是没有引用到Block作用域外面的参数的Block都会放到全局内存块中,在全局内存块的Block不用考虑内存管理问题。(放在全局内存块是为了在之后再次调用该Block时能快速反应,当然没有调用外部参数的Block根本不会出现内存管理问题)。

所以Block的内存管理出现问题的,绝大部分都是在堆内存中的Block出现了问题。默认情况下,Block初始化都是在栈上的,但可能随时被收回,通过将Block类型声明为copy类型,这样对Block赋值的时候,会进行copy操作,copy到堆上,如果里面有对self的引用,则会有一个强引用的指针指向self,就会发生循环引用,如果采用weakSelf,内部不会有强类型的指针,所以可以解决循环引用问题。

那是不是所有的block都会发生循环引用呢?其实不然,比如UIView的类方法Block动画,NSArray等的类的遍历方法,也都不会发生循环引用,因为当前控制器一般不会强引用一个类。

其他内存问题

1 NSNotification addObserver之后,记得在dealloc里面添加remove;

2 动画的repeat count无限大,而且也不主动停止动画,基本就等于无限循环了;

3 forwardingTargetForSelector返回了self。

五、如何去管理和优化iOS中的内存

内存监控

https://mp.weixin.qq.com/s/r0Q7um7P1p2gIb0aHldyNw

内存优化

在后台时对内存优化

六、参考

理解iOS的内存管理

WWDC 2018:iOS 内存深入研究

iOS内存管理研究

野指针

https://img-blog.csdn.net/20150530183820304?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvVGVuY2VudF9CdWdseQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center

整理引用计数等问题,释放池工作原理等

内存如何优化等

2、objective-c runtime运行机制和内存管理机制

3、swift带来的影响、混编影响

4、ios底层实现机制

autoreleasePool实现原理

https://www.jianshu.com/p/0d32ba68fe72

关于内存五大分区

  • BSS段:
    • BSS段( bss segment )通常是指用来存放程序中未初始化的全局变量和静态变量 的一块内存区域。
    • 这里注意一个问题:一般的书上都会说全局变量和静态变量是会自动初始化的,那么哪来的未初始化的变量呢?变量的初始化可以分为显示初始化和隐式初始化,全局变量和静态变量如果程序员自己不初始化的话的确也会被初始化,那就是不管什么类型都初始化为0,这种没有显示初始化的就 是我们这里所说的未初始化。既然都是0那么就没必要把每个0都存储起来,从而节省磁盘空间,这是BSS的主要作用
    • BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配。 BSS节不包含任何数据,只是简单的维护开始和结束的地址,即总大小。以便内存区能在运行时分配并被有效地清零。BSS节在应用程序的二进制映象文件中并不存在,即不占用 磁盘空间 而只在运行的时候占用内存空间 ,所以如果全局变量和静态变量未初始化那么其可执行文件要小很多
  • 数据段(data segment)
    • 通常是指用来存放程序中已经初始化的全局变量和静态变量的一块内存区域。数据段属于静态内存分配,可以分为只读数据段读写数据段。字符串常量等,但一般都是放在只读数据段中。
  • 代码段(code segment/text segment)
    • 通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等,但一般都是放在只读数据段中 。
  • 堆(heap)
    • 堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或 缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张); 当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)
  • 栈 (stack heap)
    • 栈又称堆栈, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}” 中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外, 在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值 也会被存放回栈中。由于栈的后进先出特点,所以 栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。