Java性能优化指南(四):GC收集器导论

来源:互联网 发布:java web访问静态资源 编辑:程序博客网 时间:2024/06/16 08:43
  • 本章主要介绍垃圾收集器的基础知识。为了提升性能,如果需要重写代码,那肯定需要花费很大的精力,所以一般都是在不得已的情况下才会这么做。实践证明,对垃圾收集器进行调优可以对应用带来比较大的性能提升,它也是性能工程师对应用进行调优的重要手段。
  • 当前Java虚拟机主要有4类垃圾收集器:Serial Collector(单线程,用于单CPU机器上)、the throughput (parallel) collector(吞吐量并行收集器)、CMS收集器、G1收集器。它们的性能特性各异,所以,在下一章会详细讨论它们使用的算法。本章重点介绍它们共同使用的基本概念,并对收集器是如何运行的做概要性描述。

垃圾收集器概述

  • Java相对于c/c++的一个重要特征就是不用开发人员对内存进行管理,Java虚拟机会对垃圾内存定时进行清理。但是对于性能工程师来说,这种机制大大加大了调优难度。不过,在绝大多数情况下,Java虚拟机都能很好的工作。
  • 垃圾收集的基本原理就是找到不再使用的对象,然后释放这些对象。找到不再使用的对象大部分情况下就是要找到那些没有任何引用指向的对象(可以使用引用计数哦)。但是也有特例,考虑到一个双向链表,链表中的每个元素都有引用指向,它们的引用计数都至少为1。这样它们就无法进行释放,但是,实际上整个链表都不在使用了,是可以释放的。所以,单独使用引用计数来标识对象是否使用还是不够的。一个替代方案就是,Java虚拟机必须要定时对heap进行全量扫描,从而找到不再使用的对象。找到之后,就可以释放这些对象的内存了,以便分配给其它对象使用。但是,实际情况并不是这么简单,由于内存频繁释放和在分配,就会导致内存碎片的问题,考虑到下面的情形:一个程序分配一个数组,它由1000个字节和24个字节组成,假设数组大小为3个并且充满了整个Heap;随后24字节的数据不再使用了,这导致无法再分配超过24个字节的空间了,所以必须要对内存进行合并处理;具体如下图所示:

  • 通过上面的介绍,我们可以总结一下垃圾收集器的主要工作:搜索未使用的对象,释放未使用对象的内存,对堆进行整理减少碎片。不同的垃圾收集器会采用不同的手段来做这些事情,从而导致它们的性能有所不同。
  • 在执行上面的操作过程中,如果所有业务线程都不再允许,那就大大简化了垃圾收集器的设计。但是由于Java程序常常是多线程的,这么做就会对应用的性能产生影响。另一方面,由于在垃圾收集过程中(特别是进行内存整理的时候),对象的地址会发生变化,此时必须要确保业务线程不能对这个对象进行访问,也就是说停止业务线程不可避免。这个停止的过程,有一个术语专门进行描述,叫做stop-the-world pauseSTWP,很多时候,我们对垃圾收集器的优化,就是想办法缩短STWP的时长。

