茶茶的小屋

不管生活有多不容易,都要守住自己的那一份优雅。

iOS Memory Deep Dive

前言

仅以此文解答自己大学以来多年对内存管理的疑惑。


经典操作系统的虚拟内存

为什么要有虚拟内存?

随着计算机的发展,我们的计算机处理的任务也变得越来越繁多,但是对于某台固定的计算机,CPU 和 Memory 都是固定的,如果有些直接使用物理内存地址的话会带来很多问题, 首先编译器不能以一种抽象的角度来描绘内存,在执行的过程中如果某个进程占据的内存过大,这个进程可能就无法运行,即便运行了内存相对来说是非常不安全的,一个不小心操作到了别的进程的内存,可能导致进程的崩溃,如果写入了内核使用的内存可能导致操作系统的崩溃。

现代操作系统的内存管理是非常多计算机科学家智慧的结晶,这种管理方式就是 虚拟内存 (Virtual Memory/VM). VM是一些列技术的总称,包括硬件异常,物理之地翻译,主存,磁盘文件,操作系统内核软件的内存管理。

虚拟内存提供了三大重要的特性

  1. 它将主存看做在存储在磁盘上的地址空间的高速缓存,利用程序的局部性原理,只将活跃的内存加载到主存中,提高了主存的利用率。
  2. 为每个进程提高了一个抽象的统一的连续的私有的地址空间。简化了内存管理方式。
  3. 对内测进行分段(segment)提供权限能力,保护每个进程的地址空间不会被其他进程影响。

寻址方式

在一些早期的操作系统和一些嵌入式操作系统中,内存管理使用的地址是物理地址,
现代操作系统基本使用的是 虚拟地址(Virtual Addressing)的寻址方式,使用 虚拟地址时 CPU将 VA 送到MMU中去翻译为物理地址。

注: MMU (Memory Management Unit) 内存管理单元一般是一个CPU上的专用芯片,是一个硬件。
结合操作系统共同完成地址翻译工作。

地址空间

通常来说地址空间是线性的 假设我们有 {0, 1, 2, ..N-1 } 个内存地址,我们可以用n位二进制来表示内存地址,那么我们就叫这个地址空间为n位地址空间, 现代操作系统通常是 32 或者 64(但是很多操作系统只用了48位寻址)的 。

2^10 = 1k
2^20 = 1M
2^30 = 1G
2^40 = 1T
2^50 = 1P
2^60 = 1E

这么看来大家能理解为什么32位的操作系统最大只支持4G内存空间了。

分页

现代操作系统将内存划分为页,来简化内存管理,一个页其实就是一段连续的内存地址的集合,通常有4k和16k(iOS 64位是16K)的,成为 Virtual Page 虚拟页。与之对应的物理内存被称为Physical Page 物理页。

注意 虚拟页的个数可能和物理页个数不一样 比如说一个 64位操作系统中使用48位地址空间的 虚拟页大小为16K,那么其虚拟页可数可达到(2^48/2^14 = 16M个)假设物理内存只有 4G 那么物理页可能只有 (2^32/2^14 = 256k个)

操作系统将虚拟页和物理页的映射关系称为页表(Page Table),每个映射叫 页表条目(Page Table Entry/Item),操作系统为每个进程提供一个页表放在主存中,CPU在使用虚拟地址时交给MMU去翻译地址,MMU去查询在主存中的页表来翻译。

缺页处理

每个 Page Table Entry 都包含了一些描述信息,比如前页的状态{未分配, 缓存的,未缓存的}。

  1. 未分配的不用多说代表未使用的内存
  2. 缓存的代表已经加载进物理内存了
  3. 未缓存的代表还没放在物理内存。

当CPU要读取一个页时,检查标记发现当前的页是未缓存的,会触发一个(Page Falut)缺页中断,这时内核、、操作系统的缺页异常处理程序,去选择一个牺牲页(有时候内存够用不用置换别的界面),然后检查这个页面是否有修改,有修改先写会磁盘,然后将需要使用到的内存加载到物理内存中,然后更新PTE 随后操作系统重新把虚拟地址发送到地址翻译硬件去重新处理。

注: 有些操作系统无 虚拟虚拟内存置换逻辑,如iOS,取而代之的是内存压缩,和收到内存警告时杀死进程的行为。

虚拟内存带来的好处

  1. 简化链接过程,允许每个进程都提供统一的内存地址的抽象,独立与物理内存。
  2. 简化加载,操作系统加载可执行文件和共享文件时,只是创建了 页表,待访问到缺页时,操作系统再去加载。
  3. 简化共享,不同进程的PT中的PTE 可以执行相同的物理地址,如动态库的代码。
  4. 内存保护,PT中的PTE中描述了一个虚拟页的权限信息,(R, W, X)、指令如果违反了这些权限信息,就会造成(Segment Fault)

地址翻译

虚拟地址翻译到物理地址是软硬件结合实现的。我们通常几个方面来描述。

如何索引

现代操作系统将地址分为两部分,页号和片了(是不是很类型网络号和主机号),由于虚拟页和物理页的大小是相同的,页偏移可以看做虚拟页和物理页的页内地址,且相同,页号则做为PT的索引查找到对应的PTE,然后查找对应的物理页地址。

提高效率

是不是像前面所说的简单的划分位两部分就足够了呢?

举个例子:

  1. 我们假设一台电脑是 32 位的,分页大小位 4k 也就说页内地址占据了 12 位,页号地址位 20 位
  2. 我们假设一台电脑是 64 位的,地址空间 48 位,分页大小位 16k 也就说页内地址占据了 14 位,页号地址位 34 位

