malloc的底层实现原理

        

目录

基于Linux操作系统malloc申请内存的实现原理

1、malloc分配内存前的初始化:

2、下为malloc_init()代码:

3、内存块的获取

(1)内存块的大致结构:

(2)寻找合适的block

(3)扩容

4、内存分配,下为内存分配代码

malloc()和free()的分配算法


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

        那么,使用 malloc() 在堆上分配内存到底是如何实现的呢?

        一种做法是把 malloc() 的内存管理交给系统内核去做,既然内核管理着进程的地址空间,那么如果它提供一个系统调用,可以让 malloc() 使用这个系统调用去申请内存,不就可以了吗?当然这是一种理论上的做法,但实际上这样做的性能比较差,因为每次程序申请或者释放堆空间都要进行系统调用。我们知道系统调用的性能开销是比较大的,当程序对堆的操作比较频繁时,这样做的结果会严重影响程序的性能。

        比较好的做法就是 malloc() 向操作系统申请一块适当大小的堆空间,然后由 malloc() 自己管理这块空间。

        malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。当然 malloc() 在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。于是 malloc() 需要一个算法来管理堆空间,这个算法就是堆的分配算法。

基于Linux操作系统malloc申请内存的实现原理

        malloc在申请内存是在堆中分配内存的,我们在之前的文章说到了很多次堆这个名字,那么究竟堆在内存中长什么样的,下面就是小编画的一个简化图,在32位系统中,寻址空间只有4GB,在Linux系统下0~3GB是用户模式,3~4GB是内核模式。而在用户模式下又分为了代码段、数据段、BSS段、DATA段、堆、栈。各个段所代表的含义在图中已经说明。其中的BSS段存放未初始化的全局变量和局部静态变量。数据段存放已经初始化的全局变量和局部静态变量。至于局部变量存放在栈中。而堆(heap)段位于bss下方,而其中有两个重要的标志:(程序溢出brk)programe break。Linux维护一个break指针,这个指针指向对空间的某个地址。从堆起始地址到break之间的地址空间为映射好的,可以供进程访问;而从break往上,是未映射的地址空间,如果访问这段空间则程序会报错。我们用malloc进行内存分配就是从break往上进行的。

        进程所面对的虚拟内存地址空间,只有按页映射到物理内存地址,才能真正使用。受物理存储容量限制,整个堆虚拟内存空间不可能全部映射到实际的物理内存,Linux对堆的管理示意图如下:

 获取了break地址,也就是内存申请的起始地址,下面就是malloc的整体实现方案:

        调用malloc 函数的实质是它有一个将可用的内存块连接为一个长长的列表的所谓空闲链表。 调用 malloc()函数时,它沿着连接表寻找一个大到足以满足用户请求所需要的内存块。 然后,将该内存块一分为二(一块的大小与用户申请的大小相等,另一块的大小就是剩下来的字节)。 接下来,将分配给用户的那块内存存储区域传给用户,并将剩下的那块(如果有的话)返回到连接表上。

        调用 free 函数时,它将用户释放的内存块连接到空闲链表上。 到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段, 那么空闲链表上可能没有可以满足用户要求的片段了。于是,malloc()函数请求延时,并开始在空闲链表上检查各内存片段,对它们进行内存整理,将相邻的小空闲块合并成较大的内存块。

1、malloc分配内存前的初始化:

        malloc_init 是初始化内存分配程序的函数。 它完成以下三个目的:将分配程序标识为已经初始化,找到操作系统中最后一个有效的内存地址,然后建立起指向需要管理的内存的指针。这里需要用到三个全局变量。

        malloc_init 分配程序的全局变量:

int has_initialized = 0; /* 初始化标记 */

void *managed_memory_start; /* 管理内存起始地址 */

void *last_valid_address; /* 操作系统的最后一个有效地址*/

        被映射的内存边界(操作系统最后一个有效地址)常被称为系统中断点或者当前中断点。为了指出当前系统中断点,必须使用 sbrk(0) 函数。 sbrk 函数根据参数中给出的字节数移动当前系统中断点,然后返回新的系统中断点。 使用参数 0 只是返回当前中断点。 这里给出 malloc()初始化代码,它将找到当前中断点并初始化所需的变量:

        Linux通过brk和sbrk系统调用操作break指针。两个系统调用的原型如下:

int brk(void *addr);
void *sbrk(intptr_t increment);

        brk将break指针直接设置为某个地址,而sbrk将break从当前位置移动increment所指定的增量。brk在执行成功时返回0,否则返回-1并设置errno为ENOMEM;sbrk成功时返回break移动之前所指向的地址,否则返回(void *)-1。如果将increment设置为0,则可以获得当前break的地址。

2、下为malloc_init()代码:

可以看到使用sbrk(0)来获得break地址。

#include <unistd.h> /*sbrk 函数所在的头文件 */
void malloc_init()
{
last_valid_address = sbrk(0); /* 用 sbrk 函数在操作系统中
取得最后一个有效地址 */
managed_memory_start = last_valid_address; /* 将 最 后 一 个
有效地址作为管理内存的起始地址 */
has_initialized = 1; /* 初始化成功标记 */
}

3、内存块的获取

