保持较低的GC开销的一些最有用的技巧是什么?
随着Java 9的一次再次延迟发布,G1(“ Garbage First”)垃圾收集器将设置为HotSpot JVM的默认收集器。 从串行垃圾收集器一直到CMS收集器,JVM在其整个生命周期中都见证了许多GC实现,而G1收集器紧随其后。
随着垃圾收集器的发展,每一代(没有双关语)都会带来比以前更高的进步和改进。 串行收集器之后的并行GC利用多核计算机的计算功能使垃圾收集成为多线程。 随后的CMS(“并发标记扫描”)收集器将收集分为多个阶段,从而使许多收集工作可以在应用程序线程运行时同时完成-从而减少了“停止世界”的频率。 G1在堆非常大的JVM上增加了更好的性能,并且具有更加可预测的统一暂停。
无论高级GC收到什么,其致命弱点仍然是:冗余且不可预测的对象分配。 无论您选择使用哪种垃圾收集器,这些快速,适用,永恒的技巧将帮助您避免GC开销。
提示1:预测收集容量
所有标准Java集合以及大多数自定义和扩展的实现(例如Trove和Google的Guava )都使用基础数组(基于原始或对象的数组)。 由于数组一旦分配就不会改变大小,因此在许多情况下向集合中添加项目可能会导致丢弃旧的基础数组,而使用较大的新分配的数组。
即使未提供预期的集合大小,大多数集合实现都尝试优化此重新分配过程并将其保持在摊销后的最小值。 但是,通过在构造时为集合提供预期的大小可以达到最佳效果。
让我们以以下代码为简单示例:
public static List reverse(List<? extends T> list) {List result = new ArrayList();for (int i = list.size() - 1; i >= 0; i--) {result.add(list.get(i));}return result;
}
此方法分配一个新数组,然后以相反的顺序填充另一个列表中的项目。
可能会很痛苦并且可以优化的一点是将项目添加到新列表的行。 对于每个添加项,列表都需要确保其基础数组中具有足够的可用插槽以容纳新项。 如果是这样,它将简单地将新项目存储在下一个空闲插槽中。 如果不是,它将分配一个新的基础数组,将旧数组的内容复制到新数组中,然后添加新项。 这将导致阵列的多个分配,这些分配将保留在那里,以供GC最终收集。
我们可以通过在构造数组时让数组知道预计要保留多少个项来避免这些多余的分配:
public static List reverse(List<? extends T> list) {List result = new ArrayList(list.size());for (int i = list.size() - 1; i >= 0; i--) {result.add(list.get(i));}return result;}
这使得ArrayList构造函数执行的初始分配足够大,可以容纳list.size()项,这意味着在迭代期间不必重新分配内存。
Guava的集合类更进一步,使我们可以使用预期项目的确切数量或估计值来初始化集合。
List result = Lists.newArrayListWithCapacity(list.size());
List result = Lists.newArrayListWithExpectedSize(list.size());
前者用于以下情况:我们确切知道集合将要容纳多少项,而后者则分配一些填充以解决估计误差。
提示2:直接处理流
例如,在处理数据流(例如从文件读取的数据或通过网络下载的数据)时,通常会看到以下内容:
byte[] fileData = readFileToByteArray(new File("myfile.txt"));
然后,可以将结果字节数组解析为XML文档,JSON对象或协议缓冲区消息,以列举一些常用的选项。
当处理大文件或大小无法预测的文件时,这显然不是一个好主意,因为在JVM无法实际分配整个文件大小的缓冲区的情况下,这会使我们面临OutOfMemoryErrors。
但是,即使数据的大小似乎是可管理的,使用上述模式在进行垃圾回收时也会导致大量开销,因为它会在堆上分配一个较大的blob来保存文件数据。
解决此问题的更好方法是使用适当的InputStream(在这种情况下为FileInputStream),将其直接输入解析器,而无需先将整个内容读取到字节数组中。 所有主要库都公开了API以直接解析流,例如:
FileInputStream fis = new FileInputStream(fileName);
MyProtoBufMessage msg = MyProtoBufMessage.parseFrom(fis);
提示3:使用不可变对象
不变性具有很多优势。 甚至不让我开始。 但是,很少受到关注的一个优点是它对垃圾回收的影响。
不变对象是指在构造对象之后其字段(在我们的情况下尤其是非原始字段)无法修改的对象。 例如:
public class ObjectPair {private final Object first;private final Object second;public ObjectPair(Object first, Object second) {this.first = first;this.second = second;}public Object getFirst() {return first;}public Object getSecond() {return second;}}
实例化以上类会导致一个不可变的对象—它的所有字段都标记为final,并且不能在构造后进行修改。
不变性意味着不变容器引用的所有对象都是在容器构造完成之前创建的。 用GC的术语来说:容器至少与所保存的最小引用一样年轻 。 这意味着,在年轻一代执行垃圾回收周期时,GC可以跳过位于老一代中的不可变对象,因为它可以确定它们不能引用正在收集的一代中的任何对象。
要扫描的对象越少,意味着要扫描的内存页面越少,而要扫描的内存页面就越少,意味着GC周期越短,这意味着GC暂停时间越短,总体吞吐量就越高。
提示4:警惕字符串连接
在任何基于JVM的应用程序中,字符串可能是最普遍的非原始数据结构。 但是,它们隐含的重量和易于使用的特性使它们很容易成为导致应用程序占用大量内存的罪魁祸首。
问题显然不在于文字字符串,因为它们是内联和插入的,而是在于在运行时分配和构造的字符串。 让我们看一下动态字符串构造的快速示例:
public static String toString(T[] array) {String result = "[";for (int i = 0; i < array.length; i++) {result += (array[i] == array ? "this" : array[i]);if (i < array.length - 1) {result += ", ";}}result += "]";return result;
}
这是一个不错的方法,它接受一个数组并为其返回字符串表示形式。 在对象分配方面也是如此。
很难理解所有这些语法糖,但是幕后的实际情况是:
public static String toString(T[] array) {String result = "[";for (int i = 0; i < array.length; i++) {StringBuilder sb1 = new StringBuilder(result);sb1.append(array[i] == array ? "this" : array[i]);result = sb1.toString();if (i < array.length - 1) {StringBuilder sb2 = new StringBuilder(result);sb2.append(", ");result = sb2.toString();}}StringBuilder sb3 = new StringBuilder(result);sb3.append("]");result = sb3.toString();return result;
}
字符串是不可变的,这意味着在进行串联时它们本身不会被修改,而是依次分配新的字符串。 另外,编译器利用标准的StringBuilder类来实际执行这些串联。 这导致了双重麻烦,因为在循环的每次迭代中,我们同时获得(1)临时字符串的隐式分配和(2)临时StringBuilder对象的隐式分配,以帮助我们构造最终结果。
避免这种情况的最佳方法是显式使用StringBuilder并将其直接附加到其上,而不是使用有些天真的串联运算符(“ +”)。 可能是这样的:
public static String toString(T[] array) {StringBuilder sb = new StringBuilder("[");for (int i = 0; i < array.length; i++) {sb.append(array[i] == array ? "this" : array[i]);if (i < array.length - 1) {sb.append(", ");}}sb.append("]");return sb.toString();
}
在此方法的开头,我们仅分配了一个StringBuilder。 从那时起,所有字符串和列表项都附加到唯一的StringBuilder上,该字符串最终仅使用其toString方法转换成字符串,然后返回。
提示5:使用专门的原始集合
Java的标准集合库既方便又通用,允许我们使用具有半静态类型绑定的集合。 如果我们想使用例如一组字符串(Set <String>),或一对和一组字符串之间的映射(Map <Pair,List <String >>),这是很棒的。
真正的问题始于我们要保存一个int列表或一个double类型值的映射。 由于泛型类型不能与基元一起使用,因此替代方法是使用装箱的类型,因此我们需要使用List <Integer>来代替List <int>。
这是非常浪费的,因为Integer是一个完整的Object,充斥着12字节的对象标头和一个内部4字节的int字段来保存其值。 每个Integer项的总和为16个字节。 这是相同大小的原始整数列表的大小的4倍! 但是,更大的问题是所有这些Integer实际上都是对象实例,在垃圾回收期间需要考虑这些实例。
为了解决这个问题,我们在塔基皮(Takipi)使用了出色的Trove收藏库。 Trove放弃了一些(但不是全部)泛型,转而使用专门的内存有效的原始集合。 例如,代替浪费的Map <Integer,Double>,还有TIntDoubleMap形式的专门替代方法:
TIntDoubleMap map = new TIntDoubleHashMap();
map.put(5, 7.0);
map.put(-1, 9.999);
...
Trove的基础实现使用基本数组,因此在操作集合时不会进行装箱(int-> Integer)或拆箱(Integer-> int),并且不会存储任何对象来代替基元。
最后的想法
随着垃圾收集器的不断发展,以及运行时优化和JIT编译器变得越来越智能,我们作为开发人员将发现自己越来越不关心如何编写与GC友好的代码。 但是,就目前而言,无论G1多么先进,我们仍然可以做很多工作来帮助JVM。
翻译自: https://www.javacodegeeks.com/2015/12/5-tips-reducing-java-garbage-collection-overhead.html