我们粗略估算一个PTE为4KB 对于 32位的操作系统每个进程的页表需要 2^20 = 4M 个页表项常驻内存尚可接受
但是对于寻址为48位的操作系统来说,每个进程的页表为需要 2^34 = 8G个页表项,这是无法接受的。

计算机的世界所有的难题都可以同加一次的办法来解决,所以现代操作系统通常都使用多级页表,减少页表项的个数。将虚拟地址分为多端,代表了一级 二级 多级页表。通过多级页表可以大大较少内存占用。

减少内存

众所周知CPU要比Memory快10^3个数量级,即便CPU中的L3Cache 也比Memory快很多,如果MMU美的地址翻译都要去查找多级PT,这个开销就会非常巨大,但是所幸 程序的局部性原理能够解救我们,MMU芯片内置一个 翻译后备缓冲器(Transalation Lookaside Buffer TLB )的硬件来充当缓存,加快地址翻译的效率.

现代 OS 虚拟内存系统

操作系统为每个进程维护一个单独的虚拟地址空间,分为两部分。

  1. 内核虚拟内存,包含内核中的代码和数据结构,还有一些被映射到所有进程共享的内存页面。还有一些页表,内核在进程上下文中执行代码使用的栈。
  2. 进程虚拟内存。OS将内存组织为一些区域(Sement)的集合,代码端,数据端,共享库端,线程栈都是不同的区域,分段的原因是便于管理内存的权限,如果了解过Mach-O文件或者ELF文件的读者可以看到相同的Segment里面的内存权限是相同的,每个Segment再划分不同的内容为section。

在内核中描述一个进程的数据结构 概略为如下

pgb指向第一级页表的基址

                                            进程虚拟地址
                        vm_area_struct      |----------------|
----    ----     |---->  vm_end------|      |                |
 mm ---> pgb     |       vm_start ---|---|  |                |
----    mmap  ---        vm_prot     |   |->|----------------|
        ----             vm_flags    |      |      共享库     |
                      -- vm_next     |----->|----------------|
                    |                       |
                    |                       |
                    |->  vm_end-----|  |--> |----------------|
                         vm_start---|--|    |     数据段      |
                         vm_prot    |------>|----------------|
                         vm_flags           |

每个区域的描述主要有一下几个
vm_start 指向这个区域的起始处
vm_end 指向这个区域的结束出
vm_prot 内存区域的读写权限
vm_flasg 一些标志位 私有的还是共享的
vm_next 指向下一个vm_area_struct的描述

内存映射 MMAP

类Unix操作系统可以荀彧映射一个普通磁盘上的文件的连续部分到一个固定的内存取区域。操作系统会会自动管理映射的内容。

内存映射允许不同的进程映射不同的虚拟内存到同一块物理内容上,他们可以是共享的也可以是私有的。

对于共享的,通常多个进程映射到相同的共享对象上,

对与私有的,不同进程初始映射的时候操作系统为了节省资源,并没有产生真的副本,知道某个进程修改了这个私有对象,操作系统运用copy on write技术在此时才发生真正的文件拷贝。

mmap在类unix操作系统上作为一个系统调用存在,函数签名如下

void *
     mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
     addr 代表要从那块虚拟地址开始映射,通常可以不用指定传递NULL让操作系统自己给我们选择
     len 映射多少长度的内容
        prot 映射文件的访问权限 读写可执行权限等
        PROT_EXEFC 可执行权限
        PROT_READ 可读权限
        PROT_WRITE 可写权限
        PROT_NONE 无法访问权限
    flags 访问文件的标记
        MAP_SHARED 共享的
        MAP_PRIVATE私有的
        MAP_ANON 私有的

举个例子将任意文件映射到stdout


#include <sys/mman.h>

