8.内存泄漏的后果?如何监测?解决方法?
8.内存泄漏的后果?如何监测?解决方法?
1.内存泄漏
1.1定义
内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制;
1.2种类
1.2.1C/C++程序中一般我们关心两种方面的内存泄漏:
1.堆内存泄漏(Heap leak)
堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
2.系统资源泄漏
指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。[(8条消息) 什么是内存泄漏,常见引起引起内存泄漏的原因,及解决办法_内存泄露_Lonely池的博客-CSDN博客]
1.2.2常见的几种情况:
1.new(malloc)和delete(free)没有配套使用
在堆里创建了对象占用了内存,但是没有显示地释放对象占用的内存
这种是最简单,最直接的内存泄露,很容易发现
2类的构造和析构函数中没有匹配的使用new和delete函数
类中涉及对资源的管理
#include <iostream>
using namespace std;
class MyClass
{
private:
int _data;
int* _p;
public:
MyClass()
{
_p = new int[5]; // 使用 new 运算符分配内存
// 构造函数的其他操作
cout << "MyClass()构造函数,使用 new 运算符分配内存,首地址地址保存在_p中 " << endl;
}
~MyClass()
{
delete[] _p; // 使用 delete[] 运算符释放堆上分配的内存
// 析构函数的其他操作
cout << "MyClass()析构函数,使用 delete 释放运算符分配的内存" << endl;
}
};
int main()
{
MyClass *p1 = (MyClass*)malloc(sizeof(MyClass)); // 创建 MyClass 对象
delete p1;//未正确退出
MyClass* p2 = new MyClass;
free(p2);//调用了构造函数但是没有调用析构函数,产生内存泄漏
MyClass* p3 = new MyClass;
delete[] p3;//直接中断
MyClass* p4 = new MyClass[10];
free(p4);//没有调用析构函数,产生内存泄漏
MyClass* p5 = new MyClass[10];
delete p5;//直接中断
return 0;
}
对于p1
●我们希望使用malloc函数申请一个MyClass类型的对象
●malloc确实申请空间成功,但未打印出构造函数中的MyClass()
●所以p1指向的空间不是对象,而是指向与MyClass类型大小相同的空间;
●用delete释放空间会调用析构函数(将p1指向的空间当做对象),此时会报错
●因为在malloc申请空间不会调用构造函数
new T,T是内置类型----申请方式与malloc相似
- 申请空间;
- 调用构造函数对空间内容初始化
现有MyClass类,类中不涉及对资源的管理
#include <iostream>
using namespace std;
class MyClass
{
private:
int _data;
public:
MyClass()
{
cout << "MyClass()构造函数,使用 new 运算符分配内存,首地址地址保存在_p中 " << endl;
}
~MyClass()
{
cout << "MyClass()析构函数,使用 delete 释放运算符分配的内存" << endl;
}
};
int main()
{
MyClass* p1 = new MyClass;
delete(p1);
MyClass* p2 = new MyClass[10];
delete (p2);
return 0;
}
p1和p2对象均可开辟成功,但是在delete时出错;
如果将析构函数屏蔽,delete时可以通过编译,也不会产生内存泄漏;
为何如此需要我们了解new和delete的底层实现;
new、delete的底层实现
- new和delete是用户进行动态内存申请和释放的操作符
- operator new 和operator delete是系统提供的全局函数
- new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间
如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:
参考:[(9条消息) C++:new与delete_c++new和delete的用法_风吹雨淋的博客-CSDN博客]
3.没有正确地清除嵌套的对象指针
int* a = new int;
int** b = new int*;
*b = a;
这样的情况下,如果你先把b释放了,a自身的值就没了,导致a原来申请的空间现在没人管了,造成了内存泄漏。
4.在释放对象数组时在delete中没有使用方括号
5.指向对象的指针数组不同于对象数组
这种泄露有点像第4种,
①.对象数组:数组的每个元素是对象本身
②.指向对象的指针数组:数组的每个元素是指针,每个指针指向的是对象
小窍门: 理解各种相似名字的时候,先不要看他的修饰词,就只看名词本身。
指向对象的指针数组:他就是一个数组,数组的每个元素是指针
数组指针:他就是一个指针,是指向数组的指针
针对对象数组:
释放的时候,直接 delete[] 就可以,刚刚讨论过了
针对指向对象的指针数组:
直接 delete[] 是不行的,因为他释放的是指针的空间,指针指向的空间并没有释放(析构函数没有调用)
应该先遍历数组,依次调用每个元素的析构函数
最后使用 delete[],将数组中的每个指针释放了
6.缺少拷贝构造函数
这部分主要参考侯捷视频,是从22分钟开始将这部分的内容
缺少的意思并不是没有,系统有默认的,但是默认的并不能达到所有要求,这时候就必须自己再写一个
这种泄露是因为两次调用了delete,
两次释放相同的内存是一种错误的做法,同时可能会造成堆的奔溃。
这里,我分了三种情况(主要讨论后面两种情况)
①.按值传递(默认、自己写)
②.指针传递(默认)
③.指针传递(自己写)
①.按值传递(默认、自己写)
这种情况,拷贝构造函数不管是默认的还是自己写的,都是将值复制一份,然后给class,没什么说的
②.指针传递(默认)
这种的话,可以解决大部分情况,但是也有不能解决的情况。
假设我们有这一个class a,他内部有个指针,指向了hello,已经分配过了空间
这时候,我们new一个class b,让他等于class a,如果没有写新的拷贝构造,他就会调用默认的,默认的他只一个功能,一个对一个的复制。最终会变成这样
两个class里面的指针都指向了hello这个空间,一旦这时候,调用了class a或者class b的析构函数,hello所在的空间会被释放,这时候,另一个class的指针虽然还是指向那个空间,但是,内容已经没有了,这种指针有一个名字 “悬空指针”不管之后是使用这个指针,还是调用析构函数释放这个指针,都会引发不可预知的错误。这就是内存泄露,这种形式的拷贝也有一个名字 “浅拷贝”
③.指针传递(自己写)
发现了默认拷贝构造函数的问题,我们就需要自己写一个。
过程:
a.给class b的指针分配空间
b.使用复制函数(strcpy),将class a中指针指向的hello复制给class b一份。
执行之后是这样的
这样的就没有内存泄露,这种方式的拷贝叫 “深拷贝”
7.缺少重载赋值运算符
这种情况与 6 相似,是因为默认的赋值运算符,并没有特殊的操作。比如下面这种
执行之后,P1原来指向的空间,因为没有调用delete,导致失控。而且,赋值之后,P1和P2指向了同一块空间,这种情况刚刚讨论过,也会导致内存泄漏
8.没有将基类的析构函数定义为虚函数
虚析构函数是为了解决基类的指针指向派生类对象,并用基类的指针删除派生类对象。
class People
{
public:
People()
{
cout << "构造函数 People!" << endl;
}
virtual void showName() = 0;
virtual ~People()
{
cout << "析构函数 People!" << endl;
}
};
class Worker : public People
{
public:
Worker()
{
cout << "构造函数 Worker!" << endl;
pName = new char[10];
}
virtual void showName()
{
cout << "打印子类的名字!" << endl;
}
~Worker()
{
cout << "析构函数 Worker!" << endl;
if (pName != NULL) {
delete pName;
}
}
private:
char* pName;
};
void test()
{
People* people = new Worker;
people->~People();
}
当基类指针指向子类对象时,如果基类的析构函数不是 virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露。
有一道题,面试官问,std::string能否被继承,为什么?
std::string的析构函数定义如下:
~basic_string()
{
_M_rep()->_M_dispose(this->get_allocator());
}
这块需要特别说明下,std::basic_string是一个模板,而std::string是该模板的一个特化,即std::basic_string。
typedef std::basic_string<char> string;
现在我们可以给出这个问题的答案:不能,因为std::string的析构函数不为virtual,这样会引起内存泄漏
。
仍然以一个例子来进行证明。
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
using namespace std;
class Base
{
public:
Base()
{
buffer_ = new char[10];
}
~Base()
{
std::cout << "in Base::~Base" << std::endl;
delete[]buffer_;
}
private:
char* buffer_;
};
class Derived : public Base
{
public:
Derived() {}
~Derived()
{
std::cout << "int Derived::~Derived" << std::endl;
}
};
int main()
{
Base* base = new Derived;
delete base;
return 0;
}
输出:
in Base::~Base
可见,上述代码并没有调用派生类Derived的析构函数,如果派生类中在堆上申请了资源,那么就会产生内存泄漏
。
为了避免因为继承导致的内存泄漏,我们需要将父类的析构函数声明为virtual
,代码如下(只列了部分修改代码,其他不变):
virtual ~Base()
{
std::cout << "in Base::~Base" << std::endl;
delete[]buffer_;
}
然后重新执行代码,输出结果如下:
int Derived::~Derived
in Base::~Base
借助此文,我们再次总结下存在继承情况下,构造函数和析构函数的调用顺序。
派生类对象在创建时构造函数调用顺序:
①.调用父类的构造函数
②.调用父类成员变量的构造函数
③.调用派生类本身的构造函数
派生类对象在析构时的析构函数调用顺序:
①.执行派生类自身的析构函数
②.执行派生类成员变量的析构函数
③.执行父类的析构函数
为了避免存在继承关系时候的内存泄漏,请遵守一条规则:无论派生类有没有申请堆上的资源,请将父类的析构函数声明为virtual。
参考:[(8条消息) C/C++ 内存泄漏-原因、避免以及定位_c++内存泄漏_北极熊~~的博客-CSDN博客] [(9条消息) C++中的内存泄漏_没有对嵌套的对象指针进行嵌套释放_Ember_Sky的博客-CSDN博客]
1.3后果
如果申请的内存没有释放,随着进程的正常结束,则这个内存会被自动释放。
但长期运行的程序遇到内存泄漏的问题,危害就非常的大。比如:操作系统、服务器,这些程序长期运行,若遇到内存泄露的问题会导致可用的内存越来越少,某些服务的操作失败(打开文件、创建套接字、发送数据)。
2.如何监测?
3) 如何排除
使用工具软件BoundsChecker,BoundsChecker是一个运行时错误检测工具,它主要定位程序运行时期发生的各种错误;
调试运行DEBUG版程序,运用以下技术:CRT(C run-time libraries)、运行时函数调用堆栈、内存泄漏时提示的内存分配序号(集成开发环境OUTPUT窗口),综合分析内存泄漏的原因,排除内存泄漏。
4) 解决方法
智能指针。
5) 检查、定位内存泄漏
检查方法:在main函数最后面一行,加上一句_CrtDumpMemoryLeaks()。调试程序,自然关闭程序让其退出,查看输出:
输出这样的格式{453}normal block at 0x02432CA8,868 bytes long
被{}包围的453就是我们需要的内存泄漏定位值,868 bytes long就是说这个地方有868比特内存没有释放。
定位代码位置
在main函数第一行加上_CrtSetBreakAlloc(453);意思就是在申请453这块内存的位置中断。然后调试程序,程序中断了,查看调用堆栈。加上头文件#include <crtdbg.h>
3.预防与排查
1.valgrind
■应用环境:Linux
■编程语言:C/C++
■使用方法: 编译时加上-g选项,如 gcc -g filename.c -o filename,使用如下命令检测内存使用情况:
■结果输出:#valgrind --tool=memcheck --leak-check=yes --show-reachable=yes ./filename,就会看到内存使用报告
■设计思路:根据软件的内存操作维护一个有效地址空间表和无效地址空间表(进程的地址空间)
■优缺点:
能够检测:
●使用未初始化的内存 (Use of uninitialised memory)
●使用已经释放了的内存 (Reading/writing memory after it has been free’d)
●使用超过 malloc分配的内存空间(Reading/writing off the end of malloc’d blocks)
●对堆栈的非法访问 (Reading/writing inappropriate areas on the stack)
●申请的空间是否有释放 (Memory leaks – where pointers to malloc’d blocks are lost forever)
●malloc/free/new/delete申请和释放内存的匹配(Mismatched use of malloc/new/new [] vs free/delete/delete [])
●src和dst的重叠(Overlapping src and dst pointers in memcpy() and related functions)
●重复free
如何获取:http://valgrind.org/
2.使用智能指针
2.1shared_ptr共享的智能指针
shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。在最后一个shared_ptr析构的时候,内存才会被释放。
注意事项:
①不要用一个原始指针初始化多个shared_ptr
②不要再函数实参中创建shared_ptr,在调用函数之前先定义以及初始化它
③不要将this指针作为shared_ptr返回出来
④要避免循环引用
2.2unique_ptr独占的智能指针
①unique_ptr是一个独占的智能指针,他不允许其他的智能指针共享其内部的指针,不允许通过赋值将一个unique_ptr赋值给另外一个 unique_ptr;
②unique_ptr不允许复制,但可以通过函数返回给其他的unique_ptr,还可以通过std::move来转移到其他的unique_ptr,这样它本身就不再 拥有原来指针的所有权了;
③如果希望只有一个智能指针管理资源或管理数组就用unique_ptr,如果希望多个智能指针管理同一个资源就用shared_ptr。
2.3weak_ptr弱引用的智能指针
弱引用的智能指针weak_ptr是用来监视shared_ptr的,不会使引用计数加一,它不管理shared_ptr内部的指针,主要是为了监视shared_ptr的生命 周期,更像是shared_ptr的一个助手。 weak_ptr没有重载运算符*和->,因为它不共享指针,不能操作资源,主要是为了通过shared_ptr获得资源的监测权,它的构造不会增加引用计数,它的析构不会减少引用计数,纯粹只是作为一个旁观者来监视shared_ptr中关连的资源是否存在。 weak_ptr还可以用来返回this指针和解决循环引用的问题。
参考:[(9条消息) C++内存泄漏/内存越界的各种情况,以及预防与排查_李吱恩的博客-CSDN博客]
3.set_new_handler(out_of_memroy); //注意参数传递的是函数的地址
4.将内存的分配封装到类中,构造函数分配内存,析构函数释放内存
5.将基类的析构函数定义为虚函数
6.对象计数
方法:在对象构造时计数++,析构时–-,每隔一段时间打印对象的数量
优点:没有性能开销,几乎不占用额外内存。定位结果精确。
缺点:侵入式方法,需修改现有代码,而且对于第三方库、STL容器、脚本泄漏等因无法修改代码而无法定位。
7.重载new和delete
方法:重载new/delete,记录分配点(甚至是调用堆栈),定期打印。
优点:没有看出
缺点:侵入式方法,需将头文件加入到大量源文件的头部,以确保重载的宏能够覆盖所有的new/delete。记录分配点需要加锁(如果你的程序是多线程),而且记录分配要占用大量内存(也是占用的程序内存)。[(9条消息) 【C++】内存泄漏详解_c++内存泄漏操作_信手斩龙的博客-CSDN博客]