从 OOM 到 iOS 内存管理 | 创作者训练营

5,320 阅读35分钟

点赞评论,感觉有用的朋友可以关注笔者公众号 iOS 成长指北,持续更新好文章。

万字长文,建议收藏阅读

从 OOM 崩溃出发,涉猎 iOS Jetsam 机制的相关内容,介绍如何获得设备内存阈值。介绍内存分配的基本概念,了解 iOS APP 的内存分布,以及如何分析 iOS 内存占用。引入一些实际的方法来在 iOS 开发过程中规避内存问题。

一切的一切,都从一个 OOM 崩溃出发。

前言

《iOS Crash Dump Analysis》 一书的翻译工作,对笔者来说意义重大。让笔者系统的学习了一下如何进行崩溃分析,以及崩溃分析的原因。

内存问题一直是导致系统崩溃的重要原因,绝大部分的原因可能是因为开发者在开发过程中往往会忽视内存问题,我们经常专注于使用而忘了深究。

由于内存问题导致的 iOS 应用程序发生的崩溃大致分为以下两种: 错误的内存访问和超出内存限制。在进行深入之前我们先了解一下。

错误的内存访问

我们的崩溃报告收集工具会收集崩溃报告,并将其符号化。

而无论自建还是使用自三方崩溃工具都会将崩溃报告中的 Exception Type ,也就是异常类型,放置在显眼位置。

如果是使用

Exception Type:  EXC_CRASH (SIGABRT)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note:  EXC_CORPSE_NOTIFY
Triggered by Thread:  0

在 iOS 崩溃报告中,关于 应用本身 的内存异常类型有两种

EXC_BAD_ACCESS (SIGSEGV)EXC_BAD_ACCESS (SIGBUS)

表明我们的程序很可能试图访问错误的或者是我们没有权限访问的内存地址。或由于内存压力,该内存已被释放,即访问错误已经不存在内存位置(常见的如野指针访问)。

SIGSEGV(段冲突):表示存储器地址甚至没有映射到进程地址区间。

  • Pointer Authentication(指针验证机制):使用 Apple A12 芯片或更高版本的设备将称为 Pointer Authentication 的安全功能作为 ARMv8.3-A 体系结构的一部分。如果由于错误或恶意操作而要更改指针地址,则该指针将被认为是无效的,并且如果将其用于更改程序的控制流,则最终将导致 SIGSEGV。

SIGBUS(总线错误):内存地址已正确映射到进程的地址区间,但不允许进程访问内存。

笔者在 [译]《iOS Crash Dump Analysis》- 崩溃报告 中介绍了详细介绍了如何分析已有的崩溃报告,如何根据崩溃报告快速定位崩溃问题。

超出内存限制(Memory Limit)

有些内存崩溃问题并不能直接提现在我们的崩溃报告中。意味着并没有特定的异常类型来告知我们这种错误属于超出内存限制的崩溃。

与 macOS 相比,激进(积极)的内存管理是 iOS 的一个特点,macOS 对内存使用有非常宽松的限制。通俗来说,移动设备是内存受限的设备。Jetsam 严格的内存管理系统为我们提供了良好的服务,在给定的 RAM 量下保证最佳的用户体验。

iOS 存在 Foreground Out-Of-Memory (FOOM)Background Out-Of-Memory (BOOM) 两种超出内存限制的 OOM 崩溃现象。从名称上可以看出来,一种是由于使用应用时本身超出内存限制导致的崩溃,另一种由于当前设备在后台中,而用户正在使用拍照功能进行大量的拍照和图像特效时,此时内存使用量大幅度增加,为了保证正在进行的进程有足够的内存可供使用。

如果在 iOS 崩溃报告中出现异常类型为 EXC_CRASH (SIGQUIT) 时,这意味着应用的某个拓展程序花费了太长的时间或者消耗了太多的内存。

OOM 崩溃

什么是 OOM 崩溃?

那么当内存不够用时,iOS 会发出内存警告,告知进程去清理自己的内存。iOS 上一个进程就对应一个 app。如果 app 在发生了内存警告,并进行了清理之后,物理内存还是不够用了,那么就会发生 OOM 崩溃,也就是 Out of Memory Crash。我们主要关注正在使用的应用程序发生的 OOM 崩溃,也就是前文提到的 Foreground Out-Of-Memory (FOOM)

iOS 通过 Jetsam 机制来实现上述功能。

Jetsam 机制

Jetsam 机制可以理解为操作系统为控制内存资源过度使用而采用的一种管理机制。Jetsam是一个独立运行的进程,每个进程都有一个内存阈值,一旦超过这个阈值,Jetsam将立即杀死该进程。

在前文我们提到,OOM 崩溃并没有体现在崩溃报告中,而是出现在 Apple 本身的 Jetsam 报告中。在 iOS 中,Jetsam 是将当前应用从内存中弹出以满足当前最重要应用需求的系统。

Jetsam 一词最初是一个航海术语,指船只将不想要的东西扔进海里,以减轻船的重量。

当我们的应用被 Jetsam 机制杀死时,手机会生成系统日志。在手机系统设置隐私分析中,找到以 JetSamEvent. 的开头的系统日志。在这些日志中,你可以获取一些关于应用程序的内存信息。可以在日志的开头,看到了pageSize,并找到了 perprocesslimit 项(不是所有日志都有,但是可以找到它)。通过使用项目的 rpages * pageSize 可以得到 OOM 的阈值。