int main(int argc, const char * argv[]) {
    struct stat stat;
    int fd;
    if (argc != 2) {
        printf("must pass file path");
        return 1;
    }
    fd = open(argv[1], O_RDONLY, 0);
    fstat(fd, &stat);
    char *buffer = mmap(NULL, stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    printf("%s", buffer);
    return 0;
}

MMAP在iOS中的用处

  1. mmap让读写一个文件像操作一个内存地址一样简单方便,
  2. mmap效率极高,不用将一个内容从磁盘读入内核态再拷贝至用户态
  3. mmap映射的文件由操作系统接管,如果进程Crash 操作系统会保证文件刷新回磁盘

动态内存分配

虽然可以使用上面的低级API去映射内存,但是需要动态申请内存用来做变量处理的时候就需要动态内存分配器(Dunamic memory allocator)简单理解为 malloc calloc realloc free等函数来自的库就称为DMA.
动态内存分配器将一个内存的区域(Heao)分为不同的大小的块(block),这些块要不然就是分配的,要不然就是空闲的。
如何设计分配器又是一个大难题。 几乎所有的计算机语言都采用以下两种。

  1. 显式分配器(手动管理内容)
  2. 隐式分配器(GC)

隐式内存分配器

通常比较知名的语言 Java javaScript Ruby 等都使用GC,最早的GC只是使用标记清除算法来管理内容,通过几十年的迭代,早已更新出了数种算法共同参与的GC。这里就不再赘述了

显式内存分配器

C语言提供了一些列的方法来管理动态内存。如

  1. malloc 申请内容并返回初始化的内存首地址
  2. calloc 同malloc一致,并且会将申请到的内存全置为0、
  3. realloc,重新分配原本已经申请的内存空间。
  4. free 释放内容空间
  5. sbrk 扩展收缩堆

如何实现一个自己的显式内存分配器

首先我们要明确内存分配器的需求

  1. 处理任意顺序的申请内存和释放内存
  2. 立即响应,不应为了性能二重新排列或者缓存请求
  3. 所有内容都在heap里存放
  4. 对齐块,使之可以存放任意类型的数据
  5. 不修改已分配的内存块

鉴于对齐和处理任意顺序内存管理的需求,堆利用效率可能会降低,主要会产生内存碎片(Fragmentation) 内存碎片分为两种。

  1. 内部碎片,通常是指一个分配过的块数据并不是全部块的内容,通常有元信息,对齐的字节等。
  2. 外部碎片是指不连续的可用的块,通常外部碎片过多会产生,所有空白块相加可以满足申请的资源,但是他们不连续。需要整理碎片。

实现显式内存分配器的重点

  1. 空闲块组织
  2. 如何分配新申请的块
  3. 如果组织空闲快的剩余部分
  4. 如何合并刚释放的块

显式内存分配器的实现方案

隐式空闲链表

这种方式在malloc申请内存的时候,实际上申请的是实际所需内存加上部门元信息大小的块,然后返回指针是有效数据的首地址,元信息直接存在数据块中,所以称为隐式空闲链表。

隐式链表需要处理如何分割空闲库和合并空闲快

显式空闲链表

由于隐式空闲链表的搜索效率角度,其实是不适用通用的内存分配的。可以使用某种形式的数据结构去管理这些内存块。
基本分为 几种,

  1. 简单分离器存储
  2. 分离适配法
  3. 伙伴系统法

关于详细的设计需要读者查看更多算法知识的文档。

显式内存分配器的实现

显式内存分配器的需求已经很清晰,下面有个简单的例子可以参考,这时候对于 C 类语言的内存管理应该不会太过恐惧了,
C++实现一个简易的内存池分配器,
毕竟源码面前了无秘密。

iOS的虚拟内存

iOS 内存的分页大小

在arm64之后的芯片,操作系统通常使用16KB作为页大小,我们写的程序中的虚拟内存地址右移动14位则可得到页编号。MMU通过TLB和固定在内存进程虚拟区域的页表来翻译来物理地址。
下面一份代码可以获取页大小。


int main(int argc, char * argv[]) {
    //    获取虚拟内存分页数据 14为页内地址
    printf("page-size%ld mask:%ld, shift%d \n", vm_kernel_page_size, vm_kernel_page_mask, vm_kernel_page_shift);
    printf("%ld\n", sysconf(_SC_PAGE_SIZE));
    printf("%d\n", getpagesize());
    printf("%d\n", PAGE_SIZE); // 编译时确定不建议使用
    return 0;
}

在观察Crash日志的时候 有时候注意崩溃的页号可以帮助我们寻找崩溃的原因。

页面的类型

当操作系统分配一个页面时,内存被称为Clean的,以为这这个内存页面没有使用,是可以被释放或者重建的,但是一旦写入,操作系统会将其标记为Dirty,这意味着磁盘或者其他地方没有此内存页面的备份,无法恢复它。

由于iPhone设备为了减少闪存的寿命,并没有在闪存上使用交换分区,因此无论使用多少,在内存压力高紧时,操作系统不会将Dirty写好磁盘,而是释放Clean的页面如,可执行代码(Mach-O)的映射和内存映射文件,或者是kill掉进程。
因此使用dirty的内存越多,对我们的进程的稳定性越差。

iOS内存的优化

在其他常见的操作系统上,由于局部性原理,OS会将不常用的内存页面写会磁盘,但是iOS没有交换空间,取而代之的是内存压缩技术,iOS 将不常用到的dirty页面压缩以减少页面占用量,在再次访问到的时候重新解压缩。这些都在操作系统层面实现,对进程无感知,有趣的是如果当前进程收到了 memoryWarning, 进程这时候准备释放大量的误用内存,如果访问到过多的压缩内存,再解压缩内存的时候反而会导致内存压力更大,然后被OS kill掉。

iOS 进程中的堆和栈

需要注意的是通常操作系统书籍中描述的进程虚拟内存模型都是这样的

Process Virtual Memory

这实际是个用于解析给读者的简化模型,对于多线程程序来说,每个线程都有自己的线程栈,在iOS上通常主线程线程栈大小为1MB,子线程栈大小为 512KB,如果你有一台越狱机 可以试验 ulimt -a 命令观察栈大小的默认参数。

iOS平台上的常见编程语言的内存管理方式

iOS 上常用的Swift 和 Objective-C ,C , C++ 都使用显式的内存管理策略,比如 malloc 和 free, new 和delete alloc 和 dealloc,在Objective-C和Swift通常使用一种叫做引用计数的简化模型来管理堆内存。现代Clang已经支持ARC的技术帮助程序员解脱内存管理的困扰,但是本质上还是显式内存管理。

建议读者可以读一下ARC的参考文档,
顺便提一下Xcode10 版本中的Clang已经支持在C结构体中对于Objective-C对象的ARC管理,请参看 whats_new_in_llvm

内存分类

要想合理的使用内存必须要掌握不同类型内存的区别,才能更合理的使用内存并且在内存资源匮乏的低端机器上写出“高内存性能”的应用。

首先在 Apple 的官方文档中内存主要分为以下几类。

  1. Free Memory 当前空闲的memory
  2. Used Mamory 当前正在使用的内存

我们最关心的当然是 Used Memory,它又分为以下几类。

  1. Wired Memory。 一般是内核占用的常驻内存,比如可执行文件的镜像 Image,内核所有的数据等,无法释放,在OS运行期间必须常驻内存。
  2. Active Memory 活跃的内存,当前正在使用的内存.
  3. Inactive Memory。不活跃的内存,最近用过,但是现在不怎么用了,按照局部性原则可以被置换出物理内存的内存。
  4. Purgeable Memory。可释放的内存,通常在Foundation中是 NSDiscardableContent 的子类,或者是 NSCache等。

等等~。上面说的好像跟没说一样/(ㄒoㄒ)/~~。我们换种方式从物理内存和虚拟内存的层面来解释。


首先我们的虚拟内存使用的是Page来描述的。 一个Page 有两种状态 Dirty 和 Clean。在iOS中Clean是可以被回收的。

Virtual Memory分类

  1. Clean Memory 主要包括 system framework、binary executable 、memory mapped files
  2. Dirty Memory 括Heap allocation、caches、decompressed images等。

(每个进程拥有一份独立的 Virtual memory pace)Virtual Memory = clean Memory

PhySical Memory

物理内存是指真正加载在主存中的内存,所以实际了解上真正的物理内存占用才对我们内存管理帮助更大。

  1. DirtyMemory
  2. Clean Memory but loaded。
  3. Page Table
  4. ComPressed memory
  5. IOKit Used
  6. Purgeable

内存测量工具

了解到前面说的内存分类之后我们应该怎么测量我们的内存分布呢。主要有几种工具,命令行工具,Xcode工具,代码工具等。

命令行工具

如果你开发的是 Mac 程序,Mac OS 自带的有一下几种。

  1. top 程序
  2. heap 程序
  3. leaks 程序
  4. vmmap 程序

这些工具读者查看 Man Page 即可。

需要注意的是。以上工具分析的大多是虚拟内存,也就是说对于 桌面级程序更适合,但是对于iOS中没有交换空间,且拥有Jetsam监控程序的设备,可能还需要更精准的测量工具。

顺便提一句。一个堆区上malloc的程序如果并没有使用,虽然它是Clean的,但是也会被程序统计到,理论上 malloc 可以申请到的虚拟内存大小非常接近 virtual Memory Space 的大小(这么说的原因是 前文也提到了 malloc 实际上是动态分配器程序提供的一些列函数,为了性能,大多数动态分配器都讲堆分为好几块用来做不同大小虚拟内存的管理,因此malloc可以申请到的虚拟内存大小实际决定于动分配器代码的实现。有兴趣的读者可以读一下。)

Xcode 提供的工具

  1. Xcode Debug Area
  2. Instruments
  3. DebugMemoryGraph

Memory Report
instruments
DebugMemoryGraph
Scheme

Tips 配置了 MallocStackLogging 的话甚至可以追踪每个 虚拟内存中的对象申请堆栈,便于我们更好的发现问题。

注意点:所有Xcode提供的工具必须使用真机测试才能最难接近用户的使用环境

代码工具

我们通过开发工具可以用来测量我们的内存,但是到了线上这些都用不了,能精准的测量APP用到的物理内存才比较重要。

大部分的代码测量内存是通过拿到Mach内核提供的 task_info 来测量的,但是这个信息更多的是虚拟内存层面的信息不能正确的衡量物理内存。


#include <malloc/malloc.h>
#include <mach/mach_host.h>
#include <mach/task.h>

int main(int argc, char * argv[]) {
    @autoreleasepool {
        // method 1
        struct mstats currentStat = mstats();
        printf("Freed Bytes:%ld, Used Bytes:%ld Total Bytes:%ld", currentStat.bytes_free, currentStat.bytes_used, currentStat.bytes_total);
        // method 2
        vm_statistics_data_t vmStats;
        mach_msg_type_number_t infoCount = HOST_VM_INFO_COUNT;
        kern_return_t kernReturn = host_statistics(mach_host_self(), HOST_VM_INFO, (host_info_t)&vmStats, &infoCount);
        printf("free: %lu\nactive: %lu\ninactive: %lu\nwire: %lu\nzero fill: %lu\nreactivations: %lu\npageins: %lu\npageouts: %lu\nfaults: %u\ncow_faults: %u\nlookups: %u\nhits: %u",
              vmStats.free_count * vm_page_size,
              vmStats.active_count * vm_page_size,
              vmStats.inactive_count * vm_page_size,
              vmStats.wire_count * vm_page_size,
              vmStats.zero_fill_count * vm_page_size,
              vmStats.reactivations * vm_page_size,
              vmStats.pageins * vm_page_size,
              vmStats.pageouts * vm_page_size,
              vmStats.faults,
              vmStats.cow_faults,
              vmStats.lookups,
              vmStats.hits
              );
        // method3
        task_basic_info_data_t taskInfo;
        infoCount = TASK_BASIC_INFO_COUNT;
        kernReturn = task_info(mach_task_self(),
                                             TASK_BASIC_INFO,
                                             (task_info_t)&taskInfo,
                                             &infoCount);
        
        if (kernReturn == KERN_SUCCESS) {
            printf("resdientSize is :%ld", taskInfo.resident_size);
        }
        return 0;
    }
}

其中尤其是和Xcode Debug Area的差距较大有时候可能会偏差 50M-100M ,于是有大佬拔出了Xcode 的 DebugServer 和 WebKit 中的的物理内存计算方式(2018WWDC 苹果也说了 footPrint才是真正的物理内存使用ios_memory_deep_dive
代码如下


std::optional<size_t> memoryFootprint()
{
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
    if (result != KERN_SUCCESS)
        return std::nullopt;
    return static_cast<size_t>(vmInfo.phys_footprint);
}

线上检查工具

线上检查内存通常会检查内存泄漏,一般有开源的工具

  1. MLeaksFinder
  2. FBRetainCycleDetector

高性能使用内存

了解完那么多原理和分析的工具,那么在日常使用中有没有什么指导原则可以帮助我们来写出更快,内存占用更低的代码呢?

  1. 首先熟读 ARCMenual ,大部分iOS开发者其实是完全不清楚ARC是怎么实现的,还有相对于的原则,尤其是Autorelease 修饰的指针,还有在多线程情况下的原则。
  2. 用weak修饰替换unsafe_unretain
  3. 使用weak strong dance 来解决block中的循环引用问题。需要注意的是大部分人都以为使用了weak指针就可以了。其实不然,在block内必须使用 strong 重新绑定变量,避免在多线程情况下weak变量为空导致Crash,使用strong指针前判断是否为空
    例:

- (void)test {
    weak __typeof(self) weakSelf = self;
    [xxobjc onCompleate:^(){
        strong __typeof(self) self = weakSelf;
        if (!self) { return; }
        [xx moreCompleate:&(){
            strong __typeof(self) self = weakSelf;
            if (!self) { return; }
            // do something
        }];
    }];
}
  1. 小心方法中的self,在Objective-C的方法中 隐含的 self 是 __unsafed_unretain的。
  2. 使用Autoreleasepool来降低循环中的内存峰值,避免OOM。
  3. 要处理 Memory Warning
  4. C/C++ new 出来的要delete malloc 的要 free。
  5. UITableView/UICollectionView 的重用(不单单是cell重用 cell 使用的子view也要重用。
  6. [UIImage imageNamed:] 适合于UI界面中的贴图的读取,较大的资源文件应该尽量避免使用。
  7. WKWebView是跨进程通信的,不会占用我们的APP使用的物理内存量。
  8. try_catch_finally 一定要清理资源
  9. 尽量少引用 performaSelector: 会对ARC的内存管理产生错误,导致内存泄漏。
  10. lazy load 那些大的内存对象, 尤其是需要保证线程安全,可以参考 java中的懒汉式Double Check 写法。
  11. 需要在收到内存警告的时候释放的Cache 用 NSCache 代替 NSDictionary, 使用 NSPurgableData代替NSData.

前文中我们说到iOS的没有交换分区的概念,取而代之的是压缩内存的办法,倘若在使用NSDictionary的时候收到内存警告,然后去释放这个NSDictionary,如果占据的内存过大,很可能在解压的过程中就被JetSem Kill 掉,如果你的内存只是误用的缓存或者是可重建的数据,就把NSCache当初NSDictionary用吧。同理 NSPurableData也是。

  1. 不要使用像素过大的图片文件,即便一个图片在磁盘中很小,但是因为图片像素宽高很大也会占据更多的内存,这里有个公式可以计算widthPx * HeightPx * 4Bytes per pixel(alpha red green blue).即便在iOS12中已经可以优化单色图的内存占用,可毕竟是iOS12,现在好多公司还在支持iOS8 ~~
  2. 使用 NSData和UIImage 的 mmap加载选型来加载那些可以被重建的数据。
  3. 在子线程手动申请(maloc)大内存的的时候ping一下主线程,因为子线程无法收到内存警告的传递

- (void)test {
    // current on sub Thread
    // if main thread is memory warning it will blocked
    dispatch_sync(dispatch_get_main_queue(), ^{
        [some description]
    });
    malloc(huge memory);
}

参考

  1. 深入理解计算机系统
  2. 高性能iOS应用开发
  3. iOS和macOS性能优化:Cocoa、Cocoa Touch、Objective-C和Swift
  4. WWDC iOS Memory Deep Dive
  5. C++实现一个简易的内存池分配器
  6. ARC的参考文档
  7. whats_new_in_llvm
  8. 先弄清楚这里的学问,再来谈 iOS 内存管理与优化(一)
  9. 先弄清楚这里的学问,再来谈 iOS 内存管理与优化(二)
  10. 让人懵逼的 iOS 系统内存分配问题
  11. 探索iOS内存分配
  12. iOS内存深入探索之VM Tracker
  13. iOS内存深入探索之Leaks
  14. iOS内存深入探索之内存用量
  15. iOS笔记-记录一次内存泄漏发现过程
  16. iOS 内存管理及优化
  17. Memory Usage Performance Guidelines
  18. Performance Overview
  19. Debugging with Xcode
  20. Threading Programming Guide
  21. LLDB Quick Start Guide
  22. LLDB Debugging Guide
  23. Instruments Help Topics
  24. Advanced Memory Management Programming Guide
  25. Exception Programming Topics
  26. 小试Xcode逆向:app内存监控原理初探
  27. osfmk/kern/task.c
  28. MacOSX/MachTask.mm
  29. No pressure, Mon!

写在最后

现在做 iOS 开发太难了


扫描二维码,在手机上阅读!
阅读更多

iOS-APP-运行时防Crash工具XXShield练就

前言

正在运行的 APP 突然 Crash,是一件令人不爽的事,会流失用户,影响公司发展,所以 APP 运行时拥有防 Crash 功能能有效降低 Crash 率,提升 APP 稳定性。但是有时候 APP Crash 是应有的表现,我们不让 APPCrash 可能会导致别的逻辑错误,不过我们可以抓取到应用当前的堆栈信息并上传至相关的服务器,分析并修复这些 BUG。

所以本文介绍的 XXShield 库有两个重要的功能:

  1. 防止Crash
  2. 捕获异常状态下的崩溃信息

类似的相关技术分析也有 网易iOS App运行时Crash自动防护实践

目前已经实现的功能

  1. Unrecoginzed Selector Crash
  2. KVO Crash
  3. Container Crash
  4. NSNotification Crash
  5. NSNull Crash
  6. NSTimer Crash
  7. 野指针 Crash

1 Unrecoginzed Selector Crash

出现原因

由于 Objective-C 是动态语言,所有的消息发送都会放在运行时去解析,有时候我们把一个信息传递给了错误的类型,就会导致这个错误。

解决办法

Objective-C 在出现无法解析的方法时有三部曲来进行消息转发。
详见Objective-C Runtime 运行时之三:方法与消息

  1. 动态方法解析
  2. 备用接收者
  3. 完整转发

1 一般适用与 Dynamic 修饰的 Property
2 一般适用与将方法转发至其他对象
3 一般适用与消息可以转发多个对象,可以实现类似多继承或者转发中心的概念。

这里选择的是方案二,因为三里面用到了 NSInvocation 对象,此对象性能开销较大,而且这种异常如果出现必然频次较高。最适合将消息转发到一个备用者对象上。

这里新建一个智能转发类。此对象将在其他对象无法解析数据时,返回一个 0 来防止 Crash。返回 0 是因为这个通用的智能转发类做的操作接近向 nil 发送一个消息。

代码如下


#import <objc/runtime.h>

/**
 default Implement
 @param target trarget
 @param cmd cmd
 @param ... other param
 @return default Implement is zero
 */
int smartFunction(id target, SEL cmd, ...) {
    return 0;
}

static BOOL __addMethod(Class clazz, SEL sel) {
    NSString *selName = NSStringFromSelector(sel);
    
    NSMutableString *tmpString = [[NSMutableString alloc] initWithFormat:@"%@", selName];
    
    int count = (int)[tmpString replaceOccurrencesOfString:@":"
                                                withString:@"_"
                                                   options:NSCaseInsensitiveSearch
                                                     range:NSMakeRange(0, selName.length)];
    
    NSMutableString *val = [[NSMutableString alloc] initWithString:@"i@:"];
    
    for (int i = 0; i < count; i++) {
        [val appendString:@"@"];
    }
    const char *funcTypeEncoding = [val UTF8String];
    return class_addMethod(clazz, sel, (IMP)smartFunction, funcTypeEncoding);
}

@implementation XXShieldStubObject

+ (XXShieldStubObject *)shareInstance {
    static XXShieldStubObject *singleton;
    if (!singleton) {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            singleton = [XXShieldStubObject new];
        });
    }
    return singleton;
}

- (BOOL)addFunc:(SEL)sel {
    return __addMethod([XXShieldStubObject class], sel);
}

+ (BOOL)addClassFunc:(SEL)sel {
    Class metaClass = objc_getMetaClass(class_getName([XXShieldStubObject class]));
    return __addMethod(metaClass, sel);
}

@end

我们这里需要 Hook NSObject的 - (id)forwardingTargetForSelector:(SEL)aSelector 方法启动消息转发。
很多人不知道的是如果想要转发类方法,只需要实现一个同名的类方法即可,虽然在头文件中此方法并未声明。


XXStaticHookClass(NSObject, ProtectFW, id, @selector(forwardingTargetForSelector:), (SEL)aSelector) {
    // 1 如果是NSSNumber 和NSString没找到就是类型不对  切换下类型就好了
    if ([self isKindOfClass:[NSNumber class]] && [NSString instancesRespondToSelector:aSelector]) {
        NSNumber *number = (NSNumber *)self;
        NSString *str = [number stringValue];
        return str;
    } else if ([self isKindOfClass:[NSString class]] && [NSNumber instancesRespondToSelector:aSelector]) {
        NSString *str = (NSString *)self;
        NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
        NSNumber *number = [formatter numberFromString:str];
        return number;
    }
    
    BOOL aBool = [self respondsToSelector:aSelector];
    NSMethodSignature *signatrue = [self methodSignatureForSelector:aSelector];
    
    if (aBool || signatrue) {
        return XXHookOrgin(aSelector);
    } else {
        XXShieldStubObject *stub = [XXShieldStubObject shareInstance];
        [stub addFunc:aSelector];
        
        NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error.target is %@ method is %@, reason : method forword to SmartFunction Object default implement like send message to nil.",
                            [self class], NSStringFromSelector(aSelector)];
        [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeUnrecognizedSelector];
        
        return stub;
    }
}
XXStaticHookEnd

