你不知道的 malloc 内幕
- 1. 引言:一个例子
- 例1
- 例2
- 2. 基础概念
- 2.1 内存管理发展过程
- 2.2 虚拟存储器
- 2.3 内存分配机制
- 2.4 VMA
- 2.4.1 进程的 VMA
- 2.4.2 vma 分析
- 3. 实例分析
- 3.1 malloc 到底干了啥
- 3.2 memset 的偷天换日
- 3.2.1 虚拟地址转物理地址
- 3.2.2 page fault
- 3.3 free 的无作为
- 4. 总结
- 5. 扩展:内存越界
1. 引言:一个例子
例1
void main()
{
char* buf = malloc(1024*1024*1024);
free(buf);
}
上面这段代码很简单,就做了两件事:申请1G的内存,然后释放。
思考下面几个问题:
- malloc调用之后,系统一定会从储存器中分配1G内存吗?
- 如果系统分配了1G内存,但我们什么都没干,就直接把内存还回去。申请和释放肯定会存在性能开销,操作系统会这么“傻”吗?
例2
void main()
{
char* buf = malloc(10);
char a = buf[11];
free(buf);
}
思考下面几个问题:
- 当程序运行到 “ char a = buf[11]” 时,会报错吗?
- 如果a = buf[n],n 逐渐增大,会发生什么?
我们将通过下面的内容来解答这些问题。
2. 基础概念
按照我们通常的理解,malloc 调用之后操作系统应该给我分配对应的内存,但实际上并不是这样的。操作系统做了很多“不为人知”的事情,让我们产生这样的错觉。
下面,我们先简单介绍操作系统内存管理的演变过程。
2.1 内存管理发展过程
内存管理的发展经过以下几个过程:
1、早期单任务系统
CPU 直接操作存储器。由于是单任务系统,整个存储器都是由单一任务独占,CPU 直接通过物理地址访问存储器的数据。
该方式的优点是实现简单;
缺点是不支持多任务。
2、固定内存分配
在存储器划分不同任务的地址空间,每个任务只能访问各自的地址空间。
该方式的优点是实现简单;
缺点是不适用任务过多的场景,存储器利用率较低,而且不利于程序移植
3、内存分页方式
对物理存储器做了一次软件抽象,抽象出来一个虚拟存储器的概念。
每一个任务存在一个独立的虚拟存储器,应用程序只需操作虚拟存储器。虚拟存储器到物理存储器的映射关系由操作系统来保证,不需要应用程序关心。
该方式的优点是存储器利用率高,用于程序易于开发和移植;
2.2 虚拟存储器
下面这个图,我们都应该比较熟悉。
一个32位操作系统的虚拟地址空间。低 3G 地址范围是用户空间,可以被用户态程序访问;高 1G 地址范围是内核空间,只能被内核访问。
用户空间从低地址往高地址分别为:
- 代码区,存放代码段的区域
- 静态存储区,存放静态数据的区域。比如全局变量等;
- 堆区,动态内存区域。malloc/new 申请 buffer 的区域;
- 共享区,mmap 或 动态库所在区域
- 栈区,临时变量区域
2.3 内存分配机制
从用户态的 malloc 调用到实际从存储器中申请到内存,需要经过3 层。我们从下到上介绍:
1、Buddy 系统
Buddy 系统是操作系统实现的物理内存分配机制。它最重要的特性是:
以页为单位,划分物理内存,一般一页为 4K 字节,所以 Buddy 申请内存最小粒度为 4K 字节。
2、C 库
由于 Buddy 申请内存以页为单位,如果我只申请 1 个字节,他也给我分配 4K 字节,那会造成浪费。因此在 Buddy 之上还需要对内存进行二次管理。
在用户态中,C 库就是这样一个内存二次管理者。它向下通过 brk/mmap 系统调用申请物理内存,向上层应用提供 malloc/free 接口。
3、中间的未知层
该层主要是通过 Buddy 系统申请物理内存,由 page fault 实现,请参考第 3.2 章。
疑问:这里的内存分配机制好像和前面的虚拟存储器没有什么关联啊?
答案是 VMA (Virtual Memory Area)。
2.4 VMA
VMA 是指在虚拟存储器上的一段虚拟内存,一个进程就是通过多个 VMA 构建了进程的虚拟地址空间。
2.4.1 进程的 VMA
如下图所示:
- 每一个进程在内核中都有一个 mm_struct 对象,主要用于管理该进程所有内存;
- mm_struct 结构体中存在一个 vm_area_struct 对象的链表,其中的每个节点是一个 VMA 区域(对应虚拟地址空间中的一段虚拟内存)
vm_area_struct 对象中几个关键变量:
- vm_start/vm_end 表示该 VMA 在虚拟地址空间的起始/结束地址;
- vm_flags 表示该 VMA 的可读、可写、可执行权限;
举几个例子,代码段的权限是可读可执行(RX),堆的权限是可读可写(RW)
2.4.2 vma 分析
Linux 系统中可以通过如下命令来查看进程的所有 VMA,其中 xxx 为进程 pid
cat /proc/xxx/maps
运行结果如下图所示,我们主要关注前两列:
- 第一列表示该 VMA 在虚拟地址空间的起始、结束地址,对应 vm_area_struct 对象中的vm_start/vm_end
- 第二列表示该 VMA 的 RWX 权限,对应 vm_flags
第一个 VMA (地址范围0x10000-0x11000)权限为RX,显然它是一个代码段。
3. 实例分析
下面我们通过一个简单的程序来分析整个流程。
这个程序只有三行代码:
- 申请10个字节的内存
- 初始化10个字节为0
- 释放这10个字节的内存
void main()
{
char* buf = malloc(10);
memset(buf, 0, 10);
free(buf);
}
3.1 malloc 到底干了啥
我们通过上一章节的命令分别在代码执行的三个位置获取 VMA 信息。
void main()
{
// 在此获取第一次 VMA
char* buf = malloc(10);
// 在此获取第二次 VMA
memset(buf, 0, 10);
free(buf);
// 在此获取第三次 VMA
}
1)malloc 之前的 VMA 信息
2)malloc 之后的 VMA 信息
相比于 malloc 前的 VMA 信息,增加了 0x13000-0x34000 地址范围的 VMA,这是一个堆空间的 VMA。
注意:这里 VMA 的大小,我们申请 10 个字节,但 VMA 远大于 10.
3) free 之后的 VMA 信息
我们可以发现,free 调用之后,堆 VMA 仍然存在。
通过对 VMA 信息的分析,我们可以得到以下信息:
malloc 之后,进程创建了一个堆空间的 VMA,但是否申请物理内存还不知道。
3.2 memset 的偷天换日
实际上,malloc 并不会真正的从存储器中申请内存,申请内存的操作是在第一次使用的时候进行的。
现代操作系统存在两大特征:局部性原理和 Copy On Write (写时复制)。
- 局部性原理是指在某一段时间内,CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中;
- Copy On Write 本质上也是基于局部性原理产生的,因为局部性原理,没有必要把进程所需的所有资源都加载,需要的时候再申请使用,这样也避免了资源的浪费。
因此,对于我们分析的例子来说,malloc 并不会从存储器分配内存,memset 才会。
CPU 执行 memset 函数的调用指令,可以简化为两个步骤:
- CPU 通过 MMU 将 memset 的起始地址,由虚拟地址转成物理地址;
- MMU 触发 page fault (缺页异常),申请一个物理内存页
3.2.1 虚拟地址转物理地址
当代 CPU 内部一般都集成了一个 MMU 硬件,它的主要功能是将虚拟地址转换成物理地址。
一般,物理页和虚拟页都是以 4K 字节为单位进行划分的,所以对于任意一个虚拟地址,低12位数据就是页内偏移。
1)CPU 拿到一个虚拟地址,先将地址分成两部分:虚拟页号 p 和 页内偏移 d;
比如 addr=0x12345678,虚拟页号为 p=0x12345,页内偏移 d=0x678
2)MMU 拿到虚拟页号 p 后,会在页表中查找虚拟页对应的物理页帧;页表包含进程内所有虚拟页和物理页的映射关系以及该页的权限。
- 如果在页表中能够查到虚拟页号 p 对应的物理页帧 f,则可以根据物理页帧 f 和页内偏移 d 找到对应的物理地址;
- 如果页表中不能查到虚拟页号与物理页帧的对应关系或权限不对,MMU 会产生 page fault 中断;
3.2.2 page fault
发生缺页中断有 4 种情况:
- 权限对,虚拟页和物理页映射关系不存在,对应图中第一种情况;
- 权限对,虚拟页和物理页映射关系不存在,虚拟地址不在合法的 VMA 区域,对应图中第二种情况;
- 权限不对,对应图中第三种情况;比如,改写一个const 变量。
- 权限对,虚拟页和物理页映射关系存在,对应图中第四种情况;
3.3 free 的无作为
在 3.1 章节时,我们已经发现 free 调用之后,VMA 仍然存在,这也说明了 free 可能并不会触发操作系统删除 VMA。
用户态的 malloc/free 实际上是 c 库提供的接口,是对系统调用 brk/mmap 的二次封装。c 库内部实现会有缓存,malloc 调用分为两种情况:
- 如果缓存 buffer 足够,c 库会从缓存中分配,而不会通过系统调用进入内核;
- 如果缓存不够,c 库会通过系统调用进入内核,并为进程创建一个 VMA;
同理,c 库实现的 free 也不会马上把释放的内存还给内核, free 调用也可以分为两种情况:
- 如果释放的 buffer 超过设定阈值,c 库会从通过系统调用进入内核,把内存还给内核,并删除 VMA;
- 如果释放的 buffer 没有超过设定阈值,不会进入内核,只会在 c 库中把 buffer 标记为释放状态;
注意:c 库中缓存的阈值可以通过 mallopt 函数设置。
4. 总结
void main()
{
char* buf = malloc(10);
memset(buf, 0, 10);
free(buf);
}
对于上面的代码:
1、malloc
只会创建一个 VMA,但不会真正申请 buffer;
2、memset
第一次使用 buffer 时,触发 page fault 申请物理内存
3、free
可能不会释放物理内存
5. 扩展:内存越界
内存越界一直都是程序员比较头疼的问题,尤其是在大型项目中,一旦出现内存越界,产生的现象可能会比较随机,无法定位。
通过 3.2.2 章,我们可以知道发生内存越界会有两种可能:对应 page fault 的情况 2 和情况 4。
-
如果是出现情况2,和直接触发 segment fault,可以根据程序崩溃产生的 coredump 信息定位到具体代码位置。
-
如果是情况4,则问题很难定位排查。比如下面的代码,操作 buf1 时,错误的处理了 buf2 的数据。
void main()
{
char* buf1 = malloc(10);
char* buf2 = malloc(60);
.....
memset(buf1, 5, 100); // 内存越界
free(buf1);
free(buf2);
}