1. 程式人生 > >26、如何監控和診斷JVM堆內和堆外記憶體使用?

26、如何監控和診斷JVM堆內和堆外記憶體使用?

目錄

今天我要問你的問題是,如何監控和診斷 JVM 堆內和堆外記憶體使用?

典型回答

考點分析

知識擴充套件

首先,堆內部是什麼結構?

1. 新生代

2. 老年代

3. 永久代

第二,分析完堆內空間,我們一起來看看 JVM 堆外記憶體到底包括什麼?

一課一練


上一講我介紹了 JVM 記憶體區域的劃分,總結了相關的一些概念,今天我將結合 JVM 引數、工具等方面,進一步分析 JVM 記憶體結構,包括外部資料相對較少的堆外部分。

今天我要問你的問題是,如何監控和診斷 JVM 堆內和堆外記憶體使用?

典型回答

瞭解 JVM 記憶體的方法有很多,具體能力範圍也有區別,簡單總結如下:

  •   可以使用綜合性的圖形化工具,如 JConsole、VisualVM(注意,從 Oracle JDK 9 開始,VisualVM 已經不再包含在 JDK  安裝包中)等。這些工具具體使用起來相對比較直觀,直接連線到 Java 程序,然後就可以在圖形化介面裡掌握記憶體使用情況。

 

以 JConsole 為例,其記憶體頁面可以顯示常見的堆記憶體和各種堆外部分使用狀態。

  •   也可以使用命令列工具進行執行時查詢,如 jstat 和 jmap 等工具都提供了一些選項,可以檢視堆、方法區等使用資料。
  •   或者,也可以使用 jmap 等提供的命令,生成堆轉儲(Heap Dump)檔案,然後利用 jhat 或 Eclipse MAT 等堆轉儲分析工具進行詳細分析。
  •   如果你使用的是 Tomcat、Weblogic 等 Java EE 伺服器,這些伺服器同樣提供了記憶體管理相關的功能。
  •   另外,從某種程度上來說,GC 日誌等輸出,同樣包含著豐富的資訊

這裡有一個相對特殊的部分,就是是堆外記憶體中的直接記憶體,前面的工具基本不適用,可以使用 JDK 自帶的 Native Memory Tracking(NMT)特性,它會從 JVM 本地記憶體分配的角度進行解讀。

 

考點分析

今天選取的問題是 Java 記憶體管理相關的基礎實踐,對於普通的記憶體問題,掌握上面我給出的典型工具和方法就足夠了。這個問題也可以理解為考察兩個基本方面能力,第一,你是否真的理解了 JVM 的內部結構;第二,具體到特定記憶體區域,應該使用什麼工具或者特性去定位,可以用什麼引數調整。

對於 JConsole 等工具的使用細節,我在專欄裡不再贅述,如果你還沒有接觸過,你可以參考JConsole 官方教程。我這裡特別推薦Java Mission Control(JMC),這是一個非常強大的工具,不僅僅能夠使用JMX進行普通的管理、監控任務,還可以配合Java Flight Recorder(JFR)技術,以非常低的開銷,收集和分析 JVM 底層的 Profiling 和事件等資訊。目前, Oracle 已經將其開源,如果你有興趣請可以檢視 OpenJDK 的Mission Control專案。

關於記憶體監控與診斷,我會在知識擴充套件部分結合 JVM 引數和特性,儘量從龐雜的概念和 JVM 引數選項中,梳理出相對清晰的框架:

  •   細化對各部分記憶體區域的理解,堆內結構是怎樣的?如何通過引數調整?
  •   堆外記憶體到底包括哪些部分?具體大小受哪些因素影響?


知識擴充套件

今天的分析,我會結合相關 JVM 引數和工具,進行對比以加深你對記憶體區域更細粒度的理解。

 

首先,堆內部是什麼結構?

對於堆記憶體,我在上一講介紹了最常見的新生代和老年代的劃分,其內部結構隨著 JVM 的發展和新 GC 
方式的引入,可以有不同角度的理解,下圖就是年代視角的堆結構示意圖。


你可以看到,按照通常的 GC 年代方式劃分,Java 堆內分為:

1. 新生代

