一、C/C++内存分布

目前为止,我们了解的到的内存分布大致如下:

栈区一般用来存放非静态局部变量、函数栈帧、返回值等;堆区是全权交由程序员用来动态内存管理;静态区存放全局变量和静态变量;常量区存放常量,如:"hello",字符串默认直接在常量区创建。

其实对这个内存的理解还是不够深,比如有这样一道题:

int globalVar = 1;
static int staticGlobalVar = 1;
void Test()
{
    static int staticVar = 1;
    int localVar = 1;
    int num1[10] = { 1, 2, 3, 4 };
    char char2[] = "abcd";
    const char* pChar3 = "abcd";
    int* ptr1 = (int*)malloc(sizeof(int) * 4);
    int* ptr2 = (int*)calloc(4, sizeof(int));
    int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);
    free(ptr1);
    free(ptr3);
}

选择题:
选项: A.栈  B.堆  C.数据段(静态区)  D.代码段(常量区)
globalVar在哪里?____
staticGlobalVar在哪里?____
staticVar在哪里?____
localVar在哪里?____
num1 在哪里?____


char2在哪里?____
* char2在哪里?___
pChar3在哪里?____
* pChar3在哪里?____
ptr1在哪里?____
* ptr1在哪里?____

如果能够将这些题答个七七八八,并且都能说出为什么,那么基本目前阶段对于内存的理解就没啥毛病了。

话不多说,一道一道来解释:

globalVar是全局变量,存放在静态区。

staticGlobalVar和staticVar都是静态变量,甭管到底在全局域还是局部域定义,都要存放在静态区。


这两道题倒是完了,但是还是得好好唠唠,一般来说,局部变量只是想要在局部域用,或者至少保持静态属性,即出了作用域也别销毁,就如当时学习static关键字的时候我们讨论过这个例子:

int Func()
{
    int a = 1;

    a++;

    return a;
}

int main()
{
    for (int i = 0; i < 5; i++)
    {
        printf("%d ", Func());
    }

    return 0;
}

眼熟吧,就是让你写个函数,打印个2,3,4,5,6,但是实际上因为局部变量进入局部域就创建,出了局部域就销毁,每次a都初始化成1,那么很显然,造成的问题就是做不到一次次真正++:

当时就由此引出了static:

int Func()
{
    static int a = 1;

    a++;

    return a;
}

int main()
{
    for (int i = 0; i < 5; i++)
    {
        printf("%d ", Func());
    }

    return 0;
}

当时解释就是static修饰局部变量,赋予局部变量静态属性,实际上就是将局部变量存放在静态区,也就好像创建出来的是全局变量一样,符合要求:

对于全局变量正常可能整个项目都得用,当时将static和extern一起讲,一个是展示extern可以引用同一项目下其它文件全局变量的值使用,一个是static可以改变全局变量的外部链接属性,使其只能在该文件内使用。


稍微回顾了一下,这些定义都有啥意义,接下来继续:

localVar标准的局部变量,创建在栈区。

num1是数组名,一般代表的意思是首元素的地址,当成指针变量看待的话,其实还是创建在局部域里,所以还是创建在栈区。

char2也是数组名,但是要明白这玩意咋创建的,首先字符串"abcd"肯定是创建在常量区,但是因为这个数组是在局部域里创建的,所以先是复制了一份到栈区,最后让char2指向了这一份,从而完成了初始化。归根结底,作为指针变量来说,还是创建到了栈区,毕竟随着初始化内容后创建。

有理论char2的解释,很明显,*char2指向的就是栈区复制的那一份。

经过char2让我们理解了字符数组的底层初始化和创建的原理。

下面来看常量指针pChar3的理解:

对于pChar3其实可以像char2一样理解,不管指向的内容存放在哪里,其实当你这么写出来,字符串肯定是存放在常量区,你写个pChar3也只是在这个局部域内有办法调用了(当然是只读),所以这么说来pChar3就是存放在栈区。

*pChar3不同于*char2的原因是字符数组和常量指针创建的不同,字符数组是复制一份到栈区,而常量指针是直接指向,所以修饰常量指针的const是不能省的,综上,很明显*pChar3存放在常量区。

经过上面那么多,ptr1也是局部变量,创建在栈区,只不过*ptr1是接收的堆区申请的内存,所以*ptr1在堆区。

经过这么多例子,又对这四个区域的创建进行一定深入的认识。

