Java虚拟机JVM性能优化(三):垃圾收集详解

时间:2021-11-16 22:08:39

Java平台的垃圾收集机制显著提高了开发者的效率,但是一个实现糟糕的垃圾收集器可能过多地消耗应用程序的资源。在Java虚拟机性能优化系列的第三部分,Eva Andreasson向Java初学者介绍了Java平台的内存模型和垃圾收集机制。她解释了为什么碎片化(而不是垃圾收集)是Java应用程序性能的主要问题所在,以及为什么分代垃圾收集和压缩是目前处理Java应用程序碎片化的主要办法(但不是最有新意的)。

垃圾收集(GC)的目的是释放那些不再被任何活动对象引用的Java对象所占用的内存,它是Java虚拟机动态内存管理机制的核心部分。在一个典型的垃圾收集周期里,所有仍然被引用的对象(因此是可达的)都将被保留,而那些不再被引用的对象将被释放、其所占用的空间将被回收用来分配给新的对象。

为了理解垃圾收集机制和各种垃圾收集算法,首先需要知道关于Java平台内存模型的一些知识。

垃圾收集和Java平台内存模型

当用命令行启动一个Java程序并指定启动参数-Xmx时(例如:java -Xmx:2g MyApp),指定大小的内存就分配给了Java进程,这就是所谓的Java堆。这个专用的内存地址空间用于存储Java程序(有时是JVM)所创建的对象。随着应用程序运行并不断为新对象分配内存,Java堆(即专门的内存地址空间)就会慢慢被填满。

最终Java堆会被填满,也就是说内存分配线程找不到一块足够大的连续空间为新对象分配内存,这时JVM决定要通知垃圾收集器并启动垃圾收集。垃圾收集也可以通过在程序中调用System.gc()来触发,但使用System.gc()并不能确保垃圾收集一定被执行。在任何一次垃圾收集之前,垃圾收集机制都会首先判断执行垃圾收集是否安全,当应用程序的所有活动线程都处于安全点时就可以开始执行一次垃圾收集。例如:当正在为对象分配内存时就不能执行垃圾收集,或者是正在优化CPU指令时也不能执行垃圾收集,因为这样很可能会丢失上下文从而搞错最终结果。

垃圾收集器不能回收任何一个有活动引用的对象,那将破坏Java虚拟机规范。也无需立即回收死对象,因为死对象最终还是会被后续的垃圾收集所回收。尽管有很多种垃圾收集的实现方法,但以上两点对所有垃圾收集实现都是相同的。垃圾收集真正的挑战在于如何识别对象是否存活以及如何在尽量不影响应用程序的情况下回收内存,因此垃圾收集器的目标有以下两个:

1.迅速释放没有引用的内存以满足应用程序的内存分配需要从而避免内存溢出。
2.回收内存时对正在运行的应用程序性能(延迟和吞吐量)的影响最小化。

两类垃圾收集

在本系列的第一篇中,我介绍了两种垃圾收集的方法,即引用计数和跟踪收集。接下来我们进一步探讨这两种方法,并介绍一些在生产环境中使用的跟踪收集算法。

引用计数收集器

引用计数收集器记录了指向每个Java对象的引用数,一旦指向某个对象的引用数为0,那么就可以立即回收该对象。这种即时性是引用计数收集器的主要优点,而且维护那些没有引用指向的内存几乎没有开销,不过为每个对象记录最新的引用数却是代价高昂的。

引用计数收集器的主要难点在于如何保证引用计数的准确性,另外一个众所周知的难点是如何处理循环引用的情况。如果两个对象彼此引用,而且没有被其他活动对象所引用,那么这两个对象的内存永远都不会被回收,因为指向这两个对象的引用数都不为0。对循环引用结构的内存回收需要major analysis(译者注:Java堆上的全局分析),这将增加算法的复杂性,从而也给应用程序带来额外的开销。

跟踪收集器

跟踪收集器基于这样的假设:所有的活动对象都可以通过一个已知的初始活动对象集合的迭代引用(引用以及引用的引用)找到。可以通过分析寄存器、全局对象和栈帧来确定初始活动对象集合(也被称为根对象)。确定了初始对象集合后,跟踪收集器顺着这些对象的引用关系依次将引用所指向的对象标注为活动对象,就这样已知的活动对象集合不断扩大。这一过程持续进行直到所有被引用的对象都被标注为活动对象,而那些没有被标注过的对象的内存就被回收。

跟踪收集器不同于引用计数收集器主要在于它可以处理循环引用结构。多数的跟踪收集器都是在标记阶段发现那些循环引用结构中的无引用对象。

