C/C++内存泄漏分析和解决方法

目录

一、内存泄漏是什么

二、常见的内存泄漏案例

1. 分配和释放不匹配

2. 嵌套指针释放不完全

3. 基类析构函数不是虚函数 

4. 用free不能触发对象的析构

5. 更新未释放内存的指针

6. 无法删除引用指向的内存地址

7. 循环引用

8. 野指针和悬挂指针

9. 浅拷贝产生悬挂指针

10. 异常安全问题

11. 隐式内存泄漏

三、解决内存泄漏的方法

1. 依靠程序员的谨慎

2. 使用智能指针

3. 使用RAII

4. 使用容器和对象管理内存

5. 内存管理工具

6. 代码审查

参考文献


一、内存泄漏是什么

内存泄漏(Memory Leak):由于某种原因,程序代码中动态申请的上内存在使用后没有被正确地释放,从而造成内存的浪费。

内存泄漏可能会带来以下几种影响:

  • 程序运行效率下降:由于内存泄漏会导致程序内存不足,从而导致程序运行效率下降,程序执行变慢或者无法正常运行。可能会使程序崩溃或者因为内存占用过多而启动失败。
  • 程序出现安全漏洞:内存泄漏也可能会导致安全漏洞,因为泄露的内存中可能包含敏感数据,如密码、银行卡号等,这些数据可能被黑客利用来进行攻击。
  • 内存资源枯竭:当程序长时间运行后,内存泄漏所占用内存不断增加,系统可能会变得不稳定、非常缓慢甚至崩溃。为避免系统崩溃,在无法申请到内存时,要果断调用exit()函数主动杀死进程,而不是试图挽救这个进程。

以产生的方式来分类,内存泄漏可以分为四类:

  • 常发性内存泄漏:产生内存泄漏的代码或者函数会被多次执行到。

  • 偶发性内存泄漏:产生内存泄漏的代码只在特定的场景下才会被执行。

  • 一次性内存泄漏:造成泄漏的代码只会被执行一次。

  • 隐式内存泄漏:程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。

二、常见的内存泄漏案例

1. 分配和释放不匹配

  • 使用 new 操作符分配的内存,没有主动使用 delete 操作符释放。
int *p = new int;
p = new int; // 错误:上一行new的内存尚未释放。缺少一次 delete p;
delete p;
  • 使用 new 申请的数组,释放时要用 delete[] 删除,如果错误地使用 delete 删除,就会造成内存泄漏。
int main(){
	int* ptr = new int[2];
	// usr ptr ...
	// delete ptr; // 错误!释放数组要用 delete[]
	delete[] ptr; // 正确!
	return 0;
}
  • 使用 malloc 申请的内存,没有主动调用 free 释放。
int *p = (int*)malloc(sizeof(int));
p = (int*)malloc(sizeof(int)); // 错误:上一行malloc的内存尚未释放。缺少一次 free(p);
free(p);
  • 一些库函数(如strdup())会返回临时内存,如果没有被显式释放,就会造成内存泄漏。
#include <string.h>
#include <stdio.h>
#include <stdlib.h>

int main(void) {
   // 指向字符常量(字符串字面量)的指针。
   // 字符串字面量通常存储在程序的只读数据段中,不应该被释放或修改。
   char* string = "www.dotcpp.com"; 
   
   // strdup函数返回指向新分配的内存空间的指针,如果空间不足则返回 NULL。
   char* dup_str = strdup(string);
   printf("%s\n", dup_str);
   free(dup_str); // 释放内存,避免内存泄漏
   return 0;
}
  • malloc/free以及new/delete必须各自成对出现,混用会导致意想不到的情况出现。例如,使用 free 释放 new 申请的内存、用delete释放void指针指向的对象等,没有调用到对象的析构函数,导致内存泄漏。 

2. 嵌套指针释放不完全

释放指针数组时,不光需要释放对象的内存空间,还要释放其中的每个指针。如果只是释放对象的内存空间,就会导致释放不完全,造成内存泄漏。

// 数组指针
int (*a)[N] = new int[M][N];  // M元数组的头指针a,指向一个N元数组,N必须为已知
delete[] a;

// 指针数组
int **b = new int*[M]; // M元数组的头指针b,数组中包含M个int指针
for(int i = 0; i < M; i++) {
    b[i] = new int[N]; 
}
//对应内存释放为
for(int i = 0; i < M; i++) {
    delete[] b[i];     
}
delete[] b;

3. 基类析构函数不是虚函数 

基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄漏。在STL中std::string、std::map等容器不能被继承,因为它们的析构函数都没有声明为虚函数。

class A {
public:
	A(){}
	// ~A(){} // 错误
    virtual ~A() // 正确
}
 
class B : public A  {
public:
	B(){}
	~B(){}
private:
	int num;
}
 
void main() {
	A* pa = new B();
	delete pa;
}

4. 用free不能触发对象的析构

用malloc/free处理类对象,并不能触发对象析构。当类成员中有指针时,就会产生内存泄漏。

class MyClass {
private:
    int* value;
public:
    MyClass(int v) : value(new int(v)) {
        std::cout << "Constructor class" << std::endl;
    }
 
    ~MyClass() {
        delete value;
        std::cout << "Delete class" << std::endl;
    }
};

int main() {
    MyClass* obj = (MyClass*)malloc(sizeof(MyClass));
    new(obj) MyClass(44); 
    free(obj);
    return 0;
} // 用malloc()为对象指针obj分配内存,再显式调用构造函数进行初始化,最后用free()释放内存
  // 由于free()不会调用析构函数,只释放了指针的内存空间,对象的内存空间无法释放,造成内存泄漏

解决办法:
int main() {
    MyClass* obj =  new MyClass(44); 
    delete obj;
    return 0;
} // 当使用delete释放内存时,会先调用析构函数,释放对象内存空间,
  // 然后再释放指针内存空间,从而避免内存泄漏

5. 更新未释放内存的指针

为类成员变量动态分配内存时,如果没有检查是否已有内存并释放旧内存,会造成内存泄漏。

class Student {
private:
	char* mName;
 
public:
	Student() : name(nullptr) {}

    Student(const char* name) {
        if (name != nullptr) {
			int len = strlen(name);
		    this->mName = new char[len + 1];
		    if (this->mName != nullptr) {
                strcpy(this->mName, name);
            }
		}
    }

    Student(const Student& s) = delete;
    Student& operator=(const Student& s) = delete;
 
	~Student() {
		if (mName != nullptr) {
			cout << "delete Student:" << mName << endl;
			delete[] mName;
			mName = nullptr;
		}
	}

	const char* getName() {
        return mName;
    }

    void setName(const char* name) {
        if (name != nullptr) {
            if (this->mName != nullptr) { // 如果不检查并释放已有内存,就会出现内存泄漏
                delete[] this->mName;
            }
			int len = strlen(name);
		    this->mName = new char[len + 1];
		    if (this->mName != nullptr) {
                strcpy(this->mName, name);
            }
		}
    }
};

6. 无法删除引用指向的内存地址

当函数返回动态分配的内存时,返回类型是引用,无法删除引用指向的内存地址,造成内存泄漏。

int& allocateInteger() {
    int* p = new int(42);
    return *p;
}

int main() {
    int ref = allocateInteger();
    delete &ref; // 无法释放内存
    return 0;
}

//==================================
// 正确做法:
int* allocateInteger() {
    int* p = new int(42);
    return *p;
}

int main() {
    int ref = allocateInteger();
    delete ref;
    return 0;
}

7. 循环引用

对象之间形成循环引用,导致它们无法被垃圾收集器回收。C++标准库没有垃圾收集器,但使用智能指针shar_ptr时可能遇到类似问题。这种情况可以引入weak_ptr来解决,在声明中用weak_ptr代替shared_ptr后,不会产生引用计数,因此不会产生循环引用的问题。

class ClassA {
public:
  ...
    void setInnerPtr(shared_ptr<ClassB> pB) {
        p = pB;
    }
private:
    // shared_ptr<ClassB> p;
    weak_ptr<ClassB> p;
};

class ClassB {
public:
  ...
    void setInnerPtr(shared_ptr<ClassA> pA) {
        p = pA;
    }

private:
    // shared_ptr<ClassA> p; // ClassA和ClassB中的智能指针shared_ptr引用计数都是1,
    // 但是两者的实例对象离开作用域时,都在等待对方释放后才能释放,便形成循环引用。
    weak_ptr<ClassA> p; // 此例中A和B有一个weak_ptr就可以打破循环引用
};

int main() {
  shared_ptr<ClassA> pA = make_shared<ClassA>();
  shared_ptr<ClassB> pB = make_shared<ClassB>();

  pA->setInnerPtr(pB);
  pB->setInnerPtr(pA);
  ...
}

8. 野指针和悬挂指针

