截取、类包装和字节码插装
简介: 本系列包括三部分,介绍有关 Java™ 应用程序的运行时监控知识,第 1 部分 重点介绍了 JVM 的健康状况和插装源代码以捕获性能指标的方法。本篇为第二部分,介绍了在无需改变原始源代码的情况下来插装 Java 类和构造的技巧。
正如您在本系列(共三篇文章)的 第 1 部分 中所了解到的,监控 Java 应用程序的可用性和性能及其生产中的依赖性,这对于确保问题检测和加速问题诊断和修复至关重要。需要进行监视的类的源代码级插装具有第 1 部分 所论述过的那些优势,但是这种方法通常都不可取或者不切实际。例如,很多您所感兴趣的监控点可能位于第三方组件中,而第三方组件的源代码您是不得而知的。在第 2 部分中,我着重介绍了无需修改原始源代码而插装 Java 类和资源的方法。
可选择的在源代码外编排插装的方法有:
- 截取
- 类包装
- 字节码插装
本文使用了 第 1 部分 中呈现的ITracer
接口来实现性能数据跟踪,依次举例阐明了这些技巧。
截取 的基本前提是通过一个截取构造和收集传入的入站与出站调用信息,对特定的调用模式进行转换。一个基本的截取程序的实现会:
- 获取对入站调用请求的当前时间。
- 取回出站响应的当前时间。
- 将运行时间作为两次度量的增量计算出来。
- 将调用的运行时间提交给应用程序性能管理(APM)系统。
图 1 展示了该流程:
图 1. 性能数据收集截取程序的基本流程
很多诸如 Java Platform 和 Enterprise Edition(Java EE)这样的 Java 框架都包括对截取栈的核心支持,服务的调用可以在截取栈中通过一系列预处理和后处理组件来进行传递。有了这些栈就可以很好地将插装注入到执行路径中,这样做的好处有二:第一,无需修改目标类的源代码;第二,只要将截取程序类插入到 JVM 的类路径中并修改组件的部署描述符,这样就把插装截取程序插入到了执行流程中。
截取程序所收集的一个典型的指标就是运行时间。其他的指标同样适合截取模式。我将介绍支持这些指标的 ITracer
接口的两个新的方面,所以在这里我要转下话题,先简要论述一下这些指标。
使用截取程序时需要收集的典型指标有:
- 运行时间:完成一个执行的平均时钟时间。
- 每个时间间隔内的调用:调用目标的次数。
- 每个时间间隔内的响应:目标响应调用的次数。
- 每个时间间隔内的异常l:目标调用导致异常的次数。
- 并发性:并发执行目标的线程数。
还有两个 ThreadMXBean
指标可以选择,但它们的作用有限,而且收集成本会高一些:
- 运行 CPU 时间:这是线程在执行期间消耗的 CPU 时间,以纳秒为单位。CPU 的利用情况在起初时似乎有用,但其实也就是作为一种趋势模式,其他的用处不大。或者,如果收集成本特别高的话,可以计算线程在执行时占用 CPU 资源的百分比的近似值。
- 阻塞/等待计数和时间:等待表示由具体线程调度导致的同步或者等待。阻塞常见于执行等待资源时,如响应来自远程数据库的 Java 数据库连接(Java Database Connectivity,JDBC)调用(至于这些指标的用处,请参见本文的JDBC 插装 部分)。
为了澄清 ThreadMXBean
指标的收集方法,清单 1 快速回顾了基于源代码的插装。在这个例子中,我针对 heavilyInstrumentedMethod
方法实现了大量插装。
清单 1. 实现大量插装的方法
|
清单 1 引入了两个新的构造:
ThreadInfoCapture
方法:ThreadInfoCapture
方法对于获取运行时间和目标调用前后的ThreadMXBean
指标增量都很有帮助。startThreadInfoCapture
为当前线程捕获基准,而endThreadInfoCapture
计算出增量和趋势。由于这些指标永远都是递增的,所以必须事先确定一个基准,再根据它计算出之后的差量。但这个场景不适用于跟踪程序的增量功能,这是因为每一个线程的绝对值都是不同的,而且运行中的 JVM 中的线程也不是保持不变的。另外还要注意跟踪程序使用了一个栈来保存基准,所以您可以(小心地)嵌套调用。要收集这个数据可是要费一番力气。图 2 展示了收集各种ThreadMXBean
指标所需要的相对运行时间:
图 2. 收集ThreadMXBean
指标所需的相对成本
虽然如果小心使用调用的话,收集这些指标的总开销不会很大,但是仍然需要遵循在记录日志时需要考虑的一些事项,例如不要在紧凑循环(tight loop)内进行。
- 并发性:要跟踪在特定时间内通过这个代码的线程数,需要创建一个计数器,该计数器既要是线程安全的又要对目标类的所有实例可用 — 在本例为
AtomicInteger
类。此种情况比较麻烦,因为有时可能多个类加载器都载入了该类,致使计数器无法精确计数,从而导致度量错误。解决这个问题的办法为:将并发计数器保存在 JVM 的一个特定的受保护位置中,诸如平台代理中的 MBean。
并发性只有在插装目标是多线程的或者共用的情况下可用,但是它是非常重要的指标,这一点我将在稍后介绍 Enterprise JavaBean(EJB)截取程序时进一步阐述。EJB 截取程序是我接下来要论述的几个基于截取的插装示例的第一个,借鉴了清单 1 中查看的跟踪方法。
发布了 EJB 3 后,截取程序就成了 Java EE 架构中的标准功能(有些 Java 应用服务器支持了 EJB 截取程序一段时间)。大多数 Java EE 应用服务器的确提供了性能指标,报告有关诸如 EJB 这样的主要组件,但是仍然需要实现自己的性能指标,因为:
- 您需要基于上下文的或者基于范围/阈值的跟踪。
- 应用服务器指标固然不错,但是您希望指标位于 APM 系统中,而不是应用服务器中。
- 应用服务器指标无法满足您的要求。
虽然如此,根据您的 APM 系统和应用服务器实现的不同,有些工作可能不用您再亲历亲为了。例如,WebSphere® PMI 通过 Java 管理扩展(Java Management Extensions,JMX)公开了服务器指标(参见参考资料)。即使您的 APM 供应商没有提供自动读取这个数据的功能,读完本篇文章之后您也会知道如何自行读取。
在下一个例子中,我将向一个称为 org.aa4h.ejb.HibernateService
的无状态会话的上下文 bean 中注入一个截取程序。EJB 3 截取程序的要求和依赖性都是相当小的:
- 接口:
javax.interceptor.InvocationContext
- 注释:
javax.interceptor.AroundInvoke
- 目标方法:任何一个名称里面有
public Object anyName(InvocationContextic)
的方法
清单 2 展示了样例 EJB 的截取方法:
清单 2. EJB 3 截取程序方法
|
如 清单 1 一样,清单 2 包含一个大的插装集,一般不推荐使用,此处仅作为一个例子使用。清单 2 中有以下几点需要注意:
@AroundInvoke
注释通过封装 EJB 调用而将方法标记为一个截取程序。
- 方法调用一直沿着栈传递调用,可能传递到最终目标,或到下一个截取程序。因此,要在调用该方法前确定度量基准,在调用后跟踪它。
- 传入跟踪方法的
InvocationContext
为截取程序提供全部有关调用的元数据,包括:- 目标对象
- 目标方法名
- 所传递的参数
从插装的角度看,这些截取程序最有用之处在于您可以通过修改部署描述符而将它们应用于 EJB。清单 3 展示了样例 EJB 的 ejb-jar.xml 部署描述符:
清单 3. EJB 3 截取程序部署描述符
|
正如我在前面所提到过的,插装截取程序对于基于上下文或者基于范围/阈值的跟踪是有用的。而 InvocationContext
中的 EJB 调用参数值是可用的,这加强了插装截取程序的作用。这些值可以用于跟踪范围或其他上下文的复合名称。考虑一下包含有issueRemoteOperation(String region、Command command)
方法的 org.myco.regional.RemoteManagement
类中的 EJB 调用。EJB 接受一个命令,然后远程调用根据域识别的服务器。在这个场景中,区域服务器遍布于一个广泛的地理区域,每一个区域服务都有自己的 WAN 特性。这里呈现的模式与第 1 部分 中的 payroll-processing 例子类似,这是因为如果没有明确命令到底被分配到哪一个区域的话,确定一个 EJB 调用的运行时间是很困难的。您可能已经预料到,从距离一个洲远的区域调用的运行时间要比从隔壁调用的运行时间要长的多。但是您是可以从InvocationContext
参数确定区域的,因此您只需将区域代码添加到跟踪复合名称并按区域划分性能数据,如清单 4 所示:
清单 4. EJB 3 截取程序实现上下文跟踪
|
Java Servlet API 提供了一个叫做过滤器(filter)的构造,它与 EJB 3 截取程序非常类似,含有无需源代码的注入和元数据可用性。清单 5 展示了一个过滤器的doFilter
方法,带有缩略了的插装。指标的复合名由过滤器类名和请求的统一资源标识符(Uniform Resource Identifier,URI)构建:
清单 5. servlet 过滤器截取程序方法
|
清单 6 展示了清单 5 的过滤器的 web.xml 部署描述符的相关片断:
清单 6. servlet 过滤器部署描述符
|
前面的例子侧重于服务器端组件,但一些诸如客户端截取这样的插装实现方法也是存在的。Ajax 客户机可以注册度量 XMLHttpRequest
运行时间的性能监听器,并可以在下一个请求的参数列表末尾承载请求的 URI(对于复合名称)和运行时间。有些 Java EE 服务器,如 JBoss,允许使用客户端的截取程序,本质上这些截取程序与 EJB 3 截取程序所作的工作相同,并且它们也能够承载下一个请求中的度量提交。
监控中的客户端通常都会被忽视。所以下次听到用户抱怨您的应用程序太慢时,不要因为服务器端的监控确保服务器端是良好的就无视这些抱怨。客户端的插装可以确保您所度量的正是用户所体验的,它们可能不会总是与服务器端的指标一致。
一些 Java EE 实现支持的客户端截取程序被实例化并绑定在 EJB 的客户端。这意味着如果一个远程客户机通过远程方法调用(Remote Method Invocation,RMI)协议调用服务器上的 EJB,则也可以从远程客户机无缝收集到性能数据。在远程调用的任一端实现截取程序类都会实现在两者间传递上下文的能力,从而获取额外的性能数据。
下面的例子展示了一对截取程序,它们共享数据并获得传输时间(传送请求和响应的运行时间)以及客户机方面对服务器的远程请求的响应时间。该例子使用了 JBoss 应用服务器的客户端和服务器端的 EJB 3 截取程序专有的实现。
这对截取程序通过在相同负载内承载上下文数据,将上下文数据作为 EJB 调用传递到同一个调用。上下文数据包括:
- 客户端发出请求的时间:EJB 客户机截取程序发出请求时的请求的时间戳
- 服务器端接收请求的时间:EJB 服务器端截取程序接收请求时的请求的时间戳
- 服务器端发送响应的时间:EJB 服务器端截取程序将响应回送给客户机时的响应的时间戳
调用参数被当作一个栈结构,上下文数据通过这个结构进出参数。上下文数据由客户端截取程序放入该调用中,再由服务器端截取程序取出,然后传入到 EJB 服务器 stub。数据返回时则按此过程的逆向过程传递。图 3 展示了这个流程:
图3. 客户机和服务器 EJB 截取程序的数据流
为这个例子构建截取程序需要为客户机和服务器实现 org.jboss.aop.advice.Interceptor
接口。该接口有一个重要的方法:
public abstract java.lang.Object invoke(
org.jboss.aop.joinpoint.Invocation invocation) throws java.lang.Throwable |
这个方法引入了调用封装 的理念,根据这个理念,一个方法的执行被封装成为一个独立对象,它表示以下内容:
- 目标类
- 要调用的方法名
- 由作为实参传入目标方法的参数组成的负载
接着这个对象可以被继续传递,直至传递到调用方,调用方解组调用对象并针对端点目标对象实现动态执行。
客户端截取程序将当前请求时间添加到调用上下文,而服务器端截取程序则负责添加接收请求的时间戳和发送响应的时间戳。或者,服务器可以获得客户机请求,由客户机计算出请求和来回传输的总运行时间。每种情况的计算方法为:
- 客户端,向上传输时间等于
ServerSideReceivedTime
减去ClientSideRequestTime
- 客户端,向下传输时间等于
ClientSideReceivedTime
减去ServerSideRespondTime
- 服务器端,向上传输时间等于
ServerSideReceivedTime
减去ClientSideRequestTime
清单 7 展示了客户端截取程序的 invoke
方法:
清单 7. 客户端截取程序的
invoke
方法
|
服务器端截取程序在概念上是类似的,不同的是为了避免使例子过于复杂,它使用了本地线程来检查 reentrancy
— 相同的请求处理线程在同一远程调用中不只一次调用相同的 EJB(和截取程序)。该截取程序忽略了除第一个请求之外的所有请求的跟踪和上下文处理。清单 8 展示了服务器端截取程序的invoke
方法:
清单 8. 服务器端截取程序的
invoke
方法
|
JBoss 通过面向方面的编程(aspect-oriented programming,AOP)(参见 参考资料)技术来应用截取程序,该技术读取名为 ejb3-interceptors-aop.xml 的指令文件并根据其中定义的指令应用截取程序。JBoss 使用这种 AOP 技术在运行时将 Java EE 核心规则应用于 EJB 3 类。因此,除了性能监控截取程序之外,该文件还包含了关于事务管理、安全性和持久性这样的指令。客户端指令则相当简单明了。它们被简单地定义为包含一系列截取程序类名的stack name
XML 元素。每一个在此定义的类名同时都有资格作为 PER_VM
或 PER_INSTANCE
截取程序,这表明每一个 EJB 实例都应该共享一个截取程序实例或者具有各自的非共享实例。针对性能监控截取程序的目标,则应该确定此项配置,无论截取程序代码是否是线程安全的。如果截取程序代码能够安全地并行处理多个线程,那么使用PER_VM
策略更有效,而对于线程安全但是效率较低的策略,则可以使用 PER_INSTANCE
。
服务器端的截取程序的配置要相对复杂一些。截取程序要依照一组语法模式和用 XML 定义的过滤器来应用。如果所关注的特定的 EJB 方法与定义的模式相符的话,那么为该模式定义的截取程序就会被应用。服务器端截取程序能够通过进一步细化定义来将部署的 EJB 的特定子集定为目标。对于客户端截取程序,您可以通过创建一个新的特定于目标 bean 的stack name
来实现自定义栈。而在服务器端,自定义栈可以在一个新的 domain
中进行定义。个别 EJB 的关联客户机stack name
和服务器栈 domain
可以在 EJB 的注释中指定。或者,如果您不能或是不想修改源代码的话,这些信息可以在 EJB 的部署描述符中指定或者跳过。清单 9 展示了一个删减的用于此例的 ejb3-interceptors-aop.xml 文件:
清单 9. 经过删减的 EJB 3 AOP 配置
|
这种性能数据收集方法可以一箭双标。首先,它可以告诉您从客户机的角度看,一个 EJB 目前的性能是多少。再者,如果性能滞后的话,传输时间可以很好地指明是否是由客户机和服务器间的网络连接速度缓慢而导致的。图 4 展示了总运行时间和上/下传输指标,该指标是从客户机度量的,度量方法是在客户机和服务器之间使用一个人为减缓的网络连接来突出显示传输时间:
图 4. 上下文客户机截取程序性能指标
使用客户端截取程序时,客户机截取程序类本身必须处于客户机应用程序的类路径中。或者一定要启用从服务器载入的远程类,这样才能够在启动时将客户端截取程序及其依赖项下载到客户机上。如果您的客户机系统时钟不是完全 与服务器系统时钟同步的话,您就会得到与两个时钟的时间差大小成正比的特殊结果。
尽管 Java EE 提供丰富的正交无缝截取方法,但很多流行的非 Java EE 容器同样支持隐式的和显式的截取。我之所以使用容器 这个词是想暗指某种使用或鼓励使用松散耦合的框架。只要不使用紧密耦合,就能够实现截取。这种类型的框架通常称为依赖注入 或者Inversion of Control(IoC) 架构。它们让您能够在外部定义个别组件如何 “粘合” 在一起,而不是硬编码组件,从而实现组件间的之间通信。我将使用流行的 IoC 框架 Spring Framework(参见参考资料)中的跟踪截取程序来查看性能数据的收集,以此结束对截取的讨论。
Spring Framework 让您能够使用普通初始 Java 对象(Plain Old Java Object,POJO)来构建应用程序。POJO 仅包含业务逻辑,而 Spring 框架添加了构建企业应用程序所需的内容。如果在最初编写 Java 应用程序时没有考虑插装的话,Spring 的分层架构是很有用处的。虽然将应用程序架构备份到 Spring 并非一无是处,但除一系列的 Java EE 和 AOP 集成外,还有 Spring 的 POJO 管理特性足以将普通硬连接的 Java 类委托给 Spring 的容器管理功能。您可以通过截取添加性能插装,无需修改目标类的源代码。
Spring 通常被描述为 IoC 容器,这是因为它颠倒了 Java 应用程序的传统拓扑结构。在传统的拓扑中,会有一个中心的程序或控制线程按照程序载入全部需要的组件和依赖项。容器用 IoC 载入几个组件,并依照外部配置管理组件间的依赖项。此处的依赖项管理称为依赖项注入,因为依赖项(如 JDBCDataSource
)是通过容器注入组件的;组件无需寻找到它们自己的依赖项。为了进行插装,容器的配置可以轻易修改,从而将截取程序插入到这些组件间的 “结缔组织” 中。图 5 解释了该概念:
图 5. Spring 和截取概观
现在我将要展示一个简单的用 Spring 截取的例子。它涉及一个 EmpDAOImpl
类,该类是一个基本的数据访问对象(data access object,DAO)模式类,它实现了一个定义了名为public Map<Integer, ? extends DAOManaged> get(Integer...pks)
的方法的 DAO
接口。该接口要求我传入一组主键作为完整的对象,DAO 实现将返回对象的 Map
。这个代码中的依赖项的列表太长了,无法在此详细说明。可以肯定地说,它没有提供插装的供应,并且不使用任何种类的对象关系映射(object-relational mapping,ORM)框架。图 6 描述出了该类结构的轮廓。参见下载,获取此处提及的工件的完整源代码和文本文件。
图 6.
EmpDAO
类EmpDAOImpl
在由 spring.xml 文件配置时被部署到 Spring 容器,清单 10 中展示了该文件的一小部分:
清单 10. Spring 例子的基本容器配置
|
被部署的还有其他几个对象。这些组件通过引用它们的 Spring bean id
来描述,这些 bean id 在清单 10 中的每一个 bean 元素中都可以看得见:
tracingInterceptor
和tracingOptimizedInterceptor
:两个SpringTracingInterceptor
类型的截取程序。这个类包含了将收集到的数据跟踪到 APM 系统的ITracer
调用。
DataSource
:一个将 JDBC 连接汇合到名为runtime
的样例数据库的 JDBCDataSource
,该样例数据库将会被注入到EmpDAOImpl
。
EmployeeDAO
:我将调用的EmpDAOImpl
将作为例子的一部分调用。
empDao
和empDaoOptimized
:spring.xml 文件中定义的最后两个 bean 为 SpringProxyFactoryBean
。它们本质上是EmpDAOImpl
的代理,且每一个都各自引用一个截取程序。虽然EmpDAOImpl
可以直接访问,但是使用代理会调用截取程序并生成性能指标。清单 10 中的两个代理和截取程序说明了一些差别和配置考虑。参见优化的截取程序 侧边栏。
Spring 容器是从 SpringRunner
类中引导出来的。它还会启动一个测试循环,针对四个目标调用 DAO.get
:
EmployeeDAO
Spring bean,它代表一个未用 Spring 插装的托管 DAO。
empDao
Spring bean,它代表一个用 Spring 插装的托管的带有标准截取程序的 DAO。
empDaoOptimized
Spring bean,它代表一个用 Spring 插装的托管的带有优化截取程序的 DAO。
- 一个非 Spring 管理的
EmpDAOImpl
,与 Spring 管理的 bean 相对。
Spring 通过一个名为 org.aopalliance.intercept.MethodInterceptor
的接口实现这些类型的截取程序。要实现的方法只有一个:public Object invoke(MethodInvocation invocation)throws Throwable
。MethodInvocation
对象提供了两个关键项:带有某种上下文(即正在被截取的方法名)的跟踪程序和proceed
方法,该方法将调用向前引领到指定目标。
清单 11 展示了 SpringTracingInterceptor
类的 invoke
方法。在这种情况下是不需要interceptorName
属性的,但是我还是添加了这个属性,目的是为这个例子提供辅助的上下文。对于一个多用途的截取程序实现,跟踪程序通常都会将类名添加到跟踪上下文,这样所有被截取的类中的所有方法都会被跟踪到单独的 APM 名称空间中。
清单 11.
SpringTracingInterceptor
类的 invoke
方法
|
SpringRunner
类是这个例子的主入口点。它初始化 Spring bean 工厂,然后开始一个长的循环,从而将负载置于每一个 bean 中。清单 12 展示了该循环的代码。注意由于daoNoInterceptor
和 daoDirect
不是通过 Spring 的截取程序插装的,所以我在 SpringRunner
循环中手动添加了插装。
清单 12. 缩略的
SpringRunner
循环
|
由 APM 系统报告的结果展示出了几个类似的项。表 1 表明了来自每一个 Spring bean 的调用在测试运行中的平均运行时间:
表 1. Spring 截取程序测试运行结果
Spring bean | 平均运行时间(ms) | 最小运行时间(ms) | 最大运行时间(ms) | 计数 |
---|---|---|---|---|
直接 | 145 | 124 | 906 | 5110 |
优化的截取程序 | 145 | 125 | 906 | 5110 |
无截取程序 | 145 | 124 | 891 | 5110 |
截取程序 | 155 | 125 | 952 | 5110 |
图 7 显示了在 APM 中为测试用例创建的指标树。
图 7. Spring 截取程序在测试运行中的 APM 指标树
图 8 以图表的形式显示了该数据:
图 8. Spring 截取程序测试运行结果
很明显,这些结果相当紧密地聚集在了一起,但有一些模式显现了出来。优化的截取程序的确稍微胜过了未优化的截取程序。然而,在这个测试运行中只运行了一个线程,所以比较分析的用处并不大。在下一节中,我将详述这个测验用例并实现多个线程。
我发现造成大多数典型企业 Java 应用程序的慢性性能问题的根本原因在于数据库接口。通过 JDBC 的数据库调用或许是最普通的从 JVM 到外部服务的调用,目的是获取 JVM 中在本地不可用的数据集或资源。所以问题的起因在于数据库接口也不足为奇。逻辑上,在这种场景中可能出现问题的是数据库客户机、数据库本身或者两者兼有。然而,很多数据库的面向客户机的应用程序被许多性能反模式所困扰,包括:
- 逻辑上正确但执行很差的 SQL。
- 请求不够具体,致使检索到的数据要比所需的数据多得多。
- 频繁地检索相同的冗余数据。
- 请求基数小,导致大量数据库请求为一个逻辑结构检索数据,而不是少数的请求有效地检索同一数据集(本人的数据库访问原则是宁可一个请求返回很多行和列,也不要多个请求检索较短、较窄的数据集)。这个模式经常用于嵌套类结构,试图应用正统的封装概念(规定每一个对象管理它自己的数据检索,而不可以委托给一个公用的统一的数据请求者)的开发人员也会使用到。
我当然不会违背每一个实例中的应用程序代码和设计,在本系列的第 3 部分中,我将展示监控数据库以进行性能统计的方法。但是基本上最有效的解决方案往往在客户机一边。因此,要监控 Java 应用程序中的数据库接口性能,最好的监控目标就是 JDBC。
我将展示如何使用类包装 的概念插装 JDBC 客户机。类包装背后的理念是:目标类可以包装在一层插装代码中,后者具有与被包装的类相同的外部行为。这些场景的难题就在于怎样可以在不改变依赖结构的情况下,无缝地引入被包装的类。
在这个例子中,我利用了 JDBC 首先是一个完全由接口定义的 API 这一事实:规范包括的具体类很少,而且 JDBC 的架构排除了直接紧密耦合到特定于数据库供应商提供的类的必要性。JDBC 的具体实现是被隐式加载的,而且源代码很少直接引用这些具体类。正因为如此,您可以定义一个全新的无实现的 JDBC 驱动程序,无需将所有针对它的调用全部委托给下面的 “真正的” 驱动程序,并在过程中收集性能数据。
我构建了一个名为 WrappingJDBCDriver
的实现,它足可以展示性能数据收集和支持前面的 Spring 例子 中的 EmployeeDAO
测试用例。图 9 展示了 WrappingJDBCDriver
的总体工作方式:
图 9.
WrappingJDBCDriver
概览载入 JDBC 驱动程序的标准过程需要两项:驱动程序的类名和连接的目标数据库的 JDBC URL。驱动程序的加载程序载入驱动程序类(可能是通过调用 Class.forName(jdbcDriverClassName)
)。大多数 JDBC 驱动程序会在类加载时用 JDBC java.sql.DriverManager
注册自己。然后驱动程序加载程序将 JDBC URL 传入 JDBC 驱动程序的一个实例中,以测试驱动程序是否接受该 URL。假定 URL 被接受,加载程序就能够对驱动程序调用connect
并返回一个 java.sql.Connection
。
包装的驱动程序的类名为 org.runtimemonitoring.jdbc.WrappingJDBCDriver
。当被实例化时,它会从类路径中载入一个名为 wrapped-driver.xml 的配置文件。该文件包含插装配置项,配置项使用与目标驱动程序相关的形象化(figurative)名称索引:
- <Figurative Name>.driver.prefix:JDBC 驱动程序的真正的 JDBC URL 前缀 — 例如,
jdbc.postgresql
。
- <Figurative Name>.driver.class:JDBC 驱动程序的类名 — 例如,
org.postgresql.Driver
。
- <Figurative Name>.driver.class.path:一连串由逗号隔开的通往 JDBC 驱动程序位置的类路径入口。该项为可选项;如果不包括此项,
WrappingJDBCDriver
会使用自己的类加载程序来定位驱动程序类。
- <Figurative Name>.tracer.pattern.<Zero Based Index>:一连串的正则表达模式,用于为特定目标数据库提取跟踪类别。索引必须以 0 开始,由序列来定义跟踪类别的层次结构。
WrappingJDBCDriver
的基本前提是配置 JDBC 客户机应用程序,让它使用 “被转换的(munged)” JDBC URL,其他任何的 JDBC 驱动程序(包括以插装为目标的)都无法识别这个 JDBC URL,因此除了WrappingJDBCDriver
以外,不接受其他的 JDBC 驱动程序。WrappingJDBCDriver
将会识别被转换的 URL、内部载入目标驱动程序并将其与被转换的 URL 关联。此时,被转换的 URL 被 “解除转换”,并会被委托给内部驱动程序以获取与目标数据库的真正的连接。然后这个真正的连接被包装在WrappingJDBCConnection
中,返回给请求应用程序。munge 算法是很基本的算法,只要它能够使目标 “真正的” JDBC 驱动程序完全无法识别 JDBC URL。否则的话,真正的驱动程序可能会绕过WrappingJDBCDriver
。在这个例子中,我将 jdbc:postgresql://DBSERVER:5432/runtime
真正的 JDBC URL 转换为jdbc:!itracer!wrapped:postgresql://DBSERVER:5432/runtime
。
“真正的” 驱动程序的类名和可选类路径配置项的作用是允许 WrappingJDBCDriver
查找和载入驱动程序类,这样它就能够被包装和委托了。跟踪程序模式配置项是一组正则表达式,它们指导WrappingJDBCDriver
如何为目标数据库确定跟踪名称空间。这些表达式被应用于 “真正的” JDBC URL,并被利用,这样跟踪程序就能够给按目标数据库划分的 APM 系统提供性能指标。由于WrappingJDBCDriver
用于多个(可能是不同的)数据库,因此按目标系统库进行划分是很重要的,这样收集的指标就可以按目标数据库进行分组了。例如,一个jdbc:postgresql://DBSERVER:5432/runtime
的 JDBC URL 可能会生成一个 postgresql, runtime
的名称空间。
清单 13 展示了一个样例 wrapped-driver.xml 文件,它使用了映射到 PostgreSQL 8.3 JDBC Driver 的 postgres
的形象化的名称:
清单 13. 样例 wrapped-driver.xml 文件
|
该部分实现受到了一个名为 P6Spy 的开源产品的启发(参见 参考资料)。
为了展示 WrappingJDBCDriver
的使用方法,我创建了一个新的 EmpDAO
Spring 测试用例的加强版。新的 Spring 配置文件是 spring-jdbc-tracing.xml,新的入口点类是SpringRunnerJDBC
。该测试用例包含几个其他的对比测试点,所以为了更明确一些,一些命名约定被更新了。我还加强了测试用例使其成为多线程的,这样就会在收集的指标中创建各种有趣的行为。而且,为了使之富于变化,DAO
的参数可以随机化。
我为这个新测试用例添加了如下的跟踪加强:
- 定义了两个数据源。一个使用直接的 JDBC 驱动程序,另一个使用插装的 JDBC 驱动程序。
- 这两个数据源可以通过 Spring 代理随意访问,插装这个代理的目的是监控获取连接的运行时间。
- DAO 截取程序被加强了,它能够监控经过截取程序的并发线程的数量。
- 另外还衍生了另一个后台线程,用以轮询数据源中统计信息的使用情况。
- 所有的
WrappingJDBC
类都通过基类WrappingJDBCCore
调用了它们的大部分 跟踪程序调用。该基类除了向它的ITracer
发出一个直接传递(passthrough)外,还在数据库实例级发出了 rollup 级别的跟踪。这展示了 APM 系统中的一个常见的特性,凭此特性可以多次将低级的和特定指标跟踪到更高级的名称空间,提供汇总的指标。例如,任何对象类型中的全部 JDBC 调用都上升到数据库级别,从而为所有的数据库调用汇总平均运行时间和请求量。
清单 14 显示了 spring-jdbc-tracing.xml 文件中的新 bean 定义的实例。注意在 InstrumentedJDBC.DataSource
bean 中定义的 JDBC URL 使用了 munged 约定。
清单 14. spring-jdbc-tracing.xml 片段
|
图 10 显示了这个测试用例的 APM 指标树:
图 10. 插装的 JDBC 指标树
有了这个例子中的大量数据,就可以使用一些具体例子展示线程 BLOCK
和 WAIT
的起因。SpringRunnerJDBC
在每一个循环末尾的一个简单的语句Thread.currentThread().join(100)
周围添加了一个 ThreadInfoCapture(WAIT+BLOCK)
跟踪。依照 APM 系统,这显示为一个平均为 103 ms 的线程等待。所以把一个线程置于等待某事发生的等待状态时,它会导致一段等待时间。相反,当线程试图从DataSource
获取连接时,它在访问一个紧密同步的资源,而随着竞争连接的线程数的增加,DAO.get
方法会明确显示出增加了的线程阻塞数。
这个测试用例显示了由于添加了插装的和非插装的数据源而导致的另外几个 DAO.get
bean 实例。表 2 展示了更新了的对比场景和数值结果的列表:
表 2. 插装的 JDBC 测试运行结果
测试用例 | 平均运行时间(ms) | 最小运行时间(ms) | 最大运行时间(ms) | 计数 |
---|---|---|---|---|
直接访问,原始 JDBC | 5 | 0 | 78 | 12187 |
直接访问,插装的 JDBC | 27 | 0 | 281 | 8509 |
无截取程序 Spring bean,原始 JDBC | 15 | 0 | 125 | 12187 |
无截取程序 Spring bean,插装的 JDBC | 35 | 0 | 157 | 8511 |
插装的 Spring bean,原始 JDBC | 16 | 0 | 125 | 12189 |
插装的 Spring bean,插装的 JDBC | 36 | 0 | 250 | 8511 |
优化的插装 Spring bean,原始 JDBC | 15 | 0 | 203 | 12188 |
优化的插装 Spring bean,插装的 JDBC | 35 | 0 | 187 | 8511 |
这些结果显示出了一些有趣的模式,但有一点很明了:插装的 JDBC 显然要比原始 JDBC 慢。这一点告诫我们一定要竭尽所能改进和调试插装。在这个基本的 JDBC 插装示例中,造成性能差异的原因是使用了插入式跟踪、较长的代码路径以及创建了大量额外的对象(用来执行一系列查询)。如果我想在高性能环境中使用这个方法,则需要对这个代码库执行更多的工作!使用插装的DAO.get
bean 会有另外一个明显但不是很严重的影响。这还是要归因于反射调用中的额外开销、较长的代码路径和跟踪活动。跟踪适配器看起来好像也能使用一些调优,但事实是所有的插装都会导致某种程度的开销。图 11 显示了此测试的运行时间结果:
图 11. 插装的 JDBC 结果
本节最后将介绍上升到数据库级的线程阻塞时间。这些数据库级的统计数字代表所有收集到的每个时间间隔内数据库调用指标的总计值。运行时间为平均值,但是计数(每个时间间隔内的响应、阻塞和等待)为每个时间间隔内的总数。在这个测试用例中,总计的每个时间间隔内的平均阻塞时间为零,但是在图 12 中,您可以观察到一些 APM 可视化工具的一个特性。虽然平均值是零,但是每一个时间间隔都有一个最大(和最小)读数。在这个图中,我的 APM 显示了一个空白的零行,它既表明了平均值也表明了最大值:
图 12. JDBC 总计阻塞时间
在本文的最后一节中,我将介绍最后一个不改变源代码插装 Java 类的技巧:字节码插装。
到此为止,我向您展示的不基于源代码的插装都涉及到添加对象并经常延长代码执行路径,使它比跟踪代码本身的执行还要长。在字节码插装(BCI)技巧中,字节码被直接插入到一个 Java 类中,从而获得类最初不支持的功能。对于希望修改类而不触及源代码,或者希望在运行时动态修改类定义的开发人员,这个过程可以实现多种用途。我将向您展示如何使用 BCI 来将性能监控插装注入到类中。
不同的 BCI 框架可以以不同的方式达到这个目的。有一个简单的可以在方法级实现插装的技巧:重新命名目标方法,并使用包含跟踪指令并调用初始(重命名的)方法的原始签名插入一个新方法。一个名为 JRat 的开源 BCI 工具演示了一个技巧,该技巧专门为方法执行收集运行时间,因此要比通用的 BCI AOP 工具(参见参考资料)简短。我将一个 JRat 项目的例子压缩成了清单 15 所示的内容:
清单 15. 使用 BCI 的插装方法示例
|
实现 BCI 的两个常用策略为:
- 静态:Java 类或者类库被插装,插装的类被保存在原始类或类库的副本中。然后这些副本被部署到一个应用程序,这个应用程序对插装的类和其他的类一视同仁。
- 动态:在类载入过程中,Java 类在运行时被插装。插装的类仅暂存于内存中;JVM 结束后,它们就会消失。
动态 BCI 的优势之一就在于提供了灵活性。动态 BCI 通常都是依照一组被配置的指令(通常位于一个文件中)执行。虽然它支持热交换,但修改插装只需要升级该文件和 JVM 周期就可以了。尽管动态 BCI 很简单,但我还是要先分析静态插装过程。
在这个例子中,我将使用静态 BCI 来插装 EmpDAOImpl
类。我将使用 JBoss AOP,一个开源 BCI 框架(参见 参考资料)。
第一步:定义我要用来收集方法调用性能数据的截取程序,因为这个类将会被静态编入 EmpDAOImpl
类的字节码中。在这种情况下,JBoss 接口与我为 Spring 定义的截取程序是相同的,不同的是导入的类名。这个例子使用的截取程序是org.runtimemonitoring.aop.ITracerInterceptor
。第二步:使用与定义 EJB 3 截取程序的 jboss-aop.xml 相同的语法定义 jboss-aop.xml 文件。清单 16 显示了该文件:
清单 16. 静态 BCI jboss-aop.xml 文件
<aop>
<interceptor class="org.runtimemonitoring.aop.ITracerInterceptor" scope="PER_VM"/>
<bind
pointcut="execution(public * $instanceof{org.runtimemonitoring.spring.DAO}->get(..))">
<interceptor-ref name="org.runtimemonitoring.aop.ITracerInterceptor"/>
</bind>
</aop>
|
第三步:使用 JBoss 提供的名为 Aop Compiler(aopc)的工具来执行静态插装过程。用 Ant 脚本来完成这个过程是最简单的。清单 17 展示了 Ant 任务和编译器输出的代码片断,该片断表明我定义的切入点与目标类相匹配:
清单 17.
aopc
Ant 任务和输出
|
定义于 jboss-aop.xml 文件的切入点和 清单 16 中定义的切入点一样实现了一个专用于 AOP 的语法,实现该语法的目的是为了提供一个表达力强的通配符语言来笼统地或是明确地定义切入点目标。实质上一个方法的任一标识属性都可以从类和包名映射到注释并返回类型。在清单 17 中,我指定 org.runtimemonitoring.spring.DAO
的任何实例中的任何名为 get
的公共方法都应被作为目标。因此,由于org.runtimemonitoring.spring.EmpDAOImpl
是惟一符合这个标准的具体类,所以只有这个类被插装了。
到此为止,插装就结束了。要运行启用了这个插装的 SpringRunner
测试用例,就必须在启动 JVM 时用诸如 -Djboss.aop.path=[directory]/jboss-aop.xml
这样的 JVM 参数把 jboss-aop.xml 文件的位置定义在系统属性中。这样做的前提是您可以获得一些灵活性,因为 jboss-aop.xml 首先在构建时的静态插装中使用,然后再在运行时使用,这是由于您一开始可以插装任意一个类,但在运行时却仅能激活特定类。为SpringRunner
测试用例生成的 APM 系统指标树现在包含了 EmpDAOImpl
的指标。图 13 展示了这个树:
图 13. 静态 BCI 指标树
虽然静态插装的确可以提供某种灵活性,但是若非静态处理这些类(这很费力),就无法为插装激活它们,这一点终究是有限制性的。而且,一旦类被静态插装,它们就只能为插装时定义的截取程序激活。在下面的例子中,我将用动态 BCI 重复这个测试用例。
完成动态 BCI 的方法很多,但是使用 Java 1.5 javaagent
接口有着一个很明显的优势。在此我将在更高的层面简要描述这个接口;想要深入了解关于这个主题的知识,请参见 Andrew Wilcox 所著的文章 “构建自己的分析工具”(参见参考资料)。
javaagent
通过两个结构启用运行时动态 BCI。首先,当用 -javaagent:a JAR file
(这里的命名的 JAR 文件包含一个javaagent
实现)启动 JVM 时,JVM 调用了在一个特殊清单条目中定义的类的一个 public static void premain(String args, Instrumentation inst)
方法。正如名称premain
所暗示的,这个方法是在主 Java 应用程序入口点前被调用的,该入口点允许调用的类优先访问它,从而开始修改载入的类。关于这点它是通过注册ClassTransformer
(第二个结构)实例来实现的。ClassTransformer
接口负责从类加载程序有效截取调用并动态重写载入类的字节码。ClassTransformer
的单个方法 — transform
— 被传入要重定义的类和包含类的字节码的字节数组。然后 transform
方法实现各种修改,并返回一个包含修改的(或插装的)类的字节码的新字节数组。这种模型允许快速有效地传输类,并且与前面的一些方法不同,它不需要本地组件参与工作。
实现 SpringRunner
测试用例中的动态 BCI 有两步:首先,必须重新编译 org.runtimemonitoring.spring.EmpDAOImpl
类,将上面的测试用例中的静态 BCI 移除。其次,JVM 启动选项需要保留-Djboss.aop.path=[directory]/jboss-aop.xml
选项,并且要按如下的方式添加 javaagent
选项:
-javaagent:[directory name]/jboss-aop-jdk50.jar |
清单 18 展示了一个稍微修改过的 jboss-aop.xml 文件,它说明了动态 BCI 的优势:
清单 18. 缩减的动态 BCI jboss-aop.xml 文件
<interceptor class="org.runtimemonitoring.aop.ITracerInterceptor"
scope="PER_VM"/>
<interceptor class="org.runtimemonitoring.aop.PreparedStatementInterceptor"
scope="PER_VM"/>
<bind
pointcut="execution(public * $instanceof{org.runtimemonitoring.spring.DAO}->get(..))">
<interceptor-ref name="org.runtimemonitoring.aop.ITracerInterceptor"/>
</bind>
<bind
pointcut="execution(public * $instanceof{java.sql.Connection}->prepareStatement(..))">
<interceptor-ref name="org.runtimemonitoring.aop.ITracerInterceptor"/>
</bind>
pointcut="execution(public * $instanceof{java.sql.PreparedStatement}->executeQuery(..))">
<interceptor-ref name="org.runtimemonitoring.aop.ITracerInterceptor"/>
</bind>
|
动态 BCI 的好处之一就是可以插装任何类,包括第三方库,所以清单 18 展示了 java.sql.Connection
所有实例的插装。然而它更强大的能力是可以把任何(但可用的)截取程序应用到定义的切入点。例如,org.runtimemonitoring.aop.PreparedStatementInterceptor
是一个普通的但却与ITracerInterceptor
有些不同的截取程序。截取程序的整个库(在 AOP 用语中常指方面(aspects))都可以被开发,并可以通过开源提供商获得。这些方面库可以提供广泛的透视图,根据您想要应用的插装类型、要插装的 API 的不同,或者两者均不同,这些透视图的用途也不一样。
图 14 展示了其他指标的指标树。注意通过使用 Spring 中的 Jakarta Commons DataSource
提供者,有几个类实现了java.sql
接口。
图 14. 动态 BCI 指标树
对比一下 WrappingJDBC
插装技术和使用 BCI 插装的驱动程序的性能差异,BCI 方法最大的优点就很明了了。这点在清单 15 中有所展示,清单 15 展示了PreparedStatement.executeQuery
的对比运行时间:
图 15. BCI 对比包装性能
在这篇文章中我介绍了很多种插装 Java 应用程序的方式,目的是为了跟踪 APM 系统的性能监控数据。我所展现的这些技巧都不需要修改原始源代码。到底哪一个方法更合适要视情况而定,但是可以确定的是 BCI 已经成为了主流。APM 系统是内部开发的、开源的、商用的系统,可以用它来为 Java 性能管理实现 BCI,要想实现性能良好且高度可用的系统,APM 系统必不可少。
本系列的第三部分也是最后一部分将介绍监控 JVM 外部资源的方式,包括主机和它们的操作系统以及诸如数据库和通信系统这样的远程服务。它还总结了应用程序性能管理的其他问题,诸如数据管理、数据可视化、报告和警报。
描述 | 名字 | 大小 | 下载方法 |
---|---|---|---|
本文的示例代码 | j-rtm2.zip | 316KB | HTTP |