谈到 iOS/OS X 内存管理开发者首先想到的可能是 Objective-C 中 ARC 相关的内容(毕竟相关的内容实在是太多了),但这篇文章与大家分享的是操作系统层面的一些内存相关的概念与知识点。Apple 对内存的抽象有自己的描述(如 Clear/Dirty Memory),理解与之相关的概念有助于理解 Apple 对内存优化的思路,并能帮助开发者对自己 App 的内存进有有效的优化。

一些概念

虚拟内存

再探 OSX / iOS 的内存管理

现代操作系统均使用了「虚拟内存」的概念,虚拟内存允许操作系统突破物理内存的限制。它使得操作系统可以为每个进程创建一个独立的逻辑地址空间(或称为“虚拟”地址空间),并将其分成大小均匀的内存块,这些块称为页面(Page)。处理器及其内存管理单元(MMU)维护一个页表,将程序逻辑地址空间中的页面映射到计算机 RAM 中的硬件地址。当程序的代码访问内存中的地址时,MMU使用页表将指定的逻辑地址转换为实际的硬件内存地址。这个转换是自动进行的,对正在运行的应用程序是透明的。

对于一个用户进程来讲,它不可能得到到真实物理内存地址,所有地址均为虚拟地址,由 MMU 负责将虚拟地址编译为真实的物理内存地址。

对于一个程序而言,其逻辑地址空间中的地址始终可用。然而,如果应用程序访问的内存页面当前不在物理 RAM 中,就会发生页面错误。当发生这种情况时,虚拟内存系统会产生一个缺页中断来响应该错误。该中断处理时会停止当前正在执行的代码,找到一个空闲的物理内存页面,从磁盘加载包含所需数据的页面,更新页表,然后将控制返回给程序的代码,程序可以正常访问内存地址。这个过程被称为分页(paging)。

如果物理内存中没有空闲页面可用,处理程序必须首先释放一个现有的页面,为新页面腾出空间,系统如何释放页面取决于具体平台。

  • OS X 中,虚拟内存系统通常会将页面写入备份存储区。备份存储区是一个磁盘上的存储区域,其中包含给定进程使用的内存页面的副本。将数据从物理内存移到备份存储区称为页面换出(page out);将数据从备份存储区移到物理内存称为页面换入(page in)

  • iOS 中,没有磁盘备份存储区,因此页面从不会交换到磁盘,但只读页面仍然会根据需要从磁盘交换进来

在 OS X 和较早版本的 iOS 中,一个 Page 的大小为 4KB 。在后来的 iOS 版本中,基于 A7 和 A8 芯片的 64 位系统一个虚拟内存 Page 为 16KB,与之对应的物理内存 Page 为 4KB,而基于 A9 芯片的 64 位系统的虚拟内存 Page 大小为 16KB,与之对应的物理 Page 大小也是 16KB。这些 Page 大小决定了当发生页面错误(缺页中断)时系统从磁盘读取的字节数。当系统花费过多的时间处理缺页中断与读写页面,而不是执行用户进程代码时,这个过程中磁盘抖动就 会发生。

在计算机系统中,当内存(RAM)不足以容纳当前正在运行程序的数据时,操作系统会将部分数据移动到磁盘上的虚拟内存中以释放内存空间。这种频繁的内存交换导致计算机性能下降,出现的现象即为抖动(Thrashing)。除了显而易见的响应速度减慢外,抖动还会对硬件设备造成额外的负担,降低整体的系统稳定性和可靠性。长时间的抖动可能导致硬盘寿命缩短,对计算机的持续性能造成影响。

Page Lists

操作系统内核会维护三个系统范围的物理内存 Page List:

  • Active List: 当前映射到虚拟内存中并且最近有被访问的页面

  • Inactive List: 当前驻留在物理内存中但最近没有被访问的页面。这些页面包含有效数据,但随时可能被从内存中移除

  • Free List: 空闲列表包含物理内存页面,这些页面不与任何地址空间或 VM 对象关联。这些页面可立即被任何需要它们的进程使用

Free List 上的 Page 数量下降到一个阈值以下(由设备物理内存的大小确定),分页管理系统会尝试平衡这些队列。它通过从 Inactive List 中取出页面来实现。如果一个 Page 最近被访问过,则它会被重新激活,并放置在 Active List 的末尾。在 OS X 中,如果一个 Inactive Page 未写入磁盘数据,则其内容必须被换出到磁盘才能放置在 Free List。(在iOS中,已修改但处于 Inactive 状态的页面必须保留在内存中,并由拥有它们的进程负责清理。)如果一个 Inactive Page 没有被修改且不是永久驻留,它将被添加到 Free List 中(任何当前对它的虚拟内存映射都将被销毁)。一旦 Free List 大小超过目标阈值,分页管理系统将停止。

