内存分配不再神秘:深入剖析malloc函数实现原理与机制

简介: 内存分配不再神秘:深入剖析malloc函数实现原理与机制

前言:内存是计算机中必不可少的资源,因为 CPU 只能直接读取内存中的数据,所以当 CPU 需要读取外部设备(如硬盘)的数据时,必须先把数据加载到内存中。

内存分配有三种方式:

  1. 从静态存储区分配,生命周期随程序的结束而结束,比如全局变量,静态变量。
  2. 从栈空间分配,函数调用结束后自动释放。
  3. 从堆空间分配,即动态内存开辟,如malloc、calloc、realloc。

一、malloc函数

谈到malloc函数相信学过c语言的人都很熟悉,但是malloc底层到底做了什么又有多少人知道。

1.1关于malloc相关的几个函数

关于malloc我们进入Linux man一下就会得到如下结果:

SYNOPSIS
#incLude <stdlib. h>
void *calloc( size_ _t nmemb,
size_t size);
void *malloc(size t size);
void free(void *ptD);
void *realloc(void *ptr, size_ t size);
DESCRIPTION
calloc()
allocates memory for an array of nmemb elements of size bytes
each and ! returns a pointer to the allocated memory; The memory is set
to
zero,
If nmemb or size is 0,then callocO) returns either NULL, or
a unique pointer value that can later be successfully passed to free().
malloc( )
allocates size bytes and returns a pointerto the allocated
memory. The memory is not cleared.
If size
is 0,
then malloc()
returns
either NULL: or a unique pointer value that canlater be suc-
cessfully passed to free().

也可以这样认为(window下)原型:

extern void *malloc(unsigned int num_bytes);

头文件:

#include<malloc.h>或者#include<alloc.h>两者的内容是完全一样的

如果分配成功:则返回指向被分配内存空间的指针,不然返回指针NULL 。同时,当内存不再使用的时候,应使用free()函数将内存块释放掉。

关于:void*,表示未确定类型的指针,c,c++规定void*可以强转为任何其他类型的指针,关于void还有一种说法就是其他任何类型都可以直接赋值给它,无需进行强转,但是反过来不可以 。

malloc:

malloc分配的内存大小至少为参数所指定的字节数,malloc的返回值是一个指针,指向一段可用内存的起始位置,指向一段可用内存的起始地址,多次调用malloc所分配的地址不能有重叠部分,除非某次malloc所分配的地址被释放掉malloc应该尽快完成内存分配并返回(不能使用NP-hard的内存分配算法)实现malloc时应同时实现内存大小调整和内存释放函数(realloc和free) malloc和free是配对的,如果申请后不释放就是内存泄露,如果无故释放那就是什么也没做,释放只能释放一次,如果一块空间释放两次或者两次以上会出现错误(但是释放空指针例外,释放空指针也等于什么也没做,所以释放多少次都是可以的。)

【文章福利】小编推荐自己的Linux内核技术交流群:【 865977150】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!

1.2malloc和new

new返回指定类型的指针,并且可以自动计算所需要的大小。

int *p;
p = new int;//返回类型为int* ,分配的大小是sizeof(int)
p = new int[100];//返回类型是int*类型,分配的大小为sizeof(int)*100

而malloc需要我们自己计算字节数,并且返回的时候要强转成指定类型的指针。

int *p;
p = (int *)malloc(sizeof(int));
  1. malloc的返回是void*,如果我们写成了:p=malloc(sizeof(int));间接的说明了(将void转化给了int*,这不合理)
  2. malloc的实参是sizeof(int),用于指明一个整型数据需要的大小,如果我们写成p=(int*)malloc(1),那么可以看出:只是申请了一个一个字节大小的空间。
  3. malloc只管分配内存,并不能对其进行初始化,所以得到的一片新内存中,其值将是随机的。

一般意义上:我们习惯性的将其初始化为NULL,当然也可以使用memset函数。

简单的说:

