遺失的JVM堆記憶體

Java譯站發表於2015-02-15

“HI,你能不能過來幫我看下這個奇怪的現象?”我之所以會寫這篇文章是因為我在一個技術支援的案例中遇到了這麼一個情況。這個問題是由於不同的JVM工具所檢測出來的可用記憶體的大小不一致所產生的。

簡言之,就是有一個工程師在排查某個應用記憶體使用過多的問題,而他一直“認為”這個程式的堆是2G的。由於某些原因,JVM工具貌似也不太確定這個程式的堆到底有多大。比如說,jconsole認為這個堆的最大可用記憶體為1963M,而jvisualvm檢測出來的是2048m。那麼到底哪個才是對的,為什麼不同的工具會顯示出不同的結果呢?

這的確很蹊蹺,尤其是嫌疑最大的JVM也被排除掉了——JVM是沒有動過其它手腳的,因為:

  • -Xmx與-Xms的配置值相等,因此在執行時堆增長的時候這個數值是不會變的。
  • 由於關掉了自適應調整的策略(-XX:-UseAdaptiveSizePolicy),JVM也無法動態地調整記憶體池的大小。

問題重現

要弄清楚這個問題首先得看一下實現的工具本身。要獲取可用記憶體的資訊,最簡單的方式就是下面這種了:

System.out.println("Runtime.getRuntime().maxMemory()="+Runtime.getRuntime().maxMemory());

沒錯,這也正是這些工具目前所使用的方法。要解決這個問題首先得有一個能復現問題的測試用例。因此我寫了這麼一段程式碼:

package eu.plumbr.test;
//imports skipped for brevity

public class HeapSizeDifferences {

  static Collection<Object> objects = new ArrayList<Object>();
  static long lastMaxMemory = 0;

  public static void main(String[] args) {
    try {
      List<String> 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[1000000]來不停地進行記憶體分配,並檢測JVM當前可用記憶體的大小。如果它發現記憶體大小發生了變化,它會將Runtime.getRuntime().maxMemory()的結果給列印出來,就像這樣:

Running with: [-Xms2048M, -Xmx2048M]
Runtime.getRuntime().maxMemory(): 2,010,112K.

沒錯,儘管我已經指定了JVM使用的堆是2G的,但是執行時就是有85M不見了。你可以把2,010,112K除以1024來將Runtime.getRuntime().maxMemory()的結果轉化成MB,看看我算的是不是有問題。你算出來的結果應該是1963M,與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的原始碼中發現了這麼一段程式碼 :

// 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;

不得不說這實在是太隱蔽了。不過線索還是有的,只有那些真正好奇的人才能發現——真相就是在計算堆大小的時候,其中的一個存活區在某些情況下可能會被排除在外

這之後的事情就比較簡單了——開啟GC日誌後我們可以發現,在2G的堆下,Serial, Parallel以及CMS演算法所設定的存活區的大小都恰好是記憶體缺失的這部分。比如說,上例中的這個ParallelGC的GC日誌是這樣的:

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,兩個存活區是87,040K,而老生代的大小是1,398,272K。將Eden區以及老生代,再加上一個存活區的大小,正好就是2,010,112K,也就是說缺失的這85M或者說87,040K,的確就是剩下的那一個存活區。

總結

讀完本文後你會對Java API的實現有一個新的認識。如果下次JVM工具將可用堆的總記憶體視覺化時比-Xmx中配置的要小了那麼一點點的話,你就知道這是少了其中的一個存活區了。

當然我也承認,這在日常的開發工作中並沒有什麼實際用途,但這並不是本文的重點。事實上,本文想說的是,通常來說,我認為一名優秀的工程師應該具備的一個特徵就是——好奇心。一個優秀的工程師應當時刻保持著一探究竟的熱情。有時候答案可能很隱蔽,但我還是建議你嘗試去把它找出來。你這一路所收穫到的知識最終一定會回饋給你的。

相關文章