不考虑windows的C++,首先看看Linux的虚拟地址空间概念

Linux虚拟地址空间

Linux 使用虚拟地址空间,大大增加了进程的寻址空间,由高地址到低地址分别为:

  • 内核虚拟空间(内核态):用户代码不可见的内存区域,由内核管理(页表就存放在内核虚拟空间)。
  • 栈:用于维护函数调用的上下文空间,堆的末端由break指针标识,当堆管理器需要更多内存时,可通过系统调用brk()和sbrk()来移动break指针以扩张堆,一般由系统自动调用。
  • 内存映射区域(mmap):内核将硬盘文件的内容直接映射到内存,一般是mmap系统调用所分配的,当malloc超过128KB时,不在堆上分配内存,而在内存映射区分配内存,既可以用于装载动态库,也可以用于匿名内存映射,没有指定文件,所以可以用来存放数据
  • 堆:就是平时所说的动态内存, malloc/new 大部分都来源于此。其中堆顶的位置可通过函数brk和sbrk进行动态调整。
  • BSS段(.bss):未初始化的全局变量或者静态局部变量
  • 数据段(.data):已初始化的全局变量或静态局部变量
  • 代码段(.text):保存代码(CPU执行的机器码)

linuxprocessmemory

32位OS的虚拟地址空间为32位即4GB,用户空间占3GB,内核空间占1GB

64位OS的虚拟地址空间为48位即256T,用户空间占128T

C++内存分配(管理)方式

C++ 自由存储区是否等价于堆?

栈:就是那些由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。在一个进程中,位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数的调用。操作方式类似于数据结构里的栈。向下增长。

堆:malloc在堆上分配的内存块,使用free释放内存。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。操作方式类似于数据结构里的链表。向上增长。可以说堆是操作系统维护的一块内存(物理上的)。

自由存储区(C++特有概念):new所申请的内存则是在自由存储区上,使用delete来释放。自由存储区是C++通过new与delete动态分配和释放对象的抽象概念(逻辑上的),有可能是由堆实现的,可以说new所申请的内存在堆上(这点和很多网络上的文章不一致,本人选择与上面的博客文章一致,在极客时间现代C++实战30讲中也是这样的观点)

全局/静态存储区:全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。

常量存储区:存放字面值常量(如字符串常量),不建议修改,程序结束后由系统释放。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//main.cpp
int a = 0; 全局初始化区
char *p1; 全局未初始化区
main()
{
int a; //栈
char s[] = "abc"; //栈
char *p2; //栈
char *p3 = "123456"; //123456\0在常量区,p3在栈上。
static int c =0//全局(静态)初始化区
p1 = (char *)malloc(10);
p2 = (char *)malloc(20);//分配得来得10和20字节的区域就在堆区。
strcpy(p1, "123456"); //123456\0放在常量区,编译器可能会将它与p3所指向的"123456",优化成一个地方。
}

堆和栈的区别

  1. 管理方式:栈是用数据结构栈实现的,由系统分配,速度较快,但不受程序员控制,不够则报栈溢出的错误;堆是由(空闲)链表实现的,由程序员手动配置(new和delete),速度较慢,也容易产生内存泄露,但使用灵活
  2. 生长方向:栈向下生长,内存地址由高到低;堆向上生长,内存地址由低到高
  3. 分配效率:栈由操作系统自动分配,硬件层面有支持;堆是由C的库函数(malloc)、C++的操作符(new)来完成申请的,实现复杂,频繁的申请容易产生内存碎片,效率更低
  4. 存放内容:栈存放函数返回地址、相关参数、局部变量和寄存器内容;堆的内容由程序员自己决定

malloc/calloc/free/realloc

堆的管理:堆分为映射区(mapped region)和未映射区(unmapped region),从堆开始的地方~break指针为映射区(通过MMU映射虚拟地址到物理地址),break往上是未映射区。当映射区不够时,break指针上移,扩大映射区,但是不能无限扩大,有rlimit限制。

void *malloc(size_t size)在堆区分配一块大小为至少为size(可能要字节对齐)的连续内存,返回指向这块未初始化内存的起始位置的指针,分配失败返回NULL

  • malloc函数用于动态分配内存。为了减少内存碎片和系统调用的开销,malloc其采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块(block),以块作为内存管理的基本单位。当用户申请内存时,直接从堆区分配一块合适的空闲块。
  • malloc采用隐式链表结构将堆区分成连续的、大小不一的块,包含已分配块和未分配块;同时malloc采用双向链表来管理所有的空闲块,双向链表将空闲块连接起来,每一个空闲块记录了一个连续的、未分配的地址。
  • malloc在申请内存时,一般会通过brk或者mmap系统调用进行申请。其中当申请内存小于128K时,会使用系统调用brk在堆区中分配;而当申请内存大于128K时,会使用系统调用mmap在内存映射区分配。mmap有一种用法是映射磁盘文件到内存中(进程间通信),如果是匿名映射,就不指定磁盘文件,也就相当于开辟了一块内存。

void *calloc(size_t numitems, size_t size)给一组对象分配内存并且初始化为0,底层会调用malloc

void *free(void *p)验证传入地址是否有效(是否是malloc开辟的),然后合并空闲的相邻内存块

void *realloc(void *ptr, size_t size)调整(通常是增加)一块内存的大小,若增加了已分配内存块大小,则额外内存是未初始化的,并且调整内存大小要考虑内存块block的关系,有可能需要分裂内存块split block,也有可能需要合并后面的空闲块,还有可能调用malloc重新分配再复制

