目录

  1. 应用程序的内存空间
    1. 代码段
    2. 初始化数据段
    3. 未初始化数据段
    4. 堆栈疑问
  2. Linux 进程堆管理
    1. brk()系统调用
    2. mmap()系统调用
  3. 堆分配算法
    1. 空闲链表法
    2. 位图法
    3. 对象池
    4. 总结
  4. 附录

应用程序的内存空间

每个进程所看到的内存其实是操作系统虚拟出来的,称为逻辑地址空间。这个逻辑地址最终要通过寻址过程翻译成实际的物理地址进行内存访问。这个物理地址可以是内存中的内核区地址、也可以是用户区、也可以是硬盘上的 Swap 区

✨一般而言,应用程序使用的内存空间里有如下默认的区域:

区域含义
栈用于维护函数调用的上下文,没有栈,函数调用就无法实现,栈通常在用户空间的最高地址处分配,一般有数兆字节的大小
堆用来容纳应用程序动态分配的内存区域,当程序使用 malloc 或者 new 分配内存的时候,得到的内存来自堆。堆通常存在于栈的下方(低地址方向),某些时候,堆也可能没有固定统一的存储区域,堆一般比栈大很多,可以有几十至数百兆字节的容量
可执行文件映像存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里
保留区保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称:例如大多数操作系统中,极小的地址通常都是不允许访问的,如 NULL,C 语言将无效指针赋值为 0 也是这个考虑
动态链接库映射区动态链接库映射区用于映射装载的动态链接库。在 Linux 下,如果可执行文件依赖其它共享库,那么系统就会为它在从 0x40000000 开始的地址分配相应的空间,并将共享库载入该空间
代码段存放程度运行的可执行指令集合
初始化数据段也称为数据段,这里存放程序运行前已经完成初始化工作的字段
未初始化数据段也称为 BSS 段,由内核进行最开始的默认初始化,存放的是程序中的全局变量和静态变量

下图就是 Linux 下一个进程里典型的内存布局:

🎶图中的箭头,标明了几个大小可变的尺寸增长的方向,当栈或堆现有的大小不够用的时候,它将按照图中的增长方向扩大自身的尺寸,直到预留的空间被用完为止。可以清晰地看出:栈式由高地址向低地址增长,堆是由低地址向高地址增长

接下来简单介绍其中比较重要的几个组件

代码段

代码段中存放可执行的指令,存在程序内存的最底层,是为了保证不会因为堆栈溢出被覆盖(从上图可以看出)。

通常来讲代码段是共享的,这样多次反复执行的指令只需要在内存中驻留一个副本即可,比如 C 编译器,文本编辑器等。代码段一般是只读的,程序执行时不能随意更改指令,也是为了进行隔离保护

初始化数据段

初始化数据段有时就称之为数据段。数据段是一个程序虚拟地址空间的一部分,包括全局变量和静态变量,这些变量在编程时就已经被初始化。数据段是可以修改的,不然程序运行时变量就无法改变了,这一点和代码段不同

数据段可以细分为:初始化只读区初始化读写区。这一点和编程中的一些特殊变量吻合。比如全局变量 int global n = 1 就被放在了初始化读写区,因为 global 是可以修改的。而 const int m = 2 就会被放在只读区

未初始化数据段

未初始化数据段有时称之为 BSS 段,BSS 是英文 Block Started by Symbol 的简称,BSS 段属于静态内存分配。存放在这里的数据都由内核进行零初始化。

未初始化数据段(BSS)从初始化数据段的末尾开始存储,存放全部的全局变量和静态变量。比如 static int i; 或者全局 int j; 都会被放到 BSS 段

栈是用来存储程序初期设定的变量的,这些变量在程序执行之前就要准备好。因此栈要放在程序内存的头部并且位置固定,否则程序就不知道该到那里去找这些变量。而程序的运行结果往往是不能预先确定的,所以把堆放在程序中内存的后部以便可以提供足够的内存保存运算结果。在经典的操作系统里,栈总是向下增长的

在 i386 下,栈顶由称为 esp 的寄存器进行定位,压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大,这里栈底的地址是 0xbffff,而 esp 寄存器标明了栈顶,地址为 0xbffff4,在栈上压入数据会导致 esp 减小,弹出数据使得 esp 增大

