级别: 初级 |
Sumit Chawla
(sumitc@us.ibm.com)
技术主管,eServer Java Enablement, IBM
2005 年 2 月
您的 Java 应用程序充分利用了所运行的 IBM eServer 硬件的能力了吗?在本文中,作者将介绍如何判断 垃圾收集 —— Java 虚拟机执行的收回不再使用空间的后台任务 —— 是否调节到最佳状态。然后,他将提供一些解决垃圾收集问题的建议。
简介
垃圾收集实现是 IBM Java Virtual Machine(JVM)卓越性能的关键。其他多数 JVM 都需要大量调整才能提供最优性能,而 IBM JVM 利用其“开箱即用”的默认设置,在多数情况下都能工作得很好。但是在某些情况下,垃圾收集的性能会莫名其妙地迅速降低。结果可能导致服务器没有响应、屏幕静止不动或者完全失效,并常常伴有“堆空间严重不足”这类含糊的消息。幸运的是,多数情况下都很容易找到原因,通常也很容易纠正错误。
本文将介绍如何确定造成性能下降的潜在原因。因为垃圾收集是一个很大、很复杂的话题,所以本文是在已经发表的一组相关文章的基础上展开讨论(请参阅参考资料)。虽然本文讨论的多数建议都将 Java 程序看作是一个黑盒子,但仍然有一些观点可以在设计或编码时运用,以避免潜在的问题。
本文提供的信息适用于所有 IBM eServer 平台,除了可重新设置的 JVM 配置(请参阅参考资料)。除非特别说明,文中的例子都取自运行在四处理器 AIX 5.1 上的 Java 1.3.1 build ca131-20020706。
堆管理概述
JVM 在初始化的过程中分配堆。堆的大小取决于指定或者默认的最小和最大值以及堆的使用情况。分配堆可能对可视化堆有所帮助,如图 1 所示,其中显示了 heapbase、heaplimit 和 heaptop。
Heapbase 表示堆底,heaptop 则表示堆能够增长到的最大绝对值。差值(heaptop - heapbase
)由命令行参数 -Xmx
决定。该参数和其他命令行参数都是在关于 verbosegc 和命令行参数的 developerWorks 文档中描述的。heaplimit 指针可以随着堆的扩展上升,随着堆的收缩下降。heaplimit 永远不能超过 heaptop,也不能低于使用 -Xms
指定的初始堆大小。任何时候堆的大小都是 heaplimit - heapbase
。
如果整个堆的自由空间比例低于 -Xminf
指定的值(minf 是最小自由空间),堆就会扩展。如果整个堆的自由空间比例高于 -Xmaxf
指定的值(maxf 是最大自由空间),堆就会收缩。-Xminf
和 -Xmaxf
的默认值分别是 0.3 和 0.6,因此 JVM 总是尝试将堆的自由空间比例维持在 30% 到 60% 之间。参数 -Xmine
(mine 是最小扩展大小)和 -Xmaxe
(maxe 是最大扩展大小)控制扩展的增量。这 4 个参数对固定大小的堆不起作用(用相等的 -Xms
和 -Xmx
值启动 JVM,这意味着 HeapLimit = HeapTop
),因为固定大小的堆不能扩展或收缩。
当 Java 线程请求存储时,如果 JVM 不能分配足够的存储块来满足这个请求,则可以说出现了分配失败(AF)。这时候就不可避免要进行垃圾收集。垃圾收集包括收集所有“不可达的(unreachable)”引用,以便重用它们所占用的空间。垃圾收集由请求分配的线程执行,是一种 Stop-The-World(STW)机制;执行垃圾收集算法时,Java 应用程序的其他所有线程(除了垃圾收集帮助器线程之外)都被挂起。
IBM 实现使用称为 mark-sweep-compact(MSC)的垃圾收集算法,它是根据三个不同的阶段命名的。
-
标记
- 表和所有“可达的”或者活动的对象。这个阶段从确定“根”开始,比如线程栈上的对象、Java Native Interface(JNI)局部引用和全局引用等,然后沿着每个引用进行递归,直到所有的引用都做上标记。 清理
- 清除所有已经分配但没有标记的对象,收回这些对象使用的空间。 压缩
- 将活动对象移动到一起,去掉堆中的空洞(hole)和碎片。
关于 MSC 算法的细节,请参阅参考资料。
并行和并发
虽然垃圾收集本身采用 STW 机制,但最新的 IBM JVM 版本都在多处理器机器上使用多个“帮助器”线程,以便减少每个阶段所花费的时间。因此,在默认情况下,JVM 1.3.0 在标记阶段采用并行模式。JVM 1.3.1 在标记和清理阶段都采用并行模式,在标记阶段还支持一种可选的并发模式,可以使用命令行开关 -Xgcpolicy:optavgpause
切换。撰写本文时,最新版本的 JVM 1.4.0 中有一种递增压缩模式,它并行化了(parallelize)压缩阶段。讨论这些模式时,重要的是要理解并行和并发的区别。
在拥有 N
个 CPU 的多处理器系统中,支持并行模式的 JVM 在初始化的时候会启动 N-1
个垃圾收集帮助器线程。运行应用程序代码的时候,这些线程一直处于空闲状态,只有当启动垃圾收集时才调用它们。在某一特定的阶段,会将一些工作分配给驱动垃圾收集的线程和帮助器线程,因此一共有 N
线程并行地运行在 N
-CPU 机器上。如果要禁止并行模式,惟一的方法是使用 -Xgcthreads
参数改变启动的垃圾收集帮助器线程个数。
采用并发模式时,JVM 会启动一个后台线程(不利于垃圾收集帮助器线程),在执行应用程序线程的同时,部分工作是在后台完成的。后台线程会试着与应用程序并发执行,以完成垃圾收集的所有操作,因此在执行垃圾收集时,可以减少 STW 造成的暂停。但在某些情况下,并发处理可能对性能造成负面影响,特别是对于 CPU 敏感的应用程序。
下表按照 JVM 版本列出了垃圾收集各个阶段的处理类型。
标记 | 清理 | 压缩 | |
IBM JVM 1.2.2 | X | X | X |
IBM JVM 1.3.0 | P | X | X |
IBM JVM 1.3.1 | P, C | P | X |
IBM JVM 1.4.0 | P, C | P | P |
其中:
-
X
- 单线程操作。 P
- 并行操作(垃圾收集期间所有帮助器线程都在工作)。 C
- 并发操作(后台线程和应用程序线程并发操作)。
分析 verbosegc 输出
虽然也有分析程序和其他第三方工具,但本文仅讨论对 verbosegc 日志的分析。这些日志由 JVM 在指定 -verbosegc 命令行参数时生成,是一种非常可靠的独立于平台的调试工具。要获得完整的 verbosegc 语法,请参阅“verbosegc and command-line parameters”。
启用 verbosegc 可能对应用程序的性能有一定影响。如果这种影响是无法接受的,则应该使用测试系统来收集 verbosegc 日志。服务器应用程序通常一直使 verbosegc 处于激活状态。这是监控整个 JVM 是否运转良好的一种好办法,在出现 OutOfMemory 错误的情况下,这种方法具有无可估计的价值。
为了有效地分析 verbosegc 记录,必须把精力集中在相关信息上,并过滤掉“噪音”。通过编写脚本从很长的 verbosegc 追踪记录中提取信息并不难,但是这些记录的格式可能(而且通常确实如此)随不同的 JVM 版本而异。下面的例子用粗体或蓝色字体表示重要的信息。即使记录的格式看起来相差很大,也很容易在 verbosegc 日志中找到这些信息。
您刷新的了吗?
在尝试本文中的建议之前,强烈建议您升级到最新的 JVM 服务刷新(SR)。每次进行新的服务刷新都会有很多修正和改进,应用新的服务刷新可以提高 JVM 的性能和稳定性。迁移到最新的版本(比如 JVM 1.4.0 或 1.3.1,根据使用的平台)提供了增强的性能特性。一定要为 JVM 安装所有必需的 OS 补丁(比如 AIX 上的维护级别)。这些信息记录在随 SDK/JRE 提供的 readme 文件中。
正确设置堆的大小
计算正确的堆大小参数很容易,但它可能对应用程序启动时间和运行时性能有很大的影响。初始大小和最大值分别由参数 -Xms
和 -Xmx
控制,这些值通常是根据理想情况和重负荷情况下堆的使用情况的估计来设置的,但 verbosegc 可以帮助确定这些值,而避免胡乱猜测。下面是从启动到完成程序的初始化(或者进入“就绪”状态)这段时间里,一个应用程序的 verbosegc 输出,如下所示。
|
上述记录表明,第一次发生 AF 时,堆中的自由空间为 0%(3983128 中有 0 字节可用)。此外,第一次垃圾收集之后,自由空间比例上升到 34%,略高于 -Xminf
标记(默认为 30%)。根据应用程序的使用,使用 -Xms
分配更大的初始堆可能会更好一些。几乎可以肯定的是,上例中的应用程序在下一次 AF 时会导致堆扩展。分配更大的初始堆可以避免这种情况。一旦应用程序进入 Ready 状态,通常不会再遇到 AF,因此也就确定了比较好的初始堆大小。类似地,通过增加应用程序负载也可以探测到避免出现 OutOfMemory 错误的 -Xmx
值。
如果堆太小,即使应用程序不会长期使用很多对象,也会频繁地进行垃圾收集。因此,自然会出现使用很大的堆的倾向。但是由于平台和其他方面的因素,堆的最大大小还受物理因素的限制。如果堆被分页,性能就会急剧恶化,因此堆的大小一定不能超出安装在系统上的物理内存总量。比如,如果 AIX 机器上有 1 GB 的内存,就不应该为 Java 应用程序分配 2 GB 的堆。
即使应用程序在拥有 64 GB 内存的 p690 超级计算机上运行,也不一定就能使用 -Xmx60g
(当然是 64 位的 JVM)。虽然在很长时间内,应用程序可能不会遇到 AF,但一旦发生 AF,STW 造成的停顿将很难应付。下面的记录取自 32 GB AIX 系统上分配了 20 GB 堆空间的 64 位 JVM 1.3.1(build caix64131-20021102
),它展示了大型堆在这方面的影响。
|
垃圾收集用了将近五秒钟,还不包括压缩!垃圾收集周期所花费的时间直接与堆的大小成正比。一条好的原则是根据需要设置堆的大小,而不是将它配置得太大或太小。
常见的一种性能优化技术是将初始堆大小(-Xms
)设成与最大堆大小(-Xmx
)相同。因为不会出现堆扩展和堆收缩,所以在某些情况下,这样做可以显著地改善性能。通常,只有需要处理大量分配请求的应用程序时,才在初始和最大堆大小之间设置较大的差值。但是要记住,如果指定 -Xms100m -Xmx100m
,那么 JVM 将在整个生命期中消耗 100 MB 的内存,即使利用率不超过 10%。
另一方面,也可以使用 -Xinitsh
在开始的时候分配较大的系统堆,从而避免出现 Expanded System Heap 消息。但这些消息完全可以忽略。系统堆随着需要而扩展,并且永远不会发生垃圾收集,它只包含那些度过了 JVM 实例整个生命期的对象。
避免堆失效
如果使用大小可变的堆(比如,-Xms
和 -Xmx
不同),应用程序可能遇到这样的情况,不断出现分配失败而堆没有扩展。这就是堆失效,是由于堆的大小刚刚能够避免扩展但又不足以解决以后的分配失败而造成的。通常,垃圾收集周期释放的空间不仅可以满足当前的分配失败,而且还有很多可供以后的分配请求使用的空间。但是,如果堆处于失效状态,那么每个垃圾收集周期释放的空间刚刚能够满足当前的分配失败。结果,下一次分配请求时,又会进入垃圾收集周期,依此类推。大量生存时间很短的对象也可能造成这种现象。
避免这种循环的一种办法是增加 -Xminf
和 -Xmaxf
的值。比方说,如果使用 -Xminf.5
,堆将增长到至少有 50% 的自由空间。同样,增加 -Xmaxf
也是很合理。如果 -Xminf.5
等于 5,-Xmaxf
为默认值 0.6,因为 JVM 要把自由空间比例保持在 50% 和 60% 之间,所以就会出现太多的扩展和收缩。两者相差 0.3 是一个不错的选择,这样 -Xmaxf.8
可以很好地匹配 -Xminf.5
。
如果记录表明,需要多次扩展才能达到稳定的堆大小,但可以更改 -Xmine
,根据应用程序的行为来设置扩展大小的最小值。目标是获得足够的可用空间,不仅能满足当前的请求,而且能满足以后的很多请求,从而避免过多的垃圾收集周期。-Xmine
、-Xmaxf
和 -Xminf
为控制应用程序的内存使用特性提供了很大的灵活性。
标记栈溢出
使用 verbosegc 最重要的一项检查是没有出现“mark stack overflow”消息。下面的记录显示了这种消息及其影响。
|
在垃圾收集的标记阶段,如果引用的个数造成 JVM 内部的“标记栈”溢出,就会引发这种消息。在标记阶段,垃圾收集处理代码使用这个栈压入所有已知的引用,以便递归扫描每个活动引用。溢出的原因是堆中的活动对象过多(或者更准确地说,对象嵌套过深),这通常表明应用程序代码存在缺陷。除非能够通过外部设置控制应用程序中的活动对象个数(比如某种对象池),那么需要在应用程序源代码中解决这个问题。建议使用分析工具确定活动的引用。
如果不能避免大量的活动引用,并发标记可能是一种可行的选择。
摆脱 finalizer
下面的记录显示了一种有趣的情况:解决分配失败花费了 2.78 秒钟,其中还不包括压缩所用的时间。
|
罪魁祸首是必须被结束掉的对象数量。无论如何,使用 finalizer 不是一个好主意,虽然在特定的情况下这是不可避免的,但是应该仅仅将它作为完成其他方法不能实现的操作的不得已方法。比方说,无论如何都要避免在 finalizer 内部执行分配。
避免非常大的分配
有时候问题不是由当时的堆状态造成的,而是因为分配失败造成的。比如:
|
这些记录来自一个非常老的 JVM(准确地说是 ca130-20010615
),因此压缩的原因(红色显示)显示为 0。但是压缩 256 MB 的堆花费了 1.5 秒!为何这么差?再来看一看最初的请求,最初请求的是 912920 字节 —— 将近 1 MB。
分配的内存块都必须是连续的,而随着堆越来越满,找到较大的连续块越来越困难。这不仅仅是 Java 的问题,使用 C 中的 malloc 也会遇到这个问题。JVM 在压缩阶段通过重新分配引用来减少碎片,但其代价是要冻结应用程序较长的时间。上面的记录表明已经完成了压缩阶段,分配一大块空间的总时间超过了 2秒。
下面的记录说明了最糟糕的一种情况。
|
请求的是一个 2 MB 的对象(2241056 bytes),虽然在 1.2 GB 的堆(1291844600)中有 135 MB (135487112) 自由空间,但却不能分配一个 2 MB 的块。虽然进行了一切可能的搜索,花费了 268 秒,但仍然没有找到足够大的块。而且还出现了糟糕的“堆空间严重不足”消息,指出 JVM 的内存不足。
最好的办法是:如果可能的话,把分配请求分解成较小的块。这样,较大的堆空间可能会起作用,但多数情况下,这样做只是推迟了问题出现的时间。
碎片及其成因
我们再看一看上例中的其中一行:
|
虽然有 177 MB 的自由空间,却不能分配 2 MB 的块。原因在于:虽然垃圾收集周期可以压缩堆中的孔洞,但是堆中有些内容不能在压缩过程中重新分配。比如,应用程序可能使用 JNI 分配和引用对象或数组。这些分配在内存中是固定的,既不能被重新分配,也不能被回收,除非使用适当的 JNI 调用来释放它们。IBM Java 服务团队可以帮助确定这类引用,在这种情况下,分析工具也大有用武之地。
类似地,因为类块是在堆的外部引用的,因此也是固定的。即使没有固定的对象,大的分配一般也会导致出现碎片。所幸的是,这类严重的碎片很少出现。
需要并发标记吗?
如果由于垃圾收集造成 Java 应用程序不时地停顿,并发标记可以帮助减少停顿的时间,使应用程序运行更平稳。但有时候,并发标记可能会降低应用程序的吞吐能力。建议分别使用和禁止并发标记,使用相同的负荷来测量对应用程序性能的影响,并加以比较。
但是,观察并发标记运行的 verbosegc 输出可能提供大量关于加速的信息。不需要分析打印出来的记录的每一部分,有意义的部分包括并发标记能够成功扫描的概率(EXHAUSTED 和 ABORTED/HALTED),以及后台线程能够做多少工作。
下面的三个记录属于同一个 Java 应用程序,是在一次运行中的不同阶段创建的,它们说明了并发运行的三种不同结果。
第一种可能的结果是并发标记得到 EXHAUSTED:
|
这表明并发标记按照我们所预期的那样工作。EXHAUSTED 意味着后台线程能够在出现分配失败之前完成自己的工作。因为后台线程扫描了 3324474 个字节(而应用程序线程扫描了 53962742 个字节),后台线程能够获得足够的 CPU 时间来减少总的标记时间。因此,STW 中的标记阶段只用了 51 毫秒(ms),总的 STW 时间也不过 230 毫秒。这对于 512 MB 的堆来说,这已经很不错了。
下面是 ABORTED 并发标记运行:
|
这是最糟糕的情况。并发标记被终止,主要是因为要分配大型对象和调用了 System.gc()。如果应用程序频繁地这样做,那么就不能从并发标记中获得好处。
最好是 HALTED 并发标记:
|
从并发标记的应用来看,HALTED 介于 EXHAUSTED 和 ABORTED 之间,它表明只完成了部分工作。上面的记录说明,在进行下一次分配失败之前,没有完成扫描。在垃圾收集周期中,标记阶段花费了 274 毫秒,总的时间上升到 414 毫秒。
在理想的情况下,多数垃圾收集周期都并发收集(由于并发标记完成其工作而触发,标记为 EXHAUSTED),而不是出现分配失败。如果应用程序调用 System.gc(),记录中会出现很多 ABORTED 行。
在多数应用程序中,并发标记都可以改善性能,对于“标记栈溢出”也有所帮助。但是,如果标记栈溢出是由于缺陷造成的,惟一的解决办法就是修正缺陷。
应该避免的开关
下列命令行开关应避免 使用。
命令行开关 | 说明 |
-Xnocompactgc | 该参数完全关闭压缩。虽然在性能方面有短期的好处,最终应用程序堆将变得支离破碎,即使堆中有足够的自由空间也会导致 OutOfMemory 错误 |
-Xcompactgc | 使用该参数将导致每个垃圾收集周期都执行压缩,无论是否有必要。JVM 在压缩时要做大量的决策,在普通模式下会推迟压缩 |
-Xgcthreads | 该参数控制 JVM 在启动过程中创建的垃圾收集帮助器线程个数。对于 N-处理器机器,默认的线程数为 N-1。这些线程提供并行标记和并行清理模式中的并行机制 |
结束语
本文简单介绍了 IBM JVM 的垃圾收集和堆管理能力。以后的 verbosegc 日志很可能提供更多有用的信息。
总结一下文中提出的建议:
- 只要可能就升级到最新的 JVM 版本。您遇到的错误可能已经被发现并解决了。
- 调整
-Xms
、-Xmx
和-Xminf
,直到 verbosegc 输出给出分配失败数量与每次垃圾收集造成的停顿数量之间的一个可接受平衡。使用固定大小的堆避免收缩或扩展。 - 如果可能的话,将较大的(>500 KB)块分解成更小的块。
- 不要忽略“标记栈溢出”消息。
- 避免使用 finalizer。
- 试一试并发标记。
- 问问是否有必要调用 System.gc(),如果没有必要则删除它。
如您所见,这个话题可不是三言两语就能说明白的。但是,只需要打一个电话或者发一封电子邮件,就能与您的好伙伴 IBM Technical Support Team 取得联系(参考资料中的链接是很好的起点)。对于您遇到的特殊情况,他们要比任何文章都更清楚。
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文。
- Sam Borman 关于 IBM JVM Storage 组件的系列文章是 IBM Java 垃圾收集实现的最详尽的参考资料:
- “Sensible sanitation: Understanding the IBM Java Garbage Collector, Part 1: Object Allocation”(developerWorks,2002 年 8 月)
- “Sensible sanitation: Understanding the IBM Java Garbage Collector, Part 2: Garbage Collection”(developerWorks,2002 年 8 月)
- “Sensible sanitation: Understanding the IBM Java Garbage Collector, Part 3: verbosegc and command-line parameters”(developerWorks,2002 年 9 月)
- 请访问 Java 2 on the OS/390 and z/OS Platforms 站点,关于可重置 JVM 的信息,请参阅 New IBM Technology featuring Persistent Reusable Java Virtual Machines (PDF)。
- artima.com 的 Inside Java 2 Virtual Machine 中,“Heap of Fish”对 Mark-Sweep-Compact 算法作了很好的介绍。
- 如果需要帮助的话,请访问 IBM eServer 技术支持页面。
- 关于 IBM 服务器的应用方面,可以在 IBM eServer Developer Domain 上可以找到更多技术文章。
- developerWorks Java 技术专区有数百篇的技术文章和教程。
关于作者![]() |