这里汇报了 Crash 信息,出现消息转发一般是一个 logic 错误,为必须修复的Bug,上报尤为重要。


2 KVO Crash

出现原因

KVOCrash总结下来有以下2大类。

  1. 不匹配的移除和添加关系。
  2. 观察者和被观察者释放的时候没有及时断开观察者关系。

解决办法

尼古拉斯赵四说过 :赵四
对比到程序世界就是,程序世界没有什么难以解决的问题都是不可以通过抽象层次来解决的,如果有,那就两层。
纵观程序的架构设计,计算机网络协议分层设计,操作系统内核设计等等都是如此。

问题1 : 不成对的添加观察者和移除观察者会导致 Crash,以往我们使用 KVO,观察者和被观察者都是直接交互的。这里的设计方案是我们找一个 Proxy 用来做转发, 真正的观察者是 Proxy,被观察者出现了通知信息,由 Proxy 做分发。所以 Proxy 里面要保存一个数据结构 {keypath : [observer1, observer2,...]} 。


@interface XXKVOProxy : NSObject {
    __unsafe_unretained NSObject *_observed;
}

/**
 {keypath : [ob1,ob2](NSHashTable)}
 */
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSHashTable<NSObject *> *> *kvoInfoMap;

@end

我们需要 Hook NSObject的 KVO 相关方法。


- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

  1. 在添加观察者时
    addObserver
  1. 在移除观察者时

removeObserver

问题2: 观察者和被观察者释放的时候没有断开观察者关系。
对于观察者, 既然我们是自己用 Proxy 做的分发,我们自己就需要保存观察者,这里我们简单的使用 NSHashTable 指定指针持有策略为 weak 即可。

对于被观察者,我们使用 iOS 界的毒瘤-MethodSwizzling
一文中到的方法。我们在被观察者上绑定一个关联对象,在关联对象的 dealloc 方法中做相关操作即可。


- (void)dealloc {
    @autoreleasepool {
        NSDictionary<NSString *, NSHashTable<NSObject *> *> *kvoinfos =  self.kvoInfoMap.copy;
        for (NSString *keyPath in kvoinfos) {
            // call original  IMP
            __xx_hook_orgin_function_removeObserver(_observed,@selector(removeObserver:forKeyPath:),self, keyPath);
        }
    }
}


3 Container Crash

出现原因

容器在任何编程语言中都尤为重要,容器是数据的载体,很多容器对容器放空值都做了容错处理。不幸的是 Objective-C 并没有,容器插入了 nil 就会导致 Crash,容器还有另外一个最容易 Crash 的原因就是下标越界。

解决办法

常见的容器有 NS(Mutable)Array , NS(Mutable)Dictionary, NSCache 等。我们需要 hook 常见的方法加入检测功能并且捕获堆栈信息上报。

例如


XXStaticHookClass(NSArray, ProtectCont, id, @selector(objectAtIndex:),(NSUInteger)index) {
if (self.count == 0) {
    
    NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
                        [self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
    [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeContainer];
    return nil;
}

if (index >= self.count) {
    NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
                        [self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
    [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeContainer];
    return nil;
}

return XXHookOrgin(index);
}
XXStaticHookEnd

但是需要注意的是 NSArray 是一个 Class Cluster 的抽象父类,所以我们需要 Hook 到我们真正的子类。

这里给出一个辅助方法,获取一个类的所有直接子类:

+ (NSArray *)findAllOf:(Class)defaultClass {
    
    int count = objc_getClassList(NULL, 0);
    
    if (count <= 0) {
        
        @throw@"Couldn't retrieve Obj-C class-list";
        
        return @[defaultClass];
    }
    
    NSMutableArray *output = @[].mutableCopy;
    
    Class *classes = (Class *) malloc(sizeof(Class) * count);
    
    objc_getClassList(classes, count);
    
    for (int i = 0; i < count; ++i) {
        
        if (defaultClass == class_getSuperclass(classes[i]))//子类
        {
            [output addObject:classes[i]];
        }
        
    }
    
    free(classes);
    
    return output.copy;
    
}

// 对于NSarray :

//[NSarray array] 和 @[] 的类型是__NSArray0
//只有一个元素的数组类型 __NSSingleObjectArrayI,
// 其他的大部分是//__NSArrayI,



// 对于NSMutableArray :
//[NSMutableDictionary dictionary] 和 @[].mutableCopy__NSArrayM



// 对于NSDictionary: :

//[NSDictionary dictionary];。 @{}; __NSDictionary0
// 其他一般是  __NSDictionaryI

// 对于NSMutableDictionary: :
// 一般用到的是 __NSDictionaryM

4 NSNotification Crash

出现原因

在 iOS8 及以下的操作系统中添加的观察者一般需要在 dealloc 的时候做移除,如果开发者忘记移除,则在发送通知的时候会导致 Crash,而在 iOS9 上即使移忘记除也无所谓,猜想可能是 iOS9 之后系统将通知中心持有对象由 assign 变为了weak

解决办法

所以这里两种解决办法

  1. 类似 KVO 中间加上 Proxy 层,使用 weak 指针来持有对象
  2. 在 dealloc 的时候将未被移除的观察者移除

这里我们使用 iOS 界的毒瘤-MethodSwizzling
一文中到的方法。


5 NSNull Crash

出现原因

虽然 Objecttive-C 不允许开发者将 nil 放进容器内,但是另外一个代表用户态 的类 NSNull 却可以放进容器,但令人不爽的是这个类的实例,并不能响应任何方法。

容器中出现 NSNull 一般是 API 接口返回了含有 null 的 JSON 数据,
调用方通常将其理解为 NSNumber,NSString,NSDictionary 和 NSArray。 这时开发者如果没有做好防御 一旦对 NSNull 这个类型调用任何方法都会出现 unrecongized selector 错误。

解决办法

我们在 NSNull 的转发方法中可以判断上面的四种类型是否可以解析。如果可以解析直接将其转发给这几种对象,如果不能则调用父类的默认实现。


XXStaticHookClass(NSNull, ProtectNull, id, @selector(forwardingTargetForSelector:), (SEL) aSelector) {
    static NSArray *sTmpOutput = nil;
    if (sTmpOutput == nil) {
        sTmpOutput = @[@"", @0, @[], @{}];
    }
    
    for (id tmpObj in sTmpOutput) {
        if ([tmpObj respondsToSelector:aSelector]) {
            return tmpObj;
        }
    }
    return XXHookOrgin(aSelector);
}
XXStaticHookEnd

6. NSTimer Crash

出现原因

在使用 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo 创建定时任务的时候,target 一般都会持有 timer,timer又会持有 target 对象,在我们没有正确关闭定时器的时候,timer 会一直持有target 导致内存泄漏。

解决办法

同 KVO 一样,既然 timer 和 target 直接交互容易出现问题,我们就再找个代理将 target 和 selctor 等信息保存到 Proxy 里,并且是弱引用 target。
这样避免因为循环引用造成的内存泄漏。然后在触发真正 target 事件的时候如果 target 置为 nil 了这时候手动去关闭定时器。


XXStaticHookMetaClass(NSTimer, ProtectTimer,  NSTimer * ,@selector(scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:),
                      (NSTimeInterval)ti , (id)aTarget, (SEL)aSelector, (id)userInfo, (BOOL)yesOrNo ) {
    if (yesOrNo) {
        NSTimer *timer =  nil ;
        @autoreleasepool {
            XXTimerProxy *proxy = [XXTimerProxy new];
            proxy.target = aTarget;
            proxy.aSelector = aSelector;
            timer.timerProxy = proxy;
            timer = XXHookOrgin(ti, proxy, @selector(trigger:), userInfo, yesOrNo);
            proxy.sourceTimer = timer;
        }
        return  timer;
    }
    return XXHookOrgin(ti, aTarget, aSelector, userInfo, yesOrNo);
}
XXStaticHookEnd
@implementation XXTimerProxy

- (void)trigger:(id)userinfo  {
    id strongTarget = self.target;
    if (strongTarget && ([strongTarget respondsToSelector:self.aSelector])) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [strongTarget performSelector:self.aSelector withObject:userinfo];
#pragma clang diagnostic pop
    } else {
        NSTimer *sourceTimer = self.sourceTimer;
        if (sourceTimer) {
            [sourceTimer invalidate];
        }
        NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error target is %@ method is %@, reason : an object dealloc not invalidate Timer.",
                            [self class], NSStringFromSelector(self.aSelector)];
        
        [XXRecord recordFatalWithReason:reason userinfo:nil errorType:(EXXShieldTypeTimer)];
    }
}