二、C语言中内存管理方式:malloc/calloc/realloc/free

C语言动态内存管理借助的就是这四个函数,malloc函数返回一个void*的地址,传参传开辟多少个字节大小的空间;calloc参数返回值与malloc完全一样,效果相当于malloc+memset为0;realloc需要传指针,以及要的内存大小;free就是传动态开辟的指针,给它释放掉,具体没啥需要再多讲了,C语言阶段已经深入学习了:C语言学习记录(15)动态内存管理-CSDN博客

三、C++内存管理方式

C++采取使用关键字new和delete进行动态内存管理。

1.new/delete操作内置类型

void Test1()
{
    //动态申请一个int类型的空间
    int *ptr1 = new int;
    //动态申请一个int类型的空间并初始化
    int* ptr2 = new int(1);
    //动态申请多个int类型的空间
    int* ptr3 = new int[3];
    //动态申请多个int类型的空间并部分初始化
    int* ptr4 = new int[10] {1, 2, 3, 4};
    //动态申请多个int类型的空间并完全初始化
    int* ptr5 = new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    delete ptr1;
    delete ptr2;
    delete[]ptr3;
    delete[]ptr4;
    delete[]ptr5;
}

简单的写几个例子,一行一行来讲解。

首先创建一个最简单的int类型的对象,重点是见识一下语法格式:

 int *ptr1 = new int;

new + 类型就相当于以前C语言的malloc,并且最终返回的是一个对应类型的指针,即申请个int就得返回个int*的指针。

 int* ptr2 = new int(1);

 还可以看见非常强力的功能,可以直接用new + 类型后用()初始化这个空间。

不仅仅是只能申请一个该类型的空间,可以用类似于数组的方式创建多个该类型的空间:

 int* ptr3 = new int[3];

还可以用{},就好像对数组初始化一样,对开辟的空间初始化:

 int* ptr4 = new int[10] {1, 2, 3, 4};

而且可以观察到,如果部分初始化,那么未初始化的空间将会:

直接初始化成0。

最后见识一下完全初始化:

至于delete,那就更简单了,很明显,只申请一个空间的直接delete + 变量名就行,申请多个空间的用delete + [] + 变量名即可。

 2.new/delete操作自定义类型

前面其实没少说过,C++有很多功能创造出来都是为了优化升级C语言中的一些功能。

比如这样一个场景:

void Test2()
{
    //单个自定义对象申请空间对比
    A* p1 = (A*)malloc(sizeof(A));
    A* p2 = new A(1);
    free(p1);
    delete p2;
    //多个自定义对象申请空间对比
    A* p3 = (A*)malloc(sizeof(A) * 10);
    A* p4 = new A[10];
    free(p3);
    delete[] p4;
    //多个自定义对象申请空间并初始化对比
    A* p5 = (A*)malloc(sizeof(A) * 3);    
    
    A* p6 = new A[3]{ 1,2,3 };
    free(p5);
    delete p6;
}

单个的如果malloc/free根本没办法对类包裹的成员变量初始化,因为我们之前了解的memset是相当于对你给的指针位置以及往后相应的偏移量全部设置成你给的值,内置类型你知道他里面就只有个int,对于自定义类型你直接set那你敢确定吗?

剩余多个自定义对象道理是一样的。

而且对于多个自定义对象申请空间并初始化上,可以有多种初始化方式:

void Test3()
{
    //有名对象初始化
    A a1(1), a2(2), a3(3);
    A* ptr1 = new A[3]{ a1,a2,a3 };
    //匿名对象初始化
    A* ptr2 = new A[3]{ A(1),A(2),A(3)};
    //直接借助构造函数隐式类型转换初始化
    A* ptr3 = new A[3]{ 1,2,3 };
    
    delete[]ptr1;
    delete[]ptr2;
    delete[]ptr3;
}

对比下来,可以说写一个好的构造函数,创建多个对象就会非常舒服,只要你懂一点点语法,你会发现代码可读性非常强。

而且new对于自定义类型最明显的好处就是会调构造函数,delete会调析构函数:

不妨再看一个类:

struct ListNode
{
    ListNode(int val = 0)
        :_val(val)
        ,_next(nullptr)
    {}
    int _val;
    struct ListNode* _next;
};