一个 Jetsam 日志大概像下面一样:

{"bug_type":"298","timestamp":"2020-10-15 17:29:58.79
 +0100","os_version":"iPhone OS 14.2
 (18B5061e)","incident_id":"B04A36B1-19EC-4895-B203-6AE21BE52B02"
}
{
  "crashReporterKey" :
 "d3e622273dd1296e8599964c99f70e07d25c8ddc",
  "kernel" : "Darwin Kernel Version 20.1.0: Mon Sep 21 00:09:01
 PDT 2020; root:xnu-7195.40.113.0.2~22\/RELEASE_ARM64_T8030",
  "product" : "iPhone12,1",
  "incident" : "B04A36B1-19EC-4895-B203-6AE21BE52B02",
  "date" : "2020-10-15 17:29:58.79 +0100",
  "build" : "iPhone OS 14.2 (18B5061e)",
  "timeDelta" : 7,
  "memoryStatus" : {
  "compressorSize" : 96635,
  "compressions" : 3009015,
  "decompressions" : 2533158,
  "zoneMapCap" : 1472872448,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 41271296,
  "pageSize" : 16384,
  "uncompressed" : 257255,
  "zoneMapSize" : 193200128,
  "memoryPages" : {
    "active" : 45459,
    "throttled" : 0,
    "fileBacked" : 34023,
    "wired" : 49236,
    "anonymous" : 55900,
    "purgeable" : 12,
    "inactive" : 40671,
    "free" : 5142,
    "speculative" : 3793
  }
},
  "largestProcess" : "AppStore",
  "genCounter" : 1,
  "processes" : [
  {
    "uuid" : "7607487f-d2b1-3251-a2a6-562c8c4be18c",
    "states" : [
      "daemon",
      "idle"
    ],
    "age" : 3724485992920,
    "purgeable" : 0,
    "fds" : 25,
    "coalition" : 68,
    "rpages" : 229,
    "priority" : 0,
    "physicalPages" : {
      "internal" : [
        6,
        183
      ]
    },
    "pid" : 350,
    "cpuTime" : 0.066796999999999995,
    "name" : "SBRendererService",
    "lifetimeMax" : 976
  },
.
.
{
  "uuid" : "f71f1e2b-a7ca-332d-bf87-42193c153ef8",
  "states" : [
    "daemon",
    "idle"
  ],
  "lifetimeMax" : 385,
  "killDelta" : 13595,
  "age" : 94337735133,
  "purgeable" : 0,
  "fds" : 50,
  "genCount" : 0,
  "coalition" : 320,
  "rpages" : 382,
  "priority" : 1,
  "reason" : "highwater",
  "physicalPages" : {
    "internal" : [
      327,
      41
    ]
  },
  "pid" : 2527,
  "idleDelta" : 41601646,
  "name" : "wifianalyticsd",
  "cpuTime" : 0.634077
},
.
.

这里可以看一下笔者的 [译]《iOS Crash Dump Analysis 2》- 系统诊断 学习如何获取设备的系统诊断报告以及对于获取的 Jetsam 报告如何解读。

当然也可以阅读一下 Identifying High-Memory Use with Jetsam Event ReportsMonitoring Basic Memory Statistics 等官方文档。

确定内存阈值

Apple 并没有准确的文档说明每个设备的内存限制。对于设备的内存 OOM 阈值大概有以下几个方法获取。这里获取的限制最好是在重启 iPhone 以后,使得设备清空 RAM 缓存。

方法一: Jetsam 日志

在前文介绍了如何从 Jetsam 日志中通过使用项目的 rpages * pageSize 可以得到 OOM 的阈值。这里就不再赘述了。

方法二: 互联网数据

互联网上有很多关于OOM 阈值的文章并列举了不同设备的 OOM阈值,笔者感觉比较精确的是这两个

StackOverflow

我们可以在 StackOverflow post 大概了解不同设备的内存限制。有问题,StackOverflow 一下。

Split 工具

基于 Split 工具 获取的 Jaspers 列表

设备 RAM阈值范围(百分制)
256MB49% - 51%
512MB53% - 63%
1024MB57% - 68%
2048MB68% - 69%
3072MB63% - 66%
4096MB77%
6144MB81%

特别的案例:

设备 RAM阈值范围(百分制)
iPhone X (3072MB)50%
iPhone XS/XS Max (4096MB)55%
iPhone XR (3072MB)63%
iPhone 11/11 Pro Max (4096MB)54% - 55%

根据笔者的经验,1GB设备 安全阈值 45%,2-3GB 设备安全阈值 50% 4GB设备安全阈值 55%。 macOS 的百分比可能更大。

利用下面的方法获取当前设备的 RAM 值

[NSProcessInfo processInfo].physicalMemory

方法三: 主动触发 didReceiveMemoryWarning

当内存不够用时,iOS 会发出内存警告,告知进程去清理自己的内存, 在当前页面(Controller)中,这个方法是 - (void)didReceiveMemoryWarning。可以通过不停地增加内存,来获取当前设备的 OOM 阈值。

我们可以根据以下方法获取 设备的 OOM 阈值

#import "ViewController.h"
#import <mach/mach.h>

#define kOneMB  2014 * 1024

@interface ViewController ()
{
    NSTimer *timer;

    int allocatedMB;
    Byte *p[10000];
    
    int physicalMemorySizeMB;
    int memoryWarningSizeMB;
    int memoryLimitSizeMB;
    BOOL firstMemoryWarningReceived;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    physicalMemorySizeMB = (int)([[NSProcessInfo processInfo] physicalMemory] / kOneMB);
    firstMemoryWarningReceived = YES;
}

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
        
    if (firstMemoryWarningReceived == NO) {
        return ;
    }
    memoryWarningSizeMB = [self usedSizeOfMemory];
    firstMemoryWarningReceived = NO;
}

- (IBAction)startTest:(UIButton *)button {
    [timer invalidate];
    timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(allocateMemory) userInfo:nil repeats:YES];
}

