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



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

我正在用Java编写一个国际象棋程序,搜索算法为每个可能的移动生成一个"Move"对象,一个名义搜索可以很容易地每秒生成超过一百万个移动对象。JVM GC已经能够处理我的开发系统上的负载,但我对探索替代方法感兴趣,这些方法将:

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

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

我不期望有完整的示例代码,但我希望得到进一步阅读/研究的建议,或者类似性质的开源示例。

运行带有详细垃圾收集的应用程序:

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虚拟机垃圾收集调优可能会有所帮助。

从版本6开始,JVM的服务器模式采用了转义分析技术。使用它可以完全避免GC。

好吧,这里有几个问题!

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,否则,它可能不值得。

例如,如果您每100毫秒有一个年轻的GC,花费10毫秒,那么您将花费10%的时间在GC上,并且您每秒有10个收集(这是非常大的)。在这种情况下,我不会花任何时间在GC调优上,因为那10 GC/s仍然存在。

3 -一些经验

我在一个创建了大量给定类的应用程序上遇到了类似的问题。在GC日志中,我注意到应用程序的创建速率大约是3gb/s,这实在是太大了。每秒3gb的数据?!)。

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

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

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

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

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

分配率下降到1 GB/s,年轻GC的频率也下降了(除以3)。

如果你只有值对象(也就是说,没有对其他对象的引用),而且真的,但我的意思是真的有很多很多,你可以使用直接的ByteBuffers和本地字节排序[后者很重要],你需要几百行代码来分配/重用+ getter/setter。getter看起来类似于long getQuantity(int tupleIndex){return buffer.getLong(tupleInex+QUANTITY_OFFSSET);}

这将几乎完全解决GC问题,只要你只分配一次,即一个巨大的块,然后自己管理对象。而不是引用,您只有索引(即int)到必须传递的ByteBuffer。你可能也需要自己做内存对齐。

该技术感觉像使用C and void*,但通过一些包装它是可以忍受的。如果编译器无法消除边界检查,则可能会导致性能下降。如果你像处理向量一样处理元组,一个主要的好处是局部性,缺少对象头也减少了内存占用。

除此之外,您很可能不需要这样的方法,因为几乎所有JVM的年轻一代都很容易死亡,分配成本只是一个指针凸起。如果您使用final字段,则分配成本可能会高一点,因为它们在某些平台(即ARM/Power)上需要内存围栏,但在x86上是免费的。

假设您发现GC是一个问题(正如其他人指出的那样,它可能不是),您将为您的特殊情况实现自己的内存管理,即遭受大量流失的类。尝试一下对象池,我看到过它工作得很好的例子。实现对象池是一条很好的路径,所以没有必要重新访问这里,注意:

  • 多线程:使用线程本地池可能适合你的情况
  • 备份数据结构:考虑使用ArrayDeque,因为它在删除时表现良好,并且没有分配开销
  • 限制池的大小:)

测量前后等

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

例如,MouseEvent有一个Point类的引用。我们缓存点并引用它们,而不是创建新的实例。对于空字符串也是一样。

另一个来源是多个布尔值,它们被替换为一个整型,对于每个布尔值,我们只使用整型的一个字节。

我在前段时间用一些XML处理代码处理了这个场景。我发现自己创建了数百万个非常小(通常只是一个字符串)且寿命极短(XPath检查失败意味着不匹配,因此丢弃)的XML标记对象。

我做了一些认真的测试,得出的结论是,使用丢弃的标签列表而不是创建新的标签,我只能在速度上提高7%左右。然而,一旦实现,我发现空闲队列需要添加一个机制来修剪它,如果它变得太大——这完全使我的优化无效,所以我把它切换到一个选项。

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

假设您正在编写一个国际象棋程序,那么您可以使用一些特殊的技术来获得良好的性能。一种简单的方法是创建一个大的长(或字节)数组,并将其视为堆栈。每次你的移动生成器创建移动时,它都会向堆栈中推送一些数字,例如从一个方块移动到另一个方块。当你评估搜索树时,你将弹出移动并更新棋盘表示。

如果你想要表达能力,使用对象。如果你想要速度(在这种情况下),那就顺其自然吧。

对于这种搜索算法,我使用的一种解决方案是只创建一个Move对象,用新的Move对其进行修改,然后在离开作用域之前撤消该Move。你可能一次只分析一个走法,然后把最好的走法存储在某个地方。

如果由于某些原因这是不可行的,并且您希望减少内存峰值使用,这里有一篇关于内存效率的好文章:http://www.cs.virginia.edu/kim/publicity/pldi09tutorials/memory-efficient-java-tutorial.pdf

只要创建数百万个对象并以适当的方式编写代码:不要保留对这些对象的不必要引用。GC将为您做一些繁琐的工作。您可以使用前面提到的详细GC,看看它们是否真的被GC了。Java是关于创建和释放对象的。:)

我认为您应该阅读Java中的堆栈分配和转义分析。

因为如果您深入了解这个主题,您可能会发现您的对象甚至没有在堆上分配,并且它们不像堆上的对象那样被GC收集。

维基百科上有对转义分析的解释,并给出了在Java中如何工作的示例:

http://en.wikipedia.org/wiki/Escape_analysis

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

这个想法是为了避免创建新的对象,将它们存储在堆栈中,以便以后重用。

Class MyPool
{
   LinkedList<Objects> stack;
   Object getObject(); // takes from stack, if it's empty creates new one
   Object returnObject(); // adds to stack
}

与堆上的对象分配相比,对象池提供了巨大的(有时是10倍)改进。但是上面使用链表的实现既幼稚又错误!链表创建对象来管理其内部结构,从而使工作无效。使用对象数组的环形缓冲区工作得很好。在给出的例子(一个管理走法的国际象棋程序)中,Ringbuffer应该被包装成一个包含所有计算走法列表的持有者对象。只有移动holder对象引用才会被传递。

最新更新