跟踪收集器是动态语言中最常用的内存管理方式,也是目前Java中最常见的方式,同时在生产环境中也被验证了很多年。下面我将从实现跟踪收集的一些算法开始介绍跟踪收集器。

跟踪收集算法

复制垃圾收集器和标记-清除垃圾收集器并不是什么新东西,但它们仍然是目前实现跟踪收集的两种最常见算法。

复制垃圾收集器

传统的复制垃圾收集器使用堆中的两个地址空间(即from空间和to空间),当执行垃圾收集时from空间的活动对象被复制到to空间,当from空间的所有活动对象都被移出(译者注:复制到to空间或者老年代)后,就可以回收整个from空间了,当再次开始分配空间时将首先使用to空间(译者注:即上一轮的to空间作为新一轮的from空间)。

在该算法的早期实现中,from空间和to空间不断变换位置,也就是说当to空间满了,触发了垃圾收集,to空间就成为了from空间,如图1所示。

Java虚拟机JVM性能优化(三):垃圾收集详解

图1 传统的复制垃圾收集顺序

最新的复制算法允许堆内任意地址空间作为to空间和from空间。这样它们不需要彼此交换位置,而只是逻辑上变换了位置。

复制收集器的优点是在to空间被复制的对象紧凑排列,完全没有碎片。而碎片化正是其他垃圾收集器所面临的一个共同问题,也是我之后主要讨论的问题。

复制收集器的缺陷

通常来说复制收集器是stop-the-world的,也就是说只要垃圾收集在进行,应用程序就无法执行。对于这种实现来说,你需要复制的东西越多,对应用程序性能的影响就越大。对于那些响应时间敏感的应用来说这是个缺点。使用复制收集器时,你还要考虑最坏的场景(即from空间中的所有对象都是活动对象),这时你需要为移动这些活动对象准备足够大的空间,因此to空间必须大到可以装下from空间的所有对象。由于这个限制,复制算法的内存利用率稍有不足(译者注:在最坏的情况下to空间需要和from空间大小相同,所以只有50%的利用率)。

标记-清除收集器

部署在企业生产环境上的大多数商业JVM采用的都是标记-清除(或者叫标记)收集器,因为它没有复制垃圾收集器对应用程序性能的影响问题。其中最有名的标记收集器包括CMS、G1、GenPar和DeterministicGC。

标记-清除收集器跟踪对象引用,并且用标志位将每个找到的对象标记为live。这个标志位通常对应堆上的一个地址或是一组地址。例如:活动位可以是对象头的一个位(译者注:bit)或是一个位向量、一个位图。

在标记完成之后就进入了清除阶段。清除阶段通常都会再次遍历堆(不仅是标记为live的对象,而是整个堆),用来定位那些没有标记的连续内存地址空间(没有被标记的内存就是空闲并可回收的),然后收集器将它们整理为空闲列表。垃圾收集器可以有多个空闲列表(通常按照内存块的大小划分),有些JVM(例如:JRockit Real Time)的收集器甚至基于应用程序的性能分析和对象大小的统计结果来动态划分空闲列表。

清除阶段过后,应用程序就可以再次分配内存了。从空闲列表中为新对象分配内存时,新分配的内存块需要符合新对象的大小,或是线程的平均对象大小,或是应用程序的TLAB大小。为新对象找到大小合适的内存块有助于优化内存和减少碎片。

标记-清除收集器的缺陷

标记阶段的执行时间依赖于堆中活动对象的数量,而清除阶段的执行时间依赖于堆的大小。因此对于堆设置较大并且堆中活动对象较多的情况,标记-清除算法会有一定的暂停时间。

对于内存消耗很大的应用程序来说,你可以调整垃圾收集参数以适应各种应用程序的场景和需要。在很多情况下,这种调整至少推迟了标记阶段/清除阶段给应用程序或服务协议SLA(SLA这里指应用程序要达到的响应时间)带来的风险。但是调优仅仅对特定的负载和内存分配率有效,负载变化或是应用程序本身的修改都需要重新调优。

标记-清除收集器的实现

至少有两种已经在商业上验证的方法来实现标记-清除垃圾收集。一种是并行垃圾收集,另一种是并发(或者多数时间是并发)垃圾收集。

并行收集器

并行收集是指资源被垃圾收集线程并行使用。大多数并行收集的商业实现都是stop-the-world收集器,即所有的应用程序线程都暂停直到完成一次垃圾收集,因为垃圾收集器可以高效地使用资源,所以通常会在吞吐量的基准测试中得到高分,如SPECjbb。如果吞吐量对你的应用程序至关重要,那么并行垃圾收集器是一个很好的选择。

并行收集的主要代价(特别是对于生产环境)是应用程序线程在垃圾收集期间无法正常工作,就像复制收集器一样。因此那些对于响应时间敏感的应用程序使用并行收集器会有很大的影响。特别是在堆空间中有很多复杂的活动对象结构时,有很多的对象引用需要跟踪。(还记得吗标记-清除收集器回收内存的时间取决于跟踪活动对象集合的时间加上遍历整个堆的时间)对于并行方法来说,整个垃圾收集时间应用程序都会暂停。

并发收集器

并发垃圾收集器更适合那些对响应时间敏感的应用程序。并发意味着垃圾收集线程和应用程序线程并发执行。垃圾收集线程并不独占所有资源,因此需要决定何时开始一次垃圾收集,需要有足够的时间跟踪活动对象集合并在应用程序内存溢出前回收内存。如果垃圾收集没有及时完成,应用程序就会抛出内存溢出错误,另一方面又不希望垃圾收集执行时间太长因为那样会消耗应用程序的资源进而影响吞吐量。保持这种平衡是需要技巧的,因此在确定开始垃圾收集的时机以及选择垃圾收集优化的时机时都使用了启发式算法。

另一个难点在于确定何时可以安全执行一些操作(需要完整准确的堆快照的操作),例如:需要知道何时标记阶段完成,这样就可以进入清理阶段。对于stop-the-world的并行收集器来说这不成问题,因为世界已经暂停了(译者注:应用程序线程暂停,垃圾收集线程独占资源)。但对于并发收集器而言,从标记阶段立刻切换到清理阶段可能不安全。如果应用程序线程修改了一段内存,而这段内存已经被垃圾收集器跟踪并标注过了,这就可能产生了新的没有标注的引用。在一些并发收集实现中,这会使应用程序陷入长时间重复标注的循环,当应用程序需要这段内存时也无法获得空闲内存。

通过到目前为止的讨论我们知道有很多的垃圾收集器和垃圾收集算法,分别适合特定的应用程序类型和不同的负载。不仅是不同的算法,还有不同的算法实现。所以在指定垃圾收集器钱最好了解应用程序的需求以及自身特点。接下来我们将介绍Java平台内存模型的一些陷阱,这里陷阱的意思是,在动态变化的生产环境中Java程序员容易做出的一些使得应用程序性能变得更差的假设。

为什么调优无法代替垃圾收集

多数的Java程序员都知道如果要优化Java程序可以有很多选择。若干个可选的JVM、垃圾收集器和性能调优参数让开发者花费大量的时间在无休无尽的性能调优方面。这使有些人因此得出结论:垃圾收集是糟糕的,通过调优使垃圾收集较少发生或者持续时间较短是一个很好的变通办法,不过这样做是有风险的。

考虑一下针对具体应用程序的调优,多数的调优参数(例如内存分配率、对象大小、响应时间)都是基于当前测试的数据量对应用程序的内存分配率(译者注:或者其他参数)调整。最终可能造成以下两个结果:

1.在测试中通过的用例在生产环境中失败。
2.数据量的变化或者应用程序的变化要求重新调优。

调优是需要反复的,特别是并发垃圾收集器可能需要很多调优(尤其在生产环境中)。需要启发式方法来满足应用程序的需要。为了要满足最坏的情况,调优的结果可能是一个非常死板的配置,这也导致了大量的资源浪费。这种调优方法是一种堂吉诃德式的探索。事实上,你越是优化垃圾收集器来匹配特定的负载,越是远离了Java运行时的动态特性。毕竟有多少应用程序的负载是稳定的呢,你所预期的负载的可靠性又有多高呢?

那么如果你不将注意力放在调优上,能够做些什么来防止内存溢出错误和提高响应时间呢?首要的事情就是找到影响Java应用程序性能的主要因素。

碎片化

影响Java应用程序性能的因素不是垃圾收集器,而是碎片化以及垃圾收集器如何处理碎片化。所谓碎片化是这样一种状态:堆空间中有空闲可用的空间,但并没有足够大的连续内存空间,以至于无法为新对象分配内存。正如在第一篇中提到的,内存碎片要么是堆中残留的一段空间TLAB,要么是在长期存活对象中间被释放的小对象所占用的空间。

