【C++】内存管理与new、delete详解

前言:C++相比于java、python来说并没有自动垃圾回收机制(GC),需要我们手动管理内存,C++的内存管理和我们C语言那里保存一致即可,甚至有了new和detele后管理内存比C语言还要方便而且更好支持自定义类型,关于C语言的内存管理在我之前的文章:【C语言】动态内存管理详细解析



1.C/C++的内存分布


在经典的32位/64位操作系统中,C/C++程序的内存通常被划分为五个主要区域。为了方便理解,我们通常按照内存地址从低到高或从高到低来排列。以下是标准的内存布局分布图:
在这里插入图片描述

1.栈区
  栈区一般用来存放函数的局部变量、函数参数、返回值以及函数调用的上下文(比如返回地址),这个区域并不用我们主动的管理内存,只要是编译器替我们完成,当我们进入一个函数时,变量被压入栈中;当函数返回时,这些变量被弹出销毁。栈区有以下的几个特点:

  • 地址增长方向从高地址向低地址生长(向下生长)与堆区正好相对
  • 生命周期随着作用域(如函数、循环块)的结束而自动销毁
  • 大小限制: 空间非常有限(通常默认几MB,比如Linux默认8MB,Windows默认1MB)。如果声明了过大的局部变量或递归过深,就会导致栈溢出,相比之下堆区一般会比栈区大很多
  • 在性能上因为仅仅是移动栈顶指针(寄存器),所以分配速度比较快

2.堆区
  堆区通常存放我们通过代码动态分配的内存(比如new、malloc、calloc)申请的内存空间,在C语言中我们用malloc, calloc, realloc 分配,使用 free 释放,而在C++中使用 new 分配,使用 delete 释放。关于堆区有下面几个特点:

  • 地址增长方向从低地址向高地址生长(向上生长)
  • 生命周期完全由我们控制。如果分配了不释放,就会造成内存泄漏
  • 空间很大,几乎受限于操作系统的虚拟内存大小,尤其是64位下达到了恐怖的数字(2^64)
  • 性能相比于栈区来说因为分配和释放需要调用操作系统API,容易产生内存碎片,速度相对较慢

3. 全局/静态存储区
  这个区域用来存放全局变量和静态变量,它在程序的整个生命周期内都存在。它在底层又被细分为两块:

  • 已初始化数据段 (.data):存放已经显式初始化且非零的全局变量和静态变量
  • 未初始化数据段 (.bss): 存放未初始化或初始化为零的全局变量和静态变量
  • 操作系统在加载程序时,会自动将 .bss 段的内存全部清零,因此未初始化的全局/静态变量默认值都是 0

4 只读数据区
  存放程序中不可修改的常量数据。比如字符串字面量(如 “Hello World”)和被 const 修饰且在编译期就能确定的全局变量。同样是严格只读的。如果用指针强行指向这里并试图修改,程序会直接崩溃。


5 代码段
  存放程序编译后的机器指令(也就是我们的代码编译出来的二进制文件)和函数体。这个区域有以下一个特点:

  • 只读: 操作系统为了防止程序在运行中意外修改自己的指令,将这块内存设置为只读。如果尝试修改会触发异常
  • 共享 : 如果同一个程序运行了多个实例(比如开了两个同样的软件),它们在物理内存中会共享同一份代码区


下面我们来看一个道经典的例题,先来来看下面的代码:
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在哪里?____

