C++编写代码跟踪内存分配的简单方法

C/C++
65
0
0
2024-09-23

为什么要跟踪内存分配?

关于内存的事情是很重要的,计算机和内存是紧密相连的,如果你只有一个cpu,而没有ram没有内存就什么都做不了。

而在C++中跟踪内存分配的重要性主要体现在以下几个方面:

避免内存泄漏:

C++中的动态内存分配(通过new和delete操作符)需要程序员手动管理内存。如果不正确地释放已分配的内存,可能会导致内存泄漏,尤其是在长时间运行的程序中。内存泄漏会随着时间的推移而累积,最终可能导致程序崩溃或系统资源耗尽。

优化内存使用:

例如在嵌入式系统中,内存资源通常有限。频繁的动态内存分配和释放可能会导致堆碎片化,从而影响程序的性能和稳定性。通过跟踪内存分配,可以更好地理解内存使用模式,从而优化内存管理策略,例如合理使用内存池或者预分配内存等。

提高程序性能:

跟踪内存分配可以找出不必要的内存分配和释放,从而减少不必要的开销。例如,如果发现某个对象频繁地分配和释放内存,可能是因为该对象的生命周期管理不当,通过优化其生命周期管理,可以提高程序的性能。

保证程序稳定性:

在复杂的软件系统中,内存管理错误可能会导致程序崩溃或者未定义的行为。通过跟踪内存分配,可以及时发现和修复这些问题,从而提高程序的稳定性和可靠性。

总之知道程序什么时候分配内存,特别是堆内存,因为堆上分配代码并不是最好的做法,尤其是性能关键的代码中。除此之外看到内存被分配到哪里,还可以更好的理解程序是如何工作的,即使这个程序的是你写的。

另外该文章中探讨,展示的所有东西,都可以很容易的插入到你现有的应用程序中!

最简单的演示例

#include <iostream>

struct Object {
  	int x, y, z;  
};

int main() {
    Object a;	//栈分配
    Object *b = new Object;		//堆分配
}

这篇文章的重点就是如何检测堆分配或栈分配,方法就是重写new运算符。

new操作符的new关键字实际上是一个函数,它被调用时带有特定的大小,可能还有其他参数。

现在我们

加入重载new

#include <iostream>

void *operator new(size_t size) {
    std::cout << "堆分配内存:"<< size << "bytes\n";
	return malloc(size);	//分配特定数量的内存并返回一个指向该内存的指针
}

struct Object {
  	int x, y, z;  
};

int main() {
    Object a;	//栈分配
    Object *b = new Object;		//堆分配
}
  • 通过这段额外的重载new代码:将不使用标准库中的new操作符,连接器实际上会链接到这个函数中。
  • 这个函数是返回一个void指针,它只是一个内存地址,因为不想影响程序的行为,便简单输入return malloc(size)

这里重写的好处有很多

  • 可以在重载的new函数中设置一个断点,则程序会在堆分配的地方停下来,便于查找程序中堆分配的语句,从而更好的去优化它们!
  • 也可以在其中输出一点东西来计数

现在运行一下程序

追踪堆分配

运行程序

可以很明显的看出该程序在return处停住了,并且通过调用堆栈这个visual提供的窗口点击告诉了我们堆分配来自于何处。

当然这个例子是非常明显的,如果我们加入一个字符串呢?

加入字符串

#include <iostream>

void* operator new(size_t size) 
{
    std::cout << "堆分配内存:" << size << "bytes\n";
    
    return malloc(size);	//分配特定数量的内存并返回一个指向该内存的指针
}

struct Object {
    int x, y, z;
};

int main() {

    std::string lcc = "lcc";

    Object a;	//栈分配
    Object* b = new Object;		//堆分配
}

我们有一个很小的字符串,它不会在堆里分配内存来存储这些字符,但在调试模式下,仍然会分配一些内存给它

追踪一下内存分配

内存分配追踪

调用堆栈的追踪

当然这并不是百分百体验其作用,如果使用智能指针,而不是显式调用new呢?

加入智能指针

#include <iostream>
#include <memory>

void* operator new(size_t size) 
{
    std::cout << "堆分配内存:" << size << "bytes\n";
    
    return malloc(size);	//分配特定数量的内存并返回一个指向该内存的指针
}

struct Object {
    int x, y, z;
};

int main() {

    std::unique_ptr<Object> obj = std::make_unique<Object>();

    std::string lcc = "Cherno";

    Object a;	//栈分配
    Object* b = new Object;		//堆分配

    return 0;
}

显然智能指针仍然会分配内存,但我们可以看到这发生在make_unique内部,因为unique会调用new分配内存

希望通过这些简单的使用例,你可以看到在重载的new函数中插入一个断点,并精确地追踪这些内存分配来源的方法。提高内存利用的方法我就不细讲了,内存池或者一个不断调整大小的vector,或者使用一些不怎么分配内存的东西都是解决办法。

同理,delete也可以重载

现在我们加入