野指针是指尚未初始化的指针,它指向的地址是未知的、不确定的、随机的。

#include<iostream>
using namespace std;
int main()
{
    int* p1;            //此时属于野指针
    int* p2 = NULL;     //非野指针
    p1 = new int(10);   //此时p1已经不是野指针了
    p2 = new int(10);
    cout << "*p1 = " << *p1 << endl;
    cout << "*p2 = " << *p2 << endl;
    delete p1;
    delete p2;
    return 0;
}

悬挂指针指向已释放的内存,但指针本身未被销毁或重新赋值,导致无法跟踪和释放内存。

#include<iostream>
using namespace std;

int* Test()
{
    int temp = 100;
    return &temp;
}

int main()
{
    // 情况一:指针释放资源后,在还没有再次赋值之前
    int* p1 = new int(10);
    cout << "*p1 = " << *p1 << endl;
    delete p1;   // p1在释放之后,此时成为了悬挂指针
    p1 = nullptr;// 此时p1又重新赋值,不再是悬挂指针

    // 情况二:超出了变量的作用范围
    int* p2;
    {
        int temp = 10;
        p2 = &temp;
    }
    //p2在此处是悬挂指针

    // 情况三:指向了函数的局部变量,但该对象的生命周期已经结束,内存可能已经被回收或覆盖
    int* p3 = Test(); //悬挂指针
    cout << "*p3 = " << endl;

    return 0;
}

9. 浅拷贝产生悬挂指针

当一个类中有指针成员,但没有编写拷贝构造函数时,系统将调用采用值传递方式的默认拷贝构造函数。这种隐式传递的方式容易造成两个对象同时具有指向同一个地址的指针成员。因此在释放对象时,第一个对象能正常释放,而第二个对象的释放将会释放相同的内存,可能会导致堆的崩溃。因此当类中含有指针成员时,应当显式地重写拷贝构造函数和重载运算符,以保证深拷贝的发生。

class Student {
private:
	char* mName;
 
public:
	Student() : name(nullptr) {}

    Student(const char* name) {
        if (name != nullptr) {
			int len = strlen(name);
		    this->mName = new char[len + 1];
		    if (this->mName != nullptr) {
                strcpy(this->mName, name);
            }
		}
    }

    Student(const Student& s) {
        if (this != &s && s->mName != nullptr) {
            int len = strlen(s->mName);
		    this->mName = new char[len + 1];
		    if (this->mName != nullptr) {
                strcpy(this->mName, s->mName);
            }
        }
    }

    Student& operator=(const Student& s) {
        if (this != &s) {
            Student stTemp(s);
            char* pTemp = stTemp.mName;
            stTemp.mName = mName;
            mName = pTemp;
        }
        return *this;
    }
 
	~Student() {
		if (mName != nullptr) {
			cout << "delete Student:" << mName << endl;
			delete[] mName;
			mName = nullptr;
		}
	}

	const char* getName() {
        return mName;
    }

    void setName(const char* name) {
        if (name != nullptr) {
            if (this->mName != nullptr) {
                delete[] this->mName;
            }
			int len = strlen(name);
		    this->mName = new char[len + 1];
		    if (this->mName != nullptr) {
                strcpy(this->mName, name);
            }
		}
    }
};

10. 异常安全问题

在抛出异常的情况下,如果资源没有在所有可能的退出路径上正确释放,可能导致内存泄漏。

try {
    int* myData = new int[100];
    // 可能会发生异常
    // ...
    delete[] myData;  // 在发生异常时可能不会执行到这里
} catch (...) {
    // 处理异常
}

11. 隐式内存泄漏

比较常见的隐式内存泄漏有以下几种:

  • 大量的内存碎片导致剩下的内存不能被重新分配,进程会因为内存耗尽(Out of Memory, OOM)而退出。

  • 即使我们调用了free/delete,运行时库不一定会将内存归还OS,只是被glibc的内存管理块标记为可用。

  • STL内部有一个自己的allocator,在释放对象时,内存并不会归还OS,而是放回allocator,其内部根据策略在特定时候将内存归还OS。

三、解决内存泄漏的方法

1. 依靠程序员的谨慎

在编写代码的时候,对动态内存保持警惕,保证每一块儿申请的内存都要得到释放。

  • 每个 return 之前,要想一想是否还有内存没有被释放。
  • 确保异常安全:编写异常安全的代码,确保在抛出异常时资源能被正确释放。C++异常处理机制-优快云博客
  • 避免野指针和悬挂指针:确保指针在使用前已经初始化,避免使用未初始化或已释放的内存。释放内存后将原指针置为NULL/nullptr是一个好习惯。
  • 尽可能显式释放内存:在C/C++中,有许多函数可以分配内存空间。例如,strdup()函数可以从已有的字符串中分配空间,但是如果忘记释放该空间,就会导致内存泄漏。
