JVM解剖公园5:TLAB和堆可解析性

问题

有没有遇到过没有注释的大型int[]数组?那些到处分配,包含垃圾数据,但仍在消耗堆的?

理论

在GC理论中,有一个重要的特性,良好的收集器会试图保持堆的可解析性,即无论怎样构造堆,它都可以被解析为对象,字段等,而无需复杂的元数据支持。例如,在OpenJDK中,许多自检任务使用如下的简单循环遍历堆:

HeapWord* cur = heap_start;
while (cur < heap_used) {
  object o = (object)cur;
  do_object(o);
  cur = cur + o->size();
}

如果堆是可解析的,那么我们需要假设从开始到分配的末尾有一个连续的对象流。严格来说,这不是必需的属性,但它使GC的实现,测试和调试变得更加容易。

现在,每个线程都有自己的TLAB(查阅《JVM解剖公园4:TLAB分配》一文)。
但从GC的角度来看,整个TLAB必须是透明的,否则GC很难知道哪些线程在哪里:它们是否在TLAB中进行指针碰撞?TLAB游标的值是多少?
而线程可能只是将它保存在寄存器中的某个位置(在OpenJDK中,它不是)并且从不显示给外部观察者。所以,有一个问题:局外人不知道TLAB究竟发生了什么。

我们可能想要暂停线程以避免它们的TLAB变化,然后准确地遍历堆,检视内存的是否是某些TLAB的一部分。但是有一个更方便的技巧:为什么我们不通过插入填充对象使堆可解析?也就是说,如果我们有:

 ...........|===================           ]............
            ^                  ^           ^
        TLAB start        TLAB used   TLAB end

我们可以暂停线程,并要求它们在TLAB的其余部分中分配一个虚拟对象,以使其部分堆可解析:

 ...........|===================!!!!!!!!!!!]............
            ^                  ^           ^
        TLAB start        TLAB used   TLAB end

虚拟对象的候选者会是什么呢?当然,得是长度可变的东西,比如int[]数组?请注意,“放置”这样的对象只相当于给出数组头,并让堆计算出其余部分,以便跳过运行。一旦线程恢复在TLAB中分配,它就可以覆盖分配的填充。

顺便说一句,同样的事情也简化了堆的扫描。如果我们清理了对象,则可以方便地将填充对象放置在其位置,从而保持堆上例程的愉快玩耍。

实验

我们能看到它在运行吗?当然可以,我们启动大量线程,这些线程会声明自己有一些TLAB,而一个单独的线程将耗尽Java堆,从而导致OutOfMemoryException,这将作为堆转储的触发器。

import java.util.*;
import java.util.concurrent.*;

public class Fillers {
  public static void main(String... args) throws Exception {
    final int TRAKTORISTOV = 300;
    CountDownLatch cdl = new CountDownLatch(TRAKTORISTOV);
    for (int t = 0 ; t < TRAKTORISTOV; t++) {
      new Thread(() -> allocateAndWait(cdl)).start();
    }
    cdl.await();
    List<Object> l = new ArrayList<>();
    new Thread(() -> allocateAndDie(l)).start();
  }

  public static void allocateAndWait(CountDownLatch cdl) {
    Object o = new Object();  // Request a TLAB
    cdl.countDown();
    while (true) {
      try {
        Thread.sleep(1000);
      } catch (Exception e) {
        break;
      }
    }
    System.out.println(o); // Use the object
  }

  public static void allocateAndDie(Collection<Object> c) {
    while (true) {
      c.add(new Object());
    }
  }
}

现在,为了获得可预测的TLAB尺寸,我们再次使用Epsilon GC。-Xmx1G -Xms1G -XX+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+HeapDumpOnOutOfMemoryError。快速运行并失败为我们生成堆转储。
在Eclipse Memory Analyzer(MAT)中打开,我非常喜欢这个工具,我们可以看到这个类直方图:

Class Name Objects Shallow Heap
int[] 1,099 814,643,272
java.lang.Object 9,181,912 146,910,592
java.lang.Object[] 1,521 110,855,376
byte[] 6,928 348,896
java.lang.String 5,840 140,160
java.util.HashMap$Node 1,696 54,272
java.util.concurrent.ConcurrentHashMap$Node 1,331 42,592
java.util.HashMap$Node[] 413 42,032
char[] 50 37,432

看看int[]占有的堆内存,这些是我们的填充对象。当然,这个实验有一些注意事项。
首先,我们配置 Epsilon 具有静态的 TLAB 大小。高性能的收集器将会自适应 TLAB 的大小,当线程已经分配了一些对象,但是仍然占用很多 TLAB 内存的时候,这种自适应的机制将会最小化无效内存的占用。这也是不要设置太大 TLAB 的一个原因。如果设置了较大的 TLAB ,那么在一个持续分配对象的线程中仍然可能观察到填充对象,但是这并不是真正的对象。
然后,我们通过配置 MAT 来展示不可达的对象。从定义上来说,填充对象是不可达的。它们出现在堆转储中仅仅是堆可解析属性的副作用。这些对象并不是真的存在,一个成熟的堆转储分析器将会为你过滤掉这些对象——这也就是 900 MB 对象就能耗尽 1G 堆内存的一个原因。

评论

TLAB很有趣,堆可解析性也很有趣。将两者结合起来甚至更有趣,有时会漏掉一些内部的机制。如果你在运行时看到令人惊讶的行为,在这里可以看到一些聪明的技巧!

Comments
Write a Comment