换句话说,自动垃圾收集解决了哪些具体问题?我从来没有做过底层编程,所以我不知道释放资源会变得多么复杂。
GC所解决的错误(至少对于外部观察者而言)似乎是一种很好地了解他的语言,库,概念,习惯用法等的程序员所不会做的事情。但是我可能是错的:手动内存处理本质上是复杂的吗?
换句话说,自动垃圾收集解决了哪些具体问题?我从来没有做过底层编程,所以我不知道释放资源会变得多么复杂。
GC所解决的错误(至少对于外部观察者而言)似乎是一种很好地了解他的语言,库,概念,习惯用法等的程序员所不会做的事情。但是我可能是错的:手动内存处理本质上是复杂的吗?
Answers:
我从来没有做过底层编程,所以我不知道释放资源会变得多么复杂。
有趣的是“低级”的定义如何随时间变化。当我第一次学习编程时,提供标准化堆模型(使简单的分配/释放模式成为可能)的任何语言实际上都被认为是高级的。在低级编程中,您必须自己跟踪内存(不是分配,而是内存位置本身!),或者如果您真的很喜欢,请编写自己的堆分配器。
话虽如此,它实际上没有任何令人恐惧或“复杂”的地方。还记得您还是个孩子的时候,妈妈告诉您在玩完玩具后就收起玩具,因为她不是您的女仆,也不会为您打扫房间吗?内存管理就是应用于代码的同一原理。(GC就像有一个女仆会在您之后进行清理,但她非常懒惰,有点无能为力。)其原理很简单:代码中的每个变量只有一个所有者,这是该所有者的责任。当不再需要该变量时,释放它的内存。(单一所有权原则)这需要为每个分配调用一次,并且存在几种方案以一种或另一种方式自动实现所有权和清除,因此您甚至不必将该调用写入自己的代码中。
垃圾收集应该解决两个问题。它总是对其中之一做得很糟糕,并且取决于实现与否可能会做得不好。问题是内存泄漏(使用完内存后要保留在内存上)和悬挂的引用(使用完内存之前要释放内存。)让我们看一下这两个问题:
悬而未决的参考文献:首先讨论这一点,因为它确实很严肃。您有两个指向同一对象的指针。您释放了其中一个,而没有注意到另一个。然后在以后的某个时刻,您尝试读取(或写入或释放)第二个。随后出现未定义的行为。如果您没有注意到它,则很容易破坏内存。垃圾收集应该通过确保在所有对它的引用都消失之前不会释放任何东西,从而使此问题变得不可能。在完全托管的语言中,这几乎可以奏效,直到您必须处理外部的非托管内存资源。然后回到第1步。在非托管语言中,事情仍然比较棘手。(在Mozilla上浏览一下
幸运的是,处理此问题基本上是一个已解决的问题。您不需要垃圾收集器,而需要调试内存管理器。 例如,我使用Delphi,并使用一个外部库和一个简单的编译器指令,就可以将分配器设置为“完全调试模式”。作为支持启用跟踪已用内存的某些功能的回报,这增加了微不足道的性能开销(不到5%)。如果我释放了一个对象,它将充满它的内存0x80
个字节(在调试器中很容易识别),并且如果我曾经尝试在已释放的对象上调用虚拟方法(包括析构函数),它会通过带有三个堆栈跟踪的错误框来通知并中断程序-创建对象时,何时释放它,以及我现在在哪里-加上一些其他有用的信息,然后引发异常。这显然不适合发布版本,但是它使得跟踪和修复悬而未决的参考问题变得微不足道。
第二个问题是内存泄漏。当您不再需要已分配的内存时,就会发生这种情况。无论有无垃圾收集,它都能以任何语言发生,并且只能通过正确编写代码来解决。垃圾回收有助于缓解一种特定形式的内存泄漏,这种情况是在您没有有效引用尚未释放的内存时发生的,这意味着内存会一直分配到程序结束。不幸的是,以自动化方式完成此操作的唯一方法是将每个分配都变成内存泄漏!
如果我尝试说这样的话,我可能会被GC的支持者迷住了,所以请允许我解释一下。请记住,当您不再需要内存泄漏的定义时,它就会保留在分配的内存上。除了不引用某些内容外,还可以通过不必要地引用它来泄漏内存,例如在应该释放它时将其保存在容器对象中。我已经看到了一些由于执行此操作而导致的内存泄漏,并且它们很难追踪您是否具有GC,因为它们涉及对内存的完全有效的引用,并且没有明确的“错误”可用于调试工具来抓住。据我所知,没有自动工具可以让您捕获这种类型的内存泄漏。
因此,垃圾收集器只关心无引用的各种内存泄漏,因为这是唯一可以自动处理的类型。如果它可以监视您对所有内容的所有引用并在指向零引用时立即释放每个对象,那将是完美的,至少在无引用问题方面如此。以自动方式进行此操作称为引用计数,可以在某些有限的情况下完成此操作,但它有其自身的问题要处理。(例如,对象A拥有对对象B的引用,对象A拥有对对象A的引用。在引用计数方案中,即使没有对A或B的外部引用,也无法自动释放任何对象。)垃圾收集器使用跟踪而不是:先从一组已知的良好对象,发现他们引用的所有对象,找到所有的对象,他们引用,依此类推,直到递归你发现一切。在跟踪过程中找不到的所有内容都是垃圾,可以扔掉。(当然,要成功地做到这一点,就需要一种对类型系统施加一定限制的托管语言,以确保跟踪垃圾收集器始终能够分辨出引用和某个看起来像指针的随机内存之间的区别。)
跟踪存在两个问题。首先,它很慢,并且在发生这种情况时,必须或多或少地暂停程序以避免竞争状况。当程序应该与用户交互时,这可能导致明显的执行故障,或者服务器应用程序的性能下降。可以通过各种技术来缓解这种情况,例如将分配的内存划分为“几代”,其原理是,如果第一次尝试分配时未收集到分配,则可能会停留一段时间。.NET框架和JVM都使用分代垃圾回收器。
不幸的是,这导致了第二个问题:使用完内存后,内存无法释放。除非在完成对象后立即进行跟踪,否则跟踪将一直持续到下一次跟踪,或者如果跟踪到第一代之后甚至会更长。实际上,我见过的有关.NET垃圾收集器的最佳解释之一就是,为了使处理过程尽可能快,GC必须尽可能推迟收集!因此,通过尽可能长时间地泄漏尽可能多的内存,可以 “解决”内存泄漏的问题。 这就是我说GC将每个分配都变成内存泄漏时的意思。其实,也不能保证任何给定对象将永远被收集。
为什么在需要时仍可以回收内存时这是一个问题?有两个原因。首先,假设分配一个占用大量内存的大对象(例如,位图)。然后,使用完后不久,您需要另一个占用相同(或接近相同)内存量的大对象。如果第一个对象已释放,则第二个对象可以重用其内存。但是在一个垃圾回收的系统上,您可能仍在等待下一个跟踪运行,因此您最终不必要地浪费了内存用于第二个大对象。基本上这是比赛条件。
其次,不必要地(特别是大量存储)会在现代多任务处理系统中引起问题。如果您占用过多的物理内存,则可能导致您的程序或其他程序不得不分页(将其部分内存交换到磁盘上),这确实使速度变慢。对于某些系统(例如服务器),分页不仅会减慢系统速度,而且在负载不足的情况下也会使整个系统崩溃。
像悬挂引用问题一样,无引用问题也可以通过调试内存管理器解决。再次,我将提到Delphi的FastMM内存管理器中的“完全调试模式”,因为它是我最熟悉的模式。(我确信其他语言也存在类似的系统。)
当在FastMM下运行的程序终止时,可以选择让它报告从未释放的所有分配的存在。完全调试模式更进一步:它可以将文件保存到磁盘,不仅包含分配的类型,而且还包含每个泄漏的分配的分配信息时的堆栈跟踪信息以及其他调试信息。这使得跟踪无引用内存泄漏变得微不足道。
当您真正看到它时,垃圾收集在防止悬挂引用方面可能做得不好,在处理内存泄漏方面通常做得不好。实际上,它的优点之一不是垃圾回收本身,而是副作用:它提供了一种自动的方法来执行堆压缩。这可以防止一个奥秘的问题(通过堆碎片导致的内存耗尽),该问题可以杀死长时间连续运行并具有高度内存搅动的程序,并且如果没有垃圾回收,堆压缩几乎是不可能的。但是,如今,任何好的内存分配器都会使用存储桶来最大程度地减少碎片,这意味着碎片只会在极端情况下才真正成为问题。对于程序中可能出现堆碎片的问题,它是 建议使用压缩垃圾收集器。但是,在任何其他情况下,IMO都认为使用垃圾回收是过早的优化,并且存在针对其“解决”的问题的更好的解决方案。
考虑到与当前流行的系统(例如C ++的RAII)中使用的垃圾收集器相同的时代,一种非垃圾收集的内存管理技术。使用这种方法,不使用自动垃圾收集的成本将降至最低,并且GC引入了许多自身的问题。因此,我建议“不多”是您问题的答案。
请记住,当人们想到非GC时,他们会想到malloc
和free
。但这是一个巨大的逻辑谬误-您将1970年代初的非GC资源管理与90年代后期的垃圾收集器进行比较。这显然是一个相当不公平的比较:如果我没记错的话,当时malloc
和free
设计时使用的垃圾收集器太慢,无法运行任何有意义的程序。比较来自大约相等的时间段(例如)中的某些内容unique_ptr
更有意义。
垃圾收集器可以更轻松地处理参考周期,尽管这些经验很少见。此外,由于GC将负责所有内存管理,因此GC可以“抛出”代码,这意味着它们可以导致更快的开发周期。
另一方面,当处理来自其GC池以外的任何地方的内存时,它们往往会遇到巨大的问题。此外,当涉及到并发时,它们会失去很多好处,因为无论如何您都必须考虑对象所有权。
编辑:您提到的许多事情与GC无关。您会混淆内存管理和面向对象。瞧,这就是问题:如果您在像C ++这样的完全不受管理的系统中进行编程,则可以根据需要进行尽可能多的边界检查,而Standard容器类确实可以提供它。例如,没有关于边界检查或强类型输入的GC。
您提到的问题是通过面向对象而非GC解决的。数组存储器的起源以及确保不要在其外部写是正交的概念。
编辑:值得注意的是,更先进的技术可以完全避免任何形式的动态内存分配。例如,考虑使用this,它在C ++中实现了Y组合,而根本没有动态分配。
垃圾收集的语言提供所谓的“从有到约释放资源的担心自由”是一个相当大的程度上的错觉。不断添加内容到地图,而不会删除任何内容,您很快就会了解我在说什么。
实际上,内存泄漏在用GCed语言编写的程序中非常常见,因为这些语言往往使程序员变得懒惰,并使他们获得一种错误的安全感,即该语言将始终(以某种方式)总是(神奇地)照顾他们所使用的每个对象。不想再考虑了。
对于具有另一个更高尚目标的语言,垃圾收集只是一种必要的工具:将所有内容都视为指向对象的指针,同时向程序员隐藏它是指针的事实,以便程序员无法提交通过尝试指针算术等自杀。一切都是对象,这意味着GCed语言比非GCed语言需要分配对象的频率要高得多,这意味着,如果将负担重分配这些对象的负担放在程序员身上,它们将毫无吸引力。
同样,垃圾回收对于使程序员能够以功能性编程的方式编写紧凑的代码,在表达式内部处理对象的功能非常有用,而不必将表达式分解为单独的语句以提供每个对象的释放。参与表达式的单个对象。
除了这一切,请注意,我的回答年初我写了“这是一个相当大的程度上的错觉”。我没有写这是一种幻想。我什至没有写这主要是一种幻想。垃圾回收对于使程序员摆脱繁重的对象分配工作非常有用。因此,从这个意义上讲,这是一种生产力功能。
垃圾收集器无法解决任何“错误”。它是某些高级语言语义的必要部分。使用GC可以定义更高级别的抽象,例如词法闭包等,而使用手动内存管理时,这些抽象将是泄漏的,不必要地绑定到较低级别的资源管理。
评论中提到的“单一所有权原则”是这种泄漏抽象的一个很好的例子。开发人员完全不必担心与任何特定基本数据结构实例的链接数量,否则,如果没有大量其他限制(在代码本身中不直接可见)的限制和要求,任何一段代码都不会是通用且透明的。这样的代码不能组成高级代码,这是对职责分离原则(软件工程的主要组成部分,不幸的是,大多数低级开发人员根本没有尊重)的违反。
确实,管理您自己的内存只是错误的另一个潜在来源。
如果忘记了对free
(或所使用的任何一种语言的等效调用)的调用,则程序可以通过所有测试,但会泄漏内存。而且在一个相当复杂的程序中,很容易忽略对的调用free
。
free
不是最坏的事情。早期free
更具破坏性。
free
!
malloc
且free
曾经是非GC方式的GC太慢了,无法用于任何事情。您需要将其与现代的非GC方法(如RAII)进行比较。
我认为垃圾收集在语言改进方面大受赞誉,这些语言改进与GC无关,而只是一大波进步的一部分。
我知道的GC的一个明显好处是,您可以在程序中自由设置一个对象,并且知道每个人都完成该操作后它就会消失。您可以将其传递给另一个类的方法,而不必担心。您不必关心它传递给了什么其他方法,或者其他什么类引用了它。(内存泄漏是引用对象的类的责任,而不是创建对象的类的责任。)
如果没有GC,则必须跟踪分配的内存的整个生命周期。每次您从创建它的子例程中向上或向下传递地址时,都会对该内存有失控的引用。在糟糕的过去,即使只有一个线程,递归和精巧的操作系统(Windows NT)也使我无法控制对分配的内存的访问。我必须在自己的分配系统中使用free方法,以使内存块保留一段时间,直到清除所有引用为止。保持时间纯粹是猜测,但确实有效。
因此,这是我所知道的唯一的GC好处,但是没有它,我无法生存。我认为没有它的OOP不会成功。
物理泄漏
GC所解决的错误(至少对于外部观察者而言)似乎是一种很好地了解他的语言,库,概念,习惯用法等的程序员所不会做的事情。但是我可能是错的:手动内存处理本质上是复杂的吗?
来自C端,它使内存管理尽可能地手动和清晰,以便我们比较极端情况(C ++大多在没有GC的情况下自动执行内存管理),我会说“不是真的”,而与之相比发生泄漏。初学者,有时甚至是专业人士,都可能忘记free
为给定的内容写作malloc
。肯定会发生。
但是,有诸如valgrind
泄漏检测之类的工具可以在执行代码时,何时/何地发生此类错误直至确切的代码行立即发现。将其集成到CI中后,几乎不可能合并这些错误,并且很容易纠正它们。因此,在具有合理标准的任何团队/流程中,这都不是什么大问题。
当然,在某些情况下,执行free
失败可能会在测试的注意范围内进行,而调用失败,也许是在遇到晦涩的外部输入错误(例如损坏的文件)时,这种情况下系统可能会泄漏32个字节或其他内容。我认为,即使在相当好的测试标准和泄漏检测工具下,也肯定会发生这种情况,但是对于几乎从未发生过的事情泄漏一点内存也不是那么重要。我们将看到更大的问题,即即使在下面的常见执行路径中,我们也可能以GC无法阻止的方式泄漏大量资源。
当对象的生存期需要某种形式的延迟/异步处理(可能由另一个线程)扩展时,如果没有类似GC伪形式(例如引用计数)的东西,这也很困难。
悬空指针
使用更多手动形式的内存管理的真正问题并没有泄漏给我。我们知道有多少用C或C ++编写的本机应用程序确实泄漏了?Linux内核泄漏吗?MySQL的?CryEngine 3?数字音频工作站和合成器?Java VM是否泄漏(以本机代码实现)?Photoshop?
如果有的话,我认为当我们环顾四周时,最泄漏的应用程序往往是使用GC方案编写的应用程序。但是在此之前,本机代码有一个重大问题,它与内存泄漏无关。
对我来说,问题始终是安全。即使当我们free
通过指针进行存储时,如果还有其他指向资源的指针,它们也将变为悬空(无效)的指针。
当我们尝试访问那些悬空指针的指针时,最终会遇到不确定的行为,尽管几乎总是出现段错误/访问冲突,从而导致立即崩溃。
我上面列出的所有那些本机应用程序可能都有一个或两个模糊的边缘情况,这可能主要是由于此问题而导致崩溃,并且肯定有相当一部分用本机代码编写的伪劣应用程序是非常崩溃的,而且经常很大程度上是由于这个问题。
...这是因为无论您是否使用GC,资源管理都很困难。面对导致资源管理不善的错误,实际的差异通常是泄漏(GC)或崩溃(没有GC)。
资源管理:垃圾回收
无论如何,复杂的资源管理都是一个困难的手动过程。GC无法在此处自动执行任何操作。
让我们举一个例子,我们有一个对象“ Joe”。乔被其加入的许多组织所引用。他们每个月左右都会从他的信用卡中提取会员费。
我们还可以参考乔来控制他的一生。可以说,作为程序员,我们不再需要Joe。他开始缠扰我们,我们不再需要他所属的这些组织浪费时间与他打交道。因此,我们试图通过删除他的生命线参考将他从大地上抹掉。
...但是等等,我们正在使用垃圾回收。对乔的任何强烈提及都会使他留在身边。因此,我们也从他所属的组织中删除了对他的引用(取消订阅)。
...除了哎呀,我们忘了取消他的杂志订阅!现在,乔仍然在记忆中,困扰着我们并耗尽了资源,而杂志公司也最终继续继续每月处理乔的会员资格。
这是主要的错误,它可能导致使用垃圾回收方案编写的许多复杂程序泄漏得越久,它们运行的时间越长,并开始使用越来越多的内存,并且处理的次数可能越来越多(循环杂志订阅)。他们忘记删除这些引用中的一个或多个,从而使垃圾收集器无法魔术,直到关闭整个程序。
该程序不会崩溃,但是。非常安全。这只是为了增加记忆,而Joe仍然会流连忘返。对于许多应用程序而言,这种泄漏行为(我们只是在这个问题上投入越来越多的内存/处理)可能比硬崩溃要好得多,尤其是考虑到当今我们的计算机具有多少内存和处理能力。
资源管理:手册
现在,让我们考虑一下使用指向Joe的指针和手动内存管理的替代方法,如下所示:
这些蓝色链接无法管理乔的一生。如果我们想将他从地球上移开,我们会手动要求销毁他,如下所示:
现在,通常情况下,我们到处都是悬空的指针,所以让我们删除指向Joe的指针。
...糟糕,我们又犯了同样的错误,却忘记了退订Joe的杂志!
除了现在,我们还有一个悬空的指针。当杂志订阅试图处理Joe的月租费时,整个世界都会爆炸式增长-通常,我们会立即遭受重创。
开发人员忘记手动删除对资源的所有指针/引用的这种基本的资源管理不当错误也可能导致本机应用程序崩溃。它们通常不会运行更长的时间,因为在这种情况下,它们经常会彻底崩溃。
真实世界
现在,上面的示例使用的是一个荒谬的简单图表。现实世界中的应用程序可能需要将数千张图像缝合在一起才能覆盖整个图,场景图中存储了数百种不同类型的资源,GPU资源与其中的某些资源相关联,加速器与其他资源相关联,观察者分布在数百个插件中观看场景中的多种实体类型以进行更改,观察者观察观察者,将音频同步到动画等。因此,似乎很容易避免我上面描述的错误,但是在现实世界中通常没有这么简单的方法了跨数百万行代码的复杂应用程序的生产代码库。
某天某人会错误地管理该代码库中某处的资源的机会往往很高,无论是否使用GC,这种可能性都是相同的。主要区别是由于此错误而将发生的情况,这也可能影响潜在地发现和修复此错误的速度。
崩溃与泄漏
现在哪一个更糟?立即崩溃,还是无声的内存泄漏,让Joe神秘地徘徊?
大多数人可能会回答后者,但可以说该软件被设计为连续运行数小时,甚至可能数天,而我们添加的这些Joe's和Jane's中的每一个都会使该软件的内存使用量增加1 GB。它不是关键任务软件(崩溃实际上并不会杀死用户),而是性能关键软件。
在这种情况下,硬调试在调试时立即显示出来,指出您所犯的错误,实际上可能比泄漏的软件更可取,而泄漏的软件甚至可能在您的测试程序的监视下飞翔。
另一方面,如果它不是以性能为目标的任务关键型软件,而不会以任何可能的方式崩溃,则泄漏实际上可能更可取。
参考文献薄弱
GC方案中存在这些想法的混合形式,称为弱引用。使用弱引用,我们可以让所有这些组织都使用弱引用Joe,但是当强引用(Joe的所有者/生命线)消失时,我们不能阻止他被删除。但是,我们获得的好处是能够通过这些弱引用来检测Joe何时不再存在,从而使我们能够轻松获得各种错误。
不幸的是,弱引用没有得到应有的使用,因此,许多复杂的GC应用程序可能容易泄漏,即使它们比复杂的C应用程序崩溃的可能性小得多,例如
无论如何,GC是否使您的生活更轻松或更艰难,取决于软件对避免泄漏的重要性,以及它是否涉及这种复杂的资源管理。
以我为例,我在性能至关重要的领域工作,资源确实跨越了数百兆字节到千兆字节,并且由于类似上述错误而在用户请求卸载时不释放该内存,实际上比崩溃更不受欢迎。崩溃很容易发现和重现,即使它们不是用户最不喜欢的地方,也经常成为程序员最喜欢的错误,并且很多此类崩溃会在他们到达用户之前通过健全的测试过程显示出来。
无论如何,这些就是GC和手动内存管理之间的区别。要回答您的紧迫问题,我想说手动内存管理很困难,但是与泄漏几乎没有关系,当资源管理不那么重要时,GC和手动形式的内存管理仍然非常困难。在这里,GC可以说具有更棘手的行为,其中程序似乎运行良好,但消耗了越来越多的资源。手动表单的技巧不那么复杂,但是会崩溃并浪费大量时间,并出现上述错误。
这是C ++程序员在处理内存时面临的问题列表:
如您所见,堆内存正在解决许多现有问题,但是这会导致额外的复杂性。GC旨在处理这种复杂性的一部分。(很抱歉,如果某些问题名称不是这些问题的正确名称,有时很难找出正确的名称)