分配内存贴示

内存是你的应用中重要的资源,所以仔细思考下你的应用程序如何使用内存以及如何为你的应用程序高效地分配内存是十分重要的。大多数应用程序在进行分配内存操作时不需要做什么特殊的操作,只需要按需申请对象或者内存块即可,你也不会发现任何因分配内存而导致的应用程序的性能下降。但是对于使用大量内存资源的程序而言,仔细的规划你的内存申请分配策略可能会使你的应用程序大不同。

下面几节将讲述几条有关分配内存的基本操作的小贴示,如果照做的话,将会使得你的应用程序更加高效。首先为了判断你的应用程序是否存在因内存分配导致的性能问题,你需要使用Xcode的相关工具去查看你的运行状态下的应用程序内存分配情况。想知道如何做,请参看《追踪你的内存使用情况》。

提升内存相关的性能的贴示

当你设计你的代码的时候,你应该清楚的知道如何使用内存。因为内存是如此重要的资源,你要确定高效的使用内存资源且不要造成浪费。此外,针对指定的操作申请合适的内存大小也同样重要。下面的几节将会讲述几种提高你内存使用效率的方法。

延迟你的内存分配时机

每一个内存分配操作都会造成性能上的损耗。这种损耗包括了在你的程序的逻辑地址空间中分配内存空间耗费的时间,也包括了将逻辑地址空间映射至物理内存时耗费的时间。如果你不打算立刻使用某一片内存空间,那么如下操作将是一种十分明智的做法,即延迟该内存空间的分配时机直到你真正需要它时。例如,为了优化你的app的加载速度,你应该尽可能减少在加载应用时候申请内存的数量,于此对应的,你应该关注你的用户界面展示及用户交互时所需的内存的分配情况。延迟其它的内存分配申请直到你的用户真正开始与你的app进行交互。这种懒分配内存策略(lazy allocation of memory)会立竿见影的起到节省时间的效果,同时能够保证在真正需要用内存空间的时机分配合适的内存空间。

有一个地方使用懒分配内存策略需要点小技巧,即针对全局变量的内存申请。因为全局变量对于你的app而言是全局的。因此你需要确保你的代码在使用它前已经被正确分配内存空间且初始化。最基本的解决方法就是在你的函数模块里定义一个静态全局变量,然后用公共访问函数对该全局变量进行取值和赋值。如下代码:

  1. MyGlobalInfo* GetGlobalBuffer()
  2. {
  3. static MyGlobalInfo* sGlobalBuffer = NULL;
  4. if ( sGlobalBuffer == NULL )
  5. {
  6. sGlobalBuffer = malloc( sizeof( MyGlobalInfo ) );
  7. }
  8. return sGlobalBuffer;
  9. }

这段代码唯一需要你关心的就是当多线程调用该函数时可能会发生的状况。在多线程环境下,你需要使用锁(Lock)来保护 if语句 ,否则可能出现多线程竞争。当然这个方法带来的负面影响就是当每个线程访问该全局变量时都要受到锁机制的限制,这会给你的应用带来极大的性能消耗。一个简单的解决上述性能下降问题的办法就是在你的其它子线程还没有访问该全局变量前,就在主线程上初始化该全局变量。

高效初始化内存块

使用malloc函数分配的小的内存块不能保证其对应的物理内存空间初始化为0,尽管你可以使用memeset函数来初始化上述的物理内存空间使之为0,但是更好的选择是使用calloc函数。calloc函数会保留所分配内存的虚拟内存地址,但是会直到真正使用该内存时才初始化它(这里笔者认为是真正使用时才会将虚拟内存与物理内存建立映射并将对应的物理内存块初始化为0)。这个方法比使用memeset高效很多,因为若memeset执行初始化为0的任务,需要迫使虚拟内存系统先将malloc申请的虚拟内存空间与实际物理地址空间建立映射关系。而使用calloc函数的好处就是只有当系统真正使用该内存时才会建立映射并初始化它们,不像malloc + memeset组合需要立刻将所有分配的内存建立映射并初始化。

重用临时内存缓冲池