char *p = strdup("hello world");
// 使用p
free(p);
  • 使用析构函数:确保类有适当的析构函数来释放分配的资源。
  • 内存分配匹配:确保每次内存分配都有一个对应的内存释放。
  • 为类成员变量动态分配内存时,检查是否已有内存并释放旧内存.
  • 当类中含指针成员时,显式地重写构造函数和重载运算符,保证深拷贝。
  • ......

2. 使用智能指针

C++11引入智能指针来自动管理内存,当智能指针超出作用域时,会自动释放其所管理的内存。以下3种智能指针定义在memory头文件中。

  • std::shared_ptr:允许多个指针指向同一个对象,内部通过引用计数知道当前对象被几个指针引用,引用计数为0时该对象就会被释放。

1)不要用一个原始指针初始化多个shared_ptr;

2)不要再函数实参中创建shared_ptr,在调用函数之前先定义以及初始化它;

3)不要将this指针作为shared_ptr返回出来;

4)要避免循环引用。

  • std::unique_ptr:“独占”所指向的对象,它不能被赋值,只能通过 std::move() 将引用转移到另一个 unique_ptr。

1)unique_ptr不允许其他的智能指针共享其内部的指针,不允许通过赋值将一个unique_ptr赋值给另外一个 unique_ptr;

2)unique_ptr不允许复制,但可以通过函数返回给其他的unique_ptr,还可以通过std::move来转移到其他的unique_ptr,它本身就不再拥有原来指针的所有权;

3)如果希望只有一个智能指针管理资源或管理数组就用unique_ptr,如果希望多个智能指针管理同一个资源就用shared_ptr。

  • std::weak_ptr:它是一种弱引用,作为观察者指向 shared_ptr 所管理的对象,不会改变对象的引用计数。它通过lock方法来获取所监视的shared_ptr。

1)weak_ptr没有重载运算符*和->,因为它不共享指针,不能操作资源,主要是为了通过shared_ptr获得资源的监测权。

2) weak_ptr构造不会增加引用计数,其析构也不会减少引用计数,它只是作为一个旁观者来监视shared_ptr中关连的资源是否存在。

3)当使用weak_ptr的lock方法时,如果所监视的对象未被释放,lock会返回一个指向该对象的shared_ptr,此时返回的shared_ptr会增加对象的引用计数;如果所监视的对象已被释放,lock会返回一个空的shared_ptr,此时不会增加任何引用计数。

4)可以使用std::weak_ptr来解决std::shared_ptr的循环引用和返回this指针的问题。

class Node {
public:
    std::shared_ptr<Node> next; // 改为weak_ptr就可以打破循环引用
};
std::shared_ptr<Node> node1 = std::make_shared<Node>();
std::shared_ptr<Node> node2 = std::make_shared<Node>();
node1->next = node2;
node2->next = node1;  // 循环引用
#include <iostream>
#include <memory>

class MyClass : public std::enable_shared_from_this<MyClass> {
public:
    std::weak_ptr<MyClass> getWeakPtr() {
        return shared_from_this(); // 解决返回this指针的问题
    }

    void doSomething() {
        std::cout << "Doing something..." << std::endl;
    }
};

int main() {
    auto sharedPtr = std::make_shared<MyClass>();
    auto weakPtr = sharedPtr->getWeakPtr();
    if (auto lockedPtr = weakPtr.lock()) {
        lockedPtr->doSomething();
    } else {
        std::cout << "Failed to lock the weak_ptr." << std::endl;
    }

    return 0;
}
// MyClass 继承自 std::enable_shared_from_this<MyClass>,
// 这是一个模板基类,它允许对象安全地生成指向自身的 shared_ptr。
// getWeakPtr 方法返回一个 weak_ptr,它是通过调用 shared_from_this() 获得的,
// 该方法返回一个指向调用它的对象的 shared_ptr。
// 这样就可以避免在对象内部直接持有指向自身的 shared_ptr,从而避免循环引用和内存泄漏的问题。

3. 使用RAII

