OOM和內存泄漏在我們的工作中,算是相對比較容易出現的問題,一旦出現了這個問題,我們就需要對堆進行分析。
一般情況下,我們生產應用都會設置這樣的JVM參數,以便在出現OOM時,可以dump出堆內存文件,也就是保留案發現場,方便我們后續的研究。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=.
至于分析堆內存的工具可以使用Jvisualvm,但Jvisualvm只能查看類使用內存的直方圖,無法有效的追蹤內存的引用關系,因此更加推薦使用Eclipse 的 Memory Analyzer(也叫做 MAT)做堆轉儲的分析。可以通過這個鏈接,下載 MAT。
使用MAT分析OOM問題,一般可以按照以下的思路進行:
- 通過支配樹功能或直方圖功能查看消耗內存最大的類型,來分析內存泄露的大概原因;
- 查看那些消耗內存最大的類型、詳細的對象明細列表,以及它們的引用鏈,來定位內存泄露的具體點;
- 配合查看對象屬性的功能,可以脫離源碼看到對象的各種屬性的值和依賴關系,幫助我們理清程序邏輯和參數;
- 輔助使用查看線程棧來看 OOM 問題是否和過多線程有關,甚至可以在線程棧看到 OOM 最后一刻出現異常的線程。
接下來,我們有一個案例,通過這個案例可以得到一個OOM后的堆轉儲文件java_pid12300.hprof,然后我們通過MAT的直方圖、支配樹、線程棧、OQL 等功能來分析此次 OOM 的原因。
在文章的最后會有代碼地址,運行代碼一段時間發生OOM后,你就可以得到一個hprof文件。
1、查看堆概述信息
通過MAT打開java_pid12300.hprof文件后,首先進入的是概覽信息界面。
從這個概覽圖中,我們可以看出整個堆的大小是437.6MB。接下來我們可以通過直方圖來看這437.6MB的對象都是哪些對象。
2、直方圖觀察對象分布
點擊工具欄的第二個圖標,進入到直方圖視圖
從直方圖中,我們可以看到,char[]字節數組占用的內存最多,對象數量給也最多。排名第二的String對象也很多,可以推斷程序可能是被String占滿了(String底層使用的就是char[]作為實際存儲,因此String多,char[]也會多)
3、分析char[]的引用關系
在 char[]上點擊右鍵,選擇 List objects->with incoming references,可以列出所有的char[]實例,以及每個 char[]的整個引用關系鏈:
隨機展開一個 char[],如下圖所示:
- 在①處看到,這些 char[]幾乎都是 10000 個字符、占用 20000 字節左右(char 是 UTF-16,每一個字符占用 2 字節);
- 在②處看到,char[]被 String 的 value 字段引用,說明 char[]來自字符串;
- 在③處看到,String 被 ArrayList 的 elementData 字段引用,說明這些字符串加入了一個 ArrayList 中;
- 在④處看到,ArrayList 又被 FooService 的 data 字段引用,這個 ArrayList 整個 RetainedHeap 列的值是 431MB。
Retained Heap(深堆)代表對象本身和對象關聯的對象占用的內存,Shallow Heap(淺堆)代表對象本身占用的內存。比如,我們的 FooService 中的 data 這個 ArrayList 對象本身只有 16 字節,但是其所有關聯的對象占用了 431MB 內存。這些就可以說明,肯定有哪里在不斷向這個 List 中添加 String 數據,導致了 OOM。
左側的藍色框可以查看每一個實例的內部屬性,圖中顯示 FooService 有一個 data 屬性,類型是 ArrayList。
如果我們希望看到字符串完整內容的話,可以右鍵選擇 Copy->Value,把值復制到剪貼板或保存到文件中:
這里,我們復制出的是 10000 個字符 a(下圖紅色部分可以看到)。對于真實案例,查看大字符串、大數據的實際內容對于識別數據來源,有很大意義:
4、利用支配樹查看內存中最大的對象
點擊工具欄的第三個按鈕可以進入到支配樹界面,這個界面會根據Retained Heap 倒序直接列出占用內存最大的對象。
這樣我們就可以很快速的定位到是哪個對象導致的OOM,接下來我們就要看一下OOM的時候,FooService在執行什么邏輯。
5、查看線程視圖
點擊工具欄的第五個按鈕,打開線程視圖,首先看到的是main線程。
從黑色框來看,確實這里發生了OOM。緊接繼續往下看,尋找我們可以的FooService,可以看到這個線程棧中FooSerice.oom()方法被調用。
在往下看的話,可看到參數中的 CommandLineRunner 你應該能想到,OOMApplication 其實是實現了 CommandLineRunner 接口,所以是 SpringBoot 應用程序啟動后執行的。
在FooService.oom()往上看,紅色框部分,我們可以猜測出這些字符串是由Stream操作產生的,以及在上面的StringBuilder 的 append是最終導致OOM的方法。
6、OQL查找類
最后我們還可以看一下FooService是不是Spring的Bean,又是不是單例?如果是的話,就更能確定是因為反復調用同一個 FooService 的 oom 方法,然后導致其內部的 ArrayList 不斷增加數據的。
我們可以點擊工具欄的第四個按鈕,進入到OQL界面,然后在這里我們可以使用類似 SQL 的語法,在 dump 中搜索數據(你可以直接在 MAT 幫助菜單搜索 OQL Syntax,來查看 OQL 的詳細語法)。
比如,輸入如下語句搜索 FooService 的實例:
select * from fcp.troubleshootingtools.mat.FooService
可以看到只有一個實例,然后我們通過 List objects 功能搜索引用 FooService 的對象:
得到以下結果:
可以看到,一共兩處引用:
- 第一處是,OOMApplication 使用了 FooService,這個我們已經知道了。
- 第二處是一個 ConcurrentHashMap。可以看到,這個 HashMap 是 DefaultListableBeanFactory 的 singletonObjects 字段,可以證實 FooService 是 Spring 容器管理的單例的 Bean。
我們甚至可以在HashMap 上點擊右鍵,選擇 Java Collections->Hash Entries 功能,來查看其內容:
我們還可以在Value列通過正則進一步對解決進行過濾篩選:
到現在為止,我們雖然沒看程序代碼,但是已經大概知道程序出現 OOM 的原因和大概的調用棧了。我們再貼出程序來對比一下,果然和我們看到得一模一樣:
@SpringBootApplication
public class OOMApplication implements CommandLineRunner {@AutowiredFooService fooService;public static void main(String[] args) {SpringApplication.run(OOMApplication.class, args);}@Overridepublic void run(String... args) throws Exception {//程序啟動后,不斷調用Fooservice.oom()方法while (true) {fooService.oom();}}
}
@Component
public class FooService {List<String> data = new ArrayList<>();public void oom() {//往同一個ArrayList中不斷加入大小為10KB的字符串data.add(IntStream.rangeClosed(1, 10_000).mapToObj(__ -> "a").collect(Collectors.joining("")));}
}
這邊做個小總結
- 我們通過MAT可以通過直方圖很方便的知道當前堆中哪個對象的數量較多且占據的堆內存較多。同時我們可以通過List objects查看引用鏈,最終定位到究竟是在哪個類中出現了大量對象導致OOM
- 除了直方圖外,我們可以使用支配樹在更快的時間發現導致OOM的對象
- 然后根據線程視圖,定位到具體是在哪個地方發生了OOM
- 最后呢,我們可以通過OQL查看類,搜索類有幾個實例,以及實例在哪幾個地方有引用
最后呢,可以到代碼地址中下載相關代碼,然后本地實踐一下。以及本篇文章的內容實際上是學習自極客時間的《Java業務開發常見錯誤100例》這是一個實戰性比較強的專欄,推薦大家也可以去看看