malloc函数其实就是在内存中找一片指定大小的空间,然后将这个空间的首地址给一个指针变量,这里的指针变量可以是一个单独的指针,也可以是一个数组的首地址,这要看malloc函数中参数size的具体内容。我们这里malloc分配的内存空间在逻辑上是连续的,而在物理上可以不连续。我们作为程序员,关注的是逻辑上的连续,其他的操作系统会帮着我们处理。

1.3malloc实现原理

虚拟内存地址和物理内存地址

为了简单,现代操作系统在处理物理内存地址时,普遍采用虚拟内存地址技术。即在汇编程序层面,当涉及内存地址时,都是使用的虚拟内存地址。采用这种技术时,每个进程仿佛自己独享一片2N字节的内存,其中N是机器位数。例如在64位CPU和64位操作系统下每个进程的虚拟地址空间为264Byte。

这种虚拟地址空间的作用主要是简化程序的编写及方便操作系统对进程间内存的隔离管理,真实中的进程不太可能如此大的空间,实际能用到的空间大小取决于物理内存的大小。 由于在机器语言层面都是采用虚拟地址,当实际的机器码程序涉及到内存操作时,需要根据当前进程运行的实际上下文将虚拟地址转化为物理内存地址,才能实现对内存数据的操作。这个转换一般由一个叫MMU的硬件完成。

页与地址构成

在现代操作系统中,不论是虚拟内存还是物理内存,都不是以字节为单位进行管理的,而是以页为单位。一个内存页是一段固定大小的连续的连续内存地址的总称,具体到Linux中,典型的内存页大小为4096 Byte。所以内存地址可以分为页号和页内偏移量。下面以64位机器,4G物理内存,4K页大小为例,虚拟内存地址和物理内存地址的组成如下:

上面是虚拟内存地址,下面是物理内存地址。由于页大小都是4k,所以页内偏移都是用低12位表示,而剩下的高地址表示页号 MMU映射单位并不是字节,而是页,这个映射通过差一个常驻内存的数据结构页表来实现。现在计算机具体的内存地址映射比较复杂,为了加快速度会引入一系列缓存和优化,例如TLB等机制,下面给出一个经过简化的内存地址翻译示意图:

内存页与磁盘页

我们知道一般将内存看做磁盘的缓存,有时MMU在工作时,会发现页表表名某个内存页不在物理内存页不在物理内存中,此时会触发一个缺页异常,此时系统会到磁盘中相应的地方将磁盘页载入到内存中,然后重新执行由于缺页而失败的机器指令。关于这部分,因为可以看做对malloc实现是透明的,所以不再详述 :

真实地址翻译流程:

Linux进程级内存管理

内存排布:明白了虚拟内存和物理内存的关系及相关的映射机制,下面看一下具体在一个进程内是如何排布内存的。 以Linux 64位系统为例。理论上,64bit内存地址空间为0x0000000000000000-0xFFFFFFFFFFFFFFF,这是个相当庞大的空间,Linux实际上只用了其中一小部分。

具体分布如图所示:

对用户来说主要关心的是User Space。将User Space放大后,可以看到里面主要分成如下几段:

  • Code:这是整个用户空间的最低地址部分,存放的是指令(也就是程序所编译成的可执行机器码) Data:这里存放的是初始化过的全局变量
  • BSS:这里存放的是未初始化的全局变量
  • Heap:堆,这是我们本文主要关注的地方,堆自底向上由低地址向高地址增长
  • Mapping Area:这里是与mmap系统调用相关的区域。大多数实际的malloc实现会考虑通过mmap分配较大块的内存空间,本文不考虑这种情况,这个区域由高地址像低地址增长 Stack:栈区域,自高地址像低地址增长 。
  • Heap内存模型:一般来说,malloc所申请的内存主要从Heap区域分配,来看看Heap的结构是怎样的。

Linux维护一个break指针,这个指针执行堆空间的某个地址,从堆开始到break之间的地址空间为映射好的,可以供进程访问,而从break往上,是未映射的地址空间,如果访问这段空间则程序会报错。

1.4brk与sbrk

由上文知道,要增加一个进程实际上的可用堆大小,就需要将break指针向高地址移动。Linux通过brk和sbrk系统调用操作break指针。两个系统调用的原型如下:

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

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

