C++是如何做内存管理的?
了解内存区域
初步了解
在C++中,内存分为:栈、堆、自由存储区、全局/静态存储区、常量存储区。
堆,就是那些由malloc分配的内存块,用free来释放内存。堆属于动态内存分配,它可以分配较大块的内存,但是速度会比较慢。
栈,局部变量,函数参数,返回地址都在栈上分配,而且栈的分配和释放非常快,但是大小有限。
自由存储区,那些使用new
分配的存储块
全局/静态存储区,程序生命周期内一直存在的内存区域。用于存储全局变量和静态变量,全局变量和静态变量被分配到同一块内存中,在程序启动时分配,在程序结束时释放。
常量存储区,用于存储程序中的常量,如const
变量和字符串字面量(用双引号括起来的字符序列就是字符串字面量。 例如:"abc""abcdn"
)
堆与自由存储区
从技术上来说,堆(heap)是C语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当运行程序调用malloc()时就会从中分配,稍后调用free可把内存交还。而自由存储区是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。
问: 借以malloc实现的new,所申请的内存是在堆上还是在自由存储区上?
答: 当你使用 C++ 的
new
操作符时,你是在自由存储区上请求内存,即使在底层实现中它可能使用了malloc
来从堆上获取那块内存。
但程序员也可以通过重载操作符,改用其他内存来实现自由存储,例如全局变量做的对象池,这时自由存储区就区别于堆了。
#include <iostream>
class Myclass {
public:
static void* operator new (size_t size) {
static char pool[sizeof(Myclass) * 100];
static int index = 0;
if (index < 100)
{
void* ptr = pool + (index * sizeof(Myclass));
return ptr;
}
else
{
throw std::bad_alloc();
}
}
static void operator delete(void* ptr) noexcept{
}
};
int main()
{
try
{
Myclass* obj = new Myclass();
delete obj;
}
catch (const std::bad_alloc& e)
{
std::cerr << "Memory allocation failed: " << e.what() << std::endl;
}
return 0;
}
堆和栈
void f() { int* p=new int[5]; }
这条短短的一句话就包含了堆与栈,看到new,我们首先就应该想到,我们分配了一块堆内存,那么指针p呢?他分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中。
下面是在Visual C++ 2022下的汇编代码
00007FF7431B18CB mov ecx,14h
00007FF7431B18D0 call operator new[] (07FF7431B11A4h)
00007FF7431B18D5 mov qword ptr [rbp+0E8h],rax
00007FF7431B18DC mov rax,qword ptr [rbp+0E8h]
00007FF7431B18E3 mov qword ptr [p],rax
那么回到正题,堆与栈的区别是什么?
管理方式:
- 栈是一种线性数据结构,按照后进先出(LIFO)的原则进行数据管理。由编译器自动管理,无需我们手工控制。栈由高向下。
- 堆是一种树状数据结构,通常通过一个自由存储区来实现,数据在堆中的排列是无序的。释放工作由程序员控制,容易产生memory leak。堆地址从低向上。
空间大小:
一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的,理论上内存有多大,就可以建多大.。但是对于栈来讲,一般都是有一定的空间大小的,例如,在Visual C++ 2022下面,默认的栈空间大小是1M。ubuntu中默认8M。
碎片问题:
在堆中频繁的malloc/free
势必会造成内存空间的不连续会产生内存碎片,栈不会产生内存碎片;
效率问题:
- 栈的分配和释放非常快,因为它只涉及栈指针的移动。这是一个简单的机器指令,通常只需要几个时钟周期。栈的管理由操作系统自动完成,不需要复杂的内存分配算法。
- 堆的分配和释放比栈慢,因为它涉及到更复杂的内存管理操作,如查找合适的空闲块、更新内存分配表等。频繁的分配和释放可 能导致堆内存碎片,这会降低分配效率,因为分配器可能需要花费更多时间来查找足够的连续内存空间。
分配方式:
堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca()
函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。
了解内存的分配与释放
初步了解
问:C++和C分别使用什么函数来做内存的分配和释放?有什么区别,能否混用?
在C++中,内存的分配和释放通常使用new
和delete
关键字来完成。而在C语言中,则使用malloc
、calloc
、realloc
和free
函数来进行内存的分配和释放。
在C++中
new
关键字用于动态分配内存,并调用对象的构造函数。delete
关键字用于释放new
分配的内存,并调用对象的析构函数。
在C语言中
malloc
用于分配指定大小的内存块。calloc
也用于分配内存,同时初始化分配的内存为0。realloc
用于调整之前分配的内存块的大小。free
用于释放之前由malloc
、calloc
或realloc
分配的内存。
区别:
- 构造与析构:C++的
new
和delete
会分别调用对象的构造函数和析构函数,而C的内存分配函数则不会。 - 类型安全:C++的
new
和delete
操作符提供类型安全,分配和释放的是特定类型的对象。而C的函数则只是处理内存,不关心内存中存储的是什么类型的数据。(也就是说free() 并不关心他释放的是什么类型的内容) - 返回类型:C++的
new
返回的是具体类型的指针,类型安全,而C语言的malloc
等函数返回的是void*
,需要用户进行类型转换。 - 获取区域以及大小:new是从自由存储区获得内存,malloc从堆中获取内存;new分配内存空间无需指定分配内存大小,malloc需要。
结论:不要混用!!!在C++中混用C的内存管理函数会导致类型安全性的丧失,因为C的函数无法保证分配的内存被正确地用于指定的类型。这可能导致类型错误,比如尝试用free
释放一个由new
分配的内存块,这样做不会调用对象的析构函数,从而导致资源泄漏或未定义行为。因此,在C++中,推荐使用new
和delete
来管理动态内存,以保持类型安全。
new 与 delete
问:为什么需要new与delete?
对于非内部数据类型的对象而言,光用malloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。
new与delete的使用
new
-
分配和构造单个对象
int* p = new int(10); //// 分配一个int类型的对象,调用其构造函数(如果有)进行初始化为10。
-
分配和构造对象数组
int* pArray = new int[10]; //分配一个包含10个int对象的数组,但是对象没有初始化 //C++11及以后 可以使用初始化器列表 int* pArray = new int[10] {1, 2, 3, 4, 5}; //分配并初始化数组的前5个元素
-
使用定位new
void* rawMemory = malloc(sizeof(int) * 10); int* pArray = new(rawMemory) int[10]; //在已经分配的内存上构造数组
-
使用初始化器列表(C++11及以后)
std::vector<int>* pVector = new std::vector<int>{ 1,2,3,4,5 }; //分配并初始化一个std::vector
-
类型推导(C++11及以后)
auto p = new auto('a'); // 分配一个char类型的对象并初始化为'a'
delete
-
释放单个对象
delete p;
-
释放对象数组
delete[] pArray;
注意:同一块内存释放两次,如果对其中一个指针进行了delete操作,对象的内存被归还给自由空间,如果我们随后又delete第二个指针,内存空间就可能被破坏。
new与delete的重载
重载new和delete
void* operator new(size_t size)
{
void* p = malloc(size);
if (p == nullptr)
{
throw std::bad_alloc();
}
return p;
}
void* operator new[](size_t size)
{
void* p = malloc(size);
if (p == nullptr)
{
throw std::bad_alloc();
}
return p;
}
void operator delete(void* p) noexcept {
free(p);
}
void operator delete[](void* p) noexcept {
free(p);
}
对单个类中的new和delete重载
#include <iostream>
#include <cstdlib> // 用于malloc和free
class MyClass {
public:
MyClass() {
std::cout << "MyClass constructed" << std::endl;
}
~MyClass() {
std::cout << "MyClass destructed" << std::endl;
}
// 重载new[]操作符
static void* operator new[](size_t size) {
std::cout << "MyClass::operator new[] called with size " << size << std::endl;
void* p = std::malloc(size);
if (!p) {
throw std::bad_alloc(); // 如果分配失败,抛出异常
}
return p;
}
// 重载delete[]操作符
static void operator delete[](void* p) noexcept {
std::cout << "MyClass::operator delete[] called" << std::endl;
std::free(p);
}
// 重载nothrow版本的new[]操作符
static void* operator new[](size_t size, const std::nothrow_t& nothrow_value) noexcept {
std::cout << "MyClass::operator new[] (nothrow) called with size " << size << std::endl;
void* p = std::malloc(size);
return p; // 注意:nothrow版本不应该抛出异常
}
// 重载nothrow版本的delete[]操作符
static void operator delete[](void* p, const std::nothrow_t& nothrow_value) noexcept {
std::cout << "MyClass::operator delete[] (nothrow) called" << std::endl;
std::free(p);
}
};
int main() {
// 使用重载的new[]和delete[]
MyClass* myArray = new MyClass[5];
delete[] myArray;
// 使用nothrow版本的new[]和delete[]
MyClass* myArrayNoThrow = new (std::nothrow) MyClass[5];
delete[] myArrayNoThrow;
return 0;
}
operator new
A* a = new A;
在这里我们分为三步
- 分配内存
- 调用A()构造对象
- 返回分配指针
分配内存这一步就是由operator new(size_t)
来完成的,如果类中重载了operator new 则将调用A::operator new(size_t)
否则将调用全局 ::operator new(size_t)
operator new的三种类型
throwing (1)
void* operator new (std::size_t size) throw (std::bad_alloc);
nothrow (2)
void* operator new (std::size_t size, const std::nothrow_t& nothrow_value) throw();
placement (3)
void* operator new (std::size_t size, void* ptr) throw();
(1)(2)的区别仅是是否抛出异常,当分配失败时,前者会抛出bad_alloc
异常,后者返回null,不会抛出异常。它们都分配一个固定大小的连续内存。
A* a = new A; //调用throwing(1)
A* a = new(std::nothrow) A; //调用nothrow(2)
(3)是placement new,它也是对operator new的一个重载,定义于#include <new>
中,它多接收一个ptr参数,但它只是简单地返回ptr。
placement new本身只是返回指针p,new(p) A()调用placement new之后,还会在p上调用A:A()
使用placement new
后,需手动调用析构函数(而非delete
),且需确保内存的正确释放。
理由:placement new只是在现有的内存位置上构造对象,它不负责内存的分配。因此,当你使用placement new时,你必须已经为对象分配好了内存空间。由于delete操作会尝试释放由new操作分配的内存,使用它来释放通过placement new构造的对象会导致未定义行为,因为delete会尝试去释放一块它没有记录分配信息的内存。
delete与free能否混用(new和malloc同理)
注意只是便于理解,不推荐混用
当指针不是类对象时,理论上你可以使用free
来释放由new
分配的内存,或者使用delete
来释放由malloc
等分配的内存
new与new[]能否混用(delete与delete[]同理)
new[]在分配时,如果类中显式定义了析构函数,new会在分配的时候,根据系统的位数额外分配对应的空间(32位系统分配32位空间,也就是4字节,64位系统分配64位空间,也就是8字节)。如对于32位系统,new[2]分配的空间应该如下:
所以基础数据类型,是可以直接混用的,但类类型是不可以的。
new开辟对象数组时,会在数组的最上方多一个四字节的数,用来记录对象的个数,方便调用析构函数。用delete【】释放内存时,会在原有new出来的内存上 – 4,从而正确释放内存,而delete却会直接崩溃。也就相当于
- 对于没有显式定义析构函数的类,delete、delete[]和free可以混用。
- 对于显式定义析构函数的类,delete[]和new[]必须配套使用,delete和free如果想混用,free需要显式调用析构函数。
指针越界机制
_CrtMemBlockHeader :这个结构体,存放了动态申请得到的内存块的各种信息,并且返回到你的指针上面。
typedef struct _CrtMemBlockHeader
{
// 指向下一块数据块的指针
struct _CrtMemBlockHeader *pBlockHeaderNext;
// 指向前一块数据块的指针
struct _CrtMemBlockHeader *pBlockHeaderPrev;
// File name:请求内存分配操作的那行代码所在的文件的路径和名称,但实际上是空指针
char *szFileName;
// Line number:行号,请求内存分配操作的那行代码的行号
int nLine;
// 请求分配的大小 size_t nDataSize;
// Type of block 类型
int nBlockUse;
// 请求号
long lRequest;
// 这个gap,正是cpp中对于指针的界限
unsigned char gap[nNoMansLandSize];
} _CrtMemBlockHeader;
我们来说一说gap[]
的作用,你所申请的空间中的内容我们假定为<tdata>
。在<tdata>
的前后各有4个B(字节)的gap[]
,他在内存中的值为0xFD
。这样系统只需要检测你的<tdata>
前后的数据是否为0xFD
就可知道你有没有越界。
在这里创建一个指向int的p指针,可以看到0X0000018e284c6af0
是p指针指向的内容,因为我们并没有创建内容所以在内存中使用0xcd
进行填充,前后各有4B的0xfd
来进行限定。
上面我们知道了gap[]
以及他的作用,那么我们顺利成章地就会想到_CrtDumpMemoryLeaks()
这个函数,他就是通过检查内存分配链表(pBlockHeaderNext
和pBlockHeaderPrev
为双链表的双链), 来查找是否有泄漏。
智能指针
智能指针是RAII思想的体现,RAII思想的核心在于将资源的获取和初始化封装在一个对象的构造函数中,将资源的释放和对象的析构函数结合起来。这样,当对象的生命周期结束时,其析构函数会自动被调用,从而释放资源,避免了资源泄漏的问题。
注意:不要使用get初始化另一个智能指针或为智能指针赋值。要小心使用,若智能指针释放了其对象,返回的指针所指向的对象也就消失了。也不要delete get()返回的指针。
智能指针的类型
-
Auto_ptr:
-
这是最早的智能指针,里面有一些反直觉的操作,
- 比如将一个auto_ptr p1的值赋给p2,p1的值就为空了,再去用p1时,程序就会崩溃,如果函数的参数是一个auto ptr,并且是按值传递,这个原本的p1就会被置为空,是个比较隐蔽的错误了。用容器管理auto_ptr也非常容易造成错误,因为容器经常会拷贝和赋值。
- 它不能管理数组,因为析构的时候调用的是delete,而不是delete[]
-
- std::unique_ptr:
-
std::unique_ptr
是具有严格拥有性的智能指针,意味着它拥有所指向对象的唯一所有权。 -
不支持复制语义,删除了左值拷贝构造函数,和左值的赋值操作,即不能将
std::unique_ptr
复制给另一个std::unique_ptr
,但可以通过std::move
进行所有权的转移。这就是说我们可以拷贝或赋值一个将要被销毁的unique_ptr
(也就是右值) -
当
std::unique_ptr
被销毁时,它所拥有的对象也会被自动删除。 -
unique_ptr
在指定自定义删除器时需要显式地提供删除器的类型作为模板参数,而shared_ptr
则不需要。写删除器的原因是并不是所有资源都是通过delete
来释放的。有些资源,如文件句柄、网络连接、互斥锁等,需要通过特定的函数来释放。在这种情况下,默认的删除器就不适用了,我们需要为智能指针提供一个自定义的删除器。 -
release
函数返回智能指针当前所管理的裸指针。调用release() 会切断unique_ptr (非const)和它原来管理的对象的联系。release 返回的指针通常被用来初始化另一个智能指针或给另一个智能指针赋值。如果不用另一个智能指针来保存release返回的指针,程序就要负责资源的释放。 -
可以使用reset()释放指针当前拥有的对象。
std::unique_ptr<int> ptr1(new int(10)); ptr1.reset(new int(20)); // 释放旧的int,并指向新的int ptr1.reset(nullptr); // 释放当前的int,ptr1变为空
- std::shared_ptr:
-
std::shared_ptr
是基于引用计数的智能指针,允许多个std::shared_ptr
实例共享同一资源的所有权。 -
引用计数机制会跟踪有多少个
std::shared_ptr
指向同一对象,每当一个新的std::shared_ptr
被创建时,引用计数增加,当一个std::shared_ptr
被销毁时,引用计数减少。 -
当引用计数变为零时,即最后一个指向对象的
std::shared_ptr
被销毁时,所管理的对象会被自动删除。 -
不正确的使用shared ptr可能会导致循环引用,从而使资源无法释放。
- 比如一个A类对象里有一个指向B类对象的强智能指针,B类对象里又有一个指向A的强智能指针,这样即使在出作用域后,指针的引用计数也不会变为0,从而资源无法释放。所以需要使用weakptr来解决。
- 如果你将shared_ptr存放于一个容器中,而后不再需要全部元素,而只使用了其中一部分,要记得使用erase删除不再需要的那些元素
- std::weak_ptr:
std::weak_ptr
是一种弱引用智能指针,用于解决std::shared_ptr
可能导致的循环引用问题。- 它不会增加引用计数,因此可以用来观察资源而不影响其生命周期。
- 当需要访问资源时,可以将其转换为
std::shared_ptr
,如果资源仍然存在,转换成功;如果资源已经被销毁,转换失败。 - Weakptr使用前应该先检查是否被释放。
5.std::scoped_ptr:
- 没有reset函数,无法在它的生命周期内更改所拥有的资源。
- 这种智能指针不支持将所有权转让给另一个指针。
- 这种智能指针的主要用途是在局部作用域内管理资源,当作用域结束时,智能指针会自动释放资源。
- 即使智能指针不支持拷贝和赋值,它通常还是会提供一个
swap
函数,用于交换两个智能指针所拥有的资源。
相关的函数与模板
make_shared
在shared_ptr的构造函数中,需要进行两次内存分配,一次是控制块的分配,这个控制块用于存储引用计数、弱引用计数以及一些其他信息,如自定义删除器或分配器。控制块通常是通过一个单独的内存分配来实现的,而且多个 shared_ptr
可以共享同一个控制块。一次是所管理对象的分配:这通常是指向动态分配内存的指针,比如通过 new
关键字分配的内存。这是 shared_ptr
所管理的资源,当引用计数变为零时,这部分内存会被 delete
。
如果在两次的内存分配中一次成功,一次失败的话,就会抛出异常,造成内存泄漏。而使用make_shared只会分配内存一次,所以优点是:更安全并且效率更高。
而缺点在于
- 无法自定义删除器:
std::make_shared
不能像std::shared_ptr
的构造函数那样接受自定义删除器。当需要自定义删除器时,必须使用std::shared_ptr
的构造函数。 - 可能导致资源延迟释放:因为shared和weak共同引用一个计数,原本是强引用计数为0则直接释放持有资源,然后等弱引用为0则释放引用计数资源,而这次new在一起,就算强引用为0,也必须等到弱引用为0才能释放资源。
至于为什么不能单独释放一部分,这主要是因为std::shared_ptr
的设计是为了保证强引用和弱引用之间的同步。当强引用计数为0时,对象应该被析构,但如果有弱引用存在,那么对象的一部分(控制块,包含引用计数)仍然需要被访问。因此,控制块的释放必须等待所有弱引用都被释放掉。这样的设计简化了std::shared_ptr
的实现,并保证了安全性。
enable_shared_from_this:
C++11 开始支持 enable_shared_from_this
,它是一个模板类,定义在头文件 <memory>
,其原型为:
template< class T > class enable_shared_from_this;
如果在类中的成员函数,需要返回管理当前对象的强智能指针,则需要继承这个类,这个类里有一个weakptr,会在构造函数中初始化,用来观察强智能指针。