所要申请的内存是由多个内存块构成的链表。

(1)内存块的大致结构

        每个块由meta区和数据区组成,meta区记录数据块的元信息(数据区大小、空闲标志位、指针等等),数据区是真实分配的内存区域,并且数据区的第一个字节地址即为malloc返回的地址。

typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block next; /* 指向下个块的指针 */
int free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};

        现在,为了完全地管理内存,我们需要能够追踪要分配和回收哪些内存。在对内存块进行了 free 调用之后,我们需要做的是诸如将它们标记为未被使用的等事情,并且,在调用 malloc 时,我们要能够定位未被使用的内存块。因此, malloc 返回的每块内存的起始处首先要有这个结构:

struct mem_control_block
{    
 int is_available;//是否空闲
  int size; //内存块大小
};

(2)寻找合适的block

        现在考虑如何在block链中查找合适的block。一般来说有两种查找算法:

  • First fit:从头开始,使用第一个数据区大小大于要求size的块所谓此次分配的块
  • Best fit:从头开始,遍历所有块,使用数据区大小大于size且差值最小的块作为此次分配的块

  两种方法各有千秋,best fit具有较高的内存使用率(payload较高),而first fit具有更好的运行效率。

        find_block从frist_block开始,查找第一个符合要求的block并返回block起始地址,如果找不到这返回NULL。这里在遍历时会更新一个叫last的指针,这个指针始终指向当前遍历的block。这是为了如果找不到合适的block而开辟新block使用的。

(3)扩容

        如果现有block都不能满足size的要求,则需要在链表最后开辟一个新的block。下为利用sbrk()创建新的block示意代码:

#define BLOCK_SIZE 24 /* 由于存在虚拟的data字段,sizeof不能正确计算meta长度,这里手工设置 */

t_block extend_heap(t_block last, size_t s) {
t_block b;
b = sbrk(0);
if(sbrk(BLOCK_SIZE + s) == (void *)-1)
return NULL;
b->size = s;
b->next = NULL;
if(last)
last->next = b;
b->free = 0;
return b;
}

4、内存分配,下为内存分配代码

void *malloc(long numbytes)
{
    void *current_location;
    struct mem_control_block *current_location_mcb;
    void *memory_location;
 if(! has_initialized)
  {
        malloc_init();
   }
   numbytes = numbytes + sizeof(struct mem_control_block);
   memory_location = 0;
   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)
 {
       sbrk(numbytes);
     memory_location = last_valid_address;
       last_valid_address = last_valid_address + numbytes;
     current_location_mcb = 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;
}

malloc()和free()的分配算法

        在程序运行过程中,堆内存从低地址向高地址连续分配,随着内存的释放,会出现不连续的空闲区域,如下图所示(已分配内存和空闲内存相间出现):

        带阴影的方框是已被分配的内存,白色方框是空闲内存或已被释放的内存。程序需要内存时,malloc() 首先遍历空闲区域,看是否有大小合适的内存块,如果有,就分配,如果没有,就向操作系统申请(发生系统调用)。为了保证分配给程序的内存的连续性,malloc() 只会在一个空闲区域中分配,而不能将多个空闲区域联合起来。

        内存块(包括已分配和空闲的)的结构类似于链表,它们之间通过指针连接在一起。在实际应用中,一个内存块的结构如下图所示:

        next 是指针,指向下一个内存块,used 用来表示当前内存块是否已被使用。这样,整个堆区就会形成如下图所示的链表(类似链表的内存管理方式):

        现在假设需要为程序分配100个字节的内存,当搜索到图中第一个空闲区域(大小为200个字节)时,发现满足条件,那么就在这里分配。这时候 malloc() 会把第一个空闲区域拆分成两部分,一部分交给程序使用,剩下的部分任然空闲,如下图所示(为程序分配100个字节的内存):

        当程序释放掉第二和三个内存块时,就会形成新的空闲区域,free() 会将第二、三、四个连续的空闲区域合并为一个,如下图所示:

 

 

        可以看到,malloc() 和 free() 所做的工作主要是对已有内存块的分拆和合并,并没有频繁地向操作系统申请内存,这大大提高了内存分配的效率。

        另外,由于单向链表只能向一个方向搜索,在合并或拆分内存块时不方便,所以大部分 malloc() 实现都会在内存块中增加一个 pre 指针指向上一个内存块,构成双向链表,如下图所示:

 

        链表是一种经典的堆内存管理方式,经常被用在教学中,很多C语言教程都会提到“栈内存的分配类似于数据结构中的栈,而堆内存的分配却类似于数据结构中的链表”就是源于此。

        链表式内存管理虽然思路简单,容易理解,但存在很多问题,例如:

  • 一旦链表中的 pre 或 next 指针被破坏,整个堆就无法工作,而这些数据恰恰很容易被越界读写所接触到。
  • 小的空闲区域往往不容易再次分配,形成很多内存碎片。
  • 经常分配和释放内存会造成链表过长,增加遍历的时间。

        针对链表的缺点,后来人们提出了位图和对象池的管理方式,而现在的 malloc() 往往采用多种方式复合而成,不同大小的内存块往往采用不同的措施,以保证内存分配的安全和效率。

版权声明:本文为VonSdite原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值