资源限制和rlimirt

系统为每一个进程所分配的资源不是无限的,包括可映射的空间,因此每个进程有一个rlimit表示当前进程可用的资源上限,这个限制可以通过getrlimit系统调用得到,下面代码获取当前进程虚拟内存空间的rlimit 其中rlimt是一个结构体

struct rlimit
{
    rlimt_t rlim_cur;
    rlim_t rlim_max;
};

每种资源有硬限制和软限制,并且可以通过setrlimit对rlimit进行有条件限制作为软限制的上限,非特权进程只能设置软限制,且不能超过硬限制

1.5实现malloc

(1)数据结构

首先我们要确定所采用的数据结构。一个简单可行方案是将堆内存空间以块的形式组织起来,每个块由meta区和数据区组成,meta区记录数据块的元信息(数据区大小、空闲标志位、指针等等),数据区是真实分配的内存区域,并且数据区的第一个字节地址即为malloc返回的地址,可以使用如下结构体定义一个block

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

(2)寻找合适的block

现在考虑如何在block链中查找合适的block。一般来说有两种查找算法: First fit:从头开始,使用第一个数据区大小大于要求size的块所谓此次分配的块 Best fit:从头开始,遍历所有块,使用数据区大小大于size且差值最小的块作为此次分配的块 两种方式各有千秋,best fit有较高的内存使用率(payload较高),而first fit具有较高的运行效率。这里我们采用first fit算法

t_block find_block(t_block *last,size_t size){
    t_block b = first_block;
    while(b&&b->size>=size)
    {
        *last = b;
        b = b->next;
    }
    return b;
}

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

(3)开辟新的block

如果现有block都不能满足size的要求,则需要在链表最后开辟一个新的block。这里关键是如何只使用sbrk创建一个struct:

#define BLOCK_SIZE 24
t_block extend_heap{
    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)分裂block

First fit有一个比较致命的缺点,就是可能会让更小的size占据很大的一块block,此时,为了提高payload,应该在剩余数据区足够大的情况下,将其分裂为一个新的block:

void split_block(t_block b,size_t s)
{
    t_block new;
    new = b->data;
    new->size = b->size-s-BLOCK_SIZE;
    new->next = b->next;
    new ->free = 1;
    b->size = s;
    b->next = new;
}

(5)malloc的实现

有了上面的代码,我们就可以实现一个简单的malloc.注意首先我们要定义个block链表的头first_block,初始化为NULL;另外,我们需要剩余空间至少有BLOCK_SIZE+8才执行分裂操作 由于我们需要malloc分配的数据区是按8字节对齐,所以size不为8的倍数时,我们需要将size调整为大于size的最小的8的倍数:

size_t align8(size_t s)
{
    if(s&0x7 == 0)
    return s;
    return ((s>>3)+1)<<3;
}
#define BLOCK_SIZE 24
void *first_block=NULL;
void *mallloc(size_t size)
{
    t_block b,last;
    size_t s;
    //对齐地址
    s = align8(size);
    if(first_block)
    //查找适合block
    last = first_block;
    b = find_block(&last,s);
    if(b)
    {
    //如果可以则分裂
    if((b->size-s)>=(BLOCK_SIZE + 8))
    split_block(b,s);
    b->free = 0;
    }
    else
    {
        //没有合适的block,开辟一个新的
        b=extend_heap(last,s);
        if(!b)
        {
            return NULL;
        }
        else
        {
            b=extend_heap(NULL,s);
            if(!b)
            {
                return NULL;
            }
            first_block = b;
        }
    }
    return b->data;
}

二、calloc函数

calloc函数也是与free()函数配套使用的,使用方式与malloc几乎相同,也是在堆区申请动态内存空间。头文件:stdlib.h,返回类型为空指针,size_t num为元素个数,size_t size为每个元素的字节大小。

calloc函数的原型:

void* calloc(size_t num ,size_t size)

2.1calloc函数的使用

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
  //calloc与malloc的区别
  //1.参数的使用方式不同
  //2.calloc会在返回起始地址之前,把在堆区申请的动态内存空间的每个字节都初始化为0
  int* p=(int*)calloc(10, sizeof(int));
  if (p == NULL)
  {
    printf("%s\n", strerror(errno));
  }
  else
  {
    int i;
    for (i = 0; i < 10; i++)
    {
      printf("%d ", *(p + i));//0 0 0 0 0 0 0 0 0 0
    }
  }
  //注意要释放calloc申请的那块空间
  //还给操作系统,并把指针置为空
  free(p);
  p = NULL;
  return 0;
}

2.2calloc与malloc的区别

1.参数的使用方式不同

malloc(单位:字节):malloc(10 * sizeof(int));或malloc(40)
calloc:calloc(10 , sizeof(int))

2.malloc的使用效率较高,因为calloc在返回在堆区申请的那块动态内存的起始地址之前,会将每个字节都初始化为0

三、realloc函数

3.1什么是realloc()

realloc()是C库的功能,用于为已分配的内存块增加更多的内存大小。C语言中重新分配的目的是扩展当前的存储块,同时保留原始内容。realloc()函数有助于通过malloc或calloc函数减少先前分配的内存大小。realloc代表内存的重新分配。

在C中realloc的语法:

ptr = realloc (ptr,newsize);

上面的语句在变量newsize中分配具有指定大小的新内存空间。执行完函数后,指针将返回到存储块的第一个字节。新的大小可以大于或小于以前的内存。我们不能确定新分配的块是否将指向与先前存储块相同的位置。

C语言中的realloc函数将在新区域中复制所有先前的数据。它确保数据将保持安全。

例如:

#includeint main () {
   char *ptr;
   ptr = (char *) malloc(10);
   strcpy(ptr, "Programming");
   printf(" %s,  Address = %un", ptr, ptr);
   ptr = (char *) realloc(ptr, 20); //ptr is reallocated with new size
   strcat(ptr, " In 'C'");
   printf(" %s,  Address = %un", ptr, ptr);
   free(ptr);
   return 0;
}

3.2如何使用realloc()

下面的C语言程序演示了如何在C语言中使用realloc来重新分配内存。

#include <stdio.h>
#include <stdlib.h>
    int main() {
        int i, * ptr, sum = 0;
        ptr = malloc(100);
        if (ptr == NULL) {
            printf("Error! memory not allocated.");
            exit(0);
        }
        ptr = realloc(ptr,500);
    if(ptr != NULL)
           printf("Memory created successfullyn");
    return 0;
    }

C示例中的realloc结果:

Memory created successfully

每当重新分配导致操作失败时,它都会返回空指针,并且先前的数据也将被释放。

  • 1、函数原型:void *realloc(void *ptr,size_f size);ptr是指向需要修改的内存块的指针,size是请求修改的大小
  • 2、realloc用来修改已分配的内存块的大小
  • 3、如果调整成功,返回值是调整大小后内存的起始位置;如果失败,则返回NULL,所以要对返回值进行判空。
  • 4、在扩大内存空间时会出现的两种情况:

(1)ptr所指向的内存后有足够的内存空间用来扩展

(2)ptr所指向的内存后没有足够的内存空间用来扩展

则在堆上重新找一个大小合适的连续空间来使用,这样函数返回的是一个新的内存地址。如果新的内存空间申请成功,则会将ptr所指向的内存中的内容拷贝到新的内存空间中,ptr所指向的内存会被释放,返回新的内存地址;如果不成功,ptr所指向的内存不会被释放,函数返回NULL。

640.jpg

  • 5、p = realloc(ptr,size)函数返回值不为空时,释放内存不需要写free(ptr),只需要写free(p)
  • 6、申请的内存空间不会进行初始化

四、free函数

  • 1、用来释放动态开辟的内存
  • 2、函数原型:void free(void *ptr);指针参数是指向malloc等函数动态申请的内存地址,这块内存释放后会返还给堆,虽然指针指向这块区域,但可以重新分配这块数据
  • 3、一般来说,free释放的是最新开辟的一个内存空间如果程序中malloc了,但是没有free,则会造成内存泄漏(即在程序运行过程中,系统会一直被申请内存,造成可用内存不断减少)
  • 4、在free之后,需要将ptr再次置空,即ptr = NULL;,如果不置空,后面程序如果通过ptr会再次访问到已经释放/无效的/已经被回收再利用的内存。
  • 5、free不能同时释放一块内存。

