malloc 的实现原理

简言

由于面试题会问到 malloc 的底层原理,今天就来记录一下,毕竟学习要 “知其所以然”,这样才会胸有成竹。

Linux 的虚拟内存管理有几个关键概念:

1. 每个进程都有独立的虚拟地址空间,进程访问的虚拟地址并不是真正的物理地址;
2. 虚拟地址可通过每个进程上的页表(在每个进程的内核虚拟地址空间)与物理地址进行映射,获得真正物理地址;
3. 如果虚拟地址对应物理地址不在物理内存中,则产生缺页中断,真正分配物理地址,同时更新进程的页表;如果此时物理内存已耗尽,则根据内存替换算法淘汰部分页面至物理磁盘中。

先说结论

1. 当开辟的空间小于 128K 时,调用 brk()函数,malloc 的底层实现是系统调用函数 brk(),先遍历空闲内存链表,如果有已释放且空间足够的内存块,就拿来用。如果找不到就移动指针 _enddata(此时的 _enddata 指的是 Linux 地址空间中堆段的末尾地址,不是数据段的末尾地址)
2. 当开辟的空间大于 128K 时,mmap()系统调用函数来在虚拟地址空间中(堆和栈中间,称为“文件映射区域”的地方)找一块空间来开辟

malloc()到底从哪里得到了内存空间?

答案:是从堆里面获得空间。也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。 当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个已释放,且空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。

malloc()在运行期动态分配分配内存,free()释放由其分配的内存。malloc()在分配用户传入的大小的时候,还分配的一个相关的用于管理的额外内存

这个用于管理的内存块定义如下,大小是8字节

struct mem_control_block

{

        int is_available;      // 1表可用,0表不可用

        int size;                 // 总大小

};

所以,实际的内存大小 = 8 + 用户申请内存

