依托JavaTM 2平台的力量,标准版(J2SETM)实现了内存的自动管理,将开发人员从复杂的显式内存管理中解放出来。
本文将对Sun公司的J2SE发行版中的Java HotSpot虚拟机中的内存管理进行一次综述。文中将会对用来进行内存管理的GC进行讲解,并对选择配置GC、设置执行GC时的内存区域给出一些意见。本文也可以用作资源文档,文中会列举出一些通用的GC选项,并提供一些链接到具有更多详细内容的文档的链接。
若你对自动内存的一些概念还不熟悉,那么第2节你应该认真读一下。其中有对自动内存管理和由程序员显式的释放数据空间进行比较的讨论。第3节对GC的整体概念,设计选择和性能指标进行了总体说明。其中会介绍一种通用的、基于对象生命周期的、分代管理内存的机制。这种分代的内存管理机制可以有效的减少GC时系统暂停的时间。
本文的其他部分提供了HotSpot虚拟机的规范。第4节描述了当前可用的GC(其中包括了在J2SE 5.0 update6中新添加的一种GC),和分代内存管理的实现文档。对每种GC,第4节都总结了其实现算法和适用的场景。
第5节中描述了J2SE 5.0中的新技术,该技术综合了GC、堆大小、基于运行平台和操作系统的HotSpot虚拟机(客户端或服务器端)的自动选择,以及基于用户指定动作的动态GC调整。
第6节提供对选择配置GC的一些建议。此外,给出了一些当发生OutOfMemory错误时的解决方法。第7节对可用来优化GC性能的工具进行了说明。第8节列出了一些常用的、与GC相关的命令行选项。最后,第9节提供了一些本文涉及到的文档的详细内容的链接。
内存管理是回收已经不再使用的对象占用的内存,使其可以被后续的内存分配请求使用的过程。在一些程序设计语言中,内存管理是由程序员负责完成的,但内存管理非常复杂,发生错误时会导致程序的异常行为,甚至是系统崩溃。结果是,开发时间有很大一部分花在了调式程序,修改bug上。
显式内存管理中经常发生的一个问题是引用挂起(dangling references),即有可能会释放掉其他对象正在使用的一块内存,并将该内存分配给另一个对象。若某个对象的引用指向的内存已经被释放掉,并分配给另一个对象,而该对象还想访问该块内存时,结果是无法预测的。
显式内存管理的另一个常见问题是内存泄漏。内存被分配出去,若使用后没有被回收,则会造成内存泄漏。例如,你想要释放一个使用过链表的内存,但在释放链表的第一个元素时由于程序错误而没有成功,则整个链表的元素就都失去了引用,程序再也无法使用或释放该块内存。若程序中有这样的内存泄漏,则它们会不断的产生更多的内存泄漏,直到消耗掉所有的内存。
内存管理的一种可选的办法是自动内存管理,即通过GC(garbage collector)完成内存
回收。这也是现在很多面向对象语言使用的内存管理方法。自动内存管理使得接口的抽象程度更高,代码更加可靠。
GC避免了引用挂起的问题,因为当一个对象还在被引用时,是不会被回收的,其内存也就不会被释放掉。此外,GC也可解决上面提到的内存泄漏问题,因为内存释放不再由程序员负责,GC会完成这项工作。
GC需要负责一下的工作:
(1)分配内存;
(2)确保内存中所有的对象都有引用指向它;
(3)在正在执行的代码中,当对象不再可到达时,回收该对象使用的内存。
被引用的对象被认为是live的,已经不可到达的对象被认为是dead的,被称为垃圾(garbage)。找到并释放(freeing,也称为reclaiming)这些对象的过程就是垃圾收集(garbage collection)。
垃圾收集可以解决很多,但不是全部,内存分配问题。例如,你可以不断的创建对象,并一直保持对它们的引用,直到系统内存被耗尽。垃圾收集是一项复杂的工作,其本身也会消耗时间和系统资源。
用来管理内存、分配或释放内存的算法有GC去实现,这些对程序员来说是不可见的。分配的空间来自于一个很大的内存池,也称为堆(heap)。
垃圾收集的完成时间取决于GC。当堆被全部占用,或其占用了达到一定的数值时,会对整个堆或其中的一部分进行垃圾收集。
圆满的完成一次内存分配是挺困难的,需要在堆中找到一块指定大小的未使用的内存。动态内存分配算法要解决的一个主要的问题是,在保证高效的分配回收内存的情况下,如何减少内存碎片。
GC必须是安全、可理解的。即,活动数据不能被错误的释放掉,垃圾不应该保持unclaimed状态过长时间。
若GC执行的非常有效率,且不会引起正在运行的程序长时间暂停,则这个GC也是比较理想的。但是,对很多计算机系统来说,时间、空间和效率是难以兼得的。例如,若堆很小,则垃圾收集 会很快,但堆会很快被填满,因此,需要更有效率的垃圾收集。相反,一个很大的堆要很久才会填满,垃圾收集也就没那么好的效率,需要较长的时间完成垃圾收集。
另外一个理想的GC的特性是对内存碎片的限制。当垃圾对象占用的内存被释放时,该块内存可能出现在不同内存区域中,这样,可能就没有足够大的连续空间来为较大的对象分配内存了。清除内存碎片的一个方法是压缩(compacting),该方法将在不同GC的设计选择中讨论。
GC的伸缩性也是很重要的。在多处理器系统上,多线程程序中,分配内存和垃圾收集不应该成为性能瓶颈。
在设计和选择垃圾收集的算法时,必须要做出一些选择:
l 串行 vs. 并行
对于串行收集,一次只会发生一件事情。例如,在多CPU系统上,每次只有一个CPU会执行垃圾收集的工作。而使用并行策略,垃圾收集的任务会被分成几个小的子任务,这些小的子任务会在不同的CPU上同时进行。这样,垃圾收集就可以更快的完成,但这样做的代价是程序的额外的复杂性和潜在的内存碎片无法回收。
l 并发 vs. Stop-the-world
当执行Stop-the-world式的垃圾收集时,本来正在运行的应用程序会完全暂停,直到垃圾收集结束。另一种方式是,多个垃圾收集任务并发执行,即垃圾收集与应用程序同时进行。典型情况下,并发式GC会并发执行其垃圾收集任务,但是,可能也会有一些任务需要以Stop-the-world方法执行,导致应用程序暂停一会。与并发式GC相比,Stop-the-world式的GC更简单,因为在进行垃圾收集期间,堆已经被封住,无法使用,而在此期间,对象也不会发生变化。这种方法的缺点是,对某些应用程序来说,暂停是无法接受的。相对而言,使用并发式的GC可以在更短的暂停时间内完成垃圾收集。但是,这种情况下,GC的实现要做一些额外的考虑,因为GC正在操作的对象可能同时会被应用程序更新。这会给并发式GC带来额外的问题,会影响执行性能,并需要一个更大的堆来完成垃圾收集。
l 压缩 vs. 不压缩 vs. 拷贝
在GC决定了内存中的哪些对象是live态,哪些是垃圾后,它会压缩内存,将所有的live对象移到一起,完全回收剩余的部分内存。在压缩之后,就可以方便快速的为新对象分配内存了。可以使用一个指针来跟踪可以用来给对象分配内存的区域。相比于压缩式收集器,非压缩式收集器会直接释放掉垃圾对象占用的内存,他并不会压缩式收集器一样将live状态的对象移动到其他地方来创建一个较大的可使用区域。这样做的好处是,垃圾收集工作可以更快的完成,缺点是有产生内存碎片的潜在可能。总体上讲,在非压缩的堆上分配或回收内存会比在压缩过的堆上执行同样的操作付出更昂贵的代价。因为它可能需要搜索整个堆才能找到一个合适大小的内存分配给新对象。第三种可选的方案是拷贝收集器,该收集器会将live态的对象拷贝到另一块不同的内存区域。这样做的好处是,拷贝后,源内存区域可以作为一块空的、立即可用的区域对待,方便后续的内存分配,但是这种方法的缺点是需要用额外的时间、空间来拷贝对象。
下面的几个指标用来衡量GC的执行性能,包括:
(1)吞吐量(Throughput):
在一段长时间内,没有花费在垃圾收集上的时间所占的比例。
(2)垃圾收集代价(Garbage Collection Overhead):
与吞吐量相反,垃圾收集时间所占的比例。
(3)暂停时间(Pause Time):
当执行垃圾收集时,应用程序被迫暂停的时间长度。
(4)垃圾收集频率(Frequency Of Collection):
相对于应用程序的执行,垃圾收集执行的频率。
(5)覆盖区(Footprint):
一种大小的度量,如堆的大小。
(6)敏捷度(Promptness):
指从一个对象成为垃圾时到该对象所占用的内存被回收时之间的时间长度。
交互式的应用程序会要求只能有很短的暂停,而非交互式的应用程序则会对总体执行时间比较看重。实时系统会对垃圾收集时的暂停时间和垃圾收集所占用时间比例都有要求。在嵌入式系统或PC上运行的应用程序可能更希望有较小的覆盖区。
当使用称为分代收集的技术时,内存将被分为不同的几代,即,会将对象按其年龄分别存储在不同的对象池中。例如,目前最广泛使用的是分代是将对象分为年轻代对象和老年代对象。
在分代内存管理中,使用不同算法对不同代的对象执行垃圾收集的工作,每种算法都是基于对某代对象的特性进行优化的。考虑到应用程序可以是用包括Java在内的不同的程序语言编写,分代垃圾收集使用了称为弱代理论(weak generational hypothesis)的方法,具体描述如下:
l 大多数分配了内存的对象并不会存活太长时间,在处于年轻代时就会死掉;
l 很少有对象会从老年代变成年轻代。
年轻代对象的垃圾收集相对频繁一些,同时会也更有效率,更快一些,因为年轻代对象所占用的内存通常较小,也比较容易确定哪些对象是已经无法再被引用的。
当某些对象经过几次年轻代垃圾收集后依然存活,则这些对象会被提升(promted , tenured)为老年代。如图1所示。典型情况下,老年代所占用的内存会比年轻代大,而且还会随时渐渐慢慢增大。这样的结果是,对老年代的垃圾收集就不能频繁进行,而且执行时间也会长很多。
图 1 分代垃圾收集
选择年轻代的垃圾收集算法时会更看重执行速度,因为年轻代的垃圾收集工作会频繁执行。另一方面,管理老年代的算法则更注重空间效率,因为老年代会占用堆中的大部分空间,这要求算法必须要处理好垃圾收集的工作,尽量降低堆中的垃圾内存的密度。
在J2SE 5.0 update 6中,Java HotSpot虚拟机中有四种垃圾收集器,所有的GC都是分代收集的。本节将会描述内存中代的划分和相应的垃圾收集的类型,并会对如何才能快速有效的分配对象进行讨论。然后,对每种垃圾收集器进行详细讨论。
在Java HotSpot虚拟机中,内存被分为3代:年轻代、老年代和永生代。大多数对象最初都是分配在年轻代内存中的,年轻代中对象经过几次垃圾收集后还存活的,会被转到老年代。一些体积比较大的对象在创建的时候可能就会在老年代中。永生代中存放了一些JVM查找起来比较方便的对象,例如对类、方法进行描述的对象,这些对象由GC去管理。
在年轻代中包含三个分区,一个Eden区和两个Survivor区,如图2所示。大部分对象最初是分配在Eden区中的(但是,如前面所述,一些较大的对象可能会直接分配在老年代中)。Survivor区中保存了至少经过一次垃圾收集后还存活的年轻代对象,这些对象有机会被提升为老年代。当其中一个Survivor分区为空时,另一个分区会一直持有这些对象,直到下一次垃圾收集开始执行。
图 2 年轻代内存划分图
当年轻代被填满时,开始执行年轻代的垃圾收集(有时也称为次要收集,minor collection)。当老年代或永生代被填满时,也会执行其相应的垃圾收集(也称为主要收集,major collection),即,会对所有代的对象进行垃圾收集。一般来说,年轻代的垃圾收集 会使用专为年轻代设计的算法,执行速度是很快的,因为在年轻代中,该算法通常是标记垃圾对象最有效率的算法。然后,使用老年代垃圾收集算法的GC会对老年代和永生代进行垃圾收集。若使用压缩,则会对每一代进行分别压缩。
有时候,年轻代第一次执行了垃圾收集,而老年代中的对象已经太多以至于无法再接收从年轻代中提升上来的对象。这种情况下,除了CMS收集器外,其他所有的年轻代垃圾收集算法都不会运行。相反,会执行老年代垃圾收集算法,对整个堆进行垃圾收集算法(老年代的CMS算法比较特殊,因为它不会对年轻代进行垃圾收集)。
在下面的描述中,你将会看到内存中有时会有很多相邻的大的内存块可用来为内存分配内存。使用碰撞指针(bump-the-pointer)的技术从这些内存块中分配空间是很简单的。即,始终跟踪上一次分配对象时使用的空间末尾地址。当要为新对象分配空间时,会在当前所处的代的各个内存块中查找是否有适合大小的空间来分配给新对象,若有,则更新指针,初始化对象。
对于多线程应用程序,内存分配操作必须是线程安全的。如果使用了全局锁来保证线程安全,这样在代中分配内存时会造成性能瓶颈,降低性能。与此不同的是,HostSpot JVM使用了名为局部线程分配缓冲(Thread-Local Allocation Buffers ,TLABs)的技术。通过给每个线程开辟一块缓冲区,在其中分配内存来提高多线程应用程序的吞吐量。因为每个TLAB只能有一个线程使用,这样就可以使用bump-the-pointer来快速分配内存,而无需使用任何锁。极少数情况下,当一个线程用完了它自己的TLAB,需要一个使用一个新的TLAB,这时必须使用同步策略。有些技术可以用来最小化使用TLAB造成的内存浪费。例如,使用分配器来为TLAB划分大小,可以使用浪费的空间减小到Eden区的1%。综合使用TLAB和使用线性分配策略的碰撞指针可以是内存分配更有效率,仅仅需要大约10个指令即可完成。
使用串行收集器,年轻代和老年代的垃圾收集工作会串行完成(在单一CPU系统上),这时是stop-the-world模式的。即,当执行垃圾收集工作时,应用程序必须停止运行。
图3展示了使用串行收集器的年轻代垃圾收集的执行过程。Eden区的活跃对象(live状态的对象)会被拷贝到初始为空的Survivor区(图中标识为To的区)中,这其中,那些体积过大以至于Survivor区装不下的对象不会进行拷贝。这些对象会被拷贝到老年代中。相对于已经被拷贝到To区的对象,源Survivor区(图中标识为From的区)中的live对象仍然比较年轻,而被拷贝到老年代中对象则相对年纪大一些。注意,若To区已经满了,来自Eden区或From区的对象就无法被拷贝到To区了,那么这些对象会被调整,无论经过多少次年轻代的垃圾收集,这些对象都不会被释放掉。在live对象被拷贝之后,Eden区和From区中还存在的对象就不再是live的了,它们不会再被检测(在图中,这些垃圾对象被标记为一个×,但事实上,垃圾收集器不会在检测或标记这些对象了)。
图 3 串行化年轻代垃圾收集示意图
在年轻代垃圾收集完成后,Eden区和From区会被清空,只有To区会继续持有live状态的对象。此时,From区和To区在逻辑上交换,To区变成From区,原From区变成To区,如图4所示。
图 4 年轻代垃圾收集后的内存示意图
对于串行收集器,老年代和永生代会在进行垃圾收集时使用标记-清理-压缩(Mark-Sweep-Compact)算法。在标记阶段,收集器会标识哪些对象是live状态的。清理阶段会跨代清理,标识垃圾对象。然后,收集器执行移动压缩(sliding compaction),将live对象移动到老年代内存空间的起始部分(永生代中情况于此类似),这样在老年代内存空间的尾部会产生一个大的连续空间。如图5所示。这种压缩可以使用碰撞指针完成。
图 5 老年代的内存压缩
大多数运行在客户机上的应用程序会选择使用并行垃圾收集器,因为这些应用程序对低暂停时间并没有较高的要求。对于当今的硬件来说,串行垃圾收集器已经可以有效的管理许多具有64M堆的重要应用程序,并且执行一次完整垃圾收集也不会超过半秒钟。
在J2SE 5.0的发行版中,在非服务器类使用的机器上,默认选择的是串行垃圾收集器。在其他类型使用的机器上,可以通过添加参数-XX:+UseSerialGC来显式的使用串行垃圾收集器。
当前,很多的Java应用程序都跑在具有较大物理内存和多CPU的机器上。并行垃圾收集器,也称为吞吐量垃圾收集器,被用于垃圾收集工作。该收集器可以充分的利用多CPU的特点,避免一个CPU执行垃圾收集,其他CPU空闲的状态发生。
这里,对年轻代的并行垃圾收集使用的串行垃圾收集算法的并行版本。它仍然会stop-the-world,拷贝对象,但执行垃圾收集时是使用多CPU并行进行的,减少了垃圾收集的时间损耗,提高了应用程序的吞吐量。图6展示了串行垃圾收集器和并行垃圾收集器对年轻代进行垃圾收集时的区别。
图 6 年轻代中串行与并行垃圾收集的对比示意图
老年代中的并行垃圾收集使用了与串行垃圾收集器相同的串行标记-清理-压缩(mark-sweep-compact)算法。
当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,使用并行垃圾收集器会有较好的效果,因为虽不频繁,但可能时间会很长的老年代垃圾收集仍然会发生。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序更适合使用并行垃圾收集。
可能你会想用并行压缩垃圾收集器(会在下一节介绍)来替代并行收集器,因为前者对所有代执行垃圾收集,而后者指对年轻代执行垃圾收集。
在J2SE 5.0的发行版中,若应用程序是运行在服务器类的机器上,则会默认使用并行垃圾收集器。在其他机器上,可以通过-XX:+UseParallelGC参数来显式启用并行垃圾收集器。
并行压缩垃圾收集器是在J2SE 5.0 update 6中被引入的,其与并行垃圾收集器的区别在于,并行压缩垃圾收集器使用了新的算法对老年代进行垃圾收集。注意,最终,并行压缩垃圾收集器会取代并行垃圾收集器。
年轻代中,并行压缩垃圾收集器使用了与并行垃圾收集器相同的垃圾收集算法。
当使用并行压缩垃圾收集时,老年代和永生代会使用stop-the-world的方式执行垃圾收集,大多数的并行模式都会使用移动压缩(sliding compaction)。垃圾收集分为三个阶段。首先,将每一个代从逻辑上分为固定大小的区域。在标记阶段(mark phase),应用程序代码可以直接到达的live对象的初始集合会被划分到各个垃圾收集线程中,然后,所有的live对象会被并行标记。若一个对象被标记为live,则会更新该对象所在的区域中与该对象的大小和位置相关的数据。
在总结阶段(summary phase)会对区域,而非单独的对象进行操作。由于之前的垃圾收集执行了压缩,每一代的左侧部分的对象密度会较高,包含了大部分live对象。这些对象密度较高的区域被恢复为可用后,就不值得再花时间去压缩了。所以,在总结阶段要做的第一件事是从最左端对象开始检查每个区域的live对象密度,直到找到了一个恢复其本区域和恢复其右侧的空间的开销都比较小时停止。找到的区域的左侧所有区域被称为dense prefix,不会再有对象被移动到这些区域里了。这个区域后侧的区域会被压缩,清除所有已死的空间(清理垃圾对象占用的空间)。总结阶段会计算并保存每个压缩后的区域中对象的新地址。注意,在当前实现中,总结阶段是串行的;当然总结阶段也可以实现为并行的,但相对于性能总结阶段的并行不及标记压缩阶段来得重要。
在压缩阶段(compaction phase),垃圾收集线程使用总结阶段收集到的数据决定哪些区域课余填充数据,然后各个线程独立的将数据拷贝到这些区域中。这样就产生了一个底端对象密度大,连一端是一个很大的空区域块的堆。
相对于并行垃圾收集器,使用并行压缩垃圾收集器对那些运行在多CPU的应用程序更有好处。此外,老年代垃圾收集的并行操作可以减少应用程序的暂停时间,对于那些对暂停时间有较高要求的应用程序来说,并行压缩垃圾程序比并行垃圾收集更加适用。并行压缩垃圾收集程序可能并不适用于那些与其他很多应用程序并存于一台机器的应用程序上,这种情况下,没有一个应用程序可以独占所有的CPU。在这样的机器上,需要考虑减少执行垃圾收集的线程数(使用-XX:ParallelGCThreads=n命令行选项),或者使用另一种垃圾收集器。
若你想使用并行压缩垃圾收集器,你必须显式指定-XX:+UseParallelOldGC命令行选项。
对于很多应用程序来说,点到点的吞吐量并不如快速响应来的重要。典型情况下,年轻代的垃圾收集并不会引起较长时间的暂停。但是,老年代的垃圾收集,虽不频繁,却可能引起长时间的暂停,特别是使用了较大的堆的时候。为了应付这种情况,HotSpot JVM使用了CMS垃圾收集器,也称为低延迟(low-latency)垃圾收集器。
CMS垃圾收集器对年轻代进行垃圾收集时采用了与并行垃圾收集器相同的行为。
大部分老年代的垃圾收集使用了CMS垃圾收集器,垃圾收集工作是与应用程序的执行并发进行的。
CMS垃圾收集器的执行的垃圾收集工作始于一次短暂停,成为初始标记(initialized mark),标识出应用程序中强可到达的live对象的初始集合。然后,在并发标记阶段(concurrent mark phase),垃圾收集器在刚才产生的集合中标记出live对象。因为此时应用程序也在运行,并更新对象的引用,所以当标记阶段结束时,并不能保证可以标记出所有的live对象。为了解决这个问题,此时会应用程序会进入第二次暂停,称为重标记(remark),对那些在并发标记阶段被应用程序更新了对象再访问一遍。因为重标记时的暂停比初始标记更加重要,因此要使用多线程并发操作来提升效率。
完成重标记阶段后,堆中所有的live可以保证都被标记了,然后,接下来的并发清理阶段(concurrent sweep phase)可以回收所有的垃圾对象。图7展示了使用串行化的标记清理垃圾收集器和使用CMS垃圾收集器对老年代进行垃圾收集的区别。
图 7 老年代中串行与CMS垃圾收集的比较示意图
由于一些工作,如在重标记阶段再次访问对象,增加了垃圾收集器的工作量,这也增加了垃圾收集器执行垃圾收集的成本。所以对大多数垃圾收集器来说,降低暂停时间是通过增加垃圾收集的开销来获得的。
CMS垃圾收集器是唯一一个不进行压缩操作的垃圾收集器。即,它释放掉垃圾对象占用的空间后,并不会将live移动到老年代的尾端。如图8所示。
图 8 老年代的CMS清理示意图
这样做节省了时间,但是可用可用空间不再是连续的了,垃圾收集也不能简单的使用指针指向下一次可用来为对象分配内存的地址了。相反,这种情况下,需要使用可用空间列表。即,会创建一个指向未分配区域的列表,每次为对象分配内存时,会从列表中找到一个合适大小的内存区域来为新对象分配内存。这样做的结果是,老年代上的内存的分配比简单实用碰撞指针分配内存消耗大。这也会增加年轻代垃圾收集的额外负担,因为老年代中的大部分对象是在新生代垃圾收集的时候从新生代提升为老年代的。
使用CMS垃圾收集器的另一个缺点是它所需要的对空间比其他垃圾收集器大。在标记阶段,应用程序可以继续运行,可以继续分配内存,潜在的可能会持续的增大老年代的内存使用。此外,尽管垃圾收集器保证会在标记阶段标记出所有的live对象,但是在此阶段中,某些对象可能会变成垃圾对象,这些对象不会被回收,直到下一次垃圾收集执行。这些对象成为浮动垃圾对象(floating garbage)。
最后,由于没有使用压缩,会造成内存碎片的产生。为了解决这个问题,CMS垃圾收集器会跟踪常用对象的大小,预估可能的内存需要,可能会差分或合并内存块来满足需要。
与其他的垃圾收集器不同,当老年代被填满后,CMS垃圾收集器并不会对老年代进行垃圾收集。相反,它会在老年代被填满之前就执行垃圾收集工作。否则这就与串行或并行垃圾收集器一样会造成应用程序长时间地暂停。为了避免这种情况,CMS垃圾收集器会基于统计数字来来定执行垃圾收集工作的时间,这个统计数字涵盖了前几次垃圾收集的执行时间和老年代中新增内存分配的速率。当老年代中内存占用率超过了称为初始占用率的阀值后,会启动CMS垃圾收集器进行垃圾收集。初始占用率可以通过命令行选项-XX:CMSInitiatingOccupancyFraction=n进行设置,其中n是老年代占用率的百分比的值,默认为68。
总体来看,与平行垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间,但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间。
CMS垃圾收集器可以在增量模式下工作,这时的并发操作都是增量进行。在这种模式下,CMS垃圾收集器会周期性的暂停并发标记阶段,从而使应用程序的执行更加顺畅。垃圾收集器的工作被划分到很多的时间段内,并在年轻代的垃圾收集中进行调度切换。这个特性对那些运行在多处理上,使用并发垃圾收集器,要求有较短的程序暂停时间的应用程序来说很有用处。有关该模式的更多详细信息请参看第9节内容。
当你的应用程序需要有较短的应用程序暂停,而可以接受垃圾收集器与应用程序共享应用程序时,你可以选择CMS垃圾收集器。典型情况下,有很多长时间保持live状态的数据对象(一个较大的老年代)的应用程序,和运行在多处理上的应用程序,更适合使用CMS垃圾收集器。例如Web服务器。若应用程序需要有较短的暂停时间的话,也可以考虑CMS垃圾收集器。单处理器上的具有中等大小的交互式应用程序也可以考虑使用。
若你想使用CMS垃圾收集器,你必须显式的使用命令行参数-XX:+UseConcMarkSweepGC。若你还想使用增量模式,也必须通过显式使用命令行参数–XX:+CMSIncrementalMode实现。
在J2SE 5.0的发行版中,垃圾收集器、堆大小、和HotSpot虚拟机的类型(服务器端或客户端)的默认值会根据应用程序运行所在的平台和操作系统进行自动选择。相比于以前的发行版,这些默认的参数值更适合与不同类型的应用程序,可以减少命令行参数的输入。
此外,对并行垃圾收集添加了动态调整垃圾收集的新方法。使用这种方法,用户可以指定想要的垃圾收集方法,垃圾收集器会动态的调整堆的大小来使用用户的需求。这种综合考虑具有平台依赖性的垃圾收集器选择和对垃圾收集器的动态调整成为人体工程学(ergonomics)。人体工程学的目标是用最少的命令行选项来实现JVM的高性能。
服务器端的机器需要满足如下两个条件:
(1)至少2个物理处理器;
(2)至少2G物理内存。
上述服务器端机器的定义适用于所有的平台,但23位Windows操作系统除外。
对于非服务器端机器,JVM、垃圾收集器和堆大小的默认值为:
l client虚拟机;
l 串行垃圾收集器;
l 初始堆为4M;
l 最大堆为64M。
对于服务器端机器,JVM永远是server虚拟机(除非你显式的使用-client命令行选项),默认的垃圾收集器是并行垃圾收集器。否则,默认的垃圾收集器是串行垃圾收集器。
对于使用并行垃圾收集器的服务器端JVM,堆的初始值和最大值分别为:
l 初始值为物理内存的1/64,最大为1GB(注意,堆的初始值最小为32M)。
l 堆的最大值为物理内存的1/4,最大为1GB。
否则的话,非服务器端虚拟机会使用初始值为4M,最大值为64M的堆。默认值可以通过命令行选项修改。相关选项,参见第8节。
在J2SE 5.0发行版中,已经为并行垃圾收集器添加了新的调整方法,该方法基于对应用程序理想行为的设定。用户可以通过制定命令行选项来指定应用程序的理想行为。
l 最大暂停时间目标
使用命令行选项-XX:MaxGCPauseMillis=n来指定垃圾收集过程中,应用程序的最大暂停时间。该选项指明了执行并行垃圾收集时,应用程序暂停时间的最大值,单位为毫秒。并行垃圾收集器会调整堆的大小和其他垃圾收集相关参数,从而尽量使垃圾收集时间小于等于n毫秒。这些调整会使垃圾收集器会降低应用程序的吞吐量,而且在某些情况下,最大暂停时间的目标并不能达到。
l 吞吐量目标
吞吐量用来衡量垃圾收集的执行时间和非垃圾收集的执行时间(也称为应用程序时间)的比例。该目标通过命令行选项-XX:GCTimeRatio=n来设定。垃圾收集执行时间占应用程序执行时间的比例的计算方法是:
1 / (1 + n)
例如,选项-XX:GCTimeRatio=19,设置了垃圾收集时间占总时间的5%。默认值是1%,即n=99。花费在垃圾收集的时间是所有代的垃圾收集时间总和。如果无法得到吞吐量的目标,会通过增大代空间的大小来增大应用程序的执行时间。一个较大的代空间经过较长的时间才会填满。
l 覆盖区目标
若吞吐量和最大暂停时间的目标都达到了,垃圾收集器会持续的减少堆的大小直到无法满足一些目标(如吞吐量)。然后,会针对无法达到的目标进行再调整。
l 目标优先级
并行垃圾收集器会优先满足最大暂停时间目标,然后,才会考虑满足吞吐量的目标,覆盖区是最后考虑的。
前面小节中描述的人体工程学使虚拟机、堆、和垃圾收集器可以应付大部分应用程序的需要。因此,对这几项的推荐是什么也不做。即,不指定某个具体的垃圾收集器。让系统根据应用程序所在的平台和操作系统自己选择。然后,测试你的应用程序,若是性能可接受,吞吐量够高,应用程序暂停够短,你的工作就完成了。你不需要为垃圾收集器的众多选项而烦恼。
另一方面,若你的应用程序有与垃圾收集器有关的性能问题,那么你首先要考虑的是默认的垃圾收集器是否适合于你的应用程序和平台的特点。若不是,显式的指定你所要使用的垃圾收集器,并查看性能是否可接受。
你可以使用一些工具(将在第7节介绍)来测量分析应用程序的性能。基于分析结果,你可以考虑修改虚拟机的命令行选项,如对堆大小和垃圾收集器的行为的控制选项。最常用的一些虚拟机选项将在第8节介绍。请注意,性能调优的最好方法是先测试,再调整。测试是对你的代码到底如何运行来测试。此外,小心过度优化,由于应用程序的设置,硬件,甚至是垃圾收集器的实现等原因,你的代码可能会发生改变。
本节将提供一些有关于选择垃圾收集器和指定堆大小的信息。然后,提供一些对并行垃圾收集器进行调优的建议,并给出一些当发生OutOfMemroty错误时的处理意见。
如第4节所述,每种垃圾收集器有其适用场景。第5节描述了不同的平台会默认选择哪种垃圾收集器,并行或是串行。若你的应用程序或应用环境的特点更适合于使用另一种垃圾收集器,则你可以通过下面的命令行选项显式的指定其中一种:
-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:+UseConcMarkSweepGC
第5节说明堆的大小的默认设置。默认值对大部分应用程序来说都是适用的,若你分析性能问题(将在第7节讨论)或OutOfMemory错误(在本节后面部分讨论)时,认定错误根源于整个堆中某个代的大小不合适,你可以使用将在第8节中介绍的命令行选项来调整大小。例如,非服务器端的虚拟机的堆的最大值为64M,通常来说这个数字有点小,你可以通过使用–Xmx选项来指定一个较大的值。除非你的应用程序无法接受长时间的暂停,否则你可以将堆调的尽可能大一些。吞吐量是与可用内存成正比的。是否有足够大的可用内存影响垃圾收集效率的最重要因素。
在决定了堆所能使用的内存的总容量之后,你可以调整各个代的大小。影响垃圾收集性能的第2个因素是年轻代在整个堆中的比例。除非你发现问题的原因在于老年代的垃圾收集或应用程序暂停次数过多,否则你应该将堆的较大部分分给年轻代。但是,当你使用串行垃圾收集器,你不应该使年轻代的大小占据整个堆的一半以上。
当你使用某种并行垃圾收集器时,应该指定期望的具体行为而不是指定堆的大小。让垃圾收集器自动地、动态的调整堆的大小来满足期望的行为。
如果当前使用的垃圾收集器(无论是默认的还是显式指定的)是并行垃圾收集器或并行压缩垃圾收集器,那么就保持这样,并为它指定一个适合你的应用程序的吞吐量的目标。除非你清楚的知道你的应用程序需要的堆比默认的最大值大,否则的话,不要设置堆的最大值。堆会自动的增长或缩小来尽量满足吞吐量目标。在初始化和应用程序行为改变时,堆的大小会有些摇摆。
若堆的大小已经增长到其最大值,这通常意味着当前堆的大小已经无法满足吞吐量的目标。可以将堆的最大值设置为接近于物理内存的最大值,但是,这并不会使应用程序有什么变化,你需要重新执行应用程序。若还不能达到吞吐量目标,则说明当前的物理内存确实无法达到所设定的吞吐量目标。
若可以达到吞吐量的目标,但是会引起应用程序的长时间暂停,那么再设置一个最长暂停时间的目标。设置了这个目标后,很有可能使吞吐量目标无法得到满足。所以,要在它们之间进行折中选择。
即使应用程序已经稳定运行,堆的大小仍然会浮动变化,因为垃圾收集器会试图通过调整堆的大小来满足指定的目标。吞吐量目标(需要一个较大的堆)、最长暂停时间和最小覆盖区(需要一个较小的堆)目标进行综合考虑。
程序员经常要处理的一个问题是应用程序因OOM错误而被强行终止。当没有足够的空来分配给新对象时会抛出此错误。即,垃圾收集器无法回收足够的内存分配给新对象,而堆也无法再变大。发生OOM错误并不意味着肯定会有内存泄漏。发生OOM可能仅仅是配置出现错误,例如,堆的大小(未指定的话是默认值)对应用程序来说太小了。
诊断OOM错误的第1步是检查所有的错误信息。在异常信息中,在“java.lang.OutOfMemoryError”后面,会有一些较深入的错误信息。下面的一些例子中说明了可能会有哪些额外信息,这些信息说明了什么,以及应该怎么做。
l Java heap space(Java堆空间)
这说明一个对象无法被分配到堆中。发生这个问题有可能是因为不当的配置造成的。当使用命令行选项-Xmx指定了堆空间的最大值,但对应用程序来说,该值不够用,则可能会发生这个错误。其实这个错误也说明了,一些不再使用的对象无法被垃圾收集器回收,因为应用程序无意中保持了对这些对象的引用。HAT工具(参见第7节)可以用来查看所有可到达的对象,以此来查看每个live对象都有哪些引用。另一个可能引发此错误的原因是应用程序使用了过多的析构方法,以至于执行这些析构方法的线程的执行速度跟不上新增的要执行的析构方法的速度。jconsole管理工具可以用来监控要执行析构方法的对象的数目。
l PermGen space(永生代空间)
发生这个错误说明永生代空间已经满了。正如前面所述,在堆空间中,JVM使用一块空间用来存储一些元数据。如果应用程序中载入了较多的类,永生代的空间会扩大。你可以使用命令行选项-XX:MaxPermSize=n来指定永生代的最大值。
l Requested array size exceeds VM limit(请求的数组大小查过了虚拟机限制)
发生这个错误说明某个数组所要分配的空间查过了堆空间的大小。例如,应用程序试图分配一个512M大小的数组,但是堆的最大值被设定为256M,此时就会发生上述错误。大多数情况下,发生这个错误是因为堆设置的太小,或错误地计算了要创建的数组的大小。
第7节中介绍了一些工具可以用来诊断OOM错误,其中最常用的一些工具是HAT(Heap Analysis Tool)、jcomsole和jmap。
有一些工具可以用来诊断和监控垃圾收集的性能。本节只是提供一个概览。更多的信息请参见第9节的“工具与解决”部分。
l -XX:+PrintGCDetails命令行选项
获取垃圾收集的初始信息最简单的方法是指定命令行选项-XX:+PrintGCDetails。使用该选项后,会在输出的结果信息中打印出各个带中执行垃圾收集前后live对象的大小,每一代中可以内存的总值和执行垃圾收集的总时长。
l -XX:+PrintGCTimeStamps命令行选项
使用此命令行选项后,会在每次执行垃圾收集时打印出时间戳(注意,这时一定要打开-XX:+PrintGCDetails命令行选项)。这个时间戳可以帮助你将垃圾收集日志与其他事件相关联,进行综合评估。
l jmap
jmap是一个命令行工具,包含在JDK的Linux版和Solaris™ Operating Environment版中(注:现在Windows版中也有)。该命令可以打印出一个正在运行的JVM或一个核心转储文件的相关内存的统计信息。如果执行该命令时,没有附加任何命令行选项,则它会打印出已加载的共享对象的列表,输出结果与Solaris中的pmap工具类似。要想看到更多详细信息,要使用-heap、-histo或-permstat命令行选项。
使用-heap命令行选项时,可以获取到垃圾收集器名称、指定算法的详细内容(例如对并行垃圾收集器来说,使用了多少个线程)、堆的配置信息和堆的使用情况的汇总信息。
使用-histo命令行选项时,可以获取到堆中类级的柱状图。对每个类来说,它会打印出该类的实例在堆中的数量,这些对象占用的内存空间的总量,以及类的全限定名。在查看分析堆的使用情况时,柱状图是很有用的。
对于那些会大量的动态创建并载入类的应用程序(例如,JSP程序和Web容器)来说,配置永生代的大小非常重要。如果应用程序载入了太多的类,那么可能会引发OOM错误。使用-permstat可以打印出永生代中对象的统计信息。
l jstat
jstat是JVM中内建的指令,可以用来查看正在执行的应用程序的性能和资源的消耗信息。该工具可以用来诊断性能问题,特殊情况下,也可以用来诊断与堆大小和垃圾收集相关的问题。该工具中的很多命令行选项可以用来打印出与垃圾收集行为、各代容量和使用情况等统计信息。
l HPROF: Heap Profiler
HPROF是随JDK 5.0一起发布的、简单的性能描述工具。它是一个使用JVMTI(JVM Tools Interface)连接到JVM的动态链接库。它可以将性能信心打印到文件和使用ACII码或二进制格式的套接字中。这些信息可以使用前端性能分析工具进一步处理。
HPROF可以展示出CPU使用情况、堆分配统计信息等一些监控信息。此外,它还可以将整个堆进行转储,报告出JVM中所有的监视器和线程的状态。在分析性能、锁争夺内存泄漏等一些问题时,HPROF是很有用的工具。HPROF的相关文档请参见第9节内容。
l HAT:Heap Analysis Tools
HAT工具可以帮助调试无意识对象保持(unintentional object retention)。这个词描述了那些已经不再使用,但还处于live态的对象,只所以这些对象没有被回收,是因为有某个引用还指着它们。HAT可以生成一个堆的快照,并提供了一个简便的方法来浏览对象的拓扑图。这个工具有一些查询方法可以使用,例如“show me all reference paths from the rootset to this object”。详情参见第9节中HAT部分的连接文档。
有一些命令行选项可以用来选择垃圾收集器、指定堆及其中各代的大小、调整垃圾收集器的行为、获取垃圾收集的统计信息等。本节将展示其中最常用的一些命令行选项。更多选项和详细信息请参见第9节。注意,命令行选项中指定的数字可以附加“k”“K”“m”“M”“g”“G”单位。
选项 |
使用的垃圾收集器 |
-XX:+UseSerialGC Serial |
串行垃圾收集器 |
-XX:+UseParallelGC Parallel |
并行垃圾收集器 |
-XX:+UseParallelOldGC Parallel compacting |
并行压缩垃圾收集器 |
-XX:+UseConcMarkSweepGC |
并发标记清理(CMS)垃圾收集器 |
选项 |
描述 |
-XX:+PrintGC |
打印出每次垃圾收集的基本信息 |
-XX:+PrintGCDetails |
打印出每次垃圾收集的详细信息 |
-XX:+PrintGCTimeStamps |
在每次开始垃圾收集时,打印出时间戳。该命令选项要与-XX:+PrintGC或-XX:+PrintGCDetails选项一起使用。 |
选项 |
默认值 |
描述 |
-Xmsn |
参见第5节 |
堆的初始大小 |
-Xmxn |
参见第5节 |
堆的最大值 |
-XX:MinHeapFreeRatio=minimum 和 -XX: MaxHeapFreeRatio=minimum |
40M
70M |
指定整个堆空间中自由区所占比例的范围。该选项将应用于每一代。例如,设minimum为30,此时若某一代中自由区所占比例小于30%,则会增大该代的大小以满足最小30%的设定。最大值与此类似。 |
-XX:NewSize=n |
依赖于不同平台的实现 |
默认的新生代的大小 |
-XX:NewRatio=n |
2:Client JVM 8:Server JVM |
指占年轻代与老年代之和比例。例如,若n=3,则比例为1:3,即Eden区和Survivor区的总和占年轻代与老年代总和的1/4。 |
-XX:SurvivorRatio=n |
32 |
每个Survivor区和Eden区的比例。例如,若n=7,则每个Survivor区占Survivor区和Eden区总和的1/9(注意,有2个Survivor区)。 |
-XX:MaxPermSize=n |
依赖于不同平台的实现 |
永生代的最大值。 |
选项 |
默认值 |
描述 |
-XX:ParallelGCThreads=n |
CPU的数量 |
执行垃圾收集的线程数 |
-XX:MaxGCPauseMillis=n |
没有默认值 |
指明执行垃圾收集时应用程序的最长暂停时间,单位为毫秒。 |
-XX:GCTimeRatio=n |
99 |
指明执行垃圾收集的时间占总运行时间的比例。注意,计算公式是: 比例=1/(1+n) |
选项 |
默认值 |
描述 |
-XX:+CMSIncrementalMode |
Disabled |
使用增量模式后,在并发阶段的操作会增量进行,此时会周期性的停止并发操作,将处理器还给应用程序,使其继续执行。 |
-XX:+CMSIncrementalPacing |
Disabled |
自动控制CMS垃圾收集器在放弃CPU之前工作总量。 |
-XX:ParallelGCThreads=n |
CPU数量 |
对年轻代进行并发垃圾收集时的线程数,和对老年代的一部分进行并发垃圾收集时的线程数。 |
l Garbage Collection in the Java HotSpot Virtual Machine
http://www.devx.com/Java/Article/21977
l Tuning Garbage Collection with the 5.0 Java[tm] Virtual Machine
http://java.sun.com/docs/hotspot/gc5.0/gc_tuning_5.html
l Server–Class Machine Detection
http://java.sun.com/j2se/1.5.0/docs/guide/vm/server–class.html
l Garbage Collector Ergonomics
http://java.sun.com/j2se/1.5.0/docs/guide/vm/gc–ergonomics.html
l Ergonomics in the 5.0 Java™ Virtual Machine
http://java.sun.com/docs/hotspot/gc5.0/ergo5.html
l Java™ HotSpot VM Options
http://java.sun.com/docs/hotspot/VMOptions.html
l Solaris and Linux options
http://java.sun.com/j2se/1.5.0/docs/tooldocs/solaris/java.html
l Windows options
http://java.sun.com/j2se/1.5.0/docs/tooldocs/windows/java.html
l Java™ 2 Platform, Standard Edition 5.0 Trouble–Shooting and Diagnostic Guide
http://java.sun.com/j2se/1.5/pdf/jdk50_ts_guide.pdf
l HPROF: A Heap/CPU Profiling Tool in J2SE 5.0
http://java.sun.com/developer/technicalArticles/Programming/HPROF.html
l Hat: Heap Analysis Tool
https://hat.dev.java.net/
l Finalization, threads, and the Java technology–based memory model
http://devresource.hp.com/drc/resources/jmemmodel/index.jsp
l How to Handle Java Finalization's Memory–Retention Issues
http://www.devx.com/Java/Article/30192
l J2SE 5.0 Release Notes
http://java.sun.com/j2se/1.5.0/relnotes.html
l Java™ Virtual Machines
http://java.sun.com/j2se/1.5.0/docs/guide/vm/index.html
l Sun Java™ Real–Time System (Java RTS)
http://java.sun.com/j2se/realtime/index.jsp
l General book on garbage collection: Garbage Collection: Algorithms for Automatic Dynamic Memory Management by Richard Jones and Rafael Lins, John Wiley & Sons, 1996.