内存

参考文章:

iOS Memory 内存详解 (长文)

Mach-O 文件格式探索

WWDC2018-深入了解iOS内存


操作系统内存处理机制

内存是计算机重要组成部分,所有的应用程序在运行时都需要加载到内存中,供CPU读取指令来执行

冯·诺依曼结构
从早期的冯·诺依曼结构可以看出存储器在计算机结构中的重要位置。随着计算机的发展,CPU的执行效率已经远超过存储器,后面为了高效率使用CPU,根据存储器的速度、材料、价位对其进行分级,如下图:

现在我们讨论的内存主要指的是主存,所有的应用程序在运行时都需要加载到内存中,供CPU读取指令来执行。

早期程序是直接加载到物理内存上面的,这样会有几个问题

  1. 地址不隔离,所有程序都可以访问物理地址,程序的空间不是相互隔离的,恶意程序可能会修改其他程序的内存数据,破坏运行
  2. 程序运行的内存地址不确定,因为程序每次运行时,我们都会分配一个满足运行的空间,导致每次运行时内存是不确定的
  3. 内存使用效率低,当一个程序运行时需要将这个程序全部加载进内存,然后执行,此时如果要执行另外一个程序,内存如果不够,就需要先将其他程序保存至磁盘,等需要的时候再读出来,由于程序运行时地址是连续的,如果此时内存仍然不够,就需要继续将其他程序换出内存,才能运行这个程序

为了解决以上几种问题,操作系统使用以下方式管理内存

  1. 操作系统采用了间接地址访问法,把程序的地址看作虚拟地址,当应用程序加载进内存时,操作系统为程序分配一块虚拟的连续的内存地址,然后通过映射方法,将虚拟地址映射到物理地址上,通过控制虚拟地址到物理地址的映射关系,可以达到地址隔离的目的。
  2. 同时由于程序运行具有局部性特征,所以使用分页的方式来提高内存使用效率。分页的基本方法是通过将地址空间人为的分为固定大小的页。具体每页的大小由硬件决定,或者硬件支持多种页大小由操作系统决定。目前几乎所有的PC都是4k大小分页,几乎所有的硬件都采用一个叫MMU (Memory Management Unit)的部件来进行页映射,MMU一般都在CPU内部集成了,不会单独存在
  3. 同时由于程序只需要关心虚拟内存地址,不需要关系物理内存地址,假设程序每次加载时虚拟地址是固定的,那么编写程序时只需关系虚拟内存地址就好了。(涉及到编译过程的重定向)

如上图:运行程序1时,操作系统为程序1分配了8页虚拟内存,其中VP0、VP1、VP7是直接映射到物理内存中,VP2、VP3因为暂时没有使用到,所以只是建立与磁盘的映射关系,在需要时从磁盘读出后加载到物理内存中,提高物理内存使用效率

内存加载应用程序

在了解了操作系统是如何处理内存后,那么应用程序是如何加载的呢?在研究这个问题之前,我们先来研究下应用程序到底是什么。

应用程序是我们编写的代码经过预编译、编译、汇编和链接之后生成的可执行文件。我们来看看可执行文件里面到底包含了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fileName: hello.c
#include <stdio.h>
#define TEST_DEFINE 3
int global_init_var = 10;
int global_uninit_var;
static int global_init_static = 11;
static int global_uninit_static;
int main ()
{
int a = 11;
int b ;
static int c = 20;
static int d;
printf("hello world\n");
printf("%d", TEST_DEFINE);
return 0;
}

执行gcc hello.c可以得到可执行文件a.out

1
2
$ file a.out
a.out: Mach-O 64-bit executable x86_64

终端执行file a.out可以看到a.outMach-O 64 位的可执行文件,此处根据操作系统的不同会有不同的文件格式,但是内容大同小异

随后我们执行objdump -h a.out查看可执行文件的Section信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ objdump -h a.out
a.out: file format mach-o-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000047 0000000100000f30 0000000100000f30 00000f30 2**4
CONTENTS, ALLOC, LOAD, CODE
1 __TEXT.__stubs 00000006 0000000100000f78 0000000100000f78 00000f78 2**1
CONTENTS, ALLOC, LOAD, READONLY, CODE
2 __TEXT.__stub_helper 0000001a 0000000100000f80 0000000100000f80 00000f80 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
3 .cstring 00000010 0000000100000f9a 0000000100000f9a 00000f9a 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 __TEXT.__unwind_info 00000048 0000000100000fac 0000000100000fac 00000fac 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
5 __DATA.__nl_symbol_ptr 00000010 0000000100001000 0000000100001000 00001000 2**3
CONTENTS, ALLOC, LOAD, DATA
6 __DATA.__la_symbol_ptr 00000008 0000000100001010 0000000100001010 00001010 2**3
CONTENTS, ALLOC, LOAD, DATA
7 .data 00000008 0000000100001018 0000000100001018 00001018 2**2
CONTENTS, ALLOC, LOAD, DATA
8 .bss 00000004 0000000100001020 0000000100001020 00000000 2**2
ALLOC
9 __DATA.__common 00000004 0000000100001024 0000000100001024 00000000 2**2
ALLOC