int main()
{

    ListNode* n1 = new ListNode(1);
    ListNode* n2 = new ListNode(2);
    ListNode* n3 = new ListNode(3);
    ListNode* n4 = new ListNode(4);

    n1->_next = n2;
    n2->_next = n3;
    n3->_next = n4;

    return 0;
}

以前废了八劲的写个ButNode,一直得调用,现在直接写好个构造函数,一劳永逸,主要看起来可读性非常强。

四、operator new和operator delete函数

new和delete难道说就是operator new和operator delete的重载吗?

严格来说不是的,其实从底层角度来说这两个函数是new和delete的组成部分。

比如拿过来我们的Stack类:

typedef int STDataType;
class Stack
{
public:
    Stack(int n = 4)
    {
        cout << "Stack(int n = 4)" << endl;
        STDataType* temp = (STDataType*)malloc(sizeof(STDataType) * n);
        if (temp == nullptr)
        {
            perror("malloc fail!");
            exit(1);
        }
        _arr = temp;
        _capacity = n;
        _top = 0;
    }
    ~Stack()
    {
        cout << "~Stack()" << endl;
        free(_arr);
        _arr = nullptr;
        _capacity = _top = 0;
    }

private:
    STDataType* _arr;
    int _top;
    int _capacity;
};

现在假如new一个Stack对象:

底层就是这样的:

系统先sizeof去计算对于一个对象需要开辟多大的空间,在32位操作系统下就是12个字节,然后就要调用构造函数去初始化,最后delete肯定要先调用析构去释放资源,然后再销毁对象。        

/*
 operator new:该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;申请空间
失败,尝试执行空
间不足应对措施,如果改应对措施用户设置了,则继续申请,否
*/
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);
}
/*
 operator delete: 该函数最终是通过free来释放空间的
*/
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的实现
*/
#define   free(p) _free_dbg(p, _NORMAL_BLOCK)

底层其实operator new和operator delete就是malloc和free的封装而已,但是为什么C++要费劲去写这么多呢,当然和下面的代码有关:

如果申请内存失败,即在创建对象时就失败,那么将会抛出异常,至于什么是异常,后期自会学习,现在只需要知道,封装起来就是为了方便抛出异常。

至于free,其实底层是宏函数实现,可不要说operator里面没有free函数。

然后再展示一下反汇编:

计算需要创建多大空间给对象,0Ch就是12个字节,没毛病;

然后调完operator new再调构造。

delete这其实还稍微封装了一下,我们进入如图所示函数后:

也是符合预期的。

五、new和delete的实现原理

1.内置类型

如果对于内置类型的空间申请,new/malloc,delete/free基本类似。不过不同的是,new如果申请失败,将会抛出异常,malloc如果申请失败,将会返回一个NULL;另外,new/delete可以连续申请和释放多个内存空间,借助new[]和delete[]。

2.自定义类型

①new

先使用operator new申请空间,然后再在申请的空间上调用相应的构造函数完成初始化。

②delete

调用析构函数先释放资源,当然,没有申请资源的类自然不用释放资源,而后调用operator delete去释放operator new申请的空间。

③new [N]

其实看着怪nb,底层来说是调用operator new[]来申请空间,但是operator new[]其实也是operator new的N次实现的封装,N次operator new后紧跟着就是N次构造函数的调用,所以总得来说new [N]就是(operator new + 构造函数)*N

④delete []

调用n次析构函数先将所有的资源释放,再调用operator delete[],不过operator delete[]只会调用一次operator delete,毕竟说起来operator delete是free的封装,只需要给出来申请的地址的起始地址,free就完成了,实质上相当于析构函数*n + operator delete。


注意

千万不要随便匹配,因为已经了解operator new是malloc的封装,operator delete是free的封装,但是,难免会有一些情况其实会不对。

比如这么一个例子:

class A
{
public:
    A(int a = 0)
        :_a(a)
    {
        cout << "A:" << this << endl;
    }
    ~A()
    {
        cout << "~A():" << this << endl;
    }

private:
    int _a;
};

int main()
{
    A* ptr = new A[10];

    delete[]ptr;

    return 0;
}

其实看见这个代码,下意识就是要申请10*4个字节,毕竟A只有个int属性,连续申请10个int就是4

0个字节,但是一旦搞个反汇编:

h是16进制的后缀,2C就是2*16+12就是44个字节,咋还多了4个字节,这4个字节哪里弄来的呢?

既然如此,那我们就看看内存里存的到底是啥,咋又多存4个字节:

首先通过监视窗口我们大概知道了ptr的地址,什么f004,而后内存窗口我们调整为一行4列,也就是一行代表4个字节,上下多截了几个字节,发现在ptr前像是多申请的那4个字节,一看0a 00 00 00,我们知道整型在内存中的存储VS里是小端字节序吧,小端字节序低地址存低位,那么存的值为10,刚好是谁呢?

new A[10],数组元素个数,所以系统会申请空间来存数组元素个数,有啥用呢?

我们知道delete []是不用给元素个数的,这样的话怎么确定调用几次析构和operator delete呢,所以肯定会先读这个值,确定调几次析构,全部资源释放完以后,调用operator delete释放。

另外再说一下,在系统中数组元素个数是用size_t申请的空间来存的,而size_t在64位系统中是8个字节,在32位系统中是4个字节,我上面的代码了,调试了,均是在32位下进行的,是44个字节,如果是64位应该是48个字节,多的就是size_t的增长,展示下吧:

最后看看能不能从反汇编展示一下delete到底怎么释放的:

这次调试给的是f684,我们知道大概在f680的位置存的是10,这个空间肯定也得被释放,换句话说,到时候operator delete应该在f680这个位置开始free:

很明显传给eax的地址就是f680,简单来说底层会干一个类似于(char*)ptr - sizeof(size_t)的操作传这个指针给operator delete来free,最终多申请的也需要free。

深入理解了多个对象申请与释放以后,回到我们最开始的问题,为什么不让你随便匹配,假如我们没有深入再理解这里,那假如你一直把delete当free用,对于这种情况将会:

直接干崩了,现在深入理解后其实就理解了,相当于这样:

蓝的可能申请可能不申请,因为蓝的存在的意义,细究就是存到底执行多少次析构函数,你想是不是,毕竟系统固定了,到时候free的位置是定死的吧,你给delete我就直接在ptr位置释放,你给我delete[]我就会计算一下偏移量然后释放,但是很明显调用的析构函数纯让那个蓝框框控制。

你如果自定义类型不用new [N]/delete[],而是当成free只用delete,析构调不够不说,你就说最底层的free是不是相当于从开辟的内存中间也就是第一个图去释放,系统不崩才怪。

当然,内置类型倒是没有这个烦恼,毕竟其实不调析构函数,那么系统从ptr位置释放就可以了,算偏移量ptr也不变:

从反汇编角度由于VS的优化,内置类型delete不调析构,两种代码都不会多申请空间去记录:

这个玩意当然是取决于编译器行为。

说这么多,还是说,你要是new就delete,要是malloc就free,要new [N]就delete [],千万别仗着对底层了解就随便整上了,人家封装好就是让你用的,非得装,那到时候程序爆雷就怪不得谁了。


六、定位new表达式(placement-new)

定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象。

使用格式:

new (place_address) type或者new (place_address) type(initializer-list) place_address必须是一个指针,initializer-list是类型的初始化列表

简单来说其实就是显式调用构造函数。

比如这样一个例子,还是A类型:

我想创建一个A类型的对象,但是呢,我又不想让它抛出异常,那么这样的话肯定不能用new/delete了,只能上malloc和free了,但是问题就在于,即使加上public修饰,我们也只能显式调用析构,而不能显式调用构造:

所以这里用个定位new表达式:

就没啥毛病了,当然,现在就写这么多代码,没必要再检查malloc返回值了,如果这么点内存申请就得崩,那这电脑也可扔了。

目前看来没啥用处,等到后面写大项目再见识,现阶段了解即可。

七、malloc/free和new/delete的区别

共同点:

malloc/free和new/delete都是从堆上申请内存,并且需要程序员手动释放。

不同点:

1.malloc和free是函数;new和delete是操作符

2.malloc申请的空间不会初始化;new可以初始化

3.malloc传参需要手动计算空间大小;new只需要给出类型,多个对象用[]指定大小即可

4.malloc的返回值为void*接收,所以最后必须强转为需要的类型;new直接根据后面类型返回相应类型

5.malloc申请失败以后返回NULL,所以必须对返回值进行判断;new需要捕获异常

6.对于自定义类型的对象申请,malloc/free只能对空间进行申请和释放,不会调用构造函数和析构函数;new/delete在申请完空间以后会调用构造函数,在最后释放空间前还会调用析构函数。

总的来说就是初始化、传参、返回值、错误、自定义类型。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