C++内存管理


前言

发现自己对C++内存管理的理解比较混乱,顾总结相关知识,并记录下面试可能遇到的问题。

一、C++程序的内存分布

内存分布

栈区:存储局部变量函数参数等,由OS自动分配和释放
堆区:由程序员手动分配和释放的内存,如果不释放,则在程序结束时可能由OS释放
全局/静态存储区:存储全局变量静态变量。程序开始时由系统分配,结束时由系统释放
常量存储区:存放常量字符串,一般不允许修改,程序结束时由编译器释放
程序代码区:存放函数体的二进制代码,只读
内存分布

int a = 1;
static int b = 2;

int main()
{
	static int c = 3;

	cout << "全局/静态存储区" << endl;
	cout << "全局变量a的地址:" << &a << endl;
	cout << "全局静态变量变量b的地址:" << &b << endl;
	cout << "局部静态变量变量c的地址:" << &c << endl;
	cout << "字符串常量\"abcd\"的地址:" << &("abcd") << endl;
	cout << endl;

	int d = 4;
	int e[] = { 1, 2, 3, 4 };
	char f[] = "abcd";
	char* g = f;
	const char h[] = "efgh";

	cout << "栈区" << endl;
	cout << "局部变量整型d的地址:" << &d << endl;
	cout << "局部变量整型数组f的地址:" << &e << endl;
	cout << "局部变量字符串f的地址:" << &f << endl;
	cout << "局部变量字符串g的地址:" << &g << endl;
	cout << "局部变量常量字符串h的地址:" << &h << endl;
	cout << endl;

	int* ptr1 = new int(3);
	int* ptr2 = (int*)malloc(sizeof(int) * 3);
	int* ptr3 = (int*)calloc(4, sizeof(int));
	int* ptr4 = (int*)realloc(ptr3, sizeof(int) * 4);

	cout << "堆区" << endl;
	cout << "指针ptr1的地址:" << ptr1 << endl;
	cout << "指针ptr2的地址:" << ptr2 << endl;
	cout << "指针ptr3的地址:" << ptr3 << endl;
	cout << "指针ptr4的地址:" << ptr4 << endl;

	delete ptr1;
	free(ptr2);
	free(ptr4);
	
	return 0;
}

/*
输出:
全局/静态存储区
全局变量a的地址:00007FF6F8099004
全局静态变量变量b的地址:00007FF6F8099008
局部静态变量变量c的地址:00007FF6F809900C
字符串常量"abcd"的地址:00007FF6F8094704

栈区
局部变量整型d的地址:00000028D22FF9F4
局部变量整型数组f的地址:00000028D22FFA18
局部变量字符串f的地址:00000028D22FFA44
局部变量字符串g的地址:00000028D22FFA68
局部变量常量字符串h的地址:00000028D22FFA84

堆区
指针ptr1的地址:0000014C63867950
指针ptr2的地址:0000014C63877050
指针ptr3的地址:0000014C63877D20
指针ptr4的地址:0000014C638773C0
*/

其他内存分布方式

在我阅读其他文章时,看到还有其他的内存分区方式,如内存四区:堆、栈、全局区、代码区,其中全局区又分为全局变量区、静态变量区、常量区。而其实将全局/静态存储区和常量存储区合在一起就是内存四区划分中的全局区。通过打印变量地址,如上面代码所示,可以看到字符串常量”abcd”的地址和全局/静态变量地址离得很近,这也说明全局区其实就是全局/静态存储区和常量存储区。

另一种C++内存分区如下:栈、堆、自由存储区(Free store)、全局/静态存储区、常量存储区。
不论是何种内存分区划分方式,都大同小异,只要理解内存分区即可。

堆和自由存储的区别

对于堆和自由存储区的区别,一种解释是:堆使用malloc、free动态分配内存和释放空间,自由存储区使用new、delete分配和释放空间的内存。但实际上,很多编译器上的new/delete就是调用malloc/free实现的。
大部分编译器默认使用堆来实现自由存储区,因此一般说new分配的对象在堆上也没有错。如果自由存储的实现方式不是堆,那么就需要与堆区别开来。
总的来说,堆是由操作系统维护的一块内存,而自由存储是C++中通过new/delete动态分配和释放内存的抽象概念,堆与自由存储区并不等价
参考:链接

堆和栈的区别

