关注

Re:从零开始的 C++ 入門篇(十一):全站最全面的C/C++内存管理的底层剖析与硬核指南

博主名称: 晓此方-CSDN博客

大家好,欢迎来到晓此方的博客。

⭐️C++系列个人专栏:

Re:从零开始的C++_晓此方的博客-CSDN博客

 ⭐️踏破千山志未空,拨开云雾见晴虹。 人生何必叹萧瑟,心在凌霄第一峰


目录

0.1概要&序論

一,布局模型与常见误区解析

1.1C/C++内存布局

1.2内存布局易误解点

二,复习C语言的内存管理方法

2.1malloc

2.2calloc

2.3relloc

2.4free

2.5罗列常见的内存管理错误

三,C++内存管理方法

3.1new/delete管理体系

3.1.1开辟单个空间与释放

3.1.2开辟多个连续的空间与释放

3.2C+++针对自定义类型的内存管理

3.2.1调用构造的重要性

3.2.2对象开辟空间的方法

3.3C++内存开辟失败与抛异常

插曲:摩尔定律

3.4C++内存开辟的底层逻辑

3.4.1operator new 与 operator delete 函数(重要点进行讲解)

3.4.1.1operator new

3.4.1.2operator delete

3.4.2new和delete的实现原理

3.4.2.1内置类型

3.4.2.2自定义类型

new的原理

delete的原理

new T[N]的原理

delete[]的原理

3.4.3从汇编代码中看底层调用操作

new的底层

delete的底层

3.5C/C++内存开辟的各种错乱情况

3.5.1C/C++混用

3.5.2new/delete多对单

3.6总结malloc/free 和 new/delete 的区别(面试常考)

3.7placement-new

3.8拆分使用new


0.1概要&序論

         这里是此方,久しぶりです!。本文内容极长将详细介绍C语言内存管理包括:malloc、calloc、relloc、free、常见内存管理错误等内容和C++内存管理包括:new、delete以及他们的底层原理,最后会总结C/C++内存管理的区别。内容干货极其丰富!这里是「此方」。让我们现在开始吧!

一,布局模型与常见误区解析

1.1C/C++内存布局

