你不知道的 malloc 内幕:malloc 真的会分配内存吗

1. 引言:一个例子

例1

void main()
{
	char* buf = malloc(1024*1024*1024);
	free(buf);
}

上面这段代码很简单,就做了两件事:申请1G的内存,然后释放。

思考下面几个问题:

  1. malloc调用之后,系统一定会从储存器中分配1G内存吗?
  2. 如果系统分配了1G内存,但我们什么都没干,就直接把内存还回去。申请和释放肯定会存在性能开销,操作系统会这么“傻”吗?

例2

void main()
{
	char* buf = malloc(10);
	char a = buf[11];
	free(buf);
}

思考下面几个问题:

  1. 当程序运行到 “ char a = buf[11]” 时,会报错吗?
  2. 如果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 信息
第一次 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);
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值