随着时间的推移和应用程序的运行,这些碎片就会遍布在堆中。在某些情况下,使用了静态化调优的参数可能会更糟,因为这些参数无法满足应用程序的动态需要。应用程序无法有效利用这些碎片化的空间。如果不做任何事情,那么将导致接连不断的垃圾收集,垃圾收集器尝试释放内存分配给新对象。在最坏的情况下,即使是接连不断的垃圾收集也无法释放更多的内存(碎片太多),然后JVM不得不抛出内存溢出的错误。你可以通过重启应用程序来解决碎片化,这样Java堆就有连续的内存空间可以分配给新对象。重启程序导致宕机,而且一段时间后Java堆将再次充满碎片,不得不再次重启。

内存溢出错误会挂起进程,日志显示垃圾收集器正在超负荷工作,这些都显示垃圾收集正试图释放内存,也表明堆中碎片很多。一些程序员会试图通过再次优化垃圾收集器来解决碎片化问题。但我认为应该寻找更有新意的办法解决这个问题。接下来的部分将重点讨论解决碎片化的两个办法:分代垃圾收集和压缩。

分代垃圾收集

你可能听过这样的理论:在生产环境中绝大多数对象的存活时间都很短。分代垃圾收集正是由这一理论衍生出的一种垃圾收集策略。在分代垃圾收集中,我们将堆分为不同的空间(或者叫做代),每个空间中保存着不同年龄的对象,所谓对象的年龄就是对象存活的垃圾收集周期数(也就是该对象多少个垃圾收集周期后仍然被引用)。

当新生代没有剩余空间可分配时,新生代的活动对象会被移动到老年代中(通常只有两个代。译者注:只有满足一定年龄的对象才会被移动到老年代),分代垃圾收集常常使用单向的复制收集器,一些更现代的JVM新生代中使用的是并行收集器,当然也可以为新生代和老年代分别实现不同的垃圾收集算法。如果你使用并行收集器或复制收集器,那么你的新生代收集器就是一个stop-the-world的收集器(参见之前的解释)。

老年代分配给那些从新生代移出的对象,这些对象要么是被引用很长一段时间,要么是被一些新生代中对象集合所引用。偶尔也有大对象直接被分配到了老年代,因为移动大对象的成本相对较高。

分代垃圾收集技术

在分代垃圾收集中,老年代运行垃圾收集的频率较低,而在新生代运行垃圾收集的频率较高,而我们也希望在新生代中垃圾收集周期更短。在极少的情况下,新生代的垃圾收集可能会比老年代的垃圾收集更频繁。如果你将新生代设置的太大时并且应用程序中的多数对象都存活较长时间,这种情况就可能会发生。在这种情况下,如果老年代设置的太小以至于无法容纳所有的长时间存活的对象,老年代的垃圾收集也会挣扎于释放空间给那些被移动进来的对象。不过通常来说分代垃圾收集可以使应用程序获得更好的性能。

划分出新生代的另一个好处是某种程度上解决了碎片化问题,或者说将最坏的情况推迟了。那些存活时间短的小对象本来可能产生碎片化问题,但都在新生代的垃圾收集中被清理了。由于存活时间长的对象被移到老年代时被更紧凑的分配空间,老年代也更加紧凑了。随着时间推移(如果你的应用运行时间足够长),老年代也会产生碎片化,这时需要运行一次或是几次完全垃圾收集,同时JVM也有可能抛出内存溢出错误。但是划分出新生代推迟了出现最坏情况的时间,这对于很多应用程序来说已经足够了。对于多数应用程序而言,它的确降低了stop-the-world垃圾收集的频率和内存溢出错误的机会。

优化分代垃圾收集

正如之前提到的,使用分代垃圾收集带来了重复的调优工作,例如调整新生代大小、提升率等。我无法针对具体应用运行时来强调怎样做取舍:选择固定的大小固然可以优化应用程序,但同时也减少了垃圾收集器应对动态变化的能力,而变化是不可避免的。

对于新生代首要原则就是在确保stop-the-world垃圾收集期间延迟时间前提下尽可能的加大,同时也要为那些长期存活的对象在堆中保留足够大的空间。下面是在调整分代垃圾收集器时要考虑的一些额外因素:

1.新生代中多数都是stop-the-world垃圾收集器,新生代设置的越大,相应的暂停时间就越长。因此对于那些受垃圾收集暂停时间影响大的应用程序来说,要仔细考虑将新生代设置为多大合适。

2.可以在不同的代上使用不同的垃圾收集算法。例如在新生代中使用并行垃圾收集,在老年代中使用并发垃圾收集。