分代垃圾收集器

  • 尽管垃圾收集器之间存在细节的不同,但是所有垃圾收集器都将堆划分为多个不同的部分。这些不同的部分,大致为:老年代(OldGeneration新生代(NewGeneration。新生代又可以分为EdenSurivior两个部分(我们经常将Eden部分指代新生代)。
  • 将heap分为多个不同的部分是有实践依据的:大多数对象的生存周期都非常短。比如,考虑下面的情形:

sum = newBigDecimal(0);

for (StockPrice sp: prices.values()){

BigDecimal diff = sp.getClosingPrice().subtract(averagePrice);

diff = diff.multiply(diff);

sum =sum.add(diff);

}

diff对象是一个BIgDecimal类型,这个类型是Immutable的,因此,每次循环都会创建一个新的对象,导致会创建大量的BigDecimal对象;而在循环结束之后,这个对象的空间就没有用了(也就是生存周期很短)。这种情况在Java代码中非常普遍。为了对这些短暂生命周期的对象进行管理,垃圾收集器对Heap进行分代划分,并使用新生代来管理这些对象。当新生代填满的时候,Java虚拟机会停止业务线程,并对新生代进行垃圾收集,最终会清空新生代,清理不再使用的对象,将还在使用的对象转移到Surivior区或老生代。这个过程称之为MinorGC。由于新生代只占整个Heap空间的一部分,所以MinorGC是比较快的。但是,由于新生代空间较小,所以填充满的可能性变大,也就是发生MinorGC的频率会加快。这里就是要做一个均衡了。另一方面,在进行MinorGC的过程中,正在使用的对象一定会进行迁移,所以GC完成了之后,内存也就完成了整理。

  • 由于新生代的对象会不断地向老生代迁移,随着时间的推移,老生代的空间就会被充满,此时就会对老生代的空间进行垃圾收集。这个是不同GC算法差别最大的地方。简单的算法是停止所有业务线程,然后进行收集。这个过程被称为FullGC,会导致应用线程的长时间停止。复杂的算法是不停止业务线程,CMSG1都是采用这种方式。由于在GC的过程中,它们不会停止业务线程(还是有可能需要停止的,不过时间很短),所以,它们又称之为并发收集器。同时,由于它们在收集过程停止业务线程的时间很短,所以也称之为低延迟收集器
  • 使用CMSG1收集器可以使业务线程停止的时间更短,但是带来的问题就是需要消耗更多的CPU。不过要记住的是,CMS和G1也可能产生更长时间的FullGC(这个是调优这些收集器的关键)。
  • 这么多垃圾收集算法,我们到底使用哪一个呢?这个需要根据系统的整体性能目标来确定。在每种情况下都需要进行均衡。假设一个应用关注的是每个请求的响应时间(JavaEE服务器),考虑下面的情形:
    • 每个请求都会受STWP时间的影响,特别是暂停时间比较长的FullGC。如果目标是减少它们对响应时间的影响,并发收集器就比较适合。
    • 如果平均响应时间更重要,吞吐量收集器(throughputcollector常常是更好的选择。
    • 并发收集器可以减少暂停时间,但是也会消耗更多的CPU。
  • 同样的,如果应用是批量处理的,我们就考虑:
    • 如果有充足的CPU,使用并发收集器来避免FullGC可以加快批量处理工作的完成。
    • 如果CPU不是特别充裕,并发收集器则会带来副作用,批量处理工作的完成需要更多的时间。

GC算法简介

线性收集器

  • 线性收集器是最简单的,是client类机器的默认收集器(32bitwindows机器或单核处理器机器)。线性收集器使用单线程来处理堆。在处理过程中,会停止所有业务线程。
  • 可以使用-XX:+UseSerialGC打开线性收集器,和其它标志性参数不同的是,使用-XX:-UseSerialGC不能关闭线性垃圾收集器。在默认使用线性收集器的机器上,只有选择另外一个垃圾收集算法才能关闭掉线性收集器。

吞吐量收集器

  • 吞吐量收集器是server类机器的默认收集器(多核的Unix机器,64位的JVM)。吞吐量收集器使用多个线程来收集新生代,提升了处理新生代的性能。吞吐量收集器也能够使用多个线程来处理老年代。在JDK 7u4之后,这个特性是默认行为;之前的JDK版本,如果要使用这个特性可以通过设置-XX:+UseParallelOldGC来实现。吞吐量收集器在MinorGC和FullGC过程中都会停止所有业务线程,并在FullGC过程中对老年代进行整理。这个特性是默认,一般不需要特别指定;如果需要,可以使用-XX:+UseParallelGC-XX:+UseParallelOldGC打开。

CMS收集器

  • CMS收集器目的就是为了减少FullGCSTWP的时长。在MinorGC的过程中,CMS会停止所有应用线程,然后使用多线程来收集新生代。虽然吞吐量收集器也是使用多线程来手机新生代,但是算法是完全不一样的。CMS的算法可以使用-XX:+UseParNewGC打开,而吞吐量收集器使用的是-XX:+UseParallelGC打开。
  • 对于老年代,CMS会启动一个或多个后台线程,周期性地进行扫描,以发现未使用的对象。这种方式减少了对老年代进行收集的时候,业务线程停止的时间。对于CMS,只有MinorGC会停止业务线程(处理老年代时也会,不过时间很短,主要是后台线程扫描老年代的时候)。总体来说,CMS比吞吐量收集器停止业务线程的时间少很多。
  • 但是没有完美的事情。CMS虽然减少了业务线程的停止时间,但是增加了CPU的利用率。因此,使用CMS垃圾收集器的时候,一般要确认系统中有足够的CPU。另外,CMS的后台线程不会对来年代进行整理,这会导致老年代的内存碎片会随着时间的推移变得越来越严重。如果发生了下面两种情况:1CMS的后台线程不能获得更多的CPU 2)老年代因为碎片不能分配新的对象,CMS就会采用线性收集器的模式来收集老年代(当然会进行整理)。之后,CMS又会进行多个后台线程的模式。CMS可以使用-XX:+UseConcMarkSweepGC-XX:+UseParNewGC打开。

G1收集器

  • G1收集器是为了处理堆空间很大的情况而设计的。它将堆分成多个区域,但是它仍是一个分代收集器。有些区域术语新生代,在对新生代进行收集的时候,还是会暂停所有业务线程,并把不再使用的对象移动到老年代或survivor。这个操作是多个线程进行的(和CMS收集器一样)。
  • G1收集器还被称为并发收集器,因为它对老年代的收集使用的是后台线程,这个过程不会影响业务线程的运行。和CMS收集器不同的是,它将老年代划分了区域,所以在收集的过程中,会将还需使用的对象从一个区域移动到另外一个区域。这样,收集的过程就对老年代进行了整理(虽然比较粗)。这使得G1收集器产生碎片的可能性大大降低。
  • 和CMS收集器一样,我们需要权衡CPU的使用率。G1收集器可以使用-XX:+UseG1GC打开。

选择一个GC算法

  • GC算法的选择,一方面依赖于应用的类型;另外一方面依赖于应用的性能目标。线性收集器只适用于那些heap占用内存小于100M的情形。这大大限制了线性收集器的使用范围,因此,我们一般都在吞吐量收集器和并行收集器之间进行选择。

批处理和GC算法

  • 对于批处理程序,吞吐量收集器引入的STWP时间(特别是FullGC带来的)会大大影响其性能。比如:吞吐量收集器每次收集带来的STWP时间为0.5s。如果批处理程序执行了5分钟,进行了20次的Heap收集。则吞吐量收集器带来了3.4%的性能损耗。
  • 如果系统中有足够的CPU,使用并行收集器将会带来更好的性能。因为富裕的CPU可以用于GC后台线程,而不会影响正常的业务线程。
  • 如果系统是单线程的,或系统有多个CPU,但是不足以同时运行所有业务线程和GC后台线程,它们都会加剧CPU的竞争。
  • 下图是在不同机器上(4核和单核)采用CMS和吞吐量收集器,程序消耗CPU和业务完成情况:

可以看到,在CPU充足的情况下(4核),CMS消耗更多的CPU,但是完成时间更短;如果CPU不充足(单核),CMS完成的时间反而更长。在4核机器上,应用本来消耗的CPU应该为25%,为什么会大于这个值呢?这就是CMS和吞吐量收集器的后台线程消耗的。CMS是后台线程周期性扫描heap;吞吐量收集器是后台线程短时使用100%CPU导致的。

吞吐量和GC算法

  • 使用之前股票servlet做测试,我们发送10个请求给这个servlet,servlet会将这些请求保持到session中(以便给GC施加压力)。下图显示了在4核机器上做这个测试的情况:

在上图中,10个请求的情况,系统不能提供足够多的CPU,此时,CMS的TPS比吞吐量收集器要低很多,大概23.5%。但是如果CPU充足的情况下,CMS的TPS比吞吐量收集器要高5%左右。这里要注意,在上表中,虽然CPU不够,但是CMS没有达到100%的CPU消耗。这是因为,由于CPU不充足,CMS产生了并发模式失效的情形。这就意味着CMS需要使用线性收集器的方式来对heap进行整理。由于整理使用的是单线程,所以CPU的消耗不可能达到100%

响应时间和GC算法

  • 同样使用之前的股票servlet做测试,这次请求每隔250ms发送一个,也就是将吞吐量固定为29TPS。性能测量则采用的是90th%99th%的请求的平均响应时间;测试结果如下图所示:

第一次测试使用保存10个请求的会话状态,得到的结果是非常典型的。吞吐量收集器在平均(甚至90%的请求)处理时间都比并发收集器要快,但是10%的请求,吞吐量收集器使用的时间明显大于并发收集器,这是由于fullGC导致的。

第二次测试使用50个请求,可以看到并发收集器比吞吐量收集器要快了,并且在fullGC的情况下,吞吐量收集器的处理请求时间是并发收集器的10倍,性能大大降低。这可能是因为请求越多,使用的内存阅读,导致fullGC可能性越大。但是当heap内存碎片不断增多(或CPU不充足),并发收集器会产生并发模式失效的情形。

对于这里两种算法的选择,我们需要做个权衡;如果我们关系平均处理时间,那么吞吐量收集器和并发收集器效果差不多;如果我们关注的是CPU使用率,那么他们之间也差不多(总体来说,吞吐量收集器稍好些)。如果应用中会导致比较多的fullGC,那么,并发收集器在平均处理时间上常常是更好的。

CMS和G1算法

  • CMS一般用于堆内存比较小的情况(一般小于4G),如果堆内存比较大,使用G1算法常常更有优势(因为G1对堆再次进行了划分)。
  • CMS后台线程在扫描完堆空间之后,才能进行对象的释放;如果在扫描完堆内存之前,堆就满了,那么就会产生并发模式失效的情况。此时,所有业务线程都会停止,并使用单线程对堆进行收集,性能大大下降。当然,CMS的后台线程可以配置为多个线程,但是当内存比较大的时候,需要扫描的范围就大,也更有可能发生并发模式失效
  • G1将老年代进行了划分,因此更容易用多线程收集,也就减少了发生并发模式失效的概率(但是还是会存在的)。
  • 另外,CMS也比G1更容易导致内存碎片,从而产生并发模式失效
  • 虽然我们可以对CMS和G1进行调优,以减少并发模式失效的发生;不过在堆内存比较大的情况下,我们一般推荐使用G1收集器。(有一些应用,无论如何调优,都无法避免CMS和G1发生并发模式失效。因此,即使并发收集器的性能能够达到要求,吞吐量收集器常常是更好的选择)。

GC调优基础

  • 虽然不同的GC算法处理堆的方式不一样,但是它们都有一些同样的配置项。

堆大小

  • 堆的大小不能太大,也不能太小;如果太小,GC会非常频繁,从而无法处理业务。如果太大,则会导致下面两个问题:
    • 降低了GC频率,但大大增加了每次GC的时间,从而影响业务。
    • 有可能会发生磁盘交换(swap),性能急剧下降。吞吐量收集器的fullGC时间大大增加,并发收集器也更加容易发生并发模式失效

因此heap的大小,一般不要超过RAM的大小,如果有多个JVM,则平均分配RAM。

  • 堆的大小通过两个参数来进行配置(-Xms和-Xmx);它们的默认值和JVM当前所在的平台、机器内存有关。具体情况如下图所示:(不考虑调优参数和相关细节)

JVM首先会分配初始大小的内存,随后根据性能需要,逐步增大内存空间,直到达到最大内存。对于绝大多数应用,采用默认方式都会工作的很好。但是,有时候应用利用了大量的时间进行GC,此时就需要增加堆内存的大小(使用-Xmx)。那么到底设置为多大合适呢?一个经验值就是,fullGC之后,有2/3的空间是空闲的。不过要注意的是,即使我们设置了堆内存的最大值,JVM也还是先分配初始内存的大小,并逐步增加。

另外,如果我们预先就清楚应用会占用多大内存,那么可以将Xms和Xmx设置为同样大小,这样稍稍提升性能。

分代大小

  • 如果确定了堆大小,我们如何确定新生代、老年代的大小呢?如果我们给新生代比较大的内存,这就意味着,发生MinorGC的可能性降低,但是老年代发生fullGC的可能性会增大(内存变小)。所以,这里对分代大小的确定是一个均衡过程。
  • 虽然不同的GC算法使用不同的方式来实现这个均衡过程,但是它们对分代大小的确定都使用了一些共同的参数。这些参数都是用来调节新生代大小的,具体如下:
    • -XX:NewRatio=N 设置新生代相对于老年代占用空间的比例
    • -XX:NewSize=N设置新生代的初始大小
    • -XX:MaxNewSize=N设置新生代的最大大小
    • -XmnN将NewSize和MaxNewSize设置为相同大小

上述参数,只有NewRatio有默认值,默认值为2。如果其他参数没有设置,就使用NewRatio使用NewRatio计算新生代大小的公式如下:

InitialYoung Gen Size = Initial Heap Size / (1 + NewRatio)

可以看到,当NewRatio2的时候,新生代占用了1/3的空间,而且NewRatio越大,占用的空间越小。

  • 如果设置了NewSize的大小,它会覆盖NewRatio计算的大小;随着堆空间的大小,新生代的空间也会增大,直到MaxNewSize的大小。

永久代和元空间大小

  • 永久代和元空间都是用来存放和类相关的数据,它们一般指的是同一个东西。永久代是Java7的说法,元空间是Java 8的说法。不过,它们还是有一点区别,元空间将一些本不应该存放在永久代的东西迁移到普通的堆空间了。
  • 永久代和元空间的默认大小入下图所示:


可以看到元空间的大小是不需要调节的,默认是无限制,有多少类就加载多少空间。

  • 永久代可以使用-XX:PermSize=N-XX:MaxPermSize=N来配置初始大小和最大大小。
  • 元空间可以使用-XX:MaxMetaspaceSize=N来配置最大大小。
  • 如果对永久代/原空间分配空间进行整理(增加或缩小),就需要执行fullGC。所以,代价比较大。在程序启动的时候,会频繁加载类,所以,分配较大的永久代空间,有利于减少fullGC的发生。对于应用服务器,永久代空间一般可以配置到128M192M,甚至更多。
  • 和永久代名字不符的是(元空间,这个名称更合适),存储在永久代(元空间)中的数据不是永久的,是可以被垃圾收集器回收的。比如:有些类是动态加载的(甚至是动态生成的),使用一次就结束了。此时,可以对这些类的数据进行内存回收。

收集器后台线程数目控制

  • 除了线性收集器之外,收集器一般都是多线程的。可以通过  -XX:ParallelGCThreads=N来配置这个线程的数目。它会影响:
    • 使用-XX:+UseParallelGC收集新生代
    • 使用-XX:+UseParallelOldGC收集老年代
    • 使用 -XX:+UseParNewGC收集新生代
    • 使用 -XX:+UseG1GC收集新生代
    • CMS的STWP
    • G1的STWP(stoptheworldphase)
  • 由于GC的时间越短越好,因此,JVM会尽可能多的使用CPU。默认情况下,JVM会使用最多8个CPU来进行GC。一旦JVM发现还需要更多的CPU,那么它将会使用余下CPU的5/8来进行GC。所以,GC的总线程数可以使用下面的公式进行计算:

ParallelGCThreads= 8 + ((N - 8) * 5 / 8)

  • 如果系统中有多个JVM在运行,限制每个JVMGC线程数是有必要的。如果不做限制,会导致在同时GC的时候,竞争会加剧,导致GC时间增加。

GC日志

  • 可以使用 -verbose:gc -XX:+PrintGC来打开GC日志。 
  • 使用-XX:+PrintGCDetails参数可以看到更详细的日志。(一般使用这个标志)
  • 一般还建议加上参数:XX:+PrintGCTimeStamps-XX:+PrintGCDateStamps,这样可以很清楚的看到两次GC之间的间隔。
  • -Xloggc:filename可以设置GC日志文件。 -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=N -XX:GCLogFileSize=N可以用来设置日志文件的循环使用、日志文件大小等。

1 0