- (void)allocateMemory {
    
    p[allocatedMB] = malloc(1048576);
    memset(p[allocatedMB], 0, 1048576);
    allocatedMB += 1;
    
    memoryLimitSizeMB = [self usedSizeOfMemory];
    if (memoryWarningSizeMB && memoryLimitSizeMB) {
        NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB);
    }
}

- (int)usedSizeOfMemory {
    task_vm_info_data_t taskInfo;
    mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
    kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);

    if (kernReturn != KERN_SUCCESS) {
        return 0;
    }
    return (int)(taskInfo.phys_footprint / kOneMB);
}

@end

在 iOS 13 以上的设备中,我们可以使用系统 os/proc.h 所提供的一个新的 API

__BEGIN_DECLS

/*!
 * @function os_proc_available_memory
 * ... 为了篇幅进行截断
 * @result
 * The remaining bytes. 0 is returned if the calling process is not an app, or
 * the calling process exceeds its memory limit.
 */

    API_UNAVAILABLE(macos) API_AVAILABLE(ios(13.0), tvos(13.0), watchos(6.0))
extern
size_t os_proc_available_memory(void);

__END_DECLS

来获取当前设备的内存阈值

#import <mach/mach.h>
#import <os/proc.h>
...

- (int)limitSizeOfMemory {
    if (@available(iOS 13.0, *)) {
        task_vm_info_data_t taskInfo;
        mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
        kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);

        if (kernReturn != KERN_SUCCESS) {
            return 0;
        }
        return (int)((taskInfo.phys_footprint + os_proc_available_memory()) / 1024.0 / 1024.0);
    }
    return 0;
}

你也可以定义一个方法,在使用大量内存之前,先获取一下当前的可用内存

#import <os/proc.h>

+ (CGFloat)availableSizeOfMemory {
    if (@available(iOS 13.0, *)) {
        return os_proc_available_memory() / 1024.0 / 1024.0;
    }
    // ...
}

请用真机测试,不要使用模拟器测试!

源码探究

我们知道 iOS/macOS 的内核是 XNU,XNU 是开源的。我们可以在开源的 XNU 内核源代码中探索 Apple Jetsam 的具体实现。

XNU 内核的内层是 Mach 层。作为一个微内核,mach 是一个只提供基本服务的薄层,比如处理器管理和调度以及IPC(进程间通信)。XNU 的第二个主要部分是 BSD 层。我们可以把它看成是 Mach 的外层。BSD 为最终用户的应用程序提供了一个接口。其职责包括进程管理、文件系统和网络。

内存管理中常见的抛弃时间也是由 BSD 生成的,因此让我们可以从 BSD init 作为切入点来探讨其原理。

BSD init 初始化各种子系统,比如虚拟内存管理等等。

...

是什么导致 FOOM 崩溃?

有多种原因可能会导致堆内存增长过度并导致 FOOM 崩溃:

循环引用

一般来说导致 OOM 的主要原因就是代码中会出现循环引用。也就是我们常说的内存泄露

内存泄漏是指在某一时刻分配的内存,但从未被释放,也不再被应用程序引用。由于没有对它的引用,现在就没有方法访问和释放它,内存不能再被使用。

内存泄漏无可避免地增加了应用程序的内存占用,这部分 RAM 将永远不会释放,直到应用程序停止运行。

缓存

对于正在处理需要大量内存或计算时间的频繁访问对象的开发人员而言,缓存可能是至关重要的。尽管在性能方面提供了巨大的好处,但是缓存可能会占用大量内存。缓存如此多的对象,可能会你或其他应用程序没有可用的RAM,从而有可能迫使系统终止它们。

常见的缓存示例是缓存图像。

图像渲染

就内存而言,图像渲染是很昂贵的。该过程分为两个阶段:解码和渲染。

解码阶段是将图像数据(数据缓冲区)转换为可由显示硬件(图像缓冲区)解释的信息。这包括每个像素的颜色和透明度。

渲染阶段是硬件消耗图像缓冲区并将其实际 绘制 在屏幕上。

在 iOS 中渲染的图片实际上所占的内存其大小是通过将每个像素的字节数乘以图像的宽度和高度来计算的。渲染一份像素为 3024 x 4032,颜色空间为 RGB,带 DisplayP3 色彩配置文件。该颜色配置文件每个像素占用16位大小。因此,常规方法渲染这样一个照片需要的内存大概需要 3024 x 4032 x 8 / 1024 / 1024 ≈ 93.02 MB的大小。

