Java堆分配比C ++更快


13

我已经在SO上发布了这个问题,它的确可以。不幸的是它关闭了(只需要一票就可以重新打开),但是有人建议我将它发布在这里,因为它更合适,因此以下内容实际上是该问题的粘贴内容


我正在阅读对此答案的评论,并且看到了这句话。

对象实例化和面向对象功能使用起来非常快(在许多情况下,它们比C ++更快),因为它们是从一开始就设计的。且收集速度很快。即使在大多数优化的C代码方面,标准Java在这一领域也优于标准C / C ++。

一位用户(我可能会添加非常高的代表)大胆地捍卫了这一主张,并指出

  1. Java中的堆分配比C ++更好

  2. 并添加了该语句来捍卫Java中的集合

    与Java集合相比,Java集合与C ++集合相比速度更快,这在很大程度上归因于内存子系统的不同。

所以我的问题是,这是否真的是真的,如果是的话,为什么Java的堆分配这么快?


您可能会发现我对类似问题的答案非常有用/相关。
Daniel Pryden 2013年

1
这很简单:使用Java(或任何其他受管理的受限环境),您可以移动对象并更新指向它们的指针-即,动态优化以获得更好的缓存位置。使用C ++及其具有不受控制的位广播的指针算法,所有对象都永远固定在其位置。
SK-logic

3
我从没想过有人会说Java内存管理更快,因为它一直在复制内存。叹。
gbjbaanb

1
@gbjbaanb,您听说过内存层次结构吗?缓存未命中罚款?您是否认识到通用分配器很昂贵,而第一代分配只是一个加法运算?
SK-logic

1
尽管在某些情况下这可能是正确的,但它遗漏了以下一点:在Java中,您将所有内容分配在堆上,而在c ++中,您在堆栈上分配了很多对象,这可能会更快。
JohnB 2013年

Answers:


23

这是一个有趣的问题,答案很复杂。

总体而言,我认为可以说JVM垃圾收集器的设计非常好并且非常高效。它可能是最好的通用内存管理系统。

