当Java应用程序消耗大量内存时,它本身就会出现问题,并可能导致GC压力增加和GC暂停时间过长。在我之前的一篇文章中,我讨论了Java中常见的内存浪费源:重复字符串。两个 java.lang.String
对象, a
并 b
在重复时 a != b && a.equals(b)
。换句话说,在JVM存储器中有两个(或更多)单独的字符串具有相同的内容。此问题经常发生,尤其是在业务应用程序中。在这样的应用程序中,字符串代表了许多真实世界的数据,然而,相应的数据域(例如客户名称,国家名称,产品名称)是有限的并且通常很小。根据我的经验,在未经优化的Java应用程序中,重复的字符串通常会浪费5到30%的堆。但是,你有没有想过其他类的实例,包括数组,有时也会重复,浪费了相当多的内存?如果没有,请继续阅读。
对象复制方案
只要某种类型的不同对象的数量有限,但应用程序不断创建此类对象而不尝试缓存/重用现有对象,就会发生内存中的对象复制。以下是我看到的对象复制的几个具体示例:
- 在Hadoop文件系统(HDFS)NameServer中,
byte[]
数组而不是String
s用于存储文件名。当在不同目录中存在具有相同名称的文件时,相应的byte[]
阵列是重复的。有关 详细信息,请参阅此票证。 - 在一些监视系统中,从被监视实体(机器,应用程序,组件等)接收的周期性“事件”或“更新”被表示为具有两个主要字段的小对象:时间戳和值。当许多更新同时到达并且所有更新都具有相同的值(例如,0或1表示被监视实体的健康状况良好)时,会创建许多重复对象。
- 该 蜂房数据仓库曾经有过以下问题。当针对具有大量分区的同一DB表执行多个并发查询时,每个分区的元数据的单独的每个查询副本被加载到内存中。分区元数据表示为
java.util.Properties
实例。因此,针对2000个分区运行50个并发查询,Properties
用于为这些分区中的每个分区创建50个相同的副本 ,或者总共100,000个这样的集合,这消耗了大量内存。有关详细信息,请参阅此票证。
这只是几个例子。其他不太明显的包括存储相同消息的多个相同字节缓冲区,具有表示某些频繁出现的数据组合的相同内容的多个(通常是小的)对象集,等等。
摆脱重复的对象
如上所述,字符串是一类特别容易重复的对象。很久以前JDK开发人员已经实现了这个问题,并使用String.intern()方法解决了这个问题。上面提到的文章详细讨论了它。简而言之,此方法使用具有有效弱引用的全局字符串缓存(池)。如果它还没有在缓存中,它会保存并返回给定的字符串实例,或者返回具有相同值的缓存字符串实例。String.intern()
曾经不是很好的 性能和可扩展性 从JDK 7开始大幅改进。因此,当没有过度使用时,它可能是许多应用程序的良好解决方案。然而,在讨论这篇文章在高度并发或性能关键的应用程序中,它可能成为瓶颈,可能需要一种不同的“手动”实习方法。
让我们考虑其他对象实习。请记住,以下讨论仅适用于不可变对象,即在创建后不会更改的对象。如果对象内容可以改变,消除重复变得更加困难,并且需要定制的逐案解决方案。
最广泛使用的现成实习功能由Guava库通过com.google.commmon.collect.Interners类提供。此类有两个返回内部实例的关键方法: newStrongInterner()
和 newWeakInterner()
。弱内部函数最终会释放不再需要的对象(未在任何地方强烈引用),通常使用较少的内存,因此更频繁地使用。它被实现为具有类似于标准JDK的弱键的并发哈希集 ConcurrentHashMap
。在许多情况下,这是一个很好的选择,有助于通过较小的CPU性能开销大幅减少内存占用,这通常会减少GC时间。但是,请考虑以下情况:
- 一些C类有2000万个实例,每个实例占32个字节
- 其中1000万个实例彼此完全相同,另外1000万个实例都是截然不同的。在实践中,这种尖锐的划分几乎从未发生过,但是,大约一半的物体仅表示少数独特的值,而在另一半中,大多数物体很少或没有重复,这种情况非常常见。简化的划分使我们的计算更容易。
在这种情况下,当我们不实习任何C实例时,它们使用32 * 20M = 640M字节。但是如果我们intern()
为每个人调用番石榴会发生什么 呢?
前1000万个对象将成功减少到只有一个C实例,占用的内存可以忽略不计。但是,对于剩下的1000万个对象中的每一个,都没有节省,因为它们中的每一个都是唯一的。尽管如此,Guava弱内部人员将维持一个内部表格,其中包含1000万个条目以容纳这些对象中的每一个。该表将使用com.google.common.collect.MapMakerInternalMap$WeakKeyDummyValueEntry
每个实习对象的一个类实例 ,以及引用每个条目的内部数组中的一个插槽。a的大小 WeakKeyDummyValueEntry
是40个字节,因此每个实习对象需要40 + 4 = 44个字节。44 * 10M = 440M。添加到32 * 10M = 320M,C的唯一实例仍然占用,现在总内存占用量为760M字节!换句话说,我们使用更多的内存比以前,而不是更少。
这种情况可能有点极端,但在实践中,一般规则仍然存在:如果在给定的一组对象中,唯一对象的百分比很高,那么传统的内部存储器可以节省内存,存储对每个对象的引用给予它,可能很小,如果不是负面的话。我们可以做得更好吗?
固定大小的阵列,无锁(FALF)内部
事实证明,如果我们不需要每个唯一对象的单个副本,而只是想节省内存,并且可能仍然有一些重复的对象 - 换句话说,如果我们同意“机会性地”重复删除对象 - 有一个简单而有效的解决方案。我没有在文献中看到它,所以我把它命名为“固定大小数组,无锁(FALF)Interner”。
此interner实现为一个小的,固定大小,基于开放哈希映射的对象缓存。当存在高速缓存未命中时,给定插槽中的高速缓存对象始终用新对象替换。没有锁定和没有同步,因此没有相关的开销。基本上,这个缓存是基于这样的想法:具有值X的具有许多副本的对象具有更高的机会保持在缓存中足够长的时间以保证在错过驱逐X之前自己的几个缓存命中并且用具有对象的对象替换它。一个不同的值Y.这是这个interner的一个可能的实现:
/** Fixed size array, lock free object interner */staticclassFALFInterner<T>{staticfinalintMAXIMUM_CAPACITY=1<<30;privateObject[]cache;FALFInterner(intexpectedCapacity) {cache=newObject[tableSizeFor(expectedCapacity)];}Tintern(Tobj) {intslot=hash(obj)&(cache.length-1);TcachedObj=(T)cache[slot];if(cachedObj!=null&&cachedObj.equals(obj))returncachedObj;else{cache[slot]=obj;returnobj;}}/** Copied from java.util.HashMap */staticinthash(Objectkey) {inth;return(key==null)?0: (h=key.hashCode())^(h>>>16);}/*** Returns a power of two size for the given target capacity.* Copied from java.util.HashMap.*/staticinttableSizeFor(intcap) {intn=cap-1;n|=n>>>1;n|=n>>>2;n|=n>>>4;n|=n>>>8;n|=n>>>16;return(n<0)?1: (n>=MAXIMUM_CAPACITY)?MAXIMUM_CAPACITY:n+1;}}
为了比较FALF interner和Guava weak interner的性能,我写了一个简单的多线程基准测试。代码生成并实现从遵循高斯分布的随机数派生的字符串。也就是说,具有某些值的字符串比其他字符串更频繁地生成,因此将导致更多重复。在这个基准测试中,FALF interner运行速度比Guava interner快约17%。但是,并非全部。当我测量内存占用时 jmap -histo:live
基准测试完成后运行,但在退出之前,事实证明,使用FALF interner,使用的堆大小比Guava interner小近30倍!这是固定大小的小缓存与弱散列映射的内存占用量的差异,其中每个独特对象都有一个条目。
公平地说,FALF interner通常需要比传统的,一刀切的内部调整器更多的调整。首先,由于缓存大小是固定的,您需要仔细选择它。一方面,为了最大限度地减少未命中,这个大小应该足够大 - 理想情况下等于你实习生类型的唯一对象的数量。另一方面,我们的目标是最小化已用内存,因此在实践中,您可能会选择(更大)更小的大小,该大小大致等于具有大量重复项的对象的数量。
另一个重要的考虑因素是为被拦截对象选择散列函数。在一个小的,固定大小的缓存中,尽可能均匀地在插槽中分布对象非常重要,以避免不经常使用许多插槽时的情况,而有一个小组,其中每个插槽由几个对象值争用很多副本。这种争用将导致缓存遗漏许多重复,因此,更大的内存占用。当这种情况发生在具有非常简单hashCode()
方法的类的实例时 (例如,代码类似于java.lang.String
类中的代码 ),它可能表明该散列函数实现是不合适的。更高级的哈希函数,就像com.google.common.hash.Hashing提供的哈希函数之一 类,可以大大提高FALF interner的效率。
检测重复对象
到目前为止,我们还没有讨论过开发人员如何确定应用程序中的哪些对象有很多重复项,因此需要进行实习。对于大型应用程序,这可能是非平凡的。即使您可以猜测哪些对象可能重复,也很难估计其确切的内存影响。根据经验,解决此问题的最佳方法是生成应用程序的堆转储,然后使用工具对其进行分析。
堆转储本质上是正在运行的JVM堆的完整快照。它可以通过调用jmap
实用程序在任意时刻进行 ,也可以将JVM配置为在失败时自动生成它 OutOfMemoryError
。如果你谷歌“JVM堆转储”,你会立即看到一堆关于这个主题的相关文章。
堆转储是一个大小与JVM堆大小相同的二进制文件,因此只能使用特殊工具读取和分析它。有许多这样的工具,包括开源和商业。最流行的开源工具是Eclipse MAT; 还有VisualVM和一些不那么强大,鲜为人知的工具。商业工具包括通用Java分析器:JProfiler和YourKit,以及JXRay - 专门为堆转储分析构建的工具。
与大多数其他工具不同,JXRay会立即分析堆转储以解决大量常见问题,包括重复字符串和其他对象。目前,对象比较浅薄。也就是说,只有两个对象(例如 ArrayList
s)x0, x1, x2, ...
以相同的顺序引用完全相同的对象组时才被认为是重复的 。换一种说法,两个对象 a
和 b
被认为是平等的,都指向其他对象 a
和 b
使用位是相等的,有点。
JXRay运行一次给定的堆转储,并生成一个包含HTML格式的所有收集信息的报告。这种方法的优点是,您可以随时随地查看分析结果,并轻松与他人分享。这也意味着您可以在任何机器上运行该工具,包括数据中心中功能强大但功能强大的“无头”机器。
从JXRay获得报告后,在您喜欢的浏览器中打开它并展开相关部分。你可能会看到这样的事情:
因此,在此转储中,24.7%的已使用堆被重复的非数组非集合对象浪费!上表列出了其实例对此开销贡献最大的所有类。要查看这些对象的来源(哪些对象引用它们,一直到GC根),向下滚动到报告的“昂贵数据字段”或“完整参考链”子部分,展开它,然后单击相关表中的一行。以下是上述类之一的示例 TopicPartition
:
从这里,我们可以很好地了解哪些数据结构可以管理有问题的对象。
总而言之,重复对象,即具有相同内容的同一类的多个实例,可能成为Java应用程序的负担。它们可能会浪费大量内存和/或增加GC压力。衡量此类对象影响的最佳方法是获取堆转储并使用JXRay之类的工具对其进行分析。当重复对象是不可变的时,您可以使用现成的或自定义的内部实现来减少此类对象的数量,从而减少其内存影响。可变复制对象更难以摆脱,并且可能只能通过逐个定制的解决方案来消除。