一起探秘JVM內存問題(OOM、內存泄漏、堆外內存等)

程序猿凱撒 發佈 2023-06-07T03:38:53.058480+00:00

一、背景1 前言遇到過幾次JVM堆外內存泄露的問題,每次問題的排查、修復都耗費了不少時間,問題持續幾月、甚至一兩年。我們將這些排查的思路梳理成一套系統的方法,希望能給對JVM內存分布、內存泄露問題有更清晰的理解。2 這篇文章能帶給你什麼1.了解JVM的內存分布.2.

一、背景

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介紹

  1. 用於存儲線程執行過程中的局部變量、方法調用、操作數棧等。
  2. 棧內存由JVM自動管理,每個線程都有一個獨立的棧
  3. 棧內存與堆內存相互獨立,它們之間不共享數據。
  4. 分為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參數做針對性優化,能減少很多機器資源。
關鍵字: