为什么在不再引用Java对象后不立即删除它们?


77

在Java中,只要对象不再具有任何引用,就可以删除它,但是JVM决定何时实际删除该对象。要使用Objective-C术语,所有Java参考本质上都是“强”的。但是,在Objective-C中,如果对象不再具有任何强引用,则该对象将被立即删除。为什么在Java中不是这种情况?


46
您不必在乎何时实际删除Java对象。这是一个实现细节。
巴士底·斯塔林凯维奇

154
@BasileStarynkevitch您应该绝对关心并挑战系统/平台的工作方式。提出“如何”和“为什么”的问题是成为更好的程序员(更笼统地说,更聪明的人)的最佳方法之一。
Artur Biesiadowski

6
有循环引用时,Objective C会做什么?我认为这只是泄漏他们?
Mehrdad '18年

45
@ArturBiesiadowksi:不,Java规范没有说明何时删除对象(同样,对于R5RS)。如果删除从未发生过,那么您可能并且应该开发Java程序(对于像Java hello world这样短暂的进程,确实不会发生)。您可能会关心活动对象集(或内存消耗),这是另一回事。
巴士底·斯塔林凯维奇

28
有一天,新手对主人说:“我有解决分配问题的方法。我们将为每个分配提供一个引用计数,当计数达到零时,我们可以删除该对象”。主人回答:“有一天,新手对主人说:“我有解决办法……
埃里克·利珀特

Answers:


79

首先,Java具有弱引用,而另一个尽力而为类别称为软引用。弱引用与强引用是与引用计数与垃圾回收完全不同的问题。

其次,内存使用中的某些模式可以通过牺牲空间来提高垃圾回收的效率。例如,较新的对象比旧的对象更有可能被删除。因此,如果在两次扫描之间稍等片刻,则可以删除大多数新一代内存,同时将少数幸存者移至长期存储。长期存储的扫描频率要低得多。通过手动内存管理或引用计数立即删除更容易造成碎片。

这有点像每张工资单去杂货店购物和每天去买一天足够的食物之间的区别。一次大行程比一次小行程要花费更长的时间,但总的来说,您最终会节省时间,甚至可能节省金钱。


58
程序员的妻子将他送到超市。她对他说:“买一条面包,如果你看到鸡蛋,那就抓一打。” 程序员随后带着手臂打了十几条面包回来。
尼尔

7
我建议提一下,新一代gc时间通常与活动对象的数量成正比,因此,删除更多的对象意味着在许多情况下根本不会支付其费用。删除就像翻转幸存者空间指针并有选择地将一个大内存集中的整个内存空间清零一样简单(不确定是在gc结束时完成还是在当前jvm中分配tlab或对象本身时摊销)
Artur Biesiadowski

64
@Neil不应该是13条面包吗?
JAD

67
“过道7出一个错误”
joeytwiddle

13
@JAD我本来会说13,但大多数人都不倾向于这样。;)
尼尔,

86

因为正确地知道不再引用某些东西并不容易。甚至都不容易。

如果您有两个对象互相引用该怎么办?他们会永远留下来吗?将这种思路扩展到解决任意数据结构,您将很快了解为什么JVM或其他垃圾收集器被迫采用更加复杂的方法来确定仍然需要什么以及可以做什么。


7
或者,您可以采用Python方法,在其中尽可能多地使用refcounting,当您期望有循环依赖性泄漏内存时,请诉诸于GC。我不明白为什么他们除了GC之外还不能进行点钞?
Mehrdad '18年

27
@Mehrdad他们可以。但是可能会更慢。没有什么能阻止您实施此操作,但是不要指望在Hotspot或OpenJ9中击败任何GC。
约瑟夫

21
@ jpmc26,因为如果立即删除不再使用的对象,则在高负载情况下删除对象的可能性很高,这会进一步增加负载。当负载较小时,GC可以运行。引用计数本身对于每个引用都是很小的开销。同样,使用GC时,如果不处理单个对象,通常可以丢弃没有引用的大部分内存。
约瑟夫

