JVM:32G以上的堆会发生什么

Published: 05 Jan 2015 Category: jvm

这篇短文主要是想告诉你如果给Oracle JVM配置超过32G的堆会发生什么事情。默认情况下,堆大小在32G以下的话JVM中的引用会占用4个字节。这是JVM在启动的时候就已经决定了的。如果你去掉了-XX:-UseCompressedOops选项的话,当然也可以在较小的堆上使用8字节的引用(但在生产系统中这么做是毫无意义的!)。

一旦堆超过了32G,你就进入到64位的世界里了,因此对象引用就只能是8字节而非4字节了。正如Scott Oaks在他的Java性能:终极指南一书中所说的(234到236页,这里有我对该书的一个评价),Java程序的堆中平均会有20%的空间是被对象引用占据了。也就是说,如果堆的配置是介于Xmx32G到Xmx37G——Xmx38G之间的话,实际上是减少了应用程序的可用堆的大小(当然了,具体的数字要取决于你的程序)。对于很多人而言,增加了额外的内存是为了能让程序可以多处理一些数据,而这样的结果会令他感到意外。

测试——生成LinkedList

我决定测试一下最坏的场景——生成一个值递增的LinkedList。这个测试非常有意思:看一下将2亿个Integer插入到LinkedList中需要多大的堆空间。这个工作就留给读者来完成了:-)

测试的代码非常简单:

public class Mem32Test {
    public static void main(String[] args) {
        List<Integer> lst = new LinkedList<>();
        int i = 0;
        while ( true )
        {
            lst.add( new Integer( i++ ) );
            if ( ( i & 0xFFFF ) == 0 )
                System.out.println( i ); //shows where you are <img src=""http://java-performance.info/wp-includes/images/smilies/icon_smile.gif" alt=":)" class="wp-smiley" />
            if ( i == System.currentTimeMillis() )
                break; //otherwise will not compile
        }
        System.out.println( lst.size() ); //needed to avoid dead code optimizations
    }
}

你可以结合Xmx以及verbose:gc(或者是-XX:+PrintGCDetails)选项来运行这个程序。同时还得查看下GC的日志来确认下内存何时会被用完(距离真正抛出OOM还需要相当长的一段时间)。

首先,我发现JVM切换到64位引用的一个确切的临界点是——Xmx32767M(很奇怪,正好比32G要少1M)。除此之外我还发现应用程序的可用内存与堆的变化并不是线性增长的。而是阶段性的增长(你可以看下Xmx49200M和49500M之间所发生的现象)——这点我想再深入地探讨一下。

测试结果

LinkedList中元素的个数 堆大小
666,697,728 Xmx32700M
667,287,552 Xmx32730M
667,680,768 Xmx32750M
667,877,376 Xmx32760M
668,008,448 Xmx32764M
668,139,520 Xmx32765M
668,008,448 Xmx32766M
422,510,592 Xmx32767M
429,391,872 Xmx33700M
535,166,976 Xmx42000M
639,041,536 Xmx48700M
643,039,232 Xmx49200M
731,578,368 Xmx49500M
734,658,560 Xmx49700M
1,442,119,680 Xmx110000M

不难看出,列表元素的个数在Xmx32767M也就是切换到64位引用的时候开始戏剧性地从6亿下降到了4亿。

我们来看下为什么插入到LinkedList中的元素的数量会发生锐减。JDK中的LinkedList是一个双向链表。因此,每个Node对象除了包含数据以外(当然了,只是个引用),还有prev及next引用。

在32位模式下,每个Java对象会包含12字节的对象头,然后才是对象本身的字段。每个对象所占用的内存还会按8个字节来对齐。因此,32位模式下一个Node对象会占用12+4*3=24个字节。一个Integer对象需要12+4=16字节(这两种情况都不需要补齐填充)。

但是,一旦切换到了64位之后,LinkedList中的每个元素的大小会从40字节涨到64字节。

内存调优的一些技巧

正如我前面所说的,JVM使用超过32G堆的话就意味着有一个不小的性能损耗。除了增加应用程序的内存使用量以外,JVM的垃圾回收器还要去回收这些对象(你可以添加-XX:+PrintGCDetails选项来看下对程序的GC所造成的影响)。 对于那些没有调优过的应用程序,我这里列举出了一些简单的技巧可以用来减少它的内存使用量(千万不要觉得这些建议没什么,某些情况下可以节省的内存相当可观):

  • 应用程序中可能包含许多内容一样的字符串对象。如果使用的是Java 7及更新的版本,可以考虑下字符串内联——这是消除冗余字符串的终极武器,但必须得谨慎使用——只有那些生命周期适中或较长的字符串才应该进行内联,因为它们更有可能会出现冗余。如果你用的是Java 8 update 20以后的版本,可以尝试使用字符串去重——JVM会自己去处理字符串冗余的问题(使用这个特性必须得启用G1垃圾回收器)。
  • 如果堆中有大量的数值型包装类譬如Integer或者Double的话,最好是将它们存储在集合里。现在都已经是2015年了,没有什么理由拒绝使用原始集合(Primitive Collection)了。最近我写了篇文章介绍了下不同的原始集合库实现的哈希表的概况。你还可以看一下我之前关于Trove的一篇文章。
  • 最后,可以看一下我之前写过的有关内存消耗和节省内存的一系列文章(第1篇,第2篇,第3篇,第4篇)。

总结

  • 如果应用程序的堆超过32G的话需要谨慎对待(从低于32G升级到32G以上)——JVM会切换到64位的对象引用,也就是说应用程序的可用堆的空间会减小。解决方法就是不要从32G加起,而是直接加到37~38G以上。这个灰色区域具体是多少取决于你的应用程序——对象的平均大小较大的话,损耗就要少一些。
  • 更明智的做法或许就是不要使用太大的堆,而是将使用的内存限制在32G以内。看一下我的这几篇文章:字符串内联,字符串去重,哈希表及其它原始集合,Trove)。

英文原文链接