创建数百万个小型临时对象的最佳实践

112

创建(和释放)数百万个小对象的最佳实践是什么?

我正在使用Java编写一个象棋程序,搜索算法为每个可能的移动生成单个“Move”对象,而标准搜索可以轻松生成每秒超过一百万个移动对象。 JVM GC已能够处理开发系统上的负载,但我有兴趣探索替代方法,以便:

  1. 最小化垃圾回收的开销,以及
  2. 减少较低端系统的峰值内存占用。

绝大多数对象的寿命非常短,但是生成的移动中约有1%会被持久化并返回作为持久化值,因此任何缓存技术都必须提供排除特定对象不被重用的能力。

我不希望完全详细的示例代码,但我希望获得进一步阅读/研究建议或具有类似性质的开源示例。


11
轻量级模式适用于您的情况吗?http://en.wikipedia.org/wiki/Flyweight_pattern - Roger Rowland
4
你需要将它封装在一个对象中吗? - nhahtdh
1
享元模式不适用,因为对象没有共享重要的公共数据。至于将数据封装在对象中,它太大了,无法打包成原始对象,这就是为什么我正在寻找POJO的替代方案。 - Humble Programmer
2
强烈推荐阅读:http://www.cs.virginia.edu/kim/publicity/pldi09tutorials/memory-efficient-java-tutorial.pdf - rkj
13个回答

47

使用详细垃圾回收运行应用程序:

java -verbose:gc

它会告诉你何时进行收集。有两种扫描类型,快速扫描和完整扫描。

[GC 325407K->83000K(776768K), 0.2300771 secs]
[GC 325816K->83372K(776768K), 0.2454258 secs]
[Full GC 267628K->83769K(776768K), 1.8479984 secs]

箭头在前后大小之间。

只要进行的是垃圾回收(GC)而不是完整的GC,您就安全了。常规GC是“年轻代”中的复制收集器,因此不再引用的对象只是被遗忘,这正是您想要的。

阅读Java SE 6 HotSpot虚拟机垃圾收集调整可能会有所帮助。


尝试调整Java堆大小,找到一个几乎不需要进行完全垃圾回收的点。在Java 7中,新的G1 GC在某些情况下更快(在其他情况下则更慢)。 - Michael Shopsin

21
自从6版本起,JVM的服务器模式采用了逃逸分析技术。使用它可以完全避免GC。

1
逃逸分析有时会让人失望,值得检查JVM是否已经弄清楚你在做什么。 - Nitsan Wakart
2
如果您有使用这些选项的经验:-XX:+PrintEscapeAnalysis和-XX:+PrintEliminateAllocations。那么分享一下就太好了。因为我没有,说实话。 - Mikhail
请参考 https://dev59.com/fGox5IYBdhLWcg3wsGWC,你需要获取 JDK 7 的调试构建版本,我承认我没有做过这个,但在 JDK 6 中已经成功了。 - Nitsan Wakart

19

嗯,这里有几个问题!

1 - 如何管理短寿命的对象?

正如之前所述,JVM可以完美地处理大量的短生命周期对象,因为它遵循弱代假说

请注意,我们所说的是已经进入主内存(堆)的对象。这并不总是这种情况。你创建的很多对象甚至不会离开CPU寄存器。例如,考虑以下for循环:

for(int i=0, i<max, i++) {
  // stuff that implies i
}

不需要考虑循环展开(JVM对您的代码进行的优化)。如果max等于Integer.MAX_VALUE,则您的循环可能需要一些时间才能执行。但是i变量永远不会逃出循环块。因此,JVM将在CPU寄存器中放置该变量,并定期递增它,但永远不会将其发送回主内存。

因此,如果创建的数百万个对象仅在本地使用,则并不是什么大问题。它们将在存储在Eden之前被释放,因此GC甚至不会注意到它们。

2-减少GC开销是否有用?

像往常一样,这要看情况。

首先,您应该启用GC日志记录以清楚地了解发生了什么。您可以使用-Xloggc:gc.log -XX:+PrintGCDetails启用它。

如果您的应用程序在GC周期中花费了大量时间,那么请调整GC,否则可能不值得。

例如,如果您每100ms进行一次Young GC,而它需要10ms,那么您就花费了10%的时间在GC上,并且您每秒有10次GC(这非常巨大)。在这种情况下,我不会花费任何时间进行GC调整,因为这10个GC / s仍然存在。

3-一些经验

我在一个应用程序上遇到了类似的问题,该应用程序创建了大量给定类的对象。在GC日志中,我注意到应用程序的创建率约为3 GB / s,这太多了(来吧...每秒3千兆字节的数据?!)。

问题:由于创建了太多的对象而导致太多频繁的GC。

在我的情况下,我附加了一个内存分析器并注意到一个类代表了所有对象的巨大百分比。我追踪了实例化,以发现该类基本上是一个包裹在对象中的布尔值对。在这种情况下,有两个解决方案:

  • 重新设计算法,使我不返回布尔值对,而是有两种方法分别返回每个布尔值

  • 缓存对象,知道只有4个不同的实例

我选择了第二个,因为它对应用程序的影响最小,并且很容易引入。我花了几分钟时间放置了一个具有非线程安全缓存的工厂(我不需要线程安全,因为最终只会有4个不同的实例)。

分配率下降到1 GB / s,年轻GC的频率也相应降低(减少了3倍)。

