最近,我有一组开发者要对性能工作室中的一个问题百出的应用程序执行故障排除工作。在解决了两个容易的问题之后,他们遇到了一个 CPU 运行过热的问题。这组开发者的反应和我见到的大多数面对 CPU 过热问题的团队的处理方式完全一样;他们启动了一个执行分析器,希望借助它找到问题所在。这个特殊例子中的问题是有关于应用程序是如何烧穿内存的。这种情况下,虽然一个执行分析器能够找到这些问题,但是内存分析器将会绘制出一副更加清晰的图像。我的开发组不知道为什么遗漏了一个能够告诉他们应该使用内存分析器的关键指标。下面就让我们通过本文中的一组相似练习看看什么时候以及为什么使用内存分析器更好。
分析器的工作方式有三种:一是对栈顶进行取样,二是使用探针(probes)检测代码,三是联合这两种方式。这些技术在查找经常发生的、或者占用很长时间的计算型问题时是非常好用的。正如我的小组所经历的,执行分析器所收集到的信息通常与内存效率低下的根源有很好的相关性。但是它指向了执行问题,有时候这样会造成混乱。
我们在清单 1 中发现了定义方法 API findByName(String,String) 的代码。这段代码的问题在很大程度上并不在于 API 本身,而是在于该方法对 String 参数的处理方式上。该代码将两个字符串连接起来形成一个用于在 Map 中查找数据的键。这种对字符串的滥用是代码中的异味,它表明这里缺少了一层抽象。正如我们将看到的,这个缺少的抽象不仅是性能问题的根源,同时添加这层抽象还会提升代码的可读性。对于清单 1 中的这种情况,缺失的抽象是一个 CompositeKey<String,String>(一个包装了两个字符串,同时实现了 equals(Object) 和 hashCode() 这两个方法的类)。
public class CustomerList { private final Map customers = new ConcurrentHashMap(); public Customer addCustomer(String firstName, String lastName) { Customer person = new Customer(firstName, lastName); customers.put(firstName + lastName, person); return person; } public Customer findCustomer(String firstName, String lastName) { return (Customer) customers.get(firstName + lastName); }
清单 1. CustomerList 源码
这个例子所使用的 API 样式还有另外一个负面影响,那就是 CPU 必须要写入内存的数据量限制了扩展性。除了创建数据的额外工作之外,CPU 写入内存的数据量还产生了一个强制 CPU 慢下来的反作用力。尽管该场景是为了重现这个问题而人为创造的,但是在使用流行日志框架的应用程序中这是一个非常常见的问题。也就是说,不要被愚弄了,只有字符串连接的情况也会产生错误。任何频繁操作内存的应用程序都有可能会对内存造成压力,无论底层的数据结构是什么。
决定应用程序是否正在狂吃内存的最简单的方法是:检查垃圾收集日志。垃圾收集日志会报告每次收集前后的堆占用情况。从当前收集之前的占用中减去前一次收集之后的占用就是两次垃圾收集期间的内存分配量。如果我们多次记录该数据,那么我们就能获得一副很清晰的应用程序内存需求的图像。此外,获取需要的 GC 日志不仅非常容易,同时还能够知道一些对应用程序性能没有影响的边界。我使用标记 -Xloggc:gc.log 和 -XX:+PrintGCDetails 创建了带有足够详细信息的 GC 日志。然后我将该 GC 日志文件载入 Censum(jClarity 的 GC 日志分析工具)进行分析。
(单击放大图片)
表格1. 垃圾收集活动摘要
Censum 提供了完整的统计数据(如表格 1),这其中我们感兴趣的是“Collection Type Breakdown”(底部显示的内容)。“% Paused”一列(表格 1 中的第六列)告诉我们 GC 暂停的总时间占 0.86%。一般情况下,我们希望 GC 的暂停时间要低于 5%,这一点它满足了。该数字显示垃圾收集器能够毫不费力地回收内存。但是无论怎样你都需要牢记:在涉及性能的时候,单一指标很少会告诉你整个故事。对于本文中的这种情况,我们需要查看分配率,这可以从图表 1 中找到。
(单击放大图片)
图表 1. 分配率
通过该图表我们能够发现:分配率最初大约是2.75gigs 每秒。运行该基准所使用的笔记本电脑在理想的条件下能够维持大约4gigs 每秒的分配率。因此这个2.75gigs 每秒的值代表了整个内存带宽的很大一部分。事实上,这台机器并不能维持2.75gigs 每秒的分配率,从图中我们可以看出:随着时间的发展该值会下降。尽管你的产品服务器可能会有更大容量的内存,但是根据我的经验:无论是什么样的机器,只要它试图维持高于500 兆字节每秒的对象创建率,那么它在分配内存时都将花费相当长的时间。同时这样做的扩展能力也非常有限。既然在我的应用程序中内存效率是主要瓶颈,那么采取措施让内存可以更高效的运行将会给我们带来巨大的收益。
执行分析
很明显,如果我们想提升内存效率那么我们应该使用一个内存分析器。但是在面对CPU 过热这个问题时,我们组决定使用执行分析;那么就让我们先从这里开始,看看它会发展成什么样。我使用了运行在VisualVM 中的NetBeans 分析器(使用默认配置)产生了图表2 的数据。
(单击放大图片)
图表 2. 执行分析
通过该图表我们能够看出,在Worker.run() 方法之外大部分时间都花在了CustomerList.findCustomer(String,String) 方法上。你可以想象一下,如果源码更加复杂一点,那么将很难理解为什么代码会存在问题,也很难知道应该怎样做才能提升性能。让我们将该图片和内存分析中的图片做一下对比。
内存分析
理想情况下,我喜欢让内存分析器告诉我有多少内存被消耗了,有多少对象被创建了。同时我还希望看到随意的执行路径——也就是负责业务处理的源码到内存之间的路径。我能够通过再次在VisualVM 中运行NetBeans 分析器获取到这些统计数据。但是这需要对分析器做一些配置,让它能够收集分配栈踪迹。这些配置可以参看图1。
(单击放大图片)
图 1. 配置 NetBeans 内存分析器
注意,分析器并不会针对每一次分配都收集数据而是每10 次分配收集一次数据。按照这种方式收集的样本应该和每一次分配都收集的方式产生相同的结果,但是前者的开销更小。分析结果如图表3。
(单击放大图片)
图表 3 内存分析
该图表显示char[] 是最流行的对象。有了这个信息之后,下一步应该是生成一个快照,然后看看针对char[] 的分配栈跟踪。该快照如图表4。
(单击放大图片)
图表 4 char[] 分配栈跟踪.
该图表显示了 3 个主要的 char[] 创建源,相关条目已经被展开以便于你能够看到详细内容。所有这三种情况的根都可以追溯到 firstName + lastName 操作。为了处理这一问题我们想了很多替代方案。但是我们所提出的解决方案都无法和编译器产生的代码一样高效。非常清楚的是,为了让应用程序能够运行的更快我们必须要移除这个连接操作。解决该问题的最终方案是引入一个使用 firstName 和 lastName 作为参数的 Pair 类。我们将该类称为 CompositeKey,正如它所引入的缺失的抽象。改善后的代码参见列表 2。
public class CustomerList { private final Map customers = new ConcurrentHashMap(); public Customer addCustomer(String firstName, String lastName) { Customer person = new Customer(firstName, lastName); customers.put(new CompositeKey(firstName, lastName), person); return person; } public Customer findCustomer(String firstName, String lastName) { return (Customer) customers.get(new CompositeKey(firstName, lastName)); } }
列表 2. 使用 CompositeKey 抽象改善后的实现
CompositeKey 实现了 hashCode() 和 equals() 两个方法,因而也消除了将两个字符串连接到一起的需要。第一个基准的完成花费了~63 秒,改善后的版本消耗了~21 秒,速度提升了 3 倍。虽然我们需要运行多次垃圾收集器才能够获得精确的信息,但是我们可以这样说:代码改善之后应用程序总共消耗了不到 3gigs 的数据,而第一种实现的消耗则超过了 141GB。
填充一个水塔的两种方式
我的一个同事曾经说过:你一次能够填充一个水塔一茶匙。这个例子证明你当然可以这样做。但是,这并不是填充水塔的唯一方式,你还可以通过一个大型水管快速地充满它。对于本文中的这种情况,通过一个执行分析器是不可能领悟问题的。虽然垃圾收集器会查看分配和恢复情况,同时内存分析器也确实会查看剪切字节数的分配。但是在一个这种大型分配占主导地位的应用程序中,开发团队已经耗尽了他们将通过使用执行分析器获得的大部分收益,然而他们依然需要从应用程序中攫取更多信息。
这个时候,他们打开了内存分析器,它暴露出一个又一个的分配热点,通过这些热点我们能够提取大量可以显著提升性能的信息。通过该示例我们团队学到的知识是,内存分析不仅能够为我们提供正确的视图,还能够提供唯一深入问题的视图。这并不是说执行分析并没有什么成效。我的意思是,有些时候执行分析并不能告诉你应用程序将所有时间都花到哪儿了,在这种情况下从另一个不同的视角分析该问题将会获得意想不到的收获。
关于作者
Kirk Pepperdine在高性能和分布式计算领域工作了将近 20 年。从 1998 年开始 Kirk 就一直在从事各种与项目生命周期每个阶段的性能调优相关的工作。2005 年他帮助创建了最初的 Java 性能调优网站,该网站已经帮助了全世界大量的开发人员。作为作者、演讲师、咨询顾问,Kirk 对 Java 社区做出的贡献在 2006 年获得了认可,他当选了该年度的 Java Champion。他是第一位在 JavaONE 大会上呈现技术实验的非 Sun 雇员,同时也为信息产业中的其他人打开了参与该活动的机会。由于他在垃圾收集方面的演讲,在 2011 年和 2012 年他被任命为 JavaONE Rockstar。如果你想和他联系,那么可以给他发 email( kirk@kodewerk.com )或者在 Twitter 上 @ kcpeppe。
查看英文原文: To Execution profile or to Memory Profile? That is the question
评论