Java中Optional<T>的GC开销问题

13

我们都知道Java中分配的每个对象都会在未来的垃圾收集周期中增加负担,而Optional<T>对象也不例外。我们经常使用这些对象来包装可为空的值,这导致了更安全的代码,但代价是什么呢?

有没有人知道可选对象相比于简单返回空值所增加的额外GC压力以及这对高吞吐量系统性能的影响是什么?


1
也许这个问题:Java中对象的内存消耗是多少?可以帮到你。毕竟普通对象也是有内存消耗的。 - Lino
6
除了“这得看情况”之外,你还期望什么样的答案呢?它要考虑很多因素:你的 Optional 对象存活多久?它们有多经常为空?当前垃圾回收器的压力有多大?所有这些都要考虑到。 - Joachim Sauer
7
Optional.empty() 是一个单例对象。因此,在实际使用中,它的成本不比 null 更高。对于包含非空对象的 Optional 实例,尽管有一定的成本,但是这个成本非常低廉,只是该对象的一个简单包装器,其状态只包含对该对象的引用。在面向对象编程中,“对象中的对象”非常常见。对于轻量级类如 Optional 来说,这应该永远不会成为问题。 - davidxxx
1
我在任何地方都没有看到过这样的信息(鉴于Optional的广泛使用,这本身可能表明没有什么大问题)。当然,您始终可以进行自己的测量。 - Ole V.V.
2
有用的链接,@jocull,与一个密切相关的问题,谢谢。然而,这个问题特别涉及垃圾回收,而在其他问题中没有提到,也没有在其答案中提到,因此我认为称其为完全重复是不公平的。 - Ole V.V.
显示剩余3条评论
1个回答

23
我们都知道在Java中分配的每个对象都会增加未来垃圾收集周期的负担,这似乎是一个无可否认的陈述,但让我们看一看垃圾回收器的实际工作,考虑现代JVM常见实现方式以及已分配对象对其的影响,特别是像Optional实例这样通常具有临时性质的对象。
垃圾回收器的第一个任务是识别仍然存活的对象。名称"garbage collector(垃圾收集器)"强调了识别垃圾的重点,但垃圾被定义为无法访问的对象,并且发现哪些对象是不可访问的唯一方法是通过排除法的过程。因此,第一个任务通过遍历和标记所有可达对象来解决。因此,这个过程的成本不取决于已分配对象的总量,而只取决于仍然可访问的对象。第二个任务是使垃圾内存可用于新的分配。所有现代垃圾收集器都不再困扰于仍然可达对象之间的内存间隙,而是通过疏散完整区域,将该内存中的所有活动对象转移到新位置并调整对它们的引用来工作。在此过程结束后,该内存作为一个完整块可供新分配使用。因此,这又是一个过程,其成本不取决于已分配对象的总量,而只取决于(部分)仍然存活的对象。
因此,像临时的Optional这样的对象,在两次垃圾回收周期之间被分配和舍弃可能根本不会对实际的垃圾收集过程造成任何成本。
当然有一个问题。每次分配都会减少可用于后续分配的内存大小,直到没有空间剩余,必须进行垃圾收集。因此我们可以说,每次分配都会将两次垃圾收集运行之间的时间减少,其减少量为分配空间大小除以对象大小。这不仅是一个相当微小的比例,而且仅适用于单线程场景。在像热点JVM这样的实现中,每个线程都使用一个线程本地分配缓冲区(TLAB)来为新对象分配内存。一旦其TLAB已满,它将从分配空间(也称为伊甸园空间)获取一个新的TLAB。如果没有可用的TLAB,则会触发垃圾收集。现在很少有所有线程同时到达其TLAB末尾的情况。因此,对于那些此时仍留有一些TLAB空间的其他线程,如果它们分配了一些适合剩余空间的对象,它们不会有任何影响。
也许令人惊讶的结论是,并非每个分配的对象都会对垃圾收集产生影响,即由未触发下一次gc的线程分配的纯局部对象可能完全免费。
当然,这并不适用于分配大量对象。分配大量对象会导致该线程分配更多的TLAB,并且比没有分配更早地触发垃圾回收。这就是为什么我们有像IntStream这样的类,允许处理大量元素而不分配对象,这会发生在使用Stream 时,而在提供单个OptionalInt实例作为结果时没有问题。正如我们现在所知道的,单个临时对象对gc只有微小的影响,如果有的话。

这甚至没有涉及JVM的优化器,如果逃逸分析已经证明对象是纯粹本地的,则优化器可能会在热点中消除对象分配。


2
这是否意味着在某些情况下逃逸分析可以决定将某个对象分配到栈上而不是堆上,从而不会对垃圾收集造成额外负担?如果是这样,是否有一种选项可以帮助控制此类栈分配? - St.Antario
4
“逃逸分析”只是一种识别纯粹局部对象的过程。它使得后续的优化变得可能,比如锁消除和标量替换。后者会完全消除分配,而不仅是将其重定向到栈上。可以将本地对象的字段访问转换为本地变量,然后进行常规优化,消除未使用的变量或折叠包含常量或与其他变量相同值的变量(通常,字段用在作用域中的其他变量的值进行初始化),接着将大多数被使用的变量移动到CPU寄存器中。 - Holger
5
最终结果可能是,一些对象字段最终会被压入堆栈,但其内存布局不一定遵循。举个简单的例子,如果一个 typcial 的 Rectangle 实现,new Rectangle(0, 0, a, b).area() 可能会被优化为 a * b,而无需分配任何内容。逃逸分析默认开启,所以没有太多要做,但其效率可能会受到一般设置(如 -XX:MaxInlineLevel-XX:MaxInlineSize-XX:FreqInlineSize)的影响,这些设置影响了优化器的视野,从而影响对象的生命周期长度,以便仍然保持“纯本地”。 - Holger
2
垃圾回收需要一些时间来运行,当使用大量临时对象进行压力测试时,这可能会影响性能,但在大多数情况下,在典型场景(如Web应用程序)中并不重要。但是在其他领域,比如游戏开发,使用具有GC的语言时,人们通常倾向于避免每帧分配任何内存,因为在帧期间运行GC可能会对用户产生可见和烦人的影响。一个很好的例子就是Minecraft游戏;) 通过大量使用临时不可变对象来强调GC,以至于你可以感觉到它的存在。 - GotoFinal
2
@GotoFinal 这就是“一些临时对象”(例如循环的单个IteratorOptional返回类型)和“大量这样的临时对象”的区别,例如使用装箱元素的流而不是原始值。此外,“吞吐量”(总体性能)和“延迟”之间存在显着差异,这两个目标位于可用选项的不同端。通过正确调整,您可以在游戏循环中摆脱分配,但最好避免分配。 - Holger
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接