其中每一个section后面都跟上一组flag

  • LOAD 代表当前Section位于可以加载的段,当程序运行时,此Section的内容可以读取到内存中
  • CODE 代表当前Section包含可执行文件
  • ALLOC 代表程序运行时,将占用内存
  • READONLY 代表此段只读
    我们可以执行 xcrun size -x -l -m a.out,分段显示
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    $ xcrun size -x -l -m a.out
    Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0)
    Segment __TEXT: 0x1000 (vmaddr 0x100000000 fileoff 0)
    Section __text: 0x47 (addr 0x100000f30 offset 3888)
    Section __stubs: 0x6 (addr 0x100000f78 offset 3960)
    Section __stub_helper: 0x1a (addr 0x100000f80 offset 3968)
    Section __cstring: 0x10 (addr 0x100000f9a offset 3994)
    Section __unwind_info: 0x48 (addr 0x100000fac offset 4012)
    total 0xbf
    Segment __DATA: 0x1000 (vmaddr 0x100001000 fileoff 4096)
    Section __nl_symbol_ptr: 0x10 (addr 0x100001000 offset 4096)
    Section __la_symbol_ptr: 0x8 (addr 0x100001010 offset 4112)
    Section __data: 0x8 (addr 0x100001018 offset 4120)
    Section __bss: 0x4 (addr 0x100001020 offset 0)
    Section __common: 0x4 (addr 0x100001024 offset 0)
    total 0x28
    Segment __LINKEDIT: 0x1000 (vmaddr 0x100002000 fileoff 8192)
    total 0x100003000

通过上面可以看到,该可执行文件包含四个Segment:__PAGEZERO、__TEXT、__DATA、__LINKEDIT,其中有些Segment又分为多个Sections。每一段的内容含义如下:

“我们很难将“Segment”和“Section”这两个词从中文的翻译上加以区分,因为很多时候Section也被翻译成“段”,回顾第2章,我们也没有很严格区分这两个英文词汇和两个中文词汇“段”和“节”之间的相互翻译。很明显,从链接的角度看,ELF文件是按“Section”存储的,事实也的确如此;从装载的角度看,ELF文件又可以按照“Segment”划分。我们在这里就对“Segment”不作翻译,一律按照原词。”

“总的来说,“Segment”和“Section”是从不同的角度来划分同一个ELF文件。这个在ELF中被称为不同的视图(View),从“Section”的角度来看ELF文件就是链接视图(Linking View),从“Segment”的角度来看就是执行视图(Execution View)。当我们在谈到ELF装载时,“段”专门指“Segment”;而在其他的情况下,“段”指的是“Section”。

”摘录来自: 俞甲子 石凡 潘爱民. “程序员的自我修养:链接、装载与库。”

Segment是将权限相同的Sections放在一起,这样在程序加载的时候可以一起映射,减少内存占用。比如上面__TEXT.__stubs__TEXT.__stub_helper,倘若分开装载的话就需要2页,但是和在一起装载1页内存就可以了

依据上面我们基本可以得到:加载应用程序时,根据分页加载机制,将程序根据不同的段映射进虚拟内存空间,此时程序并未加载进内存,只是建立了关系表,当运行到此页时,操作系统根据关系表去磁盘中将对应页加载进虚拟内存同时建立与物理内存的关系,此时应用程序的对应页才真正加载进内存,供cpu执行

iOS内存管理

了解了程序是如何加载到内存上的原理后,我们来看下iOS系统加载内存的机制

iOS系统内存管理也是以页为基本单位,目前大部分为16kb,iOS的内存由以下几部分组成:

  • Compressed Memory

    被压缩的内存,iOS中没有跟其他操作系统一样,当内存不足时,将不常用的数据换到磁盘,iOS系统会将长时间不使用的内存压缩

  • Clean Memory

    iOS系统可以安全回收的内存。可以是只读数据等可以被系统安全移除并且重载的数据或者其他只是申请但还没有使用的内存空间

  • Dirty Memory

    Dirty Memory 指的是被我们App写入的内存、已经解码的图片、Framework 的 DATA、DATA_DIRTY

当遇到内存不够时,操作系统会

  1. 尝试回收Clean Memory
  2. 向内存占用过高的App发送内存警告,APP收到警告后尝试自行处理内存
  3. 多次警告依旧内存占用过高,系统会kill进程,就是OOM崩溃

其中第二步由于Compressed Memory的存在会变得有些复杂,例如:

  1. 你的App收到内存警告时,你尝试清理内存
  2. 在清理时系统会把压缩的内存展开,然后清理
  3. 由于系统展开压缩内存反而会导致更多内存占用,有可能导致系统直接kill掉进程

官方推荐使用NSCache来管理缓存,可以很好的解决此类问题

iOS常见导致内存泄漏的情况

  1. block
  2. 图片
  3. timer
  4. Core Foundation、Core Graphics

    ARC下并不会对CF、CG对象进行管理,所以当我们使用CF对象时一定要记得使用CFReleaseCGPATHRELEASE进行释放

  5. UIWebVIew