C ++可以通过专门用于特定目的的专用内存分配器来击败JVM 。例如:

  • 每帧内存分配器,可定期擦除整个内存区域。这些在C ++游戏中经常使用,例如,每帧使用一个临时存储区域并立即丢弃该区域。
  • 自定义分配器,用于管理固定大小的对象池
  • 基于堆栈的分配(尽管请注意,JVM在各种情况下也会执行此操作,例如通过转义分析

当然,专用内存分配器受定义的限制。它们通常对对象生命周期有限制,并且/或者对可以管理的对象类型有限制。垃圾收集更加灵活。

从性能的角度来看,垃圾回收还为您提供了一些重要的优势

  • 对象实例化确实非常快。由于在内存中顺序分配新对象的方式,通常只需要添加一个指针即可,这肯定比典型的C ++堆分配算法快。
  • 避免了生命周期管理成本的需要 -例如,从性能的角度来看,引用计数(有时用作GC的替代方法)非常差,因为频繁增加和减少引用计数会增加很多性能开销(通常比GC多得多) 。
  • 如果使用不可变对象,则可以利用结构共享来节省内存并提高缓存效率。JVM上的功能语言(例如Scala和Clojure)大量使用此功能。没有GC就很难做到这一点,因为管理共享对象的生存期非常困难。如果您像我一样相信不变性和结构共享是构建大型并发应用程序的关键,那么可以说这就是GC的最大性能优势。
  • 如果所有类型的对象及其各自的生命周期都由同一垃圾收集系统管理,则可以避免复制。与C ++相反,在C ++中,由于目标需要不同的内存管理方法或对象生命周期不同,因此您通常必须获取完整的数据副本。

Java GC的主要缺点之一是:由于垃圾收集工作被推迟并按一定的时间间隔在大量工作中完成,因此它会导致GC偶尔暂停以收集垃圾,这可能会影响延迟。对于典型的应用程序,这通常不是问题,但是在需要硬实时的情况下(例如,机器人控制),可以排除Java 。软实时(例如游戏,多媒体)通常可以。


在c ++领域中有专门的库可以解决该问题。可能最著名的例子是SmartHeap。
Tobias Langner 2013年

5
软实时并不意味着您通常可以停止。这只是意味着您可以在真正的糟糕情况下(通常是意外情况)暂停/重试,而不是停止/崩溃/失败。没有人愿意使用通常会暂停的音乐播放器。GC暂停的问题是它通常发生且无法预测。这样,即使对于软实时应用程序,GC暂停也是不可接受的。仅当用户不关心应用程序质量时,GC暂停才可接受。如今,人们已经不再那么天真了。
Eonil

1
请发布一些性能指标以支持您的主张,否则我们将比较苹果和橙子。
JBR威尔金森

1
@Demetri但是实际上,除非情况能够满足一些不切实际的约束,否则只有当案例发生过多(甚至是不可预测的!)时,这种情况才会发生。换句话说,对于任何实时情况,C ++都容易得多。
恩尼尔(Eonil)2014年

1
出于完整性考虑:GC在性能方面还有另一个缺点:由于在大多数现有的GC中,释放内存发生在可能运行在不同内核上的另一个线程中,这意味着GC在同步时会产生严重的缓存失效成本L1 / L2缓存在不同内核之间;此外,在主要是NUMA的服务器上,L3缓存也必须同步(以及通过Hypertransport / QPI,ouch(!))。
No-Bugs野兔

3

这不是科学依据。在这个问题上,我只是想一想。

一个视觉类比是:给您一个铺有地毯的公寓(一个住宅单元)。地毯很脏。什么是最快的方式(以小时为单位),以使公寓的地板闪闪发光?

答:简单地把旧地毯卷起来;丢弃; 然后铺开新地毯。

我们在这里忽略了什么?

  • 搬出现有个人物品然后搬入的费用。
    • 这被称为垃圾收集的“世界停止”成本。
  • 新地毯的成本。
    • 巧合的是,对于RAM,它是免费的。

垃圾回收是一个巨大的话题,在Programmers.SE和StackOverflow中都存在很多问题。

从侧面讲,一个称为TCMalloc的C / C ++分配管理器与对象引用计数一起在理论上能够满足任何GC系统的最佳性能要求。


实际上c ++ 11甚至有一个垃圾回收ABI,这与我在SO上得到的一些答案非常相似
aaronman

担心破坏现有的C / C ++程序(诸如Linux内核之类的代码库和诸如libtiff之类的archaic_but_still_economically_important重要的库)阻碍了C ++语言创新的进展。
rwong

有道理,我想到c ++ 17会更完整,但是事实是,一旦您真正学习了如何使用c ++编程,您甚至都不再想要它了,也许他们可以找到一种方法来结合这两种习惯用法很好
aaronman

您是否意识到有一些垃圾收集器无法阻止世界?您是否考虑过压缩(在GC方面)和堆碎片(对于通用C ++分配器)的性能影响?
SK-logic

2
我认为这种类比的主要缺陷在于,GC实际上所做的是找到脏的碎片,将其切掉,然后将剩下的碎片重新聚在一起,以创建新的地毯。
svick

3

主要原因是,当您向Java请求新的内存块时,它会直接到达堆的末尾并为您提供一个块。通过这种方式,内存分配与在堆栈上的分配一样快(这是您大多数时候在C / C ++中执行的方式,但是除此之外。)

因此分配的速度很快,但是...这还不算释放内存的成本。仅仅因为您直到很晚才释放任何东西,并不意味着它不会花费很多,就GC系统而言,其花费比“正常”堆分配要多得多-不仅GC必须遍历所有对象以查看它们是否仍然存在,然后还必须释放它们,并且(花费较大)复制内存以压缩堆-因此您可以在最后进行快速分配机制(否则您将耗尽内存,例如C / C ++将在每次分配时遍历堆,以寻找下一个可以容纳该对象的可用空间块)。

这就是Java / .NET基准测试表现出如此出色的性能,而实际应用程序却表现出如此糟糕的性能的原因之一。我只需要看看我的手机上的应用程序-的真快,反应灵敏的有所有书面使用NDK,以至于即使我很惊讶。

如果所有对象都是本地分配的,例如在一个连续的块中,那么如今的收集可能会很快。现在,在Java中,您根本就不会获得连续的块,因为从堆的自由端开始一次分配一个对象。您可以使它们愉快地连续,但只能靠运气(例如,紧随GC压缩例程及其复制对象的方式)。另一方面,C / C ++明确支持连续分配(显然是通过堆栈)。通常,C / C ++中的堆对象与Java的BTW没有什么不同。

现在,使用C / C ++,您可以得到比旨在节省内存并有效使用内存的默认分配器更好的功能。您可以用一组固定块池替换分配器,因此您始终可以找到与要分配的对象大小正好合适的块。遍历堆只是查看位图以查看空闲块在哪里的问题,而解除分配只是在该位图中重新设置位。代价是您在固定大小的块中分配时使用了更多的内存,因此,您有一个4字节块的堆,另一个为16字节块的堆,等等。


2
您似乎根本不了解GC。考虑最典型的场景-不断分配数百个小对象,但是其中只有十二个可以存活超过一秒钟。这样,释放内存绝对没有成本-这十几个是从年轻一代复制而来的(压缩后,作为一个额外的好处),其余的都被免费丢弃了。而且,顺便提一下,可悲的Dalvik GC与您在适当的JVM实现中可以找到的现代,最先进的GC没有关系。
SK-logic

1
如果这些释放的对象之一位于堆的中间,则堆的其余部分将被压缩以回收空间。还是您说除非您描述了最好的情况,否则GC压缩不会发生?我知道世代GC在这里要好得多,除非您在后世代中释放一个对象,否则这种影响可能会很大。我读过一篇由Microsoftie编写的有关GC的文章,其中描述了制作世代GC时GC的权衡问题。我将拭目以待。
gbjbaanb 2013年

1
您在说什么“堆”?大多数垃圾都是在年轻阶段被回收的,而大多数性能收益正是来自这种压缩。当然,它在函数式编程的典型内存分配配置文件(许多短寿命小对象)上最常见。而且,当然,还有许多尚未充分探索的优化机会-例如,动态区域分析可以将特定路径中的堆分配自动转换为堆栈或池分配。
SK-logic

3
我不同意您的说法,即堆分配“与堆栈一样快”-堆分配需要线程同步,而堆栈不需要(根据定义)
JBRWilkinson

1
我猜是这样,但是使用Java和.net可以理解我的意思-您不必走堆就可以找到下一个空闲块,因此在这方面它的速度明显更快,但是是的-您是对的,它必须锁定将损害线程化应用。
gbjbaanb 2013年

2

伊甸园空间

所以我的问题是,这是否真的是真的,如果是的话,为什么Java的堆分配这么快?

我一直在研究Java GC的工作方式,因为这对我来说很有趣。我一直在尝试用C和C ++扩展我的内存分配策略集合(有兴趣尝试在C中实现类似的东西),这是一种非常快速的方法,可以从内存中以突发方式分配很多对象。实用的观点,但主要是由于多线程。

Java GC分配的工作方式是使用一种非常便宜的分配策略,将对象最初分配给“ Eden”空间。据我所知,它使用的是顺序池分配器。

就算法和减少强制性页面错误而言,这比malloc使用C语言或operator newC语言中的通用语言要快得多。

但是顺序分配器有一个明显的缺点:他们可以分配大小可变的块,但不能释放任何单独的块。它们只是以填充的直接顺序方式进行对齐填充,并且只能清除一次分配的所有内存。它们通常在C和C ++中用于构造仅需要插入而无需删除元素的数据结构,例如搜索树,该搜索树仅在程序启动时需要构建一次,然后重复搜索或仅添加新键(未删除任何键)。

它们甚至还可以用于允许删除元素的数据结构,但是由于我们无法单独取消分配它们,因此实际上不会从内存中释放这些元素。这种使用顺序分配器的结构只会消耗越来越多的内存,除非它经过一些延迟的传递,即使用单独的顺序分配器将数据复制到新的压缩副本中(如果固定分配器获胜,这有时是非常有效的技术)出于某种原因而做不到-只是按顺序向上分配数据结构的新副本并转储旧结构的所有内存)。