堆中的内存块总是成块分配的,并不是申请多少字节,就拿出多少个字节的内存来提供使用。 堆中内存块的大小通常与内存对齐有关(8Byte(for 32bit system)或16Byte(for 64bit system)。

因此,在64位系统下,当(申请内存大小 + 8 )% 16 == 0的时候,刚好可以完成一次满额的分配,但是当其不为0的时候,就会多分配内存块。

而且:64位系统下,即使用malloc()申请1字节,系统也会为你申请32字节,这个算是内存申请最小值了

实验1:使用malloc()申请24字节,代码如下

#include 
#include 
#include 
#include 
int main()
{
    char* p = NULL;
    long long lastaddr = 0;
    int i = 0;
    while(i<5)
    {
        p = (char*)malloc(24);
        printf("%d:%10p, size:%lu \n", i++, p, reinterpret_cast(p)-lastaddr);
        lastaddr = reinterpret_cast(p);
    }
    return 0;
}

结果如下:(可以看到,实际间隔32字节,32=24+sizeof(mem_control_block),恰好取余16==0,所以只申请了32字节)

malloc 的实现原理_第1张图片

如果代码中malloc(24)改为malloc(25),也就是说申请25个字节,由于25+8=33字节,取余16不为0,所以只好多申请一整块小内存,64位下最小块内存16字节,也就是说一共申请48字节

如下图:

malloc 的实现原理_第2张图片

 如何查看进程发生缺页中断的次数?

用 ps -o majflt,minflt -C  命令查看

其中 majflt 即major fault 表大错误;minflt 即minor fault 表小错误

我们需要重点关注majflt的值,因为相比minflt,majflt对于性能的损害是致命的,随机读一次磁盘的耗时数量级在几个毫秒,而minflt只有在大量的时候才会对性能产生影响

当一个进程发生缺页中断(要访问的页不在主存,需要操作系统将其调入主存后再进行访问)的时候,进程会陷入核心态,执行以下操作:

1. 检查要访问的虚拟地址是否合法
2. 查找/分配一个物理页
3. 填充物理页内容(读取磁盘,或者直接置0,或者什么都不做)
4. 建立映射关系(虚拟地址到物理地址的映射关系)
5. 重复执行发生缺页中断的那条指令

如果第3步,需要读取磁盘,那么这次缺页就是 majfit(大错误),否则就是 minflt(小错误)

内存分配的原理

从操作系统角度看,进程分配内存有两种方式,分别由两个系统调用完成:brk 和 mmap (不考虑共享内存)

  1. brk 是将数据段(.data)的最高地址指针 _edata 往高地址推
  2. mmap 是在进程的虚拟地址空间中(堆和栈中间,称为“文件映射区域”的地方)找一块空闲的虚拟内存

这两种方式分配的都是虚拟内存,并没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,会发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系

具体分配过程

情况一:malloc 小于 128K 的内存,使用 brk() 函数分配

将_edata往高地址推(只分配虚拟空间,不对应物理内存(因此没有初始化),等到第一次读/写数据时,引起内核缺页中断,内核才分配对应的物理内存,然后虚拟地址空间建立映射关系),如下图:

1. 进程启动的时候,其(虚拟)内存空间的初始布局如图1所示
malloc 的实现原理_第3张图片

2. 进程调用A=malloc(30K)以后,内存空间如上图2:
malloc函数会调用brk系统调用,将_edata指针往高地址推30K,就完成虚拟内存分配

你可能会问:难道这样就完成内存分配了?

事实是:_edata+30K只是完成虚拟地址的分配,A这块内存现在还是没有物理页与之对应的,等到进程第一次读/写A这块内存的时候,发生缺页中断,这个时候,内核才分配A这块内存对应的物理页。也就是说,如果用malloc分配了A这块内容,然后从来不访问它,那么,A对应的物理页是不会被分配的。

3. 进程调用B=malloc(40K)以后,内存空间如上图3
 

情况二:malloc 大于 128K 的内存,使用 mmap 分配(munmap 释放)

4. 现在进程调用malloc(200K)以后,内存空间如图4所示

malloc 的实现原理_第4张图片

默认情况下,malloc函数分配内存,如果请求内存大于128K(可由M_MMAP_THRESHOLD选项调节),那就不是去推_edata指针了,而是利用mmap系统调用,从堆和栈的中间分配一块虚拟内存

这样做主要是因为:

brk分配的内存需要等到高地址内存释放以后才能释放(例如,在B释放之前,A是不可能释放的,因为只有一个_edata 指针,这就是内存碎片产生的原因,什么时候紧缩看下面),而mmap分配的内存可以单独释放。

5,进程调用D=malloc(100K)以后,内存空间如上图5

6,进程调用free(C)以后,C对应的虚拟内存和物理内存一起释放

malloc 的实现原理_第5张图片

7,进程调用free(B)以后,如图7所示

  B对应的虚拟内存和物理内存都没有释放,因为只有一个_edata指针,如果往回推,那么D这块内存怎么办呢?当然,B这块内存,是可以重用的,如果这个时候再来一个40K的请求,那么malloc很可能就把B这块内存返回回去了

8,进程调用free(D)以后,如图8所示

  B和D连接起来,变成一块140K的空闲内存

9,默认情况下:

  当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。 在上一个步骤free的时候,发现最高地址空闲内存超过128K,于是内存紧缩,变成图9所示

提问

问题1:既然堆内内存brk和sbrk不能直接释放,为什么不全部使用 mmap 来分配,munmap直接释放呢?
问题2:既然堆内碎片不能直接释放,导致疑似“内存泄露”问题,为什么 malloc 不全部使用 mmap 来实现呢(mmap分配的内存可以会通过 munmap 进行 free ,实现真正释放)?而是仅仅对于大于 128k 的大块内存才使用 mmap ?

解答

其实,进程向 OS 申请和释放地址空间的接口 sbrk/mmap/munmap 都是系统调用,频繁调用系统调用都比较消耗系统资源的。并且, mmap 申请的内存被 munmap 后,重新申请会产生更多的缺页中断。例如使用 mmap 分配 1M 空间,第一次调用产生了大量缺页中断 (1M/4K 次 ) ,当munmap 后再次分配 1M 空间,会再次产生大量缺页中断。缺页中断是内核行为,会导致内核态CPU消耗较大。 另外,如果使用 mmap 分配小内存,会导致地址空间的分片更多,内核的管理负担更大。

同时堆是一个连续空间,并且堆内碎片由于没有归还 OS ,如果可重用碎片,再次访问该内存很可能不需产生任何系统调用和缺页中断,这将大大降低 CPU 的消耗。 因此, glibc 的 malloc 实现中,充分考虑了 sbrk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128k) 才使用 mmap 获得地址空间,也可通过 mallopt(M_MMAP_THRESHOLD, ) 来修改这个临界值