1.管理方式:栈,由编译器自动申请与释放;堆,由程序员控制,如果分配的空间没有手动释放,可能会造成内存泄漏,或者有可能在程序结束时由OS释放。
2.分配方式:堆都是动态分配方式,没有静态分配的堆。栈有静态分配和动态分配,静态分配由编译器完成,如局部变量的分配;动态分配由alloca函数进行分配,但不需要程序员手动释放,由编译器进行释放。
3.分配效率:栈受到计算机在底层提供的支持,因此效率较高;而堆由C/C++函数库提供,机制复杂,效率较低。
4.空间大小:栈能分配的空间较小,一般默认栈空间大小是1M;堆能分配的空间较大,32位系统下堆内存可以达到4G。
5.碎片问题:栈以先进后出的方式组织,因此栈内存都是连续的;堆,频繁地分配/释放内存会造成内存空间的不连续,从而出现内存碎片。
6.地址:堆地址自下而上,栈地址自上而下。

二、C/C++内存分配

malloc/calloc/realloc/free

malloc:分配size大小的连续内存空间,返回void*指针,分配失败返回NULL。

void* malloc(size_t size);

calloc:分配num个大小为size的空间。

void* calloc(size_t num, size_t size);

realloc:为ptr重新分配大小为size的地址,释放ptr指向的原空间(如果size为0,则相当于释放ptr的内存空间)。

void *realloc(void *ptr, size_t size)

free

void free(void *ptr);

free使用需要注意:
(1) 在设计时尽量遵从:谁开辟,谁回收的原则
(2) 在free完后立刻将原动态开辟的指针置为NULL
free( p );
p = NULL; // 防止p成为野指针

// C语言 申请/释放内存空间
void test1()
{
	int* ptr1 = (int*)malloc(sizeof(int));
	int* ptr2 = (int*)calloc(4, sizeof(int));
	int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 3);

	free(ptr1);
	free(ptr3);
}

另外,malloc/calloc/realloc/free操作对象时,不会调用类的构造、析构函数。

new/delete

new/delete是C++的操作符
new/delete申请/释放单个空间
new[]/delete[]申请/释放连续空间

// C++ 申请/释放内存空间
void test2()
{
	int* ptr4 = new int; // 分配空间
	int* ptr5 = new int(3); // 分配并初始化
	int* ptr6 = new int[4]; // 分配4个连续空间
	delete ptr4; // 释放空间
	delete ptr5;
	delete[] ptr6; // 释放连续空间
}

注意delete不是删除了指针,而是释放了指针所指向的空间,在delete之后指针还可以指向其他区域
另外,一个指针不要delete多次

new/delete操作对象时会调用对象的构造/析构函数。

class A
{
public:
	A() { cout << "A()" << endl; }
	~A() { cout << "~A()" << endl; }
};
void test3()
{
	A* a = new A();
	delete a;
}

/*
输出:
A()
~A()
*/

operator new、operator delete

new/delete是操作符,operator new/operator delete是系统提供的全局函数,new/delete实际上通过调用operator new/operator delete来申请/释放空间并调用构造/析构函数,而operator new/operator delete本质上又是对malloc/free的封装。
operator new/operator delete的使用方法:

void test4()
{
	int* ptr = (int*)operator new(sizeof(int));
	operator delete(ptr);
}

可以看出使用方式和malloc/free类似,对比如下:
同:都不会调用构造/析构函数;
异:operator new不需要检查开辟空间的合法性,失败就抛出异常

new原理:调用operator new申请空间,然后在申请的空间上执行构造函数完成对象的构造;
delete原理:在对象的空间上执行析构函数清理对象资源,再调用operator delete释放空间;
new[]原理:分配N个空间,调用N次operator new申请N个对象空间,再在空间上执行N次构造函数;
delete []原理:在对象空间上执行N次析构函数清理资源,再调用N次operator delete释放空间。

重载operator new/operator delete

参考:链接

三、内存泄漏

参考:链接

定义

内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
当我们在程序中对原始指针(raw pointer)使用new操作符或者free函数的时候,实际上是在堆上为其分配内存,这个内存指的是RAM,而不是硬盘等永久存储。持续申请而不释放(或者少量释放)内存的应用程序,最终因内存耗尽导致OOM(out of memory)。

分类

内存泄漏分为以下两类:

