内存泄漏一直是 C++ 中比较令人头大的问题, 即便是很有经验的 C++程序员有时候也难免因为疏忽而写出导致内存泄漏的代码。除了基本的申请过的内存未释放外,还存在诸如异常分支导致的内存泄漏等等。本项目将使用 C++ 实现一个内存泄漏检查器。
要检测一个长期运行的程序是否发生内存泄露通常是在应用中设置检查点,分析不同检查点中内存是否长期保持占用而未释放,从本质上来说,这与对一个短期运行的程序进行内存泄露检查是非常类似的。所以本项目中的内存泄漏检查器将实现一个用于短期内存泄露的检查器。
我们不妨编写下面的测试代码用于检测我们的内存泄露,在这个代码中,我们刻意的不去释放某个申请的内存,并刻意的去制造一个异常分支产生的内存泄露,在 /home/shiyanlou/
目录下新建 LeakDetector
文件夹,并在文件夹下新建 main.cpp
文件:
//
// main.cpp
// LeakDetector
//
#include <iostream>
// 在这里实现内存泄露检查
#include "LeakDetector.hpp"
// 测试异常分支泄露
class Err {
public:
Err(int n) {
if(n == 0) throw 1000;
data = new int[n];
}
~Err() {
delete[] data;
}
private:
int *data;
};
int main() {
// 忘记释放指针 b 申请的内存,从而导致内存泄露
int *a = new int;
int *b = new int;
delete a;
// 0 作为参数传递给构造函数将发生异常,从而导致异常分支的内存泄露
try {
Err* e = new Err(0);
delete e;
} catch (int &ex) {
std::cout << "Exception catch: " << ex << std::endl;
};
return 0;
}
内存泄露检查器的设计
要实现内存泄露的检查,我们可以从下面几个点来思考:
- 内存泄露产生于
new
操作进行后没有执行delete
- 最先被创建的对象,其析构函数永远是最后执行的
对应这两点,我们可以做下面的操作:
- 重载
new
运算符 - 创建一个静态对象,用于在原始程序退出时候才调用这个静态对象的析构函数
这样两个步骤的好处在于:无需修改原始代码的情况下,就能进行内存检查。这同时也是我们希望看到的。所以,我们可以在 LeakDetector/LeakDetector.hpp
里首先实现:
//
// LeakDetector.hpp
// LeakDetector
//
#ifndef __LEAK_DETECTOR__
#define __LEAK_DETECTOR__
void* operator new(size_t _size, char *_file, unsigned int _line);
void* operator new[](size_t _size, char *_file, unsigned int _line);
// 此处宏的作用下一节实现 LeakDetector.cpp 时说明
#ifndef __NEW_OVERLOAD_IMPLEMENTATION__
#define new new(__FILE__, __LINE__)
#endif
class _leak_detector
{
public:
static unsigned int callCount;
_leak_detector() noexcept {
++callCount;
}
~_leak_detector() noexcept {
if (--callCount == 0)
LeakDetector();
}
private:
static unsigned int LeakDetector() noexcept;
};
static _leak_detector _exit_counter;
#endif
为什么要设计 callCount
? callCount
保证了我们的 LeakDetector
只调用了一次。考虑下面的简化代码:
// main.cpp
#include <iostream>
#include "test.h"
int main() {
return 0;
}
// test.hpp
#include <iostream>
class Test {
public:
static unsigned int count;
Test() {
++count;
std::cout << count << ", ";
}
};
static Test test;
// test.cpp
#include "test.hpp"
unsigned int Test::count = 0;
下面我们来逐步实现这个内存泄露检查器。
既然我们已经重载了 new
操作符,那么我们很自然就能想到通过手动管理内存申请和释放,如果我们 delete
时没有将申请的内存全部释放完毕,那么一定发生了内存泄露。接下来一个问题就是,使用什么结构来实现手动管理内存?
不妨使用双向链表来实现内存泄露检查。原因在于,对于内存检查器来说,并不知道实际代码在什么时候会需要申请内存空间,所以使用线性表并不够合理,一个动态的结构(链表)是非常便捷的。而我们在删除内存检查器中的对象时,需要更新整个结构,对于单向链表来说,也是不够便利的。
为此,我们可以继续实现这样一个结构,并创建好 LeakDetector/LeakDetector.cpp
文件:
//
// LeakDetector.cpp
// LeakDetector
//
#include <iostream>
#include <cstring>
// 在此处定义 _DEBUG_NEW_ 宏,
// 从而在这个实现文件中不再继续重载 new 运算符,
// 从而防止编译冲突
#define __NEW_OVERLOAD_IMPLEMENTATION__
#include "LeakDetector.hpp"
typedef struct _MemoryList {
struct _MemoryList *next, *prev;
size_t size; // 申请内存的大小
bool isArray; // 是否申请了数组
char *file; // 存储所在文件
unsigned int line; // 保存所在行
} _MemoryList;
static unsigned long _memory_allocated = 0; // 保存未释放的内存大小
static _MemoryList _root = {
&_root, &_root, // 第一个元素的前向后向指针均指向自己
0, false, // 其申请的内存大小为 0, 且不是数组
NULL, 0 // 文件指针为空, 行号为0
};
unsigned int _leak_detector::callCount = 0;
现在我们来使用这个结构来管理内存分配:
//
// LeakDetector.cpp
// LeakDetector
//
// 从 _MemoryList 头部开始分配内存
void* AllocateMemory(size_t _size, bool _array, char *_file, unsigned _line) {
// 计算新的大小
size_t newSize = sizeof(_MemoryList) + _size;
// 由于 new 已经被重载,我们只能使用 malloc 来分配内存
_MemoryList *newElem = (_MemoryList*)malloc(newSize);
newElem->next = _root.next;
newElem->prev = &_root;
newElem->size = _size;
newElem->isArray = _array;
newElem->file = NULL;
// 如果有文件信息,则保存下来
if (_file) {
newElem->file = (char *)malloc(strlen(_file)+1);
strcpy(newElem->file, _file);
}
// 保存行号
newElem->line = _line;
// 更新列表
_root.next->prev = newElem;
_root.next = newElem;
// 记录到未释放内存数中
_memory_allocated += _size;
// 返回申请的内存,将 newElem 强转为 char* 来严格控制指针每次 +1 只移动一个 byte
return (char*)newElem + sizeof(_MemoryList);
}