采集

就像上面的数据结构/顺序池示例中一样,如果Java GC仅以这种方式分配,即使对于许多单个块的突发分配而言,它的超快速度也将是一个巨大的问题。在软件关闭之前,它将无法释放任何内容,此时它可以一次释放(清除)所有内存池。

因此,取而代之的是,在单个GC周期之后,遍历“ Eden”空间(顺序分配)中的现有对象,然后使用能够释放单个块的通用分配器分配仍被引用的对象。不再引用的对象将在清除过程中被简单地释放。因此,基本上就是“如果仍然引用对象,则将它们从Eden空间复制出来,然后清除”。

这通常会非常昂贵,因此它是在单独的后台线程中完成的,以避免显着停止最初分配所有内存的线程。

一旦将内存从Eden空间中复制出来并使用这种更昂贵的方案分配,该方案可以在初始GC周期后释放单个块,则对象将移至更持久的内存区域。如果这些单独的块不再被引用,则在随后的GC周期中释放它们。

速度

因此,粗略地说,Java GC在直接堆分配中可能会非常好于C或C ++的原因是因为它在请求分配内存的线程中使用了最便宜的,完全简化的分配策略。然后,它节省了我们在使用更通用的分配器(例如,malloc对另一个线程进行简化)时通常需要做的更昂贵的工作。