堆内存泄漏:我们经常说的内存泄漏就是堆内存泄漏,在堆上申请了资源,在结束使用的时候,没有释放归还给OS,从而导致该块内存永远不会被再次使用
资源泄漏:通常指的是系统资源,比如socket,文件描述符等,因为这些在系统中都是有限制的,如果创建了而不归还,久而久之,就会耗尽资源,导致其他程序不可用

造成内存泄漏的情况:参考1参考2

而其中的堆内存泄漏,又可以按以下分类:
1.未释放,即我们申请一块内存后,未及时进行内存释放。
另外还存在一种情况,如下所示:

class Object
{
public:
	Object() { buffer = new char; };
	~Object() {};

private:
	char* buffer;
};

int main()
{
	Object obj;
	return 0;
}

上述代码中析构函数并没有释放成员变量buffer分配的内存,可能造成内存泄漏。因此我们在编写析构函数时要检查哪些成员变量申请了动态内存,并手动释放。析构函数改写如下:

~Object() { delete buffer; };

2.未匹配。new/delete会调用构造/析构函数,如果构造后没有对应进行析构,则可能会造成内存泄漏。

class Object
{
public:
	Object() { cout << "Object()" << endl; };
	~Object() { cout << "~Object()" << endl; };
};

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

/*
输出:
Object()
*/

上述代码中,new操作符会先调用operator new分配内存,再调用Object的构造函数,而free仅仅是释放了内存,没有调用析构函数释放成员变量,引起内存泄漏。

int main()
{
	Object* obj = new Object[3];
	delete obj;
	return 0;
}

/*
输出:
Object()
Object()
Object()
~Object()
*/

上述代码中,通过new[]创建了大小为3的Object数组,调用了3次构造函数,但是只调用了一次析构函数,因此引起内存泄漏。因为delete obj释放了operator new[]申请的内存,但只调用了obj[0]的析构函数。
3.虚析构。在继承中,基类析构函数需要virtual关键字修饰,否则会引起内存泄漏,具体可以见C++虚函数和多态中关于重写析构函数一部分的介绍。

std::string能否被继承?
不能,因为std::string的析构函数不是虚函数

为了避免存在继承关系时候的内存泄漏,需遵守:无论派生类有没有申请堆上的资源,都将父类的析构函数声明为virtual

4.循环引用。智能指针(shared_ptr、weak_ptr、unique_ptr)引入以尽可能避免内存泄漏。但智能指针的循环引用也会导致内存泄漏。
如shared_ptr,多个共享指针可以指向同一个对象,采用引用计数的方式,当最后一个引用销毁时,释放空间。如果两个shared_ptr相互引用,那么就会陷入死锁,引用计数永远不可能为0。

#include <iostream>

class Object1
{
public:
    Object1() { std::cout << "Object1()" << std::endl; }
    ~Object1() { std::cout << "~Object1()" << std::endl; }

    class Object2
    {
    public:
        Object2() { std::cout << "Object2()" << std::endl; }
        ~Object2() { std::cout << "~Object2()" << std::endl; }
        std::shared_ptr<Object1> _object1;
    };

    std::shared_ptr<Object2> _object2;
};

int main()
{
    auto obj1 = std::make_shared<Object1>();
    auto obj2 = std::make_shared<Object1::Object2>();

    obj1->_object2 = obj2;
    obj2->_object1 = obj1;

    std::cout << "obj1 use_count: " << obj1.use_count() << std::endl;
    std::cout << "obj2 use_count: " << obj2.use_count() << std::endl;
    return 0;
}

/*
输出:
Object1()
Object2()
obj1 use_count: 2
obj2 use_count: 2
*/

如上述代码,可以发现析构函数并没有被调用,并且引用计数都是2,因此对象不会在程序结束时释放空间,出现内存泄漏。

当obj1的生命周期结束时,obj2还存在;当obj的生命周期结束时,obj1还存在。两个共享指针的引用计数不会变为0,因此也就不会自动释放内存。

weak_ptr就是用于解决循环引用而存在,它是对象的弱指针,不会增加对象的引用计数。

// 进行如下修改
std::weak_ptr<Object1> _object1;
/*
输出:
Object1()
Object2()
obj1 use_count: 1
obj2 use_count: 2
~Object1()
~Object2()
*/

如上述代码所示,使用weak_ptr代替shared_ptr,可以看到构造、析构函数的调用都正确,不再发生循环引用。

