前言

对于大多数开发者,特别是 C, Objective-C, Swift 等相关的开发者来说,已经很了解如何避免内存泄漏,解决循环引用等问题。但如果继续深入讨论为何会需要手动管理内存,为何内存泄漏仅在特定场景下产生,如何尽可能减少内存开销等问题时,能回答很清楚的人就相对较少了。

所以还是有必要从头梳理一下关于内存的基本知识与概念了,如果我们掌握了这些知识,相信很多问题就会迎刃而解了。


基础知识

  • 位 :( bit ) 是电子计算机中最小的数据单位。每一位的状态只能是0或1。
  • 字节:1 Byte = 8 bit ,是内存基本的计量单位,
  • 字:”字” 由若干个字节构成,字的位数叫做字长,不同档次的机器有不同的字长。
  • KB :1KB = 1024 Byte。也就是1024个字节。
  • MB : 1MB = 1024 KB。类似的还有GB、TB。

  • 内存编址:计算机中的内存按字节编址,每个地址的存储单元可以存放一个字节(8个bit)的数据,CPU通过内存地址获取指令和数据,并不关心这个地址所代表的空间具体在什么位置、怎么分布,因为硬件的设计保证一个地址对应着一个固定的空间,所以说:内存地址和地址指向的空间共同构成了一个内存单元。

  • 内存地址:内存地址通常用十六进制的数据表示,例如通常在C或者Objective-C中输出一个变量的地址可能为:0x7fff5fbff79c,这就是一个用十六进制的数表示的地址。

下图的整数100在三种进制中的表示:

什么是内存?

从硬件的角度来说,它是重要的部件之一.

也是硬盘与 CPU 之间沟通的桥梁。所有的应用程序运行时都会放入内存,然后交由 CPU 进行计算执行。CPU 能通过寻址找到内存对应的数据。

严格意义上讲,内存不仅仅是我们经常所指的内存条。寄存器(Register)、缓存(Cache) 都属于内存的一种。

所以内存一般分为 RAM(main memory), SRAM(cache), Register。

寄存器是 CPU 的组成部分,因为在CPU内,在设计上和CPU同频,所以CPU对其读写速度是最快的,不需要IO传输。

部分缓存的设计也基本保持了与 CPU 同频,所以速度相对内存也是比较快。

理论上讲,缓存和寄存器是一样快的。不过因为缓存里面的东西不一定就是需要的,如果不存在就要一级一级往下找,直到内存。因为不同的缓存或内存时钟频率不一样,所以CPU在查找时需要一定的等待时间。

CPU 寻找数据进行运算的流程是:

CPU -> Register -> L1 Cache -> L2 Cache -> 内存

RAM 内存的主频现在主流是1600左右,单位是MHz,这比CPU的速度要低的多。

32 位与 64 位是什么概念?

所谓多少位一般是指处理器(CPU)的运算能力与寻址能力。

  1. 运算能力

比如32位,一次能处理32位,也就是4个字节的数据(1字节=8位)。而64位处理器则能处理8个字节的数据。

如果我们将总长128位的指令分别按照16位、32位、64位为单位进行编辑的话:旧的16位处理器,比如Intel 80286 CPU需要8个指令,32位的处理器需要4个指令,而64位处理器则只要两个指令。

  1. 寻址能力

除了运算能力之外,与32位处理器相比,64位处理器的优势还体现在系统对内存的控制上。

相比32位的CPU来说,64位CPU最为明显的变化就是增加了8个64位的通用寄存器,内存寻址能力提高到64位。

32位的内存地址空间为2的32次方,即4GB。64位可获得更大的寻址空间,能识别4G以上的内存。

另外,要实现真正意义上的64位计算,光有64位的处理器是不行的,还必须得有64位的操作系统以及64位的应用软件才行,如果应用软件不支持,反而在64位机器上会变慢。

寄存器 (Register)

