减少内存抖动的方法

10

背景

我有一个 Spring Batch 程序,它读取一个文件(我正在处理的示例文件大小约为 4 GB),对文件进行少量处理,然后将其写入 Oracle 数据库。

我的程序使用 1 个线程来读取文件,并使用 12 个工作线程来进行处理和数据库推送。

我正在大量使用 young gen 内存,这导致我的程序比我认为的应该慢。

设置

JDK 1.6.18
Spring Batch 2.1.x
4 核机器,16 GB RAM

-Xmx12G 
-Xms12G 
-NewRatio=1 
-XX:+UseParallelGC
-XX:+UseParallelOldGC

问题

使用这些JVM参数,我的老年代大约可以获得5.x GB的内存,年轻代大约也可以获得5.x GB左右的内存。

在处理这个文件时,我的老年代很好。它最多增长到3 GB,我不需要进行一次完整的GC。

然而,年轻代会达到它的最大值很多次。它会增长到5 GB左右,然后发生并行的小型GC,并将年轻代清除至500MB已用状态。小型GC比完整GC更好,但仍会使我的程序变慢(我相信当年轻代垃圾回收发生时,应用程序仍会冻结,因为我看到数据库活动停止)。我花费了超过5%的时间在小型GC上被冻结,这似乎有点过度。我会说在处理这个4GB文件的过程中,我耗费了50-60GB的年轻代内存

我没有看到程序中明显的缺陷。我试图遵守通用的OO原则并编写干净的Java代码。我尽量避免无意义地创建对象。我正在使用线程池,并在可能的情况下传递对象而不是创建新对象。我将开始对应用程序进行性能分析,但我想知道是否有一些好的通用规则或反模式可以避免导致过度的内存使用?处理4GB文件需要50-60GB的内存使用是最好的结果吗?我必须回到像对象池这样的JDK 1.2技巧吗?(尽管Brian Goetz展示了为什么对象池是愚蠢的,并且我们不再需要它。我相信他比我自己更可靠.. :))


能否提供一个小的自包含的代码示例来展示这个翻转的过程? - President James K. Polk
我没有它..但它似乎相当“标准”。从文件中读取一行,将其存储为字符串并放入列表中。当该列表有1000个这些字符串时,将其放入队列中以供工作线程读取。让工作线程创建一个域对象,从字符串中获取一堆值来设置字段(int、long、java.util.Date或String),并将域对象传递给默认的Spring批处理JDBC写入器(请参见http://static.springsource.org/spring-batch/apidocs/org/springframework/batch/item/database/JdbcBatchItemWriter.html)。 - bwawok
7个回答

9
我感觉你正在花费时间和精力去优化一些不必要的东西。
引用部分: 我花费超过5%的程序时间在小GC上冻结,这似乎太多了。
反过来想。你花费了将近95%的程序时间用于有用的工作。或者换句话说,即使你设法将GC优化为零时间运行,最好的结果也只能获得超过5%的改进。
如果您的应用程序具有由暂停时间影响的硬定时要求,则可以考虑使用低暂停收集器。(请注意,减少暂停时间会增加总体GC开销...)但对于批处理作业,GC暂停时间不应该相关。
最重要的可能是整个批处理作业的挂钟时间。而(大约)95%的时间用于执行应用程序特定的任务,这里是您可能会得到更多分析/针对性优化工作回报的地方。例如,您是否考虑过批量更新发送到数据库的内容?
分割线:
引用部分: 那么..90%的总内存在“oracle.sql.converter.toOracleStringWithReplacement”的char[]中。
这通常表明,在向数据库发送准备好的东西时,您的大多数内存使用情况出现在Oracle JDBC驱动程序中。对此无能为力,因为这是不可避免的开销。

Spring Batch已经为我做了批处理。我知道这不是让程序100%更快的全部解决方案...但在GC上花费的时间超过5%,甚至可能达到6%或7%。墙钟时间可以更好,这会帮助我... - bwawok

3
如果您能明确“年轻”和“终身教职”的术语,那将非常有用,因为Java 6具有略有不同的GC模型:Eden,S0 + S1,Old,Perm。
您是否尝试了不同的垃圾收集算法?“UseConcMarkSweepGC”或“UseParNewGC”的表现如何?
请勿忘记简单地增加可用空间并非解决方案,因为gc运行时间更长,应将大小减小到正常值;)
您确定没有内存泄漏吗?在您描述的生产者-消费者模式中,很少会有数据位于旧代,因为这些作业处理非常快,然后被“丢弃”,或者您的工作队列正在填充吗?
您应该使用内存分析器来观察程序。

