C ++中的垃圾会发生什么?


52

Java有一个自动GC,它偶尔会停止世界,但会处理堆上的垃圾。现在C / C ++应用程序没有这些STW冻结,它们的内存使用也不会无限增长。如何实现这种行为?如何处理死物?


38
注意:停止世界是一些垃圾收集器的实现选择,但当然不是全部。例如,存在并发GC,它们与mutator并发运行(GC开发人员将其称为实际程序)。我相信您可以购买具有并发不间断收集器的IBM开源JVM J9的商业版本。Azul Zing具有一个“无暂停”的收集器,该收集器实际上并不会暂停,但速度非常快,因此不会出现明显的暂停(其GC暂停与操作系统线程上下文切换的顺序相同,通常不视为暂停) 。
约尔格W¯¯米塔格

14
大多数(长期运行)C ++程序我用的是有一段时间无粘结增长的内存使用情况。您是否有可能不习惯一次将程序打开几天以上?
乔纳森·

12
考虑到使用现代C ++及其结构,您也不再需要手动删除内存(除非您进行了一些特殊的优化),因为您可以通过智能指针管理动态内存。显然,这会增加C ++开发的开销,您需要多加注意,但这不是完全不同的事情,您只需要记住使用智能指针构造而不是仅调用manual即可new
安迪

9
请注意,仍然有可能以垃圾回收语言泄漏内存。我不熟悉Java,但是不幸的是,内存泄漏在.NET的托管GC世界中非常普遍。静态字段间接引用的对象不会自动收集,事件处理程序是非常常见的泄漏源,并且垃圾收集的不确定性使其无法完全消除手动释放资源的需求(导致IDisposable图案)。总而言之,正确使用的C ++内存管理模型远远优于垃圾回收。
科迪·格雷

26
What happens to garbage in C++? 通常不是将其编译为可执行文件吗?
BJ Myers

Answers:


101

程序员负责确保通过创建的对象new被删除delete。如果创建了一个对象,但在最后一个指针或对该对象的引用超出范围之前未将其销毁,则该对象会掉入裂缝并成为Memory Leak

不幸的是,对于C,C ++和其他不包含GC的语言,随着时间的流逝,它们只会堆积起来。它可能导致应用程序或系统内存不足,并且无法分配新的内存块。此时,用户必须求助于结束应用程序,以便操作系统可以回收使用的内存。

就减轻此问题而言,有几件事使程序员的生活变得更加轻松。这些主要受到范围性质的支持。

int main()
{
    int* variableThatIsAPointer = new int;
    int variableInt = 0;

    delete variableThatIsAPointer;
}

在这里,我们创建了两个变量。它们存在于大括号定义的“ 块范围”中{}。当执行移出该范围时,这些对象将被自动删除。在这种情况下,variableThatIsAPointer顾名思义,它是指向内存中对象的指针。当它超出范围时,指针将被删除,但指向的对象将保留。在这里,我们delete将此对象移出范围以确保没有内存泄漏。但是,我们也可以将此指针传递到其他地方,并希望以后再删除它。

范围的这种性质扩展到了类:

class Foo
{
public:
    int bar; // Will be deleted when Foo is deleted
    int* otherBar; // Still need to call delete
}

在此,同样的原则适用。我们不必担心bar何时Foo删除。但是,对于otherBar,仅删除指针。如果otherBar是指向它所指向的任何对象的唯一有效指针,则可能delete应该在Foo析构函数中使用它。这是RAII背后的驱动概念

资源分配(获取)由构造函数在对象创建(特别是初始化)期间完成,而资源释放(释放)由析构函数在对象破坏(特别是最终确定)期间完成。因此,可以保证在初始化完成和完成之间开始时保持资源(保持资源是类不变的),并且仅在对象处于活动状态时才保持资源。因此,如果没有对象泄漏,就不会有资源泄漏。

RAII还是智能指针背后的典型驱动力。在C ++标准库,这些是std::shared_ptrstd::unique_ptr,和std::weak_ptr; 尽管我已经看到并使用了遵循相同概念的其他shared_ptr/ weak_ptr实现。对于这些对象,引用计数器跟踪给定对象有多少个指针,并且delete在没有更多引用指向该对象时自动对其进行处理。

除此之外,对于程序员来说,一切都取决于适当的实践和纪律,以确保其代码正确处理对象。


4
通过删除delete-那就是我要找的东西。太棒了
Ju Shua 2016年

3
您可能想要添加有关c ++中提供的范围界定机制的信息,这些机制使大多数新的和删除的操作都可以自动进行。
whatsisname 2016年

