使用G1垃圾回收器,当实例数量较多时,分配性能是否会下降?

7
在将一些应用程序从CMS迁移到G1时,我发现其中一个应用程序的启动时间延长了4倍。由于GC循环导致的应用程序停止时间不是原因。比较应用程序行为后,我发现这个应用程序在启动后携带着2.5亿个活动对象(在12G堆中)。进一步调查显示,该应用程序在前500万个分配期间速度正常,但随着活动对象池越来越大,性能逐渐下降。
进一步实验表明,一旦达到一定数量的活动对象阈值,使用G1时分配新对象的速度确实会变慢。我发现,将活动对象数量翻倍似乎需要花费2.5倍的时间进行分配。对于其他GC引擎,这个因素只有2。这确实可以解释减速。
然而,有两个问题让我怀疑这个结论:
1. 大约500万个活动实例的阈值似乎与整个堆有关。对于G1,我本来希望任何这样的退化阈值都与一个区域有关,而不是与整个堆有关。 2. 我在网上寻找了解释(或至少说明)此行为的文档,但没有找到。我甚至没有找到“拥有超过xxx个活动对象是不好的”这样的建议。
因此:如果有人能告诉我我的观察结果是正确的,并可能指向一些说明文件或关于此领域的推荐,那将是很棒的。或者,另外一个人告诉我我做错了什么。:)
以下是一个简短的测试用例(多次运行,取平均值,减去显示的垃圾收集时间):
import java.util.HashMap;

/**
  * Allocator demonstrates the dependency between number of live objects
  * and allocation speed, using various GC algorithms.
  * Call it using, e.g.:
  *   java Allocator -Xmx12g -Xms12g -XX:+PrintGCApplicationStoppedTime -XX:+UseG1GC
  *   java Allocator -Xmx12g -Xms12g -XX:+PrintGCApplicationStoppedTime
  * Deduct stopped times from execution time.
  */
public class Allocator {

public static void main(String[] args) {
    timer(2000000, true);
    for (int i = 1000000; i <= 32000000; i*=2) {
        timer(i, false);
    }
    for (int i = 32000000; i >= 1000000; i/=2) {
        timer(i, false);
    }
}

private static void timer(int num, boolean warmup) {
    long before = System.currentTimeMillis();
    Allocator a = new Allocator();
    int size = a.allocate(num);
    long after = System.currentTimeMillis();
    if (!warmup) {
        System.out.println("Time needed for " + num + " allocations: "
           + (after - before) + " millis. Map size = " + size);
    }
}

private int allocate(int numElements) {
    HashMap<Integer, String> map = new HashMap<>(2*numElements);
    for (int i = 0; i < numElements; i++) {
        map.put(i, Integer.toString(i));
    }
    return map.size();
}

}

GC日志(通过 PrintGCDetails)并且提到您正在使用的Java版本将会很有帮助。 - the8472
实际应用的Full GC日志太大了。 :) 但无论如何,上述测试用例都展示了这种行为。请注意,这里的问题不是GC暂停。- 我使用的是Java 8 Update 45。在Windows和Linux下的行为是相同的。 - malamut
1
问题是你的测试用例是否会引起与实际工作负载相同的问题。持有数百万对象的单个哈希映射将在哈希映射内部创建一个非常大的引用数组,这可能需要为其自己分配一个区域,因此使得哈希映射表到其节点的大多数引用跨越不同的区域,这需要更多的簿记工作。 - the8472
好主意,但似乎单个大数组也不是问题所在。 原始应用程序将所有这些实例保存在ConcurrentHashMap中。我的第一个版本的测试用例也是这样做的。使用ConcurrentHashMap会创建很多(小)额外的实例,因此问题更加明显。将测试用例中的单个HashMap替换为200个HashMap,所有这些HashMap都使用map[i%200].put()进行填充,也会增强效果。 - malamut
关于我之前的评论:如果我不是一次性创建所有的HashMap,而是在创建后立即填充每个HashMap(这样元素就会靠近它们的存储位置构建),似乎对G1有所帮助,与单个HashMap相比。然而,这种效果仍然非常明显。 - malamut
显示剩余2条评论
1个回答

1

如上面的评论中所讨论的:

您的测试用例预先分配了非常大的参考数组,这些数组长期存在并且基本上占据了它们自己的区域(它们可能最终进入旧代或巨型区域),然后用数百万个其他对象填充它们,这些对象很可能存活于不同的区域。

这会创建大量跨区域引用,G1可以处理适度数量的跨区域引用,但无法处理每个区域中的数百万个引用。

G1的启发式算法也认为高度互连的区域收集起来成本很高,因此即使它们完全由垃圾组成,也不太可能被收集。

将对象一起分配以减少跨区域引用。

不要过多地人为延长它们的生命周期(例如通过将它们放入某种缓存中),这样它们就可以在年轻代GC期间死亡,而这比自然积累来自不同区域的对象的老区域要容易得多。

总之,您的测试用例对G1的基于区域的性质相当不友好。


是的,测试用例是具有敌意的,但实际应用程序也以完全相同的方式具有敌意,因此测试用例已经完成了它的工作。 :) 我已经学会了一个教训,使用G1时必须确保跨区域引用的数量不会超过一百万。通常实现这一点最简单的方法是确保活动对象的总数不会增长到成千上万的范围。 - malamut

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