@end

7. 野指针 Crash

出现原因

一般在单线程条件下使用 ARC 正确的处理引用关系野指针出现的并不频繁, 但是多线程下则不尽然,通常在一个线程中释放了对象,另外一个线程还没有更新指针状态 后续访问就可能会造成随机性 bug。

之所以是随机 bug 是因为被回收的内存不一定立马被使用。而且崩溃的位置可能也与原来的逻辑相聚很远,因此收集的堆栈信息也可能是杂乱无章没有什么价值。
具体的分类请看Bugly整理的脑图。
x

更多关于野指针的文章请参考:

  1. 如何定位Obj-C野指针随机Crash(一)
  2. 如何定位Obj-C野指针随机Crash(二)
  3. 如何定位Obj-C野指针随机Crash(三)

解决办法

这里我们可以借用系统的NSZombies对象的设计。
参考buildNSZombie

解决过程

  1. 建立白名单机制,由于系统的类基本不会出现野指针,而且 hook 所有的类开销较大。所以我们只过滤开发者自定义的类。

  2. hook dealloc 方法 这些需要保护的类我们并不让其释放,而是调用objc_desctructInstance 方法释放实例内部所持有属性的引用和关联对象。

  3. 利用 object_setClass(id,Class) 修改 isa 指针将其指向一个Proxy 对象(类比系统的 KVO 实现),此 Proxy 实现了一个和前面所说的智能转发类一样的 return 0的函数。

  4. 在 Proxy 对象内的 - (void)forwardInvocation:(NSInvocation *)anInvocation 中收集 Crash 信息。

  5. 缓存的对象是有成本的,我们在缓存对象到达一定数量时候将其释放(object_dispose)。