malloc、free源码

malloc()函数实现

/**内存控制块数据结构,用于管理所有的内存块
* is_available: 标志着该块是否可用。1表示可用,0表示不可用
* size: 该块的大小
**/
struct mem_control_block {
    int is_available;
    int size;
};

/**在实现malloc时要用到linux下的全局变量
*managed_memory_start:该指针指向进程的堆底,也就是堆中的第一个内存块
*last_valid_address:该指针指向进程的堆顶,也就是堆中最后一个内存块的末地址
**/
void *managed_memory_start;
void *last_valid_address;

/**malloc()功能是动态的分配一块满足参数要求的内存块
*numbytes:该参数表明要申请多大的内存空间
*返回值:函数执行结束后将返回满足参数要求的内存块首地址,要是没有分配成功则返回NULL
**/
void *malloc(size_t numbytes) {
    //游标,指向当前的内存块
    void *current_location;
    //保存当前内存块的内存控制结构
    struct mem_control_block *current_location_mcb;
    //保存满足条件的内存块的地址用于函数返回
    void *memory_location;
    memory_location = NULL;
    //计算内存块的实际大小,也就是函数参数指定的大小+内存控制块的大小
    numbytes = numbytes + sizeof(struct mem_control_block);
    //利用全局变量得到堆中的第一个内存块的地址
    current_location = managed_memory_start;

    //对堆中的内存块进行遍历,找合适的内存块
    while (current_location != last_valid_address) //检查是否遍历到堆顶了
    {
        //取得当前内存块的内存控制结构
        current_location_mcb = (struct mem_control_block*)current_location;
        //判断该块是否可用
        if (current_location_mcb->is_available)
            //检查该块大小是否满足
            if (current_location_mcb->size >= numbytes)
            {
                //满足的块将其标志为不可用
                current_location_mcb->is_available = 0;
                //得到该块的地址,结束遍历
                memory_location = current_location;
                break;
            }
        //取得下一个内存块
        current_location = current_location + current_location_mcb->size;
    }

    //在堆中已有的内存块中没有找到满足条件的内存块时执行下面的函数
    if (!memory_location)
    {
        //向操作系统申请新的内存块
        if (sbrk(numbytes) == -1)
            return NULL;//申请失败,说明系统没有可用内存
        memory_location = last_valid_address;
        last_valid_address = last_valid_address + numbytes;
        current_location_mcb = (struct mem_control_block)memory_location;
        current_location_mcb->is_available = 0;
        current_location_mcb->size = numbytes;
    }
    //到此已经得到所要的内存块,现在要做的是越过内存控制块返回内存块的首地址
    memory_location = memory_location + sizeof(struct mem_control_block);
    return memory_location;
}

free()函数实现

/**free()功能是将参数指向的内存块进行释放
*firstbyte:要释放的内存块首地址
*返回值:空
**/
void free(void *firstbyte)
{
    struct mem_control_block *mcb;
    //取得该块的内存控制块的首地址
    mcb = firstbyte - sizeof(struct mem_control_block);
    //将该块标志设为可用
    mcb->is_available = 1;
    return;
}

你可能感兴趣的:(知识积累,malloc原理,free原理,malloc详解,malloc源码)