C++的内存管理和c语言一致,以下是一图流分析其内存布局:

    我们来简要说明一下:

    1. 栈又叫堆栈,存放非静态局部变量/函数参数/返回值等等,栈是向下增长的
    2. 内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享共享内存,做进程间通信。(暂时不用管,现在只需要了解一下)
    3. 堆用于程序运行时动态内存分配堆是可以上增长的。(本文主要介绍)
    4. 数据段(俗称静态区)--存储全局数据和静态数据。
    5. 代码段--可执行的代码/只读常量

    我们可以这么理解:某种程度上说,分区分的是生命周期。

    1. 栈内存在栈帧销毁时销毁。
    2. 静态区内存一直到程序运行结束。
    3. 堆内存的如果不去free/delete一直都在。

    因此:实际上需要我们程序员管理的内存——只有堆区内存。

    1.2内存布局易误解点

    题目:

    char char2[] = "abcd"; 
    const char* pChar3 = "abcd"
    1. char2在哪里?____
    2. *char2在哪里?____
    3. pChar3在哪里?____
    4. *pChar3在哪里?____

    答案:栈,栈,栈,代码段(最后一个是不是猜错了doge)

    解释:

    • char2代表该数组的首元素指针存放在栈中很合理。
    • “abcd”只读字符串在代码段中,char2[]数组将该字符串拷贝一份到栈区中,*char自然指向被拷贝在在栈上的那个数组的首元素。
    • pChar3是指向“abcd”的指针,存放在栈中合理。
    • 但pchar3并没有像char2一样拷贝一份字符串到栈中,所以解引用的结果自然就在代码段上。

    二,复习C语言的内存管理方法

    头文件:<stdlib.h>

    2.1malloc

    C 语言提供了一个动态内存开辟的函数:

    void* malloc(size_t size);
    1. 这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
    2. 如果开辟成功,则返回一个指向开辟好空间的指针。
    3. 如果开辟失败,则返回一个 NULL 指针,因此 malloc 的返回值一定要做检查。
    4. 返回值的类型是 void*,所以 malloc 函数并不知道开辟空间的类型,具体在使用的时候由使用者自己来决定。
    5. 如果参数 size 为 0,malloc 的行为在标准中是未定义的,取决于编译器。

    一个鲜明的例子:

    int* ret = (int*) malloc ( sizeof(int) * 10 ) ;
    if (ret == NULL){
         perror ( "malloc fail" );
         exit (1);
    }
    

    2.2calloc

    C 语言还提供了一个函数叫 calloc,calloc 函数也用来动态内存分配。原型如下:

    void* calloc(size_t num, size_t size);
    1. 函数的功能是为 num 个大小为 size 的元素开辟一块空间并把每个字节初始化为 0。
    2. 与函数 malloc 的区别只在于初始化。

    一个鲜明的例子:

    int *p = (int*)calloc(10, sizeof(int));
    if(NULL != p){
        for(int i=0; i<10; i++){
            printf("%d ", *(p+i));
        }  
    }

    2.3relloc

    realloc 函数的出现让动态内存管理更加灵活。
             有时我们会发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,那为了合理的使用内存,我们一定会对内存的大小做灵活的调整。那 realloc 函数就可以做到对动态开辟内存大小的调整。

    函数原型如下:

    void* realloc(void* ptr, size_t size);
    1. ptr 是要调整的内存地址
    2. size 调整之后新大小
    3. 返回值为调整之后的内存起始位置。
    4. 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。

    realloc 在调整内存空间的是存在两种情况:

    • 情况1:原有空间之后有足够大的空间
    • 情况2:原有空间之后没有足够大的空间

    1. 当是情况1的时候,要扩展内存就直接原有内存之后直接追加空间,原来空间的数据不发生变化。
    2. 当是情况2的时候,原有空间之后没有足够多的空间时,扩展的方法是:在堆空间上另找一个合适大小的连续空间来使用。这样函数返回的是一个新的内存地址。

    2.4free

    C 语言提供了一个函数 free,专门是用来做动态内存的释放和回收的,函数原型如下:

    void free(void* ptr);
    1. free 函数用来释放动态开辟的内存。
    2. 如果参数 ptr 指向的空间不是动态开辟的,那 free 函数的行为是未定义的。
    3. 如果参数 ptr 是 NULL 指针,则函数什么事都不做。

    2.5罗列常见的内存管理错误

    1. 对 NULL 指针的解引用操作。
    2. 对动态开辟空间的越界访问。
    3. 对非动态开辟内存使用 free 释放。
    4. 使用 free 释放一块动态开辟内存的一部分(这个很容易忘记)
    5. 对同一块动态内存多次释放。
    6. 动态开辟内存忘记释放(内存泄漏)(这个很容易发生)

    三,C++内存管理方法

    3.1new/delete管理体系

    3.1.1开辟单个空间与释放

    void Test(){
        int * ptr = new int;
        delete ptr;
    }

    new开辟了int大小的空间给ptr。并delete销毁它。整个过程相对于C语言非常简单。

    1. new:开辟空间。——对标malloc
    2. delete:释放空间。——对标free

    我们也可以在开辟空间的时候初始化:如下:对标calloc,为ptr开辟空间的时候并同时初始化为1.

    int * ptr = new int (1);
    

    3.1.2开辟多个连续的空间与释放

    void Test(){
        int * ptr = new int[10];
        delete [] ptr;
    }

            如上,我们开辟了十个int大小的空间给ptr指针,让其指向这个10*sizeof(int)大小的数组。值得注意的是,在delete释放内存的时候要加上[]。

           同样的,我们可以在开辟空间的时候初始化:

    int * ptr = new int[5]{1,2,3,4,5};
    int * ptr = new int[10]{1,2,3,4,5};
    int * ptr = new int[10]{0};

    结合上面的代码,初始化一共有三种情况:

    1. 完全初始化。按照你初始化的来。
    2. 部分初始化,剩下未初始化是部分默认是0。
    3. 直接给0,全部初始化为0。

    C++搞出来这个不是单纯是为了优化C语言那一套的写法。以上都是表层的内容。接下来深入讲解

    3.2C+++针对自定义类型的内存管理

             对于自定义类型,C++在申请空间的时候会自动调用构造函数,释放空间的是后会自动调用析构函数。

             演示代码:如图,我们new了10个A类类型的空间。并delete释放。于是构造函数和析构函数就被调用了10次。

    3.2.1调用构造的重要性

           我们来看一段链表代码:在如下代码中,我们创建了多个ListNode节点,并通过new动态分配内存:

    struct ListNode{
        int val;
        ListNode* next;
        ListNode(int x)
            : val(x)
            , next(nullptr)
        {}
    };
    int main(){
        A* p1 = new A;
        A* p2 = new A(1);
        delete p1;
        delete p2;
        ListNode* n1 = new ListNode(1);
        ListNode* n2 = new ListNode(1);
        ListNode* n3 = new ListNode(1);
        ListNode* n4 = new ListNode(1);
        n1->next = n2;
        n2->next = n3;
        n3->next = n4;
    }

    同样的,发生了两个步骤:

    1. 分配内存:为ListNode类型的对象分配堆空间;
    2. 调用构造函数:自动执行构造函数初始化成员变量。

    若没有构造函数的自动调用:

    • val可能是未定义值(如垃圾数据);
    • next可能指向随机地址,导致后续访问崩溃;
    • 链表连接毫无逻辑,引发严重运行时错误。

    因此new自动调用构造函数,确保了每个节点在创建时就处于已知的状态

    3.2.2对象开辟空间的方法

    1,有默认构造函数并开辟一个对象大小空间

    A* p1 = new A;
    

    2,有默认构造函数并开辟多个对象大小空间

    A* p3 = new A[3];

    3,没默认构造函数并开辟一个对象大小空间

    A* p2 = new A(2, 2);

    4,拷贝构造多个对象大小空间(最基础版

    A aa1(1, 1);
    A aa2(2, 2);
    A aa3(3, 3);
    A* p3 = new A[3]{aa1, aa2, aa3};

    5,拷贝构造多个对象大小空间(使用匿名对象版编译器会自己优化。

    A* p4 = new A[3]{ A(1,1), A(2,2), A(3,3) };

    6,拷贝构造多个对象大小空间(使用C++11特性版多参数构造隐式类型转换。

    A* p5 = new A[3]{ {1,1}, {2,2}, {3,3} };

    3.3C++内存开辟失败与抛异常

              一般情况下我们不再使用C语言的malloc那一套方法。为什么我们以前要写perror那一套检查法,而C++没有?C++我们引入了一种更加先进的方法:C++异常我会在C++进阶会详细讲

              malloc失败后返回空,new失败后不是返回空而是抛异常。(我们在C++检查返回值是没有用的),内存开辟失败并不常见,先造一个简易的失败模拟器:

              如图,弹窗提示开辟内存失败。(开辟内存太多而无法实现),现在出现了这种异常,在C++中,我们要尝试并捕获这种异常

    try
    {
    	void* pa = new char[1024 * n];
    }
    catch (const exception& e)
    {
    }
    • "try{ }catch"这一套用来捕获异常。
    • exception是标准库异常类类型,不可修改。
    • e是异常类型变量。
    try
    {
    	void* pa = new char[1024 * n];
    }
    catch (const exception& e)
    {
    	cout << e.what() << endl;
    }

             如上代码,捕获异常后,我们还要知道到底发生了什么回事:异常类型变量e调用what()函数可以帮助我们。

               如上,what()函数返回异常信息"内存申请失败:bad allocation",说明我们异常捕获成功,并没有出现弹窗。

    插曲:摩尔定律

             英特尔(Intel)联合创始人之一戈登·摩尔(Gordon Moore)在 1965年 提出的一个经验性预测,它描述了半导体技术发展速度的一个趋势。摩尔最初观察到,集成电路上可容纳的晶体管数量大约每 18到24个月 便会增加一倍,同时成本保持不变。后来,这个定律常被引申为:

    集成电路(IC)上的晶体管数量大约每两年翻一番,性能也随之提升一倍。

            最初,我们使用的32位计算机,它的内存空间是2^32字节,也就是大约4GB。但是现在的64位计算机他的内存来到了2^64字节。2^64字节 = 18446744073709551616 字节(18万4467亿)

    特性32位系统 (典型例子)64位系统
    总虚拟地址空间4GB左右18,400,000,000 GB
    内核空间划分例如 1GB (固定或可选2GB)例如 128TB (或更大,非固定比例)
    用户空间划分例如 3GB (或2GB),其中大部分用于堆等例如 128TB (或更大),堆等用户内存从中分配
    堆大小限制受限于总用户空间 (约 2-3GB)受限于进程虚拟地址空间上限和物理内存,远超32位限制

    上面介绍了C++内存开辟的所有使用方法,但是知道这些显然不够,让我们深入底层再探讨探讨

    3.4C++内存开辟的底层逻辑

    3.4.1operator new 与 operator delete 函数(重要点进行讲解)

            new 和 delete 是用户进行动态内存申请和释放的操作符,operator new 和 operator delete 是系统提供的全局函数(虽然有operator,但是他们不是任何一个函数的函数重载),new 在底层调用 operator new 全局函数来申请空间,delete 在底层通过 operator delete 全局函数来释放空间。

    3.4.1.1operator new

    看看这个函数的底层实现:

    void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
    {
    // try to allocate size bytes
    void *p;
    while ((p = malloc(size)) == 0)
    if (_callnewh(size) == 0)
    {
    // report no memory
    // 如果申请内存失败了,这里会抛出bad_alloc 类型异常
    static const std::bad_alloc nomem;
    _RAISE(nomem);
    }
    return (p);
    }

            该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;申请空间失败,尝试执行空间不足应对措施。如果改应对措施用户设置了,则继续申请,否则抛异常

    3.4.1.2operator delete

    operator delete: 该函数最终是通过free来释放空间的,为什么这么说?看底层实现。

    这是operator delete的底层:

    void operator delete(void *pUserData)
    {
        _CrtMemBlockHeader * pHead;
        RTCCALLBACK(_RTC_Free_hook, (pUserData, 0));
        if (pUserData == NULL)
            return;
        _mlock(_HEAP_LOCK);  /* block other threads */
        __TRY
            /* get a pointer to memory block header */
            pHead = pHdr(pUserData);
            /* verify block type */
            _ASSERTE(_BLOCK_TYPE_IS_VALID(pHead->nBlockUse));
            _free_dbg( pUserData, pHead->nBlockUse );
        __FINALLY
            _munlock(_HEAP_LOCK);  /* release other threads */
        __END_TRY_FINALLY
        return;
    }

           这一句:_free_dbg( pUserData, pHead->nBlockUse );delete正在调用函数_free_dbg来释放空间。
    我们再看看free的底层:

    #define free(p) _free_dbg(p, _NORMAL_BLOCK)

              宏定义_free_dbg这个函数为free()。所以free本质上就是_free_dbg,而delete调用了它。C++的设计师在设计他的时候沿用了C语言的底层原理。

    3.4.2new和delete的实现原理

    3.4.2.1内置类型

    如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不会调用构造析构函数。不同的地方是:

    1. new/ delete申请和释放的是单个元素的空间,new[ ]和delete[ ]申请和释放的是连续空间,
    2. new在申请空间失败时会抛异常,malloc会返回NULL
    3.4.2.2自定义类型
    new的原理
    1. 调用operator new函数申请空间
    2. 在申请的空间上执行构造函数,完成对象的构造

            new开空间的时候是不会自己搞一套开空间的方法,它会去调用malloc,但是它不会自己去调用那一套C的malloc,而是一套包装形态的malloc:即:opreator new。原因也很简单,为了C++的异常那一套操作。

    delete的原理
    1. 在空间上执行析构函数,完成对象中资源的清理工作
    2. 调用operator delete函数释放对象的空间
    new T[N]的原理
    1. 调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请
    2. 在申请的空间上执行N次构造函数
    delete[]的原理
    1. 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
    2. 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间

    3.4.3从汇编代码中看底层调用操作

    我们用来测试的类Asser:

    class Asser{
    public:
    	Asser(int x = 0, int y = 1)
    		:_x(x)
    		,_y(y)
    	{}
    	~Asser(){
    		_x = 0;
    		_y = 0;
    	}
    private:
    	int _x;
    	int _y;
    };
    new的底层

       如上图,我们在为str开辟Asser大小的空间时,先后调用了operator new函数和构造函数

    delete的底层

               如上图,delete底层调用了Asser::`scalar deleting destructor' (07FF6EFA71055h)  ,但是实际上,这个函数是operator delete函数和析构函数的包装

    3.5C/C++内存开辟的各种错乱情况

    3.5.1C/C++混用

    int main()
    {
        int* ptr=new int ;
        free(ptr);
        return 0;
    }

    程序会不会i崩溃?会不会内存泄漏?

    先说结论:不会崩溃也不会内存泄漏。

    • 内置类型不涉及构造和析构,没有调用构造函数的内置类型不会发生内存泄漏。这里new等同于malloc
    • 调用free调用的是free_dbg,delete也是调用free_dbg,free等同于delete。

             对内置类型可以这么写,但是不建议你这么写。但是自定义类型你不能这么搞,用free你少调用了一个析构函数。如果自定义类型的析构函数释放了xxx内存,那么这个时候就会出现内存泄漏。但是程序不会崩溃。

    3.5.2new/delete多对单

    int main()
    {
        Asser* ptr =new Asser[10];
        delete ptr;
        return 0;
    }
    

    先说结论:可能崩溃也可能内存泄漏。

            内存泄漏原理:new int [10]开辟一整块空间并调用10次构造函数。delete[] 释放一整块空间。但是只调用了一次析构函数。如果类内部申请了空间,那么这些空间就有可能被内存泄漏。

            崩溃原理:释放空间时释放内存的一部分。(有点复杂,下面详细分析)

             如上图,我们运行程序,确实崩溃了。转到反汇编并监视:size:

               我们看到,实际上new调用operator new开辟空间的时候开辟了88个字节的空间而不是80个字节。这多出来的8个字节空间是用来存放“开了多少个对象的”。

             如上图,一目了然,空间开辟后我们的ptr指针指向的是80个字节的开始,而不是整个被开辟空间的开始,这个就导致了delete的时候我们不能完全释放掉所有的空间。导致崩溃。

    但是值得注意的是,内置类型不会崩溃:

    int* str = new int[10];
    delete str;

             因为内置类型事实上我们就开辟了40个字节的空间,并没有额外的4字节空间来存放“开了多少个对象”,也就没有了从中间释放空间的情况。

             实际上自定义类型有这个额外空间而内置类型没有的本质是因为delete[]str,这个空间存放的“开了多少个对象”的这个数值,是在编译的时候给[]用的。内置类型既然没有析构函数,也就不需要采用额外开辟空间来存放析构函数的调用个数

    3.6总结malloc/free 和 new/delete 的区别(面试常考)

    共同点是:都是从堆上申请空间,并且需要用户手动释放。不同的地方是:

    1. malloc 和 free 是函数,new 和 delete 是操作符
    2. malloc 申请的空间不会初始化,new 可以初始化
    3. malloc 申请空间时,需要手动计算空间大小并传递,new 只需在其后跟上空间的类型即可,如果是多个对象,[] 中指定对象个数即可
    4. malloc 的返回值为 void*,在使用时必须强转,new 不需要,因为 new 后跟的是空间的类型
    5. malloc 申请空间失败时,返回的是 NULL,因此使用时必须判空,new 不需要,但是 new 需要捕获异常
    6. 申请自定义类型对象时,malloc/free 只会开辟空间,不会调用构造函数与析构函数,而 new 在申请空间后会调用构造函数完成对象的初始化,delete 在释放空间前会调用析构函数完成空间中资源的清理释放

    3.7placement-new

    定位 new (placement new) 是 C++ 中new操作符的一种特殊形式。它并不分配新的内存,而是允许你在已经分配好的内存中创建一个对象。

    它的基本语法如下:

    new (place_address) type
    new (place_address) type(initializer_list)
    • place_address: 这是一个指针,指向一块你预先已经分配好的、足够容纳 type 类型对象的内存空间。
    • type: 你想要在该内存中创建的对象的类型。
    • initializer_list: 用于初始化新创建对象的参数列表。

    3.8拆分使用new

    说白了就是显式调用构造和析构函数初始化/释放。

    int main(){
        A* p1 = new A(1);
        delete p1;
    
        A* p2 = (A*)operator new(sizeof(A));
        new(p2)A(1);
        p2->~A();
        operator delete(p2);
        return 0;
    }

             以上两套是一回事,看到这里,读者一定会想,“这不是脱裤子放屁吗?”

              没错,实际上,拆分使用new在99%的场景下是用不到的。在极少数的情况下,会有用,如“池化技术中的内存池”(这个以后会讲)。

    剩下一些内存泄漏的规则,我们暂时不谈,因为没法讲,我放到了指针指针再说。


            好的,本期内容就到这里,如果对你有帮助,还不要忘了点赞三联一波哦,我是此方,我们下期再见。

    转载自CSDN-专业IT技术社区

    原文链接:https://blog.csdn.net/Z2314246476/article/details/156543552

    评论

    赞0

    评论列表

    微信小程序
    QQ小程序

    关于作者

    点赞数:0
    关注数:0
    粉丝:0
    文章:0
    关注标签:0
    加入于:--