监测(profiling)是测量软件程序在什么地方消耗资源(包括 CPU 时间和内存)的技术。在这篇文章中,软件架构师 Andrew Wilcox 解释了监测的好处和当前的一些监测选项及其不足。然后他介绍了如何用 Java™ 5 中新增的代理接口和简单的面向方面编程技术构建自己的监测器。
< include java script once we verify teams wants to use this and it will work on dbcs and cyrillic characters >
不论是使用 System.out.println()
还是 hprof
或 OptimizeIt 这样的监测工具,代码监测都应当是软件开发实践的关键部分。这篇文章讨论了代码监测最常见的方式,并解释了它们的不足。本文提供了对于理想的内部监测器来 说最合适的特性,并解释了为什么面向方面编程技术非常适于实现其中的一些特性。本文还介绍了 JDK 5.0 代理接口,并详细介绍了用它构建自己的面向方面的监测器的步骤。
请注意,这篇文章的示例监测器和 完整源代码 基于 Java 交互监测器(JIP)—— 一个用面向方面技术和 Java 5 代理接口构建的开放源码监测器。请参阅 参考资料 学习关于 JIP 和本文中讨论的其他工具的更多内容。
监测工具和技术
多数 Java 开发人员都是从使用 System.currentTimeMillis()
和 System.out.println()
开始测量应用程序性能的。System.currentTimeMillis()
易于使用:只要测量方法开始和结束的时间,并输出时间差即可,但是它有两个重大不足:
- 它是个手工过程,要求开发人员确定要测量哪个代码;插入工具代码;重新编译、重新部署、运行并分析结果;然后在结束时取消工具代码;而在下次出现 问题时再次重复以上所有步骤。
- 而且它对于应用程序各部分的执行情况没有提供全面的观察。
为了解决这些问题,有些开发人员转向 hprof
、JProbe 或 OptimizeIt 这样的监测器。监测器避免了与即时测量相关联的问题,因为不必修改程序就可以使用它们。它们还为程序性能提供了更全面的观察,因为它们收集每个方法调用的 计时信息,而不仅仅是某个具体代码段的计时信息。不幸的是,监测工具也有不足。
|
回页首 |
|
监测器的局限
监测器对于 System.currentTimeMillis()
这样的手工解决方案提供了很好的替代,但是它们还远谈不上理想。有一件事,就是用 hprof
运行程序,会把程序减慢 20 倍。这意味着正常情况下只需要一小时的一个 EFL(提取、转换、装入)操作,可能要花一整天才能监测!不仅等候是不方便的,而且应用程序时间范围的改变,实际上也会扭曲结果。以做许多 I/O 操作的程序为例。因为 I/O 由操作系统执行,监测不会减慢它,所以 I/O 操作看起来运行得要比实际的速度快 20 倍!所以,不能总是依靠 hprof
提供对应用程序性能的正确描述。
hprof
的另一个问题与 Java 程序装入和运行的方式有关。与 C 或 C++ 这样的静态链接语言不同,Java 程序是在运行时而不是在编译时链接的。直到第一次引用的时候,JVM 才装入类,而代码直到执行了许多次之后,才从字节码编译成机器码。如果想测量一个方法的性能,但是它的类还没有装入,那么测量就会包含类的装入时间和编译 时间再加上运行时间。因为这些事只在应用程序生命开始的时候发生一次,所以如果要测量长期的应用程序性能,通常不想把这些事包含在内。
当代码在应用服务器或 servlet 引擎中运行的时候,事情会变得更加复杂。hprof
这样的监测器会监测整个应用程序、servlet 容器和所有的东西。问题是,通常不想 监测 servlet 引擎,只想监测应用程序。
|
回页首 |
|
理想的监测器
像选择其他工具一样,选择监测器也有机会成本。hprof
易于使用,但有局限性,例如不能从监测中过滤掉类或包。商业工具提供了更多特性,但是昂贵而且有严格的许可条款。有些监测器要求通过监测器启动应用程序, 这意味着要用不熟悉的工具重新构建执行环境。监测器的选择涉及妥协,所以理想的监测器看起来应当像什么呢?下面是应当追寻的特性的一个简短列表:
- 速度:监测可能会慢得让人痛苦。但是可以使用不自动监测每个类的监测器,以便加快速度。
- 交互性:监测器允许的交互越多,对监测器得到的信息进行的精细调整就越多。例如,能够在运行时开启和关闭监测器,有助于避免测量类 的装入、编译和解释执行(预 JIT)时间。
- 过滤:根据类或包进行过滤,可以把注意力集中在手头的问题上,而不会被太多的信息扰乱。
- 100% 纯 Java 代码:多数监测器都要求使用本机库,这限制了可以使用它们的平台。理想的监测器不应当要求使用本机库。
- 开放源码:开放源码工具通常允许迅速地起步和运行,同时避免了商业许可的限制。
|
回页首 |
|
自己构建监测器!
用 System.currentTimeMillis()
生成计时信息的问题是它是一个手工过程。如果能够自动插入工具代码,那么它的许多不足就烟消云散了。这类问题正是面向方面解决方案最适合解决的问题。对于 构建面向方面的监测器来说,Java 5 引入的代理接口非常理想,因为它提供了挂接到类装入器和在类装入时修改类的方便途径。
本文的剩余部分集中在 BYOP (构建自己的监测器)上。我将介绍代理接口,并演示如何创建简单代理。将学习基本监测方面的代码,以及为了更高级的监测对它进行修改所采取的步骤。
|
回页首 |
|
创建代理
不幸的是,-javaagent
这个 JVM 选项的文档只有零星记载。找不到太多关于这个主题的书(没有 Java 代理傻瓜书 或 21 天学会 Java 代理),但是可以在 参考资料 一节中发现一些好的资源,还有这里的概述。
代理背后的想法是:在 JVM 装入类时,代理可以修改类的字节码。可以用三个步骤创建代理:
- 实现
java.lang.instrument.ClassFileTransformer
接口:
public interface ClassFileTransformer { public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException; } |
- 创建 “premain” 方法。这个方法在应用程序的
main()
方法之前调用,看起来像这样:
package sample.verboseclass; public class Main { public static void premain(String args, Instrumentation inst) { ... } } |
- 在代理的 JAR 文件中,包含一个清单条目,表示包含
premain()
方法的类:
Manifest-Version: 1.0 Premain-Class: sample.verboseclass.Main |
一个简单的代理
构建监测器的第一步是创建一个代理,在装入每个类的时候输出类的名称,与 -verbose:class
JVM 选项的功能类似。如清单 1 所示,这只要求几行代码:
清单 1. 一个简单的代理
package sample.verboseclass; public class Main { public static void premain(String args, Instrumentation inst) { inst.addTransformer(new Transformer()); } } class Transformer implements ClassFileTransformer { public byte[] transform(ClassLoader l, String className, Class<?> c, ProtectionDomain pd, byte[] b) throws IllegalClassFormatException { System.out.print("Loading class: "); System.out.println(className); return b; } }
|
如果代理被打包在叫作 vc.jar
的 JAR 文件中,就应当用 -javaagent
选项启动 JVM,如下所示:
java -javaagent:vc.jar MyApplicationClass
|
|
回页首 |
|
监测方面
有了代理的基本元素之后,下一步就是在装入应用程序的类时向其中添加简单的监测方面。幸运的是,不需要掌握修改字节码的 JVM 指令集的细节。相反,可以用 ASM 库这样的工具包(来自 ObjectWeb 论坛,请参阅 参考资料) 来处理类文件格式的细节。ASM 是个 Java 字节码操纵框架,使用访客模式实现对类文件的转换,使用的方式非常像使用 SAX 事件遍历和转换 XML 文档那样。
清单 2 中的监测方面可以用来输出类名称、方法名称和 JVM 每次进入或离开一个方法的时间戳。(对于更复杂的监测器,可能还想使用精度更高的计时器,像 Java 5 的 System.nanoTime()
。)
清单 2. 简单的监测方面
package sample.profiler; public class Profile { public static void start(String className, String methodName) { System.out.println(new StringBuilder(className) .append('\t') .append(methodName) .append("\tstart\t") .append(System.currentTimeMillis())); } public static void end(String className, String methodName) { System.out.println(new StringBuilder(className) .append('\t') .append(methodName) .append("\end\t") .append(System.currentTimeMillis())); } }
|
如果手工进行监测,那么下一步可能是把每个方法修改成像下面这样:
void myMethod() { Profile.start("MyClass", "myMethod"); ... Profile.end("MyClass", "myMethod"); }
|
|
回页首 |
|
使用 ASM 插件
现在需要找出 Profile.start()
和 Profile.end()
调用的字节码是什么样的 —— 这正是 ASM 库发挥作用的地方。ASM 有一个用于 Eclipse 的 Bytecode Outline 插件(请参阅 参考资料), 它允许查看类或方法的字节码。图 1 显示了以上方法的字节码。(也可以使用 javap
这样的反汇编器,它是 JDK 的一部分。)
图 1. 用 ASM 插件查看字节码
ASM 插件甚至还生成了能够用来生成对应字节码的 ASM 代码,如图 2 所示:
图 2. ASM 插件生成的代码
可以把图 2 中高亮的代码复制到代理中,调用 Profile.start()
方法的通用化版本,如清单 3 所示:
清单 3. 插入对监测器的调用的 ASM 代码
visitLdcInsn(className); visitLdcInsn(methodName); visitMethodInsn(INVOKESTATIC, "sample/profiler/Profile", "start", "(Ljava/lang/String;Ljava/lang/String;)V");
|
为了插入开始和结束调用,请继承 ASM 的 MethodAdapter
,如清单 4 所示:
清单 4. 插入对监测器的调用的 ASM 代码
package sample.profiler; import org.objectweb.asm.MethodAdapter; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; import static org.objectweb.asm.Opcodes.INVOKESTATIC; public class PerfMethodAdapter extends MethodAdapter { private String className, methodName; public PerfMethodAdapter(MethodVisitor visitor, String className, String methodName) { super(visitor); className = className; methodName = methodName; } public void visitCode() { this.visitLdcInsn(className); this.visitLdcInsn(methodName); this.visitMethodInsn(INVOKESTATIC, "sample/profiler/Profile", "start", "(Ljava/lang/String;Ljava/lang/String;)V"); super.visitCode(); } public void visitInsn(int inst) { switch (inst) { case Opcodes.ARETURN: case Opcodes.DRETURN: case Opcodes.FRETURN: case Opcodes.IRETURN: case Opcodes.LRETURN: case Opcodes.RETURN: case Opcodes.ATHROW: this.visitLdcInsn(className); this.visitLdcInsn(methodName); this.visitMethodInsn(INVOKESTATIC, "sample/profiler/Profile", "end", "(Ljava/lang/String;Ljava/lang/String;)V"); break; default: break; } super.visitInsn(inst); } }
|
把这个功能挂接到代理的代码非常简单,也是这篇文章的 源代码下载 的一部分。
装入 ASM 类
因为代理使用 ASM,所以需要确保装入了 ASM 类,所有东西才能工作。在 Java 应用程序中有许多类路径:应用程序类路径、扩展类路径和启动类路径。令人惊讶的是,ASM JAR 没有采用其中任何一个路径;相反,要使用清单告诉 JVM 代理需要哪个 JAR 文件,如清单 5 所示。在这种情况下,JAR 文件必须与代理的 JAR 放在同一目录中。
清单 5. 监测器的清单文件
Manifest-Version: 1.0 Premain-Class: sample.profiler.Main Boot-Class-Path: asm-2.0.jar asm-attrs-2.0.jar asm-commons-2.0.jar
|
|
回页首 |
|
运行监测器
所有东西都编译打包之后,就可以对任何 Java 应用程序运行监测器了。清单 6 中的部分输出来自对 Ant 的监测,这个 Ant 执行 build.xml 对代理进行编译:
清单 6. 监测器的输出示例
org/apache/tools/ant/Main runBuild start 1138565072002 org/apache/tools/ant/Project <init> start 1138565072029 org/apache/tools/ant/Project$AntRefTable <init> start 1138565072031 org/apache/tools/ant/Project$AntRefTable <init> end 1138565072033 org/apache/tools/ant/types/FilterSet <init> start 1138565072054 org/apache/tools/ant/types/DataType <init> start 1138565072055 org/apache/tools/ant/ProjectComponent <init> start 1138565072055 org/apache/tools/ant/ProjectComponent <init> end 1138565072055 org/apache/tools/ant/types/DataType <init> end 1138565072055 org/apache/tools/ant/types/FilterSet <init> end 1138565072055 org/apache/tools/ant/ProjectComponent setProject start 1138565072055 org/apache/tools/ant/ProjectComponent setProject end 1138565072055 org/apache/tools/ant/types/FilterSetCollection <init> start 1138565072057 org/apache/tools/ant/types/FilterSetCollection addFilterSet start 1138565072057 org/apache/tools/ant/types/FilterSetCollection addFilterSet end 1138565072057 org/apache/tools/ant/types/FilterSetCollection <init> end 1138565072057 org/apache/tools/ant/util/FileUtils <clinit> start 1138565072075 org/apache/tools/ant/util/FileUtils <clinit> end 1138565072076 org/apache/tools/ant/util/FileUtils newFileUtils start 1138565072076 org/apache/tools/ant/util/FileUtils <init> start 1138565072076 org/apache/tools/ant/taskdefs/condition/Os <clinit> start 1138565072080 org/apache/tools/ant/taskdefs/condition/Os <clinit> end 1138565072081 org/apache/tools/ant/taskdefs/condition/Os isFamily start 1138565072082 org/apache/tools/ant/taskdefs/condition/Os isOs start 1138565072082 org/apache/tools/ant/taskdefs/condition/Os isOs end 1138565072082 org/apache/tools/ant/taskdefs/condition/Os isFamily end 1138565072082 org/apache/tools/ant/util/FileUtils <init> end 1138565072082 org/apache/tools/ant/util/FileUtils newFileUtils end 1138565072082 org/apache/tools/ant/input/DefaultInputHandler <init> start 1138565072084 org/apache/tools/ant/input/DefaultInputHandler <init> end 1138565072085 org/apache/tools/ant/Project <init> end 1138565072085 org/apache/tools/ant/Project setCoreLoader start 1138565072085 org/apache/tools/ant/Project setCoreLoader end 1138565072085 org/apache/tools/ant/Main addBuildListener start 1138565072085 org/apache/tools/ant/Main createLogger start 1138565072085 org/apache/tools/ant/DefaultLogger <clinit> start 1138565072092 org/apache/tools/ant/util/StringUtils <clinit> start 1138565072096 org/apache/tools/ant/util/StringUtils <clinit> end 1138565072096
|
|
|
回页首 |
|
跟踪调用堆栈
迄今为止,已经看到了如何只用几行代码就构建了一个简单的面向方面的监测器。虽然是个好的开始,但是示例监测器没有收集线程和调用堆栈数据。调用堆 栈信息对于判断方法的毛执行时间和净执行时间是必需的。另外,每个调用堆栈都与一个线程相关,所以如果想跟踪调用堆栈数据,也需要线程信息。多数监测器使 用两趟式设计进行这类分析:首先收集数据,然后分析数据。我将介绍如何采用这种技术,而不是在收集数据的时候输出数据。
修改监测类
可以很容易地增强 Profile
类,让它捕获堆栈和线程信息。对于初学者来说,不用在每个方法调用的开始和结束时都输出时间信息,可以用图 3 所示的数据结构保存这些信息:
图 3. 跟踪调用堆栈和线程信息的数据结构
有许多方法可以收集关于调用堆栈的信息。其中之一是实例化一个 Exception
,但是如果在每个方法的开始和结束时 都做这件事,就太慢了。更简单的方法是让监测器管理它自己的内部堆栈。这很容易,因为对于每个方法都要调用 start()
; 唯一的技巧就是当抛出异常时就解开内部调用堆栈。在调用 Profile.end()
时,通过检查预期的类和方法名称,可以探测到什么时候抛出了异常。
输出的设置也很容易。可以用 Runtime.addShutdownHook()
登记一个 Thread
来创建一个 shutdown 钩子,在关闭的时候运行,向控制台输出监测报告。
|
回页首 |
|
结束语
这篇文章介绍了监测目前最常用的工具和技术,并讨论了它们的一些局限性。还提供了一个理想的监测器应当具有的特性列表。最后,学习了如何用面向方面编程和 Java 5 代理接口构建出集成了一些理想特性的自己的监测器。
这篇文章的示例代码基于 Java 交互式监测器,这是一个用这里讨论的技术构建的开放源码监测器。除了示例监测器中的基本特性之外,JIP 还集成了以下特性:
- 交互式监测
- 排除类或包的能力
- 只包含由特定类装入器装入的类的能力
- 跟踪对象分配的工具
- 代码监测之外的性能测量
JIP 是在 BSD 形式的许可下分发的。请参阅 参考资料 获得下载信息。