上一講我介紹了 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 官方教程](https://docs.oracle.com/javase/7/docs/technotes/guides/management/jconsole.html)。我這里特別推薦[Java Mission Control](http://www.oracle.com/technetwork/java/javaseproducts/mission-control/java-mission-control-1998576.html)(JMC),這是一個非常強大的工具,不僅僅能夠使用[JMX](https://en.wikipedia.org/wiki/Java_Management_Extensions)進行普通的管理、監控任務,還可以配合[Java Flight Recorder](https://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/about.htm#JFRUH171)(JFR)技術,以非常低的開銷,收集和分析 JVM 底層的 Profiling 和事件等信息。目前, Oracle 已經將其開源,如果你有興趣請可以查看 OpenJDK 的[Mission Control](http://openjdk.java.net/projects/jmc/)項目。
關于內存監控與診斷,我會在知識擴展部分結合 JVM 參數和特性,盡量從龐雜的概念和 JVM 參數選項中,梳理出相對清晰的框架:
* 細化對各部分內存區域的理解,堆內結構是怎樣的?如何通過參數調整?
* 堆外內存到底包括哪些部分?具體大小受哪些因素影響?
## 知識擴展
今天的分析,我會結合相關 JVM 參數和工具,進行對比以加深你對內存區域更細粒度的理解。
首先,堆內部是什么結構?
對于堆內存,我在上一講介紹了最常見的新生代和老年代的劃分,其內部結構隨著 JVM 的發展和新 GC 方式的引入,可以有不同角度的理解,下圖就是年代視角的堆結構示意圖。

你可以看到,按照通常的 GC 年代方式劃分,Java 堆內分為:
1\. 新生代
新生代是大部分對象創建和銷毀的區域,在通常的 Java 應用中,絕大部分對象生命周期都是很短暫的。其內部又分為 Eden 區域,作為對象初始分配的區域;兩個 Survivor,有時候也叫 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 實現了復雜的適應策略,如果你有興趣可以參考這篇[說明](https://blogs.oracle.com/jonthecollector/the-real-thing)。
不知道你有沒有注意到,我在年代視角的堆結構示意圖也就是第一張圖中,還標記出了 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
~~~
* 下面是 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。

* 接下來是 Code 統計信息,顯然這是 CodeCache 相關內存,也就是 JIT compiler 存儲編譯熱點方法等信息的地方,JVM 提供了一系列參數可以限制其初始值和最大值等,例如:
~~~
-XX:InitialCodeCacheSize=value
~~~
~~~
-XX:ReservedCodeCacheSize=value
~~~
你可以設置下列 JVM 參數,也可以只設置其中一個,進一步判斷不同參數對 CodeCache 大小的影響。


很明顯,CodeCache 空間下降非常大,這是因為我們關閉了復雜的 TieredCompilation,而且還限制了其初始大小。
* 下面就是 GC 部分了,就像我前面介紹的,G1 等垃圾收集器其本身的設施和數據結構就非常復雜和龐大,例如 Remembered Set 通常都會占用 20%~30% 的堆空間。如果我把 GC 明確修改為相對簡單的 Serial GC,會有什么效果呢?
使用命令:
~~~
-XX:+UseSerialGC
~~~

可見,不僅總線程數大大降低(25 → 13),而且 GC 設施本身的內存開銷就少了非常多。據我所知,AWS Lambda 中 Java 運行時就是使用的 Serial GC,可以大大降低單個 function 的啟動和運行開銷。
* Compiler 部分,就是 JIT 的開銷,顯然關閉 TieredCompilation 會降低內存使用。
* 其他一些部分占比都非常低,通常也不會出現內存使用問題,請參考[官方文檔](https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr022.html#BABCBGFA)。唯一的例外就是 Internal(JDK 11 以后在 Other 部分)部分,其統計信息**包含著 Direct Buffer 的直接內存**,這其實是堆外內存中比較敏感的部分,很多堆外內存 OOM 就發生在這里,請參考專欄第 12 講的處理步驟。原則上 Direct Buffer 是不推薦頻繁創建或銷毀的,如果你懷疑直接內存區域有問題,通常可以通過類似 instrument 構造函數等手段,排查可能的問題。
JVM 內部結構就介紹到這里,主要目的是為了加深理解,很多方面只有在定制或調優 JVM 運行時才能真正涉及,隨著微服務和 Serverless 等技術的興起,JDK 確實存在著為新特征的工作負載進行定制的需求。
今天我結合 JVM 參數和特性,系統地分析了 JVM 堆內和堆外內存結構,相信你一定對 JVM 內存結構有了比較深入的了解,在定制 Java 運行時或者處理 OOM 等問題的時候,思路也會更加清晰。JVM 問題千奇百怪,如果你能快速將問題縮小,大致就能清楚問題可能出在哪里,例如如果定位到問題可能是堆內存泄漏,往往就已經有非常清晰的[思路和工具](https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks004.html#CIHIEEFH)可以去解決了。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?今天的思考題是,如果用程序的方式而不是工具,對 Java 內存使用進行監控,有哪些技術可以做到?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?