👍栈在程序运行中具有举足轻重的地位,栈保存了一个函数调用所需要的维护信息,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record),栈帧一般包括如下几方面内容

  • 函数的返回地址和参数
  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器

😣使用栈也会存在非常多缺点:

  • 不利于管理大内存(尤其在 16 位和 32 位时代)
  • 变量的生命周期难于控制(栈内的有效数据通常是连续存储的,所以 pop 时后申请的内存必须早于先申请的内存失效,否则将出现出栈再入栈的情况)

📓栈不利于动态地管理并且有效地利用宝贵的内存资源,于时才有了堆,在堆上进行内存管理

❓这里说的堆和数据结构中的堆有什么关系?

在进程内存中,使用堆表示分配的一系列内存,而在数据结构中,堆则表示一种特定的数据结构,这两者没有必然的联系,内存中的堆更像是一种线性表结构

Several authors began about 1975 to call the pool of available memory a “heap.” But in the present series of books, we will use that word only in its more traditional sense related to priority queues. (Fundamental Algorithms, 3rd ed., p. 435)

❓为什么需要堆?

光有栈,对于面向过程的程序设计还远远不够,因为栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部;并且全局变量没有办法动态地产生,只能在编译的时候定义,很多情况下缺乏表现力,在这种情况下,堆(Heap)是一种唯一的选择

堆是一款巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间里,程序可以请求一块连续的内存,并自由地使用,这块内存在程序主动放弃之前都活一直保持有效。

下面是一个申请堆空间最简单的例子:

1
2
3
4
5
6
int main()
{
char* p = (char*) malloc(1024);
free(p);
return 0;
}

在第 3 行用 malloc 申请了 1024 个字节的空间之后,程序可以自由地使用这 233 个字节,直到程序用 free 函数释放它

malloc到底是怎么实现的尼?

  • 一种做法是:把进程的内存管理交给操作系统内核去做。当然这是一种理论上可行的做法,但实际上这样做的性能比较差,原因在于每次程序申请或者释放堆空间都需要进行系统调用。系统调用的性能开销是很大的,当程序对堆的操作比较频繁时,这样做的结果是会严重影响程序的性能的

  • 比较好的做法就是:程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体来讲,管理着堆空间分配的往往是程序的运行库

相对于栈,堆内存面临着一个稍微复杂的行为模式:在任意时刻,程序可能发出请求,要么申请一段内存,要么释放一段已经申请过的内存,而且申请的大小从几个字节到数 GB 都是有可能的,我们不能假设程序会一次申请多少堆空间,因此,堆的管理显得较为复杂

堆栈疑问

本小节主要记录我对程序内存中堆栈存在的诸多问题
❓为什么要将堆和栈区分出来?

  • 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰,这种隔离、模块化的思想在软件设计的方方面面都有体现
  • 堆栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。这种共享的收益是很多的。一方面这种共享提供了一种有效的数据交互方式(如:共享内存),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间
  • 栈是运行时需要的,比如保存系统运行的上下文,需要进行地址段的划分。由于栈只能不断增长,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可
  • 面向对象就是堆和栈的完美结合。面向对象方式的程序与以前结构化的程序在执行上没有任何区别。但是,面向对象的引入,使得对待问题的思考方式发生了改变,而更接近于自然方式的思考。当我们把对象拆开,你会发现,对象的属性其实就是数据,存放在堆中;而对象的行为(方法),就是运行逻辑,放在栈中。我们在编写对象的时候,其实即编写了数据结构,也编写的处理数据的逻辑

❓不能全用堆或者全用栈吗?

栈用先进后出的方式,读写速度高,这是针对它的长度固定的特征设计出来的。而堆为了保持可扩张的特性,也设计了类似于二叉树的索引方式,以便可以高速地寻址,并可以方便地释放内存空间

其实栈只是一种数据结构,是一种限定仅在表尾进行插入和删除操作的线性表。现代 CPU 体系结构决定了栈是管理函数调用和局部变量的最佳数据结构,所以现代操作系统中常用栈管理函数的调用

但是由于栈的缺点,就使得内存管理时我们不能单独使用栈(只使用栈不好管理内存)或者只使用堆(只使用堆不好进行方法调用管理),所以基于此,才分成了内存的堆栈管理,让专业的人去做专业的事

❓在 Java 语言中,堆中存什么?栈中存什么?