33
@Josef:正确的引用计数也不是免费的;参考计数更新需要原子增量/减量,这是令人惊讶的高成本,尤其是在现代多核体系结构上。在CPython中,这并不是什么大问题(CPython本身非常慢,并且GIL将其多线程性能限制在单核级别),但是在一种还支持并行性的更快的语言上,这可能是个问题。PyPy完全摆脱引用计数而只使用GC的机会不大。
意大利的Matteo意大利,

10
@Mehrdad一旦实现了Java的引用计数GC,我将很乐意对其进行测试,以发现其性能比任何其他GC实现都要差的情况。
约瑟夫

45

AFAIK,JVM规范(用英语编写)没有提到何时应删除确切的对象(或值),而是将其留给实现(对于R5RS也是一样)。它以某种方式要求或建议使用垃圾收集器,但将细节留给实现。Java规范也是如此。

请记住,编程语言规范语法语义等),而不是软件实现。诸如Java(或其JVM)之类的语言具有许多实现。它的规范已发布,可下载(以便您进行研究)并以英语编写。第2.5.3节 JVM规范的提到了垃圾收集器:

自动存储管理系统(称为垃圾收集器)可以回收对象的堆存储;对象永远不会显式释放。Java虚拟机不假定特定类型的自动存储管理系统

(强调的是矿;顺便说一句定稿中提到§12.6 Java规范的,和存储器模型是在§17.4 Java规范的)

因此,(在Java中)您不必关心何时删除对象,并且可以将代码按原样(如果未发生)进行编码(通过推理抽象化而忽略该对象)。当然,您需要关心内存消耗和活动对象集,这是一个不同的问题。在几种简单的情况下(例如“ hello world”程序),您可以证明(或说服自己)分配的内存非常小(例如,小于1 GB),然后您根本就不在乎删除单个对象。在更多情况下,您可以使自己相信有生命的物体(或可达的,这是一个超集-易于推理-有生命的生命)永远不会超出合理的限制(然后您确实依赖于GC,但是您不关心垃圾收集的方式和时间)。了解有关空间复杂性的信息

我猜想在运行像Java Hello World这样的短暂Java程序的几种JVM实现中,根本不会触发垃圾收集器,也不会发生删除。AFAIU,这种行为符合众多Java规范。

大多数JVM实现使用世代复制技术(至少对于大多数Java对象,那些不使用终结处理弱引用的对象;终结处理不能保证在短时间内发生并且可以推迟,因此这是一个有用的功能,您的代码不应在很大程度上取决于),删除单个对象的概念没有任何意义(因为一大块内存-包含许多对象的存储区-可能一次释放几兆字节)。

如果JVM规范要求尽快准确地删除每个对象(或者只是对对象删除施加更多限制),那么将禁止高效的世代GC技术,因此Java和JVM的设计者应避免这种情况。