通过在设置图像属性之前简单地将图像调整为图像视图的大小,可以减少RAM数量级。可以查看 Mattt 大神的 Image Resizing Techniques 了解在 iOS 中我们如何调整获取的图片大小。

iOS 内存详解

在了解 iOS/macOS 内存之前,我们先了解一下基本概念。

所有进程(执行的程序)都必须占用一定数量的内存,它或是用来存放从磁盘载入的程序代码,或是存放取自用户输入的数据等等。不过进程对这些内存的管理方式因内存用途不一而不尽相同,有些内存是事先静态分配和统一回收的,而有些却是按需要动态分配和回收的。

基本概念

在操作系统中,管理内存的方法是首先将连续的内存排序为内存页,然后将页面排序为段。这允许将元数据属性分配给应用于该段内的所有页面的段。这允许我们的程序代码(程序 TEXT )被设置为只读但可执行。提高了性能和安全性。

RAMROM

RAM(random access memory)即随机存储内存,这种存储器在断电时将丢失其存储内容,故主要用于存储短时间使用的程序。

ROM(Read-Only Memory)即只读内存,是一种只能读出事先所存数据的固态半导体存储器。

App 程序启动时,系统会将 App 程序从 Flash 或 ROM 中拷贝到内存(RAM),然后从 RAM 里面执行代码。CPU 不能直接从 ROM 中读取并运行程序。

关于 iOS 的启动过程,可以参照笔者之前的文章 深入理解 iOS 启动流程和优化技巧 文章,获取 iOS 的启动流程。

虚拟内存

macOS 和 iOS都包含一个完全集成的 虚拟内存 系统。这两个系统为每个 32 位进程提供了多达 4 GB的可寻址空间。

虚拟内存允许操作系统摆脱物理 RAM 的限制。虚拟内存管理器为每个进程创建一个逻辑地址空间(或虚拟地址空间),并将其划分为称为页面的大小相同的内存块。处理器和它的内存管理单元(MMU)维护一个页表来将程序的逻辑地址空间中的页面映射到计算机 RAM 中的硬件地址。当程序代码访问内存中的地址时,MMU 使用页表将指定的逻辑地址转换为实际的硬件内存地址。这种转换是自动发生的,并且对正在运行的应用程序是透明的。

内存分页

就程序而言,其逻辑地址空间中的地址总是可用的。但是,如果应用程序访问当前不在物理 RAM 中的内存页上的地址,则会发生页面错误。当发生这种情况时,虚拟内存系统调用一个特殊的页面错误处理程序来立即响应错误。页面错误处理程序停止当前执行的代码,在物理内存中找到一个空闲的页面,从磁盘加载包含所需数据的页面,更新页表,然后将控制权返回给程序代码,然后程序代码可以正常访问内存地址。这个过程称为分页。

内存交换

如果物理内存中没有可用的可用页面,则处理程序必须首先释放现有页面以为新页面腾出空间。系统发布页面的方式取决于平台。在 OSX 中,虚拟内存系统通常将页写入备份存储。备份存储是一个基于磁盘的存储库,其中包含给定进程使用的内存页的副本。将数据从物理内存移动到备份存储称为 paging out(或 swapping out);将数据从备份存储移回物理内存称为paging in(或 swapping in)。

iOS 不支持内存交换

但是,iOS 不支持交换空间,并且大多数移动设备都不支持交换空间。移动设备的大容量内存通常是闪存,它的读写速度远远小于计算机使用的硬盘,这导致即使移动设备上使用了交换空间,也无法提高性能。其次,移动设备本身容量往往不足,内存的读写寿命也有限,在这种情况下,使用闪存进行内存交换有点奢侈。

页面永远不会被调出到磁盘,但是只读页面仍然可以根据需要从磁盘调出。

分页大小

在早期的 iOS 设备上, 分页的大小为 4 KB,而在 A7 和 A8 芯片上,对 64 位机器其分页大小为 16 KB 而 32 为机器分页大小依旧为 4 KB。对于 A9 及以上芯片,其分页大小都为 16 KB。

针对 iOS 设备来说,其32位机器内存分页大小为 4 KB 而 64 位机器内存分页大小为 16 KB

VM 对象

进程的逻辑地址空间由内存的映射区域组成。每个映射的内存区域包含已知数量的虚拟内存页。每个区域都有特定的属性来控制诸如继承(区域的一部分可以从区域映射)、写保护以及它是否被连接(即,它不能被调出)。因为区域包含已知数量的页面,所以它们是页面对齐的,这意味着区域的起始地址也是页面的起始地址,而结束地址也定义了页面的结尾。

内核将 VM 对象与逻辑地址空间的每个区域相关联。内核使用VM对象来跟踪和管理相关区域的驻留页和非驻留页。区域可以映射到备份存储的一部分或文件系统中的内存映射文件。每个 VM 对象都包含一个映射,该映射将区域与默认 pagervnode pager 相关联。默认的寻呼机是一个系统管理器,它管理后台存储中的非驻留虚拟内存页,并在请求时获取这些页。vnode pager 实现内存映射文件访问。vnode pager 使用分页机制直接向文件提供一个窗口。这种机制允许读写文件的某些部分,就像它们位于内存中一样

