引言
内存泄漏一直是编程中最棘手的问题之一,即使对于资深程序员也是如此。当程序运行一段时间后,内存泄漏仍然可能发生。除了基本的内存分配泄漏现象外,还有许多不同类型的内存泄漏,例如异常分支泄漏等。本项目将引导你实现一个内存泄漏检测器。
学习内容
- 重载
new
操作符 - 预定义宏
__FILE__
和__LINE__
- 头文件中的静态变量
- 智能指针
std::shared_ptr
内存泄漏一直是编程中最棘手的问题之一,即使对于资深程序员也是如此。当程序运行一段时间后,内存泄漏仍然可能发生。除了基本的内存分配泄漏现象外,还有许多不同类型的内存泄漏,例如异常分支泄漏等。本项目将引导你实现一个内存泄漏检测器。
new
操作符__FILE__
和 __LINE__
std::shared_ptr
内存泄漏通常与无意中分配但未释放的内存有关。在现代操作系统中,当一个应用程序终止后,操作系统会回收其使用的常规内存,因此由临时应用程序引起的内存泄漏不会造成严重后果。
然而,如果我们编写的应用程序(例如服务器)需要持续运行,那么如果存在导致内存泄漏的逻辑,内存泄漏可能会不断增加。最终,系统的性能会下降,甚至可能导致操作失败。
一个持续分配内存的运行程序非常简单:
int main() {
// 永远分配内存,从不释放
while(1) int *a = new int;
return 0;
}
为了检测运行程序中的内存泄漏,我们通常会在应用程序中设置检查点,以分析内存是否与其他检查点不同。本质上,这类似于短期程序内存泄漏检查。因此,本项目中的内存泄漏检测器将针对短期内存泄漏进行实现。
我们希望以下测试代码能够检测到内存泄漏。在这段代码中,我们没有释放分配的内存,并通过异常分支创建了内存泄漏:
//
// 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[12];
delete a;
// 内存泄漏:0 作为构造函数参数导致异常泄漏
try {
Err* e = new Err(0);
delete e;
} catch (int &ex) {
std::cout << "Exception: " << ex << std::endl;
};
return 0;
}
如果我们注释掉 #include "LeakDetector.hpp"
代码片段,那么这段代码可以正常运行,如下所示。然而,我们知道它实际上会导致内存泄漏:
运行结果:
为了实现内存泄漏检测,我们需要考虑以下几点:
new
操作后没有执行 delete
,就会产生内存泄漏。基于以上两点,我们可以采取以下对应措施:
new
操作符。这两个步骤的好处是,无需修改原始代码即可完成内存泄漏检测。因此,我们可以编写 LeakDetector.hpp
:
//
// /home/labex/Code/LeakDetector.hpp
//
#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;
最终输出是 1, 2,
。这是因为头文件中的静态变量会被多次定义,每次被包含到一个 .cpp
文件中时都会定义一次。(在这种情况下,它会被包含在 main.cpp
和 test.cpp
中。)然而,本质上它们是同一个对象。
现在,剩下的问题是:如何实现一个内存泄漏检测器?
让我们来实现内存泄漏检测器。
由于我们已经重载了 new
操作符,很容易想到手动管理内存分配和自然释放内存。如果我们的 delete
没有释放所有分配的内存,就会发生内存泄漏。现在的问题是:我们应该使用什么结构来手动管理内存?
双向链表是一个不错的选择。在实际代码中,我们不知道何时需要分配内存,因此单向线性链表不够好,而动态结构(例如链表)会更加方便。此外,单向线性链表在删除内存泄漏检测器中的对象时也不方便。
因此,双向链表是目前的最佳选择:
#include <iostream>
#include <cstring>
// 定义 __NEW_OVERLOAD_IMPLEMENTATION__ 宏,
// 防止重载 `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;
现在,让我们手动管理分配的内存:
// 从 _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;
// 返回分配的内存
return (char*)newElem + sizeof(_MemoryList);
}
以及重载 delete
操作符:
void DeleteMemory(void* _ptr, bool _array) {
// 回到 MemoryList 的起始位置
_MemoryList *currentElem = (_MemoryList *)((char *)_ptr - sizeof(_MemoryList));
if (currentElem->isArray != _array) return;
// 更新链表
currentElem->prev->next = currentElem->next;
currentElem->next->prev = currentElem->prev;
_memory_allocated -= currentElem->size;
// 释放文件信息的内存
if (currentElem->file) free(currentElem->file);
free(currentElem);
}
考虑到我们有两种不同的方式使用 new
和 delete
:
void* operator new(size_t _size) {
return AllocateMemory(_size, false, NULL, 0);
}
void* operator new[](size_t _size) {
return AllocateMemory(_size, true, NULL, 0);
}
void* operator new(size_t _size, char *_file, unsigned int _line) {
return AllocateMemory(_size, false, _file, _line);
}
void* operator new[](size_t _size, char *_file, unsigned int _line) {
return AllocateMemory(_size, true, _file, _line);
}
// 重载 operator delete
void operator delete(void *_ptr) noexcept {
DeleteMemory(_ptr, false);
}
void operator delete[](void *_ptr) noexcept {
DeleteMemory(_ptr, true);
}
最后,我们需要实现 _leak_detector::LeakDetector
,它将在 static _leak_detector _exit_counter
的析构函数中被调用。然后所有分配的对象将被释放。如果 static _MemoryList _root
不为空,则会出现内存泄漏,我们只需要遍历 _root
来找到内存泄漏的位置:
unsigned int _leak_detector::LeakDetector(void) noexcept {
unsigned int count = 0;
// 遍历整个链表,如果存在内存泄漏,
// 则 _LeakRoot.next 不会指向自身
_MemoryList *ptr = _root.next;
while (ptr && ptr != &_root)
{
// 输出所有泄漏信息
if(ptr->isArray)
std::cout << "Leaking[] ";
else
std::cout << "Leaking ";
std::cout << ptr << ", size of " << ptr->size;
if (ptr->file)
std::cout << " (located in " << ptr->file << ", line " << ptr->line << ")";
else
std::cout << " (no file info)";
std::cout << std::endl;
++count;
ptr = ptr->next;
}
if (count)
std::cout << "Existing " << count << " memory leaking behavior, "<< _memory_allocated << " byte in total." << std::endl;
return count;
}
让我们编译所有内容:
g++ main.cpp LeakDetector.cpp -std=c++11 -Wno-write-strings
在 main.cpp
中,取消注释 include "LeakDetector.hpp"
。以下是预期输出:
在本实验中,我们重载了 C++ 中的 new
操作符,并实现了一个内存泄漏检测器。实际上,该代码也可以用于检测智能指针的循环引用泄漏。我们只需要将 main.cpp
修改如下:
#include <memory> // 使用智能指针
// 开头部分与之前的 main.cpp 相同
// 添加两个测试类
class A;
class B;
class A {
public:
std::shared_ptr<B> p;
};
class B {
public:
std::shared_ptr<A> p;
};
int main() {
// 此处与之前的 main.cpp 相同
// 额外测试用例,使用智能指针
auto smartA = std::make_shared<A>();
auto smartB = std::make_shared<B>();
smartA->p = smartB;
smartB->p = smartA;
return 0;
}
我们的最终输出如下:
对于某些人来说,结果可能是:
你的结果可能会有所不同。以上两种结果都是正确的,因为你的环境和编译器可能存在差异。