顺便说一句,一个永不删除对象并且不释放内存的幼稚JVM可能符合规范(字母,不是精神),并且确实能够在实践中运行一个hello world事物(请注意,大多数小型且寿命短的Java程序可能分配的内存不会超过几GB。当然,这样的JVM是不值得一提的是只是一个玩具的东西(比如是这样实现的malloc对C)。有关更多信息,请参见Epsilon NoOp GC。现实生活中的JVM是非常复杂的软件,并且混合了几种垃圾回收技术。

另外,Java与JVM不同,并且您确实在没有JVM的情况下运行了Java实现(例如,提前 Java编译器,Android运行时)。在某些情况下(大多数是学术性的情况),您可能会想像(所谓的“编译时垃圾收集”技术),Java程序不会在运行时分配或删除(例如,由于优化的编译器足够聪明,只能使用调用堆栈自动变量)。

为什么在不再引用Java对象后不立即删除它们?

因为Java和JVM规范不需要这样做。


阅读GC手册以了解更多信息(以及JVM规范)。请注意,对一个对象保持活动状态(或对将来的计算有用)是整个程序(非模块化)属性。

Objective-C支持在内存管理中使用引用计数方法。这也有陷阱(例如,Objective-C 程序员必须通过显式声明弱引用来关心循环引用,但是JVM在实践中可以很好地处理循环引用,而无需Java程序员的注意)。

没有银弹在编程和编程语言设计(知晓的停机问题 ;是一个有用的生命的物体是不可判定一般)。

您可能还会阅读SICP编程语言实用程序龙书Lisp小片段操作系统:三篇简单文章。它们不是关于Java的,但是它们会打开您的胸怀,应该帮助您了解JVM应该做什么以及它在计算机上(与其他组件一起)如何实际工作。您还可能需要花费数月(或数年)的时间研究现有开放源代码 JVM实现的复杂源代码(例如OpenJDK,它具有数百万个源代码行)。


20
“有可能永远不会删除对象并且不释放内存的幼稚JVM可能符合规范”。实际上,Java 11实际上为非常短命的程序添加了一个无操作垃圾收集器
迈克尔,

6
“您不必关心何时删除对象”。首先,您应该知道RAII不再是一种可行的模式,并且您不能依赖于finalize任何资源管理(文件句柄,数据库连接,gpu资源等)。
亚历山大

4
@Michael对于具有已用内存上限的批处理而言,这非常有意义。操作系统只能说“该程序使用的所有内存现在都消失了!” 毕竟,这相当快。实际上,许多用C编写的程序都是用这种方式编写的,尤其是在早期的Unix世界中。Pascal拥有令人惊叹的“重置堆栈/堆指针到预先保存的检查点”,这使您可以做很多事情,尽管那是非常不安全的-标记,启动子任务,重置。
a安

6
@Alexander通常在C ++(以及有意从中衍生的几种语言)之外,假设RAII仅能基于终结器工作是一种反模式,应予以警告,并用明确的资源控制块代替。毕竟,GC的要点是生命周期和资源是分离的。
卢申科'18

3
@Leushenko我强烈反对“生命周期和资源分离”是GC的“重点”。您为GC的主要要点支付的负价是:简单,安全的内存管理。“假设RAII仅能基于终结器工作是一种反模式”在Java中?也许。但是在CPython,Rust,Swift或Objective C中却没有。通过RAII管理资源的对象为您提供了传递范围内生命的句柄。资源尝试模块仅限于一个范围。
亚历山大

23

要使用Objective-C术语,所有Java参考本质上都是“强”的。

这是不正确的-Java确实具有弱引用和软引用,尽管这些引用是在对象级别而不是作为语言关键字实现的。

在Objective-C中,如果对象不再具有任何强引用,则该对象将立即删除。

这也不一定是正确的-Objective C的某些版本确实使用了世代垃圾收集器。其他版本完全没有垃圾回收。

的确,较新版本的Objective C使用自动引用计数(ARC)而不是基于跟踪的GC,并且(通常)这导致对象的引用计数达到零时被“删除”。但是,请注意,JVM实现也可以兼容并完全按照这种方式工作(哎呀,它可以兼容并且完全没有GC。)

那么,为什么大多数JVM实现都不这样做,而是使用基于跟踪的GC算法?

简而言之,ARC并不像它最初看起来那样乌托邦式:

  • 每当引用被复制,修改或超出范围时,都必须增加或减少计数器,这会带来明显的性能开销。
  • ARC无法轻松清除周期性引用,因为它们都相互引用,因此它们的引用计数永远不会为零。

当然,ARC确实具有优势-它的易于实现和收集是确定性的。但是,上述缺点以及其他缺点是大多数JVM实现将使用基于跟踪的世代GC的原因。


1
有趣的是,苹果之所以选择ARC,恰恰是因为他们发现实际上,它大大优于其他GC(特别是几代GC)。公平地讲,这在内存受限平台(iPhone)上大多如此。但我要反驳您的说法“ ARC并不像它最初看起来那样乌托邦式”,而是说世代(以及其他非确定性)GC并不像它们最初看起来那样乌托邦式:确定性销毁可能是一个更好的选择。绝大多数情况。
康拉德·鲁道夫

3
@KonradRudolph虽然我也很喜欢确定性破坏,但我认为“在大多数情况下更好的选择”不会成立。当延迟或内存比平均吞吐量更重要时,尤其是当逻辑相当简单时,这无疑是一个更好的选择。但这并不像没有那么多复杂的应用程序需要大量的循环引用等,并且需要快速的平均操作,但实际上并不关心延迟并拥有足够的内存。对于这些,怀疑ARC是否是个好主意。
大约

1
@leftaroundabout在“大多数情况”下,吞吐量和内存压力都不是瓶颈,因此无论哪种方式都没有关系。您的示例是一种特定的情况。诚然,这并不少见,但是我不会声称它比其他更适合ARC的方案更普遍。此外,ARC 可以很好地处理周期。程序员只需要一些简单的手动干预即可。这使其不那么理想,但几乎不会破坏交易。我认为确定性终结比您假装要重要得多。
康拉德·鲁道夫'18

3
@KonradRudolph如果ARC需要程序员进行一些简单的手动干预,则它不会处理循环。如果开始大量使用双向链接列表,则ARC会转移到手动内存分配中。如果您有任意大的图形,ARC会强制您编写垃圾收集器。GC的论点是,需要销毁的资源不是内存子系统的工作,为了跟踪相对较少的资源,应该通过程序员的一些简单手动干预来确定性地确定这些资源。
prosfilaes

2
如果不手动处理@KonradRudolph ARC和循环,则从根本上导致内存泄漏。在足够复杂的系统中,如果例如某个映射中存储的某个对象存储了对该映射的引用,则可能会发生重大泄漏,这种变化可以由不负责创建和销毁该映射的代码部分的程序员来进行。较大的任意图并不意味着内部指针不强,链接的项目消失也可以。我不会说,处理一些内存泄漏是否比手动关闭文件要少的问题,但这是真实的。
prosfilaes

5

Java没有精确指定何时收集对象,因为这使实现可以自由选择如何处理垃圾收集。

有许多不同的垃圾收集机制,但是那些保证您可以立即收集对象的垃圾收集机制几乎完全基于引用计数(我不知道有任何打破这种趋势的算法)。参考计数是一个功能强大的工具,但是要付出一定的代价才能保持参考计数。在单线程代码中,无非是递增和递减,所以分配指针所花费的成本大约是非引用计数代码的3倍(如果编译器可以将所有内容分解到机器中)码)。

