1、擷取記憶體的快照
有兩種方式來擷取記憶體的快照。
- 通過配置一些參數,可以在發生 OOM 的時候,被動 dump 一份堆棧資訊。
- 是通過 jmap 主動去擷取記憶體的快照。
jmap 指令在 Java 9 之後,使用 jhsdb 指令替代,它們在用法上,差別不大。
注意,這些指令本身會占用作業系統的資源,在某些情況下會造成服務響應緩慢,是以不要頻繁執行。
jmap -dump:format=b,file=heap.bin 37340
jhsdb jmap --binaryheap --pid 37340
2、MAT工具介紹
專業的事情要有專業的工具來做,今天要介紹的是一款專業的開源分析工具,即 MAT。
MAT 工具是基于 Eclipse 平台開發的,本身是一個 Java 程式,是以如果你的堆快照比較大的話,則需要一台記憶體比較大的分析機器,并給 MAT 本身加大初始記憶體,這個可以修改安裝目錄中的 MemoryAnalyzer.ini 檔案。
來看一下 MAT 工具的截圖,主要的功能都展現在工具欄上了。
其中,預設的啟動界面,展示了占用記憶體最高的一些對象,并有一些常用的快捷方式。
通常,發生記憶體洩漏的對象,會在快照中占用比較大的比重,分析這些比較大的對象,是我們切入問題的第一步。
點選對象,可以浏覽對象的引用關系,這是一個非常有用的功能:
- outgoing references 對象的引出
- incoming references 對象的引入
path to GC Roots 這是快速分析的一個常用功能,顯示和 GC Roots 之間的路徑。
另外一個比較重要的概念,就是淺堆(Shallow Heap)和深堆(Retained Heap),在 MAT 上經常看到這兩個數值。
淺堆代表了對象本身的記憶體占用,包括對象自身的記憶體占用,以及“為了引用”其他對象所占用的記憶體。
深堆是一個統計結果,會循環計算引用的具體對象所占用的記憶體。但是深堆和“對象大小”有一點不同,深堆指的是一個對象被垃圾回收後,能夠釋放的記憶體大小,這些被釋放的對象集合,叫做保留集(Retained Set)。
如上圖所示,A 對象淺堆大小 1 KB,B 對象 2 KB,C 對象 100 KB。A 對象同時引用了 B 對象和 C 對象,但由于 C 對象也被 D 引用,是以 A 對象的深堆大小為 3 KB(1 KB + 2 KB)。
A 對象大小(1 KB + 2 KB + 100 KB)> A 對象深堆 > A 對象淺堆。
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
public class Objects4MAT {
static class A4MAT {
B4MAT b4MAT = new B4MAT();
}
static class B4MAT {
C4MAT c4MAT = new C4MAT();
}
static class C4MAT {
List<String> list = new ArrayList<>();
}
static class DominatorTreeDemo1 {
DominatorTreeDemo2 dominatorTreeDemo2;
public void setValue(DominatorTreeDemo2 value) {
this.dominatorTreeDemo2 = value;
}
}
static class DominatorTreeDemo2 {
DominatorTreeDemo1 dominatorTreeDemo1;
public void setValue(DominatorTreeDemo1 value) {
this.dominatorTreeDemo1 = value;
}
}
static class Holder {
DominatorTreeDemo1 demo1 = new DominatorTreeDemo1();
DominatorTreeDemo2 demo2 = new DominatorTreeDemo2();
Holder() {
demo1.setValue(demo2);
demo2.setValue(demo1);
}
private boolean aBoolean = false;
private char aChar = '';
private short aShort = 1;
private int anInt = 1;
private long aLong = 1L;
private float aFloat = 1.0F;
private double aDouble = 1.0D;
private Double aDouble_2 = 1.0D;
private int[] ints = new int[2];
private String string = "1234";
}
Runnable runnable = () -> {
Map<String, A4MAT> map = new HashMap<>();
IntStream.range(0, 100).forEach(i -> {
byte[] bytes = new byte[1024 * 1024];
String str = new String(bytes).replace('', (char) i);
A4MAT a4MAT = new A4MAT();
a4MAT.b4MAT.c4MAT.list.add(str);
map.put(i + "", a4MAT);
});
Holder holder = new Holder();
try {
//sleep forever , retain the memory
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
};
void startHugeThread() throws Exception {
new Thread(runnable, "huge-thread").start();
}
public static void main(String[] args) throws Exception {
Objects4MAT objects4MAT = new Objects4MAT();
objects4MAT.startHugeThread();
}
}
2.1. 代碼介紹
我們以一段代碼示例 Objects4MAT,來具體看一下 MAT 工具的使用。
代碼建立了一個新的線程 "huge-thread",并建立了一個引用的層級關系,總的記憶體大約占用 100 MB。
同時,demo1 和 demo2 展示了一個循環引用的關系。最後,使用 sleep 函數,讓線程永久阻塞住,此時整個堆處于一個相對“靜止”的狀态。
如果你是在本地啟動的示例代碼,則可以使用 Accquire 的方式來擷取堆快照。
2.2. 記憶體洩漏檢測
如果問題特别突出,則可以通過 Find Leaks 菜單快速找出問題。
如下圖所示,展示了名稱叫做 huge-thread 的線程,持有了超過 96% 的對象,資料被一個 HashMap 所持有。
對于特别明顯的記憶體洩漏,在這裡能夠幫助我們迅速定位,但通常記憶體洩漏問題會比較隐蔽,我們需要更加複雜的分析。
2.3. 支配樹視圖
支配樹視圖對資料進行了歸類,展現了對象之間的依賴關系。如圖,我們通常會根據“深堆”進行倒序排序,可以很容易的看到占用記憶體比較高的幾個對象,點選前面的箭頭,即可一層層展開支配關系。
圖中顯示的是其中的 1 MB 資料,從左側的 inspector 視圖,可以看到這 1 MB 的 byte 數組具體内容。
從支配樹視圖同樣能夠找到我們建立的兩個循環依賴,但它們并沒有顯示這個過程。
支配樹視圖的概念有一點點複雜,我們隻需要了解這個概念即可。
如上圖,左邊是引用關系,右邊是支配樹視圖。可以看到 A、B、C 被當作是“虛拟”的根,支配關系是可傳遞的,因為 C 支配 E,E 支配 G,是以 C 也支配 G。
另外,到對象 C 的路徑中,可以經過 A,也可以經過 B,是以對象 C 的直接支配者也是根對象。同理,對象 E 是 H 的支配者。
我們再來看看比較特殊的 D 和 F。對象 F 與對象 D 互相引用,因為到對象 F 的所有路徑必然經過對象 D,是以,對象 D 是對象 F 的直接支配者。
可以看到支配樹視圖并不一定總是能看到對象的真實應用關系,但對我們分析問題的影響并不是很大。
這個視圖是非常好用的,甚至可以根據 package 進行歸類,對目标類的查找也是非常快捷的。
編譯下面這段代碼,可以展開視圖,實際觀測一下支配樹,這和我們上面介紹的是一緻的。
public class DorminatorTreeDemo {
static class A {
C c;
byte[] data = new byte[1024 * 1024 * 2];
}
static class B {
C c;
byte[] data = new byte[1024 * 1024 * 3];
}
static class C {
D d;
E e;
byte[] data = new byte[1024 * 1024 * 5];
}
static class D {
F f;
byte[] data = new byte[1024 * 1024 * 7];
}
static class E {
G g;
byte[] data = new byte[1024 * 1024 * 11];
}
static class F {
D d;
H h;
byte[] data = new byte[1024 * 1024 * 13];
}
static class G {
H h;
byte[] data = new byte[1024 * 1024 * 17];
}
static class H {
byte[] data = new byte[1024 * 1024 * 19];
}
A makeRef(A a, B b) {
C c = new C();
D d = new D();
E e = new E();
F f = new F();
G g = new G();
H h = new H();
a.c = c;
b.c = c;
c.e = e;
c.d = d;
d.f = f;
e.g = g;
f.d = d;
f.h = h;
g.h = h;
return a;
}
static A a = new A();
static B b = new B();
public static void main(String[] args) throws Exception {
new DorminatorTreeDemo().makeRef(a, b);
Thread.sleep(Integer.MAX_VALUE);
}
}
2.4. 線程視圖
想要看具體的引用關系,可以通過線程視圖。我們在第 5 講,就已經了解了線程其實是可以作為 GC Roots 的。
如圖展示了線程内對象的引用關系,以及方法調用關系,相對比 jstack 擷取的棧 dump,我們能夠更加清晰地看到記憶體中具體的資料。
如下圖,我們找到了 huge-thread,依次展開找到 holder 對象,可以看到循環依賴已經陷入了無限循環的狀态。這在檢視一些 Java 對象的時候,經常發生,不要感到奇怪。
2.5. 柱狀圖視圖
我們傳回頭來再看一下柱狀圖視圖,可以看到除了對象的大小,還有類的執行個體個數。
結合 MAT 提供的不同顯示方式,往往能夠直接定位問題。
也可以通過正則過濾一些資訊,我們在這裡輸入 MAT,過濾猜測的、可能出現問題的類,可以看到,建立的這些自定義對象,不多不少正好一百個。
右鍵點選類,然後選擇 incoming,這會列出所有的引用關系。
再次選擇某個引用關系,然後選擇菜單“Path To GC Roots”,即可顯示到 GC Roots 的全路徑。通常在排查記憶體洩漏的時候,會選擇排除虛弱軟等引用。
使用這種方式,即可在引用之間進行跳轉,友善的找到所需要的資訊。
再介紹一個比較進階的功能。
我們對于堆的快照,其實是一個“瞬時态”,有時候僅僅分析這個瞬時狀态,并不一定能确定問題,這就需要對兩個或者多個快照進行對比,來确定一個增長趨勢。
可以将代碼中的 100 改成 10 或其他數字,再次 dump 一份快照進行比較。如圖,通過分析某類對象的增長,即可輔助問題定位。
3. 進階功能—OQL
MAT 支援一種類似于 SQL 的查詢語言 OQL(Object Query Language),這個查詢語言 VisualVM 工具也支援。
以下是幾個例子,你可以實際實踐一下。
查詢 A4MAT 對象:
SELECT * FROM Objects4MAT$A4MAT
正則查詢 MAT 結尾的對象:
SELECT * FROM ".*MAT"
查詢 String 類的 char 數組:
SELECT OBJECTS s.value FROM java.lang.String s
SELECT OBJECTS mat.b4MAT FROM Objects4MAT$A4MAT mat
根據記憶體位址查找對象:
select * from 0x55a034c8
使用 INSTANCEOF 關鍵字,查找所有子類:
SELECT * FROM INSTANCEOF java.util.AbstractCollection
查詢長度大于 1000 的 byte 數組:
SELECT * FROM byte[] s WHERE s.@length>1000
查詢包含 java 字樣的所有字元串:
SELECT * FROM java.lang.String s WHERE toString(s) LIKE ".*java.*"
查找所有深堆大小大于 1 萬的對象:
SELECT * FROM INSTANCEOF java.lang.Object o WHERE o.@retainedHeapSize>10000
如果你忘記這些屬性的名稱的話,MAT 是可以自動補全的。
OQL 有比較多的文法和用法,若想深入了解,可參考 http://tech.novosoft-us.com/products/oql_book.htm
一般,我們使用上面這些簡單的查詢語句就夠用了。
OQL 還有一個好處,就是可以分享。如果你和同僚同時在分析一個大堆,不用告訴他先點哪一步、再點哪一步,共享給他一個 OQL 語句就可以了。
如下圖,MAT 貼心的提供了複制 OQL 的功能,但是用在其他快照上,不會起作用,因為它複制的是如下的内容。
4. 小結
這一講我們介紹了 MAT 工具的使用,其是用來分析記憶體快照的;在最後,簡要介紹了 OQL 查詢語言。
在 Java 9 以前的版本中,有一個工具 jhat,可以以 html 的方式顯示堆棧資訊,但和 VisualVm 一樣,都太過于簡陋,推薦使用 MAT 工具。
我們把問題設定為記憶體洩漏,但其實 OOM 或者頻繁 GC 不一定就是記憶體洩漏,它也可能是由于某次或者某批請求頻繁而建立了大量對象,是以一些嚴重的、頻繁的 GC 問題也能在這裡找到原因。
有些情況下,占用記憶體最多的對象,并不一定是引起記憶體洩漏問題的元兇,但我們也有一個比較通用的分析過程。
并不是所有的堆都值得分析的,我們在做這個耗時的分析之前,需要有個依據。
比如,經過初步調優之後,GC 的停頓時間還是較長,則需要找到頻繁 GC 的原因;再比如,我們發現了記憶體洩漏,需要找到是誰在搞鬼。
首先,我們高度關注快照載入後的初始分析,占用記憶體高的 topN 對象,大機率是問題産生者。
對照自己的代碼,首先要分析的,就是産生這些大對象的邏輯。
舉幾個實際發生的例子。
有一個 Spring Boot 應用,由于啟用了 Swagger 文檔生成器,但是由于它的 API 關系非常複雜,嵌套層次又非常深(每次要産生幾百 M 的文檔!),結果請求幾次之後産生了記憶體溢出,這在 MAT 上就能夠一眼定位到問題;而另外一個應用,在讀取資料庫的時候使用了分頁,但是 pageSize 并沒有做一些範圍檢查,結果在請求一個較大分頁的時候,使用 fastjson 對擷取的資料進行加工,直接 OOM。
如果不能通過大對象發現問題,則需要對快照進行深入分析。
使用柱狀圖和支配樹視圖,配合引入引出和各種排序,能夠對記憶體的使用進行整體的摸底。
由于我們能夠看到記憶體中的具體資料,排查一些異常資料就容易得多。
可以在程式運作的不同時間點,擷取多份記憶體快照,對比之後問題會更加容易發現。
我們還是用一個例子來看。有一個應用,使用了 Kafka 消息隊列,開了一般大小的消費緩沖區,Kafka 會複用這個緩沖區,按理說不應該有記憶體問題,但是應用卻頻繁發生 GC。
通過對比請求高峰和低峰期間的記憶體快照,我們發現有工程師把消費資料放入了另外一個 “記憶體隊列”,寫了一些畫蛇添足的代碼,結果在業務高峰期一股腦把資料加載到了記憶體中。
上面這些問題通過分析業務代碼,也不難發現其關聯性。
問題如果非常隐蔽,則需要使用 OQL 等語言,對問題一一排查、确認。
可以看到,上手 MAT 工具是有一定門檻的,除了其操作模式,還需要對我們前面介紹的理論知識有深入的了解,比如 GC Roots、各種引用級别等。
在很多場景,MAT 并不僅僅用于記憶體洩漏的排查。
由于我們能夠看到記憶體上的具體資料,在排查一些難度非常高的 bug 時,MAT 也有用武之地。
比如,因為某些髒資料,引起了程式的執行異常,此時,想要找到它們,不要忘了 MAT 這個老朋友。