为什么使用“new”会导致内存泄漏?

137

我先学了C#,现在开始学习C++。据我了解,C++中的new操作符与C#中的不相似。

你能解释一下在这段示例代码中为什么会出现内存泄漏的原因吗?

class A { ... };
struct B { ... };

A *object1 = new A();
B object2 = *(new B());

一个几乎重复的问题:标准C++中的垃圾回收是否自动进行? - Brent Bradburn
9个回答

477

正在发生什么

当你写下 T t; 时,你正在创建一个具有自动存储期限的类型为 T 的对象。它将在作用域结束时自动清除。

当你写下 new T() 时,你正在创建一个具有动态存储期限的类型为 T 的对象。它不会自动清除。

new without cleanup

你需要传递一个指向它的指针给 delete 来清除它:

newing with delete

然而,你的第二个例子更糟糕:你正在解引用指针并复制对象。这样做会丢失使用 new 创建的对象的指针,因此即使你想要删除它,也永远无法删除!

newing with deref

你应该怎么做

你应该优先选择自动存储期限。需要一个新对象,只需编写:

A a; // a new object of type A
B b; // a new object of type B

如果您需要动态存储期,将指向分配对象的指针存储在自动存储期对象中,该对象会自动删除它。

template <typename T>
class automatic_pointer {
public:
    automatic_pointer(T* pointer) : pointer(pointer) {}

    // destructor: gets called upon cleanup
    // in this case, we want to use delete
    ~automatic_pointer() { delete pointer; }

    // emulate pointers!
    // with this we can write *p
    T& operator*() const { return *pointer; }
    // and with this we can write p->f()
    T* operator->() const { return pointer; }

private:
    T* pointer;

    // for this example, I'll just forbid copies
    // a smarter class could deal with this some other way
    automatic_pointer(automatic_pointer const&);
    automatic_pointer& operator=(automatic_pointer const&);
};

automatic_pointer<A> a(new A()); // acts like a pointer, but deletes automatically
automatic_pointer<B> b(new B()); // acts like a pointer, but deletes automatically

newing with automatic_pointer

这是一个常见的习语,被称为RAII(Resource Acquisition Is Initialization)。当您获取需要清理的资源时,将其放入自动存储期对象中,以便您无需担心清理。这适用于任何资源,无论是内存、打开的文件、网络连接还是其他任何您喜欢的东西。

automatic_pointer类已经以各种形式存在,我只是提供了一个示例。标准库中有一个非常相似的类,称为std::unique_ptr

还有一个旧的类(C++11之前)叫做auto_ptr,但它现在已被弃用,因为它具有奇怪的复制行为。

然后还有一些更智能的例子,比如std::shared_ptr,它允许多个指针指向同一个对象,并且仅在销毁最后一个指针时才清理它。


5
很高兴你又提了一个问题。正如你看到的那样,在评论中解释并不是很容易 :) - R. Martinho Fernandes
1
@R.MartinhoFernandes:非常好的回答。只有一个问题。为什么您在operator*()函数中使用了引用返回? - Destructor
1
@Destructor 晚回复 :D。返回引用可以让您修改指针所指向的内容,因此您可以像使用普通指针一样执行 *p += 2 等操作。如果不返回引用,则无法模拟普通指针的行为,而这正是本意所在。 - R. Martinho Fernandes
1
非常感谢您的建议,让我们“将分配对象的指针存储在自动存储期对象中,以便自动删除它。” 如果有一种方法可以要求程序员在编译任何C++代码之前先学习这个模式就好了! - Andy

34

逐步解释:

// creates a new object on the heap:
new B()
// dereferences the object
*(new B())
// calls the copy constructor of B on the object
B object2 = *(new B());

因此,在完成后,您将在堆上拥有一个对象,但没有指向它的指针,因此无法删除。

另一个示例:

A *object1 = new A();

只有在忘记 delete 已分配的内存时才会出现内存泄漏:

delete object1;
在C++中,有自动存储的对象,也就是在栈上创建的对象,会自动释放;还有动态存储的对象,在堆上通过new进行分配并需要使用delete进行释放。这仅仅是一个粗略的定义。
需要记住的是,每个通过new分配的对象都应该有对应的delete
编辑:
想一想,object2不一定是内存泄漏。
下面的代码只是为了说明问题,这是一个糟糕的想法,千万不要写这样的代码:
class B
{
public:
    B() {};   //default constructor
    B(const B& other) //copy constructor, this will be called
                      //on the line B object2 = *(new B())
    {
        delete &other;
    }
}

在这种情况下,由于other是通过引用传递的,因此它将是由new B()指向的确切对象。 因此,通过&other获取其地址并删除指针将释放内存。

但我无法强调这一点:不要这样做。这只是为了说明一个观点而已。


2
我也在想同样的事情:我们可以对其进行黑客攻击,以防止泄漏,但您不希望这样做。object1 也不必泄漏,因为它的构造函数可以将自己附加到某种数据结构上,在某个时候删除它。 - CashCow
2
总是很容易写出那些“虽然可以这样做,但最好不要”的答案! :-) 我知道那种感觉。 - Kos

10

给定两个“对象”:

obj a;
obj b;

它们不会占用相同的内存位置,换句话说,&a != &b

将一个变量的值赋给另一个变量不会改变它们的位置,但会改变它们的内容:

obj a;
obj b = a;
//a == b, but &a != &b

直观地说,指针“对象”工作方式相同:

obj *a;
obj *b = a;
//a == b, but &a != &b

现在,让我们看一下你的示例:

A *object1 = new A();

这里将new A()的值赋给了object1。这个值是一个指针,意味着object1 == new A(),但&object1 != &(new A())。(请注意,此示例不是有效的代码,仅用于说明)

由于指针的值被保留,我们可以释放它所指向的内存:delete object1;根据我们的规则,这等同于delete (new A());没有任何泄漏。


对于第二个示例,你正在复制所指向的对象。该值是该对象的内容,而不是实际的指针。与其他情况一样,&object2 != &*(new A())

B object2 = *(new B());
我们已经失去了指向已分配内存的指针,因此我们无法释放它。delete &object2; 看起来似乎可以工作,但是因为 &object2 != &*(new A()),它不等同于 delete (new A()),因此是无效的。

7
在C#和Java中,您使用new来创建任何类的实例,然后您不需要担心以后销毁它。
C++也有一个关键字“new”,用于创建对象,但与Java或C#不同,这不是创建对象的唯一方式。
C++有两种机制来创建对象:
- 自动 - 动态
使用自动创建,您可以在作用域环境中创建对象: - 在函数中或 - 作为类(或结构体)的成员。
在函数中,您可以按以下方式创建它:
int func()
{
   A a;
   B b( 1, 2 );
}

在一个类中,通常会这样创建它:

class A
{
  B b;
public:
  A();
};    

A::A() :
 b( 1, 2 )
{
}

在第一种情况下,当作用域块退出时,对象会自动销毁。这可以是函数或函数内的作用域块。
在后一种情况下,对象b与其所属的A实例一起被销毁。
当您需要控制对象的生命周期时,使用new进行对象分配,然后需要使用delete进行销毁。通过RAII技术,您可以通过将其放入自动对象中来在创建对象时处理对象的删除,并等待该自动对象的析构函数生效。
其中一个这样的对象是shared_ptr,它将调用“deleter”逻辑,但仅当共享该对象的所有shared_ptr实例都被销毁时才会调用。
通常,虽然您的代码可能有许多对new的调用,但应尽量减少对delete的调用,并始终确保这些调用是从析构函数或放入智能指针的“deleter”对象中调用的。
您的析构函数也永远不应抛出异常。
如果您这样做,就会有很少的内存泄漏。

4
除了 automaticdynamic,还有 static - Mooing Duck

7
B object2 = *(new B());

这一行代码是内存泄漏的根本原因。让我们来详细分析一下...