我不会在这里使用UseConcMarkSweepGC,因为对于批处理来说响应时间并不重要(请参见http://java.sun.com/javase/technologies/hotspot/gc/gc_tuning_6.html#available_collectors.selecting)。无论如何,我认为问题不在于GC算法。 - Pascal Thivent
我尝试了标记清除,但性能下降了约10%。我同意它不适用于批处理。 - bwawok
我使用young和tenured这些术语来指代jmap-heap返回给我的新旧对象。我可能在某个地方搞错了术语。我有16GB的内存可用,如果我可以从2GB的内存增加到12GB并获得5-10%的速度提升,那么这是非常值得的。我不确定为什么要降低内存。我愿意用10个缓慢的GC来换取100个快速的GC...但在GC中花费相同的时间。我认为我需要减少church而不是我的newgen大小来提高速度... - bwawok
关于内存泄漏问题。可能是,但我不认为这是导致我的问题的原因。在批处理之前,我缓存了1-2 GB的数据,所以3-3.5 GB的旧代空间对我来说不是问题。我的工作队列正在填充,但它受到java.util.concurrent.BlockingQueue的限制,因此我确保在任何给定时间点上,内存中的文件不超过大约10%。 - bwawok

2
我认为使用内存分析器的会话能够让这个问题变得更加清晰。这将给出一个很好的概述,展示创建了多少对象,从而揭示一些问题。
我总是惊讶于有多少字符串被生成。
对于领域对象,它们之间的交叉引用也是具有启示性的。如果您看到从派生对象中创建的对象数量突然增加了三倍以上,则可能存在问题。
Netbeans内置了一个很好的内存分析器。我以前使用过JProfiler。我认为如果您在Eclipse上投入足够的时间,您也可以从PPTP工具中获得相同的信息。

1
jvisualvm(可用于此问题;它是Java6)有助于识别这些问题吗? - Donal Fellows
好主意,我会尝试使用NetBeans Profiler和JVisualVM。我是一个Eclipse用户,但在PPTP方面从未有过太多的运气。 - bwawok
所以,我的总内存的90%都在“oracle.sql.converter.toOracleStringWithReplacement”中的char []中。这就缩小了范围,但不确定如何进一步缩小范围,或者像享元模式这样的东西是否会减少内存。 - bwawok

2
您需要对应用程序进行分析,以了解发生了什么。我建议首先尝试使用JVM的人体工程学特性,如下所示:

2. 人体工程学

J2SE 5.0引入了一项称为人体工程学的功能。 人体工程学的目标是在JVM启动时选择

  • 垃圾收集器,
  • 堆大小,
  • 和运行时编译器
而不是使用固定的默认值,以提供良好的性能而无需调整命令行选项。这种选择假定运行应用程序的机器类是应用程序特征的提示(即,在大型机器上运行大型应用程序)。除了这些选择外,还有一种简化的垃圾回收调优方式。使用并行收集器,用户可以为应用程序指定最大暂停时间和期望吞吐量的目标。这与指定需要良好性能的堆的大小形成对比。这旨在特别改善使用大堆的大型应用程序的性能。更通用的人体工程学在名为“5.0 Java虚拟机中的人体工程学”的文档中描述。 建议在使用本文档中解释的更详细的控件之前尝试使用此后者文档中介绍的人体工程学

此文档包括作为自适应大小策略的一部分提供的人体工程学特性。这包括指定垃圾回收性能目标的选项和进一步微调该性能的其他选项。

有关Java SE 6 HotSpot[tm] Virtual Machine Garbage Collection Tuning指南中更详细的人体工程学部分,请参阅。


好主意,我会给人体工程学一个选择,并将结果与我已有的进行比较。然而,我知道默认情况下它从非常小的堆开始,并执行垃圾回收、垃圾回收、增加堆、垃圾回收、垃圾回收、增加堆、垃圾回收、垃圾回收、增加堆...这完全是糟糕的。我认为通过在所需大小处启动XMS和XMX,我缩短了运行时间。 - bwawok
@bwawok:我并不是想说“不要覆盖-Xms-Xmx”。 - Pascal Thivent

1
在我看来,年轻一代不应该和老一代一样大,这样小的垃圾收集才能保持快速。
如果您有许多表示相同值的对象,可以使用简单的HashMap合并这些重复的对象:
public class MemorySavingUtils {

    ConcurrentHashMap<String, String> knownStrings = new ConcurrentHashMap<String, String>();

    public String unique(String s) {
        return knownStrings.putIfAbsent(s, s);
    }

    public void clear() {
        knownStrings.clear();
    }
}

使用Sun Hotspot编译器时,对于大量字符串,本地String.intern()非常缓慢,因此我建议您构建自己的字符串内部化程序。

使用此方法,可以重用旧代中的字符串,并且新代中的字符串可以快速进行垃圾回收。


只有在字符串重复出现,特别是在批处理中才值得使用。否则你并没有帮助到程序。 (而且除非你知道在处理的具体情况下使用String.intern是有用的;因为字符串池是一种优化手段...) - Donal Fellows
我尝试了新的比率-2(默认值),以及4和6。但是都没有起到帮助作用。垃圾收集器的速度略有提升,但发生的频率更高。每个大小为5GB的GC大约需要进行10次,与每个大小为500MB的GC进行100次相比,时间几乎相同(我认为较大的GC可能会稍微快一些)。 - bwawok
  1. 没有字符串应该是重复的,或者至少不是很多。我知道文件中有几个部分是三个可能选择之一...我可以在这些部分上具体执行一个 intern。不确定这是否是微优化。不担心一些琐碎的东西,只是我的数据集的10倍数量。
- bwawok

1
从文件中读取一行,存储为字符串并放入列表中。当该列表有1000个这样的字符串时,将其放入队列中以供工作线程读取。让工作线程创建一个域对象,从字符串中获取一堆值来设置字段(int、long、java.util.Date或String),然后将域对象传递给默认的Spring批处理JDBC写入器。
如果这是您的程序,为什么不将内存大小设置得更小,例如256MB?

a) 我预加载了一个哈希映射的数据,大约有1-2 GB的数据(因此存储在旧代中)。 b) 我有很多内存和16个线程,这个程序可以在整个服务器上运行,不用担心“浪费”内存。 - bwawok
仅因为服务器上没有其他进程在运行并不意味着您的程序应该分配所有内存。您应该只给它所需的内存,以及一些额外的内存用于意外情况。这样,垃圾收集器就不必保留对象的时间比必要的时间更长。 - Roland Illig
人们说,在几个GB之外的堆上,GC的性能非常糟糕。我不明白为什么 - GC只处理活对象,那么有多少死对象有什么关系 - 但这就是人们所说的。 - irreputable
@irreputable - 这是因为遍历非垃圾对象以确定垃圾所在的成本很高。当堆已满时,有许多非垃圾对象需要遍历。(并且在标记阶段,弱引用对象被视为非垃圾对象。) - Stephen C

1
我猜测,由于内存限制如此之高,您在处理之前必须将整个文件完全读入内存。您是否考虑使用java.io.RandomAccessFile呢?

实际上我并不是。为了避免“浪费”内存,我使用了java.util.concurrent.BlockingQueue。我只保留足够的文件读取内容以使所有工作线程保持繁忙状态,但我从未在同一时间将超过约10%的文件保存在内存中。理论上,我将扩展到更大的文件,范围在10-30GB之间,而且肯定无法将所有内容都保存在内存中。 - bwawok

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