级别: 初级
Jack Shirazi, 董事, JavaPerformanceTuning.com Kirk Pepperdine, CTO, JavaPerformanceTuning.com
2004 年 7 月 30 日
如果您是当前写网志(blogging)狂热者中的一员,则可能听说过 Blog-City,这是由苏格兰的一家小公司 Blog-City Ltd. 拥有和运营的网志站点。当一些意料之外的性能问题突然出现时,Java 性能专家 Jack Shirazi 和 Kirk Pepperdine 被邀请帮助进行 Blog-City 的技术调整。他们的检测工作因为受硬件约束和整个项目所使用的通信通道(IRC、ftp 和 偶尔的电子邮件)的限制而变得复杂。
随着网志作为公共日记的流行,网志主机迅速地增长。所以对于 Blog-City 的人来说,非常清楚他们的站点需要发展和提高。为了满足其增长的需要,该公司最近刚刚推出了 Blog-City version 2.0。正像经常出现的情况那样,当新的应用程序转入运行阶段时,由于各种原因,其性能无法完全满足期望的要求,突然出现随机的长时间应用程序被挂起的现象还不是最坏的情况。
在其核心,Blog-City 依靠 Blue Dragon Servlet 引擎(CFML 引擎)和数据库。令人惊讶的是,所有这些软件都宿主在运行 Red Hat Linux 的相当老的 P3 机器上。这台机器具有单个硬盘和 512MB 内存,这对于过去的负载来说是足够强大的,但它正在承受不断增长的负载。Blog-City 的运作方式很成功,但其资源限制却成了其成功路上的绊脚石。尽管如此,这就是未来还要继续使用一段时间的所有硬件。
问题定义
整个过程的第一步是确定突然出现应用程序减慢的原因。首先我们怀疑的对象是垃圾收集。正如我们在 本专栏的上月文章 中所论述的那样,确定垃圾收集和内存利用问题是否对应用程序产生负面影响的最容易的方式是,设置 -verbose:gc JVM 选项,并检查日志输出。因此我们重新启动应用程序,打开冗长的垃圾收集日志选项,然后耐心地等待应用程序的性能降低。我们的耐心换来的是非常详细的垃圾收集日志文件。
从对日志文件的最初分析中看,在这一应用程序中垃圾收集的瓶颈是显而易见的。种种迹象包括垃圾收集的频率、持续时间和总体效率都已表明这一点。高于普通垃圾收集频率的常见原因是,堆的大小刚好足以适应所有当前正在使用的运行对象,无法适应新的正被创建的对象。虽然应用程序消耗大量堆可能有许多原因,但主要原因可能是没有足够内存而导致垃圾收集器运行,因为它设法满足当前需要。换句话说,应用程序试图分配新对象,但失败了,如果失败的话,将触发垃圾收集程序。如果垃圾收集失败而无法恢复足够内存,它将迫使另一个花费更大的垃圾收集程序发生。即使 GC 恢复了足够的空间来满足瞬间需求,可以肯定的是,在应用程序程序另一次分配失败,触发另一个 GC 之前,时间不会很长。因此,应该关注重复扫描空闲堆空间的无效任务,而不是服务于应用程序的 JVM。
应用程序逐步消耗所有可用的堆空间可能有许多原因,但如果有更多内存的话,临时解决方案就是配置更大的堆。假设应用程序没有内存泄漏(或者也就是我们常说的“无意识地保留对象”),它将找到一个“自然”级别的堆消耗,在这个级别中,GC 将能够很适应地得到维持(除非对象创建的速度过快,以至 GC 总是处于赛跑状态)。在这种情况下,以及无意识地保留对象的情况下,我们需要对应用程序做一些变动,以便获得某些改进。
如果仅仅是这样,那就太简单了
遗憾的是,我们必须面对严酷的现实因素——正在运行的机器只有 512 MB 内存。更糟的是,我们必须与数据库和其他运行在机器中的进程共享该空间。要完整理解这一点为什么至关重要,首先您必须明确理解垃圾收集的基本知识,以及它如何与底层操作系统进行交互。
虚拟内存不再是灵丹妙药
操作系统已经使用虚拟内存许多年了。正如您所知道的,虚拟内存使操作系统的内存看起来比实际的内存要多,这允许计算机运行那些所需内存比可用物理内存更大的程序,不使用内存的应用程序部分将保存在磁盘上。为了进一步简化,操作系统同时按页管理内存。页通常包含 512 字节到 8 KB,所有页的组合就组成了一个虚拟地址空间。操作系统维持一个页表,用于告诉操作系统如何映射虚拟地址到物理地址。当应用程序要求某个内存位置的内容时,操作系统(或硬件)将识别包含虚拟地址的页面。然后确定该页面是否在内存中,如果不在,将会报告 页面错误。但是有许多种方式来处理页面错误,最终的结果是,页面必须从磁盘载入到内存中。这样应用程序就可以访问到有效虚拟地址的内容。
如果相关对象总是在内存的同一页面上聚合,那么 GC 的连续工作很可能出现困难。但是现实世界中,相关对象很少(如果有的话)出现聚合现象。实际结果是,依靠虚拟内存的系统将导致操作系统将页从内存中换入和换出,因为它标记然后废弃堆空间,而当聚合现象发生时,GC 将很多时间花在等待页面从磁盘换入而不是实际恢复内存上。因此,应用程序正在等待 GC,而 GC 正在等待磁盘,其间未完成任何真正的工作。由于本系统只有一个磁盘,并且它还需要支持数据库,因此我们在解决问题时处于两难境地。一方面,我们需要增加内存数量,这样我们可以减少 GC 的频率,但另一方面,我们还需要确保数据库的完好运行,而数据库也是内存的消耗大户。因此,我们需要了解应用程序所需的最小内存数量。
正如我们在上月看到的,在冗长的 GC 日志中这一信息可以很容易得到,无需为这一信息而扫描整个日志,我们使用免费的 JTune 工具(请参阅 参考资料)来解释冗长的 GC 日志。图 1 显示了经过垃圾收集之后的内存利用情况,其中我们将 -Xmx 设置为 256 MB。 图 1. 垃圾收集之后的内存利用情况
分析 verbose:gc 输出
在图 1 中,蓝色部分表示部分 GC。橙色区域表示完整的 GC,而粉色矩形表示两个完整 GC 在它们之间少于一毫秒之内已经发生的堆利用情况。从结果中我们看到,平均每 0.257 秒有 12,823 次清除。总共有 345 次完整的垃圾收集和 44 次紧挨着的垃圾收集。完整垃圾收集的平均持续时间是 7.303 秒,结果表明有 9.36% 的运行时间花费在垃圾收集程序上。虽然这个值偏高,它仍然保持在 10% 的正常水平之内。因此,在本例中,GC 是系统的繁重负担但还没有达到严重的地步。真正的问题是存在内存泄漏,这一点可以从总体上堆利用率不断增长的趋势看出来。
即使内存泄漏消耗了 50 MB 内存,它也应该是经过很长一段时间后才发生,这使得内存泄漏在较短的测试中很少会引人注意。内存泄漏的实际结果是,它把 JVM 的内存消耗推动到某个点,在该点它强迫 JVM (从而强迫操作系统)消耗内存,它强迫启动分页。图 2 就证明了这一点。注意正好在 55,000 秒标记之后,每一 GC 周期的持续时间中内存消耗突然地持续增加。 图 2. GC 持续时间
如您所想,由于垃圾收集的阻塞将导致系统只有更少的时间来分配给用户线程,因此用户响应开始增加。在日志的过去 10,000 秒中,我们看到每次完全收集(总共 15 次)花费时间超过了 30 秒,平均持续时间大约 70 秒 —— 这导致超过 10% 的处理时间分配给完全 GC。部分收集(这里刚好超过了 1000 次)无法正常工作,平均每次请求耗时 1.24 秒,远高于以前 11,800 次清除中的平均 0.25 秒。
分代收集
本文不涉及太深的细节(请参阅 参考资料,获取分代 GC 的详细描述),分代堆空间产生了“年轻”和“年老”对象,它们位于分开的堆空间中。在本配置中,年轻和年老分代空间可以通过不同的 GC 算法和策略来维持,以提高 GC 的整体性能。
一种这样的策略是,进一步将年轻分代划分为创建空间,称为 Eden,以及残存(survivor)空间,用于幸存一个或者多个收集的年轻对象。如果在 Eden 中有足够的内存来适应新对象创建的话,这一般能工作正常。如果不是这种情况,那么对象可以在年老对象空间中创建。同样,如果残存空间足够的话,那么对象将移入年老分代空间。我们将使用这些事实来帮助调优遇到的问题。
减少完全收集的次数
Blog-City 所碰到的难题是在某一随机点出现长的暂停时间。一旦应用程序启动出现问题,不重新启动机器的话,就无法返回跟踪。由于长时间暂停的现象直接与长的 GC 相关,我们考虑如果将对象保持在年轻分代来减少完全 GC 的次数。由于完全 GC 的代价如此之大,在年轻分代收集更多对象能够得到更短的暂停时间。要完成这一任务,我们调整了一些垃圾收集参数,包括 残存比率(survivor ratio)和 期限阈值(tenuring threshold)。
残存比率用于设置与年轻分代空间总体大小相关的残存空间的大小。如果残存比率设置为 8(Intel 的默认值),那么每一残存空间将是 Eden 空间的 1/8 大小。另一种考察它的方式是,年轻分代将该 Eden 空间划分为 10 个相同大小的值,该 Eden 将分配其中的 8 个,每一个残存空间的大小为 1。
我们的假设是,通过减少残存比率,我们可以减少由于残存空间中空间的缺乏,对象过早地被提升为年老分代的几率。另一种方法是增加期限阈值,这样的话,对象在提升之前将需要保留更多的 GC 事件。本着这个想法,Blog-City 将设置更改为 -XX:SurvivorRatio=4 ,然后重新启动。
选择低暂停时间的垃圾收集算法
由于这次技术调优的目标之一是减少暂停时间,我们决定抛弃默认的单线程、标记清扫的垃圾收集程序。我们选择通过标志 XX:+UseParallelGC 来采用并行拷贝收集程序。同样,有关实际算法的细节可以在 Resources中找到,这里需要提一下的是,这一标志调用了一个多线程收集程序。线程的数量设置为 CPU 的数量。基于这一事实,了解为什么单线程并行拷贝垃圾收集程序要比传统的标记清扫算法工作更好是很困难的,但是从实际观察中可以体会到提供了某些性能上的优势。
图 3 和图 4 的输出展示了使用标志 -XX:SurvivorRatio=4 +XX:+UseParallelGC -server -Xmx256M 运行时的结果。 图 3. 新配置下的内存使用情况
结果图表显示了明显的不同。虽然仍然有一个内存漏洞。内存消耗的总数相比前一个图已经是大大降低了。GC 持续时间的快速比较揭示了年轻分代和年老分代的总体 GC 持续时间的明显减少。 图 4. 新配置下的 GC 持续时间
有意的、无意的对象保持
由于应用程序是依靠内存的,跟踪内存泄漏并消除它们已经变得越来越重要。在本例中,用于支持缓存策略的组件决定了主要漏洞的来源。从最后的内存分析情况来看(图 3),虽然消除了主要内存泄漏,我们可以看到仍有另一个“低级别的”的漏洞,但这个漏洞比较小,因此它在下一版本发布之前可以忽略。
结束语
本文提出了许多挑战。首先,我们正在调优一个现实中的应用程序,这意味着更改会受到很多限制。第二个挑战是,这项任务是使用 IRC 聊天室远程操控的。聊天室不提供任何级别或质量的相互通信,而通信在这种类型的任务中往往是必需的。在本例中,团队已经习惯了聊天室的真实性,并能通过这种真实性毫无任何阻碍地工作着。
最后也是最困难的挑战是我们受硬件的限制。由于多种原因,我们不可能为系统添加新硬件。其中最大的问题是系统中物理内存的数量,而 JVM 和 MySQL 需要大量的内存。但是,通过系统地逐一应用许多更改,并度量它们对系统产生的影响,我们可以逐步地改进总体系统性能。
参考资料
作者简介
|
|
Kirk Pepperdine is 是 Java Performance Tuning.com 的首席技术官(Chief Technical Officer,CTO),过去 15 年来他一直专攻对象技术和性能调优。Kirk 是 Ant Developer's Handbook (MacMillan) 一书的合著者。 | |