Five Tips for Managing Java Garbage Collection

[Editor's note] The author of this article is Niv Steingarten, co-founder of Takipi, who is passionate about writing elegant and concise code. Through the introduction and combing of the garbage collector, the author puts forward five suggestions for managing garbage collection, reducing the overhead of the collector and helping you to further improve the performance of the project.  This article is compiled and organized by OneAPM engineers of the domestic ITOM management platform .

What is the most practical advice to keep GC overhead low?

There have long been news that Java 9 is about to be released, but now it has been delayed again and again, and it is worth noting that the G1 ("Garbage-First") garbage collector will become the default collector for the HotSpot JVM. From the serial collector to the CMS collector, the JVM has undergone multiple generations of GC implementations and updates throughout its life cycle, and next, the G1 collector will write a new chapter.

As the garbage collector continues to evolve, improvements and enhancements are made with each generation. The parallel collector after the serial collector utilizes the powerful computing power of multi-core machines to realize multi-threading of garbage collection. The subsequent CMS (Concurrent Mark-Sweep) collector divides the collection into multiple stages, allowing a large number of collections to be performed while the application thread is running, greatly reducing the frequency of "stop-the-world" global pauses. Now, G1 adds a large heap and predictable even pauses to the JVM, which effectively improves performance.

Although the GC continues to improve, its Achilles' heel remains the same: redundant and unpredictable object allocations. But here are some efficient long-term practical suggestions that can help you reduce GC overhead no matter which garbage collector you choose.

 

Recommendation 1: Predict collection capacity

All Java standard collections and most custom extension implementations (such as Trove  and Google's Guava ) use the underlying array (whether primitive or object-based). Once the length of the data is allocated, the array is immutable, so in many cases adding items to the collection can cause the old underlying array to be deleted, and then need to reallocate a larger array in its place.

大多数的集合实现都尝试在集合没有被设置为预期大小时,还能对重分配过程进行优化,并降低其开销。但是,最好的结果还是在构造集合时就设置成预期大小。

让我们看一下下面这个简单的例子:

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;
    }
}

 

实例化上面类的结果为不可变对象——所有的字段一旦标记后则不能再被修改。

不变性意味着在构造容器完成之前,由不可变容器引用的所有对象都已经创建。在GC看来:容器会和其最新的新生代保持一致。这意味着当对新生代(young generations)执行垃圾回收周期时,GC可以跳过老年代(older generations)中的不可变对象,因为它知道不可变对象不能引用新生代的任何内容。

越少对象扫描意味着需扫描的内存页越少,而越少的内存页扫描意味着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>>),直接利用标准库会非常方便。

事实上,问题之所以出现是因为我们想把double类型的值放在 int 类型的list集合或map映射中。由于泛型不能调用原始集合,则可以用包装类型代替,所以放弃List<int>而使用List<Integer>更好。

但其实这非常浪费,Integer本身就是一个完备对象,由12字节的对象头和内部4字节的整数字段组合而成,加起来每个Integer对象占16个字节,这是同样大小的基类int类型长度的4倍!然而,更大的问题是所有这些Integer实际上都是垃圾回收过程中的对象实例。

为了解决这个问题,我们在Takipi 中使用优秀Trove 集合库。Trove放弃了一些(但不是全部)支持专业高效内存的原始集合的泛型。例如,不用浪费的Map,而用专门的原始集合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

OneAPM 为您提供端到端的 Java 应用性能解决方案,我们支持所有常见的 Java 框架及应用服务器,助您快速发现系统瓶颈,定位异常根本原因。分钟级部署,即刻体验,Java 监控从来没有如此简单。想阅读更多技术文章,请访问 OneAPM 官方技术博客

本文转自 OneAPM 官方博客

想知道更多关于 Java 性能优化的内容,请扫码关注下方的公众号:

 

 

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=326216705&siteId=291194637