存在问题

  1. 延迟释放内存会造成性能浪费,所以默认缓存会造成野指针的Class实例的对象限制是50,超出之后会释放,如果这时候再此触发了刚好释放掉的野指针,还是会造成Crash的,

  2. 建议使用的时候如果近期没有野指针的Crash可以不必开启,如果野指针类型的Crash突然增多,可以考虑在 hot Patch 中开启野指针防护,待收取异常信息之后,再关闭此开关。


收集信息

由于希望此库没有任何外部依赖,所以并未实现响应的上报逻辑。使用者如果需要上报信息 只需要自行实现 XXRecordProtocol 即可,然后在开启 SDK 之前将其注册进入 SDK。
在实现方法里面会接收到 XXShield 内部定义的错误信息。
开发者无论可以使用诸如 CrashLytics,友盟, bugly等第三库,或者自行 dump堆栈信息都可。

@protocol XXRecordProtocol <NSObject>

- (void)recordWithReason:(NSError * )reason userInfo:(NSDictionary *)userInfo;

@end

使用方法

示例工程


git clone git@github.com:ValiantCat/XXShield.git
cd Example
pod install 
open XXShield.xcworkspace

Install

    
  pod "XXShield"
    

Usage


/**
 注册汇报中心
 
 @param record 汇报中心
 */
+ (void)registerRecordHandler:(id<XXRecordProtocol>)record;

/**
 注册SDK,默认只要开启就打开防Crash,如果需要DEBUG关闭,请在调用处使用条件编译
 本注册方式不包含EXXShieldTypeDangLingPointer类型
 */
+ (void)registerStabilitySDK;

/**
 本注册方式不包含EXXShieldTypeDangLingPointer类型
 
 @param ability ability
 */
+ (void)registerStabilityWithAbility:(EXXShieldType)ability;

/**
 ///注册EXXShieldTypeDangLingPointer需要传入存储类名的array,暂时请不要传入系统框架类
 
 @param ability ability description
 @param classNames 野指针类列表
 */
+ (void)registerStabilityWithAbility:(EXXShieldType)ability withClassNames:(nonnull NSArray<NSString *> *)classNames;


ChangeLog

ChangeLog

单元测试

相关的单元测试在示例工程的Test Target下,有兴趣的开发者可以自行查看。并且已经接入 TrivisCI保证了代码质量。

Bug&Feature

如果有相关的 Bug 请提 Issue

如果觉得可以扩充新的防护类型,请提 PR 给我。

作者

ValiantCat, 519224747@qq.com
个人博客
南栀倾寒的简书

License

XXShield 使用 Apache-2.0 开源协议.


扫描二维码,在手机上阅读!
阅读更多

shijiebei 365bet manbetx 188bet xinshui caipiao 95zz tongbaoyule beplay 88bifa 18luck betway bwin hg0088 aomenjinshayulecheng ca88 shenbotaiyangcheng vwin w88 weide