一、背景
1 前言
遇到過幾次JVM堆外內存泄露的問題,每次問題的排查、修復都耗費了不少時間,問題持續幾月、甚至一兩年。
我們將這些排查的思路梳理成一套系統的方法,希望能給對JVM內存分布、內存泄露問題有更清晰的理解。
2 這篇文章能帶給你什麼
1.了解JVM的內存分布.
2.更合理地去設置JVM參數。
3.能大大提升排查JVM內存問題的效率。
3 本文的限定範圍
JDK版本
JDK8,其他JDK版本可能有所差異。
重點講解堆外內存
堆內的內存問題文章比較多,一般是dump堆內存,然後分析即可。
4 文章講解的順序
1.講解JVM內存分布,了解有哪些內存區域、JVM參數等。堆內相關的文章比較多,堆外的比較少,所以重點講解堆外的。
2.講解排查JVM內存問題的思路。
二、JVM內存分布
1 JVM內存分布
【重點中的重點】JVM內存分布圖
總體分為堆內內存、堆外內存。
三、【重點】Heap Space(堆內內存)
重點關注新生代、老年代。
1 Young Generation新生代
用於存放新創建的對象,分為一個Eden區和兩個Survivor區。
當Young GC發生時會回收該塊內存。
2 老年代(Old Generation)
2.1作用
主要用於存放生命周期較長的對象。
2.2何時回收
當Old GC發生時會回收該塊內存,一般觸發Old GC時會伴隨著一次Young GC。
2.3參數
-Xmx: 新生代的內存大小
-Xms: Heap的初始大小
-Xmx: Heap的最大大小
2.4 問答
配置了Xms,那是不是JVM一啟動就使用了這麼多的物理內存來劃分給Heap?
分情況而定:
(1) 如果未配置了-XX:AlwaysPreTouch,則實際是使用的是虛擬內存,給了一張空頭支票,只在首次訪問時,例如存放一批新的Java對象數據,但原來申請的內存不夠用了,需要新的內存來,這時才需要分配物理內存,也就是通過缺頁異常進入內核中,再由內核來分配內存,再交給JVM進程使用。
一般情況,不會配置-XX:AlwaysPreTouch。
(2) 如果配置了-XX:AlwaysPreTouch,則JVM啟動時,則不僅分配Xms的大小的虛擬內存,還會使用物理內存、填充整個堆。
配置-XX:AlwaysPreTouch可以提前申請好物理內存,減少程序運行過程中發生的物理內存分配帶來的延遲,可以提升性能。例如部署ElasticSearch節點時,可以指定該參數,提升性能。
XMX設置多大合適?
一般的應用,XMX可以設置為物理內存的1/2到2/3,較充分地去利用內存。
需要較多地使用Heap外內存應用,物理內存不要超過1/2,例如ElasticSearch、RocketMQ-broker、Kafka等中間件,需要大量讀寫文件,作業系統需要大量的Page Cache,才能有足夠的緩存提高性能,所以JVM Heap不要過大,以預留給非Heap的其他內存。
四、【重點】Non-Heap Space(非堆內存、堆外內存)
1 什麼是堆外內存
Non-Heap Space 翻譯為非堆內存,也被稱為Off-Heap(堆外內存),大家習慣於叫這部分內存為堆外內存。查看了很多國內外文章,對於這塊內存,沒有很統一的定義。
較可信的是分為下面兩種定義:
(1) 廣義上的Non-Heap
除開Heap以外的所有內存,包括MetaSpace、NativeMemory(JNI Memory、Direct Memory等)、Stack、Code Cache等。
下面講解的Non-Heap是針對於廣義的定義。
(2) 狹義上的Non-Heap
只包含Metaspace、code_cache。
注意:
監控系統里會有Non-Heap的監控,例如SkyWalking、Arthas的Non-Heap指標,都是通過JDK自帶的MemoryMXBean方法獲取的。
所以一般監控系統採集的Non-Heap只是Heap以外的一部分內存!還需要留意NativeMemory等等內存。
監控數據示例;
對應的代碼:
java複製代碼@Override
public long getNonHeapMemoryMax() {
return memoryMXBean.getNonHeapMemoryUsage().getMax();
}
@Override
public long getNonHeapMemoryUsed() {
return memoryMXBean.getNonHeapMemoryUsage().getUsed();
}
2 【重點】MetaSpace(元數據空間)
用於存儲類元數據(如類定義和方法定義)的內存區域。Metaspace 在 JDK 8 中取代了永久代(PermGen)。
2.1 相關參數
-XX:MetaspaceSize=<size>
-XX:MaxMetaspaceSize=<size>
-XX:MetaspaceSize 參數設置了元空間的初始大小,在 JDK 8 中,-XX:MetaspaceSize 參數的默認值為 21 MB。。當元空間使用量達到這個值時,JVM 將觸發 Full GC(也會附帶younggc) 來嘗試回收不再需要的類元數據以及相關資源。
如果回收後元空間仍然無法滿足需求,那麼 JVM 將嘗試擴展元空間的大小。
問答:很多同學奇怪,我們有時看到某些應用啟動一段時候,堆內存使用量不高,為何會發生一次FULL GC?
這很可能是因為應用的JVM參數裡沒有設置-XX:MetaspaceSize,或者-XX:MetaspaceSize設置的比較小。
-XX:MaxMetaspaceSize 參數設置了元空間的最大大小。元空間會根據需要動態擴展,但不會超過這個設置的最大值。當元空間使用量超過這個值時,JVM 將觸發 Full GC(也會附帶younggc),嘗試回收不再需要的類元數據以及相關資源。如果回收後元空間仍然無法滿足需求,那麼 JVM 將拋出java.lang.OutOfMemoryError: Metaspace錯誤。因此,這個參數既與 Full GC 相關,也與 OOM 相關。
2.2 問答
如何合理設置-XX:MaxMetaspaceSize參數?
建議JVM啟動參數指定-XX:MaxMetaspaceSize,一般大小256M足夠,因為默認值無限大,如果出現頻繁加載class等情況,容易出現OOM。
2.2 OOM異常
OOM報錯: java.lang.OutOfMemoryError: Metaspace
3 Native Memory(本地內存)
3.1 Direct Memory(直接內存)
是Java NIO 框架引入的一種內存分配機制,允許在堆外分配內存以便更高效地執行 I/O 操作,通常用於NIO網絡編程,JVM使用該內存作為緩衝區,提升I/O性能。
創建 Direct Buffer 的方法
byteBuffer.allocateDirect()
該方法分配內存:內部用的是unsafe.allocateMemory(size)方法,但不屬於Java NIO庫的一部分,
且jdk官方不推薦直接使用unsafe.allocateMemory(size)方法,該方法不受-XX:MaxDirectMemorySize參數控制,容易導致內存被無節制地使用,所以推薦ByteBuffer.allocateDirect()方法分配內存。
相關參數
-XX:MaxDirectMemorySize=<size>
如果未設置-XX:MaxDirectMemorySize,默認值等於Xmx。
可指定最大直接內存大小,DirectMemory會超過MaxDirectMemorySize前,觸發FULL GC(也會附帶Young GC),堆內DirectByteBuffer等會對象回收時,會觸發對象的clean邏輯,釋放該對象關聯的DirectMemory,當gc後還是不夠,就會OOM。
問答:如何合理設置-XX:MaxDirectMemorySize參數?
因為默認值等於Xmx,所以建議指定一下MaxDirectMemorySize,Netty等框架會用到DirectMemory,且一般設置1G足夠。
框架和中間件
Netty(底層使用Java NIO技術)、Java NIO庫(Java NIO庫本身使用直接緩衝區進行高性能網絡和文件I/O操作)等。
當申請堆外內存時,NIO 和 Netty 會比較計數器欄位和最大值的大小,如果計數器的值超過了最大值的限制,會拋出 OOM 的異常。
OOM結果
NIO 中是:OutOfMemoryError: Direct buffer memory。
Netty 中是:OutOfDirectMemoryError: failed to allocate capacity byte(s) of direct memory (used: usedMemory , max: DIRECT_MEMORY_LIMIT )
3.2 JNI Memory(JNI內存)
JNI (Java Native Interface) memory是指Java應用程式與本地代碼交互時使用的內存。Java Native Interface (JNI) 是 Java 與本地(如 C 或 C++)代碼進行交互的橋樑。
JNI方法
使用方式:在Java中使用native關鍵字定義方法,並在C/C++代碼中實現相關的本地方法。
示例:
java複製代碼private native int inflateBytes(long addr, byte[] b, int off, int len);
該native方法內部也會申請內存用以存儲數據,這部分內存屬於JNI內存的一部分。
參數
無特定的 JVM 參數,但需要在本地代碼中管理內存分配和釋放。
注意:與-XX:MaxDirectMemorySize=無關。
JNI內存分配過程
4 Stack(棧內存)
4.1 Stack介紹
- 用於存儲線程執行過程中的局部變量、方法調用、操作數棧等。
- 棧內存由JVM自動管理,每個線程都有一個獨立的棧。
- 棧內存與堆內存相互獨立,它們之間不共享數據。
- 分為VM Stack(Java虛擬機棧)、Native Stack(本地方法棧)
4.2 分類
(1) VM Stack(Java虛擬機棧)
用於存儲線程執行Java方法時所需的信息。
當一個方法執行完成後,其對應的棧幀會從棧中彈出,釋放該方法所占用的內存空間。
每個線程對應一個Java線程棧,大小由-Xss參數控制,默認是1M,當超過1M會報錯StackOverFlowError。
(2) Native Stack(本地方法棧)
用於存儲本地方法(通過Java Native Interface,JNI調用的方法)的信息。
本地方法棧與Java虛擬機棧的主要區別在於,它是為本地方法提供內存空間,而不是Java方法。
5 特殊內存
5.1 mmap
介紹
底層用的作業系統的mmap,將文件或文件的一部分映射到內存中的技術,通過內存映射文件可以實現高效的文件讀寫操作。
使用方式
java複製代碼FileChannel FileChannel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ, StandardOpenOption.WRITE); // 以讀寫的方式打開文件通道
MappedByteBuffer buf = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size()); // 將整個文件映射到內存
參數
無特定的 JVM 參數。
注意:與-XX:MaxDirectMemorySize=無關。
框架和中間件
Lucene、RocketMQ、Kafka等。
注意
mmap不屬於JVM進程占用的內存!
當使用java.nio.channels.FileChannel#map方法時,分配的內存實際上是由作業系統管理的,並不是由JVM管理。這部分內存是映射到文件的內存區域,又稱為內存映射文件(Memory-Mapped File)。在作業系統中,這部分內存被分類為文件緩存,而非Java進程的私有內存。
內存映射文件允許將文件或文件的一部分映射到進程的地址空間。一旦建立了映射,進程可以像訪問常規內存一樣訪問文件。作業系統會負責將對映射內存的更改寫回磁碟。
因此,當你使用一些命令(如ps、top)查看Java進程的內存使用時,這部分內存映射文件的使用量並不會直接計算到進程的私有內存中。這部分內存使用在某種程度上是透明的,但仍然受作業系統的文件緩存管理。
在Linux系統中,可以通過查看**/proc/meminfo**文件來獲取關於內存映射文件的信息。
該結論基於實驗:使用mmap方式寫入2G文件,用arthas的memory命令查看JVM進程對應mmap使用量,已經是2G,但實際JVM的內存占有量,只有703M,這是因為mmap的內存是由作業系統控制的,不算在進程占用。
內存分配過程
五、【重點】內存排查工具
1 堆內內存相關工具
整理了堆內內存相關的工具。
建議從上往下逐一執行命令,從整體到局部,逐步排查出具體的問題。
2 堆外內存相關工具
不同的內存區域可以使用不同的命令進行排查,同時也留意合理設置對應內存區域的參數。
六、JVM內存使用量過大問題排查思路
1 整體的排查思路
使用量大原因一般分為
1.數據量大,自然使用量大
2.JVM內存泄露,導致可以釋放的內存未釋放
JVM內存泄露:
在JVM運行過程中,由於(1)未正確釋放不再使用的內存 (2)或者執行內存釋放步驟後內存卻未回收,導致內存占用持續增長,甚至最終耗盡導致OOM(內存溢出)的現象
發現問題、提前預知問題
依賴於監控告警:falcon、prometheus、troy等,主要是內存、GC相關
發現問題、提前預知問題
先止損,一般處理方式是通過重啟,或者手動觸發fullgc。
保留現場
如果條件允許一定不要直接操作重啟、回滾等動作恢復,優先通過摘掉流量的方式來恢復,例如:通過dubbo控制台將某個provider實例禁止訪問。
然後將堆(手工dump、或者指定-XX:+HeapDumpOnOutOfMemoryError)、棧(jstack命令導出)、GC 日誌等關鍵信息保留下來,不然錯過了定位根因的時機,後續想要復現、解決的難度將大大增加。
確定是那個進程的問題
當出現內存問題時,需要確認是那個進場的問題。
當發生進程A被作業系統的OOM-killer殺掉時,可能不是A的問題,可能是進程B占用內存過多,導致系統內存不夠用,
然後觸發OOM-killer計算出oom分數(根據內存、進程運行時間等打分,參考文檔),選擇殺掉了進程A。
分析日誌
分析應用日誌是否有outofmemory等關鍵字;
分析系統日誌/var/log/messages或者dmesg觀察outofmemory的情況、進程運行的記錄;
分析應用GC日誌;
查找不同內存區域占比、判斷可疑的內存
根據命令、監控平台,逐個分析內存區域大戶:Heap、MetaSpace、DirectMemory、JNI Memory。
分析可疑內存數據內容
分析內存占用大的區域中的數據,也可以輔助定位對應源碼。
分析可疑內存調用棧
對於java而言,推薦使用arthas的trace和stack命令,但是arthas無法對native方法進行攔截,此時可以藉助jstack或者arthas攔截可能調用native方法的上層方法。
對於JNI Memory,這塊內存是C、C++等native方法相關的,需要用gperftools、gdb等工具進行分析。
復現問題
在沒有了解問題原因、內存增長規律的情況下,想要復現問題,有時是很困難的!可能要花費很長時間、且需要些運氣。
所以我們儘量保留問題現場,方便找出規律。
內存泄漏按發生方式來分類:
按發生方式來分類 |
說明和示例 |
復現難度 |
周期性增長 |
例如有的可能是定時任務觸發才發生,但定時任務可能一周才跑一次 |
周期越長,排查難度越大。 |
常發性內存泄漏 |
發生內存泄漏的代碼會被多次執行到,每次被執行的時候都會導致一塊內存泄漏 |
容易重現 |
偶發性 |
發生內存泄漏的代碼只有在某些特定環境或操作過程下才會發生。例如:只有某個執行步驟中執行到某個if段才會發生 |
一般難度較大 |
常發性內存泄漏 |
|
一般難度較大 |
一次性內存泄漏 |
發生內存泄漏的代碼只會被執行一次。例如,只有應用啟動過程中,或者某個類初始化時才會發生 |
一般難度較大 |
隱式內存泄漏 |
程序在運行過程中不停的分配內存,只有在特殊情況下才會回收。(1) 需要到達一個極限才會進行回收,例如:如果沒有設置metaspace最大大小,但是一直加載class,當觸發fullgc可回收metaspace,但是直到內存不夠也未能觸發過fullgc。(2) 存在內存碎片,雖然執行了釋放內存的步驟,但是實際並未是否內存。例如ptmalloc內存分配庫導致的內存泄露問題。 |
一般難度較大 |
修復問題
JVM內存問題一般是代碼問題、JVM參數問題、malloc內存分配庫等,針對不同類型的問題進行修復。
七、案例
案例遇到比較多:
1.(1) 不合理地使用fastjson,導致頻繁地在創建、加載class (2)未設置-XX:MaxMetaspaceSize 導致了內存一直增長,直到OOM
2.JNI Memory內存泄漏
3.JVM參數-XX:SoftRefLRUPolicyMSPerMB和metaspace導致的fullgc
4.vim命令編輯文件導致的業務應用的進程被oom-killer殺掉
案例需要比較長的文章來說明,這些後續再另外寫文章補充吧。
八、總結
- 首先是看這張圖,了解JVM內存的分布。
- 遇到內存問題,先根據通用的排查思路一遍內存的使用情況。
- 有很多JDK、Linux內存相關的命令,大家可以去嘗試一下,先查大範圍的內存占用,再逐步定位到具體的內存區域、代碼、參數等。
- 重啟程序、系統能臨時解決很多內存問題,但是,建議去深究一下,會學到很多JVM內存管理和Linux內存管理的知識,還是很有趣的。
- 此外,掌握了JVM內存管理的設計後,發現很多程序的內存是比較浪費的,可以對JVM參數做針對性優化,能減少很多機器資源。