object2是一个类型为B的变量,存储在地址1(是的,我在这里选择了任意数字)。在右边,你要求一个新的B,或者说是指向类型为B的对象的指针。程序很高兴地给了你,并将你的新B分配到地址2,并在地址3中创建了一个指针。现在,访问地址2中的数据的唯一方法是通过地址3中的指针。接下来,您使用*解除引用指针,以获取指针指向的数据(地址2中的数据)。这实际上创建了该数据的副本,并将其分配给了存储在地址1中的object2。请记住,这是一个副本,不是原件。

现在,问题来了:

您实际上从未将该指针存储在可以使用它的任何位置!一旦此赋值完成,指针(用于访问地址2的地址3中的内存)就超出了作用域并且无法访问!您无法再对其调用delete,因此无法清理地址2中的内存。你所剩下的是地址1中地址2数据的副本。两个相同的东西坐落在内存中。其中一个可以访问,另一个则无法访问(因为你失去了它的路径)。这就是为什么这是内存泄漏的原因。

我建议从您的C#背景出发,多了解一下C++中指针的工作方式。它们是一个高级主题,可能需要一些时间来掌握,但它们的使用对您非常有价值。


6

如果您在某个时候没有通过将内存指针传递给 delete 操作符来释放使用 new 操作符分配的内存,那么就会创建一个内存泄漏。

在上述两种情况下:

A *object1 = new A();

在这里,你没有使用delete释放内存,所以如果你的object1指针超出了范围,你将会有一个内存泄漏,因为你丢失了该指针,所以无法使用delete操作符。
还有,在这里:
B object2 = *(new B());

您正在丢弃new B()返回的指针,因此永远无法将该指针传递给delete以释放内存。因此会出现另一个内存泄漏。


6
如果有助于理解,可以将计算机内存视为酒店,程序则是需要房间的客户。
这家酒店的运作方式是,您预订一个房间并告诉门童何时离开。
如果您的程序预订了一个房间,然后没有告诉门童就离开了,门童会认为房间仍在使用中,不会让其他人使用它。这种情况称为“房间泄漏”。
如果您的程序分配了内存但没有删除它(仅停止使用),那么计算机会认为该内存仍在使用中,不允许其他人使用它。这就是“内存泄漏”。
这并不是一个完全准确的比喻,但可能会有所帮助。

5
我很喜欢这个比喻,虽然不是完美的,但绝对是向新手解释内存泄漏的好方法! - AdamM
1
我在伦敦布隆伯格面试高级工程师时,曾向一位人力资源女士解释内存泄漏的概念。我成功通过了那次面试,因为我能够以非程序员能理解的方式解释内存泄漏(和线程问题)。 - Stefan

5

这一行代码立即泄漏:

B object2 = *(new B());

在这里,您正在堆上创建一个新的B对象,然后在堆栈上创建副本。已在堆上分配的对象不再可以访问,因此会出现泄漏。

这行代码不是立即泄漏的:

A *object1 = new A();

如果您从未删除object1,那么可能会发生泄漏。

4
请勿在解释动态/自动存储时使用堆栈。 - Pubby
2
@Pubby 为什么不使用?因为动态/自动存储总是在堆上,而不是栈上?这就是为什么不需要详细说明栈/堆的原因,我说得对吗? - user1131997
4
@user1131997 堆和栈是实现细节。了解它们很重要,但与此问题无关。 - Pubby
2
嗯,我想要一个单独的答案,即与我的相同,但用您认为最好的方式替换堆栈。我很想知道您如何更喜欢解释它。 - mattjgalloway

5
创建object2时,您正在创建使用new创建的对象的副本,但您也丢失了(从未分配的)指针(因此无法以后删除它)。为避免这种情况,您需要将object2设置为引用。

3
把引用的地址用来删除对象是极其不好的做法。应该使用智能指针。 - Tom Whittock
3
非常糟糕的做法,呃?你认为智能指针在幕后使用了什么? - Blindy
3
智能指针(至少是良好实现的)直接使用指针。 - Luchian Grigore
2
说实话,这个想法并不是很好,不是吗?其实,我甚至不确定 OP 中尝试的模式实际上会有什么用处。 - Mario

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接