weak_ptr可以和shared_ptr相互转化,shared_ptr可以直接赋值给它,可以通过lock函数来获得shared_ptr。
shared_ptr可以直接访问成员函数,因此如果是weak_ptr,可以使用如下方式:
std::shared_ptr obj= _object1.lock();

防止内存泄漏的经验

1.只要有malloc/new,就得有free/delete
2.尽可能使用智能指针
3.使用log记录
4.谁申请,谁释放

四、内存对齐

参考:链接1链接2

为什么需要对齐

根本原因在于CPU存取数据是按块(固定长度)进行的,如32位系统中,CPU每次取32bit即4字节。
在这里插入图片描述
如上图所示,如果一个整型变量恰好在CPU存取的块大小内,则只需要CPU取一次数据;如果整型变量横跨了两个块,那么需要CPU取两次数据,这种情况会导致CPU效率降低。
内存对于提高程序性能确保程序的安全性和可移植性、以及优化内存使用都至关重要。

如何对齐

C++11标准引入
alignas:指定内存对齐字节数
alignof:查询内存对齐字节数

#include <iostream>

using namespace std;

class alignas(16) Object
{
public:
	Object() : a(0), b('_'), c(0) {}
	double a;
	char b;
	int c;
};

int main()
{
	Object obj;
	cout << &obj.a << " " << (void*)(&obj.b) << " " << &obj.c << endl;
	cout << alignof(Object) << endl;
	cout << sizeof(Object) << endl;
	cout << sizeof(obj) << endl;
}

/*
输出:
000000ACB559F930 000000ACB559F938 000000ACB559F93C
16
16
16
*/

在上述代码中,我们通过alignas(16)指定object的对齐大小为16字节,通过alignof可以获取到Object的对齐字节数。另外,通过打印地址可以看出,char本来只占一个字节,但是通过对齐后填充了3个字节,具体成员字节数见下面所示:

class alignas(16) Object
{
public:
	Object() : a(0), b('_'), c(0) {}
	double a; // 8 + 0padding
	char b;   // 1 + 3padding
	int c;    // 4 + 0padding
};

cout打印char时的问题:链接

五、内存池

先记录下参考文章
https://zhuanlan.zhihu.com/p/664358975
https://www.bilibili.com/read/cv14592704/

六、其他

动态库和静态库

七、练习

1.C++如何做内存管理

a.全局/静态存储区,存储全局变量和静态变量
b.常量存储区,存储常量
c.栈,存储函数局部变量,能分配较小的内存
d.堆,使用malloc、free动态分配内存和释放空间,能分配较大空间
e.自由存储区,使用new、delete分配和释放空间的内存,具体实现可能是堆或者内存池

2.堆和栈的区别

地址:栈地址自上而下;堆地址自下而上。
管理方式:栈由OS自动管理;堆由程序员控制,如果分配空间后未及时释放,可能会造成内存泄漏。
分配方式:栈有静态分配和动态分配,静态分配由编译器完成,如局部变量的分配;动态分配由alloc函数分配,由编译器释放。堆只有动态分配。
分配效率:栈受到计算机底层的支持,分配效率高;堆由C++函数库实现,机制复杂,分配效率低
可分配大小:栈可分配空间小;堆可分配空间大。
碎片问题:栈采用先进后出的方式,不可能出现碎片;堆在经过频繁地分配和释放空间后,会导致内存空间不连续,进而产生内存碎片

3.C和C++的动态分配/释放内存方式,有什么区别,能否混用?

C使用malloc和free,C++使用new和delete,前者是C语言库函数,后者是C++运算符;对于自定义对象,malloc/free只进行内存分配和释放,不能进行构造/析构函数的调用,只有new/delete能完成对象的空间分配和初始化,以及对象的销毁和空间释放,不能混用。
a.(核心)对于类对象,new/delete会调用构造/析构函数,malloc/free不会
b.内存大小,new不需要指定内存大小,malloc需要
c.返回值,new返回对应类型的指针,而malloc返回void*,需要强制转换类型
d.内存区域,new从自由存储区中获取内存,malloc从堆中获取

4.什么是内存对齐,为什么要做内存对齐,如何对齐

由于CPU按固定大小的块进行存储数据,而对内存的存取相对来说更加耗时,因此采用内存对齐这种使用空间换取时间的方式来提高读写效率。
C++11标准使用alignas和alignof来进行内存对齐。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值