新生代是大部分物件建立和銷燬的區域,在通常的 Java 應用中,絕大部分物件生命週期都是很短暫的。其內部又分為 Eden 區域,作為物件初始分配的區域;兩個 Survivor Space,有時候也叫 from、to 區域,被用來放置從 Minor GC 中保留下來的物件。

  •   JVM 會隨意選取一個 Survivor 區域作為“to”,然後會在 GC 過程中進行區域間拷貝,也就是將 Eden 中存活下來的物件和 from  區域的物件,拷貝到這個“to”區域。這種設計主要是為了防止記憶體的碎片化,並進一步清理無用物件。
  •   從記憶體模型而不是垃圾收集的角度,對 Eden 區域繼續進行劃分,Hotspot JVM 還有一個概念叫做 Thread Local Allocation Buffer(TLAB),據我所知所有 OpenJDK 衍生出來的 JVM 都提供了 TLAB 的設計。這是 JVM  為每個執行緒分配的一個私有快取區域,否則,多執行緒同時分配記憶體時,為避免操作同一地址,可能需要使用加鎖等機制,進而影響分配速度,你可以參考下面的示意圖。從圖中可以看出,TLAB 仍然在堆上,它是分配在 Eden 區域內的。其內部結構比較直觀易懂,start、end 就是起始地址,top(指標)則表示已經分配到哪裡了。所以我們分配新物件,JVM 就會移動 top,當 top 和 end 相遇時,即表示該快取已滿,JVM 會試圖再從 Eden 裡分配一塊兒。

 

2. 老年代

放置長生命週期的物件,通常都是從 Survivor 區域拷貝過來的物件。當然,也有特殊情況,我們知道普通的物件會被分配在 TLAB 上;如果物件較大,JVM 會試圖直接分配在 Eden 其他位置上;如果物件太大,完全無法在新生代找到足夠長的連續空閒空間,JVM 就會直接分配到老年代。

 

3. 永久代

這部分就是早期 Hotspot JVM 的方法區實現方式了,儲存 Java 類元資料、常量池、Intern 字串快取,在 JDK 8 
之後就不存在永久代這塊兒了。

那麼,我們如何利用 JVM 引數,直接影響堆和內部區域的大小呢?我來簡單總結一下:

  •   最大堆體積
-Xmx value

 

  • 初始的最小堆體積
-Xms value

 

  • 老年代和新生代的比例
-XX:NewRatio=value

預設情況下,這個數值是 2,意味著老年代是新生代的 2 倍大;換句話說,新生代是堆大小的 1/3。

 

  •   當然,也可以不用比例的方式調整新生代的大小,直接指定下面的引數,設定具體的記憶體大小數值。
-XX:NewSize=value

 

  • Eden 和 Survivor 的大小是按照比例設定的,如果 SurvivorRatio 是 8,那麼 Survivor 區域就是 Eden 的 1/8 大小,也就是新生代的 1/10,因為 YoungGen=Eden + 2*Survivor,JVM 引數格式是
-XX:SurvivorRatio=value

 

  • TLAB 當然也可以調整,JVM 實現了複雜的適應策略,如果你有興趣可以參考這篇說明。

例題:

對於JVM記憶體配置引數:-Xmx10240m -Xms10240m -Xmn5120m -XX:SurvivorRatio=3,其最小記憶體值和Survivor區總大小分別是( )

A 5120m,1024m

B 5120m,2048m

C 10240m,1024m

D 10240m,2408m



正確答案是:D

解析:

JVM引數配置中:

-Xmx指定 jvm 的最大堆大小。

-Xms指定 jvm 的初始堆大小。

-Xmn指定 jvm 中年輕代(New Generation)的大小。

-XX:SurvivorRatio:指定年輕代中Eden區與Survivor區的大小比值。



由題目得年輕代為5120m,年輕代中Eden區與Survivor區的大小比值為3(-XX:SurvivorRatio=3),
而Survivor區有兩個,即將年輕代看做5份,每個Survivor區佔一份,Eden區佔3份,
Survivor區大小=5120/5=1024m,Survivor區總大小為2048m。

-Xms初始堆大小即最小記憶體值為10240m。

 

不知道你有沒有注意到,我在年代視角的堆結構示意圖也就是第一張圖中,還標記出了 Virtual 區域,這是塊兒什麼區域呢?

在 JVM 內部,如果 Xms 小於 Xmx,堆的大小並不會直接擴充套件到其上限,也就是說保留的空間(reserved)大於實際能夠使用的空間(committed)。當記憶體需求不斷增長的時候,JVM 會逐漸擴充套件新生代等區域的大小,所以 Virtual 區域代表的就是暫時不可用(uncommitted)的空間。

 

第二,分析完堆內空間,我們一起來看看 JVM 堆外記憶體到底包括什麼?

在 JMC 或 JConsole 的記憶體管理介面,會統計部分非堆記憶體,但提供的資訊相對有限,下圖就是 JMC 活動記憶體池的截圖。


接下來我會依賴 NMT 特性對 JVM 進行分析,它所提供的詳細分類資訊,非常有助於理解 JVM 內部實現。

首先來做些準備工作,開啟 NMT 並選擇 summary 模式,

-XX:NativeMemoryTracking=summary

為了方便獲取和對比 NMT 輸出,選擇在應用退出時列印 NMT 統計資訊

-XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics

然後,執行一個簡單的在標準輸出列印 HelloWorld 的程式,就可以得到下面的輸出


