本地記憶體跟蹤NMT詳解
出處
1. Overview
為什麼java程式消耗的記憶體,遠超-Xms、-Xmx的限制?因為各種原因,或是為了進行某些優化,JVM會額外分配記憶體。這些額外的分配,會導致java程式佔用的記憶體,超出-Xmx的限制。
本文件列舉了通常情況下,JVM會分配哪幾部分記憶體,以及各部分調整大小的方法。然後,瞭解如何使用Native Memory Tracking工具進行監控。
2. Native Allocations
通常,heap是java程式佔用記憶體中的最大的一部分,但也有特例。除了heap,JVM會分配很大一塊記憶體,用於儲存metadata、application code、the code generated by JIT、internal data structures等等。如下章節,對各部分進行詳述。
2.1. Metaspace
JVM使用專用的non-heap區域,儲存已載入類的元資料。java 8版本以前,此區域稱為PermGen or Permanent Generation。此區域儲存的是類的元資料,而不是類的例項,例項是儲存在heap記憶體中的。
對heap記憶體的限制,是無法影響Metaspace的。如果要調整Metaspace,要使用如下標誌:
-XX:MetaspaceSize,最小值
-XX:MaxMetaspaceSize,最大值
Java 8版本以前,使用-XX:PermSize、-XX:MaxPermSize,含義是一樣的。
2.2. Threads
另一個消耗記憶體較大的部分是stack。建立執行緒時,同時建立stack。stack儲存區域性變數和中間結果,在方法呼叫中扮演重要角色。
執行緒stack的預設大小與平臺相關,但是,對於現在大多的64位作業系統來說,大約是1MB。此值可以通過-Xss調整。
建立的執行緒越多,此部分佔用的記憶體越多。
另外需要注意的是,JVM本身也需要一些執行緒,用於執行內部操作,如:GC、JIT編譯。
2.3. Code Cache
為了在不同平臺執行JVM位元組碼,需要將其轉換成機器指令。程式執行時,JIT編譯器負責這個編譯工作。當JVM將位元組碼編譯為彙編指令時,它將這些指令儲存在一個特殊non-heap區域:Code Cache。Code Cache可以像JVM的其他資料區域一樣被管理。控制此區域的大小,使用如下兩個指令:
-XX:InitialCodeCacheSize,初始值
-XX:ReservedCodeCacheSize,最大值
2.4. Garbage Collection
JVM附帶了一些GC演算法,每個演算法都適用於不同的用例。所有這些GC演算法都有一個共同的特徵:他們需要一些堆外資料結構來執行任務。這些內部資料結構,會消耗一些記憶體。
2.5. Symbols
讓我們從Strings開始,這是最常用的資料型別之一。因為其使用頻率很高,Strings通常會佔用較大一部分heap記憶體。如果大量的Strings包含相同的內容,那麼會造成heap記憶體的浪費。
為了節省記憶體,對於每一個String可以僅存一個副本,然後其他的指向該副本。這個過程稱為字串駐留(String Interning)。JVM僅可以駐留編譯時的字串常量(Compile Time String Constants),我們可以對strings手動呼叫intern()方法以實現駐留。
JVM將駐留的strings儲存在一個專用的固定大小的hashtable中,稱為String Table,也稱為String Pool。可以通過如下標誌調節其大小:-XX:StringTableSize。
除了String Table,還有一個記憶體區域稱為執行時常量池(Runtime Constant Pool),JVM使用這個池來儲存一些必須在執行時解析的常量,如編譯時數值常量或方法和欄位引用。
2.6. Native Byte Buffers
JVM通常是大量記憶體佔用的可疑物件,但有時開發人員也可以直接分配記憶體。最常見的方式是:
- malloc call by JNI;
- NIO's directByteBuffers;
2.7. Additional Tuning Flags
本章節使用了一些JVM調節標誌。使用如下命令,可以找到幾乎所有的、關於特定概念的調節標誌。
java -XX:+PrintFlagsFinal -version | grep <concept>
PrintFlagsFinal會打印出JVM中所有的-XX標誌。例如,找出所有關於Metaspace的標誌:
java -XX:+PrintFlagsFinal -version | grep Metaspace
// truncated
uintx MaxMetaspaceSize = 18446744073709547520 {product}
uintx MetaspaceSize = 21807104 {pd product}
// truncated
3. Native Memory Tracking (NMT)
我們知道了JVM中消耗記憶體的幾個源頭,現在就來看看如何監視它們。首先,啟用NMT,在啟動命令中加入如下標誌即可:
-XX:NativeMemoryTracking=off|sumary|detail
NMT預設是關閉的。
假設,我們想要跟蹤一個典型的SpringBoot應用程式:
java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseG1GC -jar app.jar
3.1. Instant Snapshots
開啟NMT後,可以使用如下命令,隨時獲取本地記憶體佔用資訊。其中
jcmd <pid> VM.native_memory
下面詳解NMT命令的輸出內容。
3.2. Total Allocations
NMT顯示總的預留記憶體、已提交記憶體:
Native Memory Tracking:
Total: reserved=1731124KB, committed=448152KB
預留記憶體表示我們的應用程式可能使用的記憶體總量。已提交記憶體表示應用程式當前使用的記憶體。
儘管僅為應用程式分配了300MB記憶體,但總的預留記憶體近1.7GB。類似的,已提交記憶體近440M。這兩個資料都比300MB多了很多。
除了整體的記憶體佔用資訊外,NMT還報告了各個源頭佔用記憶體的情況。下面章節詳述。
3.3. Heap
heap記憶體佔用情況顯示如下:
Java Heap (reserved=307200KB, committed=307200KB)
(mmap: reserved=307200KB, committed=307200KB)
預留記憶體、已提交記憶體均為300MB,符合我們對heap記憶體的設定。
3.4. Metaspace
已載入類的元資料記憶體佔用資訊如下:
Class (reserved=1091407KB, committed=45815KB)
(classes #6566)
(malloc=10063KB #8519)
(mmap: reserved=1081344KB, committed=35752KB)
載入了6566個class,預留記憶體差不多1G,提交記憶體45M。
3.5. Thread
執行緒記憶體分配情況如下:
Thread (reserved=37018KB, committed=37018KB)
(thread #37)
(stack: reserved=36864KB, committed=36864KB)
(malloc=112KB #190)
(arena=42KB #72)
37個執行緒,stack記憶體共計36M,差不多每個執行緒的stack佔用1M。JVM在建立執行緒時,同時分配stack記憶體,所以預留記憶體和提交記憶體是一樣的。
3.6. Code Cache
JIT生成並快取的彙編指令的記憶體佔用情況:
Code (reserved=251549KB, committed=14169KB)
(malloc=1949KB #3424)
(mmap: reserved=249600KB, committed=12220KB)
當前,大概13M的記憶體佔用,可能會增加到大約245M(預留記憶體)。
3.7. GC
G1 GC記憶體佔用情況如下:
GC (reserved=61771KB, committed=61771KB)
(malloc=17603KB #4501)
(mmap: reserved=44168KB, committed=44168KB)
預留記憶體大概60M。
Serial GC是一個簡單的多的方法,當使用此方法時,配置方法如下:
java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseSerialGC -jar app.jar
記憶體佔用情況如下,僅僅用了1M:
GC (reserved=1034KB, committed=1034KB)
(malloc=26KB #158)
(mmap: reserved=1008KB, committed=1008KB)
當然,我們不能僅根據記憶體消耗來決定選擇什麼GC演算法,因為Serial GC的“stop-the-world”特性,可能會導致效能下降。
3.8. Symbol
symbol記憶體佔用情況如下,如string table和constant pool:
Symbol (reserved=10148KB, committed=10148KB)
(malloc=7295KB #66194)
(arena=2853KB #1)
大概佔用10M。
3.9. NMT over Time
NMT使得我們可以跟蹤記憶體佔用情況。首先,要記錄應用程式當前的記憶體佔用情況,做為基線。命令如下:
$ jcmd <pid> VM.native_memory baseline
Baseline succeeded
然後,過一段時間,可以將當前記憶體佔用與基線做比較:
$ jcmd <pid> VM.native_memory summary.diff
NMT通過+ -符號,表示這段時間內,記憶體佔用的變化情況:
Total: reserved=1771487KB +3373KB, committed=491491KB +6873KB
- Java Heap (reserved=307200KB, committed=307200KB)
(mmap: reserved=307200KB, committed=307200KB)
- Class (reserved=1084300KB +2103KB, committed=39356KB +2871KB)
// Truncated
預留記憶體、提交記憶體分別增長了3M、6M。記憶體分配中的其他波動也可以很容易地發現。
3.10. Detailed NMT
NMT可以提供關於整個記憶體空間佔用情況的非常詳細的資訊。要顯示詳細資訊,要使用如下標誌:
-XX:NativeMemoryTracking=detail
4. Conclusion
我們列舉了JVM中記憶體佔用的不同類別。然後,我們學習瞭如何監控一個正在執行的應用程式的記憶體佔用情況。有了這些,我們可以更有效地調整執行時環境的大小。
關於JIT
對於Java語言:
一、你可以說它是編譯型的:因為所有的Java程式碼都是要編譯的,.java不經過編譯就什麼用都沒有。
二、你可以說它是解釋型的:因為java程式碼編譯後不能直接執行,它是解釋執行在JVM上的,所以它是解釋執行的,那也就算是解釋的了。
三、但是,現在的JVM為了效率,都有一些JIT優化。它又會把.class的二進位制程式碼編譯為本地的程式碼(彙編)直接執行,所以,又是編譯的。
像C、C++ 他們經過一次編譯之後直接可以編譯成作業系統瞭解的型別,可以直接執行的,所以他們是編譯型的語言。沒有經過第二次的處理。
而Java不一樣,他首先由編譯器編譯成.class型別的檔案,這個是java自己型別的檔案 然後再通過虛擬機器(JVM)從.class檔案中讀一行解釋執行一行,所以他是解釋型的語言,而由於java對於多種不同的作業系統有不同的JVM,所以,Java實現了真正意義上的跨平臺!
JIT:Just In Time Compiler,一般翻譯為即時編譯器,這是是針對解釋型語言而言的,而且並非虛擬機器必須,是一種優化手段,Java的商用虛擬機器HotSpot就有這種技術手段,Java虛擬機器標準對JIT的存在沒有作出任何規範,所以這是虛擬機器實現的自定義優化技術。
HotSpot虛擬機器的執行引擎在執行Java程式碼是可以採用【解釋執行】和【編譯執行】兩種方式的,如果採用的是編譯執行方式,那麼就會使用到JIT,而解釋執行就不會使用到JIT,所以,早期說Java是解釋型語言,是沒有任何問題的,而在擁有JIT的Java虛擬機器環境下,說Java是解釋型語言嚴格意義上已經不正確了。
HotSpot中的編譯器是javac,他的工作是將原始碼編譯成位元組碼,這部分工作是完全獨立的,完全不需要執行時參與,所以Java程式的編譯是半獨立的實現。有了位元組碼,就有直譯器來進行解釋執行,這是早期虛擬機器的工作流程,後來,虛擬機器會將執行頻率高的方法或語句塊通過JIT編譯成本地機器碼,提高了程式碼執行的效率。
--結束--