如果你有一个自创大型临时内存缓冲池以用于计算操作的函数的话,你也许会考虑如何重用这些临时缓冲池而不是每次调用该函数时都要重新创建一遍。即使你的函数需要一个可变的缓冲池空间,你也可以当需要增加该缓冲池空间时使用 realloc 函数来进行操作。对于多线程环境下的应用,重用缓冲池最好的方法就是将它们加入你的线程所对应的TLS(线程局部存储 thread-local-storage)。当然,你也可以使用静态变量存储你的缓冲池(笔者注:这样就分配在堆的静态变量存储区域了),但是这么做就无法使得多线程同时访问了。使用缓冲池所带来的收益可以抵消频繁申请销毁大型内存块所带来的开销,但是这个方法只适用于那些频繁申请销毁内存块的情况。同时,你也要注意不要缓存太多内存,因为这样会增加你的应用的内存占用量,使用时要谨慎,仅当你测试后发现这样做确实能够提升你的应用性能时才这样做。

释放无用内存

对于使用malloc库申请分配的内存,最重要的一点就是当你用完以后一定要及时释放掉它。如果你忘记释放该内存,那么将会导致内存泄漏(memory leaks),这样会减少你的应用所能够使用的内存,同时还会影响你的系统的性能。内存泄漏同时也可能导致你的应用因申请不到足够的内存而挂掉。

注意:使用ARC机制编译的应用app不要显式的释放OC对象。相对应的,如果你的app需要保留一个对象存在你的内存中,你需要使用强引用(strong references),如果你不想让它保留该对象在内存中,则删除掉所有指向该对象的强引用即可,因为一旦一个对象没有任何强引用指向它,依据ARC机制,编译器会自动释放掉它。想了解更多有关ARC机制的信息,请阅读《Transitioning to ARC Release Notes》。

无论你在什么平台下书写你的应用,你都应该消除你的应用的内存泄漏。对于那些使用malloc申请分配内存的代码而言,请记住尽可能推迟申请内存的时机总是好的,但是可千万别推迟释放该内存的时机。如果你想追踪你的应用中的内存泄漏,请使用Instruments app。

内存分配技术

内存是一个如此基础且重要的资源,OS XIOS都提供了好几种方式去分配内存。你使用的分配内存的方式主要取决于你的需求。但是归根结底,所有内存分配方法最终都会使用malloc库,甚至连Cocoa对象的内存分配最终都仍然使用的是malloc库。这样的单一库(malloc)申请分配内存的使用方式为我们使用性能测试工具检测应用内存申请分配的情况提供了可能。

如果你在编写Cocoa应用程序,你也许只会使用NSObject类下的alloc函数分配内存。即便如此,你也会在一些时候需要使用其它内存分配技术来申请内存。例如,你也许想要直接使用malloc函数来申请分配内存以避免调用一些低等级的函数(笔者注:大意就是说不用通过alloc函数一层层调用相关函数,最终调用到malloc函数了,直接用malloc申请内存)。

下面几节将会讲述有关malloc库和虚拟内存系统以及它们如何申请分配内存的相关信息。这些章节会帮助你理解每一种内存申请分配方式的耗费代价。你可以通过理解这些信息帮助优化你的应用程序的内存申请分配方式从而达到优化应用程序性能的目的。

注意:下面这些章节内容是假设你使用原版malloc库的原装函数申请分配你的内存,如果你使用的是自定义版本的malloc库的相关函数,这些技术可能对你无效。

申请对象内存

对于基于Objective-C的应用而言,你申请分配内存主要采用如下两种技术中的某一种,一是alloc方式,通过执行OC类的初始化函数进行初始化操作。另一种是使用new方式申请分配内存,通过调用默认的init函数对OC类进行初始化。

在创建一个OC类对象之后,编译器的ARC特性就会决定这个对象的生命周期以及什么时候它该被销毁。每一个新的OC类对象都至少需要一个强引用指针指向它以防止被编译器立刻销毁。因此,当你创建一个新的对象时,你总是需要创建至少一个强引用指针指向它。在此之后,你也许会创建额外的强引用指针或者弱引用指针指向该对象,当然,这些都是依据你的代码需求所决定的。当指向该对象的所有强引用指针被删除,那么编译器就会自动删除掉该对象。

想了解更多有关ARC的信息,以及如果管理你的对象的生命周期,请看《Transitioning to ARC Release Notes》一文。