答案解析

  1. C:全局变量,生命周期贯穿整个程序,存放在全局/静态存储区
  2. C:静态全局变量,虽然它的作用域被限制在当前源文件内,但它的物理存储位置和全局变量一样,都在数据段
  3. C:静态局部变量。虽然它写在函数内部,但有 static 修饰,这意味着它只会被初始化一次,且函数结束后不会被销毁。它同样被挪到了数据段
  4. A:普通局部变量,没有 static 修饰,进入函数时自动在栈上分配,函数退出时自动销毁
  5. A:num1 是一个局部数组的名字。在 C/C++ 中,局部数组的所有元素(包括 1, 2, 3, 4 以及后面自动补的 0)都是在栈上连续开辟的空间
  6. A:char2 是一个局部数组名。char char2[] = “abcd”; 的底层原理是:编译器在常量区存放了 “abcd”,当程序运行到这一行时,在栈上开辟了 5 个字节的空间,并把常量区的 “abcd\0” 拷贝到了栈上的这块空间里。所以 char2 代表的是栈上的这块空间
  7. A:*char2 代表数组的第一个元素,即字符 ‘a’。既然整个数组都在栈上,它的元素自然也都在栈上
  8. A:pChar3 是一个指针变量。只要是函数内部定义的普通局部变量,不管它是什么类型(指针、整型、结构体),变量本身一定在栈上
  9. D:解引用 *pChar3 代表它所指向的内容。pChar3 指向的是 “abcd” 这个字符串字面量。字符串字面量是不可修改的,存放在只读数据区/常量区
  10. A:与第 8 题同理
  11. B:解引用 *ptr1 代表它指向的动态内存空间。因为这块空间是通过 malloc 申请出来的,malloc/calloc/realloc/new 申请的内存都在堆区


2.C++的内存管理方式


  C++里依旧可以用C语言的mallocfree那套,但是C++提供了newdetele这两个关键字来管理内存,虽然底层依旧是C语言那套,但是在套了一层马甲后功能更加的强大,下面来介绍这两个关键字的用法


2.1操作内置类型:

对于 int, double 等基础类型,可以直接分配并选择性地初始化:

int main()
{
	// 1. 仅分配内存,不初始化(是一个随机值)
	int* p1 = new int;

	// 2. 分配内存,并初始化为 0
	int* p2 = new int();

	// 3. 分配内存,并初始化为指定值 (例如 10)
	int* p3 = new int(10);

	delete p1;
	delete p2;
	delete p3;

	return 0;
}

2.2操作自定义类型

  如果用C语言的malloc申请空间的话对于自定义类型是不会对申请的类进行初始化的,但是new会自动调用类的构造函数,再这个类实例化时自动的初始化这个对象。同理,用delete会自动的调用这个类的析构函数,然后才是释放这个对象的空间:

class A
{
public:

	A(int a = 0)
		:_a(a)
	{
		std::cout << "A()" << std::endl;
	}

	A(const A& aa)
	{
		std::cout << "A(const A& aa)" << std::endl;
	}

	~A()
	{
		std::cout << "~A()" << std::endl;
	}

private:
	int _a = 1;
	int _b = 1;
};



int main()
{
	//会自动的调用构造函数进行初始化
	A* p1 = new A(1);
	
	//先调用析构函数,然后再用free释放(底层)
	delete p1;
	return 0;
}

2.3 数组用法

无论对于内置类型还是自定义类型,我们都可以使用new来创建数组:

int main()
{
	//申请大小为3个整形大小的整形数组
	int* arr1 = new int[3];

	//申请大小为3个整形大小的整形数组,并都初始化为0
	int* arr2 = new int[3]();

	//申请大小为10个整形大小的整形数组,并将前三个元素初始化为1、2、3
	int* arr3 = new int[10] {1, 2, 3};

	return 0;
}

调试观察:
在这里插入图片描述
对于内置类型也是一样的:

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

	//注意这里要使用delete[]释放,否则会产生未定义行为
	delete[] p1;

	return 0;
}


3.new和delete的底层原理简单介绍


3.1 operator new与operator delete函数


  new和delete是我们进行动态内存申请和释放的操作符,而operator new与operator delete函数是系统提供的全局函数,new在底层调用的正是operator new全局函数来申请空间,而delete正是调用的operator delete函数来释放空间

我们可以来看看operator new和operator delete的底层代码:

//operator new 实现
void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
    void *p;
    while ((p = malloc(size)) == 0)
    {
        if (_callnewh(size) == 0)
        {
            static const std::bad_alloc nomem;
            _RAISE(nomem);
        }
    }
    return (p);
}
//operator delete 实现
void operator delete(void *pUserData)
{
    _CrtMemBlockHeader * pHead;

    RTCCALLBACK(_RTC_Free_hook, (pUserData, 0));

    if (pUserData == NULL)
        return;

    _mlock(_HEAP_LOCK);
    __TRY

        pHead = pHdr(pUserData);

        _ASSERTE(_BLOCK_TYPE_IS_VALID(pHead->nBlockUse));

        _free_dbg( pUserData, pHead->nBlockUse );

    __FINALLY
        _munlock(_HEAP_LOCK);
    __END_TRY_FINALLY

    return;
}
//. 底层 free 的宏定义
#define free(p) free_dbg(p, _NORMAL_BLOCK)

可以看到实际上new、delete其实就是malloc、free套了层马甲,相当于是这两个的升级版。底层其实还是malloc和free,我们可以看看这个关系图:

【 new 操作符 (关键字) 】 —— 程序员在代码里写下的字眼
   │
   ├── 步骤 1:调用 【 operator new 函数 】 (获取物理内存)
   │      │
   │      └── 底层循环调用 【 malloc() 】 (向操作系统 C 库要内存)
   │
   └── 步骤 2:调用 【 类的构造函数 】 (在要来的内存上建房子)


【 delete 操作符 (关键字) 】
   │
   ├── 步骤 1:调用 【 类的析构函数 】 (拆除房子,清理内部资源)
   │
   └── 步骤 2:调用 【 operator delete 函数 】 (归还物理内存)
          │
          └── 底层调用 【 free() 】 (把内存还给 C 库和操作系统)

  可以看到虽然底层还是malloc、free但是C++的new和delete明显功能更加强大而且更好的支持了内置类型

  在C语言阶段,当我们申请内存失败了我们会判断时候返回空指针,但是在C++中,如果资源申请失败了并不会返回一个空指针,而是会抛异常,关于抛异常因为涉及到继承、多态的知识所以我想放到后面再讲解,反正我们平常写一些小练习小程序的基本是不会失败的,但是到正经项目中肯定是要处理这种情况的。美国有个火箭就是一个未被处理的异常导致陨落了,感兴趣可以看看这个视频为什么战斗机禁用 90% 的 C++ 功能


3.2 定位new表达式

  定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象,这个先了解一下即可,说实话我感觉挺偏的:

int main()
{
	A* p1 = new A(1);
	
	//申请空间
	A* p2 = (A*)operator new(sizeof(A));
	//定位new,调用构造函数
	new(p2)A(2);

	//析构
	p2->~A();
	operator delete(p2);
	
	delete p1;
	return 0;
}

上面的代码p1、p2在实例化和析构上是一样的,是不是有一种脱裤子放屁的感觉,但是存在即合理听过定位new在内存池上会有应用,这里我就不展开了因为我也不懂



3.3 new和delete总结


1.new的原理:

  • 调用 operator new 函数,向操作系统申请刚好容纳该对象的内存空间
  • 在这块刚申请到的内存空间上,执行该类的构造函数,完成对象内部数据的初始化

2.delete的原理:

  • 准备释放的空间上,先执行该类的析构函数,清理对象内部占用的其他资源(比如释放内部指针、关闭文件等)
  • 调用 operator delete 函数,把对象本身占用的这块内存还给操作系统

3.new T[N]的原理:

  • 调用 operator new[] 函数(实际上是调用 operator new),一口气申请足以容纳 N 个对象的总内存
  • 在这块连续的内存上,循环执行 N 次构造函数