资源获取即初始化(Resource Acquisition Is Initialization,RAII),通过对象初始化实现资源(动态内存)获取,通过对象销毁实现资源(动态内存)的释放。资源的有效期与持有资源的对象的生命周期严格绑定,通过构造函数获取资源,通过析构函数释放资源,从而有效地避免资源泄漏。

class MemBlock {
public:
	MemBlock(size_t size) {
		_ptr = malloc(size);
	}
	~MemBlock() {
		free(_ptr);
	}
public:
	void* ptr() {
		return _ptr;
	}
protected:
	void* _ptr;
};
 
void main() {
	MemBlock buff(1024);
	memset(buff.ptr(), 0x00, 1024);
	// 使用内存后无需主动释放
}

void test() { // 使用智能指针结合RAII管理动态内存
    auto mem = make_shared<MemBlock>(1024);
    memset(mem->ptr(), 0x00, 1024);
    // 使用内存后无需主动释放
}

4. 使用容器和对象管理内存

C/C++中的容器和对象可以帮助程序员更轻松地管理内存。使用STL中的容器,如vector、map等,就能够自动管理内存。使用C++中的对象也可以使用析构函数自动释放分配的内存。

#include <iostream>
#include <vector>
 
using namespace std;
 
class TestClass {
public:
    TestClass() {
        cout << "TestClass constructor called!" << endl;
    }
    ~TestClass() {
        cout << "TestClass destructor called!" << endl;
    }
};
 
int main() {
    vector<TestClass> v;
    v.push_back(TestClass());
    return 0;
}

5. 内存管理工具

  • 静态分析工具cppcheck可以在编译阶段检测到一些潜在的内存泄漏问题,通过分析源代码来查找可能导致内存泄漏的模式。cppcheck还可搭配jenkins使用,实现自动编译分析并进行图形化显示。在Jenkins中已经有cppcheck插件,Jenkins可以对cppcheck检测后的结果进行处理,并且可以将结果图形化的显示。
cppcheck --enable=all --inconclusive --std=posix source.cpp
  • 动态分析工具Valgrind:在Linux上,通过该工具来运行程序,检查其内存使用情况。Valgrind 特别适用于检测未释放的内存。
valgrind --leak-check=full ./your_program
  • 内存检测工具ASan(AddressSanitizer):ASan是由Google开发的,广泛用于C、C++等语言的代码中。目的是帮助开发者检测和调试内存相关的问题,如使用未分配的内存、使用已释放的内存、堆内存溢出等。要使用ASan,你需要使用支持ASan的编译器,如Clang或GCC,并开启ASan相关的编译选项。
gcc -fsanitize=address -g your_program.c -o your_program
  •  VLD(Visual LeakDetector):在vc++中可以使用 VLD进行检测,它 是一个免费开源的,只需要包含头文件即可,并且可以获取到内存泄漏的代码文件行号。
  • Tencent tMem Monitor:腾讯推出的一款运行时C/C++内存泄漏检测工具。TMM认为在进程退出时,堆内存中没有被释放且没有指针指向的无主内存块即为内存泄漏,并进而引入垃圾回收(GC, Garbage Collection)机制,在进程退出时检测出堆内存中所有没有被引用的内存单元,因而内存泄露检测准确率为100%。
  • gperftools: google 开源的一组套件,提供了高性能的、支持多线程的 malloc 实现,以及一组优秀的性能分析工具。gperftools 的 heap chacker 组件可以用于检测 C++ 程序中的内存泄露问题,它可以在不重启程序的情况下,进行内存泄露检查。

6. 代码审查

定期进行代码审查,以识别可能导致内存泄漏的代码模式。

参考文献

[1] C++ 内存泄漏-原因、避免、定位_c++内存泄漏-优快云博客

[2] 【C/C++】常见问题之内存泄露_c++内存泄漏-优快云博客

[3] 【C++】C++内存泄漏介绍及解决方案-优快云博客

[4] C++内存泄露_c++内存泄漏-优快云博客

[5] C/C++ 静态代码检查工具cppCheck_cppcheck下载-优快云博客 

[6] 内存检查工具valgrind介绍、安装与使用-优快云博客

[7] 内存检测工具,ASan(AddressSanitizer)的介绍和使用_address sanitizer-优快云博客

[8] Visual Leak Detector 2.5.1 (VLD)下载、安装与使用_visual leak detector下载-优快云博客

[9] 内存泄漏分析工具tMemoryMonitor_微信小程序内存检查工具-优快云博客 

[10] C++ 性能分析的实战指南(gperftools工具)[建议收藏]-优快云博客 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

贫道绝缘子

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值