堆中存的是对象,栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个 4btye 的引用,当然在某些情况下,栈中也可以存复杂对象

❓那么为什么在 Java 中不将所有的东西都放在堆中,而是一部分数据放在堆中?

因为基本数据类型占用的空间一般是1~8个字节,需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况——长度固定,因此栈中存储就够了,如果把他存在堆中是没有什么意义的(还会浪费空间,后面说明)。可以这么说,基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,他们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据。最常见的一个问题就是,Java 中参数传递时的问题。

Linux 进程堆管理

Linux 系统下,通过两个系统调用提供对堆空间进行分配:brk() 系统调用 和 mmap() 系统调用,这两种方式分配的都是虚拟内存,没有分配物理内存,在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系

在标准 C 库中,提供了 malloc/free 函数分配释放内存,这两个函数底层是由 brkmmapmunmap 这些系统调用实现的

brk()系统调用

C 语言形式声明:

1
int brk() {void* end_data_segment;}

brk() 的作用是设置进程数据段的结束地址,即它可以扩大或者缩小数据段(Linux 下数据段和 BBS 合并在一起统称数据段),如果将数据段的结束地址向高地址移动,那么扩大的那部分空间就可以被程序使用,把这块空间拿过来使用作为堆空间是最常见的做法

mmap()系统调用

和 Windows 系统下的 VirtualAlloc 相似,mmap() 的作用就是向操作系统申请一段虚拟地址空间,(堆和栈中间,称为文件映射区域的地方)这块虚拟地址空间可以映射到某个文件。

glibc 的 malloc 函数是这样处理用户的空间请求的:对于小于 128KB 的请求来说,它会在现有的堆空间里面,按照堆分配算法为它分配一块空间并返回;对于大于 128KB 的请求来说,它会使用 mmap() 函数为它分配一块匿名空间,然后在这个匿名空间中为用户分配空间。

下面就是 mmap() 系统调用原型:

1
void* mmap ( void* start ; size_t length ; int prot ; int flags ; int fd ; off_t offset ;)

mmap 前两个参数分别用于指定需要申请的空间的起始地址和长度,如果起始地址设置 0,那么 Linux 系统会自动挑选合适的起始地址。prot/flags 参数:用于设置申请的空间的权限(可读,可写,可执行)以及映射类型(文件映射,匿名空间等)。最后两个参数用于文件映射时指定的文件描述符和文件偏移的

堆分配算法

本文简单介绍在 Linux 中的堆分配算法

空闲链表法

将堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间的时候,可以遍历整个列表,直到找到合适大小的块并且将它拆分;当用户释放空间的时候将它合并到空闲链表中。空闲链表是这样一种结构,在堆里的每一个空闲空间的开头(或结尾)有一个头 (header),头结构里记录了上一个 (prev) 和下一个 (next) 空闲块的地址,也就是说,所有的空闲块形成了一个链表。如图所示

位图法

针对空闲链表的弊端,另一种分配方式显得更加稳健,这种方式称为位图Bitmap)。

位图的核心思路是将整个堆划分为大量的块(block),每个块的大小相同,当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块我们称为已分配区域的头(Head),其余的称为己分配区域的主体(Body),而我们可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,仅仅需要两位即可表示一个块,因此称为位图

对象池

还有一种分配方法是对象池,也是将堆空间分成了大小相等的一些块,它认为某些场合每次分配的空间都相等,所以每次就直接返回一个块的大小,它的管理方法可以是链表也可以是位图。因为不用每次查找合适的大小的内存返回,所以效率很高。

总结

📓实际上很多现实应用中,堆的分配算法往往是采取多种算法复合而成的,比如对于 glibc 来说,它对于小于 64 字节的空间申请是采用类似于对象池的方法;而对于大于 512 字节的空间申请采用的是最佳适配算法;对于大于 64 字节而小于 512 字节的,它会根据情况采取上述方法中的最佳折中策略;对于大于 128KB 的申请,它会使用 mmap 机制直接向操作系统申请空间

附录

进程的内存结构(以 Java 为例)
浅谈程序的内存布局
进程结构和内存布局
为什么要把堆和栈区分出来呢?
为什么要有堆区和栈区呢?
为什么要有堆内存和栈内存之分?
内存为什么要分堆栈在编程里,要是全部只用堆或者全部只用栈,可行吗?