9
@whatsisname并不是新的和删除是自动完成的,而是在许多情况下它们根本不发生
Caleth

10
delete通过被自动调用智能指针,如果你使用他们,你应该考虑每次使用它们时不能使用自动存储。
玛丽安·斯帕尼克

11
@JuShua请注意,在编写现代C ++时,您实际上不需要delete在应用程序代码中(从C ++ 14开始,与相同new),而应使用智能指针和RAII删除堆对象。std::unique_ptr类型和std::make_unique功能是直接,简单的更换new,并delete在应用程序代码的水平。
海德

83

C ++没有垃圾回收。

C ++应用程序需要处理自己的垃圾。

C ++应用程序程序员需要了解这一点。

当他们忘记时,结果称为“内存泄漏”。


22
您当然要确保您的答案也没有任何垃圾,也没有样板……
大约

15
@leftaroundabout:谢谢。我认为这是一种赞美。
John R. Strohm

1
好的,这个没有垃圾的答案确实有一个关键词可以搜索:内存泄漏。以某种方式提及new和也很好delete
Ruslan

4
@Ruslan这同样也适用于mallocfree,或new[]delete[],或任何其他分配器(如Windows的GlobalAllocLocalAllocSHAllocCoTaskMemAllocVirtualAllocHeapAlloc,...),而内存为您分配(例如,通过fopen)。
user253751 '16

43

在C,C ++和其他没有垃圾收集器的系统中,该语言及其库为开发人员提供了指示何时可以回收内存的工具。

最基本的设施是自动存储。很多时候,语言本身确保物品被处置:

int global = 0; // automatic storage

int foo(int a, int b) {
    static int local = 1; // automatic storage

    int c = a + b; // automatic storage

    return c;
}

在这种情况下,编译器负责知道何时未使用这些值并回收与它们关联的存储。

在使用动态存储时,在C中,传统上是使用分配malloc和回收内存的free。在C ++中,内存通常是由分配new和回收的delete

多年来,C并没有太大变化,但是现代的C ++避开了,new并且delete完全并依赖于库工具(它们本身使用newdelete适当地使用):

  • 智能指针是最有名的:std::unique_ptrstd::shared_ptr
  • 但集装箱是更广泛的实际上是:std::stringstd::vectorstd::map,...所有的内部动态管理分配的内存透明

说到这shared_ptr,存在一个风险:如果形成一个引用循环并且没有破坏引用,则可能存在内存泄漏。开发人员应避免这种情况,最简单的方法是shared_ptr完全避免,第二个最简单的方法是避免类型级别的循环。

其结果是内存泄漏是不是在C ++的问题,即使是新用户,只要他们使用避免newdeletestd::shared_ptr。这与C语言不同,在C语言中,严格的纪律是必需的,并且通常不够用。


但是,如果没有提到内存泄漏的双胞胎姐妹:悬空指针,这个答案是不完整的。

悬空指针(或悬空引用)是通过保持指向死对象的指针或引用而造成的危险。例如:

int main() {
    std::vector<int> vec;
    vec.push_back(1);     // vec: [1]

    int& a = vec.back();

    vec.pop_back();       // vec: [], "a" is now dangling

    std::cout << a << "\n";
}

使用悬空指针或引用是Undefined Behavior。通常,幸运的是,这是立即崩溃。不幸的是,这经常会首先导致内存损坏……并且不时出现怪异的行为,因为编译器会发出真正怪异的代码。

就程序的安全性/正确性而言,迄今为止,未定义行为是C和C ++的最大问题。您可能想检查Rust的语言,该语言没有垃圾收集器,也没有未定义的行为。


17
回复:“使用悬挂的指针或引用是未定义的行为。通常,幸运的是,这是立即崩溃”:真的吗?这根本不符合我的经验;相反,我的经验是,使用悬挂指针几乎不会立即导致崩溃。。。
ruakh

9
是的,因为要“悬空”一个指针,所以必须在某一点上将先前分配的内存作为目标,并且该内存通常不太可能不会完全从进程中取消映射,从而根本无法访问,因为它将成为立即重用的理想选择...实际上,悬空指针不会导致崩溃,而会导致混乱。
Leushenko '16

2
“因此,内存泄漏在C ++中不是问题,”可以肯定的是,总是存在C绑定到库的问题,以及递归的shared_ptrs甚至递归的unique_ptrs以及其他情况。
Mooing Duck

3
“即使对于新用户,C ++也不是问题” –我将其限定为“ 不是来自类似Java语言或C的新用户”。
左右约

3
@leftaroundabout:它是合格的“只要他们使用避免newdeleteshared_ptr”; 没有newshared_ptr您拥有直接所有权,因此不会泄漏。当然,您可能有悬挂的指针等。但是,恐怕您需要离开C ++才能摆脱这些指针。
Matthieu M.

27

C ++有个叫做RAII的东西。从根本上讲,这意味着您在走走时会清理垃圾,而不是将其堆放,然后让清洁工将其整理干净。(想象我在房间里看足球-当我喝啤酒罐并且需要新的罐时,C ++的方法是将空罐在冰箱的路上拿到垃圾箱中,C#的方法是将其装在地板上然后等着女仆来打扫卫生。

现在可以在C ++中泄漏内存,但是这样做需要您保留常规的构造并恢复为C的处理方式-分配一个内存块并在没有任何语言帮助的情况下跟踪该内存块的位置。有些人忘记了该指针,因此无法删除该块。


9
共享指针(使用RAII)提供了一种创建泄漏的现代方法。假设对象A和B通过共享指针相互引用,而没有其他对象引用对象A或对象B。结果是泄漏。这种相互引用在带有垃圾回收的语言中不是问题。
David Hammen

@DavidHammen可以确定,但是要遍历几乎每个对象来确定。您的智能指针示例忽略了以下事实:智能指针本身将超出范围,然后对象将被释放。您假设一个智能指针就像一个指针,而不是一个指针,它是一个像大多数参数一样在堆栈上传递的对象。这与GC语言导致的内存泄漏没有太大区别。例如,著名的从UI类中删除事件处理程序的事件将使它被静默引用,从而泄漏。
gbjbaanb

1
在本例中@gbjbaanb与智能指针,无论是智能指针永远超出范围,这就是为什么有泄漏。由于这两个智能指针对象都是在动态范围内分配的,而不是在词汇范围内分配的,因此它们每个都在破坏之前尝试等待另一个。智能指针不仅是指针,而且是C ++中的真实对象,这实际上是导致泄漏的原因- 堆栈作用域中指向容器对象的其他智能指针对象在销毁自己时也无法释放它们,因为refcount是非零。
Leushenko '16

2
.NET的方法不是将其在地板上。它只是保持在原处,直到女仆出现。而且由于.NET在实践中分配内存的方式(而非契约式),堆更像是一个随机访问堆栈。这就像拥有一堆合同和文件,并不时地通过它来丢弃那些不再有效的合同和文件。为了简化操作,将每次丢弃的幸存者提升为不同的堆叠,这样您就可以避免大部分时间遍历所有堆叠-除非第一个堆叠足够大,否则女仆不会碰到其他堆叠。
罗安

@Luaan这是一个比喻...我猜如果我说把罐子留在桌子上直到女仆来清理之前,你会更开心。
gbjbaanb

26

应该注意的是,在C ++的情况下,常见的误解是“您需要执行手动内存管理”。实际上,您通常不会在代码中进行任何内存管理。

固定大小的对象(具有作用域生存期)

在大多数情况下,当您需要一个对象时,该对象将在程序中具有定义的生存期,并在堆栈上创建。这适用于所有内置原始数据类型,也适用于类和结构的实例:

class MyObject {
    public: int x;
};

int objTest()
{
    MyObject obj;
    obj.x = 5;
    return obj.x;
}

函数结束后,将自动删除堆栈对象。在Java中,对象总是在堆上创建,因此必须通过诸如垃圾回收的某种机制将其删除。这不是堆栈对象的问题。

管理动态数据的对象(具有作用域生存期)

在堆栈上使用空间可用于固定大小的对象。当您需要可变数量的空间(例如数组)时,可以使用另一种方法:将列表封装在固定大小的对象中,该对象将为您管理动态内存。之所以可行,是因为对象可以具有特殊的清除功能,即析构函数。当对象超出范围并且与构造函数相反时,可以保证调用它:

class MyList {        
public:
    // a fixed-size pointer to the actual memory.
    int* listOfInts; 
    // constructor: get memory
    MyList(size_t numElements) { listOfInts = new int[numElements]; }
    // destructor: free memory
    ~MyList() { delete[] listOfInts; }
};

int listTest()
{
    MyList list(1024);
    list.listOfInts[200] = 5;
    return list.listOfInts[200];
    // When MyList goes off stack here, its destructor is called and frees the memory.
}

使用内存的代码中根本没有内存管理。我们唯一需要确定的是我们编写的对象具有合适的析构函数。不管我们如何离开范围listTest,无论是通过异常还是通过从其返回,~MyList()都将调用析构函数,并且我们不需要管理任何内存。

(我认为使用二进制NOT运算符~指示析构函数是一个有趣的设计决策。当用于数字时,它会将位求反;以此类推,在这里,它表明构造函数所做的是求反的。)

基本上所有需要动态内存的C ++对象都使用此封装。它被称为RAII(“资源获取就是初始化”),这是表达对象关心自己内容的简单想法的一种怪异方法。他们获得的是他们要清理的东西。

多态对象和生存期超出范围

现在,这两种情况都用于具有明确定义的生存期的内存:生存期与作用域相同。如果我们不希望对象在离开范围时过期,则可以使用第三种机制为我们管理内存:智能指针。当您具有类型在运行时变化但具有公共接口或基类的对象实例时,也会使用智能指针:

class MyDerivedObject : public MyObject {
    public: int y;
};
std::unique_ptr<MyObject> createObject()
{
    // actually creates an object of a derived class,
    // but the user doesn't need to know this.
    return std::make_unique<MyDerivedObject>();
}

int dynamicObjTest()
{
    std::unique_ptr<MyObject> obj = createObject();
    obj->x = 5;
    return obj->x;
    // At scope end, the unique_ptr automatically removes the object it contains,
    // calling its destructor if it has one.
}

还有另一种智能指针, std::shared_ptr,用于在多个客户端之间共享对象。它们仅在最后一个客户端超出范围时才删除所包含的对象,因此可以在完全未知将有多少客户端以及将使用该对象多长时间的情况下使用它们。

总而言之,我们看到您实际上并没有执行任何手动内存管理。一切都被封装,然后通过全自动,基于作用域的内存管理来处理。在这还不够的情况下,将使用智能指针封装原始内存。

在C ++代码中的任何地方,在构造函数之外的原始分配以及delete在析构函数之外的原始调用中,都使用原始指针作为资源所有者是极不明智的做法,因为在发生异常时几乎无法管理原始指针,并且通常很难安全使用它们。

最好:适用于所有类型的资源

RAII的最大好处之一是它不仅限于内存。实际上,它提供了一种非常自然的方式来管理资源,例如文件和套接字(打开/关闭)以及同步机制(例如互斥锁)(锁定/解锁)。基本上,可以在C ++中以完全相同的方式管理可以获取并必须释放的每个资源,并且这些管理都不留给用户。它全部封装在构造函数中获取并在析构函数中释放的类中。

例如,锁定互斥量的函数通常在C ++中这样编写:

void criticalSection() {
    std::scoped_lock lock(myMutex); // scoped_lock locks the mutex
    doSynchronizedStuff();
} // myMutex is released here automatically

其他语言使情况变得更加复杂,要么要求您手动执行此操作(例如,在finally子句中),要么衍生出专门的机制来解决此问题,但不是以一种特别优雅的方式(通常是在他们的晚年,当有足够的人拥有遭受缺点)。这种机制是Java 中的try-with-resources和C#中的using语句,两者都是C ++ RAII的近似值。

因此,总而言之,所有这些都是C ++中RAII的非常肤浅的描述,但是我希望它可以帮助读者理解C ++中的内存甚至资源管理通常不是“手动”的,而是实际上是自动的。


7
这是唯一不会误导人们,也不会比实际更难或更危险地描绘C ++的答案。
亚历山大·雷沃

6
顺便说一句,使用原始指针作为资源所有者仅被认为是不好的做法。如果它们指向可以保证指针本身寿命更长的东西,那么使用它们就没有错。
亚历山大·雷沃

8
我第二个亚历山大。我很困惑地看到“ C ++没有自动内存管理功能,忘了一个delete,你就死了”的答案飙升到30分以上并被接受,而这个答案只有5分。有人在这里实际使用C ++吗?
Quentin

8

特别是对于C语言,该语言没有为您提供管理动态分配内存的工具。您绝对有责任确保每个*alloc人在free某处都有对应的内容。

真正令人讨厌的地方是资源分配中途失败时。您是否会重试,是否从头开始回滚并重新开始,是否因错误而回滚并退出,是否只是完全保释并让OS处理?

例如,这是一个分配非连续2D数组的函数。这里的行为是,如果在过程中途发生分配失败,我们将回滚所有内容并使用NULL指针返回错误指示:

/**
 * Allocate space for an array of arrays; returns NULL
 * on error.
 */
int **newArr( size_t rows, size_t cols )
{
  int **arr = malloc( sizeof *arr * rows );
  size_t i;

  if ( arr ) // malloc returns NULL on failure
  {
    for ( i = 0; i < rows; i++ )
    {
      arr[i] = malloc( sizeof *arr[i] * cols );
      if ( !arr[i] )
      {
        /**
         * Whoopsie; we can't allocate any more memory for some reason.
         * We can't just return NULL at this point since we'll lose access
         * to the previously allocated memory, so we branch to some cleanup
         * code to undo the allocations made so far.  
         */
        goto cleanup;
      }
    }
  }
  goto done;

/**
 * We encountered a failure midway through memory allocation,
 * so we roll back all previous allocations and return NULL.
 */
cleanup:
  while ( i )         // this is why we didn't limit the scope of i to the for loop
    free( arr[--i] ); // delete previously allocated rows
  free( arr );        // delete arr object
  arr = NULL;

done:
  return arr;
}

此代码是对接丑gotoS,但是,在没有任何类型的结构化异常处理机制,这是相当多的应对问题,而只是想逃出来完全,只有这样,特别是如果你的资源分配代码嵌套更多超过一个循环。这是极少数goto实际具有吸引力的选择之一。否则,您将使用一堆标志和额外的if语句。

通过为每种资源编写专用的分配器/释放器函数,您可以使自己的生活更轻松,例如

Foo *newFoo( void )
{
  Foo *foo = malloc( sizeof *foo );
  if ( foo )
  {
    foo->bar = newBar();
    if ( !foo->bar ) goto cleanupBar;
    foo->bletch = newBletch(); 
    if ( !foo->bletch ) goto cleanupBletch;
    ...
  }
  goto done;

cleanupBletch:
  deleteBar( foo->bar );
  // fall through to clean up the rest

cleanupBar:
  free( foo );
  foo = NULL;

done:
  return foo;
}

void deleteFoo( Foo *f )
{
  deleteBar( f->bar );
  deleteBletch( f->bletch );
  free( f );
}

1
即使有goto陈述,这也是一个很好的答案。建议在某些地区进行此操作。这是一种防止C语言中的异常等效的常用方案。看看Linux内核代码,其中充满了goto语句-而且不会泄漏。
David Hammen

公平地说,“没有完全摆脱困境”->如果您想谈谈C,这可能是个好习惯。C是最适合用于处理来自其他地方的内存块将小块内存分派给其他过程的语言,但最好不要以交错方式同时执行这两种操作。如果您在C语言中使用经典的“对象”,则可能没有充分利用该语言的优势。
Leushenko '16

第二个goto是无关紧要的。如果你改变了它会是更具可读性goto done;return arr;arr=NULL;done:return arr;return NULL;。尽管在更复杂的情况下可能确实存在多个gotos,但它们开始在不同的就绪级别上展开(通过在C ++中展开异常堆栈来完成)。
Ruslan

2

我学会了将内存问题分为许多不同的类别。

  • 一滴水。假设程序在启动时泄漏了100个字节,但是再也不会泄漏了。追逐并消除那些一次性泄漏是一件好事(我确实喜欢通过泄漏检测功能获得一份清晰的报告),但这不是必需的。有时还有更大的问题需要解决。

  • 反复泄漏。在程序寿命期间重复调用的函数会定期泄漏内存,这是一个大问题。这些滴水会折磨该程序,甚至可能折磨OS,直至致死。

  • 相互参考。如果对象A和B通过共享指针相互引用,则必须在这些类的设计中或在实现/使用这些类的代码中打破圆形性的过程中,做一些特殊的事情。(对于垃圾收集语言,这不是问题。)

  • 记住太多了。这是垃圾/内存泄漏的邪恶表亲。RAII将无济于事,垃圾收集也不会。无论哪种语言,这都是一个问题。如果某个活动变量具有将其连接到某个随机内存块的路径,则该随机内存块不会成为垃圾。使程序变得健忘,使其可以运行几天是很棘手的。制作一个可以运行几个月(例如,直到磁盘出现故障)的程序非常非常棘手。

很长一段时间以来,我都没有遇到严重的泄漏问题。在C ++中使用RAII非常有助于解决这些滴漏和泄漏。(但是,使用共享指针时一定要小心。)更重要的是,我遇到了一些应用程序的问题,这些应用程序的内存使用量不断增长,因为与内存的未断开连接已不再使用。


-6

由C ++程序员在必要时实现自己的垃圾收集形式。否则,将导致所谓的“内存泄漏”。内置垃圾回收的“高级”语言(如Java)是很常见的,但诸如C和C ++的“低级”语言则没有。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.