【C++面试篇】堆的动态内存管理

215 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

C中堆的动态内存管理

内存申请和释放

C中堆的内存申请主要通过malloc\calloc\realloc这三个函数实现,我们比较经常使用的是malloc,下面讲对这三个函数做一下介绍

malloc

void* malloc(size_t size);
  • 向堆申请一块连续可用大小为size的空间,如果申请成功则返回一个指向该空间的指针,否则返回NULL
  • 返回值是void*,malloc并不知道开辟空间的类型,需要由程序员自己决定

calloc

void* calloc(size_t num, size_t size);
  • 为num个大小为size的元素开辟一块空间,并将空间中的每个字节初始化为0,返回一个指向它的指针
  • 和malloc的区别主要是会将空间初始化为0

realloc

void* reallo(void* ptr, size_t size);
  • 重新调整之前分配的内存空间大小,使新空间大小为size,用于扩展空间大小

    • 原有空间后有足够大的空间则直接在后面追加
    • 原有空间之后的空间不够扩展则重新找一块合适大小的空间并将原来空间中的数据拷贝过去
    • 找不到合适空间大小则返回NULL

free

void free(void* ptr);
  • 用于释放动态申请的内存空间,如果ptr指向的内存空间不是堆区的那么行为是未定义的,如果ptr是NULL则free不会指向任何动作

malloc的实现

在堆中分配内存主要是通过brk/sbrk和mmap/munmap这几个系统调用实现的

sbrk和brk在底层会维护一个位置,通过位置的移动完成内存的分配和回收

void* sbrk(int crement);
  • 参数是增量:正数时分配内存;负数时回收内存;0时取当前位置
  • 返回移动之前的位置(可用内存的首地址)
  • sbrk在分配内存时很方便,但是回收内存时比较麻烦
void brk(void* ptr);
  • 将传入的地址作为新的位置,释放内存时很方便

所以经常使用sbrk分配内存,brk释放内存

mmap在进程的堆和栈中间(文件与社区)找一块空间的虚拟内存分配给用户,mummap将该内存释放

实现

  • 当申请的内存空间小于128K时以内存池的方式实现

    • 使用sbrk申请大块的内存作为内存池,将内存池分为多个块,以块为单位对内存池进行管理
    • 每个块都有一个相同的首部”内存控制块”,用于记录块的信息(指向下一个块的指针、当前块的大小、当前块是否被使用),首部对于程序是不可见的,malloc返回的是首部后的地址,也就是可用空间的起始地址
    • 内存池通过隐式链表将池中的块组合起来,包含已分配块和未分配块;使用显式链表将空闲块连接起来
    • 当进行内存分配的时候,malloc会在空闲链表中寻找一个大于等于所需空间的空闲区块,如果该块与申请大小刚好相等则直接将其从链表移走并返回给用户;如果太大则将其分为两部分,尾部给用户,剩下部分留在空闲链表中
  • 当开辟空间大于128k时调用mmap()函数,在堆和栈中间的文件映射区域开辟一块内存空间

使用sbrk和brk会产生内存碎片

因为只有一个指针指向堆的顶部地址,所以sbrk分配的内存需要等到高地址内存释放后才可以释放

img

比如这里A只能等B释放后才可以释放,只有一个指针,如果A释放了,那么等B释放后,指针如果会在AB之间,那么就会导致原来A区域的地方没办法再申请使用(因为不知道A是否释放了)

为什么不全部使用mmap呢

  • 都是系统调用,频繁调用系统调用会消耗系统资源
  • mmap 申请的内存被 munmap 后,重新申请会产生更多的缺页中断;但是堆申请的内存被释放后不会立即归还给操作系统,如果重用可以避免系统调用和缺页中断
  • 使用mmap分配小内存,会导致地址空间的分片更多,内核管理负担更大

被释放的内存回立即返回给操作系统吗

  • 如果是使用mmap进行分配的话会直接归还给操作系统
  • 如果使用brk进行分配,不会立即返回给操作系统,而是在空闲链表中寻找可以插入的位置,等待被重新分配,可以减少系统调用以及缺页中断;被释放的块如果相邻任一边是空闲块会被合并,减少内存碎片

C++中堆的动态内存管理

C++中由于引入了对象导致C中堆的内存管理方式在C++中显得有些无力,使用起来比较麻烦,所以C++进化出了自己的内存管理方式,通过new和delete对堆中的内存进行管理

内存申请和释放

new

int* a = new int;
int* b = new int(10);
int* c = new int[10]; 

new是一个操作符,用于动态申请堆的空间,会自动计算类型的大小,同时返回具体类型的指针

delete

delete a;
delete b;
delete[] c;a = nullptr;
b = nullpttr;
c = nullptr;

delete也是一个操作符,将堆中申请的内存释放

new的类型

plain new

在空间分配失败时跑出异常std::bad_alloc而不是返回NULL

nothrow new

在空间分配失败时不抛出异常,返回NULL

placement new

允许在一块已经分配成功的内存(栈或堆)上重新构造对象或对象数组,不用担心内存分配失败,因为没有分配内存,只调用对象的构造函数

优点:

  • 反复使用一块较大的动态分配的内存构造不同类型的对象或数组(mmap减少系统调用和缺页中断)
  • 在已经分配好的内存进行对象的构建,速度快

placement new构造的对象数组要显示调用其析构函数销毁,不能用delete,因为构造的对象或数组大小不一定等于原来分配的内存大小,使用delete会造成内存内漏或释放内存是出现运行错误

如下图并不会调用析构函数img

new/delete的底层实现

new

  • 简单的数据类型直接调用opearato new分配内存
  • 复杂结构先调用operator new分配内存,然后在分配的内存上调用构造函数

delete

  • 简单的数据类型直接调用free函数
  • 复杂结构先调用析构函数再调用operator delete释放内存

operator new和operator delete本质都是通过malloc和free实现堆的申请和释放

new[]

  • 简单类型new[]计算好大小后调用operator new

  • 复杂类型

    • new[]先调用operator new[]分配内存,分配内存时多分配四个字节用于存放元素个数,返回地址为p
    • p的最开始的4个字节p-4用于存放元素个数n,然后调用n次构造函数从p开始构造对象

delete[]

  • 简单类型直接调用operator delete释放内存
  • 复杂类型:先将指针前移4个字节获取元素个数,然后执行n次析构函数,最后调用operator delete释放内存

new/delete和malloc/free的区别

  • new/delete是C++运算符,malloc/free是C/C++语言标准库函数
  • new/delete不仅会进行内存的申请和释放,还会调用构造函数和析构函数;free/malloc只会进行内存的申请和释放
  • new自动计算需要分配的空间大小,malloc需要手工计算
  • new是类型安全的,malloc不是
  • malloc返回的是void指针必须进行类型转换,new返回的是具体类型指针

为什么需要new和delete

malloc/free和new/delete都是用来申请内存和回收内存的

对于非基本数据类型的对象,创建的时候还需要执行构造函数,销毁的时候要执行析构函数。而malloc/free是库函数,是已经编译的代码,所以不能把构造函数和析构函数的功能强加给malloc/free,所以new/delete是必不可少的

总结

本文主要介绍了C和C++中关于堆的内存管理的方式,主要涉及平时代码中对于堆的内存申请和释放操作,以及其底层实现。但是动态申请内存不仅只有堆可以,栈也可以,具体操作可以参考本专栏的另一篇博客