為什麼我的JVM能實際使用的內存比-Xmx指定的少?
“你好,你能過來看看幫我解決一個奇怪的問題麼。”就是這個技術支持案例使我想起寫下這篇帖子。眼前的這個問題就是關於不同工具對於可用內存大小檢測的差異。
其實就是一個工程師在調查一個應用程序的過高的內存使用情況時發現,盡管該程序已經被指定分配2G堆內存,但是JVM檢測工具似乎並不能確定進程實際能用多少內存。例如 jconsole顯示可用堆內存為1,963M,然而 jvisualvm 卻顯示能用2,048M。所以到底哪個工具才是對的,為什麼檢測結果會出現差異呢?
這確實是個挺奇怪的問題,特別是當最常出現的幾種解釋理由都被排除後,看來JVM並沒有耍一些明顯的小花招:
- -Xmx和-Xms是相等的,因此檢測結果並不會因為堆內存增加而在運行時有所變化。
- 通過關閉自適應調整策略(-XX:-UseAdaptiveSizePolicy),JVM已經事先被禁止動態調整內存池的大小。
重現差異檢測結果
要弄清楚這個問題的第一步就是要明白這些工具的實現原理。通過標準APIs,我們可以用以下簡單語句得到可使用的內存信息。
System.out.println("Runtime.getRuntime().maxMemory()="+Runtime.getRuntime().maxMemory());
而且確實,現有檢測工具底層也是用這個語句來進行檢測。要解決這個問題,首先我們需要一個可重複使用的測試用例。因此,我寫了下麵這段代碼:
package eu.plumbr.test;
//imports skipped for brevity
public class HeapSizeDifferences {
static Collection objects = new ArrayList();
static long lastMaxMemory = 0;
public static void main(String[] args) {
try {
List inputArguments = ManagementFactory.getRuntimeMXBean().getInputArguments();
System.out.println("Running with: " + inputArguments);
while (true) {
printMaxMemory();
consumeSpace();
}
} catch (OutOfMemoryError e) {
freeSpace();
printMaxMemory();
}
}
static void printMaxMemory() {
long currentMaxMemory = Runtime.getRuntime().maxMemory();
if (currentMaxMemory != lastMaxMemory) {
lastMaxMemory = currentMaxMemory;
System.out.format("Runtime.getRuntime().maxMemory(): %,dK.%n", currentMaxMemory / 1024);
}
}
static void consumeSpace() {
objects.add(new int[1_000_000]);
}
static void freeSpace() {
objects.clear();
}
}
這段代碼通過將new int[1_000_000]置於一個循環中來不斷分配內存給程序,然後監測JVM運行期的當前可用內存。當程序監測到可用內存大小發生變化時,通過打印出Runtime.getRuntime().maxMemory()返回值來得到當前可用內存尺寸,輸出類似下麵語句:
Running with: [-Xms2048M, -Xmx2048M]
Runtime.getRuntime().maxMemory(): 2,010,112K.
實際情況也確實如預估的那樣,盡管我已經給JVM預先指定分配了2G對內存,在不知道為什麼在運行期有85M內存不見了。你大可以把 Runtime.getRuntime().maxMemory()的返回值2,010,112K 除以1024來轉換成MB,那樣你將得到1,963M,正好和2048M差85M。
找到根本原因
在成功重現了這個問題之後,我嚐試用使用不同的GC算法,果然檢測結果也不盡相同。
GC algorithm |
Runtime.getRuntime().maxMemory() |
-XX:+UseSerialGC |
2,027,264K |
-XX:+UseParallelGC |
2,010,112K |
-XX:+UseConcMarkSweepGC |
2,063,104K |
-XX:+UseG1GC |
2,097,152K |
除了G1算法剛好完整使用了我預指定分配的2G之外,其餘每種GC算法似乎都不同程度地丟失了一些內存。
現在我們就該看看在JVM的源代碼中有沒有關於這個問題的解釋了。我在CollectedHeap這個類的源代碼中找到了如下的解釋:
Running with: [-Xms2048M, -Xmx2048M]
// Support for java.lang.Runtime.maxMemory(): return the maximum amount of
// memory that the vm could make available for storing 'normal' java objects.
// This is based on the reserved address space, but should not include space
// that the vm uses internally for bookkeeping or temporary storage
// (e.g., in the case of the young gen, one of the survivor
// spaces).
virtual size_t max_capacity() const = 0;
我不得不說這個答案藏得有點深,但是隻要你有足夠的好奇心,還是不難發現的:有時候,有一塊Survivor區是不被計算到可用內存中的。
明白這一點之後問題就好解決了。打開並查看GC logging 信息之後我們發現,在Serial,Parallel以及CMS算法回收過程中丟失的那些內存,尺寸剛好等於JVM從2G堆內存中劃分給Survivor區內存的尺寸。例如,在上麵的ParallelGC算法運行時,GC logging信息如下:
Running with: [-Xms2g, -Xmx2g, -XX:+UseParallelGC, -XX:+PrintGCDetails]
Runtime.getRuntime().maxMemory(): 2,010,112K.
... rest of the GC log skipped for brevity ...
PSYoungGen total 611840K, used 524800K [0x0000000795580000, 0x00000007c0000000, 0x00000007c0000000)
eden space 524800K, 100% used [0x0000000795580000,0x00000007b5600000,0x00000007b5600000)
from space 87040K, 0% used [0x00000007bab00000,0x00000007bab00000,0x00000007c0000000)
to space 87040K, 0% used [0x00000007b5600000,0x00000007b5600000,0x00000007bab00000)
ParOldGen total 1398272K, used 1394966K [0x0000000740000000, 0x0000000795580000, 0x0000000795580000)
由上麵的信息可以看出,Eden區被分配了524,800K,兩個Survivor區都被分配到了87,040K,老年代(Old space)則被分配了1,398,272K。把Eden區、老年代以及一個Survivor區的尺寸求和,剛好等於2,010,112K,說明丟失的那85M(87,040K)確實就是剩下的那個Survivor區。
總結
讀完這篇帖子的你現在應該對如何探索Java API的實現原理有了一些新的想法。下次當你用某個可視化工具查看可用堆內存發現所得的結果略少於-Xmx指定分配的大小時,你就知道這兩者之間的差值是一塊Survivor區的大小。
我必須承認這個知識點在日常編程中並不是特別常用,但這並不是這篇帖子的重點。我寫下這篇帖子是為了描述一種特質,一種我經常在優秀的程序員身上尋找的特質-好奇心。好的程序員們會經常試著去了解一些事物工作的機理以及原因。有時問題的答案並不會那麼顯而易見,但是希望你能堅持尋找下去,最終在尋找過程中的所累積的知識總會讓你獲益匪淺。
最後更新:2017-05-22 16:39:30