(給ImportNew加星標,提高Java技能)
編譯:唐尤華
連結:shipilev.net/jvm/anatomy-quarks/5-tlabs-and-heap-parsability/
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 essay-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 {
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 很有趣,堆的可解析性一樣有趣。把二者結合有助瞭解一些內部工作機制,這是極好的。如果在執行中發現一些奇怪的結果,那麼你很可能正在探索更有趣的技巧!
朋友會在“發現-看一看”看到你“在看”的內容