malloc最多能分配多少内存

malloc最多能分配多少内存

一次malloc:取决于OS内核文件的策略,启发式分配最多能分配接近物理内存上限,超额分配能分配虚拟地址空间的上限(64位OS是2^47位的内存)

多次malloc:无论是启发式还是超额,最终都能分配到虚拟地址空间的上限

malloc()成功返回,OS已经分配相应内存给该进程了吗

答:不是的,这是虚拟内存,但可能会分配4K(一页的大小)的物理内存,用来增加页表项

new/delete与malloc/free的区别是什么

new和malloc

  • malloc需要给定申请内存的大小,返回的是void*,一般需要强制类型转化;new会调用构造函数,不用指定内存大小,返回的指针不用强转。
  • malloc失败返回空,new失败抛bad_malloc异常,在catch捕捉之前,所有的栈上对象都会被析构,资源全都回收了,而且释放资源的范围由栈帧限定了
  • malloc分配的内存不够时,可以使用realloc扩容,而new没有这种操作

delete和free

  • free会释放内存空间,对于类类型的对象,不会调用析构函数;
  • delete会释放内存空间,而且还会调用析构函数

new[]和delete[]

  • 申请数组时,new[]一次分配所有内存,多次调用构造函数,搭配使用delete[]delete[]多次调用析构函数,销毁数组中的每个对象,而malloc只能接收类似sizeof(int)*n这样的参数形式来开辟能容纳n个int型元素的数组空间
  • delete[]操作符释放空间,而且会调用由new[]创建的一组对象的析构函数
  • new[]创建的内存空间,如果由delete释放,则编译器只会释放第一个对象的内存空间,后面的内存空间没法释放,于是产生内存泄漏
  • 编译器怎么知道delete[]要销毁多少个对象呢,关键在于new[]时会把instance的数量存在开头,编译器遇到delete[]时就会寻找这个字段值,所以建议:用delete删除new的空间,用delete[]删除new[]的空间

三个“妞”:new operator、operator new、placement new

  • new operator:我们最常用的new操作符,内置的,无法重载,它只干两件事:(根据operator new函数)分配内存+调用构造函数

  • operator new:是new函数(最容易引起歧义的地方),可以重载,重载时可以添加额外参数,但第一个参数必须是size_t用来确定分配的内存,仅干一件事:分配内存

    1
    2
    void* operator new(size_t size); // 返回指针,指向一块size大小的内存
    void* rawMemory = operator new(sizeof(string)); // 直接调用new函数创建原始内存,这就跟调用malloc差不多
  • placement new:在指定地址上创建对象,不分配内存,它只干一件事:调用构造函数

    1
    A *ptr2 = new(ptr) A();

new一个类A的对象分为二步:

  1. 调用operator new分配内存(通常在堆中),void* operator new (size_t);,这里会把sizeof(A)传入size_t型参数中
  2. 调用构造函数生成类对象,A::A()

三种delete与三种new正好对应

operator new 的异常处理

  • 避免使用nothrow new,因为它如今并不能提供任何显著的优点,而且其特性通常要比简单new(可能抛出异常)的还差。
  • 记住,无论如何,检查new是否失败几乎是没什么意义的,原因有若干。
  • 如果你理当关心内存耗尽的话,请确保你正在检查的是你所认为你正在检查的,因为:
    • 在那些直到内存被用到时才去提交实际内存的系统之上,检查new失败通常是没有意义的。
    • 在拥有虚拟内存的系统上,new失败几乎不会发生,因为早在虚拟内存耗尽之前,系统
    • 通常就已经开始颠簸了,而此时系统管理员自然会杀掉一些进程。
    • 除了一些特殊情况之外,通常即便你检测到了new失败,要是真的没内存剩下了的话,那么你也就做不了什么了。

定义一个只能new出来的类/定义一个不能new的类

在C++中,类的对象建立分为两种,一种是静态建立,如A a;另一种是动态建立,如A* ptr=new A;这两种方式是有区别的。

  1. 静态建立类对象:是由编译器为对象在栈空间中分配内存,再调用类的构造函数。
  2. 动态建立类对象,是使用new运算符将对象建立在堆空间中。这个过程分为两步,第一步是执行operator new()函数,在堆空间中搜索合适的内存并进行分配;第二步是调用构造函数构造对象,初始化这片内存空间。
  • 答案1:将析构函数设为私有,类对象就无法建立在栈上了,也就只能new出来了

    编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性,其实不光是析构函数,只要是非静态的函数,编译器都会进行检查。如果类的析构函数是私有的,则编译器不会在栈空间上为类对象分配内存。

    这样的类只能new出来,但是不能直接调用析构函数回收,类必须定义一个public的destroy函数,类对象用完后必须调用destroy回收

    1
    2
    3
    4
    5
    6
    7
    class A{
    public:
    A(){};
    void destroy(){delete this;}
    private:
    ~A();
    }

    若要解决继承问题,则把析构函数设为protected

  • 答案2:重载operator new设为private,因为new operator总是先调用operator new,而设为私有后就不能调用new了,故类对象只能建立在栈上,无法建立在堆上

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class A  
    {
    private:
    void* operator new(size_t t){} // 注意函数的第一个参数和返回值都是固定的
    void operator delete(void* ptr){} // 重载了new就需要重载delete
    public:
    A(){}
    ~A(){}
    };