使用 C++ 实现内存泄漏检测器

C++C++Beginner
立即练习

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

引言

内存泄漏一直是编程中最棘手的问题之一,即使对于资深程序员也是如此。当程序运行一段时间后,内存泄漏仍然可能发生。除了基本的内存分配泄漏现象外,还有许多不同类型的内存泄漏,例如异常分支泄漏等。本项目将引导你实现一个内存泄漏检测器。

学习内容

  • 重载 new 操作符
  • 预定义宏 __FILE____LINE__
  • 头文件中的静态变量
  • 智能指针 std::shared_ptr

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL cpp(("`C++`")) -.-> cpp/BasicsGroup(["`Basics`"]) cpp(("`C++`")) -.-> cpp/OOPGroup(["`OOP`"]) cpp(("`C++`")) -.-> cpp/AdvancedConceptsGroup(["`Advanced Concepts`"]) cpp/BasicsGroup -.-> cpp/operators("`Operators`") cpp/OOPGroup -.-> cpp/classes_objects("`Classes/Objects`") cpp/OOPGroup -.-> cpp/class_methods("`Class Methods`") cpp/OOPGroup -.-> cpp/constructors("`Constructors`") cpp/AdvancedConceptsGroup -.-> cpp/pointers("`Pointers`") cpp/AdvancedConceptsGroup -.-> cpp/exceptions("`Exceptions`") subgraph Lab Skills cpp/operators -.-> lab-178620{{"`使用 C++ 实现内存泄漏检测器`"}} cpp/classes_objects -.-> lab-178620{{"`使用 C++ 实现内存泄漏检测器`"}} cpp/class_methods -.-> lab-178620{{"`使用 C++ 实现内存泄漏检测器`"}} cpp/constructors -.-> lab-178620{{"`使用 C++ 实现内存泄漏检测器`"}} cpp/pointers -.-> lab-178620{{"`使用 C++ 实现内存泄漏检测器`"}} cpp/exceptions -.-> lab-178620{{"`使用 C++ 实现内存泄漏检测器`"}} end

代码测试

内存泄漏通常与无意中分配但未释放的内存有关。在现代操作系统中,当一个应用程序终止后,操作系统会回收其使用的常规内存,因此由临时应用程序引起的内存泄漏不会造成严重后果。

然而,如果我们编写的应用程序(例如服务器)需要持续运行,那么如果存在导致内存泄漏的逻辑,内存泄漏可能会不断增加。最终,系统的性能会下降,甚至可能导致操作失败。

一个持续分配内存的运行程序非常简单:

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" 代码片段,那么这段代码可以正常运行,如下所示。然而,我们知道它实际上会导致内存泄漏:

运行结果:

image desc

设计内存泄漏检测器

为了实现内存泄漏检测,我们需要考虑以下几点:

  1. 如果在 new 操作后没有执行 delete,就会产生内存泄漏。
  2. 第一个创建的对象的析构函数总是在程序结束时执行。

基于以上两点,我们可以采取以下对应措施:

  1. 重载 new 操作符。
  2. 创建一个静态对象,并在原始程序结束时调用其析构函数。

这两个步骤的好处是,无需修改原始代码即可完成内存泄漏检测。因此,我们可以编写 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

为什么要设计一个 callCountcallCount 确保我们的 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.cpptest.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);
}

考虑到我们有两种不同的方式使用 newdelete

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"。以下是预期输出:

image desc

总结

在本实验中,我们重载了 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;

}

我们的最终输出如下:

image desc

对于某些人来说,结果可能是:
image desc

你的结果可能会有所不同。以上两种结果都是正确的,因为你的环境和编译器可能存在差异。

您可能感兴趣的其他 C++ 教程