malloc底层实现

xv6实现的动态内存分配虽然只有不到100行代码,但却体现了动态内存分配的精髓,非常值得学习

xv6的内存布局

一谈到C语言的动态内存分配,就会想到以及malloc()和free()这两个函数。先来回顾一下XV6中的内存布局是怎样的,我们动态分配的内存就在下图的heap部分:在这里插入图片描述

总体思路

在看源码之前,我们先来想想自己会如何设计malloc()和free()。

设计malloc(),我们需要给malloc()传入一个参数表示需要申请的内存块的大小,同时返回申请成功的内存块的首地址,在xv6中的系统调用sbrk()刚好就能满足这一需求。

设计free(),我们需要给free()传入一个内存块地址,让他能自动地释放掉对应的内存块,然而如果仅仅只有内存块地址的信息是不够的,删多少就是一个问题,解决办法就是给每一个内存块添加size这一属性;同时释放掉的内存我们也需要处理,不然那块内存就会被浪费,解决办法是使用链表管理所有可用的内存块

在这里插入图片描述

照着上面的思路,我们再来看看源码是如何实现这些功能的。

umalloc.c:

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/param.h"

// Memory allocator by Kernighan and Ritchie,
// The C programming Language, 2nd ed.  Section 8.7.

typedef long Align;

union header {
  struct {
    union header *ptr;
    uint size;
  } s;
  Align x;
};

typedef union header Header;

static Header base;
static Header *freep;

void
free(void *ap)
{
  Header *bp, *p;

  bp = (Header*)ap - 1;
  for(p = freep; !(bp > p && bp < p->s.ptr); p = p->s.ptr)
    if(p >= p->s.ptr && (bp > p || bp < p->s.ptr))
      break;
  if(bp + bp->s.size == p->s.ptr){
    bp->s.size += p->s.ptr->s.size;
    bp->s.ptr = p->s.ptr->s.ptr;
  } else
    bp->s.ptr = p->s.ptr;
  if(p + p->s.size == bp){
    p->s.size += bp->s.size;
    p->s.ptr = bp->s.ptr;
  } else
    p->s.ptr = bp;
  freep = p;
}

static Header*
morecore(uint nu)
{
  char *p;
  Header *hp;

  if(nu < 4096)
    nu = 4096;
  p = sbrk(nu * sizeof(Header));
  if(p == (char*)-1)
    return 0;
  hp = (Header*)p;
  hp->s.size = nu;
  free((void*)(hp + 1));
  return freep;
}

void*
malloc(uint nbytes)
{
  Header *p, *prevp;
  uint nunits;

  nunits = (nbytes + sizeof(Header) - 1)/sizeof(Header) + 1;
  if((prevp = freep) == 0){
    base.s.ptr = freep = prevp = &base;
    base.s.size = 0;
  }
  for(p = prevp->s.ptr; ; prevp = p, p = p->s.ptr){
    if(p->s.size >= nunits){
      if(p->s.size == nunits)
        prevp->s.ptr = p->s.ptr;
      else {
        p->s.size -= nunits;
        p += p->s.size;
        p->s.size = nunits;
      }
      freep = prevp;
      return (void*)(p + 1);
    }
    if(p == freep)
      if((p = morecore(nunits)) == 0)
        return 0;
  }
}

不看函数,先看数据。

header

union header {
  struct {
    union header *ptr;	// 指向下一个内存块
    uint size;
  } s;
  Align x;
};

前面提到内存块的size和链表就是由header实现的,他是umalloc里的核心数据结构

freep

static Header base;
static Header *freep;

freep表示指向空闲链表任意节点的指针,base用于初始化freep

内存池模型

在看源码之前先看一下xv6动态内存分配时的内存池模型,这样更助于代码理解。
在这里插入图片描述
从图中可以看到:

  • 每一个申请的内存块都不是单独存在的,而是由header+数据载荷构成
  • 空闲的内存块会被加入空闲链表中管理(使用中的不会加入链表管理),并且空闲链表是一个环形链表
  • freep指向其中一个空闲内存块

下面的源码结合这张图理解效果最佳

malloc()

malloc()用于申请指定大小的内存块,并返回内存块首地址

void*
malloc(uint nbytes)
{
  Header *p, *prevp;
  uint nunits;
  // 计算申请nbytes空间需要多少Header结构(向上取整)
  nunits = (nbytes + sizeof(Header) - 1)/sizeof(Header) + 1;	
  // 初始化空闲链表
  if((prevp = freep) == 0){	
    base.s.ptr = freep = prevp = &base;	// 使用base进行初始化
    base.s.size = 0;
  }
  // 遍历空闲链表
  for(p = prevp->s.ptr; ; prevp = p, p = p->s.ptr){
  	// 内存池中是否有满足条件的内存块
    if(p->s.size >= nunits){
      if(p->s.size == nunits)
        prevp->s.ptr = p->s.ptr;
      else {
        p->s.size -= nunits;
        p += p->s.size;
        p->s.size = nunits;
      }
      freep = prevp;
      return (void*)(p + 1);
    }
    // 内存池没有则morecore申请新的内存块
    if(p == freep)
      if((p = morecore(nunits)) == 0)
        return 0;
  }
}

计算需要申请的内存大小

nunits = (nbytes + sizeof(Header) - 1)/sizeof(Header) + 1;

在umalloc中所有的内存块都是以header为单位进行分配的(也就是说我们的内存块占用空间是n header,而非n byte),目的是以header个单位对齐。

这里需要注意的是,在从byte转换为header时,两数相除需要向上取整才能容纳所有的数据。

向下取整:a/b eg.13 / 3 = 4
向上取整:(a-1)/b + 1 eg.(13 + 3 - 1) / 3 = 5