4.delete[] 的原理:

  • 循环执行 N 次析构函数,依次完成这 N 个对象内部资源的清理
  • 调用 operator delete[] 函数(实际是调用 operator delete),将整块连续空间一次性释放


3.4new[]与delete错误搭配问题(图一乐):


我们先来看下面的代码,有两个类:

class A//有构造函数 
{
public:

	A()
	{
		std::cout << "A()" << std::endl;
	}

	A(const A& aa)
	{
		std::cout << "A(const A& aa)" << std::endl;
	}

	~A()
	{
		std::cout << "~A()" << std::endl;
	}

private:
	int _a = 1;
	int _b = 1;
};


class B//无构造函数
{

private:
	int _a = 1;
	int _b = 1;
};

可以运行报错的程序:

int main()
{
	A* p1 = new A[3];

	delete p1;
	return 0;
}

可以正常运行的程序:

int main()
{

	B* p2 = new B[3];

	delete p2;
	return 0;
}

那么问题来了,同样是错误的搭配,为什么A类报错了,但是B类却正常运行呢?当我错误搭配使用时会产生未定义的行为,因为类 A 提供了自定义析构函数,而类 B 没有。这导致编译器在底层为它们分配数组内存时,采用了不同的内存布局

&mesp;&mesp;类A有一个自定义的析构函数 ~A()。当编译器看到 new A[3] 时,它知道在释放这块内存时,必须调用 3 次析构函数,为了在 delete[] p1 时知道到底要调用多少次析构函数,编译器会在实际分配的内存块头部偷偷多分配一点空间(通常是 4 或 8 个字节),用来记录数组的元素个数。这个隐藏的记录通常被称为Cookie

  当我们调用 delete p1时编译器以为 p1 指向的是一个单个对象,于是它只对第一个元素调用了一次析构函数 ~A()接着,它试图把 p1 指向的地址直接交给底层的内存释放函数(如 C 语言中的 free())去释放,底层释放函数要求传入的地址必须是当初分配时的原始起始地址。但由于 Cookie 的存在,p1 实际上比原始地址偏移了几个字节。这时候把一个错误的、偏移过的指针交给了内存管理器,直接导致堆损坏:
在这里插入图片描述


  那为什么B可以成功运行呢?类 B 没有自定义析构函数(且它的成员变量 _a 和 _b 都是基本类型),编译器认为 B 是一个平凡析构类型,当编译器看到 new B[3] 时,它知道释放这块内存时不需要执行任何额外的析构代码。既然不需要调用析构函数,编译器为了优化内存和性能,干脆就不生成那个记录数组大小的 Cookie了

  当我们调用 delete p2 时编译器以为 p2 是单个对象,不调用析构函数,它把 p2 直接交给底层内存释放函数,因为没有 Cookie,p2 指向的地址恰好就是底层内存分配的原始起始地址。内存管理器一看,地址是对的,就顺利把这一整块(包含 3 个 B 对象的空间)物理内存给释放了。因此没有报错:
在这里插入图片描述
但我们正常正确的搭配使用就可以了,何必自找麻烦



4.malloc/free和new/delete的区别总结


我们可以把上面的几点总结成一个表格:

维度malloc / free (C 风格)new / delete (C++ 风格)
1. 语法属性标准库函数C++ 运算符 / 关键字
2. 尺寸与类型必须手动计算并传递字节大小(如 sizeof(int));

返回 void*,使用时必须强转类型。
后面直接跟类型/对象个数,自动计算大小(如 new int[10]);

返回具体类型指针,无需强转
3. 初始化申请的空间不会初始化,里面全是垃圾随机值。可以通过括号/大括号进行合法的初始化
4. 失败处理机制申请失败时返回 NULL,因此代码里必须判空申请失败时抛出 bad_alloc 异常,无需判空,但需要捕获异常。
5. 对象生命周期只会开辟空间,绝不会调用自定义类型的构造函数与析构函数申请空间后自动调用构造函数

释放空间前自动调用析构函数

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值