Java性能权威指南-总结5
- 垃圾收集入门
- 垃圾收集概述
- 分代垃圾收集器
垃圾收集入门
很多时候没有机会重写代码,又面临需要提高Java应用性能的压力,这种情况下对垃圾收集器的调优就变得至关重要。
现代JVM的类型繁多,最主流的四个垃圾收集器分别是:Serial收集器(常用于单CPU环境)、Throughput(或者Parallel)收集器、Concurrent收集器(CMS)和G1收集器。虽然它们的性能特征迥异,不过它们也有不少共性。
垃圾收集概述
Java特性之一是不需要显式地管理对象的生命周期:可以在需要时创建对象,对象不再被使用时,会由JVM在后台自动进行回收。这其实是一把双刃剑,如果花费大量的时间来优化Java程序的内存使用,这套机制看起来可能更像个缺点,而非其优点。
简单来说,垃圾收集由两步构成:查找不再使用的对象,以及释放这些对象所管理的内存。JVM从查找不再使用的对象(垃圾对象)入手。有时,这也被称为查找不再有任何对象引用的对象(暗指采用“引用计数”的方式统计对象引用)。然而,这种靠引用计数的方式不太靠谱:假设有一个对象链接列表,列表中的每一个对象(除了头节点)都指向列表中的另一个对象,但是,如果没有任何一个引用指向列表头,这个列表就没人使用,可以被垃圾回收器回收。如果这是一个循环列表(即列表的尾元素反过来又指向了头元素),那么列表中的每一个元素都包含一个引用,即使这个列表内没有任何一个对象实际被使用,因为没有任何一个对象指向这个列表。
所以引用是无法通过计数的方式动态跟踪的;JVM必须定期地扫描堆来查找不再使用的对象。一旦发现垃圾对象,JVM会回收这些对象所持有的内存,把它们分配给需要内存的其他对象。然而,简单地记录空闲内存也无法保证将来有足够的可用内存,有些时候,还必须进行内存整理来防止内存碎片。
假设以下场景,一个程序需要分配大小为1000字节的数组,紧接着又分配一个大小为24字节的数组,并在一个循环中持续进行这样的分配。最终程序会耗尽整个堆,结果如下图所示:
堆内存用尽会触发JVM回收不再使用的数组空间。假设所有大小为24字节的数组都不再被使用,而大小为1000字节的数组还继续使用,这就形成了上图中第二行的场景。虽然堆内部有足够的空闲空间,却找不到任何一个大于24字节的连续空间,除非JVM移动所有大小为1000字节的数组,让它们连续存储,把空闲的空间整合成一块更大的连续空间,供其他的内存分配使用(如上图的第三行)。
深入到这些具体实现似乎太过于琐屑,但垃圾收集的性能就是由这些基本操作所决定的:**找到不再使用的对象、回收它们使用的内存、对堆的内存布局进行压缩整理。**完成这些操作时不同的收集器采用了不同的方法,这也是不同垃圾收集器表现出不同性能特征的原因。
如果垃圾收集进行时,没有任何应用程序线程在运行,那么完成这些操作将是一件轻松愉快的事情。然而实际情况要复杂得多,通常Java程序都启动了大量的线程,垃圾收集器自身往往也是多线程的。接下来的讨论中,从逻辑上将线程分成两组,分别是应用程序线程和处理垃圾收集的线程。垃圾收集器回收对象,或者在内存中移动对象时,必须确保应用程序线程不再继续使用这些对象。 这一点在收集器移动对象时尤其重要:在操作过程中,对象的内存地址会发生变化,因此这个过程中任何应用线程都不应再访问该对象。
所有应用线程都停止运行所产生的停顿被称为时空停顿(stop-the-world)。通常这些停顿对应用的性能影响最大,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。
分代垃圾收集器
虽然实现的细节千差万别,但所有的垃圾收集器都遵循了同一个方式,即根据情况将堆划分成不同的代(Generation)。这些代被称为“老年代”(Old Generation或TenuredGeneration)和“新生代”(Young Generation)。新生代又被进一步地划分为不同的区段,分别称为Eden空间和Survivor空间(不过Eden有时会被错误地用于指代整个新生代)。
采用分代机制的原因是很多对象的生存时间非常短。譬如下面这个例子,这是一个计算股价的循环,它将股价与股票均价的差值进行乘方运算,然后将结果加和(作为标准偏差计算的一部分)。
sum = new BigDecimal(0);
for (StockPrice sp: prices.values()) {
BigDecimal diff = sp.getClosingPrice().subtract(averagePrice);
diff = diff.multiply(diff);
sum = sum.add(diff);
}
BigDecimal
同许多Java类一样是不可变对象:该对象代表的是一个不能修改的数字。运算使用这个对象时,会创建一个新的对象(通常,前一个对象及其值会被丢弃)。这个简单的循环处理一年的股票数据(大约250个循环)时,为了存储循环的中间值,会创建750个BigDecimal
对象,只是在这一个循环里。这些对象在循环的下一个周期开始时会被丢弃。在add()
以及其他方法内,JDK的库方法会创建更多类似BigDecimal
(以及其他的类)的中间对象。最终,在一小段代码中大量的对象被快速地创建和丢弃。
Java中,这种操作是非常普遍的,所以垃圾收集器设计时就特别考虑要处理大量(有时候是大多数)的临时对象。这也是分代设计的初衷之一。新生代是堆的一部分,对象首先在新生代中分配。新生代填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象会被回收,仍然在使用的对象会被移动到其他地方。这种操作被称为Minor GC。
采用这种设计有两个性能上的优势。其一,由于新生代仅是堆的一部分,与处理整个堆相比,处理新生代的速度更快。而这意味着应用线程停顿的时间会更短。这意味着应用程序线程会更频繁地发生停顿,因为JVM不再等到整个堆都填满才进行垃圾收集。然而,就目前而言,更短的停顿显然能带来更多的优势,即使发生的频率更高。
第二个优势源于新生代中对象分配的方式。对象分配于Eden空间(占据了新生代空间的绝大多数)。**垃圾收集时,新生代空间被清空,Eden空间中的对象要么被移走,要么被回收;所有的存活对象要么被移动到另一个Survivor空间,要么被移动到老年代。由于所有的对象都被移走,相当于新生代空间在垃圾收集时自动地进行了一次压缩整理。**所有的垃圾收集算法在对新生代进行垃圾回收时都存在“时空停顿”现象。
对象不断地被移动到老年代,最终老年代也会被填满,JVM需要找出老年代中不再使用的对象,并对它们进行回收。这便是垃圾收集算法差异最大的地方。简单的垃圾收集算法直接停掉所有的应用线程,找出不再使用的对象,对其进行回收,接着对堆空间进行整理。 这个过程被称为FulI GC,通常导致应用程序线程长时间的停顿。
另一方面,通过更复杂的计算,还有可能在应用线程运行的同时找出不再使用的对象;CMS和G1收集器就是通过这种方式进行垃圾收集的。由于不需要停止应用线程就能找出不再用的对象,CMS和G1收集器被称为Concurrent垃圾收集器。同时,由于它们将停止应用程序的可能降到了最小,也被称为低停顿(Low-Pause)收集器(有时也称为无停顿收集器,虽然这个叫法相当不确切)。Concurrent收集器也使用各种不同的方法对老年代空间进行压缩。
使用CMS或G1收集器时,应用程序经历的停顿会更少(也更短)。其代价是应用程序会消耗更多的CPU。 CMS和G1收集也可能遭遇长时间的Full GC停顿(尽量避免发生那样的停顿是这些调优算法要考虑的重要方面)。
评估垃圾收集器时,想想需要达到的整体性能目标。每一个决定都需要权衡取舍。如果应用对单个请求的响应时间有要求(譬如Java企业版服务器),应该考虑下面这些因素:
- 单个请求会受停顿时间的影响——不过其受Full GC长时间停顿的影响更大。如果目标是要尽可能地缩短响应时间,那么选择使用Concurrent收集器更合适。
- 如果平均响应时间比最大响应时间更重要(譬如90%的响应时间),采用Throughput收集器通常就能满足要求。
使用Concurrent收集器来避免长的停顿时间也有其代价,这会消耗额外的CPU。类似地,为批量应用选择垃圾收集器可以遵循下面的原则:
- 如果CPU足够强劲,使用Concurrent收集器避免发生Full GC停顿可以让任务运行得更快。
- 如果CPU有限,那么Concurrent收集器额外的CPU消耗会让批量任务消耗更多的时间。
快速小结
- 所有的GC算法都将堆划分成了老年代和新生代。
- 所有的GC算法在清理新生代对象时,都使用了“时空停顿”(stop-the-world)方式的垃圾收集方法,通常这是一个能较快完成的操作。