所以(nbytes + sizeof(Header) - 1)/sizeof(Header)计算出了数据载荷占用的header个数后,还需要+1加上一个header。

初始化空闲链表

 if((prevp = freep) == 0){	
    base.s.ptr = freep = prevp = &base;	// 使用base进行初始化
    base.s.size = 0;
  }

空闲链表只有在最开始会被初始化,判断条件就是freep为0的话就是还未初始化;紧接着会将base的地址赋值给prevp,freep(base是位于data段的一个静态变量,由编译器分配地址);同时base的地址还会赋值给base.s.ptr,环形链表就是在这里形成的。

遍历空闲链表

  for(p = prevp->s.ptr; ; prevp = p, p = p->s.ptr){
  	// 内存池中是否有满足条件的内存块
    if(p->s.size >= nunits){
      if(p->s.size == nunits)
        prevp->s.ptr = p->s.ptr;
      else {
        p->s.size -= nunits;
        p += p->s.size;
        p->s.size = nunits;
      }
      freep = prevp;
      return (void*)(p + 1);
    }
    // 内存池没有则morecore申请新的内存块
    if(p == freep)
      if((p = morecore(nunits)) == 0)
        return 0;
  }

遍历空闲链表中的空闲内存块,查看空闲链表(内存池)中是否有满足条件(空闲内存块大小>= 待申请内存块大小)的内存块:

  • 有则将此内存块拿去用,这里涉及链表节点以及大小的调整。
  • 没有则通过morecore申请新的内存块。因为他是从freep的后一个节点开始遍历的,遍历一圈结束时也就是p == freep的时候。

我们不会将以前的内存块彻底删除,而是将他留下,作为内存池的一部分重复使用。这样的好处是减少了申请新内存块的开销:

  • 空间上,不需要额外的空间;
  • 时间上:免去了物理地址到虚拟地址空间的映射,特别是对于lazy allocation的场合,频繁的申请新的地址空间特别耗时。

morecore()

morecore()用于申请新的内存块

static Header*
morecore(uint nu)
{
  char *p;
  Header *hp;

  if(nu < 4096)
    nu = 4096;						// 设置最小分配单位为4096个header
  p = sbrk(nu * sizeof(Header));	// 分配新的内存块
  if(p == (char*)-1)
    return 0;
  hp = (Header*)p;
  hp->s.size = nu;
  free((void*)(hp + 1));			// 将新内存加入空闲链表
  return freep;
}

morecore()会设置最小的分配单位为4096个header,可能是尽量分配大空间,因为小空间的内存块更容易产生内存碎片(猜测);然后通过sbrk()系统调用申请新的内存块,最后将新申请的这个内存块通过free()加入空闲链表,他会在malloc的下次循环被使用。

free()

free()用于释放内存块,同时还会合并空闲内存

void
free(void *ap)
{
  Header *bp, *p;
  // 获取内存块对应的header地址
  bp = (Header*)ap - 1;		
  // 遍历内存块的位置(位于哪两个空闲内存之间)
  for(p = freep; !(bp > p && bp < p->s.ptr); p = p->s.ptr)	
    if(p >= p->s.ptr && (bp > p || bp < p->s.ptr))
      break;
  // 检查并合并相邻的内存块
  if(bp + bp->s.size == p->s.ptr){
    bp->s.size += p->s.ptr->s.size;
    bp->s.ptr = p->s.ptr->s.ptr;
  } else
    bp->s.ptr = p->s.ptr;
  if(p + p->s.size == bp){
    p->s.size += bp->s.size;
    p->s.ptr = bp->s.ptr;
  } else
    p->s.ptr = bp;
  // 更新指向空闲内存块的指针
  freep = p;
}

free()首先遍历当前内存块的位置,它需要获得它相邻空闲链表节点的位置,这样才能进行之后的操作;然后它会检查当前内存块的大小是否满足合并的条件,分别和前后内存块比较,不能合并则重定向链表指向。

Linux中的malloc()实现

Linux中的动态内存分配策略有些许不一样:

  • malloc小于128k的内存,使用brk分配内存
  • malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配(对应独立内存,而且初始化为0)

之所以使用brk来分配小内存,是因为brk是在堆空间这块连续区域上申请内存,频繁的brk申请大内存很容易导致内存的碎片化;而mmap不用担心这个问题,因为他是在堆和栈上的一片独立的区域分配的内存,不会有内存碎片的问题。

看起来mmap很有用,那为什么还需要brk来申请小内存呢?
这就是我们上面总结的知识了。brk并不是每次都需要申请新内存,而是可以从内存池中重用以前申请的内存,再次访问该内存不需要任何系统调用和缺页中断,开销极小,相比mmap每次都是重新申请要方便许多。

相关推荐

  1. [C语言] 使用数组模拟实现malloc

    2024-07-17 20:02:03       50 阅读
  2. Android malloc_debug实现原理及使用

    2024-07-17 20:02:03       29 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-17 20:02:03       67 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-17 20:02:03       72 阅读
  3. 在Django里面运行非项目文件

    2024-07-17 20:02:03       58 阅读
  4. Python语言-面向对象

    2024-07-17 20:02:03       69 阅读

热门阅读

  1. XML详解

    2024-07-17 20:02:03       17 阅读
  2. Vue进阶之Vue无代码可视化项目(六)

    2024-07-17 20:02:03       19 阅读
  3. 209.长度最小的子数组 数组 双指针 滑动窗口

    2024-07-17 20:02:03       18 阅读
  4. notes for datawhale 2th summer camp NLP task2

    2024-07-17 20:02:03       21 阅读
  5. linux学习笔记整理: 关于linux系统介绍 2024/7/16;

    2024-07-17 20:02:03       20 阅读
  6. 单例模式-C#

    2024-07-17 20:02:03       17 阅读