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