VM 对象对我们分析常驻内存具有很有效的。后面我们会用点时间来分析一下如何进行 iOS 内存分析。

iOS 内存占用

当我们在讨论 iOS 内存占用及内存管理时,我们提到的都是虚拟内存

应用程序的内存使用取决于页数及内存页的大小。

上文讲到内存分页,实际上内存页也有分类,一般来说分为 Clean MemoryDirty MemoryCompressed Memory 的概念。

Clean Memory

  • 内存映射文件是磁盘中存在的已加载到内存中的文件。
  • 如果内存映射文件是只读的,那么它们将始终作为 Clean 页面。(例如,image.jpgblob.data,,Training.modelFrameworks
  • 内核管理文件何时进入和离开 RAM。

Dirty Memory

  • Dirty Memory 指的是应用程序锁写入的任何内存。
  • 所有堆分配对象(例如 mallocArrayNSCacheUIViewsString图像解码缓冲区,例如CGRasterDataImageIOFrameworks)都会是 Dirty Memory
  • 在使用Frameworks的过程中会产生Dirty Memory。使用单个实例或全局初始化方法可以减少 Dirty Memory,因为一旦创建了单个实例,它就不会被销毁,并且全局初始化方法将在类加载时执行。

Compressed Memory

由于闪存容量和读写寿命的限制,iOS 上没有交换空间机制,因此改用压缩内存。压缩内存将压缩和存储未访问的页面。内存压缩器用于存储和检索压缩内存。 内存压缩主要执行两个操作

  • 压缩未访问的页面
  • 访问时解压缩页面

压缩内存能够在内存紧张时将最近使用的内存使用率压缩到原始大小的一半以下,并在需要时可以解压缩和重新使用。它不仅节省了内存,而且提高了系统的响应速度。

具有以下优势:

  • 减少非活动内存使用
  • 优化用电使用,通过压缩减少磁盘 IO 损耗
  • 快速压缩/解压缩,最大程度地减少 CPU 时间开销
  • 充分利用多核优势

例如,当我们使用 NSDictionary 来缓存数据时,假设现在我们已经使用了 3 页内存,当我们不访问它时,它可能被压缩为 1 页,而当我们再次使用它时,它将被解压缩为 3 页。

本质上来讲,Dirty Memory 也属于 Compressed Memory

macOS 也存在内存压缩,通过内存压缩可以提高内存交换的效率。

内存占用限制

Dirty MemoryCompressed Memory 会增加内存占用量。

内存压缩技术使得内存的释放变得复杂。内存压缩技术是在操作系统级实现的,它对进程不敏感。有趣的是,如果当前进程收到内存警告,则该进程此时准备释放大量误用的内存。如果访问了太多的压缩内存,当内存被解压缩时,内存压力会更大,然后出现 OOM,当前进程被系统杀死。

缓存数据的目的是减轻 CPU 的压力,但是过多的缓存将占用过多的内存。在某些需要缓存数据的情况下,可以使用 NSCache 代替 NSDictionary。 NSCache 分配的内存实际上是可清除内存,可以由系统自动释放。还建议将 NSCache 和 NSPurgeableData 结合使用不仅可以使系统根据情况回收内存,而且还可以在清理内存的同时删除相关对象。

iOS 内存占用分析(稍微提一下)

iOS 内存占用量可以通过 Xcode 内存量规进行测量,Instruments 提供了多种工具来分析应用程序的内存占用情况。

VM Tracker

VM Tracker 提供 Dirty Memory 大小 、交换(预压缩)大小和驻留大小的内存分配信息。对确定 Dirty Memory 大小有显著作用。

  • 首先打开 Xcode 选择 Product 中的 Profile

  • 然后打开Instruments 的 Allocations 工具

当你有了 Snapshots 之后,你可以看到 Dirty Memory 状态随着时间的推移。你还可以看到哪些对象占用了你大部分的 Dirty Memory 。如果您想更深入地研究,可以使用 VMMap,这对于高级内存调试非常有用。

命令行工具 - VMMap

与 Heap 和 Leaks 一样,VMMap是一个很好的命令行工具,用于在虚拟内存环境中调试内存对象。

在使用 VMMap 之前,首先应该准备当前应用程序的的 Memory Graph。

  • 在运行 APP 过程中,打开 Memory Graph

选择 View Memory Graph Hierarchy

  • 生成完 Memory Graph 以后,点击 File -> Export Memory graph 导出 一个 .memgraph 文件

  • 使用 VMMAP -sumary test.memgraph 命令进行分析

使用 -summary 提供了虚拟内存区域中的大小,例如虚拟内存大小,常驻内存大小,Dirty Memory 大小 ,交换大小等。Dirty Memory 和交换大小是增加内存大小的主要方面。

iOS APP 内存分配

前文我们说过,每个进程都有独立的虚拟内存地址空间,也就是所谓的进程地址空间。现在我们稍微简化一下,一个 iOS app 对应的进程地址空间大概如下图所示:

iOS 中的内存大致可以分为代码区,全局/静态区,常量区,堆区,栈区。

代码区

代码段是用来存放可执行文件的操作指令(存放函数的二进制代码),也就是说是它是可执行程序在内存种的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作,而不允许写入(修改)操作——它是不可写的。

全局/静态区

全局/静态区存放的是静态变量,静态全局变量,以及全局变量。初始化的全局变量,静态变量,静态全局变量存放在同一区域,未初始化的变量存放在相邻的区域。程序结束后由系统释放。

常量区

常量区存放的就是字符串常量,int常量等这些常量。

栈区

这块区域是由编译器自动分配并释放的,栈区存放的是函数的参数及自动变量。栈是向低地址扩展的一块连续的内存区域。分配在栈上的变量,当函数的作用域结束,系统就会自动销毁变量。

堆区

堆区内存一般是由程序员自己分配并释放的。当我们使用 alloc 来分配内存时分配的内存就是在堆上。由于我们现在大部分都是使用 ARC,所以现在堆区的分配和释放也基本不需要我们来管理。堆区是向高地址扩展的一块非连续区域。

栈区和堆区的比较

分配方式不同

栈区是由编译器自动分配和释放,但是堆区是由程序员来分配和释放。

申请后系统的响应

栈区:栈区内存由编译器分配和释放,在函数执行时分配,在函数结束时收回。只要栈区剩余内存大于所申请的内存,那么系统将为程序提供内存。 堆区:系统有一个存放空闲内存地址的链表,当程序员申请堆内存的时候,系统会遍历这个链表,找到第一个内存大于所申请内存的堆节点,并把这个堆节点从链表中移除。由于这块内存的大小很多时候不是刚刚好所申请的一样大,所以剩余的那一部分还会回到这个空闲链表中。

申请大小的限制

栈区:栈区是向低地址扩展的数据结构,也就是说栈顶的地址和栈的容量大小是由系统决定的。栈的容量大小一般是 2M,当申请的栈内存大于 2M 时就会出现栈溢出。因此栈可分配的空间比较小。 堆区:堆是向高地址扩展的数据结构,是不连续的。堆的大小受限于计算机系统中有效的虚拟空间,因此堆可分配的空间比较大。

申请效率的比较

栈:栈由系统自动分配,速度较快,但是不受程序员控制。 堆:堆是由 alloc 分配的内存,速度较慢,并且容易产生内存碎片。

栈的限制

应用中新创建的每个线程都有专用的栈空间,该空间由保留的内存和初始提交的内存组成。栈可以在线程存在期间自由使用。线程的最大栈空间很小,这就决定了以下的限制。

  • 可被递归调用的最大方法数。

    每个方法都有其自己的栈帧,并会消耗整体的栈空间。

  • 一个方法中最多可以使用的变量个数。

    所有的变量都会载入方法的栈帧中,并消耗一定的栈空间。

  • 视图层级中可以嵌入的最大视图深度。

    渲染复合视图将在整个视图层级树中递归地调用 layoutSubViews 和 drawRect 方法。如 果层级过深,可能会导致栈溢出。

总结

知道 iOS APP 内存分配对我们是有好处的。众所周知的 sunnyxx 大神的 神经病院objc runtime入院考试 的最后一题:

@interface Sark : NSObject
@property (nonatomic, copy) NSString *name;
@end
@implementation Sark
- (void)speak {
    NSLog(@"my name's %@", self.name);
}
@end
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    id cls = [Sark class];
    void *obj = &cls;
    [(__bridge id)obj speak];
}
@end

在理解 iOS 中内存分配的入栈顺序,以及栈地址如何进行偏移,才能更好地理解为什么最终答案打印的是当前的 self

内存管理

内存管理模型基于 持有关系 的概念。如果一个对象正处于被持有状态,那它占用的内存就不能被回收。当一个对象创建于某个方法的内部时,那该方法就持有这个对象了。如果这个对象从方法 返回,则调用者声称建立了持有关系。这个值可以赋值给其他变量,对应的变量同样会声称建立了持有关系。

一旦与某个对象相关的任务全部完成,那么就是放弃了 持有关系 。这一过程没有转移 持有关系 ,而是分别增加或减少了持有者的数量。当持有者的数量降为零时,对象会被释放相关的内存也会被回收。

这种持有关系 持有关系 被称作 引用计数(Retain Count)。

Apple 提供了两种内存管理的方法

MRC or MRR

我们一般将手动管理引用计数的方法称为(manual reference counting,MRC)手动引用计数管理。官方文档上则将这种方式称为(manual retain-release, MRR)手动持有释放,可以通过跟踪自己拥有的对象显式地管理内存。

ARC

ARC 背后的原理是依赖编译器的静态分析能力,通过在编译时找出合理的插入引用计数管理代码,从而彻底解放程序员。ARC 的工作原理是在编译时添加代码,以确保对象的生存期尽可能长,但不会更长。从概念上讲,它遵循与手动引用计数相同的内存管理约定,为开发者添加了适当的内存管理调用

手动的内存管理方法,已经淹没在 iOS 开发的历史长河中。对从 ARC 时代成长起来的 iOS 开发者来说,虽然 ARC 帮我们解决了引用计数的大部分问题,但是一旦开发者需要与底层 Core Foundation 对象交互的话,就需要自己来考虑管理这些对象的引用计数。

内存管理策略