delete的重载

#include <iostream>
#include <memory>

void* operator new(size_t size) 
{
    std::cout << "堆分配内存:" << size << "bytes\n";
    
    return malloc(size);	//分配特定数量的内存并返回一个指向该内存的指针
}

void operator delete(void* memory, size_t size) {
    std::cout << "释放了" << size << "bytes\n";
    free(memory);
}

struct Object {
    int x, y, z;
};

int main() {
    {
        std::unique_ptr<Object> obj = std::make_unique<Object>();
    }
    std::string lcc = "Cherno";

    Object a;	//栈分配
    Object* b = new Object;		//堆分配

    return 0;
}

在free处放一个断点,把unique_ptr放到一个小的作用域内,你可以看到重载的delete被调用,在main函数中的unique_ptr被销毁之后

堆栈追踪

实际上是这个unique_ptr的析构函数,它实际删除了底层的原始指针

另外通过下面这张运行截图你可以发现,我们少释放了

Object* b = new Object; //堆分配

这是因为缺少delete b;导致的,可以看出追踪内存分配的重要性。

运行截图

关于动态申请的数组

这里的 new delete对动态申请的数组没有作用

这是因为C++中的动态数组分配是通过new[]操作符完成的,而释放则是通过delete[]操作符。因此,需要为这两个操作符提供重载版本。

// 重载的new[]和delete[]操作符
void* operator new[](size_t size) 
{
    std::cout << "堆分配数组内存:" << size << "bytes\n";
    return malloc(size);
}

void operator delete[](void* memory, size_t size) {
    std::cout << "释放了数组内存:" << size << "bytes\n";
    free(memory);
}

如果有检查动态申请数组的需求加入这两段就好了

内存分配追踪器

而现在利用这两个函数,便可以创建简单的内存分配跟踪器了,可以知道有多少内存被使用,分配,释放等等。

struct MemoryTracker {
    uint32_t TotalMemory = 0;	//总分配内存
    uint32_t TotalFreed = 0;	//总释放内存

    uint32_t CountMemory() { return TotalMemory - TotalFreed; } 	//写一个小函数来输出 当前用了多少内存
};
static MemoryTracker temp;	//创建一个静态实例,全局的!

void* operator new(size_t size) {
    temp.TotalMemory += size;	//💡在每一个new里计算总共分配了多少内存

    return malloc(size);
}

void operator delete(void* memory, size_t size) {
    temp.TotalFreed += size;

    free(memory);
}

void* operator new[](size_t size)
{
        temp.TotalMemory += size;
        return malloc(size);
}

void operator delete[](void* memory, size_t size) 
{
    temp.TotalFreed += size;
    free(memory);
}

//可以用一个函数输出我们的内存使用情况
static void PrintMemoryUsage() {
    std::cout << "内存使用了:" << temp.CountMemory() << "\n";
}

内存分配器使用例

#include <iostream>
#include <memory>

struct MemoryTracker {
    uint32_t TotalMemory = 0;	//总分配内存
    uint32_t TotalFreed = 0;	//总释放内存

    uint32_t CountMemory() { return TotalMemory - TotalFreed; } 	//写一个小函数来输出 当前用了多少内存
};
static MemoryTracker temp;	//创建一个静态实例,全局的!

void* operator new(size_t size) {
    temp.TotalMemory += size;	//💡在每一个new里计算总共分配了多少内存

    return malloc(size);
}

void operator delete(void* memory, size_t size) {
    temp.TotalFreed += size;

    free(memory);
}

void* operator new[](size_t size)
{
        temp.TotalMemory += size;
        return malloc(size);
}

void operator delete[](void* memory, size_t size) 
{
    temp.TotalFreed += size;
    free(memory);
}

//可以用一个函数输出我们的内存使用情况
static void PrintMemoryUsage() {
    std::cout << "内存使用了:" << temp.CountMemory() << "\n";
}


struct Object {
    int x, y, z;
};

int main() {
    PrintMemoryUsage();
    {
        std::unique_ptr<Object> obj = std::make_unique<Object>();
        PrintMemoryUsage();
    }
    PrintMemoryUsage();
    {
        std::string lcc = "Cherno";
        PrintMemoryUsage();
    }
    //作用域结束时,lcc对象将自动被销毁,其内存也将被自动释放。
    PrintMemoryUsage();//输出0
    Object a;	//栈分配
    Object* b = new Object;		//堆分配

    PrintMemoryUsage();
    delete b;
    PrintMemoryUsage();//释放了输出0
    return 0;
}

运行结果

至此结束

总结

如果觉得这很有用,可以放在自己的程序里测试一下效果如何,当然也可以使用工具来解决这个,而不是使用代码,例如可以使用vs内置的内存分配跟踪分析工具外面有很多现成可用的工具,但是就个人而言这是一个快速简单的方法,有时会更有效XD

参考例

  1. Track MEMORY ALLOCATIONS the Easy Way in C++
  2. 跟踪内存分析的简单方法