在多线程代码中,成本更高。它要么要求原子的增/减,要么要求加锁,这两者都可能很昂贵。在现代处理器上,原子操作的费用可能比简单的寄存器操作高20倍左右(显然,每个处理器的操作不同)。这会增加成本。

因此,我们可以考虑几种模型之间的权衡。

  • Objective-C专注于ARC-自动引用计数。他们的方法是对所有内容都使用引用计数。(我不知道)没有周期检测,因此程序员应该防止周期的发生,这会花费开发时间。他们的理论是指针不会经常分配,它们的编译器可以识别递增/递减的引用计数不会导致对象死亡的情况,并完全消除那些递增/递减的情况。因此,它们最小化了参考计数的成本。

  • CPython使用混合机制。它们使用引用计数,但是它们还具有识别周期并释放周期的垃圾收集器。这以两种方法为代价提供了两个世界的利益。CPython必须同时维护引用计数做簿记以检测周期。CPython通过两种方式避免了这种情况。首要的是CPython确实不是完全多线程的。它具有一个称为GIL的锁,该锁限制了多线程。这意味着CPython可以使用普通的增量/减量,而不是原子的增量/减量,这要快得多。还解释了CPython,这意味着像对变量进行赋值之类的操作已经需要少量指令,而不仅仅是1条指令。进行增量/减量的额外成本(在C代码中快速完成)已不再是问题,因为我们我已经支付了这笔费用。

  • Java采用了完全不保证引用计数系统的方法。事实上,规范并没有说任何有关对象如何进行管理以外,会有一个自动存储管理系统。但是,该规范还强烈暗示了以下假设:将以处理周期的方式将其垃圾收集。通过不指定对象何时过期,java可以自由使用收集器,而不会浪费时间增加/减少时间。确实,诸如代垃圾收集器之类的聪明算法甚至可以处理许多简单的情况,而无需查看正在回收的数据(它们只需要查看仍在引用的数据)。