NSObject protocol 中定义的方法和命名惯例一起提供了一个引用计数环境,内存管理的基本模式处于这种环境中。NSObject 类还定义了一个方法 dealloc,该方法在释放对象时自动调用。NSObject 类作为 Foundation 框架的根类,几乎主要的类都继承于 NSObject 类。

内存管理模型基于对象所有权。任何对象都可以有一个或多个所有者。只要一个对象至少有一个所有者,它就继续存在。如果一个对象没有所有者,运行时系统会自动销毁它。为了确保清楚地知道您何时拥有一个对象,什么时候没有,Cocoa 设置了以下策略:

  • 自己生成的对象,自己持有。
  • 非自己生成的对象,自己也能持有。
  • 不在需要自己持有对象的时候,释放。
  • 非自己持有的对象无需释放。

要想避免内存泄漏和应用崩溃,你应当在编写 Objective-C 代码时牢记这些规则。

错误的内存管理执行会导致两种问题:

  • 释放或覆盖仍在使用的数据 这会导致内存损坏,通常会导致应用程序崩溃,或者更糟的是,损坏的用户数据。
  • 不释放不再使用的数据会导致内存泄漏 内存泄漏是指分配的内存没有被释放,即使它不再被使用。泄漏会导致应用程序使用越来越多的内存,而这又可能导致系统性能下降或应用程序被终止。

内存泄露

内存泄漏不等于发生了循环引用,内存泄漏是指内存在不需要访问的时候没有释放,或者说任何导致对象在内存中长时间活动的原因都可能会导致内存泄漏。

例如,不合理的使用单例,不合理的使用全局变量(主要指非全局常量), 以及不合理的存储一些内容。

如果一个对象存活的时间足够长,并且这个对象附带了大量的对象属性的话,那么这也会导致内存泄露。

17年,网易在其公众号上发布了其称为 大白 的 iOS APP运行时Crash自动修复系统,几乎涵盖了当前 iOS 崩溃的主要原因。在其 野指针防护 一节中,当对已经释放的内存进行访问时,提供一个临时对象用来响应消息传递。并且说明了需要控制其内存大小。

当我们需要实现一个全局变量或单例时,或者是当我们存储一些信息用作信息收集时,我们需要考虑,在什么时候我们应该释放掉内存。

当我们使用 runtime(获取方法,属性、关联对象等) 或者是进行绘制时,不要忘记释放内存。

objc_property_t *props = class_copyPropertyList(class, &propsCount);
...
free(props);

例如 FXBlurView 里,仅仅为了展示一些 Core Foundation 绘制时需要

- (UIImage *)blurredImageWithRadius:(CGFloat)radius iterations:(NSUInteger)iterations tintColor:(UIColor *)tintColor
{
   ...
    vImage_Buffer buffer1, buffer2;
    buffer1.width = buffer2.width = CGImageGetWidth(imageRef);
    buffer1.height = buffer2.height = CGImageGetHeight(imageRef);
    buffer1.rowBytes = buffer2.rowBytes = CGImageGetBytesPerRow(imageRef);
    size_t bytes = buffer1.rowBytes * buffer1.height;
    buffer1.data = malloc(bytes);
    buffer2.data = malloc(bytes);
    ...
    //copy image data
    CFDataRef dataSource = CGDataProviderCopyData(CGImageGetDataProvider(imageRef));
    memcpy(buffer1.data, CFDataGetBytePtr(dataSource), bytes);
    CFRelease(dataSource);
    ...
    //free buffers
    free(buffer2.data);
    free(tempBuffer);
    ...
    CGImageRelease(imageRef);
    CGContextRelease(ctx);
    free(buffer1.data);
    return image;
}
 

如何发现内存泄露

在使用模拟器或真机运行时,我们可以查看应用程序的内存占用

关注 Usage over Time 部分, 如果在运行过程中发现内存峰值存在阶梯性增长,那么很有可能发生内存泄露。但是此时我们只知道出现了内存泄露。我们需要去尝试一些方法来找到泄露的地方。

循环引用

不可免俗的,对 iOS 来说,真正容易导致内存泄露的还是循环引用。尤其是在大量使用 block, delegate,NSTimer、KVO的时候。

以持有关系声明对内存的引用就会引发一个问题,对象间互相持有,导致持有关系形成一个闭环,最终任何内存都无法释放。

如何发现循环引用?

在这里我们介绍几个发现循环引用的方法

memory graph

通过生成 memory graph 来查看内存使用,熟练解读 memory graph 文件的相关信息,足够开发者发现开发过程中的各种问题。让我们来调试 iOS 内存 - Memory Graph

Instruments: Allocations 和 Memory Leaks

善用 Instruments 会极大提高我们的开发效率,并且会应用程序保驾护航。这个 WWDC Getting Started with Instruments,能让你成为一个 Instruments 调试高手。

善于使用 dellocdeinit

Apple 组件几乎不会引起你的循环引用的,问题绝大可能出现在我们这里。我们可以创建一个 Memory Checker 类用来判断 Controller 对象是否被释放,并且 Hook Controller 的 pop 或者 dispresent 方法。

可以改写下面的Swift 代码值 Objective-C 代码

import Foundation