使用Malloc申请小内存块

对于小内存块申请分配而言,这里的小是指比几页虚拟内存页还小,malloc函数会从一个能够逐步增长的空闲块链表中再分配所需要的内存数量。当你使用free函数释放这些小内存块后,这些内存块又会以一个合适的方式添加回这个空闲块链表中来。这个空闲块链表是由系统通过vm_allocate函数为你创建了几个虚拟内存页从而共同组成的。

当分配任何小内存块时,请记住malloc函数能够申请的最小空间为16B,也就是说,你最小只能够申请16B的内存块,或者申请16B的倍数级别的内存块。例如,你使用malloc函数申请4B的内存块,该函数将会返回一个16B的内存块,如果你申请的是24B的内存块,该函数返回32B的内存块。因为这样一个特性,你应该仔细的设计你的数据结构,尽可能使得它们的大小是16B的倍数。

因为这样一个特性,如果申请比一个虚拟内存页还小的内存块会导致内存无法页对齐(page aligned)

使用Malloc申请大内存块

对于大内存块申请分配而言,这里的大是指比几页虚拟内存页还大,malloc函数会自动使用vm_allocate函数获取所要求分配的内存。vm_allocate函数会为新的大内存块分配一个当前进程逻辑地址空间上的地址段,但是它不会立刻为这些虚拟地址空间建立任何与物理内存空间的映射关系。相对应的,内核会做如下操作:

  1. 内核会先创建 map entry 将这些虚拟内存地址空间中的地址段匹配(map)进来,这个map entry 实际上就是一个定义了内存段起始地址与结束地址的简单的数据结构。
  2. 这个内存段(range of memory)会被默认分页器(default pager)备份起来。
  3. 内核会创建并初始化一个VM对象,并将该VM对象与上述map entry关联起来。

此时此刻,物理内存中没有任何页驻留,硬盘中的备份存储区域也没有任何被交换出来的页存在。所有的页都被系统虚拟映射着。当你的代码访问了内存块中的某一部分,例如读或者写了某一个特殊的地址,那么此时就会产生页错误,因为此时访问的虚拟地址并没有真正映射到任何一个物理内存页中去。在OS X平台下,内核在此时发现了存在没有在备份存储空间有映射关系的VM对象,然后就会针对每一个页错误做出如下操作:

  1. 内核首先会从空闲(内存)链表中取出一个物理页并将其数据初始化为0。
  2. 在VM对象的驻留内存页链表中插入一个指向步骤1分配出的页的引用对象。
  3. 通过填充pmap这个数据结构的相关字段,完成虚拟内存页与物理内存页的映射关系。pmap结构中包含着记录着给定虚拟内存到实际物理内存映射关系的页表(page table)

这种大内存块的粒度大小等同于虚拟内存页的大小,即4k。换句话说,任何大内存块的分配空间都是4k的倍数,如果申请分配的空间不是4k的倍数,那么也将会自动申请到4k的倍数。因此,如果你申请大内存块缓存,你应该申请4k倍数的大小以避免浪费内存。

注意:大内存块的申请分配可以保证内存页对齐(page-aligned)

对于大的内存块申请分配,你也许会发现直接使用vm_allocate申请虚拟内存地址会更直截了当且高效。下面的例子就是告诉你如何使用vm_allocate函数申请内存:

  1. void* AllocateVirtualMemory(size_t size)
  2. {
  3. char* data;
  4. kern_return_t err;
  5. // In debug builds, check that we have
  6. // correct VM page alignment
  7. check(size != 0);
  8. check((size % 4096) == 0);
  9. // Allocate directly from VM
  10. err = vm_allocate( (vm_map_t) mach_task_self(),
  11. (vm_address_t*) &data,
  12. size,
  13. VM_FLAGS_ANYWHERE);
  14. // Check errors
  15. check(err == KERN_SUCCESS);
  16. if(err != KERN_SUCCESS)
  17. {
  18. data = NULL;
  19. }
  20. return data;
  21. }

批量分配内存

