从垃圾回收的角度来看,java服务器程序可以有广泛多样的需求:
一些高流量应用需要响应大量请求并创建非常多的对象。有时候,一些使用了高资源消耗框架的中等流量应用也会遇到同样的问题。总之,对垃圾回收来说,如何有效地清理这些生成的对象是一个很大的挑战。
另外,一些应用需要长时间运行并且在运行过程提供稳定的服务,要求性能不会随着时间而慢慢变差或者突然恶化。
某些场景需要严格限制用户响应时间(比如网络游戏或者投注应用等),几乎不允许额外的gc暂停。
在很多场景中,你可以通过不同的优先级将几种需求结合起来。我的几个样例程序对第一点要求比第二点要高很多,但是绝大部分程序不会同时对这三方面要求都高。这给你留下了足够权衡的空间。
默认配置下jvm gc的性能jvm有很多改进,但仍然不能在程序运行时对任务做优化。除了上面提到的三点,默认的jvm设置还有一个优先级仅次于它们的需求:减小内存占用。考虑到成千上万的用户并不是在内存充足的服务器上运行。对很多电子商务产品也很重要,因为这些应用大部分时间被配置在开发笔记本上运行,而不是在商用服务器上。因此,如果你的服务器配置着最小的堆空间和gc参数,比如下面这样配置,
java -xmx1024m -xx:maxpermsize=256m -cp portal.jar my.portal.portal
这样肯定会导致系统运行不够高效。首先,好的做法不仅配置内存最大限制,也需要配置初始内存大小,以避免服务器在启动过程中逐步增加内存。否则代价会很大。当知道服务器需要多少内存时(你应该及时地查明),最好将初始内存大小与最大内存设置相等。可以通过以下jvm参数来设置:
-xms1024m -xx:permsize=256m
最后一个经常在jvm配置的基本选项是配置新生代堆内存大小,与上面设置的方式类似:
-xx:newsize=200m -xx:maxnewsize=200m
下面的章节会对上面的配置以及更复杂的配置给出解释。首先,让我们看一个门户网站的应用,它运行在一台相当慢的测试主机上。当进行负载测试时,它的垃圾回收是怎么工作的:
图1 堆大小稍微优化后的jvm在25小时左右的gc行为(-xms1024m -xmx1024m -xx:newsize=200m -xx:maxnewsize=200m)
其中,蓝色的曲线表示总的堆内存占用量随时间的变化,垂直的灰色线条表示gc暂停的间隔。
除了曲线图,gc操作的关键指标和性能显示在最右边。首先我们看一下在这次测试中,垃圾被创建(和回收)的平均量。30.5mb/s的数值被标为黄色,因为这是一个相当大但还可以的垃圾生成速率,对一个引导性的gc调优例子而言还算可以。其他值表示jvm在清理这些垃圾时的表现:99.55%的垃圾是在新生代中被清理的,老年代的只占0.45%。这个结果相当不错,因此标为绿色。
之所以有这样的结果,可以从gc引入的暂停间隔看出来(以及处理用户请求的工作线程):有很多但很短暂的新生代gc间隔,平均每6s一次,持续时间不会超过50ms。这些暂停使jvm停止运行的时间占总时间的0.77%,但是每次暂停对等待服务器响应的用户来说完全感觉不到。
另一方面,老年代gc的暂停只占总时间的0.19%。但是,在这段时间内老年代gc只清理了0.45%的垃圾,而新生代gc用占0.77%的时间清理了99.55%的垃圾。可见,与新生代gc相比,老年代gc是多么低效。另外,老年代gc的暂停平均触发速率不到一个小时一次,但平均持续时间可达到8s,最大异常值甚至达到19s。由于这些暂停会真正地停止jvm处理用户请求的线程,因此暂停应尽量不频发且持续时间短。
通过以上观察可以得出分代垃圾回收的基本调优目标:
新生代gc尽量回收多的垃圾,避免老年代gc频发且持续时间较短。
分代垃圾回收的基本思想与堆内存大小调整先从下图开始。这个图可以通过jdk工具得到,比如jstat或者jvisualvm以及它的visualgc插件:
图2 jvm的堆内存结构,包括新生代的子分区(最左列)
java的堆内存由永久代(perm),老年代(old)和新生代(new or young)组成。新生代进一步划分为一个eden空间和两个survivor空间s0、s1。eden空间是对象被创建时的地方,经过几轮新生代gc后,他们有可能被存放在survivor空间。如果想了解更多,可以读一下sun/oracle的白皮书memory management in the java hotspot virtual machine
默认情况下,作为整体的新生代特别是survivor空间太小,导致在gc清理大部分内存之前就无法保存更多对象。因此,这些对象被过早地保存在老年代中,这会导致老年代被迅速填满,必须频繁地清理垃圾。这也是图1中产生较多的full gc暂停的原因。
(译者注:一般新生代的垃圾回收也称为minor gc,老年代的垃圾回收称为major gc或full gc)
优化新生代内存大小优化分代垃圾回收意味着让新生代,特别是survivor空间,比默认情形大。但是同时也要考虑虚拟机使用的具体gc算法。
当前硬件上运行的sun/oracle虚拟机使用了parallelgc作为默认gc算法。如果使用的不是默认算法,可以通过显式配置jvm参数来实现:
-xx:+useparallelgc
默认情况下,这个算法并不在固定大小的eden和survivor空间中运行。它使用了一种自适应调整大小的策略,称为“adaptivesizepolicy”策略。正如描述的那样,它可以适应很多场景,包括服务器以外的机器的使用。但在服务器上运行时,这并不是最优策略。为了可以显式地设置固定的survivor空间大小,可以通过以下jvm参数关闭它:
-xx:-useadaptivesizepolicy
一旦这么设置后,就不能进一步增加新生代空间的大小,但我们可以有效地为survivor空间设置合适的大小:
-xx:newsize=400m -xx:maxnewsize=400m -xx:survivorratio=6
“survivorratio=6”表示survivor空间是eden空间的1/6或者是整个新生代空间的1/8,在这个例子中就是50mb,而自适应大小策略经常运行在非常小的空间上,大约只有几mb。使用现在的配置,重复上面的负载测试,我们得到了下面的结果:
图3 堆内存优化后的jvm在50小时内的gc行为(-xms1024m -xmx1024m -xx:newsize=400m -xx:maxnewsize=400m -xx:-useadapativesizepolicy -xx:survivorratio=6)
这次的测试时间是上次的两倍,而垃圾的平均创建速率和之前基本一致(30.2mb/s,之前是30.5mb/s)。然而,整个过程只有两次老年代(full)gc暂停,25小时左右才发生一次。这是因为老年代垃圾死亡速率(所谓的promation rate)从137kb/s减小到了6kb/s,老年代的垃圾回收只占整体的0.02%。同时新生代gc的暂停持续时间仅仅从平均48ms增加到57ms,两次暂停的间隔从6s增长到10s。总之,关闭了自适应大小调整,合理地优化堆内存大小,使gc暂停占总时间的比例从0.95%减小到0.59%,这是一个非常棒的结果。
优化后,使用parnew算法作为默认parallelgc的替代,也能得到相似的结果。这个算法是为了与cms算法兼容而开发的,可以通过jvm参数来配置-xx:+useparnewgc。关于cms下面会提到。这个算法不使用自适应大小策略,可以运行在固定survivor大小的空间上。因此,即使使用默认的配置survivorratio=8,也比parallelgc拥有更高的服务器利用率。
避免老年代gc的长时间暂停上述结果的最后一个问题就是,老年代gc的长时间暂停平均为8s左右。通过适当的优化,老年代gc暂停已经很少了,但是一旦触发,对用户来说还是很烦人的。因为在暂停期间,jvm不能执行工作线程。在我们的例子中,8s的长度是由低速老旧的测试机导致的,在现代硬件上速度能快3倍左右。另一方面,现在的应用一般使用1g以上的堆内存,可以容纳更多的对象。当前的网络应用使用的堆内存能达到64gb,(至少)需要一半的内存来保存存活的对象。在这种情况下,8s对老年代暂停来说是很短的。这些应用中的老年代gc可以很随意地就接近1分钟,对于交互式网络应用来说是绝对不能接受的。
缓解这个问题的一个选择就是采用并行的方式处理老年代gc。默认情况下,在java 6中,parallelgc和parnew gc算法使用多个gc线程来处理新生代gc,而老年代gc是单线程的。以parallelgc回收器为例,可以在使用时添加以下参数:
-xx:+useparalleloldgc
从java 7开始,这个选项和-xx:+useparallelgc默认被激活。但是,即使你的系统是4核或8核,也不要期望性能可以提高2倍以上。通常的结果会比2被小一些。在某些例子中,比如上述例子中的8s,这种提高还是比较有效的。但在很多极端的例子中,这还远远不够。解决方法是使用低延迟gc算法。
下篇中会讨论cms(the concurrent mark and sweep collector)、幽灵般的碎片、g1(garbage first)垃圾收集器和垃圾收集器的量化比较,最后给出总结。
以上就是详解为任务关键型java应用优化垃圾回收(上)的详细内容。