为了处理数据,暂时储存结果,或者做间接寻址等动作,每个处理器都具备一些内建的内存,这些能够在不延迟的状态下存取的内存就称为寄存器。

寄存器是数据处理的重要一环,如果经常使用汇编语言,对它应该会非常熟悉。

IA-32构架提供了16个基本寄存器,这16个基本寄存器可以归纳为如下几类:

  • 通用寄存器
  • 段寄存器
  • 状态和控制寄存器
  • 指令寄存器

通用寄存器
32位通用寄存器有八个, eax, ebx, ecx, edx, esi, edi, ebp, esp

他们主要用作逻辑运算、地址计算和内存指针,具体功能如下:

  • eax 累加和结果寄存器
  • ebx 数据指针寄存器
  • ecx 循环计数器
  • edx i/o指针
  • esi 源地址寄存器
  • edi 目的地址寄存器
  • esp 堆栈指针
  • ebp 栈指针寄存器

当然,以上功能并未限制寄存器的使用,特殊情况为了效率也可作其他用途。

在 64-bit 模式下,有16个通用寄存器,但是这16个寄存器是兼容32位模式的,

32位方式下寄存器名分别为 eax, ebx, ecx, edx, esi, edi, ebp, esp, r8d r15d

在64位模式下,他们被扩展为 rax, rbx, rcx, rdx, rdi, rsi, rbp, rsp, r8 r15,其中 r8 – r15 这八个寄存器是64-bit模式下新加入的寄存器。

段寄存器
段寄存器是因为对内存的分段管理而设置的。计算机需要对内存分段,以分配给不同的程序使用(类似于硬盘分页)。

把内存分为很多段,每一段有一个段基址,当然段基址也是一个20位的内存地址。不过段寄存器仍然是16位的,它的内容代表了段基址的高16位,这个16位的地址后面再加上4个0就构成20位的段基址。而原来的16位地址只是段内的偏移量。这样,一个完整的物理内存地址就由两部分组成,高16位的段基址和低16位的段内偏移量,当然它们有12位是重叠的,它们两部分相加在一起,才构成完整的物理地址。

段寄存器又分为 cs, ds, ss, es, fs, gs

  • cs 代码段寄存器
  • ds, es, fs, gs 数据段寄存器
  • ss 堆栈段寄存器

在 64-bit 模式下,这6个寄存器并无变化,只是使用上略有区别。

状态和控制寄存器 EFLAGS
这个寄存器表示的意义非常丰富,程序中并不直接操作此寄存器,并由此衍生出很多操作指令。

指令寄存器 EIP
标志当前进程将要执行指令位置,在64位模式下扩展为 RIP 64位指令寄存器。

OK 以上,关于寄存器,在这里我们只需要了解这么多就够了,不用深入。

内核空间与用户空间

为了保护操作系统不会被运行的用户程序所干扰,所以分为了内核空间与用户空间,所有的应用程序会被分配在用户空间。

内核空间是持续存在的,并且在所有进程中都映射到同样的物理内存,内核代码和数据总是可寻址的,随时准备处理中断和系统调用。

现代的操作系统都处于32位模式下。每个进程一般都能寻址4G的物理空间。但是我们的物理内存不足 4G,通常我们使用一种叫做虚拟内存的技术来实现,因为可以使用硬盘中的一部分来当作内存使用。

虚拟空间中的内存布局 (Memory layout)

如果一个应用程序在运行时,会被装载到用户内存空间,并构成一个虚拟地址空间(virtual address space)。

典型的虚拟地址空间有四个部分构成

  • 执行的代码 (Executable code)
    这个空间包括了可被机器执行的代码,为只读区域

  • 静态数据 (Static data)
    这个部分包括了静态分配的变量

  • 堆 (Heap)
    这个部分包括了动态分配的变量

  • 栈 (Stack)
    这个部分包括了临时变量,返回地址,执行参数

每个部分分别占用了一块或多块连续的地址空间,他们被操作系统所管理。

