目录
1.未初始化指针 (Uninitialized Pointer)
7.错误的数组删除方式 (Mismatched Delete)
[10.Virtual destructor](#10.Virtual destructor)
[11. 对象循环引用](#11. 对象循环引用)
1.未初始化指针 (Uninitialized Pointer)
- 错误描述:内存分配成功后,未进行初始化就引用,可能导致错误的数据引用。
- 解决策略:无论用何种方式创建数组或动态内存,都应为其赋初值,防止将未被初始化的内存作为右值使用。
常见的示例代码如下:
cpp
int* p;
*p = 10; //可能会导致内存访问错误
解决方案:在使用指针前,进行初始化。
cpp
int* p = nullptr;
p = new int;
*p = 10;
2.内存分配未成功却使用了它
- 错误描述:在内存分配失败的情况下,程序仍然尝试使用未成功分配的内存。
- 解决策略 :在使用内存之前,应检查指针是否为NULL。如果指针是函数的参数,那么在函数的入口处用
assert(p!=NULL)
(头文件是assert.h
)进行检查。如果是用malloc
或new
来申请内存,应该用if(p==NULL)
进行防错处理。
3.野指针 (Dangling Pointer)
-
错误描述:内存释放后,程序仍然尝试使用已释放的内存,可能导致程序崩溃或不稳定。
-
解决策略:
- 重新设计数据结构,从根本上解决对象管理的混乱局面。
- 注意函数的
return
语句,不要返回指向"栈内存"的"指针"或者"引用",因为该内存在函数体结束时被自动销毁。 - 使用
free
或delete
释放了内存后,将指针设置为NULL
,防止产生"野指针"。 - 使用智能指针可以避免野指针问题,因为它们会在对象超出作用域时自动释放资源。
4.内存泄漏 (Memory Leak)
问题: 动态分配的内存在不需要时没有被正确释放,导致可用内存越来越少。
解决策略:
-
智能指针 : 使用 C++ 的智能指针(如
std::unique_ptr
和std::shared_ptr
)来管理动态内存。它们可以在超出作用域时自动释放内存。 -
确保
delete
匹配new
: 每个通过new
分配的内存,都要在适当的时机调用delete
释放。 -
工具检查: 使用内存检测工具如 Valgrind 或 AddressSanitizer 来检测内存泄漏。
下面看个例子:
cpp
void MemoryLeakFunction()
{
XXX_Class * pObj = new XXX_Class();
pObj->DoSomething();
return;
}
下面这个场景,就是析构函数中并没有释放成员所指向的内存。这个我们就要注意了,一般当你构建一个类的时候,写析构函数一定要切记释放类成员关联的资源。
cpp
class MemoryLeakClass
{
public:
MemoryLeakClass()
{
m_pObj = new XXX_ResourceClass;
}
void DoSomething()
{
m_pObj->DoSomething();
}
~MemoryLeakClass()
{
;
}
private:
XXX_ResourceClass* m_pObj;
};
在boost
或者C++ 11
后,通过智能指针去进行包裹这个原始指针,这是一种RAII
的思想, 在out of scope
的时候,释放自己所包裹的原始指针指向的资源。将上述例子用unique_ptr
改写一下。
cpp
void MemoryLeakFunction()
{
std::unique_ptr<XXX_Class> pObj = make_unique<XXX_Class>();
pObj->DoSomething();
return;
}
5.重复释放内存 (Double Free)
问题: 动态分配的内存被释放多次,这可能会导致程序崩溃或未定义行为。
解决策略:
-
将指针设置为 nullptr : 在释放指针之后,将其设置为
nullptr
,以避免重复释放。 -
检查指针状态 : 释放内存前,检查指针是否为
nullptr
。
cpp
int* p = new int(10);
delete p;
p = nullptr; // 避免重复释放
6.内存越界访问 (Buffer Overflow)
问题: 动态分配的内存被超范围使用,可能导致数据损坏、程序崩溃等问题。
解决策略:
-
谨慎使用指针和数组: 确保访问的内存区域是合法的。
-
边界检查: 在处理动态数组时,始终执行边界检查。
-
使用标准库容器 : 使用如
std::vector
、std::string
等标准库容器,它们会自动管理边界检查。
7.错误的数组删除方式 (Mismatched Delete)
问题 : 使用new[]
分配的数组使用delete
而不是delete[]
释放,或反之。
解决策略:
-
确保匹配使用
new
/delete
和new[]
/delete[]
:数组需要使用delete[]
来释放。 -
使用 C++ 的智能指针(如
std::unique_ptr
和std::shared_ptr
)来管理动态数组内存 -
手动编写获取内存的类,自动释放内存,如:
8.栈内存溢出 (Stack Overflow)
问题: 过度使用栈内存,通常是由于递归调用过深或栈上分配的大型对象。
解决策略:
-
减少递归深度: 优化递归算法,避免过深的递归调用。
-
将大对象放在堆上 : 如果对象太大,使用
new
将其分配到堆上而非栈上。
9.delete (void*)
因为C++
的灵活性,有时候会将一个对象指针转换为void *
,隐藏其类型。这种情况SDK比较常用,实际上返回的并不是SDK用的实际类型,而是一个没有类型的地址,当然有时候我们会为其亲切的取一个名字,比如叫做XXX_HANDLE
。
那么继续用上述为例MemoryLeakClass
, SDK假设提供了下面三个接口:
InitObj
创建一个对象,并且返回一个PROGRAMER_HANDLE
(即void *
),对应用程序屏蔽其实际类型DoSomething
提供了一个功能去做一些事情,输入的参数,即为通过InitObj
申请的对象- 应用程序使用完毕后,一般需要释放SDK申请的对象,提供了
FreeObj
cpp
typedef void * PROGRAMER_HANDLE;
PROGRAMER_HANDLE InitObj()
{
MemoryLeakClass* pObj = new MemoryLeakClass();
return (PROGRAMER_HANDLE)pObj;
}
void DoSomething(PROGRAMER_HANDLE pHandle)
{
((MemoryLeakClass*)pHandle)->DoSomething();
}
void FreeObj(void *pObj)
{
delete pObj;
}
看到这里,也许有读者已经发现问题所在了。上述代码在调用FreeObj
的时候,delete
看到的是一个void *
, 只会释放对象所占用的内存,但是并不会调用对象的析构函数,那么对象内部的m_pStr
所指向的内存并没有被释放,从而会导致内存泄露。修改也是自然比较简单的:
cpp
void FreeObj(void *pObj)
{
delete ((MemoryLeakClass*)pObj);
}
那么一般来说,最好由相对资深的程序员去进行SDK的开发,无论从设计和实现上面,都尽量避免了各种让人泪流满满的坑。
10.Virtual destructor
现在大家来看看这个很容易犯错的场景, 一个很常用的多态场景。那么在调用delete pObj;
会出现内存泄露吗?
cpp
class Father
{
public:
virtual void DoSomething()
{
std::cout << "Father DoSomething()" << std::endl;
}
};
class Child : public Father
{
public:
Child()
{
std::cout << "Child()" << std::endl;
m_pStr = new char[100];
}
~Child()
{
std::cout << "~Child()" << std::endl;
delete[] m_pStr;
}
void DoSomething()
{
std::cout << "Child DoSomething()" << std::endl;
}
protected:
char* m_pStr;
};
void MemoryLeakVirualDestructor()
{
Father * pObj = new Child;
pObj->DoSomething();
delete pObj;
}
会的,因为Father
没有设置Virtual 析构函数
,那么在调用delete pObj;
的时候会直接调用Father
的析构函数,而不会调用Child
的析构函数,这就导致了Child
中的m_pStr
所指向的内存,并没有被释放,从而导致了内存泄露。
并不是绝对,当有这种使用场景的时候,最好是设置基类的析构函数为虚析构函数。修改如下:
cpp
class Father
{
public:
virtual void DoSomething()
{
std::cout << "Father DoSomething()" << std::endl;
}
virtual ~Father() { ; }
};
class Child : public Father
{
public:
Child()
{
std::cout << "Child()" << std::endl;
m_pStr = new char[100];
}
virtual ~Child()
{
std::cout << "~Child()" << std::endl;
delete[] m_pStr;
}
void DoSomething()
{
std::cout << "Child DoSomething()" << std::endl;
}
protected:
char* m_pStr;
};
11. 对象循环引用
看下面例子,既然为了防止内存泄露,于是使用了智能指针shared_ptr
;并且这个例子就是创建了一个双向链表,为了简单演示,只有两个节点作为演示,创建了链表后,对链表进行遍历。
那么这个例子会导致内存泄露吗?
cpp
struct Node
{
Node(int iVal)
{
m_iVal = iVal;
}
~Node()
{
std::cout << "~Node(): " << "Node Value: " << m_iVal << std::endl;
}
void PrintNode()
{
std::cout << "Node Value: " << m_iVal << std::endl;
}
std::shared_ptr<Node> m_pPreNode;
std::shared_ptr<Node> m_pNextNode;
int m_iVal;
};
void MemoryLeakLoopReference()
{
std::shared_ptr<Node> pFirstNode = std::make_shared<Node>(100);
std::shared_ptr<Node> pSecondNode = std::make_shared<Node>(200);
pFirstNode->m_pNextNode = pSecondNode;
pSecondNode->m_pPreNode = pFirstNode;
//Iterate nodes
auto pNode = pFirstNode;
while (pNode)
{
pNode->PrintNode();
pNode = pNode->m_pNextNode;
}
}
先来看看下图,是链表创建完成后的示意图。有点晕乎了,怎么一个双向链表画的这么复杂,黄色背景的均为智能指针或者智能指针的组成部分。其实根据双向链表的简单性和下图的复杂性,可以想到,智能指针的引入虽然提高了安全性,但是损失的是性能。所以往往安全性和性能是需要互相权衡的。 我们继续往下看,哪里内存泄露了呢?
如果函数退出,那么m_pFirstNode
和m_pNextNode
作为栈上局部变量,智能指针本身调用自己的析构函数,给引用的对象引用计数减去1(shared_ptr
本质采用引用计数,当引用计数为0的时候,才会删除对象)。此时如下图所示,可以看到智能指针的引用计数仍然为1, 这也就导致了这两个节点的实际内存,并没有被释放掉, 从而导致内存泄露。
你可以在函数返回前手动调用pFirstNode->m_pNextNode.reset();
强制让引用计数减去1, 打破这个循环引用。
还是之前那句话,如果通过手动去控制难免会出现遗漏的情况, C++提供了weak_ptr
。
cpp
struct Node
{
Node(int iVal)
{
m_iVal = iVal;
}
~Node()
{
std::cout << "~Node(): " << "Node Value: " << m_iVal << std::endl;
}
void PrintNode()
{
std::cout << "Node Value: " << m_iVal << std::endl;
}
std::shared_ptr<Node> m_pPreNode;
std::weak_ptr<Node> m_pNextNode;
int m_iVal;
};
void MemoryLeakLoopRefference()
{
std::shared_ptr<Node> pFirstNode = std::make_shared<Node>(100);
std::shared_ptr<Node> pSecondNode = std::make_shared<Node>(200);
pFirstNode->m_pNextNode = pSecondNode;
pSecondNode->m_pPreNode = pFirstNode;
//Iterate nodes
auto pNode = pFirstNode;
while (pNode)
{
pNode->PrintNode();
pNode = pNode->m_pNextNode.lock();
}
}
看看使用了weak_ptr
之后的链表结构如下图所示,weak_ptr
只是对管理的对象做了一个弱引用,其并不会实际支配对象的释放与否,对象在引用计数
为0的时候就进行了释放,而无需关心weak_ptr
的weak计数
。注意shared_ptr
本身也会对weak计数
加1.
那么在函数退出后,当pSecondNode
调用析构函数的时候,对象的引用计数减一,引用计数
为0,释放第二个Node,在释放第二个Node的过程中又调用了m_pPreNode
的析构函数,第一个Node对象的引用计数减1,再加上pFirstNode
析构函数对第一个Node对象的引用计数也减去1,那么第一个Node对象的引用计数
也为0,第一个Node对象也进行了释放。
如果将上述代码改为双向循环链表,去除那个循环遍历Node的代码,那么最后Node的内存会被释放吗?这个问题留给读者。
推荐阅读