【转】iOS Memory Deep Dive
前言
仅以此文解答自己大学以来多年对内存管理的疑惑。
经典操作系统的虚拟内存
为什么要有虚拟内存?
随着计算机的发展,我们的计算机处理的任务也变得越来越繁多,但是对于某台固定的计算机,CPU 和 Memory
都是固定的,如果有些直接使用物理内存地址的话会带来很多问题。首先编译器不能以一种抽象的角度来描绘内存,在执行的过程中如果某个进程占据的内存过大,这个进程可能就无法运行,即便运行了,内存相对来说是非常不安全的,一个不小心操作到了别的进程的内存,可能导致进程的崩溃,如果写入了内核使用的内存可能导致操作系统的崩溃。
现代操作系统的内存管理是非常多计算机科学家智慧的结晶,这种管理方式就是 虚拟内存 (Virtual Memory/VM) 。VM
是一系列技术的总称,包括硬件异常,物理地址,主存,磁盘文件,操作系统内核软件的内存管理。
虚拟内存提供了三大重要的特性:
它将主存看做在存储在磁盘上的地址空间的高速缓存,利用程序的局部性原理,只将活跃的内存加载到主存中,提高了主存的利用率;
为每个进程提供了一个抽象的统一的连续的私有的地址空间。简化了内存管理方式;
对内存进行分段(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
都包含了一些描述信息,比如当前页的状态{未分配,缓存的,未缓存的}。
未分配的不用多说代表未使用的内存;
缓存的代表已经加载进物理内存了;
未缓存的代表还没放在物理内存。
当 CPU 要读取一个页时,检查标记发现当前的页是未缓存的,会触发一个(Page Falut) 缺页中断
,这时内核、操作系统的缺页异常处理程序,去选择一个牺牲页(有时候内存够用不用置换别的界面),然后检查这个页面是否有修改,有修改会先写磁盘,然后将需要使用到的内存加载到物理内存中,然后更新PTE
,随后操作系统重新把虚拟地址发送到地址翻译硬件去重新处理。
注:有些操作系统无虚拟虚拟内存置换逻辑,如 iOS,取而代之的是内存压缩和收到内存警告时杀死进程的行为。
虚拟内存带来的好处
简化链接过程,允许每个进程都提供统一的内存地址的抽象,独立于物理内存;
简化加载,操作系统加载可执行文件和共享文件时,只是创建了 页表 ,待访问到缺页时,操作系统再去加载;
简化共享,不同进程的 PT 中的 PTE 可以执行相同的物理地址,如动态库的代码;
内存保护,PT 中的 PTE 中描述了一个虚拟页的权限信息,(R, W, X),指令如果违反了这些权限信息,就会造成 Segment Fault
地址翻译
虚拟地址翻译到物理地址是软硬件结合实现的。我们通常几个方面来描述。
如何索引
现代操作系统将地址分为两部分, 页号 和 片
了(是不是很类型网络号和主机号),由于虚拟页和物理页的大小是相同的,页偏移可以看做虚拟页和物理页的页内地址,且相同;页号则做为 PT 的索引查找到对应的
PTE,然后查找对应的物理页地址。
提高效率
是不是像前面所说的简单的划分为两部分就足够了呢?
举个例子:
我们假设一台电脑是 32 位的,分页大小位 4k,也就说页内地址占据了 12 位,页号地址位 20 位;
我们假设一台电脑是 64 位的,地址空间 48 位,分页大小为 16k,也就说页内地址占据了 14 位,页号地址位 34 位
我们粗略估算一个 PTE 为 4KB,对于 32位 的操作系统每个进程的页表需要 2^20 = 4M 个页表项常驻内存尚可接受,但是对于寻址为 48位
的操作系统来说,每个进程的页表为需要 2^32 = 4G 个页表项,这是无法接受的。
计算机的世界所有的难题都可以用多加一层的办法来解决,所以现代操作系统通常都使用多级页表,减少页表项的个数。将虚拟地址分为多端,代表了一级、二级、多级页表。通过多级页表可以大大减少内存占用。
减少内存
众所周知 CPU 要比 Memory 快 10^3
个数量级,即便 CPU 中的 L3Cache
也比 Memory 快很多,如果 MMU
的地址翻译都要去查找多级 PT,这个开销就会非常巨大,但是所幸程序的局部性原理能够解救我们。MMU 芯片内置一个 翻译后备缓冲器
(Transalation Lookaside Buffer TLB )的硬件来充当缓存,加快地址翻译的效率.
现代 OS 虚拟内存系统
操作系统为每个进程维护一个单独的虚拟地址空间,分为两部分:
内核虚拟内存 :包含内核中的代码和数据结构,还有一些被映射到所有进程共享的内存页面。还有一些页表,内核在进程上下文中执行代码使用的栈。
进程虚拟内存 :OS 将内存组织为一些区域(Segment)的集合,代码端,数据端,共享库端,线程栈都是不同的区域,分段的原因是便于管理内存的权限,如果了解过 Mach-O 文件或者 ELF 文件的读者可以看到相同的 Segment 里面的内存权限是相同的,每个 Segment 再划分不同的内容为 section。
在内核中描述一个进程的数据结构概略为如下
pgb 指向第一级页表的基址
每个区域的描述主要有以下几个
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 中的用处
mmap 让读写一个文件像操作一个内存地址一样简单方便;
mmap 效率极高,不用将一个内容从磁盘读入内核态再拷贝至用户态;
mmap 映射的文件由操作系统接管,如果进程 Crash 操作系统会保证文件刷新回磁盘;
动态内存分配
虽然可以使用上面的低级 API 去映射内存,但是需要动态申请内存用来做变量处理的时候就需要 动态内存分配器 (Dunamic memory
allocator),简单理解为 malloc calloc realloc free
等函数来自的库就称为DMA
。动态内存分配器将一个内存的区域(Heao)分为不同的大小的块(block),这些块要不然就是分配的,要不然就是空闲的。
如何设计分配器又是一个大难题。 几乎所有的计算机语言都采用以下两种:
显式分配器(手动管理内容)
隐式分配器(GC)
隐式内存分配器
通常比较知名的语言 Java javaScript Ruby
等都使用 GC,最早的 GC
只是使用标记清除算法来管理内容,通过几十年的迭代,早已更新出了数种算法共同参与的 GC。这里就不再赘述了。
显式内存分配器
C 语言提供了一些列的方法来管理动态内存。如
malloc 申请内容并返回初始化的内存首地址;
calloc 同 malloc 一致,并且会将申请到的内存全置为0;
realloc 重新分配原本已经申请的内存空间;
free 释放内容空间;
sbrk 扩展收缩堆
如何实现一个自己的显式内存分配器
首先我们要明确内存分配器的需求
处理任意顺序的申请内存和释放内存;
立即响应,不应为了性能而重新排列或者缓存请求;
所有内容都在 heap 里存放;
对齐块,使之可以存放任意类型的数据;
不修改已分配的内存块;
鉴于对齐和处理任意顺序内存管理的需求,堆利用效率可能会降低,主要会产生内存碎片(Fragmentation) 。内存碎片分为两种:
内部碎片 :通常是指一个分配过的块数据并不是全部块的内容,通常有元信息,对齐的字节等;
外部碎片 :是指不连续的可用的块,通常外部碎片过多会产生所有空白块相加可以满足申请的资源,但是他们不连续。需要整理碎片。
实现显式内存分配器的重点
空闲块组织
如何分配新申请的块
如何组织空闲快的剩余部分
如何合并刚释放的块
显式内存分配器的实现方案
隐式空闲链表
这种方式在 malloc
申请内存的时候,实际上申请的是实际所需内存加上部门元信息大小的块,然后返回指针是有效数据的首地址,元信息直接存在数据块中,所以称为隐式空闲链表。
隐式链表需要处理如何分割空闲块和合并空闲块。
显式空闲链表
由于隐式空闲链表的搜索效率较低,其实是不适用通用的内存分配的。可以使用某种形式的数据结构去管理这些内存块。基本分为几种:
简单分离器存储
分离适配法
伙伴系统法
关于详细的设计需要读者查看更多算法知识的文档。
显式内存分配器的实现
显式内存分配器的需求已经很清晰,下面有个简单的例子可以参考,这时候对于 C 类语言的内存管理应该不会太过恐惧了,
- C++实现一个简易的内存池分配器 https://blog.csdn.net/oyoung_2012/article/details/78874869
毕竟源码面前了无秘密。
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的参考文档
http://clang.llvm.org/docs/AutomaticReferenceCounting.html
顺便提一下 Xcode10 版本中的 Clang 已经支持在 C 结构体中对于 Objective-C 对象的 ARC 管理,请参看whats_new_in_llvm
内存分类
要想合理的使用内存,必须要掌握不同类型内存的区别,才能更合理的使用内存并且在内存资源匮乏的低端机器上写出“高内存性能”的应用。
首先在 Apple 的官方文档中内存主要分为以下几类。
Free Memory 当前空闲的memory
Used Mamory 当前正在使用的内存
我们最关心的当然是 Used Memory,它又分为以下几类。
Wired Memory :一般是内核占用的常驻内存,比如可执行文件的镜像 Image,内核所有的数据等,无法释放,在OS运行期间必须常驻内存;
Active Memory 活跃的内存,当前正在使用的内存;
Inactive Memory 不活跃的内存,最近用过,但是现在不怎么用了,按照局部性原则可以被置换出物理内存的内存;
Purgeable Memory 可释放的内存,通常在 Foundation 中是
NSDiscardableContent
的子类,或者是NSCache
等。
等等~。上面说的好像跟没说一样/(ㄒoㄒ)/~~。我们换种方式从物理内存和虚拟内存的层面来解释。
首先我们的虚拟内存使用的是 Page 来描述的。一个 Page 有两种状态 Dirty 和 Clean。在 iOS 中 Clean 是可以被回收的。
Virtual Memory 分类
Clean Memory 主要包括 system framework、binary executable 、memory mapped files
Dirty Memory 包括 Heap allocation、caches、decompressed images 等。
(每个进程拥有一份独立的 Virtual memory pace) Virtual Memory = clean Memory
PhySical Memory
物理内存是指真正加载在主存中的内存,所以实际了解真正的物理内存占用才对我们内存管理帮助更大。
DirtyMemory
Clean Memory but loaded。
Page Table
ComPressed memory
IOKit Used
Purgeable
内存测量工具
了解到前面说的内存分类之后我们应该怎么测量我们的内存分布呢。主要有几种工具:命令行工具、Xcode工具、代码工具等。
命令行工具
如果你开发的是 Mac 程序,Mac OS 自带的有一下几种。
top 程序
heap 程序
leaks 程序
vmmap 程序
这些工具读者查看 Man Page 即可。
需要注意的是。以上工具分析的大多是虚拟内存,也就是说对于桌面级程序更适合,但是对于 iOS 中没有交换空间,且拥有 Jetsam
监控程序的设备,可能还需要更精准的测量工具。
顺便提一句。一个堆区上 malloc 的程序如果并没有使用,虽然它是 Clean 的,但是也会被程序统计到。理论上 malloc
可以申请到的虚拟内存大小非常接近 Virtual Memory Space
的大小(这么说的原因是 前文也提到了 malloc
实际上是动态分配器程序提供的一些列函数,为了性能,大多数动态分配器都讲堆分为好几块用来做不同大小虚拟内存的管理,因此malloc可以申请到的虚拟内存大小实际决定于动分配器代码的实现。有兴趣的读者可以读一下。)
Xcode 提供的工具
Xcode Debug Area
Instruments
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);
}
线上检查工具
线上检查内存通常会检查内存泄漏,一般有开源的工具
MLeaksFinder
FBRetainCycleDetector
高性能使用内存
了解完那么多原理和分析的工具,那么在日常使用中有没有什么指导原则可以帮助我们来写出更快,内存占用更低的代码呢?
首先熟读
ARCMenual
,大部分 iOS 开发者其实是完全不清楚 ARC 是怎么实现的,还有相对于的原则,尤其是 Autorelease 修饰的指针,还有在多线程情况下的原则。用
weak
修饰替换unsafe_unretain
使用
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
}];
}];
}
小心方法中的self,在 Objective-C的方法中 隐含的 self 是
__unsafed_unretain
的;使用
Autoreleasepool
来降低循环中的内存峰值,避免 OOM;要处理
Memory Warning
;C/C++ new 出来的要 delete,malloc 的要 free;
UITableView/UICollectionView 的重用(不单单是cell重用,cell 使用的子view也要重用。);
[UIImage imageNamed:] 适合于 UI 界面中的贴图的读取,较大的资源文件应该尽量避免使用;
WKWebView 是跨进程通信的,不会占用我们的 APP 使用的物理内存量;
try_catch_finally
一定要清理资源;尽量少引用
performaSelector:
会对 ARC 的内存管理产生错误,导致内存泄漏;lazy load 那些大的内存对象,尤其是需要保证线程安全,可以参考 java 中的懒汉式
Double Check
写法;需要在收到内存警告的时候释放的 Cache,用 NSCache 代替 NSDictionary,使用 NSPurgableData 代替NSData.
前文中我们说到 iOS 的没有交换分区的概念,取而代之的是压缩内存的办法,倘若在使用 NSDictionary
的时候收到内存警告,然后去释放这个NSDictionary,如果占据的内存过大,很可能在解压的过程中就被 JetSem Kill
掉,如果你的内存只是误用的缓存或者是可重建的数据,就把 NSCache 当初 NSDictionary 用吧。同理 NSPurableData 也是。
不要使用像素过大的图片文件,即便一个图片在磁盘中很小,但是因为图片像素宽高很大也会占据更多的内存,这里有个公式可以计算
widthPx * HeightPx * 4Bytes per pixel(alpha red green blue)
。即便在 iOS 12 中已经可以优化单色图的内存占用,可毕竟是 iOS 12,现在好多公司还在支持iOS8 ~~使用 NSData 和 UIImage 的 mmap 加载选型来加载那些可以被重建的数据;
在子线程手动申请(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);
}