為什麼JVM指定-Xmx引數後佔用記憶體會變少?

ImportNew發表於2015-05-12

“嘿,你能順便過來看看這個奇怪的事情嗎?” 就是讓我提供支援的這個事情,驅使我寫下這篇部落格的。這個特殊的問題是,不同工具給出的可用記憶體的報告是不一樣的。

簡而言之,工程師正在調查特定應用程式的記憶體使用。根據以往的經驗,他給這個應用指定了2G堆記憶體。但是不知道什麼原因,JVM工具似乎不能確定這個程式到底有多少記憶體。例如 jconsole 探測可用堆總共為1963M,但 jvisualvm 報告稱堆為2048M。到底哪一個是正確的呢?為什麼另一個給出了不一樣的資訊呢?

這的確很不可思議,特別是以往的認知被突然改變。表面上JVM沒有耍任何花招:

  • -Xmx 和 -Xms 是相等的,這就使得報告的數字不會隨著堆實時增加。
  • JVM避免通過記憶體的自適應策略(-XX:-UseAdaptiveSizePolicy)動態改變記憶體池的大小。

重現不同

搞懂這個問題的第一步是深入這些工具的實現方式。一般通過標準API檢視可用記憶體會像下面這樣:

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中的可用記憶體。無論何時,只要最後知道的記憶體大小改變時,都會通過列印出 ofRuntime.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 的原始碼中,我發現了下面這些:

//對java.lang.Runtime.maxMemory()的支援:
//返回虛擬機器提供給“標準”java物件的最大記憶體。
//這個基於保留的地址空間,但是不應該包括虛擬機器使用內部統計或臨時儲存的這部分空間。
//(例如:在青年代中,殘留空間之一)
virtual size_t max_capacity() const = 0;

不得不承認答案隱藏得很深。但真相還是在好奇心的驅使下找到——事實上,某些情況下殘留空間其中一些可能被排除在記憶體計算之外

從這裡開始就一帆風順了。開啟GC日誌發現,確實在設定2G記憶體時,Parallel和CMS演算法都會在不同程度上,設定殘留的空間是可變的。例如,以Parallel演算法為例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空間被設定為了524800K,殘留空間都被設為了 87040K,Old空間大小為 1398272K。把Eden、Old和殘留空間之一加在一起等於2010112K,確認丟失的 85 或 87040K 確實是保留的殘留空間

總結

讀完這篇文章後,相信你現在已經準備好以一種新的視角深入到Java API的實現細節。下次遇到視覺化工具的總可用堆大小略低於Xmx規定的大小時,你就知道少的那部分等於你一個殘留空間的大小。

不得不承認的一個事實是,在日常的程式設計中不是特別有用,但是這不是我寫這篇文章的初衷。相反地,寫這篇文章目的是為了強調我在優秀工程師身上看到的特質——好奇心。優秀的工程師總是想去知道,那些東西的工作方式並探究為什麼它們會像那樣工作。有時候答案藏匿地很深,但仍然建議你去試圖尋求答案。最終,在這個過程中獲取的知識,將會讓你受益無窮。

相關文章