如果你的代码想申请分配大量大小一致的内存块,你可以使用 malloc_zone_batch_malloc 函数来一次性申请分配完毕。同样申请分配大量大小一致内存块,该函数比数次调用malloc函数拥有更优秀的性能表现。尤其当每一个单独内存块的大小小于4K时,其性能达到最佳。该函数会尽最大可能返回你所要求的大小一致的内存块数量,但是请注意它也可能不会返回给你所需的数量。所以当使用该函数时,请一定仔细检查返回的内存块数量是否符合你的预期。OS X 10.3及其以上版本,IOS版本都支持批量分配内存。想知道更多,请查看 /usr/include/malloc/malloc.h 头文件。

申请共享内存

共享内存是一种可以被两个或者多个进程共同读写的内存。共享内存的使用途径包括以下几种情况:

  • 共享大的资源,例如图标或者声音等
  • 两个或者多个进程之间的快速沟通

共享内存很脆弱且通常在有其它可用办法的情况下不建议使用。如果一个程序破坏了共享内存的某一段,那么其它程序可能会访问到被破坏的共享内存。详细信息请查看 /usr/include/sys/shm.h 头文件。

使用内存分配域(Malloc Memory Zones)

所有的内存块都是在一个malloc zone(也有称为malloc heap) 中被分配的。一个zone是一个能分配内存块的内存系统中的一段虚拟内存区域。一个zone有它自己的空闲(内存)链表以及内存页池(pool of memory pages),从该zone分配出来的内存页实际上仍在这个内存页池中。如果你需要创建很多拥有相同生命周期及访问方式的内存块的话,zone对你来说是很有用的。你可以在zone中分配许多对象空间或内存块,然后通过直接销毁zone的方式直接将这些对象空间和内存块一并销毁,而不是一个个的单独销毁它们。理论上来说,这样使用zone可以减少内存空间的浪费及减少分页操作(reduce paging activity)。但实际上,使用zone的开销基本上也就抵消了使用它带来的上述的优势。

注意:在使用malloc方式分配内存时,zone与heap,pool,arena等词汇的意思是一样的。

默认情况下,使用malloc函数分配内存一般会发生在默认的zone上(default malloc zone),该zone是你第一次使用malloc时由你的应用程序创建的。尽管经过测算你发现额外申请其它zone并在其上分配内存会为你的应用程序的性能带来提高,但是我们仍然不推荐这样做。例如,如果你发现你的应用中释放大量缓存对象的效率严重的降低了应用性能,那么你可以将它们统一的在一个zone中分配空间,并通过简单的销毁zone一并销毁这些缓存对象,而不是单独一个个销毁,从而达到提升应用性能的目的。当你这么做时,请一定确保在该zone上你的应用的其它数据结构不再持有指向该zone上的对象空间或者内存块的引用指针,因为试图访问一个已经销毁的zone上的内存空间会导致内存错误进而使你的应用崩溃。

注意:你绝不能销毁你的应用创建的默认zone空间(default zone)

在malloc库层级,zone的相关函数被定义在 /usr/include/malloc/malloc.h 可以使用 malloc_create_zone 函数创建用户自定义的 malloc zone,或者使用 malloc_default_zone 函数获取应用为你创建的 default zone。如果想分配一个特指的zone(particular zone),可以使用 malloc_zone_malloc, malloc_zone_calloc, malloc_zone_valloc, 或者是malloc_zone_realloc函数 。如果想释放用户自定义的zone(custom zone),请使用 malloc_destroy_zone

使用Malloc函数拷贝内存

直接拷贝内存的方式有很多,诸如使用memcpy函数或者使用memove函数将数据从一个内存块拷贝至另一个数据库。当然拷贝过程中,源内存块和目的内存块都必须同时驻留在内存中,但是这种情况只适合如下几种情况:

  • 你想要拷贝的内存块很小(小于16k)。
  • 你想要立刻就使用源内存块或者目的内存块。
  • 目的内存块不是页对齐的。
  • 源内存块与目的内存块地址重叠。

如果你不计划立刻使用源内存块或者目的内存块的话,那么在拷贝大内存块时,使用直接拷贝的方法会很明显的降低你的应用性能。拷贝内存会直接增加你的应用的内存占用空间。一旦你增加了你的应用的内存占用空间,你就增加了页出至硬盘的几率。如果你要对两个大内存块进行拷贝操作(一个源内存块一个是目的内存块),那么很可能这两个大内存块会被页出至硬盘,当你后面真正想要去访问这两个内存块中的某一个的时候,你还需要将它们从硬盘载回内存中,这代价太高了。而使用vm_copy函数可以 延迟拷贝操作 ,从而降低拷贝成本。

