Skip to content

1. 内存分配

1.1 如何分配内存

malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。

malloc 申请内存的时候,会有两种方式向操作系统申请堆内存:

  • 通过brk()系统调用从堆分配内存
  • 通过mmap()系统调用在文件映射区域分配内存

malloc() 分配的是虚拟内存

  • 如果分配之后的虚拟内存没有被访问,那么就不会映射也不会占用物理内存了
  • 如果被访问的话,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。

1.1.1 brk() 函数

通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。

1.1.2 mmap()函数

通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。

1.1.3 阈值

malloc() 源码里默认定义了一个阈值:

  • 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
  • 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;

Warning

不同的 glibc 版本定义的阈值也是不同的。

1.2 会分配多大的虚拟内存

malloc() 在分配内存的时候,会预分配更大的空间作为内存池

预分配多大的空间,跟 malloc 使用的内存管理器有关系,我们就以 malloc 默认的内存管理器(Ptmalloc2)来分析。

实验代码,glibc 库的版本是 2.17:

#include <stdio.h>
#include <malloc.h>

int main() {
  printf("使用 cat /proc/%d/maps查看内存分配\n",getpid());

  //申请 1 字节的内存
  void *addr = malloc(1);
  printf("此 1 字节的内存起始地址:%x\n", addr);
  printf("使用 cat /proc/%d/maps查看内存分配\n",getpid());

  //将程序阻塞,当输入任意字符时才往下执行
  getchar();

  //释放内存
  free(addr);
  printf("释放了 1 字节的内存,但 heap 堆并不会释放\n");

  getchar();
  return 0;
}

执行,输出:

# ./alloc_addr此 1 字节的内存起始地址:d73010
使用 cat /proc/3191/maps查看内存分配

查看进程的内存分布情况:

# cat /proc/3191/maps | grep d730
00d73000-00d94000 rw-p 00000000 00:00 0                                  [heap]

这个例子分配的内存小于 128 KB,所以是通过 brk() 系统调用向堆空间申请的内存,因此可以看到最右边有 [heap] 的标识。 如果是通过 mmap 以匿名映射的方式从文件映射区分配的匿名内存,最右边是没有 [head] 标志的。

可以看到,堆空间的内存地址范围是 00d73000-00d94000,这个范围大小是 132KB,也就说明了 malloc(1) 实际上预分配 132K 字节的内存。

程序里打印的内存起始地址是 d73010,而 maps 文件显示堆内存空间的起始地址是 d73000,多出来的这 0x10 16 字节 是用来存储内存块信息的,通过向左偏移 16 字节就可以获取这块内存的信息。

1.3 free 释放内存

  • 通过 brk 方式申请的内存,通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用。
  • 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。

malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节,多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。

这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,就知道要释放多大的内存了。

1.4 全部使用 mmap 来分配内存?

向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。

  1. 如果都用 mmap 来分配内存,等于每次都要执行系统调用。
  2. 因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。

频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大

为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。

等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗

1.5 全部使用 brk 来分配内存?

如果连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。

但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。

因此,随着系统频繁地 malloc 和 free,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存碎片”。而这种“泄露”现象使用 valgrind 是无法检测出来的(Valgrind 是一个开源的内存调试和性能分析工具集,它主要用于帮助开发人员发现和调试程序中的内存错误、内存泄漏以及性能问题)。

另外,mmap 还可以用于将文件映射到内存中,这种方式可以实现文件和内存之间的直接读写,提高了 IO 性能。

所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。