ImportNew注:本文是JVM性能优化 系列-第3篇-《JVM性能优化, Part 3 —— 垃圾回收》
第一篇 《JVM性能优化, Part 1 ―― JVM简介 》
第二篇《JVM性能优化, Part 2 ―― 编译器》
Java平台的垃圾回收机制大大提高的开发人员的生产力,但实现糟糕的垃圾回收器却会大大消耗应用程序的资源。本文作为JVM性能优化系列的第3篇,Eva Andeasson将为Java初学者介绍Java平台的内存模型和GC机制。她将解释为什么碎片化(不是GC)是Java应用程序出现性能问题的主要原因,以及为什么当前主要通过分代垃圾回收和压缩,而不是其他最具创意的方法,来解决Java应用程序中碎片化的问题。
垃圾回收(GC)是旨在释放不可达Java对象所占用的内存的过程,是Java virtual machine(JVM)中动态内存管理系统的核心组成部分。在一个典型的垃圾回收周期中,所有仍被引用的对象,即可达对象,会被保留。没有被引用的Java对象所占用的内存会被释放并回收,以便分配给新创建的对象。
为了更好的理解垃圾回收与各种不同的GC算法,你首先需要了解一些关于Java平台内存模型的内容。
垃圾回收与Java平台内存模型
当你在启动Java应用程序时指定了启动参数_-Xmx_(例如,java -Xmx2g MyApp),则相应大小的内存会被分配给Java进程。这块内存即所谓的*Java堆*(或简称为*堆*)。这块专用的内存地址空间用于存储Java应用程序(有时是JVM)所创建的对象。随着Java应用程序的运行,会不断的创建新对象并为之分配内存,Java堆(即地址空间)会逐渐被填满。
最后,Java堆会被填满,这就是说想要申请内存的线程无法获得一块足够大的连续空闲空间来存放新创建的对象。此时,JVM判断需要启动垃圾回收器来回收内存了。当Java程序调用System.gc()方法时,也有可能会触发垃圾回收器以执行垃圾回收的工作。使用System.gc()方法并不能保证垃圾回收工作肯定会被执行。在执行垃圾回收前,垃圾回收机制首先会检查当前是否是一个“恰当的时机”,而“恰当的时机”指所有的应用程序活动线程都处于安全点(safe point),以便启动垃圾回收。简单举例,为对象分配内存时,或正在优化CPU指令(参见本系列的前一篇文章)时,就不是“恰当的时机”,因为你可能会丢失上下文信息,从而得到混乱的结果。
垃圾回收不应该回收当前有活动引用指向的对象所占用的内存;因为这样做将违反JVM规范。在JVM规范中,并没有强制要求垃圾回收器立即回收已死对象(dead object)。已死对象最终会在后续的垃圾回收周期中被释放掉。目前,已经有多种垃圾回收的实现,它们都包含两个沟通的假设。对垃圾回收来说,真正的挑战在于标识出所有活动对象(即仍有引用指向的对象),回收所有不可达对象所占用的内存,并尽可能不对正在运行的应用程序产生影响。因此,垃圾回收器运行的两个目标:
两类垃圾回收
在本系列的第一篇文章中,我提到了2种主要的垃圾回收方式,引用计数(reference counting)和引用追踪(tracing collector。译者注,在第一篇中,给出的名字是“reference tracing”,这里仍沿用之前的名字)。这里,我将深入这两种垃圾回收方式,并介绍用于生产环境的实现了引用追踪的垃圾回收方式的相关算法。
相关阅读:JVM性能优化系列
引用计数垃圾回收器
引用计数垃圾回收器会对指向每个Java对象的引用数进行跟踪。一旦发现指向某个对象的引用数为0,则立即回收该对象所占用的内存。引用计数垃圾回收的主要优点就在于可以立即访问被回收的内存。垃圾回收器维护未被引用的内存并不需要消耗很大的资源,但是保持并不断更新引用计数却代价不菲。
使用引用计数方式执行垃圾回收的主要困难在于保持引用计数的准确性,而另一个众所周知的问题在于解决循环引用结构所带来的麻烦。如果两个对象互相引用,并且没有其他存活东西引用它们,那么这两个对象所占用的内存将永远不会被释放,两个对象都会因引用计数不为0而永远存活下去。要解决循环引用带来的问题需要,而这会使算法复杂度增加,从而影响应用程序的运行性能。
引用跟踪垃圾回收
引用跟踪垃圾回收器基于这样一种假设,所有存活对象都可以通过迭代地跟踪从已知存活对象集中对象发出的引用及引用的引用来找到。可以通过对寄存器、全局域、以及触发垃圾回收时栈帧的分析来确定初始存活对象的集合(称为“根对象”,或简称为“根”)。在确定了初始存活对象集后,引用跟踪垃圾回收器会跟踪从这些对象中发出的引用,并将找到的对象标记为“活的(live)”。标记所有找到的对象意味着已知存活对象的集合会随时间而增长。这个过程会一直持续到所有被引用的对象(因此是“存活的”对象)都被标记。当引用跟踪垃圾回收器找到所有存活的对象后,就会开始回收未被标记的对象。
不同于引用计数垃圾回收器,引用跟踪垃圾回收器可以解决循环引用的问题。由于标记阶段的存在,大多数引用跟踪垃圾回收器无法立即释放“已死”对象所占用的内存。
引用跟踪垃圾回收器广泛用于动态语言的内存管理;到目前为止,在Java编程语言的视线中也是应用最广的,并且在多年的商业生产环境中,已经证明其实用性。在本文余下的内容中,我将从一些相关的实现算法开始,介绍引用跟踪垃圾回收器,
引用跟踪垃圾回收器算法
拷贝和*标记-清理*垃圾回收算法并非新近发明,但仍然是当今实现引用跟踪垃圾回收器最常用的两种算法。
拷贝垃圾回收器
传统的拷贝垃圾回收器会使用一个“from”区和一个“to”区,它们是堆中两个不同的地址空间。在执行垃圾回收时,from区中存活对象会被拷贝到to区。当from区中所有的存活对象都被拷贝到to后,垃圾回收器会回收整个from区。当再次分配内存时,会首先从to区中的空闲地址开始分配。
在该算法的早期实现中,from区和to区会在垃圾回收周期后进行交换,即当to区被填满后,将再次启动垃圾回收,这是to区会“变成”from区。如图Figure 1所示。
Figure 1. A traditional copying garbage collection sequence
在该算法的近期实现中,可以将堆中任意地址空间指定为from区和to区,这样就不再需要交换from区和to区,堆中任意地址空间都可以成为from区或to区。
拷贝垃圾回收器的一个优点是存活对象的位置会被to区中重新分配,紧凑存放,可以完全消除碎片化。碎片化是其他垃圾回收算法所要面临的一大问题,这点会在后续讨论。
拷贝垃圾回收的缺陷
通常来说,拷贝垃圾回收器是“stop-the-world”式的,即在垃圾回收周期内,应用程序是被挂起的,无法工作。在“stop-the-world”式的实现中,所需要拷贝的区域越大,对应用程序的性能所造成的影响也越大。对于那些非常注重响应时间的应用程序来说,这是难以接受的。使用拷贝垃圾回收时,你还需要考虑一下最坏情况,即当from区中所有的对象都是存活对象的时候。因此,你不得不给存活对象预留出足够的空间,也就是说to区必须足够大,大到可以将from区中所有的对象都放进去。正是由于这个缺陷,拷贝垃圾回收算法在内存使用效率上略有不足。
标记-清理垃圾回收器
大多数部署在企业生产环境的商业JVM都使用了标记-清理(或标记)垃圾回收器,这种垃圾回收器并不会想拷贝垃圾回收器那样对应用程序的性能有那么大的影响。其中最著名的几款是CMS、G1、GenPar和DeterministicGC(参见相关资源)。
标记-清理垃圾回收器会跟踪引用,并使用标记位将每个找到的对象标记位“live”。通常来说,每个标记位都关联着一个地址或堆上的一个地址集合。例如,标记位可能是对象头(object header)中一位,一个位向量,或是一个位图。
当所有的存活对象都被标记位“live”后,将会开始*清理*阶段。一般来说,垃圾回收器的清理阶段包含了通过再次遍历堆(不仅仅是标记位live的对象集合,而是整个堆)来定位内存地址空间中未被标记的区域,并将其回收。然后,垃圾回收器会将这些被回收的区域保存到空闲列表(free list)中。在垃圾回收器中可以同时存在多个空闲列表——通常会按照保存的内存块的大小进行划分。某些JVM(例如JRockit实时系统, JRockit Real Time System)在实现垃圾回收器时会给予应用程序分析数据和对象大小统计数据来动态调整空闲列表所保存的区域块的大小范围。
当清理阶段结束后,应用程序就可以再次启动了。给新创建的对象分配内存时会从空闲列表中查找,而空闲列表中内存块的大小需要匹配于新创建的对象大小、某个线程中平均对象大小,或应用程序所设置的TLAB的大小。从空闲列表中为新创建的对象找到大小合适的内存区域块有助于优化内存的使用,减少内存中的碎片。
关于TLAB
更多关于TLAB和TLA(Thread Local Allocation Buffer和Thread Local Area)的内容,请参见ImportNew翻译整理的第一篇《JVM性能优化, Part 1 ―― JVM简介》。
标记-清理垃圾回收器的缺陷
标记阶段的时长取决于堆中存活对象的总量,而清理阶段的时长则依赖于堆的大小。由于在*标记*阶段和*清理*阶段完成前,你无事可做,因此对于那些具有较大的堆和较多存活对象的应用程序来说,使用此算法需要想办法解决暂停时间(pause-time)较长这个问题。
对于那些内存消耗较大的应用程序来说,你可以使用一些GC调优选项来满足其在某些场景下的特殊需求。很多时候,调优至少可以将标记-清理阶段给应用程序或性能要求(SLA,SLA指定了应用程序需要达到的响应时间的要求,即延迟)所带来的风险推后。当负载和应用程序发生改变后,需要重新调优,因为某次调优只对特定的工作负载和内存分配速率有效。
标记-清理算法的实现
目前,标记-清理垃圾回收算法至少已有2种商业实现,并且都已在生产环境中被证明有效。其一是并行垃圾回收,另一个是并发(或多数时间并发)垃圾回收。
并行垃圾回收器
并行垃圾回收指的是垃圾回收是多线程并行完成的。大多数商业实现的并行垃圾回收器都是stop-the-world式的垃圾回收器,即在整个垃圾回收周期结束前,所有应用程序线程都会被挂起。挂起所有应用程序线程使垃圾回收器可以以并行的方式,更有效的完成标记和清理工作。并行使得效率大大提高,通常可以在像SPECjbb这样的吞吐量基准测试中跑出高分。如果你的应用程序好似有限考虑吞吐量的,那么并行垃圾回收是你最好的选择。
对于大多数并行垃圾回收器来说,尤其是考虑到应用于生产环境中,最大的问题是,像拷贝垃圾回收算法一样,在垃圾回收周期内应用程序无法工作。使用stop-the-world式的并行垃圾回收会对优先考虑响应时间的应用程序产生较大影响,尤其是当你有大量的引用需要跟踪,而此时恰好又有大量的、具有复杂结构的对象存活于堆中的时候,情况将更加糟糕。(记住,标记-清理垃圾回收器回收内存的时间取决于跟踪存活对象中所有引用的时间与遍历整个堆的时间之和。)以并行方式执行垃圾回收所导致的应用程序暂停会一直持续到整个垃圾回收周期结束。
并发垃圾回收器
并发垃圾回收器更适用于那些对响应时间比较敏感的应用程序。并发指的是一些(或大多数)垃圾回收工作可以与应用程序线程同时运行。由于并非所有的资源都由垃圾回收器使用,因此这里所面临的问题如何决定何时开始执行垃圾回收,可以保证垃圾回收顺利完成。这里需要足够的时间来跟踪存活对象即的引用,并在应用程序出现OOM错误前回收内存。如果垃圾回收器无法及时完成,则应用程序就会抛出OOM错误。此外,一直做垃圾回收也不好,会不必要的消耗应用程序资源,从而影响应用程序吞吐量。要想在动态环境中保持这种平衡就需要一些技巧,因此设计了启发式方法来决定何时开始垃圾回收,何时执行不同的垃圾回收优化任务,以及一次执行多少垃圾回收优化任务等。
并发垃圾回收器所面临的另一个挑战是如何决定何时执行一个需要完整堆快照的操作时安全的,例如,你需要知道是何时标记所有存活对象的,这样才能转而进入清理阶段。在大多数并行垃圾回收器采用的stop-the-world方式中,*阶段转换(phase-switching)*并不需要什么技巧,因为世界已静止(堆上对象暂时不会发生变化)。但是,在并发垃圾回收中,转换阶段时可能并不是安全的。例如,如果应用程序修改了一块垃圾回收器已经标记过的区域,可能会涉及到一些新的或未被标记的引用,而这些引用使其指向的对象成为存活状态。在某些并发垃圾回收的实现中,这种情况有可能会使应用程序陷入长时间运行重标记(re-mark)的循环,因此当应用程序需要分配内存时无法得到足够做的空闲内存。
到目前为止的讨论中,已经介绍了各种垃圾回收器和垃圾回收算法,他们各自适用于不同的场景,满足不同应用程序的需求。各种垃圾回收方式不仅在算法上有所区别,在具体实现上也不尽相同。所以,在命令行中指定垃圾回收器之前,最好能了解应用程序的需求及其自身特点。在下一节中,将介绍Java平台内存模型中的陷阱,在这里,陷阱指的是在动态生产环境中,Java程序员常常做出的一些中使性能更糟,而非更好的假设。
为什么调优无法取代垃圾回收
大多数Java程序员都知道,如果有不少方法可以最大化Java程序的性能。而当今众多的JVM实现,垃圾回收器实现,以及多到令人头晕的调优选项都可能会让开发人员将大量的时间消耗在无穷无尽的性能调优上。这种情况催生了这样一种结论,“GC是糟糕的,努力调优以降低GC的频率或时长才是王道”。但是,真这么做是有风险的。
考虑一下针对指定的应用程序需求做调优意味着什么。大多数调优参数,如内存分配速率,对象大小,响应时间,以及对象死亡速度等,都是针对特定的情况而来设定的,例如测试环境下的工作负载。例如。调优结果可能有以下两种:
调优是需要不断往复的。使用并发垃圾回收器需要做很多调优工作,尤其是在生产环境中。为满足应用程序的需求,你需要不断挑战可能要面对的最差情况。这样做的结果就是,最终形成的配置非常刻板,而且在这个过程中也浪费了大量的资源。这种调优方式(试图通过调优来消除GC)是一种堂吉诃德式的探索——以根本不存在的理由去挑战一个假想敌。而事实是,你针对某个特定的负载而垃圾回收器做的调优越多,你距离Java运行时的动态特性就越远。毕竟,有多少应用程序的工作负载能保持不变呢?你所预估的工作负载的可靠性又有多高呢?
那么,如果不从调优入手又该怎么办呢?有什么其他的办法可以防止应用程序出现OOM错误,并降低响应时间呢?这里,首先要做的是明确影响Java应用程序性能的真正因素。
碎片化
影响Java应用程序性能的罪魁祸首并不是垃圾回收器本身,而是碎片化,以及垃圾回收器如何处理碎片。碎片是Java堆中空闲空间,但由于连续空间不够大而无法容纳将要创建的对象。正如我在本系列第2篇中提到的,碎片可能是TLAB中的剩余空间,也可能是(这种情况比较多)被释放掉的具有较长生命周期的小对象所占用的空间。
随着应用程序的运行,这种无法使用的碎片会遍布于整个堆空间。在某些情况下,这种状态会因静态调优选项(如提升速率和空闲列表等)更糟糕,以至于无法满足应用程序的原定需求。这些剩下的空间(也就是碎片)无法被应用程序有效利用起来。如果你对此放任自流,就会导致不断垃圾回收,垃圾回收器会不断的释放内存以便创建新对象时使用。在最差情况下,甚至垃圾回收也无法腾出足够的内存空间(因为碎片太多),JVM会强制抛出OOM(out of memory)错误当然,你也可以重启应用程序来消除碎片,这样可以使Java堆焕然一新,于是就又可以为对象分配内存了。但是,重新启动会导致服务器停机,另外,一段时间之后,堆将再次充满碎片,你也不得不再次重启。
OOM错误(OutOfMemoryErrors)会挂起进程,日志中显示的垃圾回收器很忙,是垃圾回收器努力释放内存的标志,也说明了堆中碎片非常多。一些开发人员通过重新调优垃圾回收器来解决碎片化的问题,但我觉着在解决碎片问题成为垃圾回收的使命之前应该用一些更有新意的方法来解决这个问题。本文后面的内容将聚焦于能有效解决碎片化问题的方法:分代黛式垃圾回收和压缩。
分代式垃圾回收
这个理论你可以已经听说过,即在生产环境中,大部分对象的生命周期都很短。分代式垃圾回收就源于这个理论。在分代式垃圾回收中,堆被分为两个不同的空间(或成为“代”),每个空间存放具有不同年龄的对象,在这里,年龄是指该对象所经历的垃圾回收的次数(也就是该对象挺过了多少次垃圾回收而没有死掉)。
当新创建的对象所处的空间,即*年轻代*,被对象填满后,该空间中仍然存活的对象会被移动到老年代。(译者注,以HotSpot为例,这里应该是挺过若干次GC而不死的,才会被搬到老年代,而一些比较大的对象会直接放到老年代。)大多数的实现都将堆会分为两代,年轻代和老年代。通常来说,分代式垃圾回收器都是单向拷贝的,即从年轻代向老年代拷贝,这点在早先曾讨论过。近几年出现的年轻代垃圾回收器已经可以实现并行垃圾回收,当然也可以实现一些其他的垃圾回收算法实现对年轻代和老年代的垃圾回收。如果你使用拷贝垃圾回收器(可能具有并行收集功能)对年轻代进行垃圾回收,那垃圾回收是stop-the-world式的(参见前面的解释)。
分代式垃圾回收的缺陷
在分代式垃圾回收中,老年代执行垃圾回收的平率较低,而年轻代中较高,垃圾回收的时间较短,侵入性也较低。但在某些情况下,年轻代的存在会是老年代的垃圾回收更加频繁。典型的例子是,相比于Java堆的大小,年轻代被设置的太大,而应用程序中对象的生命周期又很长(又或者给年轻代对象提升速率设了一个“不正确”的值)。在这种情况下,老年代因太小而放不下所有的存活对象,因此垃圾回收器就会忙于释放内存以便存放从年轻代提升上来的对象。但一般来说,使用分代式垃圾回收器可以使用应用程序的性能和系统延迟保持在一个合适的水平。
使用分代式垃圾回收器的一个额外效果是部分解决了碎片化的问题,或者说,发生最差情况的时间被推迟了。可能造成碎片的小对象被分配于年轻代,也在年轻代被释放掉。老年代中的对象分布会相对紧凑一些,因为这些对象在从年轻代中提升上来的时候会被会紧凑存放。但随着应用程序的运行,如果运行时间够长的话,老年代也会充满碎片的。这时就需要对年轻代和老年代执行一次或多次stop-the-world式的全垃圾回收,导致JVM抛出_OOM错误_或者表明提升失败的错误。但年轻代的存在使这种情况的出现被推迟了,对某些应用程序来说,这就就足够了。(在某些情况下,这种糟糕情况会被推迟到应用程序完全不关心GC的时候。)对大多数应用程序来说,对于大多数使用年轻代作为缓冲的应用程序来说,年轻代的存在可以降低出现stop-the-world式垃圾回收频率,减少抛出OOM错误的次数。
调优分代式垃圾回收
正如上面提到的,由于使用了分代式垃圾回收,你需要针对每个新版本的应用程序和不同的工作负载来调整年轻代大小和对象提升速度。我无法完整评估出固定运行时的代价:由于针对某个指定工作负载而设置了一系列优化参数,垃圾回收器应对动态变化的能力降低了,而变化是不可避免的。
对于调整年轻代大小来说,最重要的规则是要确保年轻代的大小不应该使因执行stop-the-world式垃圾回收而导致的暂停过长。(假设年轻代中使用的并行垃圾回收器。)还要记住的是,你要在堆中为老年代留出足够的空间来存放那些生命周期较长的对象。下面还有一些在调优分代式垃圾回收器时需要考虑的因素:
压缩
尽管分代式垃圾回收推出了碎片化和OOM错误出现的时机,但压缩仍然是唯一真正解决碎片化的方法。*压缩*是将对象移动到一起,以便释放掉大块连续内存空间的GC策略。因此,压缩可以生成足够大的空间来存放新创建的对象。
移动对象并修改相关引用是一个stop-the-world式的操作,这会对应用程序的性能造成影响。(只有一种情况是个例外,将在本系列的下一篇文章中讨论。)存活对象越多,垃圾回收造成的暂停也越长。假如堆中的空间所剩无几,而且碎片化又比较严重(这通常是由于应用程序运行的时间很长了),那么对一块存活对象多的区域进行压缩可能会耗费数秒的时间。而如果因出现OOM而导致应用程序无法运行,因此而对整个堆进行压缩时,所消耗的时间可达数十秒。
压缩导致的暂停时间的长短取决于需要移动的存活对象所占用的内存有多大以及有多少引用需要更新。当堆比较大时,从统计上讲,存活对象和需要更新的引用都会很多。从已观察到的数据看,每压缩1到2GB存活数据的需要约1秒钟。所以,对于4GB的堆来说,很可能会有至少25%的存活数据,从而导致约1秒钟的暂停。
压缩与应用程序内存墙
应用程序内存墙涉及到在GC暂停时间对应用程序的影响大到无法达到满足预定需求之前所能设置的的堆的最大值。目前,大部分Java应用程序在碰到内存墙时,每个JVM实例的堆大小介于4GB到20GB之间,具体数值依赖于具体的环境和应用程序本身。这也是大多数企业及应用程序会部署多个小堆JVM而不是部署少数大堆(50到60GB)JVM的原因之一。在这里,我们需要思考一下:现代企业中有多少Java应用程序的设计与部署架构受制于JVM中的压缩?在这种情况下,我们接受多个小实例的部署方案,以增加管理维护时间为代价,绕开为处理充满碎片的堆而执行stop-the-world式垃圾回收所带来的问题。考虑到现今的硬件性能和企业级Java应用程序中对内存越来越多的访问要求,这种方案是在非常奇怪。为什么仅仅只能给每个JVM实例设置这么小的堆?并发压缩是一种可选方法,它可以降低内存墙带来的影响,这将是本系列中下一篇文章的主题。
从已观察到的数据看,每压缩1到2GB存活数据的需要约1秒钟。所以,对于4GB的堆来说,很可能会有至少25%的存活数据,从而导致约1秒钟的暂停。
总结:回顾
本文对垃圾回收做了总体介绍,目的是为了使你能了解垃圾回收的相关概念和基本知识。希望本文能激发你继续深入阅读相关文章的兴趣。这里所介绍的大部分内容,它们。在下一篇文章中,我将介绍一些较新颖的概念,并发压缩,目前只有Azul公司的Zing JVM实现了这一技术。并发压缩是对GC技术的综合运用,这些技术试图重新构建Java内存模型,考虑当今内存容量与处理能力的不断提升,这一点尤为重要。
现在,回顾一下本文中所介绍的关于垃圾回收的一些内容:
下个月的_JVM性能调优系列_:深入了解C4垃圾回收器(Continuously Concurrent Compacting Collector)相关算法。
关于作者
Eva Andearsson对JVM计数、SOA、云计算和其他企业级中间件解决方案有着10多年的从业经验。在2001年,她以JRockit JVM开发者的身份加盟了创业公司Appeal Virtual Solutions(即BEA公司的前身)。在垃圾回收领域的研究和算法方面,EVA获得了两项专利。此外她还是提出了确定性垃圾回收(Deterministic Garbage Collection),后来形成了JRockit实时系统(JRockit Real Time)。在技术上,Eva与Sun公司和Intel公司合作密切,涉及到很多将JRockit产品线、WebLogic和Coherence整合的项目。2009年,Eva加盟了Azul System公司,担任产品经理。负责新的Zing Java平台的开发工作。最近,她改换门庭,以高级产品经理的身份加盟Cloudera公司,负责管理Cloudera公司Hadoop分布式系统,致力于高扩展性、分布式数据处理框架的开发。
英文原文:JVM performance optimization, Part 3,翻译:ImportNew - 曹旭东
译文链接:http://www.importnew.com/2233.html