public class MemoryChecker {
    public static func verifyDealloc(object: AnyObject?) {
        #if DEBUG
            DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak object] in
                if let object = object {
                    fatalError("Class Not Deallocated: \(String(describing: object.classForCoder ?? object)))")
                }
            }
        #endif
    }
}
import UIKit

class NavigationController: UINavigationController {
    override func popViewController(animated: Bool) -> UIViewController? {
        let viewController = super.popViewController(animated: animated)
        MemoryChecker.verifyDealloc(object: viewController)
        return viewController
    }
}

我们的内存并不是立即释放掉的,所以需要定义一个延迟时间。

如何避免循环引用

通过以下几个方法来规避开发过程中的循环引用

对象不应该持有它的父对象,应该用 weak 引用指向它的父对象

当我们在子类中使用其父对象时, 例如在 subview 或 cell 中需要执行跳转时,请使用 weak 修饰父对象 Controller,或者是使用代理时,请用 weak 修饰你的 delegate。

连接对象不应持有它们的目标对象

目标对象的角色是持有者。连接对象包括以下几种。

  • 使用 delegate 的对象。委托应该被当作目标对象,即持有者。

  • 包含目标和 action 的对象。例如,UIButton

  • 观察者模式中被观察的对象。观察者就是持有者,并会观察发生在被观察对象上的变化。

不要让自己成为自己的 Target 目标,请不要自己持有自己

避免在 Block 内直接引用外部的变量

避免直接引用外部变量并不意味之自己需要在每个block前都是用 __weak 获取一个 weakSelf,无脑添加 weakSelfstrongSelf 。如何分析当前 Block 内到底有没有形成闭环,需要开发者好好思考。

这里推荐阅读霜神的 深入研究 Block 用 weakSelf、strongSelf、@weakify、@strongify 解决循环引用

在组件移除时,请主动释放自己

当我们使用子组件来封装一些页面时,例如轮播图、计时器等操作时,可以监听页面的移除方法,然后在移除时做一些内存释放。或者执行一些方法时,在方法执行完成,将自身置为 nil。

- (void)willMoveToSuperview:(UIView *)newSuperview {
    if (!newSuperview) {
        [self invalidateTimer];
    }
}
善于利用 NSProxy 来打破循环

NSProxy 实现根类所需的基本方法,包括那些在 NSObject protocol 协议中定义的方法。但是,作为一个抽象类,它不提供初始化方法,并且在接收到任何它不响应的消息时引发异常。

NSProxy 通常用来实现消息转发机制和惰性初始化资源。

使用 NSProxy,你需要写一个子类继承它,然后需要实现 init 以及消息转发的相关方法。

我们可以使用 NSProxy 来处理一些强引用的 target, 打破循环引用。这里主要处理 NSTimer。

@interface WeakProxy : NSProxy

@property (weak, nonatomic, readonly) id target;

+ (instancetype)proxyWithTarget:(id)target;

- (instancetype)initWithTarget:(id)target;

@end
  
@implementation WeakProxy

+ (instancetype)proxyWithTarget:(id)target{
    return [[self alloc] initWithTarget:target];
}

- (instancetype)initWithTarget:(id)target{
    _target = target;
    return self;
}

- (void)forwardInvocation:(NSInvocation *)invocation{
    SEL sel = [invocation selector];
    if (self.target && [self.target respondsToSelector:sel]) {
        [invocation invokeWithTarget:self.target];
    }
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector{
    return [self.target methodSignatureForSelector:aSelector];
}

- (BOOL)respondsToSelector:(SEL)aSelector{
    return [self.target respondsToSelector:aSelector];
}

@end

学会使用自动释放池

众所周知,整个 iOS 的应用都是包含在一个自动释放池 block 中的

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

虽然,Swift 没有 main 函数,但是有一个 @UIApplicationMain 来替代 main 函数。

系统在每个runloop迭代中都加入了自动释放池Push和Pop

AppKit 和 UIKit 框架将事件 - 循环的迭代放入了 autoreleasepool 块中。因此,通常 不需要你自己再创建 autoreleasepool 块了。

什么时候我们应该创建自己的 autoreleasepool 呢?

  • 当我们创建一个很多临时对象的循环时

    我们说过,AppKit 和 UIKit 框架将事件 - 循环的迭代放入了 autoreleasepool 块中,所以当循环执行完成以后,内存会降到一定的值,但是我们可以通过创建自己的 autoreleasepool 来避免内存峰值。

  • 在异步线程中实现 autoreleasepool

    iOS 应用程序包含在一个主 autoreleasepool 中,所以当主线程的 runloop 完成一次迭代时,autoreleasepool会自动进行释放操作,而对于异步线程,可以自己主动实现一个autoreleasepool

参考文档

《Memory and Virtual Memory》

《Abolish Retain Cycles》

《The case of iOS OOM Crashes at Compass》

《iOS — Advanced Memory Debugging to the Masses》

《iOS Memory Allocation》

《What is the difference between ROM and RAM?》

《Learn more about oom (low memory crash) in iOS》

《iOS Memory Deep Dive - WWDC 2018 - Videos - Apple Developer》

《iOS 的内存分配

《Advanced Memory Management Programming Guide》

《Transitioning to ARC Release Notes》

《Memory Management Programming Guide for Core Foundation》