如果我们想看看操作系统自己的内存分布,可以用下面的命令

 

第一列显示了内存地址块,第二列显示一些标记,如只读,可写等,三,四,五,六列分别表示偏移地址,设备,节点,名称

C 语言应用程序的内存布局

除了核心的内核空间外,其它的空间分为 5 种类型:

  • 文本段 (Text segment)

通常存放执行的代码,只读段。

  • 初始化的数据段 (Initialized data segment)

这个区域分为”只读数据段”和”可读写的数据段”

只读数据段是程序使用的一些不会被更改的数据。
可读写数据段会放入在程序中声明,具有初值的变量,以供程序运行时读写。
如全局 ( main 函数外) 字符串定义 char s[] = “hello world” 或 int debug=1 。

这样的全局声明 const char* string = “hello world” 中,“hello world” 被放入只读区,而字符串指针变量 string 会被放入读写区。
这样的声明 static int i = 10 与 int I = 10 都会被存入数据段。

  • 未初始化的数据段 (Uninitialized data segment)

又称为 BSS (block started by symbol) 段。
这样的声明 static int I; 或 全局的 int j; 都会放入这个区域。

  • 栈 (Stack)

栈空间主要用于以下3种数据的存储:

  • 函数内部的动态变量
  • 函数的参数
  • 函数的返回值

栈空间是动态开辟与回收的。在函数调用过程中,如果函数调用的层次比较多,所需的栈空间也逐渐加大,对于参数的传递和返回值,如果使用较大的结构体,在使用的栈空间也会比较大。

栈上的局部变量往往通过偏移地址去访问,如果通过 push/pop 会浪费大量时间。

栈通常都会放在高位区,它遵循 LIFO (后进先出) 机制。

变量a的地址 0x7fff5fbff79c 比变量 b 的地址 0x7fff5fbff798 要大。所以它也是从高位开始按顺序一条条出栈执行。

  • 堆 (Heap)

堆开始于 BSS 段的未尾。然后往高地址增长。Heap 区通常由 malloc, realloc, and free 命令管理。Heap 区可以在一个进程中被多个库或动态加载的模块所共享。

我们来看看一个最简单的 C 程序,在 linux 系统上编译后它的内存部局是什么样的:

然后我们增加一个全局变量,看看内存有什么变化

重新编译

如果我们增加一个 static 变量

让我们实例化这个变量,它会被放入 Data Segment (DS)

如果我们实例化全局变量后,它也会被放入 DS

注意:如果你是在 Mac 系统上编译,查看内存布局时,会是这样的情况

同样也是分为 Text 区,Data 区。

代码段、只读数据段、读写数据段、未初始化数据段属于静态区域,而堆和栈属于动态区域。代码段、只读数据段和读写数据段将在链接之后产生,未初始化数据段将在程序初始化的时候开辟,而堆和栈将在程序的运行中分配和释放。

设想一下,如果系统将程序初始化阶段把所有变量全部分配好,那得要多少内存。所以有这样段设计,将动态的部分根据需要再去分配,这才有了运行时的概念。

基本数据类型的内存空间

变量在内存中以二进制形式存储,一个变量占用的存储空间,不仅和变量类型有关,还和编译环境有关,同一种类型的变量在不同编译环境下占用的存储空间不一样。比如开发中常用的基本数据类型char、int等在不同编译环境下就会占用不同大小的空间。

在 64 位机器下的许多程序设计环境,int 变量仍然是 32 位宽,不过 long 是 64 位宽。这就是为什么 Objective-C 中会有 NSInteger 的定义,它会根据操作系统位数自由选择不同的数据类型。

我们通过一个例子来验证 int 的内存空间。

我们可以看到 b 与 a 刚好是 4 个字节。

C语言中数组的存储和普通的变量不太一样,数值中存储的元素,是从所占用的低地址开始存储的。例如

仅仅通过上面的字符数组例子还不能完全说明数组在内存中关于其元素存储和元素中值的存储关系。如果换用一个整型数组就能看出一些差别。

数组在使用过程中遇到的最多的问题可能就是下标越界,下面的代码就是越界访问数组示例:

结构体变量占用的内存空间是其成员占用最大内存空间的整数倍

内存对齐

对于结构体来说,会有内存对齐的规范。
首先结构体从首个成员分配空间;如果空间不够则重新分配,如果空间剩余则会把下一个成员的数据存储到剩余的空间中

按照前面的说明,系统为 s1 分配内存时以 sizeof(double) 8个字节为单位,所以为 s1.id 分配了8个字节的空间,但是由于id定义为char类型,所以只占了8个字节中数值最小的一个内存空间;由于前面剩下的 8 – 1 = 7个字节不足以存放double类型的值,所以接着为s1.score分配8个字节并占满8个字节空间;最后为s1.age分配8个字节并占用了前4个字节空间。故s1变量在内存中占用的内存大小为 8 X 3 = 24个字节。

如果调换结构体Student成员之间的顺序如下,情况又会发生变化。

这是由于第二次为 s1.id 分配内存时没有完全占满8个字节的空间,而且第三次为s1.age分配时其需要的4个字节空间也没有超出剩余的8-1 = 7个字节空间,所以s1.age的值按照内存对齐的原则就存放在了第二次分配的8个字节的后4位空间中。

总结:结构体变量所占存储空间受其不同类型的成员排列顺序及编译器内存对齐影响,开发中尽量将相同类型的成员依次定义,有助于节省内存空间。

内存管理

根据以上知识点,我们知道内存管理分为系统和手动管理两个部分。
BSS, Data, Text, Stack 区都是系统自动管理的。Heap 区是手动创建,所以需要手动释放。否则内存会不断的增大,直到进程被强制退出。

不过对于栈区 (Stack ),虽然是由系统管理的,但它也是动态分配的。所以如果栈特别大,比如递归函数,会不断的压栈,也会造成内存耗尽。另外,如果在函数里定义的变量,也会分配在栈区,假设有一个无限循环,局部变量不停的生成,如果不及时释放,也会造成内存溢出。

所以无论是何种内存管理方式,垃圾回收机制,无外乎要注意以上几点。比如 iOS 开发中的 ARC 机制,就是采用了引用计数,来帮我们管理 Heap 区的内容,防止我们遗忘释放对象。 Autorelease Pool 也是一种管理栈区变量的一种机制。

内存泄漏

内存泄漏是指分配的内存未能及时释放,导致这部分的内存长期占用,产生了浪费,甚至还会导致系统资源不足引起崩溃。

根据上述提到的点,这种场景确实是会发生的。

场景一

这两段代码分别创建了一块内存,并且将内存的地址传给了指针 pOld 和 pNew。此时指针 pOld 和 pNew 分别指向两块内存。

如果接下来进行这样的操作:

pOld = pNew;

pOld 指针就指向了 pNew 指向的内存地址,这时候再进行释放内存操作:

free(pOld);

此时释放的 pOld 所指向的内存空间就是原来 pNew 指向的,于是这块空间被释放掉了。但是 pOld 原来指向的那块内存空间还没有被释放,不过因为没有指针指向这块内存,所以这块内存就造成了丢失。

场景二

另外,不应该进行类似下面这样的操作:

malloc( sizeof(int) );

这样的操作没有意义,因为没有指针指向分配的内存,无法使用,而且无法通过 free() 释放掉,造成了内存泄露。

泄漏检测

我们可以用 leaks 命令 (Mac 系统下);

首先在命令行下用 MallocStackLogging=1 ./test.out & 打开 Malloc 调试日志。
当你运行上面的命令后,会出现一个线程 id。然后运行命令:leaks 线程 ID, 即可看到是否有泄漏。
注意: 进程需要保持运行状态,否则退出后,所有的内存都会被销毁,就没办法跟踪了。可以用 sleep(100) 能让它一直运行

比如

Written by square