注意:如果源内存块与目的内存块内存重叠了,你应该使用memove而不是memcpy,在OS X中,使用memove能够保重源内存地址与目的内存地址重叠的情况下正确拷贝,但是memcpy不行。(编者注:这是C语言函数实现所决定的,详见memove与memcpy的具体实现)

延迟内存拷贝操作

如果你打算拷贝许多内存页,但是你又不打算立刻使用源页或者目的页,那么你也许希望使用vm_copy函数来实现这一想法。不像memove或者memcpy,vm_copy并不会真正访问任何物理内存。它只是修改了虚拟内存映射, 使得目的地址空间是源地址空间的一个copy-on-write的版本。

在一些特殊场景下,vm_copy比memcpy要高效的多。当你的代码在拷贝大内存块操作后一段时间不会去访问源内存块以及目的内存块的情况下,使用vm_copy将非常高效。其高效的原因就是vm_copy并不是真正立刻执行拷贝,而是将目的地址空间处理为源地址空间的一个copy-on-write版本,这样就可以延迟拷贝操作的进行。当真正进行该拷贝操作时,内核首先会移除所有虚拟地址空间中指向源地址空间的引用指针。然后进程会访问源页,此时软错误发生(soft fault),内核将物理内存中的该页重新做为copy-on-write页映射到进程的地址空间中来,这样就实现了拷贝。这一处理单一软错误的过程的代价花费与直接拷贝数据的代价差不多。

拷贝小规模数据

如果你需要拷贝小数据量的非内存地址重叠的内存块时,你应该最优先使用memcpy而不是其它方法。对于小规模内存块,GCC编译器可以用內联指令来进行值拷贝操作,这样会优化拷贝性能,而如果你使用例如memove或者BlockMoveData函数,编译器不会对此进行优化。

拷贝数据至Video RAM

当要拷贝数据至VRAM时,请使用 BlockMoveDataUncached 函数替代诸如 bcopy 等函数。bcopy函数会使用缓存操作指令,这样当拷贝时可能会引起异常操作,内核在此期间必须要修复这些错误,这样会极大降低性能。

IOS中收到低内存警告时的响应措施

IOS中的虚拟内存系统由于不会使用硬盘存储备份空间(笔者注:就是不会将内存页交换至硬盘中去),因此需要依靠与应用程序的合作来移除OC对象的强引用指针。当空闲链表中空闲页的数量低于某一个经过计算的阈值时,系统就会给当前正在运行的应用程序发出低内存警告提示,并且释放内存中那些未经修改的页。如果你的应用程序受到了低内存警告通知,请一定注意。一旦收到该通知,你的应用程序必须尽可能的移除其虚拟内存空间内对象的强引用指针。例如,你可以当收到低内存警告后清除最近新产生的数据缓存等。

UIKit提供了如下几种接收低内存警告通知的方式:

  • 执行你的应用程序代理函数 :applicationDidReceiveMemoryWarning:
  • 在你继承UIViewCOntroller的子类里面复写 didReceiveMemoryWarning : 函数。
  • 在默认通知中心中注册通知: UIApplicationDidReceiveMemoryWarningNotification

如果你的应用只有少量带有已知可清除资源的对象的话,那么你可以为这些对象注册UIApplicationDid ReceiveMemoryWarningNotification通知,一旦收到内存警告,则这些对象主动释放其携带的可清除数据。如果你的应用有很多可清除对象或者你只想选择性的清除其中的几个,那么你可能希望使用你的应用程序代理来决定哪些对象需要释放。

重要!!:

即使你在测试环节接收不到低内存警告,你的应用程序也应该像系统应用程序一样总处理低内存警告。当系统低内存情况被检测到时,系统会向所有正在运行的程序(包括你的应用程序)发送低内存警告,同时也可能关闭一些后台应用(如果需要的话)来减轻系统的内存压力。如果你的应用程序因为内存泄漏或者消耗太多内存资源导致系统仍然处于低内存状态,那么系统可能会杀掉你的应用程序。