遗失的JVM堆内存

Published: 13 Feb 2015 Category: jvm

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

不得不说这实在是太隐蔽了。不过线索还是有的,只有那些真正好奇的人才能发现——真相就是在计算堆大小的时候,其中的一个存活区在某些情况下可能会被排除在外。

https://deepinmind.oss-cn-beijing.aliyuncs.com/java-heap-permgen-different-memory-pools.png

这之后的事情就比较简单了——打开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中配置的要小了那么一点点的话,你就知道这是少了其中的一个存活区了。

当然我也承认,这在日常的开发工作中并没有什么实际用途,但这并不是本文的重点。事实上,本文想说的是,通常来说,我认为一名优秀的工程师应该具备的一个特征就是——好奇心。一个优秀的工程师应当时刻保持着一探究竟的热情。有时候答案可能很隐蔽,但我还是建议你尝试去把它找出来。你这一路所收获到的知识最终一定会回馈给你的。

英文原文链接