希望这有所帮助!


11
如果你只有值对象(即没有其他对象的引用),并且有大量大量这样的对象,你可以使用具有本地字节顺序的直接ByteBuffers,这是很重要的,你需要几百行代码来分配/重用+ getter / setter。getter看起来类似于long getQuantity(int tupleIndex){return buffer.getLong(tupleInex+QUANTITY_OFFSSET);} 如果你只分配一次,也就是说,分配一个巨大的块,然后自己管理对象,那么这将几乎完全解决GC问题。ByteBuffer中只有索引(即int),而不是引用,必须传递它们。您可能还需要自己进行内存对齐。
这种技术会感觉像使用C和void*,但通过一些包装,它是可承受的。性能的缺点可能是边界检查,如果编译器无法消除它。一个主要的优势是局部性,如果您像向量一样处理元组,则对象头的缺乏也会减少内存占用。
除此之外,如果所有JVM的年轻代都可以轻松死亡,并且分配成本仅为指针跳动,那么您可能不需要这种方法。如果您使用final字段,分配成本可能会更高,因为它们在一些平台上需要内存屏障(即ARM / Power),在x86上是免费的。

8
假设您发现GC是一个问题(正如其他人指出的可能不是),那么您将为您的特殊情况实现自己的内存管理,即遭受大量翻转的类。尝试一下对象池,我看到过它很有效的案例。实现对象池是一条经过验证的道路,因此无需在此处重新访问,注意以下几点:
  • 多线程:使用线程本地池可能适用于您的情况
  • 支持数据结构:考虑使用ArrayDeque,因为它在删除时表现良好并且没有分配开销
  • 限制池的大小 :)

测量前/后等等


6

我遇到了类似的问题。首先,尝试减小小对象的大小。我们引入了一些默认字段值,在每个对象实例中引用它们。

例如,MouseEvent 引用 Point 类。我们缓存 Points 并引用它们,而不是创建新的实例。对于空字符串也是同样的处理。

另一个问题是多个布尔变量,这些变量被替换为一个整数,并且对于每个布尔变量,我们只使用整数的一个字节。


只是出于兴趣问一下:这个改变在性能方面给你带来了什么?在更改之前和之后,您是否对应用程序进行了分析,如果是,结果是什么? - Axel
@Axel 对象使用的内存更少,因此垃圾回收不会经常调用。我们确实对应用程序进行了分析,但甚至还有改进速度的视觉效果。 - StanislavL

6

我曾经用一些XML处理代码来处理这种情况。我发现自己创建了数百万个XML标记对象,它们非常小(通常只是一个字符串),并且非常短暂(XPath检查失败意味着不匹配,所以需要丢弃)。

我进行了一些严格的测试,并得出结论:使用已丢弃标记的列表而不是创建新标记,我只能实现大约7%的速度提升。然而,一旦实施,我发现如果空闲队列变得太大,就需要添加一种机制来修剪它 - 这完全抵消了我的优化,所以我将其切换为选项。

总之 - 可能不值得,但我很高兴看到你在考虑它,这表明你关心。


2
鉴于您正在编写国际象棋程序,有一些特殊的技术可以用于获得良好的性能。一个简单的方法是创建一个大型的长整型数组(或字节数组),将其视为堆栈。每次移动生成器创建移动时,它会将一对数字推送到堆栈上,例如从方格移动和移动到方格。在评估搜索树时,您将弹出移动并更新棋盘表示。
如果您想要表现力,使用对象。如果您想要速度(在这种情况下),请使用本地方式。

1

链接失效了。那篇文章还有其他来源吗? - dnault

0

我不是GC的忠实拥护者,所以我总是尝试找到绕过它的方法。在这种情况下,我建议使用对象池模式

这个想法是通过将对象存储在堆栈中来避免创建新对象,以便稍后可以重用它们。

Class MyPool
{
   LinkedList<Objects> stack;

   Object getObject(); // takes from stack, if it's empty creates new one
   Object returnObject(); // adds to stack
}

3
使用池来管理小对象是一个不太好的主意,你需要为每个线程创建一个池(否则共享访问会降低性能)。这种池比一个好的垃圾回收器表现更差。最后,当处理并发代码/结构时,垃圾回收器是救星——许多算法实现起来更容易,因为自然地不存在ABA问题。在并发环境中使用引用计数至少需要原子操作+内存屏障(在x86上是LOCK ADD或CAS)。 - bestsss
1
对象池中的对象管理可能比让垃圾回收器运行更昂贵。 - Thorbjørn Ravn Andersen
@ThorbjørnRavnAndersen 一般来说我同意你的观点,但是要注意到检测这种差异是非常具有挑战性的。当你得出结论GC在你的情况下运行更好时,如果这种差异很重要,那么它必须是一个非常独特的案例。然而反过来,对象池可能会拯救你的应用程序。 - Ilya Gazman
1
我简直不明白你的论点?很难检测出GC是否比对象池更快?因此,你应该使用对象池?JVM针对干净的编码和短寿命对象进行了优化。如果这些是问题的关键(我希望如果OP每秒生成一百万个),那么只有在有可证明的优势可以切换到更复杂和容易出错的方案时,才应该采用你所建议的方案。如果这太难证明,那为什么要费心呢。 - Thorbjørn Ravn Andersen

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