我來仔細分析一下,NMT 所表徵的 JVM 本地記憶體使用:

  •   第一部分非常明顯是 Java 堆,我已經分析過使用什麼引數調整,不再贅述。
  •   第二部分是 Class 記憶體佔用,它所統計的就是 Java 類元資料所佔用的空間,JVM 可以通過類似下面的引數調整其大小:
-XX:MaxMetaspaceSize=value

對於本例,因為 HelloWorld 沒有什麼使用者類庫,所以其記憶體佔用主要是啟動類載入器(Bootstrap)載入的核心類庫。你可以使用下面的小技巧,調整啟動類載入器元資料區,這主要是為了對比以加深理解,也許只有在 hack JDK 時才有實際意義。

-XX:InitialBootClassLoaderMetaspaceSize=30720

 

1、下面是 Thread,這裡既包括 Java 執行緒,如程式主執行緒、Cleaner 執行緒等,也包括 GC 等本地執行緒。你有沒有注意到,即使是一個 HelloWorld 程式,這個執行緒數量竟然還有 25。似乎有很多浪費,設想我們要用 Java 作為 Serverless 執行時,每個 function 是非常短暫的,如何降低執行緒數量呢?


如果你充分理解了專欄講解的內容,對 JVM 內部有了充分理解,思路就很清晰了:

  •   JDK 9 的預設 GC 是 G1,雖然它在較大堆場景表現良好,但本身就會比傳統的 Parallel GC 或者 Serial GC 之類複雜太多,所以要麼降低其並行執行緒數目,要麼直接切換 GC 型別;
  •   JIT 編譯預設是開啟了 TieredCompilation 的,將其關閉,那麼 JIT 也會變得簡單,相應本地執行緒也會減少。

我們來對比一下,這是預設引數情況的輸出:


下面是替換了預設 GC,並關閉 TieredCompilation 的命令列


得到的統計資訊如下,執行緒數目從 25 降到了 17,消耗的記憶體也下降了大概 1/3。

 

2、 接下來是 Code 統計資訊,顯然這是 Code Cache 相關記憶體,也就是 JIT compiler 儲存編譯熱點方法等資訊的地方,JVM 提供了一系列引數可以限制其初始值和最大值等,例如:

-XX:InitialCodeCacheSize=value
-XX:ReservedCodeCacheSize=value

你可以設定下列 JVM 引數,也可以只設置其中一個,進一步判斷不同引數對 Code Cache 大小的影響。

很明顯,Code Cache 空間下降非常大,這是因為我們關閉了複雜的 TieredCompilation,而且還限制了其初始大小。

 

3、下面就是 GC 部分了,就像我前面介紹的,G1 等垃圾收集器其本身的設施和資料結構就非常複雜和龐大,例如 Remembered Set 通常都會佔用 20%~30% 的堆空間。如果我把 GC 明確修改為相對簡單的 Serial GC,會有什麼效果呢?

使用命令:

-XX:+UseSerialGC

可見,不僅匯流排程數大大降低(25 → 13),而且 GC 設施本身的記憶體開銷就少了非常多。據我所知,AWS Lambda 中 Java 執行時就是使用的 Serial GC,可以大大降低單個 function 的啟動和執行開銷。

  •   Compiler 部分,就是 JIT 的開銷,顯然關閉 TieredCompilation 會降低記憶體使用。
  •   其他一些部分佔比都非常低,通常也不會出現記憶體使用問題,請參考官方文件。唯一的例外就是 Internal(JDK 11 以後在 Other 部分)部分,其統計資訊包含著 Direct Buffer 的直接記憶體,這其實是堆外記憶體中比較敏感的部分,很多堆外記憶體 OOM 就發生在這裡,請參考專欄第 12 講的處理步驟。原則上 Direct Buffer 是不推薦頻繁建立或銷燬的,如果你懷疑直接記憶體區域有問題,通常可以通過類似 instrument  建構函式等手段,排查可能的問題。


JVM 內部結構就介紹到這裡,主要目的是為了加深理解,很多方面只有在定製或調優 JVM 執行時才能真正涉及,隨著微服務和 Serverless 等技術的興起,JDK 確實存在著為新特徵的工作負載進行定製的需求。

今天我結合 JVM 引數和特性,系統地分析了 JVM 堆內和堆外記憶體結構,相信你一定對 JVM 記憶體結構有了比較深入的瞭解,在定製 Java 執行時或者處理 OOM 等問題的時候,思路也會更加清晰。JVM 問題千奇百怪,如果你能快速將問題縮小,大致就能清楚問題可能出在哪裡,例如如果定位到問題可能是堆記憶體洩漏,往往就已經有非常清晰的思路和工具可以去解決了。

 

一課一練

關於今天我們討論的題目你做到心中有數了嗎?今天的思考題是,如果用程式的方式而不是工具,對 Java 記憶體使用進行監控,有哪些技術可以做到?

答:利用JMX MXbean公開出來的api。