垃圾收集与手动内存管理

Garbage collection vs manual memory management

本文关键字:内存 管理      更新时间:2023-10-16

这是一个非常基本的问题。我将使用c++和Java来表述它,但它确实是独立于语言的。考虑c++中一个众所周知的问题:

struct Obj
{
    boost::shared_ptr<Obj> m_field;
};
{
    boost::shared_ptr<Obj> obj1(new Obj);
    boost::shared_ptr<Obj> obj2(new Obj);
    obj1->m_field = obj2;
    obj2->m_field = obj1;
}

这是内存泄漏,每个人都知道:)。解决方案也是众所周知的:应该使用弱指针来打破"重计数联锁"。我们也知道,这个问题在原则上是不能自动解决的。解决这个问题完全是程序员的责任。

但是有一个积极的事情:程序员可以完全控制折算值。我可以在调试器中暂停我的程序并检查obj1, obj2的重新计数,并了解有问题。我还可以在对象的析构函数中设置断点并观察析构时刻(或者发现对象没有被析构)。

我的问题是关于Java, c#, ActionScript和其他"垃圾收集"语言。我可能错过了什么,但在我看来,他们

  1. 不要让我检查对象的计数
  2. 不要让我知道对象何时被销毁(好吧,当对象暴露给GC时)

我经常听说这些语言不允许程序员泄漏内存,这就是为什么它们很棒的原因。据我所知,它们只是隐藏了内存管理问题,使它们难以解决。

最后,问题本身:

Java:

public class Obj
{
    public Obj m_field;
}
{
     Obj obj1 = new Obj();
     Obj obj2 = new Obj();
     obj1.m_field = obj2;
     obj2.m_field = obj1;
}
    是内存泄漏吗?
  1. 如果是:我如何检测和修复它?
  2. 如果没有:为什么?

托管内存系统是建立在您不希望首先跟踪内存泄漏问题的假设之上的。而不是让它们更容易解决,你试图确保它们从一开始就不会发生。

Java确实有一个"内存泄漏"的术语,它意味着任何可能影响应用程序的内存增长,但从来没有一点是托管内存不能清理所有内存。

JVM不使用引用计数有很多原因

  • 它不能处理循环引用。
  • 它有大量的内存和线程开销来保持准确。对于托管内存,有更好、更简单的方法来处理这种情况。

虽然JLS不禁止使用引用计数,但它不会在任何JVM AFAIK中使用。

相反,Java跟踪许多根上下文(例如每个线程堆栈),并且可以根据这些对象是否强可及来跟踪哪些对象需要保留,哪些可以丢弃。它还为弱引用(只要对象没有被清理就保留)和软引用(通常不被清理,但可以由垃圾收集器决定)提供了功能

AFAIK, Java GC的工作方式是从一组定义良好的初始引用开始,并计算可以从这些引用到达的对象的传递闭包。

Java有一个独特的内存管理策略。所有东西(除了一些特定的东西)都分配在堆上,直到GC开始工作才释放。

例如:

public class Obj {
    public Object example;
    public Obj m_field;
}
public static void main(String[] args) {
    int lastPrime = 2;
    while (true) {
        Obj obj1 = new Obj();
        Obj obj2 = new Obj();
        obj1.example = new Object();
        obj1.m_field = obj2;
        obj2.m_field = obj1;
        int prime = lastPrime++;
        while (!isPrime(prime)) {
            prime++;
        }
        lastPrime = prime;
        System.out.println("Found a prime: " + prime);
    }
}

C通过要求您手动释放'obj'的内存来处理这种情况,而c++计算对'obj'的引用并在它们超出作用域时自动销毁它们。Java不会释放内存,至少一开始不会。

Java运行时等待一段时间,直到感觉有太多的内存被使用。在此之后,垃圾回收器开始工作。

假设java垃圾收集器决定在外部循环第10,000次迭代后进行清理。到目前为止,已经创建了10,000个对象(在C/c++中已经释放了)。

尽管外部循环有10,000次迭代,但只有新创建的obj1和obj2可能被代码引用。

这些是GC '根',java使用它来查找所有可能被引用的对象。然后,垃圾收集器递归地向下迭代对象树,将'example'标记为与垃圾收集器根相关的活动。

所有其他对象随后被垃圾回收器销毁。这确实会带来性能损失,但这个过程已经经过了大量优化,对于大多数应用程序来说并不重要。

与c++不同,根本不用担心引用循环,因为只有从GC根可访问的对象才存在。

对于java应用程序,您确实必须担心内存(考虑列表保留所有迭代中的对象),但它不像其他语言那样重要。

至于调试:Java调试高内存值的想法是使用一个特殊的"内存分析器"来找出哪些对象仍然在堆上,而不是担心哪个对象引用了什么。

关键的区别在于,在Java等中,您根本不涉及处理问题。这可能感觉是一个相当可怕的位置,但它是令人惊讶的授权。您过去必须做出的关于谁负责处置已创建的对象的所有决定都消失了。

这确实有意义。系统比你更清楚什么是可及的,什么是不可及的。它还可以在何时拆除建筑物等方面做出更灵活、更明智的决定。

本质上——在这个环境中,你可以用更复杂的方式处理对象,而不用担心掉一个。你现在唯一需要担心的是,如果你不小心把一个粘在天花板上。

作为一个前C程序员转到Java,我能感受到你的痛苦。

回答你的最后一个问题——这不是内存泄漏。当GC启动时,除了可访问的内容外,的所有内容都被丢弃。在本例中,假设您已经释放了obj1obj2,两者都不可达,因此它们都将被丢弃。

垃圾收集不是简单的引用计数

您演示的循环引用示例不会出现在垃圾收集托管语言中,因为垃圾收集器将希望跟踪分配引用,一直追溯到堆栈上的某些内容。如果没有堆栈引用,它就是垃圾。像shared_ptr这样的refcounting系统并不是那么聪明,并且有可能(如您所演示的)在堆的某个地方有两个对象,以防止彼此被删除。

垃圾收集语言不允许检查refcounter,因为它们没有refcounter。垃圾收集与重新计算内存管理完全不同。真正的区别在于决定论。

{
std::fstream file( "example.txt" );
// do something with file
}
// ... later on
{
std::fstream file( "example.txt" );
// do something else with file
}

在c++中,您可以保证example.txt在第一个块关闭后被关闭,或者如果抛出异常。与Java

比较
{
try 
  {
  FileInputStream file = new FileInputStream( "example.txt" );
  // do something with file
  }
finally
  {
  if( file != null )
    file.close();
  }
}
// ..later on
{
try 
  {
  FileInputStream file = new FileInputStream( "example.txt" );
  // do something with file
  }
finally
  {
  if( file != null )
    file.close();
  }
}

如您所见,您已经将内存管理换成了所有其他资源管理。这是真正的区别,重新计数的对象仍然保持确定性销毁。在垃圾收集语言中,必须手动释放资源,并检查异常。有人可能会说,显式内存管理很乏味,而且容易出错,但在现代c++中,智能指针和标准容器可以缓解这种情况。你仍然有一些责任(例如,循环引用),但是考虑使用确定性销毁可以避免多少catch/finally块,以及Java/c#等类型的输入。程序员必须这样做(因为他们必须手动关闭/释放内存以外的资源)。我知道c#中有使用语法(在最新的Java中也有类似的东西),但它只涵盖块作用域的生命周期,而不是更普遍的共享所有权问题。