内核会将未被访问 Page 从 Active List 移动到 Inactive List,这个过程会在软中断中进行。当虚拟页面被换出时(换出到磁盘),相关的物理页面会被放置在Free List 中。此外,当进程显式释放内存时,内核会将受影响的页面移动到 Free List 中。

Page Out

在 OS X中,当 Free List 的 Page 数量低于计算得到的阈值时,内核通过将不活动的页面换出内存来回收物理页面并移动到 Free List 。为此,内核遍历 Active ListInactive List 中的所有页面,并执行以下步骤:

  • 如果 Active List 中的页面最近没有被访问,它将被移动到 Inactive List
  • 如果 Inactive List 中的页面最近没有被访问,内核会查找页面的VM对象。
  • 如果 VM 对象以前从未被换页(写入磁盘)过,内核会调用一个初始化例程,创建并分配一个默认的换页对象。
  • VM 对象的换页对象尝试将页面写入备份存储区(磁盘)。
  • 如果写入成功,内核释放页面占用的物理内存,并将页面从 Inactive List 移动到 Free List 中。

内存分类

内存有分页(Page)的概念,实际上内存页本身也有分类,一般分为两类:Clear Memory / Dirty Memory

Clean Memory & Dirty Memory

对于 OS X,Clean Memory 页是没有对其内容进行修改的内存页,同时只有 Clean Memory 才可以被 Page Out(交换到磁盘中保存),当再次需要时这些数据又可以再从磁盘 Page In。多个进程之间可以共享某些 Clean 类型的页 由于 iOS 并没有内存交换机制,对于 iOS 来说,Clean memory 指的是能被重新创建的内存,它主要包含如下面几类:

  • App 的二进制可执行文件
  • Framework 中的未使用的 _DATA_CONST 段
  • 内存文件(MMap)映射的内存页
  • 未写入数据的内存页

Dirty Memory 是指那些被 App 写入过数据的内存。它包括如下几部分:

  • 所有堆区的对象
  • 图像解码缓冲区
  • Framework 中的 _DATA_DIRTY 段
  • Framework 中被使用过的 _DATA_CONST 段

总结:Dirty Memory 是指被进程修改过的内存页,Clean Memory 是指可以被还原(从磁盘读取)的内存页。

// 分配堆内存,所在 Page 会标记为 Dirty Memory
NSString *str1 = [NSString stringWithString:@"Hello world!!!!"]; 
// 常量字符串, 存放只读数据段里面,这段内存释放后,还可以在读取重建所示是 Clear Memory
NSString *str2 = @"Hello world again !!!!";
// 分配 100M 虚拟内存,当没有用时没有建立映射,故为 Clear Memory
char *buf = malloc(100 * 1024 *1024); 关系
for (int i = 0; i < 3 * 1024 * 1024; ++i) {
    // buf 内存块写入数据了变为 Dirty Memory
    buf[i] = rand();								
}

Resident Memory

这部分内存是指进程实际使用的物理内存(Physical Memory),也就是虚拟内存(Virtual Memory)中被映射到真实物理内存的部分。

Compressed memory

当内存吃紧时,会回收 Clean Page。而 Dirty page 是不能被回收的,那么如果 Dirty Memory 过多会如何呢?在iOS7之前,如果进程的 Dirty Memory 过高则系统会直接终止进程。iOS7 之后,引入了Compressed Memory的机制。并且 iOS7 之后,操作系统可以通过内存压缩器来对 Dirty 内存页进行压缩。首先,针对那些有一段时间没有被访问的 Dirty Pages(多个page),内存压缩器会对其进行压缩。但是,在这块内存再次被访问时,内存压缩器会对它解压以正确的访问。

举个例子,某个Dictionary使用了 3 个 page 的内存,如果一段时间没有被访问同时内存吃紧,则系统会尝试对它进行压缩从 3 个page压缩为 1 个page从而释放出 2 个page的内存。但是如果之后需要对它进行访问,则它占用的page又会变为3个。

对于 iOS ,当可使用的物理内存达到低位时(比如有很多应用在后台,或者前台应用使用了过多物理内存),操作系统就会试图去减小内存压力,它会做以下几件事:

  • 系统会先移除一些 Clean Memory 页(因为这些 Page 在需要时可以再次被恢复)
  • 如果应用使用了太多的 Dirty Memory,系统就会对应用发送警告以期望应用自己去释放一些内存
  • 如果在数次警告之后,应用程序还是继续使用大量的 Dirty Memory,系统就会杀掉这个应用

