09 【C++ 初阶】C/C++内存管理

文章目录

  • 前言
  • [1. C/C++内存分布](#1. C/C++内存分布)
  • [2. C语言中动态内存管理方式](#2. C语言中动态内存管理方式)
  • [3. C++内存管理方式](#3. C++内存管理方式)
    • [3.1 定位new表达式(placement-new)](#3.1 定位new表达式(placement-new))
    • [3.2 new/delete](#3.2 new/delete)
      • [3.2.1 new和delete操作自定义类型](#3.2.1 new和delete操作自定义类型)
      • [3.2.2 new和delete操作内置类型](#3.2.2 new和delete操作内置类型)
      • [3.2.3 new和delete的实现原理:](#3.2.3 new和delete的实现原理:)
    • [3.3 重载operator new和operator delete](#3.3 重载operator new和operator delete)
  • 常见面试题
  • 全文总结

前言

通过00【C++ 入门基础】前言得知,C++是为了解决C语言在面对大型项目的局限而诞生:

C语言面对的现实工程问题(复杂性、可维护性、可扩展性、安全性)

C语言面临的具体问题:

复制代码
1.struct 数据公开暴露,函数数据分离,逻辑碎片化。(复杂性、安全性)
2.修改数据结构,如 struct 新增字段,可能导致大量相关函数需要修改。(可维护性)
3.添加新功能常需修改现有函数或结构体,易引入错误。(可扩展性)
4.资源(内存、文件句柄)需手动管理,易泄漏或重复释放。(安全性)

类和对象阶段,我们知道,C++引入了面向对象编程(即引入类的概念),并且规定特殊的类成员函数,保证存在内存各种地方的类对象的资源初始化和销毁自动进行,但是如果是C语言在堆上的内存申请方式(malloc等),它没办法满足以上C++的面向对象特性,因为它不显示的调用我们类的构造和析构,而是直接与堆进行操作,不执行我们的构造和析构,就无法实现基本的RAII。

C++ 引入 new 和 delete 并非仅仅为了替换 malloc 和 free 的语法。它解决的核心问题是 C 语言动态内存分配机制对面向对象特性(特别是构造/析构)缺乏支持以及类型不安全。


1. C/C++内存分布

要想分析我们C++的内存管理对于C语言的提升,我们首先需要了解一下基本的知识:

内存究竟是如何分布的?我们一般不同方式申请的对象它存在内存的什么区域呢?

我们知道,一个程序,想要跑起来,需要经过一些过程,最终变成我们的可执行文件,但是我们在代码中不是常说"创建了一个堆上的对象""调用一个函数就是在栈上创建了一个栈帧"的概念吗?

这个可执行文件就是一个文件而已,怎么能创建对象或者调用函数呢?

没错,只有在我们的操作系统运行了这个可执行程序,才会有以上的操作,所以,可执行文件中,其实记录了我们代码的逻辑,包括我们C++代码中使用的全局变量、定义的各种函数、代码中使用了哪些依赖关系 和 程序执行应该从哪条机器指令开始等等一切程序被运行所需要的信息。

当用户双击或在命令行中键入可执行文件名时,操作系统(Shell/Command Processor)会请求内核创建一个新的进程。这个新进程会先获得一个虚拟地址空间的骨架。

操作系统内部的加载器根据可执行文件头中的元数据信息,将文件的相关部分加载到新进程的虚拟地址空间中。具体步骤包括:

将代码段(机器指令)从文件复制到内存(映射到虚拟地址空间)。

为初始化数据段(.data)分配内存并复制初始值。

为未初始化数据段(.bss)分配内存并将其置零。

将程序计数器 (PC/EIP/RIP)​设置到文件中指定的入口点(Entry Point)​。

所以,可执行文件,就是包含代码逻辑,指导操作系统如何建立进程的虚拟地址空间(通过元数据描述布局和内容)的一个文件。

虚拟地址空间分布:

  1. 代码段(Text Segment):存放可执行的机器指令。这部分通常是只读的。
  2. 数据段(Data Segment):存放已初始化的全局变量和静态变量。这部分数据在程序加载时从可执行文件中加载并初始化。
  3. BSS段(Block Started by Symbol Segment) :存放未初始化的全局变量和静态变量。BSS段的特殊之处在于,它在可执行文件中不占用实际空间,而只是记录了这些变量的名称和大小。当程序加载到内存中时,操作系统或C运行时库会负责将BSS段的内存区域全部清零。这是为了确保所有未显式初始化的全局和静态变量在程序启动时都具有确定的零值,从而避免使用随机的"垃圾值"导致程序行为不可预测。
  4. 堆区(Heap Segment) :用于动态内存分配。程序员通过 malloc/calloc(C语言)或 new/delete(C++)等函数在此区域申请和释放内存。堆区的大小在程序运行时可以动态增长和收缩。操作系统通常不会对新分配的堆内存进行自动清零,除非显式请求(如 calloc)。
  5. 栈区(Stack Segment):用于存放局部变量、函数参数、返回地址等。栈区由编译器自动管理,遵循"先进后出"的原则。当函数被调用时,栈帧被创建;当函数返回时,栈帧被销毁。栈上的局部变量通常不会被自动初始化,其内容是未定义的"垃圾值"。

每个进程都有这个进程地址空间,是操作系统"欺骗"进程的手段,让每个进程都以为自己有这么大的内存。
我们现在就单纯的认为以上的就是内存的分布即可。

看看我们的C++代码中使用的各种变量,在内存(虚拟地址空间)中的分布是怎么样的:

  • 局部临时变量:
    当函数被调用时,系统为该函数在调用栈上创建一个新的栈帧(Stack Frame)。当程序执行流到达该变量的声明语句时(严格来说,在进入其作用域时),系统在 栈帧 中为其分配所需的内存空间;
  • malloc / new 分配的内存:
    当程序执行到 malloc 或 new 调用语句时,运行时库(Runtime Library)或操作系统在 上查找并分配一块符合要求大小的空闲内存,返回其地址给程序;
  • 全局变量、静态变量 (static) :
    在程序启动(加载)时,操作系统会在 数据段 为其分配空间并设置好初始值(.data 初始化为指定值,.bss 初始化为0);
  • 只读常量(字符串字面量、constexpr、部分常量全局变量):
    程序启动(加载)时被加载到只读内存区域。constexpr 在编译时求值,结果通常存放在 代码段 或在优化时被嵌入使用它的代码中;

我们本文讲解的内存管理,就是我们程序员手动在堆上分配的内存。


2. C语言中动态内存管理方式

要想了解C++内存管理对于C语言内存管理的提升,我们还需要了解C语言的内存管理方式:

malloc:

函数声明:void* malloc (size_t size);

分配内存块,分配一个大小为字节的内存块,并返回指向该块起始位置的指针。

新分配的这块内存区域中的内容尚未进行初始化,因此其值是不确定的。

如果size为零,则返回值取决于具体的库实现(可能是空指针,也可能不是),但返回的指针不得进行解引用操作。

calloc:

函数声明:void* calloc (size_t num, size_t size);

为数组分配内存并进行零初始化,为包含 num 个元素的数组分配一段内存空间,每个元素的长度为 size 字节,并将所有位初始化为零。

最终的结果是分配了一个大小为(num * size)字节的零初始化内存块。

如果尺寸为零,则返回值取决于具体的库实现(可能是空指针,也可能不是),但返回的指针不得进行解引用操作。

realloc:

函数声明:void* realloc (void* ptr, size_t size);

重新分配内存块,更改由 ptr 指向的内存块的大小。

该函数可能会将内存块移动到一个新的位置(移动后的地址将由该函数返回)。

内存块中的内容会保留到新大小和旧大小中的较小值为止,即便该内存块被移动到了新的位置。如果新大小更大,新分配部分的值则是不确定的。

如果 ptr 是一个空指针,该函数的行为类似于 malloc 函数,会分配一个大小为 bytes 的新块,并返回指向该块起始位置的指针。

free:

函数声明:void free (void* ptr);

释放内存块,之前通过调用 malloc、calloc 或 realloc 函数分配的内存块将被释放,使其可再次被分配使用。

如果 ptr 指向的并非是通过上述函数所分配的内存块,那么就会导致未定义的行为。

如果 ptr 是一个空指针,那么该函数则不执行任何操作。

请注意,此函数并不会改变 ptr 的实际值,所以访问释放之后ptr指向的位置将是违法的。

cpp 复制代码
void Test()
{
	int* p1 = (int*)malloc(sizeof(int));                 //申请一块指定大小的内存空间
	free(p1);

	int* p2 = (int*)calloc(4, sizeof(int));              //申请一块连续的内存空间,并且对其初始化.
	int* p3 = (int*)realloc(p2, sizeof(int) * 10);       //对已申请的一块空间重新指定的大小,
														 //如果指定的内存块大于原内存块,则重新给于一块新内存块,并将原数据拷贝,并将原内存块归还;
														 //如果指定的内存块小于原内存块,就会在原内存块处缩小,将多余的大小连同数据一起丢弃.
														 
	// 这里需要free(p2)吗?   //不需要,因为realloc已经将p2指向的内存块归还了.
	free(p3);

	return 0;
}

C语言malloc只是一个封装好的接口,代替我们去访问操作系统申请内存,至于它内部对于内存的申请方式和管理方式,我们后续有机会再谈。


3. C++内存管理方式

从C语言申请堆内存的代码可见,我们用C语言申请的内存块,都需要我们程序员使用free手动的释放,这就会有内存忘记释放而导致内存泄漏的安全性问题,该问题C++的解决方式在我们前面几篇文章已经讲解了。

更致命的是,C语言的内存管理方式,缺乏对C++面向对象特性(特别是构造/析构)的支持,看如下场景:

我们有一个类,是使用RAII的方式管理内存块资源的,但是又因为一些特殊的需求,我们将它申请在堆上,

cpp 复制代码
#include<assert.h>
class ResourceManagement
{
public:
	ResourceManagement()
	{
		_ptr = (int*)calloc(4, sizeof(int));
		for (int i = 0; i < 4; i++)
		{
			_ptr[i] = i;
		}
	}
	~ResourceManagement()
	{
		free(_ptr);
	}
	int operator[](int i)
	{
		assert(i > 0 && i < 4);
		return _ptr[i];
	}
private:
	int* _ptr = nullptr;
};
int main()
{
	ResourceManagement R;
	cout << R[1] << " " << R[2] << " " << R[3] << endl;     //1 2 3

	ResourceManagement* ptrR = (ResourceManagement*)malloc(sizeof(ResourceManagement));   // (进程 24804)已退出,代码为 -1073741819 (0xc0000005)。
	cout << (*ptrR)[1] << " " << (*ptrR)[2] << " " << (*ptrR)[3] << endl;

	return 0;
}

为什么在堆上开辟它,就会报错呢?

因为我们C语言的malloc,它只是申请内存!

那么通过前几篇我们知道,编译器自动调用函数、自动生成函数、生成中间代码等各种马叉虫操作无所不能,为什么就不在malloc空间之后,立刻自动调用我们对应类的构造函数呢?

  1. C++ 兼容C语言:

    • C++ 必须兼容 C 的底层内存操作。malloc 是 C 标准库函数,其行为在 C 中定义为 只分配原始内存(不初始化内存内容)。
    • 若 C++ 编译器擅自修改 malloc 的行为(如自动插入构造函数调用),会破坏 C/C++ 混合编程的兼容性。纯 C 代码依赖 malloc 仅分配内存的特性加。
  2. 内存分配与对象构造的解耦
    C++的核心原则:不为不需要的功能付费(Zero Overhead Principle)、保证程序员掌控力。

    • 对于内存管理,有只需要内存,而不需要调用构造的需求场景:
      我们需要保证一些程序员在特殊场景下的需求,且如果全部都默认调用构造的行为,无疑会增加开销:
    cpp 复制代码
    // 场景1:用于原始内存缓冲(不需要构造)
    char* networkBuffer = (char*)malloc(1024);  // 编译器强行调用char的构造函数?char不需要构造!
    
    // 场景2: 预分配大内存池(只分配原始内存)
    void* memoryPool = malloc(GB(1)); 
    //按需构造对象(避免频繁分配)
    for (int i=0; i<1000; ++i) {
    	// 从内存池切片后手动构造
    	MyClass* obj = new (memoryPool + offset) MyClass(); 
    	obj->~MyClass(); // 手动析构但不释放内存
    }
    // 整个程序周期只分配一次内存

    这样做保证程序无需为不需要的功能付费,也保证程序员员在需要时对程序的掌控力。

但是如果我们如果malloc去申请类对象的空间,那么我们应该再使用C++的关键字new去调用其构造函数,为其初始化。

3.1 定位new表达式(placement-new)

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

使用格式:
new(place_address) type;或者new(place_address) type(initializer-list);

place_address必须是一个指针,指向一块已经分配的内存空间,initializer-list是传给对应构造函数的参数列表。
使用场景:

定位new表达式在实际中一般是配合内存池使用。因为内存池分配出的内存没有初始化,所以如

果是自定义类型的对象,需要使用new的定义表达式进行显示调构造函数进行初始化。

cpp 复制代码
class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		cout << "A():" << this << endl;
	}
	~A()
	{
		cout << "~A():" << this << endl;
	}
private:
	int _a;
};

//定位new/replacement new
int main()
{
	//p1现在指向的只不过是与A对象相同大小的一段空间,还不能算是一个对象,因为构造函数没有执行
	A* p1 = (A*)malloc(sizeof(A));
	new(p1)A;//注意如果A类构造有参数,则需要传参.
	p1->~A();
	free(p1); 

	return 0;
}

但是在C++中其实大部分场景我们还是不使用C语言的malloc的,而是直接使用new和delete操作符进行动态内存管理。

3.2 new/delete

3.2.1 new和delete操作自定义类型

C++引入new和delete做内存管理,对比malloc和free,当使用它们时编译器会:new在申请堆内存空间的前提下,会自动调用构造函数;会在delete释放内存之前,自动的调用析构函数。

cpp 复制代码
class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		cout << "A():" << this << endl;
	}
	~A()
	{
		cout << "~A():" << this << endl;
	}
private:
	int _a;
};
int main()
{
	// new/delete 和 malloc/free最大区别是 new/delete对于【自定义类型】除了开空间还会调用构造函数和析构函数
	A* p1 = (A*)malloc(sizeof(A));
	A* p2 = new A(1);   //A():0000018498157C90
	
	free(p1);
	delete p2;          //~A():0000018498157C90

	return 0;
}

3.2.2 new和delete操作内置类型

cpp 复制代码
void Test()
{
	// 动态申请一个int类型的空间
	int* ptr4 = new int;

	// 动态申请一个int类型的空间并初始化为10
	int* ptr5 = new int(10);
	
	delete ptr4;
	delete ptr5;
	
	// 动态申请10个int类型的空间
	int* ptr6 = new int[3];
	delete[] ptr6;
}

3.2.3 new和delete的实现原理:

先抛出结论:
"C++中的new、delete其实是运算符,当我们使用new、delete给自定义类型申请堆内存时,其实是编译器在调用operator new、operator delete的全局函数,给我们在堆上申请/释放空间,并会自动调用对应的构造函数(如果是自定义类)和析构函数。"

我们在库中代码找到对应的函数声明:(使用ctrl+鼠标左键,可以快速跳转到函数的声明或者定义处)

cpp 复制代码
//new运算符重载的函数声明:
void* __CRTDECL operator new(size_t _Size);

//delete运算符重载的函数声明:
_VCRT_EXPORT_STD void __CRTDECL operator delete(void*  _Block,size_t _Size) noexcept;

以此可见,new和delete其实就是在调用运算符重载函数operator new 和operator delete。

operator new、operator delete的全局函数:

cpp 复制代码
/*
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;
}

通过上述在标准库中的实现的operator new、operator delete全局函数知道,operator new实际也是通过malloc来申请空间,如果malloc申请空间成功就直接返回,否则执行用户提供的空间不足应对措施,如果用户提供该措施就继续申请,否则就抛异常。operator delete 最终是通过free来释放空间的。

疑问1:

operator new中并没有调用对类的构造,operator delete中也没有调用类的析构,不是说会调用的嘛?为什么没有?

C++标准规定,规定:

  1. operator new和operator delete函数只是操作内存的函数,只operator new只申请内存,而operator delete只释放内存;

  2. 并且规定必须在成功分配的内存上构造对象。

所以我们编译器去执行的时候,就是:

  1. 识别到了new表达式,就去调用对应的operator new函数申请对象;识别到了operator delete,就去调用对应的operator delete函数;
  2. 并且只要识别到了new,在operator new函数申请好内存之后就必须自动调用构造函数,
    相同的,识别到delete,就要在operator delete释放内存之前自动调用析构函数。

这是一种内存申请、释放和自动调用构造、析构之间的解耦,也是为了一些特殊场景的设计,比如只申请内存的场景:

cpp 复制代码
#include<iostream>
using namespace std;
class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		printf("A(int a = 0):你好世界!\n");
	}
	~A()
	{
		printf("A(int a = 0):再见世界!\n");
	}
	int _a;
};
int main()
{
	A* p1 = (A*)operator new(sizeof(A));
	operator delete(p1);

	A* p2 = new A();      //A(int a = 0):你好世界!
	delete p2;            //A(int a = 0) :再见世界!
	return 0;
}

以上代码只有通过new运算符去调用的内存申请函数、释放函数,才会被编译器自动的调用构造函数和析构函数。

直接调用的内存申请函数、释放函数,编译器是不会自动调用构造和析构的。

这个自动调用的行为和我们之前讲的编译器对与局部类对象的构造和析构的自动调用是一样的,都是编译器通过在AST抽象语法树的调用节点中的合适位置自动插入对应的函数调用节点,并且如果程序员没有手动实现构造函数的话,编译器会在根据AST树生成中间代码的时候生成对应的函数逻辑。

疑问2:

operator new的参数是size_t类型的,而一般我们的使用方式是:new Myclass();,为什么可以匹配到size_t类型的类型?

分解编译器如何处理 new MyClass():

  1. 计算对象大小:

    编译器在编译期间(编译时)​静态计算​ MyClass所需的完整对象大小 sizeof(MyClass)。

    sizeof(MyClass)是一个编译时常量表达式。它考虑了成员变量的大小、对齐要求(Padding)、基类、虚函数表指针(如果有多态)等所有因素。

  2. 调用 operator new并传入大小:

    编译器将 new MyClass()解析为一个 CXXNewExpr节点。

    在该节点的代码生成阶段,编译器生成对 ​operator new​ 函数的调用代码。​sizeof(MyClass)被作为 operator new的第一个(必需的)参数传入。

    与编译器生成的中间代码(LLVM)的等效C++代码示例:

    cpp 复制代码
    // 概念上编译器生成的中间步骤 (不一定直接可见):
    void* raw_memory = ::operator new(sizeof(MyClass)); // 核心在此!调用operator new, 传入MyClass的大小
  3. operator new利用大小分配内存:

    operator new(size_t size)函数(无论是全局库实现的还是用户自定义重载的)接收到这个 size参数(就是 sizeof(MyClass))。

    该函数负责依据传入的 size值,在堆上分配足够容纳该对象的连续内存块。标准的全局 operator new通常封装系统调用(如 malloc)来分配这块大小为 size字节的原始内存。

    分配成功则返回指向这块原始内存起始地址的 void*指针 (raw_memory)。

  4. 编译器自动调用构造函数去构造类对象:

    如疑问1中所述,编译器在调用 operator new并获得 raw_memory指针后,会紧接着在生成的代码中插入对 MyClass构造函数的调用(通过AST树)。

    与编译器生成的中间代码(LLVM)的等效C++代码示例:

    cpp 复制代码
    MyClass* p = static_cast<MyClass*>(raw_memory); // 转换为正确类型指针
    p->MyClass::MyClass();                          // 在分配的内存上调用构造函数
  5. 最终结果:

    经过上述两步(内存分配 + 对象构造)后,new MyClass()表达式的结果 p就是一个指向完全构造好的、位于堆上的 MyClass对象的有效指针。

疑问3:

为什么我们不加任何头文件,也可以使用new去创造内置类型变量或者类对象?

当我们不包任何头文件,看看以下代码:

cpp 复制代码
class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		//cout << "A():" << this << endl;
	}
	~A()
	{
		//cout << "~A():" << this << endl;
	}
private:
	int _a;
};
int main()
{
	A* p1 = (A*)malloc(sizeof(A));  //"malloc": 找不到标识符
	free(p1);                       //"free": 找不到标识符

	A* p2 = new A(1);
	delete p2;
	
	int* p3 = new int(3);
	delete p3;
	
	return 0;
}

以上的new我们不加任何头文件都可以使用,是因为它是C++语言强制要求提供的基础内存分配接口之一,

C++语言强制要求提供的基础内存分配接口

  • void* operator new(std::size_t);
  • void* operator new;
  • void operator delete(void*) noexcept;
  • void operator delete noexcept;

编译器/标准库系统会隐式地提供它们的声明并确保它们在全局作用域可见,而我们的标准库,一般是默认会链接的,所以编译器最终可以正确通过operator new的隐式声明调用到我们标准库中的operator new全局函数。

证明一下:

cpp 复制代码
#include<stdio.h>
#include<malloc.h>
class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		printf("A(int a = 0):你好世界!\n");
	}
	~A()
	{
		printf("A(int a = 0):再见世界!\n");
	}
	int _a;
};
int main()
{
	A* p5 = new A();  //你好世界!
	//编译器识别到new自动调用类的构造:  //A(int a = 0):你好世界!
	delete p5;        //A(int a = 0):再见世界!
	return 0;
}
//我们自己定义一个和标准库中同名同参的operator new,它打印"你好世界!"
void* operator new(size_t size)
{
	printf("operator new(size_t size):你好世界!\n");
	void* p = malloc(size);
	return p;
}
  1. 我们的函数定义在main函数之后,按理来说是识别不到的,但是因为有编译器默认添加的隐式声明,所以没有报错;
  2. 这个隐式声明原本是库中实现的全局operator new函数的声明,但是我们自己实现的函数和库中的参数相同,所以编译器在最终拿着声明去找到的是我们自己实现的operator new函数;
  3. 虽然是我们自己实现的operator new内存申请函数,但是编译器还是识别到了new运算符(疑问1中),所以它就在执行完之后还是去调用了我们类的构造函数。
  4. 我们的delete调用的是标准库中的operator delete,并且也通过编译器的自动调用调用到了类的析构函数。

虽然不加头文件也可以使用new,但是其实这种行为,是非常不规范,非常不好的!!!

疑问4:

不加头文件可以使用普通new,但是为什么不可以使用定位new?

我们的定位new,它其实就是我们标准库中实现的,基于普通new的一个函数重载(多传入了一个指针,其实就是通过这个指针,让没有被编译器自动调用构造的内存重走一遍逻辑,让编译器自动的调用构造):

cpp 复制代码
inline void* __CRTDECL operator new(size_t _Size,_Writable_bytes_(_Size) void* _Where) noexcept
{
    (void)_Size;
    return _Where;
}

但是它并不是C++强制要求的,所以如果我们不加对应的iostream头文件,编译器就不知道它的声明,没有到链接阶段就报错了。

cpp 复制代码
#include<malloc.h>
class A
{
public:
	A(int a = 0)
		: _a(a)
	{}
	~A()
	{}

	int _a;
};
//手动声明定位new
//void* __CRTDECL operator new(size_t _Size, _Writable_bytes_(_Size) void* _Where);
int main()
{
	A* p3 = (A*)malloc(sizeof(A)); 
	free(p3); 
	new(p3)A();   //报错:"operator new": 函数不接受 2 个参数

	return 0;
}

因为编译器没有隐式添加其声明,所以也就不可以使用,如果我们正确的显示的声明出来,最后自动链接的时候也是可以正常调用到的。

虽然手动添加声明也可以正常使用定位new,但是其实这种行为,是不规范的,一切只为演示,要想使用库,请正确添加对应的头文件。

3.3 重载operator new和operator delete

现在我们知道:

  1. operator new和operator delete不过也就是函数而已,可以实现重载。
  2. 只要让编译器通过识别new、delete表达式去调用operator new和operator delete函数去做申请、释放空间的操作,那么编译器也就会去自动调用对应类的构造和析构函数。
  3. 通过new表达式申请变量的时候,我们的类型其实最终会被算成大小size,然后作为operator new函数的第一个参数,传入operator new函数中,然后内部再去申请对应大小的内存空间。
  4. 通过delete表达式申请变量的时候,我们的指针,最终会被传入operator delete函数中,内部逻辑会对内存做释放。

注意:一般情况下不需要对 operator new 和 operator delete进行重载,除非在申请和释放空间

时候有某些特殊的需求。比如:在使用new和delete申请和释放空间时,打印一些日志信息,可

以简单帮助用户来检测是否存在内存泄漏。

cpp 复制代码
#include<iostream>
using namespace std;

// 重载operator delete,在申请空间时:打印在哪个文件、哪个函数、第多少行,申请了多少个字节
void* operator new(size_t size, const char* fileName, const char* funcName,size_t lineNo)
{
	void* p = ::operator new(size);
	cout << fileName << "-" << funcName << "-" << lineNo << "-" << p << "-"
		<< size << endl;
	return p;
}

// 重载operator delete,在释放空间时:打印再那个文件、哪个函数、第多少行释放
void operator delete(void* p, const char* fileName, const char* funcName,size_t lineNo)
{
	cout << fileName << "-" << funcName << "-" << lineNo << "-" << p <<
		endl;
	::operator delete(p);
}

//int main()
//{
//	// 对重载的operator new 和 operator delete进行调用
//	int* p = new(__FILE__, __FUNCTION__, __LINE__) int;
//	operator delete(p, __FILE__, __FUNCTION__, __LINE__);
//	return 0;
//}

// 上述调用显然太麻烦了,可以使用宏对调用进行简化
// 只有在Debug方式下,才调用用户重载的 operator new 和 operator delete
#ifdef _DEBUG
#define new new(__FILE__, __FUNCTION__, __LINE__)
#define delete(p) operator delete(p, __FILE__, __FUNCTION__, __LINE__)
#endif
int main()
{
	int* p = new int;
	delete(p);
	return 0;
}

常见面试题

malloc/free和new/delete的区别

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在释放空间前会调用析构函数完成
    空间中资源的清理

全文总结

  1. 我们本文讲解的内存管理,就是我们程序员手动在堆上分配的内存。
  2. C++引入新的内存管理方式,为了解决的核心问题是 C 语言动态内存分配机制对面向对象特性(特别是构造/析构)的支持缺乏。
  3. 可执行文件,就是包含代码逻辑,指导操作系统如何建立进程的虚拟地址空间(通过元数据描述布局和内容)的一个文件。
  4. malloc和free是函数,new和delete是操作符
  5. C语言malloc只是一个封装好的接口,代替我们去访问操作系统申请内存,至于它内部对于内存的申请方式和管理方式,我们后续有机会再谈。
  6. C++中的new、delete其实是运算符,当我们使用new、delete给自定义类型申请堆内存时,其实是编译器在调用operator new、operator delete的全局函数,给我们在堆上申请空间,并会自动调用对应的构造函数(如果是自定义类)和析构函数。
  7. 分解编译器如何处理 new MyClass():
  1. 计算对象大小:

    编译器在编译期间(编译时)​静态计算​ MyClass所需的完整对象大小 sizeof(MyClass)。

    sizeof(MyClass)是一个编译时常量表达式。它考虑了成员变量的大小、对齐要求(Padding)、基类、虚函数表指针(如果有多态)等所有因素。

  2. 调用 operator new并传入大小:

    编译器将 new MyClass()解析为一个 CXXNewExpr节点。

    在该节点的代码生成阶段,编译器生成对 ​operator new​ 函数的调用代码。​sizeof(MyClass)被作为 operator new的第一个(必需的)参数传入。

    与编译器生成的中间代码(LLVM)的等效C++代码示例:

    cpp 复制代码
    // 概念上编译器生成的中间步骤 (不一定直接可见):
    void* raw_memory = ::operator new(sizeof(MyClass)); // 核心在此!调用operator new, 传入MyClass的大小
  3. operator new利用大小分配内存:

    operator new(size_t size)函数(无论是全局库实现的还是用户自定义重载的)接收到这个 size参数(就是 sizeof(MyClass))。

    该函数负责依据传入的 size值,在堆上分配足够容纳该对象的连续内存块。标准的全局 operator new通常封装系统调用(如 malloc)来分配这块大小为 size字节的原始内存。

    分配成功则返回指向这块原始内存起始地址的 void*指针 (raw_memory)。

  4. 编译器自动调用构造函数去构造类对象:

    如疑问1中所述,编译器在调用 operator new并获得 raw_memory指针后,会紧接着在生成的代码中插入对 MyClass构造函数的调用(通过AST树)。

    与编译器生成的中间代码(LLVM)的等效C++代码示例:

    cpp 复制代码
    MyClass* p = static_cast<MyClass*>(raw_memory); // 转换为正确类型指针
    p->MyClass::MyClass();                          // 在分配的内存上调用构造函数
  5. 最终结果

经过上述两步(内存分配 + 对象构造)后,new MyClass()表达式的结果 p就是一个指向完全构造好的、位于堆上的 MyClass对象的有效指针。

  1. operator new和operator delete重载:
  1. operator new和operator delete不过也就是函数而已,可以实现重载。
  2. 只要让编译器通过识别new、delete表达式去调用operator new和operator delete函数去做申请、释放空间的操作,那么编译器也就会去自动调用对应类的构造和析构函数。
  3. 通过new表达式申请变量的时候,我们的类型其实最终会被算成大小size,然后作为operator new函数的第一个参数,传入operator new函数中,然后内部再去申请对应大小的内存空间。
  4. 通过delete表达式申请变量的时候,我们的指针,最终会被传入operator delete函数中,内部逻辑会对内存做释放。

9.new本质是运算符,C++语言强制要求提供的基础内存分配接口的声明:

  • void* operator new(std::size_t);
  • void* operator new;
  • void operator delete(void*) noexcept;
  • void operator delete noexcept;

本文章为作者的笔记和心得记录,顺便进行知识分享,有任何错误请评论指点:)。

相关推荐
夜斗小神社9 分钟前
【LeetCode 热题 100】(六)矩阵
算法·leetcode·矩阵
小六学编程9 分钟前
C语言库中的字符函数
c语言
天地一流殇1 小时前
SimBA算法实现过程
深度学习·算法·对抗攻击·黑盒
hqxstudying1 小时前
java分布式定时任务
java·开发语言·分布式
Hello_Embed1 小时前
STM32HAL 快速入门(三):从 HAL 函数到寄存器操作 —— 理解 HAL 库的本质
c语言·stm32·单片机·嵌入式硬件·学习
2501_924730612 小时前
智慧城管复杂人流场景下识别准确率↑32%:陌讯多模态感知引擎实战解析
大数据·人工智能·算法·计算机视觉·目标跟踪·视觉检测·边缘计算
weixin_307779132 小时前
C++实现MATLAB矩阵计算程序
开发语言·c++·算法·matlab·矩阵
学不动CV了2 小时前
FreeRTOS入门知识(初识RTOS任务调度)(三)
c语言·arm开发·stm32·单片机·物联网·算法·51单片机
Kingfar_12 小时前
智能移动终端导航APP用户体验研究案例分享
人工智能·算法·人机交互·ux·用户界面·用户体验
捏尼卜波卜2 小时前
try/catch/throw 简明指南
linux·开发语言·c++