因此,我们可以看到这三个必须进行权衡。哪种权衡是最好的,很大程度上取决于使用该语言的方式的性质。


4

尽管finalize垃圾回收背负于Java的GC,但垃圾回收的核心不是死对象,而是活动对象。在某些GC系统(可能包括Java的某些实现)上,唯一可以将代表对象的位和不用于任何用途的存储区分开的唯一原因可能是对前者的引用。虽然带有终结符的对象被添加到特殊列表中,但其他对象在Universe中可能没有任何地方可以说其存储与对象相关联,但用户代码中保留的引用除外。当最后一个这样的引用被覆盖时,内存中的位模式将立即停止被识别为对象,无论宇宙中是否有任何对象意识到这一点。

垃圾回收的目的不是破坏没有引用的对象,而是完成三件事:

  1. 使弱引用无效,这些弱引用用于标识没有与之关联的任何强可达引用的对象。

  2. 使用终结器搜索系统的对象列表,以查看其中是否没有与它们关联的任何高度可达的引用。

  3. 识别并合并任何对象未使用的存储区域。

请注意,GC的主要目标是#3,而在等待之前等待的时间越长,合并一个人的机会就越多。在需要立即使用存储的情况下执行#3是有意义的,但是在其他情况下,推迟存储则更有意义。


5
实际上,gc只有一个目标:模拟无限内存。您命名为目标的所有内容要么是抽象上的缺陷,要么是实现细节。
Deduplicator

@Deduplicator:弱引用提供了有用的语义,如果没有GC的帮助就无法实现。
超级猫

当然,弱引用具有有用的语义。但是,如果模拟效果更好,是否需要这些语义?
Deduplicator

@Deduplicator:是的。考虑一个集合,该集合定义更新将如何与枚举交互。这样的集合可能需要保留对任何实时枚举器的弱引用。在不受限制的内存系统中,反复迭代的集合将使其感兴趣的枚举数列表无限制地增长。该列表所需的内存不会有问题,但是遍历该列表所需的时间会降低系统性能。添加GC可以表示O(N)和O(N ^ 2)算法之间的差异。
超级猫

2
为什么要通知枚举者,而不是附加到列表并让他们在使用时自行寻找?任何依赖于及时处理垃圾的程序,而不是依赖于内存压力的程序,如果运行的话,总会处于犯罪状态。
Deduplicator

4

让我建议对您的问题进行重新措词和概括:

Java为什么不对其GC流程做出有力保证?

考虑到这一点,快速浏览此处的答案。到目前为止,有七个(不算这一个),还有很多注释线程。

那就是你的答案。

GC很难。有很多考虑因素,很多折衷方案,最终还有很多非常不同的方法。其中一些方法使在不需要对象时立即对它进行GC成为可能。其他人没有。通过保持契约松散,Java为实现者提供了更多选择。

当然,即使在这个决定中也要权衡:通过保持契约松散,Java大多数*消除了程序员依赖析构函数的能力。特别是C ++程序员经常会错过的一件事([需要引用];)),因此这并不是一个微不足道的折衷。我还没有看到有关该特定元决策的讨论,但是大概Java人士认为,拥有更多GC选项的好处胜过能够准确告知程序员何时销毁对象的好处。


*有finalize方法,但是由于各种原因超出了此答案的范围,因此很难依靠它也不是一个好主意。


3

在没有开发人员编写显式代码的情况下,有两种不同的处理内存的策略:垃圾回收和引用计数。

垃圾回收的优点是“有效”,除非开发人员做一些愚蠢的事情。通过引用计数,您可以拥有参考周期,这意味着它可以“运行”,但是开发人员有时必须很聪明。因此,这是垃圾收集的优点。

使用参考计数时,当参考计数降至零时,对象会立即消失。这是引用计数的优势。

从速度上看,如果您相信垃圾回收的支持者,那么垃圾回收会更快,而如果您相信引用计数的支持者,则引用计数会更快。

这是实现同一目标的两种不同方法,Java选择了一种方法,Objective-C选择了另一种方法(并添加了许多编译器支持,以将其从繁琐的工作变为对开发人员几乎没有用的工作)。

将Java从垃圾回收更改为引用计数将是一项艰巨的任务,因为需要进行大量代码更改。

从理论上讲,Java可以实现垃圾回收和引用计数的混合:如果引用计数为0,则对象是不可访问的,但不一定相反。因此,您可以保留引用计数,并在它们的引用计数为零时删除对象(然后不时运行垃圾回收以在无法访问的引用周期内捕获对象)。我认为,人们认为将垃圾回收添加到引用计数是一个坏主意,而那些认为将垃圾回收添加到引用计数是一个坏主意的人,这个世界是50/50。因此,这不会发生。

因此,如果Java 的引用计数变为零,则Java 可以立即删除它们,然后在无法访问的周期内删除对象。但这是设计决定,而Java则反对。


使用引用计数,最终确定是微不足道的,因为程序员需要处理周期。使用gc,周期是微不足道的,但是程序员在完成时必须小心。
Deduplicator

@Deduplicator在Java中,还可以创建对即将完成的对象的强引用...在Objective-C和Swift中,一旦引用计数为零,该对象就会消失(除非您将无限循环放入dealloc / deist中)。
gnasher729

刚注意到愚蠢的拼写检查器将deinit替换为deist ...
gnasher729

1
大多数程序员讨厌一个自动拼写更正的原因... ;-)
Deduplicator

大声笑...我认为认为在垃圾回收中添加引用计数是一个坏主意的人与认为将垃圾回收添加到引用计数是一个坏主意的人与不断计数直到垃圾收集到达为止的日子,因为那吨已经再
散发

1

尽管我认为值得一提的另一个想法是,至少有一个JVM(azul)考虑这样的事情,但所有其他性能参数和有关在不再有对象引用时的理解困难的讨论都是正确的它实现的并行gc本质上具有一个vm线程,该线程不断检查引用以尝试删除它们,这些行为与您所谈论的行为并不会完全不同。基本上,它将不断地查看堆,并尝试回收未引用的任何内存。这确实招致了非常小的性能成本,但实际上导致了GC时间为零或非常短。(那是除非不断扩大的堆大小超过系统RAM,然后Azul感到困惑,然后出现巨龙)

TLDR JVM有点类似的东西,它只是一个特殊的jvm,它具有与其他任何工程折衷一样的缺点。

免责声明:我与Azul没有关系,我们只是在上一份工作中使用过它。


1

动态压力是使持续吞吐量最大化或gc延迟最小的原因,这可能是GC不会立即发生的最常见原因。在某些系统中,例如911紧急应用程序,未达到特定的延迟阈值可以开始触发站点故障转移过程。在另一些地方,例如银行和/或套利网站,最大化吞吐量更为重要。


0

速度

为何所有这些事情最终都归因于速度。如果处理器无限快,或者(实际上)接近处理器,例如每秒1,000,000,000,000,000,000,000,000,000,000,000,000,000次操作,那么每个操作符之间可能会发生漫长而复杂的事情,例如确保删除了被取消引用的对象。由于目前每秒的操作数还不正确,并且正如大多数其他答案所解释的那样,要弄清这一点实际上很复杂且需要大量资源,因此存在垃圾回收,以便程序可以专注于程序中他们实际试图实现的目标。快速的方式。


好吧,我敢肯定,我们会找到更多有趣的方式来消耗额外的周期。
Deduplicator
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.