在使用以上函数时,需要加头文件#include <stdlib.h>

将free与malloc函数的联用情况:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int main()
{
  int* p = (int*)malloc(40);
  int* ptr = p;
  if (ptr == NULL)
  {
  printf("%s\n", strerror(errno));
  return 1;
  }
  //使用
    //自行添加使用的代码!!
  //释放
  free(p);  
  p = NULL;
}

在这个代码中,就是将malloc函数与free函数初步联用!!所以才能更合理的分配内存!!

但是在malloc函数与free函数联用的情况,由于代码的不规范,也会出现或多或少的错误!!

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int test ()
{
  int* p= (int*)malloc(40);
  if (p == NULL)
  {
  printf("%s\n", strerror(errno));
  return 1;
  }
  //使用
  if (1)
  {
  //某个成立的条件!
  return 2;
  }
  //释放
  free(p);
  p = NULL;
}//该段代码,存在内存泄露的问题!
int main()
{
  test();
  return 0;

其实,在该段代码中,可能出现内存泄漏的问题!!

原因在于:在该段代码中:

//使用
  if (1)
  {
  //某个成立的条件!
  return 2;
  }

如果条件成立,直接返回该值,但并不会继续执行代码,导致,后续的释放(//释放 free(p); p = NULL;)出现问题!

精选文章推荐阅读:


相关文章
|
28天前
|
C语言 C++
C语言 之 内存函数
C语言 之 内存函数
31 3
|
30天前
|
存储 监控 算法
Java中的内存管理:理解Garbage Collection机制
本文将深入探讨Java编程语言中的内存管理,着重介绍垃圾回收(Garbage Collection, GC)机制。通过阐述GC的工作原理、常见算法及其在Java中的应用,帮助读者提高程序的性能和稳定性。我们将从基本原理出发,逐步深入到调优实践,为开发者提供一套系统的理解和优化Java应用中内存管理的方法。
|
11天前
|
存储 算法 Java
Go语言的内存管理机制
【10月更文挑战第25天】Go语言的内存管理机制
17 2
|
13天前
|
存储 运维 Java
💻Java零基础:深入了解Java内存机制
【10月更文挑战第18天】本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
24 1
|
20天前
|
程序员 C++ 容器
在 C++中,realloc 函数返回 NULL 时,需要手动释放原来的内存吗?
在 C++ 中,当 realloc 函数返回 NULL 时,表示内存重新分配失败,但原内存块仍然有效,因此需要手动释放原来的内存,以避免内存泄漏。
|
1月前
|
存储 程序员 编译器
C语言——动态内存管理与内存操作函数
C语言——动态内存管理与内存操作函数
|
1月前
|
编译器 C语言 C++
详解C/C++动态内存函数(malloc、free、calloc、realloc)
详解C/C++动态内存函数(malloc、free、calloc、realloc)
133 1
|
16天前
|
存储 C语言
【c语言】字符串函数和内存函数
本文介绍了C语言中常用的字符串函数和内存函数,包括`strlen`、`strcpy`、`strcat`、`strcmp`、`strstr`、`strncpy`、`strncat`、`strncmp`、`strtok`、`memcpy`、`memmove`和`memset`等函数的使用方法及模拟实现。文章详细讲解了每个函数的功能、参数、返回值,并提供了具体的代码示例,帮助读者更好地理解和掌握这些函数的应用。
15 0
|
29天前
|
C语言 C++
c语言回顾-内存操作函数
c语言回顾-内存操作函数
39 0
|
1月前
|
存储 C语言 C++
来不及哀悼了,接下来上场的是C语言内存函数memcpy,memmove,memset,memcmp
本文详细介绍了C语言中的四个内存操作函数:memcpy用于无重叠复制,memmove处理重叠内存,memset用于填充特定值,memcmp用于内存区域比较。通过实例展示了它们的用法和注意事项。
61 0