为什么要跟踪内存分配?
关于内存的事情是很重要的,计算机和内存是紧密相连的,如果你只有一个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