有哪些一般提示可以确保我不会在 C++ 程序中泄漏内存?如何确定谁应该释放动态分配的内存?
29 回答
我完全赞同有关 RAII 和智能指针的所有建议,但我还想添加一个稍微高级一点的提示:最容易管理的内存是您从未分配过的内存。与 C# 和 Java 等几乎所有内容都是引用的语言不同,在 C++ 中,您应该尽可能将对象放在堆栈上。正如我看到几个人(包括 Stroustrup 博士)指出的那样,垃圾收集在 C++ 中从未流行过的主要原因是,编写良好的 C++ 首先不会产生太多垃圾。
不要写
Object* x = new Object;
甚至
shared_ptr<Object> x(new Object);
当你可以写
Object x;
使用RAII
- 忘记垃圾收集(改用 RAII)。请注意,即使是垃圾收集器也可能泄漏(如果您忘记在 Java/C# 中将某些引用“设为空”),并且垃圾收集器不会帮助您处理资源(如果您有一个对象获得了一个句柄一个文件,如果您不在 Java 中手动执行或使用 C# 中的“dispose”模式,当对象超出范围时,该文件将不会自动释放)。
- 忘记“每个函数一个返回”规则。这是避免泄漏的一个很好的 C 建议,但它在 C++ 中已经过时,因为它使用了异常(改用 RAII)。
- 虽然“三明治模式”是一个很好的 C 建议,但它在 C++ 中已经过时, 因为它使用了异常(改用 RAII)。
这篇文章似乎是重复的,但在 C++ 中,要知道的最基本的模式是RAII。
学习使用智能指针,无论是来自 boost、TR1 还是低级(但通常足够高效)的 auto_ptr(但你必须知道它的局限性)。
RAII 是 C++ 中异常安全和资源处理的基础,没有其他模式(三明治等)可以同时提供(而且大多数情况下,它不会提供)。
请参阅下面的 RAII 和非 RAII 代码的比较:
void doSandwich()
{
T * p = new T() ;
// do something with p
delete p ; // leak if the p processing throws or return
}
void doRAIIDynamic()
{
std::auto_ptr<T> p(new T()) ; // you can use other smart pointers, too
// do something with p
// WON'T EVER LEAK, even in case of exceptions, returns, breaks, etc.
}
void doRAIIStatic()
{
T p ;
// do something with p
// WON'T EVER LEAK, even in case of exceptions, returns, breaks, etc.
}
关于RAII
总结一下(在Ogre Psalm33的评论之后),RAII 依赖于三个概念:
- 一旦构造了对象,它就可以工作了!在构造函数中获取资源。
- 对象破坏就够了!在析构函数中释放资源。
- 都是关于范围的!作用域对象(参见上面的 doRAIIStatic 示例)将在其声明时构造,并在执行退出作用域的那一刻被销毁,无论退出方式如何(返回、中断、异常等)。
这意味着在正确的 C++ 代码中,大多数对象不会用 构造new
,而是在堆栈上声明。而对于那些使用 构造的new
,所有都将以某种方式限定范围(例如附加到智能指针)。
作为开发人员,这确实非常强大,因为您不需要关心手动资源处理(就像在 C 中所做的那样,或者对于 Java 中的某些对象,在这种情况下大量使用try
/ finally
)......
编辑 (2012-02-12)
“作用域对象......将被破坏......无论出口如何”这并不完全正确。有办法欺骗RAII。任何类型的 terminate() 都会绕过清理。exit(EXIT_SUCCESS) 在这方面是矛盾的。
——威廉泰尔
wilhelmtell对此非常正确:有一些特殊的方法可以欺骗 RAII,所有这些都会导致进程突然停止。
这些是特殊的方式,因为 C++ 代码没有乱扔终止、退出等,或者在有异常的情况下,我们确实希望一个未处理的异常使进程崩溃并且核心转储其内存映像,而不是在清理之后。
但我们仍然必须了解这些情况,因为虽然它们很少发生,但它们仍然可能发生。
(谁调用terminate
或exit
使用随意的 C++ 代码?...我记得在玩GLUT时必须处理这个问题:这个库非常面向 C,甚至积极设计它以使 C++ 开发人员遇到困难,比如不关心关于堆栈分配的数据,或者有关于永远不会从主循环返回的“有趣”决定......我不会对此发表评论)。
您需要查看智能指针,例如boost 的智能指针。
代替
int main()
{
Object* obj = new Object();
//...
delete obj;
}
boost::shared_ptr 将在引用计数为零时自动删除:
int main()
{
boost::shared_ptr<Object> obj(new Object());
//...
// destructor destroys when reference count is zero
}
请注意我的最后一条注释,“当引用计数为零时,这是最酷的部分。因此,如果您的对象有多个用户,则不必跟踪该对象是否仍在使用中。一旦没有人引用您的共享指针,它会被销毁。
然而,这不是灵丹妙药。尽管您可以访问基指针,但您不希望将其传递给第三方 API,除非您对它的工作有信心。很多时候,您在创建范围完成后将内容“发布”到其他线程以完成工作。这在 Win32 中的 PostThreadMessage 中很常见:
void foo()
{
boost::shared_ptr<Object> obj(new Object());
// Simplified here
PostThreadMessage(...., (LPARAM)ob.get());
// Destructor destroys! pointer sent to PostThreadMessage is invalid! Zohnoes!
}
与往常一样,将您的思维上限与任何工具一起使用...
阅读RAII并确保您理解它。
呸,你们这些年幼的孩子和你们新奇的垃圾收集器……
关于“所有权”的非常严格的规则 - 哪些对象或软件的一部分有权删除该对象。清晰的注释和明智的变量名称,以使指针“拥有”或“只看,不要碰”变得明显。为了帮助决定谁拥有什么,请在每个子例程或方法中尽可能遵循“三明治”模式。
create a thing
use that thing
destroy that thing
有时需要在不同的地方进行创建和销毁;我认为很难避免这种情况。
在任何需要复杂数据结构的程序中,我都会使用“所有者”指针创建一个包含其他对象的严格清晰的对象树。此树对应用程序域概念的基本层次结构进行建模。示例 3D 场景拥有对象、灯光、纹理。在程序退出时渲染结束时,有一个明确的方法可以销毁所有内容。
每当一个实体需要访问另一个实体、扫描数组或其他任何东西时,都会根据需要定义许多其他指针;这些是“只是看”。对于 3D 场景示例 - 对象使用纹理但不拥有;其他对象可能使用相同的纹理。对象的销毁不会调用任何纹理的销毁。
是的,这很耗时,但这就是我所做的。我很少有内存泄漏或其他问题。但后来我在有限的高性能科学、数据采集和图形软件领域工作。我不经常处理银行和电子商务、事件驱动的 GUI 或高度网络化的异步混乱等交易。也许新奇的方法在那里有优势!
大多数内存泄漏是由于不清楚对象所有权和生命周期造成的。
首先要做的是尽可能在堆栈上分配。这处理了您需要为某种目的分配单个对象的大多数情况。
如果您确实需要“新建”一个对象,那么大多数情况下,它在其剩余生命周期内都会有一个明显的所有者。对于这种情况,我倾向于使用一组集合模板,这些模板旨在通过指针“拥有”存储在其中的对象。它们是使用 STL 矢量和地图容器实现的,但有一些区别:
- 无法复制或分配这些集合。(一旦它们包含对象。)
- 指向对象的指针被插入其中。
- 当集合被删除时,首先对集合中的所有对象调用析构函数。(我有另一个版本,它断言是否已破坏且不为空。)
- 由于它们存储指针,因此您还可以将继承的对象存储在这些容器中。
我对 STL 的看法是它如此专注于 Value 对象,而在大多数应用程序中,对象是唯一的实体,没有在这些容器中使用所需的有意义的复制语义。
好问题!
如果您使用 c++ 并且正在开发实时 CPU 和内存应用程序(如游戏),则需要编写自己的内存管理器。
我认为你可以做的更好的是合并不同作者的一些有趣的作品,我可以给你一些提示:
固定大小分配器被广泛讨论,在网络中无处不在
小对象分配是由 Alexandrescu 于 2001 年在他的完美著作《现代 c++ 设计》中介绍的
在 Game Programming Gem 7 (2008) 中一篇由 Dimitar Lazarov 撰写的名为“高性能堆分配器”的惊人文章中可以找到一个巨大的进步(已分发源代码)
可以在本文中找到大量资源
不要自己开始编写一个noob无用的分配器......首先记录你自己。
一种在 C++ 中流行的内存管理技术是RAII。基本上,您使用构造函数/析构函数来处理资源分配。当然,由于异常安全性,C++ 中还有一些其他令人讨厌的细节,但基本思想非常简单。
这个问题通常归结为所有权之一。我强烈推荐阅读 Scott Meyers 的 Effective C++ 系列和 Andrei Alexandrescu 的 Modern C++ Design。
已经有很多关于如何不泄漏的信息,但是如果您需要一个工具来帮助您跟踪泄漏,请查看:
- VS下的BoundsChecker
- FluidStudio 的 MMGR C/C++ 库 http://www.paulnettle.com/pub/FluidStudios/MemoryManagers/Fluid_Studios_Memory_Manager.zip(它覆盖分配方法并创建分配、泄漏等报告)
随处可见的用户智能指针!整个类的内存泄漏就会消失。
在您的项目中共享和了解内存所有权规则。使用 COM 规则可实现最佳一致性([in] 参数归调用者所有,被调用者必须复制;[out] 参数归调用者所有,如果保留引用,则被调用者必须复制;等等)
valgrind也是在运行时检查程序内存泄漏的好工具。
它适用于大多数版本的 Linux(包括 Android)和 Darwin。
如果你曾经为你的程序编写单元测试,你应该养成在测试中系统地运行 valgrind 的习惯。它可能会在早期阶段避免许多内存泄漏。通常在简单的测试中比在完整的软件中更容易确定它们。
当然,这个建议对任何其他内存检查工具都有效。
此外,如果有标准库类(例如向量),请不要使用手动分配的内存。确保您是否违反了该规则,即您有一个虚拟析构函数。
如果您不能/不使用智能指针(尽管这应该是一个巨大的危险信号),请输入您的代码:
allocate
if allocation succeeded:
{ //scope)
deallocate()
}
这很明显,但请确保在键入范围内的任何代码之前键入它
这些错误的常见来源是当您有一个方法接受对象的引用或指针但所有权不明确时。样式和注释约定可以降低这种可能性。
让函数获得对象所有权的情况成为特例。在发生这种情况的所有情况下,请务必在头文件中的函数旁边写一个注释来表明这一点。您应该努力确保在大多数情况下分配对象的模块或类也负责释放它。
在某些情况下,使用 const 会有很大帮助。如果一个函数不会修改一个对象,并且不存储在它返回后仍然存在的对它的引用,则接受一个 const 引用。通过阅读调用者的代码,很明显您的函数没有接受对象的所有权。你可以让同一个函数接受一个非常量指针,调用者可能会也可能不会假设被调用者接受了所有权,但是使用 const 引用是毫无疑问的。
不要在参数列表中使用非常量引用。在阅读调用者代码时,很不清楚被调用者可能保留了对参数的引用。
我不同意推荐引用计数指针的评论。这通常工作得很好,但是当你有一个错误并且它不起作用时,特别是如果你的析构函数做了一些不平凡的事情,比如在多线程程序中。如果不太难的话,一定要尝试调整你的设计以不需要引用计数。
提示按重要性排序:
-Tip#1 永远记得将你的析构函数声明为“虚拟的”。
-提示#2 使用 RAII
-Tip#3 使用 boost 的智能指针
-Tip#4 不要编写自己的有缺陷的智能指针,使用 boost(在我现在正在进行的项目中,我不能使用 boost,而且我不得不调试自己的智能指针,我肯定不会使用再次使用相同的路线,但现在我无法向我们的依赖项添加提升)
-提示#5 如果它的一些随意/非性能关键(如在具有数千个对象的游戏中)的工作,请查看 Thorsten Ottosen 的 boost 指针容器
-提示#6 为您选择的平台查找泄漏检测标头,例如 Visual Leak Detection 的“vld”标头
如果可以,请使用 boost shared_ptr 和标准 C++ auto_ptr。那些传达所有权语义。
当你返回一个 auto_ptr 时,你是在告诉调用者你给了他们内存的所有权。
当你返回一个 shared_ptr 时,你是在告诉调用者你有一个对它的引用并且他们拥有部分所有权,但这不仅仅是他们的责任。
这些语义也适用于参数。如果调用者传递给你一个 auto_ptr,他们就给了你所有权。
如果要手动管理内存,有两种情况:
- 我创建了对象(可能间接地,通过调用分配新对象的函数),使用它(或者我调用的函数使用它),然后释放它。
- 有人给了我参考,所以我不应该释放它。
如果您需要违反任何这些规则,请记录下来。
这都是关于指针所有权的。
其他人首先提到了避免内存泄漏的方法(如智能指针)。但是,分析和内存分析工具通常是一旦遇到内存问题就可以追踪它们的唯一方法。
Valgrind memcheck是一款出色的免费工具。
仅对于 MSVC,将以下内容添加到每个 .cpp 文件的顶部:
#ifdef _DEBUG
#define new DEBUG_NEW
#endif
然后,在使用 VS2003 或更高版本进行调试时,您会在程序退出时被告知任何泄漏(它会跟踪新/删除)。这是基本的,但它在过去帮助了我。
valgrind(仅适用于 *nix 平台)是一个非常好的内存检查器
- 尽量避免动态分配对象。只要类具有适当的构造函数和析构函数,使用类类型的变量,而不是指向它的指针,并且避免动态分配和释放,因为编译器会为您完成。
实际上,这也是“智能指针”使用的机制,并被其他一些作者称为 RAII ;-)。 - 当您将对象传递给其他函数时,更喜欢引用参数而不是指针。这避免了一些可能的错误。
- 尽可能声明参数 const,尤其是指向对象的指针。这样就不能“意外”释放对象(除非您将 const 扔掉;-)))。
- 尽量减少程序中进行内存分配和释放的位置数量。例如。如果您确实多次分配或释放相同的类型,请为它编写一个函数(或工厂方法;-))。
如果需要,您可以通过这种方式轻松创建调试输出(分配和取消分配的地址,...)。 - 使用工厂函数从单个函数分配多个相关类的对象。
- 如果您的类有一个带有虚拟析构函数的公共基类,您可以使用相同的函数(或静态方法)释放所有这些类。
- 使用 purify 之类的工具检查您的程序(不幸的是,很多 $/€/...)。
您可以拦截内存分配函数,看看是否有一些内存区域在程序退出时未释放(尽管它并不适合所有应用程序)。
也可以在编译时通过替换操作符 new 和 delete 以及其他内存分配函数来完成。
例如检查这个站点[Debugging memory allocation in C++] 注意:删除运算符也有一个技巧,如下所示:
#define DEBUG_DELETE PrepareDelete(__LINE__,__FILE__); delete
#define delete DEBUG_DELETE
您可以在一些变量中存储文件的名称以及重载的删除运算符何时知道它是从哪个位置调用的。这样,您就可以跟踪程序中的每个 delete 和 malloc。在内存检查序列结束时,您应该能够报告哪些分配的内存块没有被“删除”,通过文件名和行号来识别它,我猜这就是你想要的。
您也可以在 Visual Studio 下尝试类似BoundsChecker的东西,它非常有趣且易于使用。
我们用一个层包裹所有分配函数,该层在前面附加一个简短的字符串,在最后附加一个标记标志。因此,例如,您将调用“myalloc(pszSomeString, iSize, iAlignment); 或 new("description", iSize) MyObject(); 它在内部为您的标题和标记分配指定的大小加上足够的空间。当然,不要忘记为非调试版本注释掉这个!这样做需要更多的内存,但好处远远超过成本。
这有三个好处 - 首先,它允许您轻松快速地跟踪哪些代码正在泄漏,通过快速搜索分配在某些“区域”但在这些区域应该释放时没有清理的代码。通过检查以确保所有哨兵完好无损,检测边界何时被覆盖也很有用。当我们试图找到那些隐藏得很好的崩溃或数组失误时,这为我们节省了很多次。第三个好处是跟踪内存的使用以查看谁是大玩家——例如,MemDump 中某些描述的整理会告诉您“声音”何时占用的空间比您预期的要多。
C++ 在设计时考虑了 RAII。我认为在 C++ 中管理内存确实没有更好的方法。但请注意不要在本地范围内分配非常大的块(如缓冲区对象)。它可能导致堆栈溢出,如果在使用该块时边界检查存在缺陷,您可以覆盖其他变量或返回地址,从而导致各种安全漏洞。
关于在不同位置分配和销毁的唯一示例之一是线程创建(您传递的参数)。但即使在这种情况下也很容易。这是创建线程的函数/方法:
struct myparams {
int x;
std::vector<double> z;
}
std::auto_ptr<myparams> param(new myparams(x, ...));
// Release the ownership in case thread creation is successfull
if (0 == pthread_create(&th, NULL, th_func, param.get()) param.release();
...
这里改为线程函数
extern "C" void* th_func(void* p) {
try {
std::auto_ptr<myparams> param((myparams*)p);
...
} catch(...) {
}
return 0;
}
很容易不是吗?如果线程创建失败,资源将被 auto_ptr 释放(删除),否则所有权将传递给线程。如果线程如此之快以至于在创建之后它会在创建之前释放资源怎么办?
param.release();
在主函数/方法中被调用?没有!因为我们将“告诉”auto_ptr 忽略释放。C++ 内存管理容易吗?干杯,
艾玛!
以与管理其他资源(句柄、文件、数据库连接、套接字......)相同的方式管理内存。GC 也不会帮助你。
任何函数都返回一个。这样你就可以在那里进行释放并且永远不会错过它。
否则很容易出错:
new a()
if (Bad()) {delete a; return;}
new b()
if (Bad()) {delete a; delete b; return;}
... // etc.