3.当发现频繁的提升(译者注:从新生代移动到老年代)失败时说明老年代中碎片太多了,也就是说老年代中没有足够的空间来存放从新生代移出的对象。这时你可以调整一下提升率(即调整提升的年龄),或者确保老年代中的垃圾收集算法会进行压缩(将在下一段讨论)并调整压缩以适应应用程序的负载。也可以增加堆大小和各个代大小,但是这样更会进一步延长老年代上的暂停时间。要知道碎片化是无法避免的。

4.分代垃圾收集最适合这样的应用程序,他们有很多存活时间很短的小对象,很多对象在第一轮垃圾收集周期就被回收了。对于这种应用程序分代垃圾收集可以很好的减少碎片化,并将碎片化产生影响的时机推迟。

压缩

尽管分代垃圾收集延迟了出现碎片化和内存溢出错误的时间,然而压缩才是真正解决碎片化问题的唯一办法。压缩是指通过移动对象来释放连续内存块的垃圾收集策略,这样通过压缩为创建新对象释放了足够大的空间。

移动对象并更新对象引用是stop-the-world操作,会带来一定的消耗(有一种情况例外,将在本系列的下一篇中讨论)。存活的对象越多,压缩造成的暂停时间就越长。在剩余空间很少并且碎片化严重的情况下(这通常是因为程序运行了很长的时间),压缩存活对象较多的区域可能会有几秒种的暂停时间,而当接近内存溢出时,压缩整个堆甚至会花上几十秒的时间。

压缩的暂停时间取决于需要移动的内存大小和需要更新的引用数量。统计分析表明堆越大,需要移动的活动对象和更新的引用数量就越多。每移动1GB到2GB活动对象的暂停时间大约是1秒钟,对于4GB大小的堆很可能有25%的活动对象,因此偶尔会有大约1秒的暂停。

压缩和应用程序内存墙

应用程序内存墙是指在垃圾收集产生的暂停(例如:压缩)前可以设置的堆大小。根据系统和应用的不同,大多数的Java应用程序内存墙都在4GB到20GB之间。这也是多数的企业应用都是部署在多个较小的JVM上,而不是少数较大的JVM上的原因。让我们考虑一下这个问题:有多少现代企业的Java应用程序设计、部署是根据JVM的压缩限制来定义的。在这种情况下,为了绕过整理堆碎片的暂停时间,我们接受了更耗费管理成本的多个实例部署方案。考虑到现在硬件的大容量存储能力和企业级Java应用对增加内存的需求,这就有点奇怪了。为什么为每个实例只设置了几个GB的内存。并发压缩将会打破内存墙,这也是我下一篇文章的主题。

总结

本文是一篇关于垃圾收集的介绍性文章,帮助你了解有关垃圾收集的概念和机制,并希望能够促使你进一步阅读相关文章。这里讨论的很多东西都已经存在了很久,在下一篇中将介绍一些新的概念。例如并发压缩,目前是由Azul‘s Zing JVM实现的。它是一项新兴的垃圾收集技术,甚至尝试重新定义Java内存模型,特别是在今天内存和处理能力都不断提高的情况下。

以下是我总结出的一些关于垃圾收集的要点:

1.不同的垃圾收集算法和实现适应不同的应用程序需要,跟踪垃圾收集器是商业Java虚拟机中使用的最多的垃圾收集器。

2.并行垃圾收集在执行垃圾收集时并行使用所有资源。它通常是一个stop-the-world垃圾收集器,因此有更高的吞吐量,但是应用程序的工作线程必须等待垃圾收集线程完成,这对应用程序的响应时间有一定影响。

3.并发垃圾收集在执行收集时,应用程序工作线程仍然在运行。并发垃圾收集器需要在应用程序需要内存之前完成垃圾收集。

4.分代垃圾收集有助于延迟碎片化,但无法消除碎片化。分代垃圾收集将堆分为两个空间,其中一个空间存放新对象,另一个空间存放老对象。分代垃圾收集适合有很多存活时间很短的小对象的应用程序。

5.压缩是解决碎片化的唯一方法。多数的垃圾收集器都是以stop-the-world的方式执行压缩的,程序运行时间越久,对象引用越是复杂,对象的大小越是分布不均匀都将导致压缩时间延长。堆的大小也会影响压缩时间,因为可能有更多的活动对象和引用需要更新。

6.调优有助于延迟内存溢出错误。但是过度调优的结果是僵化的配置。在通过试错的方式开始调优之前,要确保清楚生产环境的负载、应用程序的对象类型以及对象引用的特性。过于僵化的配置很可能无法应付动态负载,因此在设置非动态值时一定要了解这样做的后果。

本系列的下一篇是:深入探讨C4(Concurrent Continuously Compacting Collector)垃圾收集算法,敬请期待!

(全文完)