5.2.6 不恰当数据结构导致内存占用过大

例如,有一个后台RPC服务器,使用64位虚拟机,内存配置为-Xms4g-Xmx8g-Xmn1g,使用ParNew+CMS的收集器组合。平时对外服务的Minor GC时间约在30毫秒以内,完全可以接受。但业务上需要每10分钟加载一个约80MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过100万个HashMap<Long,Long>Entry,在这段时间里面Minor GC就会造成超过500毫秒的停顿,对于这个停顿时间就接受不了了,具体情况如下面GC日志所示。


{Heap before GC invocations=95(full 4):

par new generation total 903168K,used 803142K[0x00002aaaae770000,0x00002aaaebb70000,0x00002aaaebb70000)

eden space 802816K,100%used[0x00002aaaae770000,0x00002aaadf770000,0x00002aaadf770000)

from space 100352K,0%used[0x00002aaae5970000,0x00002aaae59c1910,0x00002aaaebb70000)

to space 100352K,0%used[0x00002aaadf770000,0x00002aaadf770000,0x00002aaae5970000)

concurrent mark-sweep generation total 5845540K,used 3898978K[0x00002aaaebb70000,0x00002aac507f9000,0x00002aacae770000)

concurrent-mark-sweep perm gen total 65536K,used 40333K[0x00002aacae770000,0x00002aacb2770000,0x00002aacb2770000)

2 0 1 1-1 0-2 8 T 1 1:4 0:4 5.1 6 2+0 8 0 0:2 2 6.5 0 4:[G C 2 2 6.5 0 4:[P a r N e w:803142K->100352K(903168K),0.5995670 secs]4702120K->4056332K(6748708K),0.5997560

secs][Times:user=1.46 sys=0.04,real=0.60 secs]

Heap after GC invocations=96(full 4):

par new generation total 903168K,used 100352K[0x00002aaaae770000,0x00002aaaebb70000,0x00002aaaebb70000)

eden space 802816K,0%used[0x00002aaaae770000,0x00002aaaae770000,0x00002aaadf770000)

from space 100352K,100%used[0x00002aaadf770000,0x00002aaae5970000,

0x00002aaae5970000)

to space 100352K,0x00002aaaebb70000)0%used[0x00002aaae5970000,0x00002aaae5970000,

concurrent mark-sweep generation total 5845540K,used 3955980K[0x00002aaaebb70000,0x00002aac507f9000,0x00002aacae770000)

concurrent-mark-sweep perm gen total 65536K,used 40333K[0x00002aacae770000,0x00002aacb2770000,0x00002aacb2770000)

}

Total time for which application threads were stopped:0.6070570 seconds


观察这个案例,发现平时的Minor GC时间很短,原因是新生代的绝大部分对象都是可清除的,在Minor GC之后Eden和Survivor基本上处于完全空闲的状态。而在分析数据文件期间,800MB的Eden空间很快被填满从而引发GC,但Minor GC之后,新生代中绝大部分对象依然是存活的。我们知道ParNew收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确就成为一个沉重的负担,因此导致GC暂停时间明显变长。

如果不修改程序,仅从GC调优的角度去解决这个问题,可以考虑将Survivor空间去掉(加入参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0或者-XX:+AlwaysTenure),让新生代中存活的对象在第一次Minor GC后立即进入老年代,等到Major GC的时候再清理它们。这种措施可以治标,但也有很大副作用,治本的方案需要修改程序,因为这里的问题产生的根本原因是用HashMap<Long,Long>结构来存储数据文件空间效率太低。

下面具体分析一下空间效率。在HashMap<Long,Long>结构中,只有Key和Value所存放的两个长整型数据是有效数据,共16B(2×8B)。这两个长整型数据包装成java.lang.Long对象之后,就分别具有8B的MarkWord、8B的Klass指针,在加8B存储数据的long值。在这两个Long对象组成Map.Entry之后,又多了16B的对象头,然后一个8B的next字段和4B的int型的hash字段,为了对齐,还必须添加4B的空白填充,最后还有HashMap中对这个Entry的8B的引用,这样增加两个长整型数字,实际耗费的内存为(Long(24B)×2)+Entry(32B)+HashMap Ref(8B)=88B,空间效率为16B/88B=18%,实在太低了。