上面说的 Dirty Memory 实际上还包含 Compressed Memory,也就是说判断是否发出警告并杀掉应用的依据来源于 Dirty Memory 与 Compressed Memory 之和,这部分亦称为 Memory Footprint,是苹果内存占用优劣度量及优化的指标。

如何高效进行内存分配

懒加载内存

每次内存分配都存在性能成本,这个成本包含 虚拟内存分配物理内存分配 两部分。如果你暂时用不到内存,最好推迟分配,等到真正需要的时候再去申请。比如,为了让应用启动更快,最好在启动时尽量少分配内存。延后分配内存可以节省启动时间,并确保分配的内存真的被用到。典型的方式是使用全局变量或静态变量:

MyGlobalInfo* GetGlobalBuffer()
{
    static MyGlobalInfo* sGlobalBuffer = NULL;
    if ( sGlobalBuffer == NULL )
        {
            sGlobalBuffer = malloc( sizeof( MyGlobalInfo ) );
        }
        return sGlobalBuffer;
}

使用这种方式分配内存虽然实现了延迟分配内存的目的,但在多线程环境下需要加锁,否则将会出现内存泻漏。同时,加锁就意味着每次访问这个全局/静态变量都会出现性能损耗。一个折衷的办法是在主线程启动子线程前,在主线程中初始化这个全局/静态变量。当然具体哪种方式更合适取决于你的场景。

需要注意的是分配内存可以延迟,但释放内存不需要延期,尽早的内存释放有利减少内存压力,延长进程生命周期。

使用更高效的内存初始化方法

使用 malloc 函数分配的内存块时,不保证所分配的内存块被初始化为零(将内存块写入0值)。虽然你可以使用 memset 函数来初始化内存,但更好的选择是一开始就使用 calloc 函数来分配内存。calloc函数会预留所需的虚拟地址空间,等到实际使用内存时才进行初始化(分配物理内存并初始化为零)。这种方法比使用memset更高效,因为使用 memset 会强制虚拟内存系统将相应的页面映射到物理内存以进行零值初始化。使用 calloc 函数的另一个优点是,它允许系统在真正使用某个页面时初始化页面,而不是一次性全部初始化。

BAD

int main() {
    // 申请大小为10个整型的内存空间
    // 此时只分配了虚拟内存,虚拟内存还未映射到物理内存
    int *arr = (int *)malloc(10 * sizeof(int));
    if (arr == NULL) {
        printf("内存分配失败n");
        return 1;
    }
    // 使用 memset 初始化内存为 0 
    // 此时内存会被映射到物理内存,这导致了内存「提前」分配
    memset(arr, 0, 10 * sizeof(int));
    // 输出初始化后的数组内容
    for (int i = 0; i < 10; i++) {
        printf("%d ", arr[i]);
    }
    // 释放内存
    free(arr);
    return 0;
}

GOOD

int main() {
    // 使用calloc分配大小为10个整型的内存空间,并初始化为0
    // 此时只分配了虚拟内存,虚拟内存还未映射到物理内存
    int *arr = (int *)calloc(10, sizeof(int));
    if (arr == NULL) {
        printf("内存分配失败n");
        return 1;
    }
    // 输出初始化后的数组内容
    // 读取该内存时会按页完成物理内存映射与 0 值初始化
    // 物理内存只有在真正访问时才会被分配
    // 故打印输出的值均为 0
    for (int i = 0; i < 10; i++) {
        printf("%d ", arr[i]);
    }
    // 释放内存
    free(arr);
    return 0;
}

复用临时内存

如果一个频繁调用的函数,函数内部某些逻辑计算需要一个大的临时缓存,你应尽量复用该缓存,而不是每次重新申请。即使你的函数需要一个可变大小的内存块,你也可以使用 realloc 函数根据需求扩大或缩小这个内存块。

对于多线程应用,重用缓存的最佳方法是将它们添加到线程局部存储中(TLS 的使用参考下面的代码示例)。你也可以像懒加载内存一样使用静态变量来保存,但这样你同样需要考虑多线程的竞争问题。

注:复用缓存是否能带来性能上的提升需要根据真实环境下的性能测试

基础示例如下:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <malloc.h>
#define CACHE_SIZE 1
// 定义线程局部存储的键
pthread_key_t cache_key;
// 定义缓存结构体
typedef struct {
    int count;
    int cache[CACHE_SIZE];
} Cache;
// 创建并初始化线程局部存储的回调函数,线程结束缓存会被释放
void destructor(void *ptr) {
    printf("释放线程 %ld 的缓存n", pthread_self());
    free(ptr);
}
// 获取线程的缓存,如果不存在则创建并初始化,每个线程的缓存独立
Cache *get_cache() {
    Cache *cache = (Cache *)pthread_getspecific(cache_key);
    if (cache == NULL) {
        cache = (Cache *)malloc(sizeof(Cache));
        pthread_setspecific(cache_key, cache);
        cache->count = 0;
    } else {
        cache = (Cache *)realloc(cache, sizeof(Cache) + sizeof(int) * cache->count);
    }
    return cache;
}
// 将数据添加到缓存
void add_to_cache(int data) {
    Cache *cache = get_cache();
    cache->cache[cache->count++] = data;
}
// 使用缓存中的数据
void use_cache() {
    Cache *cache = get_cache();
    for (int i = 0; i < cache->count; i++) {
        printf("%d ", cache->cache[i]);
    }
    printf("n");
}
// 线程函数
void *thread_function(void *arg) {
    add_to_cache(1);
    add_to_cache(2);
    add_to_cache(3);
    use_cache();
    return NULL;
}
int main() {
    // 初始化线程局部存储的键
    pthread_key_create(&cache_key, destructor);
    // 创建并启动线程
    pthread_t thread;
    pthread_create(&thread, NULL, thread_function, NULL);
    // 等待线程结束
    pthread_join(thread, NULL);
    // 销毁线程局部存储的键
    pthread_key_delete(cache_key);
    return 0;
}

选用更合适的内存分配方式

大内存块的申请

一般来说申请较小的内存块(个位数的 page 以内)建议使用 malloc 函数,申请大块内存时建议使用 vm_allocate

对于小内存块的分配,malloc 会从一个内存池中分配所需大小的内存。使用 free 函数释放的任何小块都会被添加回池中,并根据“最佳匹配”原则重新使用。内存池本身由几个虚拟内存页面组成,这些页面是使用 vm_allocate 函数分配并由系统管理。

注意:在分配任何小内存块时,由 malloc 函数分配的块的最小为 16 字节,而大于这个值的任何块都是 16 的倍数。例如,如果调用 malloc 并申请 4 个字节,它将返回一个大小为 16 字节的内存块;如果请求 24 个字节,它将返回一个大小为 32 字节的内存块。由于这种粒度,你需要仔细设计数据结构,并尽可能使它们成为 16 的倍数。而申请大内存块时需要是 4096 字节的倍数,否则就造成内存浪费。

void* AllocateVirtualMemory(size_t size)
{
    char*          data;
    kern_return_t   err;
    // In debug builds, check that we have
    // correct VM page alignment
    check(size != 0);
    check((size % 4096) == 0);
    // Allocate directly from VM
    err = vm_allocate(  (vm_map_t) mach_task_self(),
                        (vm_address_t*) &data,
                        size,
                        VM_FLAGS_ANYWHERE);
    // Check errors
    check(err == KERN_SUCCESS);
    if(err != KERN_SUCCESS)
    {
        data = NULL;
    }
    return data;
}

批量小内存申请

当需要申请多个固定 size 的小内存块时使用 malloc_zone_batch_malloc 函数会是一个更好的选择。malloc_zone_batch_malloc 会一次性申请多个指定的内存块,这比多次使用 malloc 函数来得更加高效。

另外申请的多个内存块并且这些内存块有相同的权限与使用模式,建议将这些内存块申请在自定义的 zone 内,这样当你需要释放这些内存块时可以直接释放整个 zone 而不用单独释放他们。在自定义 zone 进行内存管理的相关函数有:

  • malloc_create_zone / malloc_destroy_zone
  • malloc_zone_malloc / malloc_zone_calloc
  • malloc_zone_valloc / malloc_zone_realloc

OSX / iOS 中的 zone 用来管理一个内存池,zone 管理的内存池具有相同的权限与使用模式,这样做可以减少内存碎片,提高内存利用率,并降低内存管理的开销。

内存懒复制

在进行内存复制时通常使用 memcpy 函数,此函数会立即开辟一个虚拟内存并将源数据映射到新的物理内存中。如果复制后不需要立即「写」源数据或者目标数据,使用 vm_copy 函数会有更高的效率。因为 vm_copy 在开辟虚拟内存后仅将目标地址范围标记为源地址范围的写时复制版本。只有当真正向源地址或者目标地址「写」数据时才真正的「复制」。由此可见 vm_copy 函数有懒复制的效果,只有当真正需要的时间才会进行内存的复制从而在一定程度上减少内存占用。

需要注意 vm_copy 只适合「」内存块的复制,小内存的复制仍然建议使用 memcpy。因 GCC 会对使用 memcpy 的小内存复制进行编译优化(将复制的值使用内联指令直接进行替代)。

再探 OSX / iOS 的内存管理

结语

关于 OS X/iOS 内存相关概念与优化方法参考了官方文档并加入了自己的理解,如果想更深入了解建议阅读原文。