📢 大家好,我是 【戰神劉玉棟】,有10多年的研發經驗,致力于前后端技術棧的知識沉淀和傳播。 💗
🌻 近期剛轉戰 CSDN,會嚴格把控文章質量,絕不濫竽充數,如需交流,歡迎留言評論。👍
文章目錄
- 寫在前面的話
- 書寫背景
- 知識補充
- 常見原因
- 排查方式
- 使用 JProfiler 排查
- 具體步驟
- 使用 VisualVM 排查
- 監視視圖
- 線程視圖
- 抽樣器
- Visual GC插件
- 使用Eclipse Memory Analyzer排查
- 工具介紹
- Overview組件
- Top consumers組件
- Histogram組件
- Thread Overview組件
- Domainator Tree組件
- Leak Suspects組件
- 常用分析技巧
寫在前面的話
書寫背景
博主所在公司采用的是技術棧為:后端 SpringCloud,前端 Nuxt,部署 K8S。(看過前面文章的應該知道)
由于涉及服務較多,代碼量也隨著需求研發不斷增加,難免出現一些臭魚爛蝦的隱患代碼。
最終導致的是,線上服務的內存溢出問題屢見不鮮,每次排查都要依靠架構等少數人員,現著手整理了一份分析內存溢出問題的流程文檔,方便研發主管人員可以自行排查,而不需要依賴架構部門。
此篇博文將以上述文檔為基礎略為調整,發布到博客,若出現一些未及時調整的話術,希望理解。
知識補充
Tips:這部分純屬知識補充,可以跳過。
關于內存泄漏和內存溢出
一般來說內存異常問題分為內存泄漏和內存溢出。
內存泄漏:對象已經不使用了,但是還占用著內存空間,沒有被釋放。
內存溢出:堆空間不夠用了,通常表現為 OutOfMemoryError,內存泄漏通常會導致內存溢出。
再看一下“內存泄漏”的定義:一個不再被程序使用的對象或變量還在內存中占有存儲空間。
一次內存泄漏似乎不會有大的影響,但內存泄漏堆積后的后果就是內存溢出。
“內存溢出”:指程序申請內存時,沒有足夠的內存供申請者使用,或者說,給了你一塊存儲 int 類型數據的存儲空間,但是你卻存儲 long 類型的數據,那么結果就是內存不夠用,此時就會報錯OOM,即所謂的內存溢出。
二者的關系:內存泄漏的堆積最終會導致內存溢出
**內存泄漏 :**是指你向系統申請分配內存進行使用(new),可是使用完了以后卻不歸還(delete),結果你申請到的那塊內存你自己也不能再訪問(也許你把它的地址給弄丟了),而系統也不能再次將它分配給需要的程序。就相當于你租了個帶鑰匙的柜子,你存完東西之后把柜子鎖上之后,把鑰匙丟了或者沒有將鑰匙還回去,那么結果就是這個柜子將無法供給任何人使用,也無法被垃圾回收器回收,因為找不到他的任何信息。
**內存溢出:就是你要的內存空間超過了系統實際分配給你的空間,此時系統相當于沒法滿足你的需求,就會報內存溢出的錯誤。
內存溢出種類與解決
JVM 管理的內存大致包括三種區域:Heap space(堆區域)、Java Stacks(Java 棧)、Permanent Generation space(永久保存區域)。由此,OOM 簡單的分為堆溢出、棧溢出、永久代溢出(常量池/方法區)。Java 程序的每個線程中都有一個獨立的堆棧。
PS:我們日常開發中,遇到最后的就是堆內存溢出,這里重點介紹這塊,其他兩塊自行了解。
Java 堆內存溢出
Java 堆是線程共有的區域,主要用來存放對象實例,幾乎所有的 Java 對象都在這里分配內存,也是 JVM 內存管理最大的區域。Java堆內存分年輕代和年老代,堆內存溢出一般是年老代溢出。當程序不斷地創建大量對象實例并且沒有被GC回收時,就容易產生內存溢出。當一個對象產生時,主要過程是這樣的:
1、JVM首先在年輕代的Eden區為它分配內存;
2、若分配成功,則結束,否則JVM會觸發一次Young GC,試圖釋放Eden區的不活躍對象;
3、如果釋放后還沒有足夠的內存空間,則將Eden區部分活躍對象轉移到Survivor區,Survivor區長期存活的對象會被轉移到老年代;
4、當老年代空間不夠,會觸發Full GC,對年老代進行完全的垃圾回收;
5、回收后如果Suvivor和老年代仍沒有充足的空間接收從Eden復制過來的對象,使得Eden區無法為新產生的對象分配內存,即溢出。
由此可見,當程序不斷地創建大量對象實例并且沒有被GC回收時,就容易產生內存溢出。如下:
public static void main(String[] args){ArrayList list = new ArrayList();while(true){list.add(new heap());}
}
堆內存溢出很可能伴隨內存泄漏,應首先排查可能泄露的對象,再通過工具檢查 GC roots 引用鏈,從而發現泄露對象是由于何種引用關系使得GC無法回收他們;若不存在內存泄漏,換句話說就是內存中的對象還都需要繼續存活,則可通過修改虛擬機的堆參數將堆內存增大。
補充說明:
堆區域用來存放 Class 的實例(即對象),對象需要存儲的內容主要是非靜態屬性。每次用 new 創建一個對象實例后,對象實例存儲在堆區域中,這部分空間也被 JVM 的垃圾回收機制管理。
java.lang.OutOfMemoryError: Java heap space 此種情況最常見,一般由于內存泄露或者堆的大小設置不當引起。原因是 JVM 創建的對象太多,在進行垃圾回收之間,虛擬機分配的到堆內存空間已經用滿了,與 Heap space 有關。
解決這類問題有兩種思路:
1、對于內存泄露,可以通過內存監控軟件查找程序中的泄露代碼。檢查程序,看是否有死循環或不必要地重復創建大量對象。找到原因后,修改程序和算法。
2、增加 JVM 中 Xms(初始堆大小)和 Xmx(最大堆大小)參數的大小。如:set JAVA_OPTS= -Xms256m -Xmx1024
PS:JVM大小通常運維人員會考慮一個合適的值,研發要注意的就是第一點。
常見原因
首先排除Java程序的JVM配置問題,代碼有哪些常見問題會導致內存溢出:
1、內存中加載的數據量過大,一次從數據庫取出過多數據導致內存溢出;
2、集合類中有對對象的引用,使用完后沒有及時清空,使得 JVM 不能回收;
3、代碼中存在死循環或循環產生過多重復的實體對象;
4、未完待續。。。
排查方式
憑經驗肉眼掃描代碼
針對上述列的常見導致內存溢出的代碼,日常排查代碼可以遵循如下軌跡:
1、檢查對數據庫查詢中,是否有一次獲得全部數據的查詢,一般來說,如果一次取十萬條記錄到內存,就可能引起內存溢出。這個問題比較隱蔽且有潛伏性,在上線前,數據庫中數據較少,不容易出問題,上線后,數據庫中數據多了,一次查詢就有可能引起內存溢出。所以可以使用分頁查詢數據庫;
2、檢查代碼中是否有死循環或遞歸調用導致有大循環重復產生新對象實體;
3、檢查 List、MAP 等集合對象是否有使用完后未清除的問題,集合中存在對對象的引用會導致這些對象不能被 GC 回收;
借助工具定位問題
1、服務啟動的時候,設置其參數,使其支持在發生內存溢出時自動dump內存快照;
PS:也就是通常說的dump文件,這步運維那邊會做好。
2、內存快照文件是后綴為 .hprof 的文件,內存溢出的時候產生,可以從服務器下載到本地;
PS:找運維拿到這個文件,或者從SpringBootAdmin嘗試下載。
3、借助內存溢出排查工具 VisualVM、Jprofiler 等,直接加載hprof文件,按步驟定位問題代碼;
PS:安裝一款你順手的工具,排查一下。
研發人員如何手動操作
1、進入到對應的容器里面去
docker exec -it 容器id /bin/bash
2、ps -ef | grep java 查看java進程id
3、保存堆和棧的現場快照 ,jstack命令用于打印指定Java進程、核心文件或遠程調試服務器的Java線程的Java堆棧跟蹤信息
jstack pid > stack.log
jmap -dump:format=b,file=heap.hprof pid
4、退出容器
5、看情況是否重啟容器
docker restart 容器id
6、將文件從容器導出到宿主機
docker cp 容器id:要導出的文件路徑 宿主機路徑
7、再次進入容器刪掉 相關文件
8、下載文件導本地進行分析并且刪除
9、接下來使用具體工具分析
使用 JProfiler 排查
具體步驟
選擇指定的 dump 文件(后綴是hprof),使用 Jprofiler 加載,第一次可能較慢。
文件范例:D:\cjwmy1013\devlop\zoe-optimus-dia-dc-8157.hprof
如圖點擊大對象
補充說明
若軟件提示內存不夠,可以加大內存,如下:
C:\Users\cjwmy1013.jprofiler11\jprofiler.vmoptions
-Xmx4036m
-Xss2m
使用 VisualVM 排查
VisualVM是jdk自帶的jvm監測工具,主要用來監測cpu、線程和堆內存的使用情況,使用簡單,不需要額外配置,可以支持本地和遠程環境,軟件的位置在jdk的bin目錄下,找到jvisualvm.exe,雙擊打開。
監視視圖
選擇本地的運行的某個進程雙擊,選擇監視tab按鈕就可以查詢到當前進程的cpu、內存、類、線程運行信息
點擊堆Dump用來生成某一時刻的堆轉儲文件快照(.hprof),并且把堆轉儲信息轉換成堆轉儲標簽內,我們可以看到摘要、類、實例數等信息以及通過 OQL 控制臺執行查詢語句功能,幫助我們分析對象的引用關系、是否有內存泄漏情況的發生。
堆轉儲的概要包括轉儲的文件大小、路徑等基本信息,運行的系統環境信息,也可以顯示所有的線程信息。
從類視圖可以獲得各個類的實例數和占用堆大小數,分析出內存空間的使用情況,找出內存的瓶頸,避免內存的過度使用。
對兩個堆轉儲文件進行比較,通過比較我們能夠分析出兩個時間點哪些對象被大量創建或銷毀。
打開實例數視圖需要指定一個類,即在類視圖中雙擊某一個類進入該類的實例數視圖,通過下圖可以看出主要的信息包含該類的實例(對象)總數,單個實例、總實例占堆內存的大小,單個實例在堆內存中的所有字段、值,以及對象與引用對象之間的依賴關系。
線程視圖
線程視圖顯示當前進程包含的所有線程,類型分為User Thread(用戶線程)、Daemon Thread(守護線程),任何一個守護線程都是整個JVM中所有非守護線程的保姆,Daemon的作用是為其他線程的運行提供便利服務,守護線程最典型的應用就是 GC (垃圾回收器)
- 運行狀態:線程正在運行
- 休眠狀態:線程在休眠
- 等待狀態:調用Object.wait的線程,此處要注意,condtion.await并不是此狀態,而是下面的狀態。
- 駐留狀態:調用了LockSupport.park的線程就是此狀態,常見的有如下:
Lock lock = new ReentrantLock();
lock.lock();
Condition condition = lock.newCondition();
condition.await();
- 監視狀態:synchrnoiezed獲取鎖被阻塞時的狀態
如果想保存某個時間節點的線程快照信息,就點擊右上角的線程Dump,其實就是執行jstack命令,jstack命令可以生成JVM當前時刻的線程快照。線程快照是當前JVM內每一條線程正在執行的方法堆棧的集合,生成線程快照的主要目的是定位線程出現長時間停頓的原因,如線程間死鎖、死循環、請求外部資源導致的長時間等待等。
抽樣器
抽樣器可以對該進程的某個時間段的CPU使用情況進行抽樣,即點擊CPU樣例的快照按鈕。
抽樣器同樣可以對內存使用情況進行實時監測,以及保存快照信息,根據內存大小(字節數)降序排序就能快速定位出占用內存較多的類,下圖可以看出是byte[]數組占用了大量內存,如果此時還不能定位到程序哪里的問題,那就通過保存內存的快照,然后分析這個dump。
根據下圖可以看出byte[]數組實例很少,但是占用的內存很多,繼續向上查找引用它的實例,最后發現是arrayList中引用了byte[],既然問題找到了,后續的解決方法就是在程序中進行定位,優化現有代碼。
Visual GC插件
visual gc插件可以清晰的看到堆的使用情況以垃圾收集信息,工具欄選擇插件,可用插件搜索visual gc點擊安裝,
安裝完畢后,重啟visualVM,會出現如下圖:
visual gc分為兩個板塊:
1、visual gc window(即spaces區域)
Metaspace :方法區的內存,如果JDK1.8之前的版本,就是Perm,JDK7和之前的版本都是以永久
Old:老年代
新生代:由Eden、S0、S1組成
每個方框都使用不同的顏色表示,有顏色的區域是占用的空間,空白的是剩余的空間,當程序運行時,會動態的顯示
2、Graph區域,包含了以時間軸為橫坐標的狀態面板
Compile Time:編譯情況,1407 compoles - 2.644s 表示編譯總數為1407,編譯總耗時為2.644s。
一個脈沖表示一次JIT編譯,脈沖越寬表示編譯時間越長。
Class Loader Time:類加載情況,1709 loaded,0 unloaded - 696.184ms表示已加載的數量為1709,卸載的數量為0,耗時為696.184ms。
GC Time:總的(包含新生代和老年代)gc情況記錄 52 collections,708.580ms Last Cause:Allocation Failure表示一共經歷了52次gc,總共耗時708.580ms。
Eden Space:新生代Eden區內存使用情況
Survivor 0和Survivor 1:新生代的兩個Survivor區內存使用情況
Old Gen:老年代內存使用情況
Metaspace:方法區內存使用情況(最大容量,當前容量)
使用Eclipse Memory Analyzer排查
工具介紹
Java VisualVM只提供了一些基本的功能,所以我們一般不使用Java VisualVM來分析,而是使用Eclipse Memory Analyzer(MAT)來分析,MAT工具是一款強大的Java堆內存分析工具,特點是免費使用,無需安裝解壓即用,可用于查找內存泄露以及查看內存消耗情況,便于開發或運維人員快速定位內存溢出或內存泄露問題。
需要注意必須安裝jdk1.8環境才能使用
下載地址:https://www.eclipse.org/mat/previousReleases.php
解壓后,雙擊MemoryAnalyzer.exe打開
界面效果如下:
這邊寫一個小案例來演示內存不斷增加的場景:
public class Main {public static void main(String[] args) {List<Demo> list = new ArrayList<>();while (true) {list.add(new Demo());}}
}
然后設置啟動參數,讓程序內存溢出時自動生成Dump文件
-Xmx30m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump
-Xmx30m:最大堆內存為30m -XX:+HeapDumpOnOutOfMemoryError:當JVM發生OOM時,自動生成DUMP文件。 -XX:HeapDumpPath:指定文件路徑,例如:-XX:HeapDumpPath=${目錄}/java_heapdump.hprof。如果不指定文件名,默認為:java_pid.hprof
當內存溢出的時候自動在指定的目錄下生成了一個hprof文件,然后用Eclipse Memory Analyzer打開這個文件:
點擊File-》Open Heap Dump,選擇內存溢出生產的.hprof文件,默認選擇Leak Suspects Report泄漏可疑報告
導入成功之后首先來看這個界面,我們先不著急如何分析問題,先快速了解下面板上各個組件的用法,方便后面的學習。
Overview組件
OverView功能是導入了dump文件之后,對可能出現的問題做了一個整體性的分析概述,首先呈現在眼前的是以一個形狀圖的方式,快速展現了該文件中dump文件大小,以及類、對象和類加載器的數量,當光標移動到藍色區域時候,會呈現該日志文件的主要線程,類加載器信息。
![4E3B`I)FS4I4}B3NN%Z_OQ.png
總共使用的內存為24M Thread對象占用了23M,類加載器占用了206kb,其他對象占用了752kb。
Top consumers組件
展示的是占用內存比較多的對象的分布,下面是具體的一些類和占用
![MSN(4598`T$JEKW~H4RT_]5.png](https://img-blog.csdnimg.cn/img_convert/9b754529578285828253081bd16d22f1.png)
Histogram組件
Histogram 可以列出內存中的對象,對象的個數以及大小
Objects:對象的個數
Shallow Heap:對象所占用的本身內存大小(不包含引用對象)
Retained Heap:對象以及它所持有的其它引用(包括直接和間接)所占的總內存大小
當點擊進去之后,為我們呈現出dump文件中,已經創建的主要的對象信息,默認按照對象的個數進行排序,而這個排序,多少也反映出在當前的dump文件中,那些排在前面的數量最多的對象可能是我們分析問題的關鍵入口
頂部的可以支持對象名稱的模糊匹配,比如搜索Picture,可以快速找到我們需要的對象名稱。
MAT還提供了分組查看功能,方便快速定位類的信息,默認視圖就是Group by class根據類分組。
Group by superclass根據父類分組,所有類的父類都是Object,所以會生成樹形結構。
![F`3TDHG8CHI5J3`ZLLPSVM.png
Group by class loader根據類加載器分組
這里需要了解淺堆、深堆的概念,因為這兩個參數是分析OOM的一個關鍵點。
淺堆:指的是一個對象所消耗的內存(不包括內部引用的對象大小)
深堆:指的是一個對象直接訪問或者間接訪問到的所有對象的淺堆之和,即對象被回收后,可以釋放的真實空間。
淺堆、深堆總結:對象的深堆個數總是多于淺堆,實際開發過程中,可能因為編碼的習慣不好,導致某些類中,對象的引用鏈條特別長,層級也很深,搞不清楚那些對象是實際在使用的,假如正好有那么一些對象實際上并沒有使用,但是在某些循環中大量創建,尤其是大對象,在這種情況下,很容易造成GC過程的失敗最終引發OOM,所以分析這兩個參數對于快速定位那些數量較多的對象還是很有幫助的。
對象引用鏈:
查看引用當前對象的外部對象可以點擊這個右鍵—》show objects by class-》by incomming references
![KJ(783)WBOKYAUZ4VV({~7.png
查看當前對象引用的外部對象可以點擊這個右鍵—》show objects by class-》by outgoing references
![3[1)Q}RY_M]R}LKQ)KaTeX parse error: Expected 'EOF', got '}' at position 190: …問題的一個很好的點。 ![PP}?EW~X44IIRB7O03)…W.png
Thread Overview組件
Thread Overview展示出當前dump文件中,所有的線程信息,展示出了所有的線程,主線程,以及各個線程中的淺堆和深堆占用的大小,類加載器,是否守護線程等信息。
![9PZQ%HKT$Y2FYPSGMQ84AM.png
以main線程為例,列舉出了里面的成員變量的信息,比如在當前的main線程中,有一個成員變量包含了很多對象,并且淺堆和深堆的大小都很大。
Domainator Tree組件
Domainator Tree指的是支配樹的對象圖,支配樹體現了對象實例之間的支配關系,理解支配樹的目的是,在我們分析dump文件時,可以通過支配樹,清楚的知道某個對象引用的對象情況。
Retained Heap表示這個對象以及它所持有的其它引用(包括直接和間接)所占的總內存,因此從上圖中看,前兩行的Retained Heap是最大的,我們分析內存泄漏時,內存最大的對象也是最應該去懷疑的。
Leak Suspects組件
點擊Leak Suspects查看具體的內存泄露報告,MAT分析工具會根據你導入的dump文件,快速生成一份懷疑報告,將可能出現內存泄露的點展示出來,便于開發或運維人員進行問題定位。
在內存泄漏報告中Thread Stack可以清晰的看到內存溢出代碼的位置,對于快速定位程序問題非常方便友好
常用分析技巧
第一種:通過Thread Overview線程視圖,按照Retained Heap深堆大小排序,展開線程查看,從調用棧中找到當前服務的代碼。
第二種:通過histogram內存大小直方圖,選擇group by package按照包路徑分組,再按照Retained Heap深堆大小排序,找到當前服務的代碼。
第三種:通過Dominator Tree 支配樹,按照Retained Heap深堆大小排序,然后查看深堆最大的線程在持有哪些大對象,接著在該對象右鍵查看線程詳細堆棧信息。