因此,从概念上讲,GC实际上实际上必须做更多的工作,但是它是在线程之间分配的,因此,单个线程不会预先支付全部费用。它使分配内存的线程能够以超便宜的价格进行分配,然后推迟正确执行操作所需的实际开销,以便可以将各个对象实际上释放给另一个线程。在C或C ++中,当我们malloc调用时operator new,我们必须在同一线程内预先支付全部费用。

这是主要区别,这就是为什么Java仅使用天真调用mallocoperator new单独分配一堆小块就可以胜过C或C ++的原因。当然,当GC周期开始时,通常会有一些原子操作和一些潜在的锁定,但是可能已经做了很多优化。

基本上,简单的解释可以归结为在单个线程(malloc)中支付较重的成本,而不是在单个线程中支付较便宜的成本,然后在可以并行运行的另一个线程()中支付较重的成本GC。不利的是,这种方式意味着您需要根据需要从对象引用到对象两个间接访问,以允许分配器在不使现有对象引用无效的情况下复制/移动内存,并且一旦对象内存被占用,您可能会失去空间局部性。移出“伊甸园”空间。

最后但并非最不重要的一点是,该比较有点不公平,因为C ++代码通常不会在堆上单独分配大量对象。体面的C ++代码倾向于为连续块或堆栈中的许多元素分配内存。如果它一次在免费商店中分配一小批小对象,那么代码就很糟糕。


0

这完全取决于谁来衡量速度,他们要衡量哪种实现的速度以及他们想证明什么。和他们比较。

如果只看分配/取消分配,则在C ++中,可能有1,000,000个调用malloc,有1,000,000个调用free()。在Java中,您将有1,000,000次对new()的调用,并有一个垃圾回收器在循环中运行,查找可以释放的1,000,000个对象。循环可能比free()调用快。

另一方面,malloc / free改善了其他时间,通常,malloc / free仅在单独的数据结构中设置一位,并且针对同一线程中发生的malloc / free进行了优化,因此在多线程环境中没有共享内存变量在许多情况下都使用(并且锁定或共享内存变量非常昂贵)。

第三,没有引用收集,您可能需要像引用计数这样的东西,并且这不是免费的。

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.