不管是使用 System.out.println()
仍是 hprof
或 OptimizeIt 这样的监测工具,代码监测都应当是软件开发实践的关键部分。这篇文章讨论了代码监测最多见的方式,并解释了它们的不足。本文提供了对于理想的内部监测器来 说最合适的特性,并解释了为何面向方面编程技术很是适于实现其中的一些特性。本文还介绍了 JDK 5.0 代理接口,并详细介绍了用它构建本身的面向方面的监测器的步骤。java
请注意,这篇文章的示例监测器和 完整源代码 基于 Java 交互监测器(JIP)—— 一个用面向方面技术和 Java 5 代理接口构建的开放源码监测器。请参阅 参考资料 学习关于 JIP 和本文中讨论的其余工具的更多内容。web
监测工具和技术apache
多数 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 才装入类,而代码直到执行了许屡次以后,才从字节码编译成机器码。若是想测量一个方法的性能,可是它的类尚未装入,那么测量就会包含类的装入时间和编译 时间再加上运行时间。由于这些事只在应用程序生命开始的时候发生一次,因此若是要测量长期的应用程序性能,一般不想把这些事包含在内。app
当代码在应用服务器或 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 形式的许可下分发的。请参阅 参考资料 得到下载信息。