👑作者主页:@安 度 因
🏠学习社区:StackFrame
📖专栏链接:C++修炼之路
文章目录
- 一、C/C++ 内存分布
- 二、考题
- 三、C语言动态内存管理方式
- 四、C++内存管理方式
- 1、对内置类型
- 2、对自定义类型
- 五、C++对动态管理的升级
- 六、operator new/operator delete函数
- 七、new/delete 的实现原理
- 1、内置类型
- 2、自定义类型
- 八、定位new表达式(placement-new)
- 九、内存泄漏
- 1、内存泄漏分类
- 2、如何检测内存泄漏
- 3、如何避免内存泄漏
- 4、补充
如果无聊的话,就来逛逛 我的博客栈 吧! 🌹
一、C/C++ 内存分布
划分是为了更加高效的管理 :
说明 :
- 栈又叫堆栈,函数调用建立的栈帧,非静态局部变量、函数参数、返回值等,栈是向下增长的
- 内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享内存,做进程间通信。(Linux课程如果没学到这块,现在只需要了解一下)
- 堆用于程序运行时动态内存分配,堆是可以上增长的;程序运行过程中按需求申请和释放空间,比如我们实现的数据结果都是在堆开空间
- 数据段(静态区)–存储全局数据和静态数据
- 代码段(常量区)–可执行的代码/只读常量(常量字符串);代码段是从操作系统/程序角度说的,常量区是从语法角度命名说的
补充:
- 栈不大,Linux 32位下 8M;静态区和常量区不大,堆很大,32位下约 2G
- 函数编译完成后为指令,都在代码段
- 对于栈、数据、代码段,是自动控制的;堆是手动控制的
- 它们属于进程虚拟地址空间
- C++ 继承了这些内存分布
二、考题
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"; // char2 在栈区;*char2 ;*char2 是栈上的字符串
const char* pChar3 = "abcd"; // pChar3 是栈上的指针;*pChar3 是常量区的常量字符串
int* ptr1 = (int*)malloc(sizeof(int) * 4); // ptr1 是栈上的指针,*ptr1 就是指针指向的堆上的空间
int* ptr2 = (int*)calloc(4, sizeof(int));
int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);
free(ptr1);
free(ptr3);
}
1)选择题
选项: A.栈 B.堆 C.数据段(静态区) D.代码段(常量区)
globalVar在哪里?(C) 2. staticGlobalVar在哪里?(C)
staticVar在哪里?(C) 4. localVar在哪里?(A)
num1 在哪里?(A)
char2在哪里?(A) 7. *char2在哪里?(A)
pChar3在哪里?(A) 9. *pChar3在哪里?(D)
ptr1在哪里?(A) 11. *ptr1在哪里?(B)
2)填空题
sizeof(num1) = (40) 2. sizeof(char2) = (5)
strlen(char2) = (4) 4. sizeof(pChar3) = (4)
strlen(pChar3) = (4) 6. sizeof(ptr1) = (4)
三、C语言动态内存管理方式
malloc / calloc / realloc / free 都是库函数:
void Test ()
{
int* p1 = (int*) malloc(sizeof(int));
free(p1);
// 1.malloc/calloc/realloc的区别是什么?
int* p2 = (int*)calloc(4, sizeof (int));
int* p3 = (int*)realloc(p2, sizeof(int)*10);
// 这里需要free(p2)吗?
free(p3);
}
calloc 和 malloc 区别:
calloc 会按字节初始化,空间每个字节初始化为 0,相当于 malloc + memset 。calloc 开辟的空间也需要释放。
这里不需要 free(p2):
因为 realloc 对 p2 指向的空间进行了扩容,此刻无论是空间充足还是不足并扩容成功的情况下,之后对于申请内存的释放只要针对 p3 即可。
如果你能正确回答以上两个问题,请阅读:深度剖析动态内存管理
四、C++内存管理方式
C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力,而且使用起来比较麻烦,因此C++又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理。
new 和 delete 是操作符,不是函数。
1、对内置类型
int main()
{
int* p1 = new int; // 动态申请 1 个 int 类型的空间
int* p2 = new int[5]; // 申请 5 个 int 类型的空间
delete p1; // 释放 p1
delete[] p2; // 释放 p2
p1 = nullptr;
p2 = nullptr;
return 0;
}
显而易见比C语言的更加简洁,而 new 申请的空间,和 malloc 一样的:
总结:malloc/free 和 new/delete 对于内置类型没有本质区别,只有用法上的区别;而 delete 完的指针最好置空,更加安全,不置也没关系,但是要注意不能使用,因为此刻为野指针。
区分:
int* p1 = new int(5); // 申请一个 int 的空间,空间初始化为 5
int* p2 = new int[5]; // 申请 5 个 int 类型的空间
C++98不支持初始化 new 的数组;C++11 可以通过如下方式进行初始化:
int main()
{
int* p3 = new int[5] {1, 2};
}
申请五个 int ,按照 {} 中顺序依次对空间进行初始化,其他的初始化为 0 :
2、对自定义类型
对于自定义类型来说,malloc 不会调用构造函数,free 不会调用析构函数初始化:
而 new 会调用构造函数,对于数组来说,则会调用数组元素个数的次;同理对于 delete 会调用析构函数,也会析构数组元素个数的次:
(delete[] ,这个 [] 是为了告诉编译器它是一个数组,数组有多少个元素,就要调用对应次数的析构函数,之前 [] 会把对象个数存起来,方便之后调用)
总结 :
-
new 在堆上申请对象空间(指针指向的) + 调用构造函数初始化对象
-
delete 先调用指针类型的析构函数(清理资源) + delete 释放空间申请的空间给堆
若没有默认构造会报错:
但是可以解决报错:
一定要 malloc / free 和 new / delete 和 new[] / delete[] 匹配使用,否则可能会造成死循环或程序奔溃等情况,总之后果自负:
例如 new 和 delete[] 造成死循环 :
new[] 和 delete 报错:
之前说过 new 和 delete 开辟多个空间的方式是把对象个数存起来,以此知道大小。对于 vs ,这块空间会在头部有一块空间,存储个数,这时指针指向的位置是个数的下一个位置:
delete[] 就会往前减去四个字节,取到空间里的值,然后根据值来决定调用多少次析构函数,然后从存放数值的空间的地址开始释放空间。
new[] 和 delete 崩溃的原因:释放空间的指针位置不对。
但是如果把析构函数屏蔽,就不会崩溃:
因为对于自己的析构函数,调用与否也无所谓,这时,不会在头部开这一块空间。
对于初始化,也会进行隐式类型转换:
A* p = new A[4]{1, 2, 3, 4}; // 将 1 进行隐式类型转换为 A 对象
A* p = new A[4]{ A(1), A(2), A(3), A(4) }; // 匿名对象进行调用也可以
// 多参构造函数
A* p = new A[4]{ A(1, 2), A(2, 2), A(3, 2), A(4, 2) }; // 会优化,优化为一次构造
A* p = new A[4]{ {1, 2}, {1, 2}, {1, 2}, {1, 2} }; // 隐式类型转换能成功
五、C++对动态管理的升级
两个方面:
第一个升级的地方自定义类型对象自动申请的时候,初始化和销毁清理的问题,new/delete会调用构造函数和析构函数。
第二个升级则是new失败了以后要求抛异常,这样才符合面向对象语言的出错处理机制。
了解面向过程和对象语言处理错误的方式:
- 面向对象的语言,处理错误的方式一般是抛异常,C++中也要求错误抛异常 – try catch
- 面向过程的语言,处理错误的方式是返回错误码 – perror
比如 C 语言在动态内存开辟时,堆空间是有限的,很有可能申请失败:
int main()
{
char* p1 = (char*)malloc(1024u*1024u*1024u*2u); // u 表示为无符号正数
if (p1 == nullptr)
{
printf("%d\n", errno); // 错误码
perror("malloc fail"); // perror 报错
exit(-1);
}
else
{
printf("%p\n", p1);
}
}
对于 C++ 来说。则是抛出异常,这时再使用上次的检查就无效了,甚至还会奔溃:
int main()
{
char* p1 = new char[1024u * 1024u * 1024u * 2u - 1]; // new 的大小不能超过 7fff ffff,所以要 -1
if (p1 == nullptr)
{
printf("%d\n", errno);
perror("malloc fail");
exit(-1);
}
else
{
printf("%p\n", p1);
}
}
用 C++ 的方法就是 抛异常 ,但是异常其实很难,所以简单了解一下(之后会讲):
bad allocation 就是坏的申请,就是申请失败。
正常申请:
若不抛异常,则走完 try ,不走 catch ;否则会直接走 catch ;对于异常的捕获只会捕获在 try 内的。
抛异常可以跳过函数:
当在函数中捕获到异常后,函数之后的语句不再执行,直接跳转到 catch 处执行。
这样就不必在可能出错的下面检查,只要在 main 函数中捕获即可。不捕获异常就会弹出未经处理的异常的错误。
抛异常解决的是抛出来的错误,对于一些严重错误:内存错误、断言错误等,会终止程序,不会走异常。
ps:delete/free一般不会失败,如果失败了,都是释放空间存在越界或者释放指针的位置不对
六、operator new/operator delete函数
new 是要先申请空间,再调用构造函数;当一个对象被 new 时,是怎么做到开辟空间的?难道是舍弃了之前的 malloc 开辟空间的方式,另辟蹊径来开空间?我们试着探究。
当调试起来后,看到反汇编,一共调了两个函数 operator new
和 它的构造函数,可 operator new 到底是什么?
new 和 delete 是用户进行动态内存申请和释放的操作符,operator new 和 operator delete 是系统提供的全局函数 ,new在底层调用operator new全局函数来申请空间,delete 在底层通过 operator delete 全局函数来释放空间。
使用方法,例如开一个栈的空间:
operator new 是不会调用构造函数的(如果会调用,就没有 new 什么事了),里面的仍然是随机值。
而 operator new 又是对 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 :
/*
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 的封装
- operator new 和 operator delete 调用 malloc 申请内存,失败后,改为抛异常处理错误,这样才符合 C++ 面向对象语言处理错误的方式
如果没有 operator new ,那么 new Stack 之后,就会 call malloc + call Stack构造,而 malloc 不符合 C++ 处理错误方式(失败返回 0 ,而 new 失败也是返回 0)。
operator new 是给 new 的,我们直接使用 new 即可。
补充 :
operator new 会抛异常,并且调用 malloc 函数,如果 malloc 失败,则会抛异常;对于 delete 底层,也进行过了封装,先调用析构函数,再用 operator delete 进行释放。
例:
七、new/delete 的实现原理
1、内置类型
如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。
2、自定义类型
- new的原理
- 调用operator new函数申请空间
- 在申请的空间上执行构造函数,完成对象的构造
- delete的原理
- 在空间上执行析构函数,完成对象中资源的清理工作
- 调用operator delete函数释放对象的空间
- new T[N]的原理
- 调用operator new[]函数(实际上是 operator new 的封装),在operator new[]中实际调用operator new函数完成N个对象空间的申请,类似于
Stack p1 = new Stack[10] ---> Stack* pst1 = (Stack*)operator new[](sizeof(Stack) * 10)
- 在申请的空间上执行N次构造函数
- delete[]的原理
- 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
- 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间
八、定位new表达式(placement-new)
能否调用构造函数?
class A
{
public:
A(int a = 0)
: _a(a)
{
cout << "A():" << this << endl;
}
~A()
{
cout << "~A():" << this << endl;
}
private:
int _a;
};
int main()
{
A* p = (A*)malloc(sizeof(A));
return 0;
}
不能。
在之前构造函数不支持显示调用;而现在则可以使用 定位 new 表达式 来调用构造函数。
定位new表达式是在 已分配的原始内存空间中调用构造函数初始化一个对象 ,例如 malloc 开辟空间后初始化对象。
使用格式:
-
new (place_address) type
或者new (place_address) type(initializer-list)
-
place_address
必须是一个指针,initializer-list
是类型的初始化列表
class A
{
public:
A(int a = 0)
: _a(a)
{
cout << "A():" << this << endl;
}
~A()
{
cout << "~A():" << this << endl;
}
private:
int _a;
};
int main()
{
A* p = (A*)malloc(sizeof(A));
new(p)A; // 不带参初始化,显示调用构造函数
new(p)A(1); // 带参初始化
return 0;
}
模拟 new 行为:
int main()
{
A* p2 = new A(2); // operator new + 构造函数
// 等价于
A* p3 = (A*)operator new(sizeof(A)); // operator new
new(p3)A(3); // 调用构造函数
return 0;
}
析构函数可以显示调用:
int main()
{
A* p3 = (A*)operator new(sizeof(A)); // operator new
new(p3)A(2); // 调用构造函数
p3->~A(); // 调用析构函数
operator delete(p3); // 释放内存
return 0;
}
使用场景 :
定义 new 表达式在平常作用不大,它的真正应用场景是对内存池(池化技术–内存池、线程池、连接池):
定位new表达式在实际中一般是配合内存池使用。因为内存池分配出的内存没有初始化 ,所以如果是自定义类型的对象,需要使用new的定义表达式进行显示调构造函数进行初始化。
内存池解读:
平常都是在堆上拿,现在在内存池拿,如果内存池有,则直接拿(比较快),没有内存池就去堆上拿,拿一大块,慢慢用。
定位 new 的应用:链表中创建节点时,使用内存池来进行,下面两步为显示调用构造函数和显示调用析构函数
九、内存泄漏
动态申请的内存,不使用了,没有释放,就可以说成是内存泄漏。
好玩的:
内存泄漏不是一定有危害,就比如:
int main()
{
char* p = new char[1024u * 1024u * 1024u];
printf("%p\n", p);
return 0;
}
每次耗费一个 g 。虽然没有释放,但是当程序执行结束后,内存会被释放,还给OS.
而内存泄漏无非几种现象:
- 出现内存泄漏的进程正常结束,进程结束时内存会还给OS,不会有什么损害
- 出现内存泄漏的进程非正常结束,比如僵尸进程
- 需要长期运行的程序,出现内存泄漏,危害很大,OS会越来越慢,甚至卡死宕机,例如服务器程序:王者匹配机制、美团骑手匹配
2、3 两点会造成很大的危害。尤其是第三点,每天一点点,服务器崩坏一点点,不容易发现。
内存泄漏有两个典型现象:
void MemoryLeaks()
{
// 1.内存申请了忘记释放
int* p1 = (int*)malloc(sizeof(int));
int* p2 = new int;
// 2.异常安全问题
int* p3 = new int[10];
Func(); // 这里Func函数抛异常导致 delete[] p3未执行,p3没被释放.
delete[] p3;
}
若 Func 有异常,则直接跳转到 catch ,这时 delete[] p3 没有执行,就内存泄漏了。
java 也可能会有内存泄漏,虽然有回收机制,但是 java 后台虚拟机会有一定代价,但是比 C++ 更优。
1、内存泄漏分类
C/C++程序中一般我们关心两种方面的内存泄漏:
堆内存泄漏(Heap leak)
- 堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak 。
系统资源泄漏
- 指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
2、如何检测内存泄漏
在vs下,可以使用windows操作系统提供的_CrtDumpMemoryLeaks() 函数进行简单检测,该函数只报出了大概泄漏了多少个字节,没有其他更准确的位置信息。
int main()
{
int* p = new int[10];
// 将该函数放在main函数之后,每次程序退出的时候就会检测是否存在内存泄漏
_CrtDumpMemoryLeaks();
return 0;
}
// 程序退出后,在输出窗口中可以检测到泄漏了多少字节,但是没有具体的位置
Detected memory leaks!
Dumping objects ->
{79} normal block at 0x00EC5FB8, 40 bytes long.
Data: < > CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD
Object dump complete.
因此写代码时一定要小心,尤其是动态内存操作时,一定要记着释放。但有些情况下总是防不胜防,简单的可以采用上述方式快速定位下。如果工程比较大,内存泄漏位置比较多,不太好查时一般都是借助第三方内存泄漏检测工具处理的。
3、如何避免内存泄漏
- 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
- 采用RAII思想或者智能指针来管理资源。
- 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
- 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。
4、补充
对于 32 位,申请 2g 的空间是失败的,因为对于 32 位的堆空间一共就 2g ,一下子申请显而不太可能,但是将程序改为 64 位就可以,因为此刻的堆空间就变大了很多: