探索iOS内存分配
iOS内存深入探索之VM Tracker
使用 Instruments 检测内存泄漏
首先我们先简单了解一下iOS中内存的五大区域,便于了解后续内容。
**由编译器管理(分配释放)**存放函数参数值、局部变量的值(函数中的基本数据类型)栈区的操作方式类似于数据结构中的栈(先进后出)。
由程序员管理(分配释放),若程序员不释放,程序结束时可能由系统(OS)回收,存放程序员new出来的对象。堆的操作方式于数据结构中的堆不同,操作方式类似于链表。
由编译器管理(分配释放),程序结束后由系统释放。存放全局变量和静态变量。有两块区域组成全局区(静态区),一块是存放未初始化的全局变量和静态变量,另一块是初始化完成的全局变量和静态变量,这两块区域是相邻的。
由编译器管理(分配释放),程序结束后由系统释放。存放常量字符串。
存放函数的二进制代码。
详细的五大分区优缺陷介绍等可参考该博客:[iOS]-内存的五大分区
(注: 到此为止iOS中的内存五大分区就介绍完啦,我们可以看到,这几大分区中只有堆区是由程序员管理的,所以我们后续主要探究的也就是堆区上的操作和原理)
下面我们就开始讲iOS中的内存分配:
在Instruments的Allocations
模板中,可以看到主要统计的是All Heap & Anonymous VM
的内存使用量。All Heap
好理解,就是App运行过程中在堆上(堆上所有)分配的内存。我们可以通过搜索关键字查看你关注的类在堆上的内存分配情况。那么Anonymous VM
是什么呢?按照官方描述,它是和你的App进程关联比较大的VM regions。原文如下:
interesting VM regions such as graphics- and Core Data-related. Hides mapped files, dylibs, and some large reserved VM regions.
要想了解什么是VM Regions,就得先了解什么是虚拟内存。当我们向系统申请内存时,系统并不会给你返回物理内存的地址,而是给你一个虚拟内存地址。每个进程都拥有相同大小的虚拟地址空间,对于32
位的进程,可以拥有4GB
的虚拟内存,64
位进程则更多,可达16EB
。只有我们开始使用申请到的虚拟内存时,系统才会将虚拟地址映射到物理地址上,从而让程序使用真实的物理内存。下面是一个简易示意图:
进程A和B都拥有1到4的虚拟内存。系统通过虚拟内存到物理内存的映射,让A和B都可以使用到物理内存。上图中物理内存是充足的,但是如果A占用了大部分内存,B想要使用物理内存的时候物理内存却不够该怎么办呢?在OSX上系统会将不活跃的内存块写入硬盘,一般称之为swapping out
。iOS上则会通知App,让App清理内存,也就是我们熟知的Memory Warning。
关于虚拟内存的讲解,详见该博客:一篇文带你搞懂,虚拟内存、内存分页、分段、段页式内存管理(超详细)
系统会对虚拟内存和物理内存进行分页,虚拟内存到物理内存的映射都是以页为最小粒度的。在OSX和早期的iOS系统中,物理和虚拟内存都按照4KB的大小进行分页。iOS的系统中,基于A7和A8处理器的系统,物理内存按照4KB分页,虚拟内存按照16KB分页。基于A9处理器及以上的系统,物理和虚拟内存都是以16KB进行分页。系统将内存页分为三种状态。
当可用的内存页降低到一定的阀值时,系统就会采取低内存应对措施,在OSX中,系统会将非活跃内存页交换到硬盘上,而在iOS中,则会触发Memory Warning,如果你的App没有处理低内存警告并且还在后台占用太多内存,则有可能被杀掉。
为了更好的管理内存页,系统将一组连续的内存页关联到一个VMObject上,VMObject主要包含下面的属性。
Anonymous VM
里看到的每条记录都是一个VMObject或者也可以称之为VM Region
。那么堆和VM Region是什么关系呢?按照前面的说法,应该任何内存分配都逃不过虚拟内存这套流程,堆应该也是一个VM Region才对。我们应该怎样才能知道堆和VM Region的关系呢?Instruments中有一个VM Track模版,可以帮助我们清楚的了解他们的关系。我创建了一个空的Command Line Tool App。
int main(int argc, const char * argv[]) {
NSMutableSet *objs = [NSMutableSet new];
@autoreleasepool {
for (int i = 0; i < 1000; ++i) {
TestObject *obj = [TestObject new];
[objs addObject:obj];
}
sleep(100000);
}
return 0;
}
TestObject是一个简单的OC类,只包含一个long类型的数组属性。
@interface TestObject : NSObject {
long a[200];
}
@end
运行Profile,选择Allocation模版,进入后再添加VM Track模版,如果Allocation模版自带的VM Track不工作,就自己手动加一个。
左上角这个红色嵌套的按钮是运行键,点击这个按钮就可以查看该项目中的内存情况。
可以看到这上图中最后一行的TestObject有1000个实例,点击TestObject右边的箭头,查看对象地址。
我们发现第一个地址是0x126008200
。我们接着切换到下面的VM Track,将模式调整为Regions Map。
然后找到Address Range包含0x126008200
的Region,如上图中最后一行即符合条件。我们发现这个 Region的Type是MALLOC_SMALL。点击箭头查看详情,你将会看到这个Region中的内存页列表。
我们可以清晰地看到,从图中标蓝的那行开始到包含那一千个TestObject的实例地址结束的行的右边,内存页Swapped列下都是被标记的,因为我测试的是Mac上的App,所以当内存页不活跃时会被交换到硬盘上。这也就验证了我们在上面提到的交换机制。如果我们将TestObject
的尺寸变大,比如作如下变动。
@interface TestObject : NSObject {
long a[20000];
}
@end
内存上会有什么变化呢,答案是TestObject
会被移动到MALLOC_MEDIUM
内存区。
我们从上图可以看到,TestObject
实例所占的内存是相当之大的
我们找到这些实例的地址,然后接着去VM Track的Regions Map模式里去找TestObject
实例所对应的页表。
我们看到TestObject
实例的类型变为了MALLOC_MEDIUM
所以总的来说,堆区会被划分成很多不同的VM Region,不同类型的内存分配根据需求进入不同的VM Region。除了MALLOC_MEDIUM和MALLOC_SMALL外,还有MALLOC_TINY,MALLOC_LAEGE, MALLOC metadata等等。
我们在VM Track中可以看到,一个VM Region有4种size。
Virtual Size
顾名思义,就是虚拟内存大小,将一个VM Region的结束地址减去起始地址就是这个值。Resident Size
指的是实际使用物理内存的大小。Swapped Size
则是交换到硬盘上的大小,仅OSX可用。Dirty Size
根据官方的解释我的理解是如果一个内存页想要被复用,必须将内容写到硬盘上的话,这个内存页就是Dirty的。下面是官方对Dirty Size
的解释。secondary storage
可以理解为硬盘。The amount of memory currently being used that must be written to secondary storage before being reused.
我们除了使用NSObject的alloc分配内存外,还可以使用c的函数malloc进行内存分配。malloc的内存分配当然也是先分配虚拟内存,然后使用的时候再映射到物理内存,不过malloc有一个缺陷,必须配合memset将内存区中所有的值设置为0。这样就导致了一个问题,malloc出一块内存区域时,系统并没有分配物理内存。然而,调用memset后,系统将会把malloc出的所有虚拟内存关联到物理内存上,因为你访问了所有内存区域。我们通过代码来验证一下。在main方法中,创建一个1024*1024的内存块,也就是1M。
void *memBlock = malloc(1024 * 1024);
如上图中最后一行所示,MALLOC_TINY中有一块虚拟内存大小为1M的VM Region。因为我们没有使用这块内存,所以其他Size都是0。现在我们加上memset再观察。
void *memBlock = malloc(1024 * 1024);
memset(memBlock, 0, 1024 * 1024);
从上图最后一行的信息中我们发现Resident Size,Dirty Size
的值已经不是0了,说明这块内存已经被映射到物理内存中去了。为了解决这个问题,苹果官方推荐使用calloc代替malloc,calloc返回的内存区域会自动清零,而且只有使用时才会关联到物理内存并清零。
相信大家对NSZone并不陌生,allocWithZone
或者copyWithZone
这2个方法大家应该也经常见到。那么Zone究竟是什么呢?Zone可以被理解为一组内存块,在某个Zone里分配的内存块,会随着这个Zone的销毁而销毁,所以Zone可以加速大量小内存块的集体销毁。不过NSZone实际上已经被苹果抛弃。你可以创建自己的NSZone,然后使用allocWithZone
将你的OC对象在这个NSZone上分配,但是你的对象还是会被分配在默认的NSZone里。例如:
static NSMutableSet *objs = nil;
if (objs == nil) { objs = [NSMutableSet new]; }
NSZone *testZone = NSCreateZone(1024, 1024, YES);
NSSetZoneName(testZone, @"Test Object Zone");
for (int i = 0; i < 1000; ++i) {
TestObject *obj = [TestObject allocWithZone:testZone];
[objs addObject:obj];
}
代码创建了1000个TestObject对象,但是最后其实都在系统默认床架的NSZone中,Test Object Zone中只有1个node,其中是用来存放Zone本身的信息的,如果你真的想用Zone内存机制,可以使用malloc_zone_t。通过下面的代码可以在自定义的zone上malloc内存块,例如:
malloc_zone_t *testZone = malloc_create_zone(1024, 0);
malloc_set_zone_name(testZone, "Test malloc zone");
for (int i = 0; i < 1000; ++i) {
malloc_zone_malloc(testZone, 300 * 4096);
}
最后运行的结果是我们的Test malloc zone中有1001个node,也就是1000个Test_zone_malloc出来的内存块加上zone本身的信息所占的内存块。
另外我们可以使用malloc_destroy_zone(testZone)
一次性释放上面分配的所有内存。
**总结:**本文主要介绍了iOS (OSX)系统中VM的相关原理,以及如何使用VM Track模板来分析VM Regions。
VM Tracker是Xcode Instruments自带的一个内存分析工具,可以帮助你快速查看虚拟内存块的用量状态以及根据虚拟内存块的tag进行分类。如果你想知道关于虚拟内存的相关知识,可以先阅读上文,如果你对虚拟内存以及VM Region不太了解的话,阅读下面的内容可能会有些障碍。想要使用VM Tracker,使用Instruments的Allocations模版即可。如果模版自带的VM Tracker不显示信息,可以用右边的加号再添加一个VM Tracker。
上面是一个空的iOS App的VM Tracker示意图。一共有9列,下面我来一一解释它们的含义。
% of Res
, 当前Type的VM Regions总Resident Size占比。Type
,VM Regions的Type,All和Dirty算是统计性质的Type,__TEXT表示代码段的内存映射,__DATA表示数据段的内存映射。MALLOC_TINY,MALLOC_LARGE,CG Image等Type可以从VM Region的Extend Info中读取出来,后面会着重介绍。# Regs
,当前Type的VM Region总数。Path
,VM Region是从哪个文件映射过来,因为有些类似于__DATA和mapped file的内存块是从文件直接映射过来的。Resident Size
,使用的物理内存量。Dirty Size
,使用中的物理内存块如果不交换到硬盘保存状态就不能复用,那么就是Dirty的内存块,比如你主动malloc出来的内存块,如果不保留其中的状态就把它给别人用,那你肯定就无法恢复这个内存块的信息,所以它是Dirty的。如果是一个映射到内存的文件,就算使用它的内存块,还是可以重新从磁盘载入文件到内存的,所以是非Dirty的,比如最上面图中的mapped file那一行,你可以看到Dirty Size是0。Swapped Size
, 在OSX中,不活跃的内存页可以被交换到硬盘,这是被交换的大小。在iOS中,只有非Dirty的内存页可以被交换,或者说是被卸载。Virtual Size
,VM Regions所占虚拟内存的大小Res. %
,Resident Size在Virtual Size中的占比我们可以使用vm_allocate方法申请一块虚拟内存。下面是具体代码。
vm_address_t address;
vm_size_t size = 1024 * 1024 * 100;
vm_allocate((vm_map_t)mach_task_self(), &address, size, VM_MAKE_TAG(200) | VM_FLAGS_ANYWHERE);
sleep(100000);
上面的代码申请了一块100M的虚拟内存,(vm_map_t)mach_task_self()表示在自己的进程空间内申请。size的单位是byte。 VM_MAKE_TAG(200)是给你申请的内存块提供一个Tag标记。我这里提供了一个200数值作为标记,后面我会具体介绍这个数值在VM Tracker中的作用。最后我们用VM Tracker看一下我们自己分配的虚拟内存块。
从上图最后两行我们看到这块内存块的Resident Size和Dirty Size都是0KB,因为我们并未使用这块内存,所以并没有虚拟内存被关联到物理内存上去。你可以尝试使用这块内存,然后去VM Tracker观察变化。比如使用下面的方式填充内存块:
for (int i = 0; i < 1024 * 1024 * 100; ++i) {
*((char *)address + i) = 0xab;
}
我们看到这块内存块的Resident Size和Dirty Size都是64KB,大部分都被交换到了磁盘当中保存即都在Swapped中,然后过了一会儿之后全部都在Swapped中,Resident Size和Dirty Size都是0KB
接下来我们来介绍内存块的Type,我曾经思考很久VM Tracker是如何识别出每个内存块的Type的。比如MALLOC_TINY,MALLOC_SMALL,ImageIO等等。答案就在vm_allocate
方法的最后一个参数flags。flags可以分成2个部分。VM_FLAGS_ANYWHERE
属于flags里控制内存分配方式的flag,它表示可以接受任意位置的内存分配。它的宏定义如下。
#define VM_FLAGS_ANYWHERE 0x0001
从定义可以看出,2个字节就可以存储它,int有4个字节,还剩下2个就可以用来存储标记内存类型的Type了。苹果提供了VM_MAKE_TAG
宏帮助我们快速设置Type。VM_MAKE_TAG
实际上做了一件很简单的事情,把值左移24个bit,也就是3个字节,所以系统留给了我们1个字节(只用一个字节表示tag)来表示内存的类型。下面是VM_MAKE_TAG
的宏定义。
#define VM_MAKE_TAG(tag) ((tag) << 24)
实际上苹果已经内置了很多默认的Type,下面列出一部分。
#define VM_MEMORY_MALLOC 1
#define VM_MEMORY_MALLOC_SMALL 2
#define VM_MEMORY_MALLOC_LARGE 3
#define VM_MEMORY_MALLOC_HUGE 4
#define VM_MEMORY_SBRK 5// uninteresting -- no one should call
#define VM_MEMORY_REALLOC 6
#define VM_MEMORY_MALLOC_TINY 7
#define VM_MEMORY_MALLOC_LARGE_REUSABLE 8
#define VM_MEMORY_MALLOC_LARGE_REUSED 9
如果我们使用VM_MEMORY_MALLOC_HUGE
来作为Type,再用VM Tracker观察会怎么样呢?下面是内存分配的代码。
vm_address_t address;
vm_size_t size = 1024 * 1024 * 100;
vm_allocate((vm_map_t)mach_task_self(), &address, size, VM_MAKE_TAG(VM_MEMORY_MALLOC_HUGE) | VM_FLAGS_ANYWHERE);
如上图中的MALLOC_HUGE,很明显VM Tracker认出了这块内存,并且将它的Type设定为MALLOC_HUGE。如果你想使用vm_allocate来分配和管理大内存,也可以设置一个Type,方便快速定位到自己的虚拟内存块。
并且有一个细节: 在测试中发现,苹果对于正在使用和执行的这个VM Region,它的Resident Size和Dirty Size都是它的完整的Virtual Size(虚拟内存的大小),等执行完成之后就会很快进行磁盘和内存的交换,最后所有的内存都写到了磁盘中,即Resident Size和Dirty Size都是0KB,Swapped都是完整的Virtual Size(虚拟内存的大小)。
总结: 本文主要介绍了VM Tracker中关于虚拟内存的一些概念,以及如何自行分配虚拟内存。了解了这些之后,在分析内存暴涨或者泄漏时就有了新的思路,而不仅仅是局限于基于malloc内存块的内存分析了。