您的位置:首页 > 其它

TLAB 与堆可解析性

小晨晨 2021-01-13 20:51 99 查看 https://blog.51cto.com/1508239

1. 写在前面


“[JVM 解剖公园][1]”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,不做写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。


Aleksey Shipilёv,JVM 性能极客   

推特 [@shipilev][2]   

问题、评论、建议发送到 [aleksey@shipilev.net][3]


[1]:https://shipilev.net/jvm-anatomy-park

[2]:http://twitter.com/shipilev

[3]:aleksey@shipilev.net


 2. 问题


你是否遇到过无法申请大数组 `int[]` 的情况?看起来没有分配到任何地方,但仍然占据堆空间,存储的内容像是垃圾数据?


3. 理论


按照 GC 理论,好的回收器具有一种非常重要的特性——堆可解析性,即无需复杂的元数据就可以解析对象、字段等。例如在 OpenJDK 中,许多内部任务采取下面这样的简单循环进行堆遍历:


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


就像这样!如果堆具备可解析性,可以从头到尾分配一个连读的对象流。虽然不是必备特性,但是可解析性能够使 GC 实现、测试与调试变得更容易。


从 [TLAB 机制][4]中可以知道,每个线程都有自己的当前 TLAB 可分配对象。从 GC 的角度看,这意味着声明了整个 TLAB。GC 无法快速知道有哪些线程在那里,它们是否正在操作 TLAB 游标?当前 TLAB 游标的值是什么?线程可能把这些信息存储在寄存器中不向外部展示( OpenJDK 并没有这么做)。因此,这里的问题在于外部无法了解 TLAB 中到底发生了什么。


[4]:https://shipilev.net/jvm/anatomy-quarks/4-tlab-allocation/


为了验证当前是否正在遍历 TLAB 中的一部分,希望最好能够停止线程以避免 TLAB 发生变化,从而可以实现精确的堆遍历。但这里还有一个更便捷的技巧:为什么不向堆中插入填充对象?这样就可以让堆变得可解析。也就是说,如果 TLAB 像下面这样:


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


我们可以停止线程,让它们在 TLAB 剩余空间分配一个 dummy 对象,这样就可以使它们的堆变得可解析:


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


有什么比 dummy 对象更好的选择?当然,可以用 `int[]` 数组。请注意,这种“放置”方法只分配了 array header,堆处理机制会跳过数组内容继续完成接下来的工作。一旦线程恢复在 TLAB 中分配对象,会像什么都没有发生一样覆盖之分配的填充的内容。


顺便说一下,在移除对象的时候,堆遍历程序也可以很好地处理填充对象,简化堆清扫工作。


4. 实验


能看到上面方案的执行效果吗?当然可以。我们可以启动很多线程,声明各自的 TLAB。然后启动单独的线程耗尽 Java 堆,抛出 `OutOfMemoryException` 并触发 heap dump。


例如下面这样的代码:


```java
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();  // 请求一个 TLAB 对象
   cdl.countDown();
   while (true) {
     try {
       Thread.sleep(1000);
     } catch (Exception e) {
       break;
     }
   }
   System.out.println(o); // 使用对象
 }


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


为了精确得到 TLAB 大小,可以使用 Epsilon GC 设置 `-Xmx1G -Xms1G -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -XX:+HeapDumpOnOutOfMemoryError` 参数运行。这样可以迅速失败并生成 heap dump 文件。


用 [Eclipse Memory Analyzer (MAT)][5] 打开 heap dump 文件,可以看到下图:


[5]:http://www.eclipse.org/mat/


```shell
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 展示无法访问的对象。根据定义,这些填充对象是无法访问的。它们出现在 heap dump 文件中是因为在转储过程利用堆的可解析性进行了遍历。这些对象实际上并不存在,好的分析器会把它们过滤出来。这样就可以解释为什么1G heap dump 实际上只存储了900MB对象。


5. 观察


TLAB 很有趣,堆的可解析性一样有趣。把二者结合有助了解一些内部工作机制,这是极好的。如果在运行中发现一些奇怪的结果,那么你很可能正在探索更有趣的技巧!


标签: