JDK集合是使用标准库的实现List和Map。如果您查看一个典型的大型Java应用程序的内存快照,您将看到数以千计甚至数百万个Java .util.ArrayList,java.util.HashMap的实例。集合对于内存中的数据存储和操作是必不可少的。但你有没有想过你的应用程序中的所有集合是否都以最佳方式使用内存?换句话说:如果您的Java应用程序发生了臭名昭著的OutOfMemoryError内存溢出,或者经历了长时间的GC暂停,那么您是否检查了它的集合中是否存在内存浪费?如果你的回答是“不”或“不确定”,那就继续读下去。
首先,要注意JDK集合的内部结构并不是什么不可思议的。它们是用Java编写的。它们的源代码附带JDK,所以您可以在IDE中打开它。它也很容易在网上找到。而且,事实证明,在进行优化内存占用时,大多数集合并不十分复杂。
例如,考虑一个最简单和最流行的集合类:java.util.ArrayList。在内部,每个ArrayList都维护一个对象[]elementData数组。这就是存储列表元素的地方。让我们看看这个数组是如何管理的。
当您使用默认构造函数创建ArrayList时,elementData被设置为指向一个单例共享的零大小数组(elementData也可以设置为null,但是单例数组提供了一些较小的实现优势)。一旦将第一个元素添加到列表中,就会创建一个真正的、惟一的elementData数组,并将提供的对象插入其中。为了避免在每次添加新元素时调整数组的大小,它的创建长度为10(“默认容量”)。这里有一个问题:如果您不向这个ArrayList添加更多元素,那么elementData数组中的10个插槽中的9个将保持空。即使您稍后清除这个列表,内部数组也不会收缩。下图总结了这个生命周期:
这里浪费了多少内存?在绝对条件下,它被计算为(对象指针大小)* 9。如果您使用HotSpot JVM(附带了Oracle JDK),那么指针大小取决于最大堆大小。通常,如果指定-Xmx小于32g,则指针大小为4字节;对于较大的堆,它是8字节。因此,使用默认构造函数初始化的ArrayList只添加了一个元素,浪费了36或72个字节。
事实上,一个空的ArrayList也会浪费内存,因为它没有用,但是ArrayList对象本身的大小是非零的,并且比您想象的要大。这是因为,首先,HotSpot JVM管理的每个对象都有一个12字节或16字节的头,JVM用于内部目的。接下来,大多数集合对象包含size字段、指向内部数组的指针或另一个“有用的”对象、跟踪内容修改的modCount字段等。因此,即使是表示空集合的最小的对象也可能需要至少32字节的内存。有些,如ConcurrentHashMap,需要更多。
考虑另一个普遍存在的集合类:java.util.HashMap。其生命周期与ArrayList相似,
总结如下:
如您所见,一个只包含一个键值对的HashMap会浪费15个内部数组槽,也就是60或120个字节。这些数字很小,但重要的是你的应用程序中所有的集合丢失了多少内存。并且证明了一些应用可以以这种方式浪费许多。例如,作者分析的几个流行的开源Hadoop组件在某些场景中丢失了大约20%的堆!对于没有经验的工程师开发的产品,并且没有定期检查性能,内存浪费可能会更高。有足够多的用例,例如,大型树中90%的节点只包含一到两个子节点(或者根本不包含子节点),以及堆中充满0、1或2元素集合的其他情况。
如果在应用程序中发现未使用或未充分利用的集合,如何修复它们?以下是一些常用的方法。在这里,我们有问题的集合被假定为一个由Foo引用的ArrayList。数据字段列表。
如果清单的大多数实例从未使用过,请考虑延迟初始化它。
因此,以前看起来像……
void addToList(Object x) {
list.add(x);
}
…应该重构为:
void addToList(Object x) {
getOrCreateList().add(x);
}
private list getOrCreateList() {
// To conserve memory, we don't create the list until the first use
if (list == null) list = new ArrayList();
return list;
}
请记住,您有时需要采取额外的措施来解决可能的并发问题。例如,如果您维护一个并发地由多个线程更新的ConcurrentHashMap,那么延迟初始化它的代码不应该允许两个线程意外地创建这个map的两个副本:
private Map getOrCreateMap() {
if (map == null) {
// Make sure we aren't outpaced by another thread
synchronized (this) {
if (map == null) map = new ConcurrentHashMap();
}
}
return map;
}
如果列表或映射的大多数实例只包含少数元素,考虑使用更合适的初始容量初始化它们,例如。
list = new ArrayList(4); // Internal array will start with length 4
如果您的集合在大多数情况下要么是空的,要么只包含一个元素(或键-值对),那么您可以考虑一种极端的优化形式。只有当集合在给定的类中被完全管理时,它才会起作用,也就是说,其他代码不能直接访问它。其思想是您将数据字段的类型从List更改为一个更通用的对象,以便它现在可以指向一个真正的List,或者直接指向惟一的List元素。这里有一个简单的草图:
// *** Old code ***
private List<Foo> list = new ArrayList<>();
void addToList(Foo foo) { list.add(foo); }
// *** New code ***
// If list is empty, this is null. If list contains only one element,
// this points directly to that element. Otherwise, it points to a
// real ArrayList object.
private Object listOrSingleEl;
void addToList(Foo foo) {
if (listOrSingleEl == null) { // Empty list
listOrSingleEl = foo;
} else if (listOrSingleEl instanceof Foo) { // Single-element
Foo firstEl = (Foo) listOrSingleEl;
ArrayList<Foo> list = new ArrayList<>();
listOrSingleEl = list;
list.add(firstEl);
list.add(foo);
} else { // Real, multiple-element list
((ArrayList<Foo>) listOrSingleEl).add(foo);
}
}
显然,这样的优化会降低代码的可读性,并且更难维护。但是,如果您知道您将以这种方式节省大量内存,或者消除长时间GC暂停,这可能是值得的。
这可能已经让你想到:我如何知道在我的应用程序浪费内存中哪些集合,以及多少?
简单的答案是:如果没有合适的工具,这是很难发现的。试图猜测大型、复杂应用程序中数据结构使用或浪费的内存数量几乎是行不通的。而且,在不知道内存具体去向的情况下,您可能会花费大量时间寻找错误的目标,而您的应用程序却一直在以OutOfMemoryError的方式失败。
因此,您需要使用工具检查应用程序的堆。根据经验,分析JVM内存(以可用信息量和工具对应用程序性能的影响来衡量)的最优方法是获取堆转储,然后脱机查看它。堆转储实质上是堆的完整快照。可以通过调用jmap实用程序在任意时刻获取它,也可以将JVM配置为在出现OutOfMemoryError错误时自动生成它。如果您为“JVM堆转储”使用谷歌,您将立即看到一堆详细解释如何获取转储的文章。
堆转储是一个二进制文件,大小与JVM的堆差不多,因此只能使用特殊工具读取和分析堆转储。有许多这样的工具,开源的和商业的。最流行的开源工具是Eclipse MAT;还有VisualVM和一些功能不太强大、不太为人知的工具。商业工具包括通用的Java分析器:JProfiler和YourKit,以及专门为堆转储分析构建的JXRay工具。
与其他工具不同的是,JXRay对堆转储进行分析,以解决大量常见问题,如重复字符串和其他对象,以及次优数据结构。上面描述的集合的问题属于后一类。该工具以HTML格式生成包含所有收集到的信息的报告。这种方法的优点是,您可以随时随地查看分析结果,并轻松地与他人共享。它还意味着您可以在任何机器上运行该工具,包括在数据中心中的大型和强大但“无头”的机器。
JXRay以字节和使用堆的百分比计算开销(如果去掉某个特定问题,您将节省多少内存)。它将具有相同问题的同一个类的集合集合组合在一起。
…然后将有问题的集合分组,这些集合可以通过相同的引用链从某个GC根获取,如下面的示例所示
知道什么引用链和/或单独的数据字段(例如INodeDirectory)。(上面的子例)指向浪费了大部分内存的集合,允许您快速而精确地指出导致问题的代码,然后进行必要的更改。
总之,未优化配置的Java集合可能会浪费大量的内存。在许多情况下,这个问题很容易解决,但是有时候,您可能需要以非平凡的方式更改您的代码以获得显著的改进。很难猜测需要对哪些集合进行优化才能产生最大的影响。为了避免浪费时间优化代码的错误部分,您需要获得JVM堆转储并使用适当的工具对其进行分析。