Java 运行时监控,第 2 部分: 编译后插装和性能监控

通过截取进行 Java 插装
截取的基本前提是通过一个截取构造和收集传入的入站与出站调用信息,对特定的调用模式进行转换 。一个基本的截取程序的实现会:
获取对入站调用请求的当前时间 。取回出站响应的当前时间 。将运行时间作为两次度量的增量计算出来 。将调用的运行时间提交给应用程序性能管理(APM)系统 。
图 1 展示了该流程:
图 1. 性能数据收集截取程序的基本流程
清晰的界限
变更管理的爱好者可能会对通过源代码实现变更和通过配置实现变更之间的差异持有争议 。诚然,“代码”、XML 和 “脚本” 之间的界限变得有些模糊了 。但是下面两个变更之间还存在明显的界限:
这两种变更之间最主要的差异是实现前滚(roll-)和后滚(roll-back)的简单性 。在某些情况下,这种差异可能在理论上说不通,或者可能低估了某些环境的复杂度或变更过程的严格性 。
很多诸如 Java和(Java EE)这样的 Java 框架都包括对截取栈的核心支持 , 服务的调用可以在截取栈中通过一系列预处理和后处理组件来进行传递 。有了这些栈就可以很好地将插装注入到执行路径中,这样做的好处有二:第一,无需修改目标类的源代码;第二,只要将截取程序类插入到 JVM 的类路径中并修改组件的部署描述符,这样就把插装截取程序插入到了执行流程中 。
截取的核心指标
截取程序所收集的一个典型的指标就是运行时间 。其他的指标同样适合截取模式 。我将介绍支持这些指标的接口的两个新的方面,所以在这里我要转下话题,先简要论述一下这些指标 。
使用截取程序时需要收集的典型指标有:
还有两个指标可以选择,但它们的作用有限,而且收集成本会高一些:
为了澄清指标的收集方法 , 清单 1 快速回顾了基于源代码的插装 。在这个例子中,我针对ethod方法实现了大量插装 。
清单 1. 实现大量插装的方法
protected static AtomicInteger concurrency = new AtomicInteger();..for(int x = 0; x < loops; x++) {tracer.startThreadInfoCapture(CPU+BLOCK+WAIT);int c = concurrency.incrementAndGet();tracer.trace(c, "Source Instrumentation", "heavilyInstrumentedMethod", "Concurrent Invocations");try {// ===================================//Here is the method// ===================================heavilyInstrumentedMethod(factor);// ===================================tracer.traceIncident("Source Instrumentation", "heavilyInstrumentedMethod", "Responses");} catch (Exception e) {tracer.traceIncident("Source Instrumentation", "heavilyInstrumentedMethod", "Exceptions");} finally {tracer.endThreadInfoCapture("Source Instrumentation", "heavilyInstrumentedMethod");c = concurrency.decrementAndGet();tracer.trace(c, "Source Instrumentation", "heavilyInstrumentedMethod", "Concurrent Invocations");tracer.traceIncident("Source Instrumentation", "heavilyInstrumentedMethod", "Invocations");}try { Thread.sleep(200); } catch (InterruptedException e) { }}
清单 1引入了两个新的构造:
并发性只有在插装目标是多线程的或者共用的情况下可用,但是它是非常重要的指标 , 这一点我将在稍后介绍(EJB)截取程序时进一步阐述 。EJB 截取程序是我接下来要论述的几个基于截取的插装示例的第一个,借鉴了清单 1中查看的跟踪方法 。
EJB 3 截取程序
发布了 EJB 3 后,截取程序就成了 Java EE 架构中的标准功能(有些 Java 应用服务器支持了 EJB 截取程序一段时间) 。大多数 Java EE 应用服务器的确提供了性能指标,报告有关诸如 EJB 这样的主要组件,但是仍然需要实现自己的性能指标 , 因为:
虽然如此,根据您的 APM 系统和应用服务器实现的不同 , 有些工作可能不用您再亲历亲为了 。例如,? PMI 通过 Java 管理扩展(Java , JMX)公开了服务器指标(参见参考资料) 。即使您的 APM 供应商没有提供自动读取这个数据的功能,读完本篇文章之后您也会知道如何自行读取 。
在下一个例子中,我将向一个称为org.aa4h.ejb.的无状态会话的上下文 bean 中注入一个截取程序 。EJB 3 截取程序的要求和依赖性都是相当小的:
清单 2 展示了样例 EJB 的截取方法:
清单 2. EJB 3 截取程序方法
@AroundInvokepublic Object trace(InvocationContext ctx) throws Exception {Object returnValue = http://www.kingceram.com/post/null;int concur = concurrency.incrementAndGet();tracer.trace(concur,"EJB Interceptors", ctx.getTarget().getClass().getName(), ctx.getMethod().getName(),"Concurrent Invocations");try {tracer.startThreadInfoCapture(CPU + BLOCK + WAIT);// ===================================//This is the target.// ===================================returnValue = http://www.kingceram.com/post/ctx.proceed();// ===================================tracer.traceIncident("EJB Interceptors", ctx.getTarget().getClass().getName(), ctx.getMethod().getName(), "Responses");concur = concurrency.decrementAndGet();tracer.trace(concur, "EJB Interceptors", ctx.getTarget().getClass().getName(), ctx.getMethod().getName(),"Concurrent Invocations");return returnValue;} catch (Exception e) {tracer.traceIncident("EJB Interceptors", ctx.getTarget().getClass().getName(), ctx.getMethod().getName(), "Exceptions");throw e;} finally {tracer.endThreadInfoCapture("EJB Interceptors", ctx.getTarget().getClass().getName(), ctx.getMethod().getName());tracer.traceIncident("EJB Interceptors", ctx.getTarget().getClass().getName(), ctx.getMethod().getName(), "Invocations");}}
如清单 1一样,清单 2包含一个大的插装集 , 一般不推荐使用,此处仅作为一个例子使用 。清单 2 中有以下几点需要注意:
注意到这点是很重要的,因为该截取程序可以应用于很多不同的 EJB , 因此要截取什么类型的调用是无法预知的 。拥有一个可以从截取程序内部访问的元数据源是至关重要的:没有这个源的话,只能得到很少的关于被截取调用的信息;您的指标可以展示出很多有趣的趋势,但却无法明确它们所指的是哪个操作 。
从插装的角度看,这些截取程序最有用之处在于您可以通过修改部署描述符而将它们应用于 EJB 。清单 3 展示了样例 EJB 的 ejb-jar.xml 部署描述符:
清单 3. EJB 3 截取程序部署描述符
org.runtimemonitoring.interceptors.ejb.EJBTracingInterceptortraceAA4H-HibernateServiceorg.runtimemonitoring.interceptors.ejb.EJBTracingInterceptor
正如我在前面所提到过的,插装截取程序对于基于上下文或者基于范围/阈值的跟踪是有用的 。而中的 EJB 调用参数值是可用的,这加强了插装截取程序的作用 。这些值可以用于跟踪范围或其他上下文的复合名称 。考虑一下包含有( 、 )方法的org.myco..类中的 EJB 调用 。EJB 接受一个命令 , 然后远程调用根据域识别的服务器 。在这个场景中,区域服务器遍布于一个广泛的地理区域,每一个区域服务都有自己的 WAN 特性 。这里呈现的模式与第 1 部分中的 - 例子类似 , 这是因为如果没有明确命令到底被分配到哪一个区域的话,确定一个 EJB 调用的运行时间是很困难的 。您可能已经预料到,从距离一个洲远的区域调用的运行时间要比从隔壁调用的运行时间要长的多 。但是您是可以从参数确定区域的,因此您只需将区域代码添加到跟踪复合名称并按区域划分性能数据,如清单 4 所示:
清单 4. EJB 3 截取程序实现上下文跟踪
String[] prefix = null;if(ctx.getTarget().getClass().getName().equals("org.myco.regional.RemoteManagement") &&ctx.getMethod().getName().equals("issueRemoteOperation")) {prefix = new String[]{"RemoteManagement",ctx.getParameters()[0].toString(),"issueRemoteOperation"};}// Now add prefix to the tracing compound name
过滤器截取程序
JavaAPI 提供了一个叫做过滤器()的构造 , 它与 EJB 3 截取程序非常类似,含有无需源代码的注入和元数据可用性 。清单 5 展示了一个过滤器的方法,带有缩略了的插装 。指标的复合名由过滤器类名和请求的统一资源标识符(,URI)构建:
清单 5.过滤器截取程序方法
public void doFilter(ServletRequest req, ServletResponse resp,FilterChain filterChain) throws IOException, ServletException {String uri = null;try {uri = ((HttpServletRequest)req).getRequestURI();tracer.startThreadInfoCapture(CPU + BLOCK + WAIT);// ===================================//This is the target.// ===================================filterChain.doFilter(req, resp);// ===================================} catch (Exception e) {} finally {tracer.endThreadInfoCapture("Servlets", getClass().getName(), uri);}}
清单 6 展示了清单 5 的过滤器的 web.xml 部署描述符的相关片断:
清单 6.过滤器部署描述符
ITraceFilterITraceFilterorg.myco.http.ITraceFilterITraceFilter/*
EJB 客户端截取程序与上下文传递
前面的例子侧重于服务器端组件 , 但一些诸如客户端截取这样的插装实现方法也是存在的 。Ajax 客户机可以注册度量运行时间的性能监听器,并可以在下一个请求的参数列表末尾承载请求的 URI(对于复合名称)和运行时间 。有些 Java EE 服务器,如 JBoss,允许使用客户端的截取程序,本质上这些截取程序与 EJB 3 截取程序所作的工作相同,并且它们也能够承载下一个请求中的度量提交 。
监控中的客户端通常都会被忽视 。所以下次听到用户抱怨您的应用程序太慢时 , 不要因为服务器端的监控确保服务器端是良好的就无视这些抱怨 。客户端的插装可以确保您所度量的正是用户所体验的,它们可能不会总是与服务器端的指标一致 。
一些 Java EE 实现支持的客户端截取程序被实例化并绑定在 EJB 的客户端 。这意味着如果一个远程客户机通过远程方法调用(,RMI)协议调用服务器上的 EJB,则也可以从远程客户机无缝收集到性能数据 。在远程调用的任一端实现截取程序类都会实现在两者间传递上下文的能力 , 从而获取额外的性能数据 。
下面的例子展示了一对截取程序 , 它们共享数据并获得传输时间(传送请求和响应的运行时间)以及客户机方面对服务器的远程请求的响应时间 。该例子使用了 JBoss 应用服务器的客户端和服务器端的 EJB 3 截取程序专有的实现 。
这对截取程序通过在相同负载内承载上下文数据 , 将上下文数据作为 EJB 调用传递到同一个调用 。上下文数据包括:
调用参数被当作一个栈结构,上下文数据通过这个结构进出参数 。上下文数据由客户端截取程序放入该调用中,再由服务器端截取程序取出 , 然后传入到 EJB 服务器 stub 。数据返回时则按此过程的逆向过程传递 。图 3 展示了这个流程:
图3. 客户机和服务器 EJB 截取程序的数据流
为这个例子构建截取程序需要为客户机和服务器实现org.jboss.aop..接口 。该接口有一个重要的方法:
public abstract java.lang.Object invoke(org.jboss.aop.joinpoint.Invocation invocation) throws java.lang.Throwable
这个方法引入了调用封装的理念,根据这个理念,一个方法的执行被封装成为一个独立对象,它表示以下内容:
接着这个对象可以被继续传递,直至传递到调用方,调用方解组调用对象并针对端点目标对象实现动态执行 。
客户端截取程序将当前请求时间添加到调用上下文 , 而服务器端截取程序则负责添加接收请求的时间戳和发送响应的时间戳 。或者,服务器可以获得客户机请求,由客户机计算出请求和来回传输的总运行时间 。每种情况的计算方法为:
清单 7 展示了客户端截取程序的方法:
清单 7. 客户端截取程序的方法
/*** The interception invocation point.* @param invocation The encapsulated invocation.* @return The return value of the invocation.* @throws Throwable* @see org.jboss.aop.advice.Interceptor#invoke(org.jboss.aop.joinpoint.Invocation)*/public Object invoke(Invocation invocation) throws Throwable {if(invocation instanceof MethodInvocation) {getInvocationContext().put(CLIENT_REQUEST_TIME, System.currentTimeMillis());Object returnValue = http://www.kingceram.com/post/clientInvoke((MethodInvocation)invocation);long clientResponseTime = System.currentTimeMillis();Map context = getInvocationContext();long clientRequestTime = (Long)context.get(CLIENT_REQUEST_TIME);long serverReceiveTime = (Long)context.get(SERVER_RECEIVED_TIME);long serverResponseTime = (Long)context.get(SERVER_RESPOND_TIME);long transportUp = serverReceiveTime-clientRequestTime;long transportDown = serverResponseTime-clientResponseTime;long totalElapsed = clientResponseTime-clientRequestTime;String methodName = ((MethodInvocation)invocation).getActualMethod().getName();String className = ((MethodInvocation)invocation).getActualMethod().getDeclaringClass().getSimpleName();ITracer tracer = TracerFactory.getInstance();tracer.trace(transportUp,"EJB Client", className, methodName, "Transport Up", transportUp);tracer.trace(transportDown, "EJB Client", className, methodName, "Transport Down", transportDown);tracer.trace(totalElapsed, "EJB Client", className, methodName, "Total Elapsed", totalElapsed);return returnValue;} else {return invocation.invokeNext();}}
JBoss EJB 3 截取程序
JBoss 的 EJB 2 截取程序架构内置了传递任意负载的能力;它的目标是在 EJB 3 中交付,但效果不是很好 。所以我实现了截取程序,从而将上下文负载作为请求的附加调用参数来传递 。并且将响应对象编组为一个[2]数组;第一项是 “real” 结果,第二项为上下文 。在这两种情况下,被编组的对象都被对应的截取程序解组,所以请求方和服务端点都能获得它们所需要的类型 。
服务器端截取程序在概念上是类似的,不同的是为了避免使例子过于复杂,它使用了本地线程来检查— 相同的请求处理线程在同一远程调用中不只一次调用相同的 EJB(和截取程序) 。该截取程序忽略了除第一个请求之外的所有请求的跟踪和上下文处理 。清单 8 展示了服务器端截取程序的方法:
清单 8. 服务器端截取程序的方法
/*** The interception invocation point.* @param invocation The encapsulated invocation.* @return The return value of the invocation.* @throws Throwable* @see org.jboss.aop.advice.Interceptor#invoke(org.jboss.aop.joinpoint.Invocation)*/public Object invoke(Invocation invocation) throws Throwable {Boolean reentrant = reentrancy.get();if((reentrant==null || reentrant==false)&& invocation instanceof MethodInvocation) {try {long currentTime = System.currentTimeMillis();MethodInvocation mi = (MethodInvocation)invocation;reentrancy.set(true);Map context = getInvocationContext(mi);context.put(SERVER_RECEIVED_TIME, currentTime);Object returnValue = http://www.kingceram.com/post/serverInvoke((MethodInvocation)mi);context.put(SERVER_RESPOND_TIME, System.currentTimeMillis());return addContextReturnValue(returnValue);} finally {reentrancy.set(false);}} else {return invocation.invokeNext();}}
JBoss 通过面向方面的编程(-,AOP)(参见参考资料)技术来应用截取程序,该技术读取名为 ejb3--aop.xml 的指令文件并根据其中定义的指令应用截取程序 。JBoss 使用这种 AOP 技术在运行时将 Java EE 核心规则应用于 EJB 3 类 。因此,除了性能监控截取程序之外 , 该文件还包含了关于事务管理、安全性和持久性这样的指令 。客户端指令则相当简单明了 。它们被简单地定义为包含一系列截取程序类名的stack元素 。每一个在此定义的类名同时都有资格作为或截取程序,这表明每一个 EJB 实例都应该共享一个截取程序实例或者具有各自的非共享实例 。针对性能监控截取程序的目标,则应该确定此项配置 , 无论截取程序代码是否是线程安全的 。如果截取程序代码能够安全地并行处理多个线程,那么使用策略更有效,而对于线程安全但是效率较低的策略 , 则可以使用 。
服务器端的截取程序的配置要相对复杂一些 。截取程序要依照一组语法模式和用 XML 定义的过滤器来应用 。如果所关注的特定的 EJB 方法与定义的模式相符的话,那么为该模式定义的截取程序就会被应用 。服务器端截取程序能够通过进一步细化定义来将部署的 EJB 的特定子集定为目标 。对于客户端截取程序,您可以通过创建一个新的特定于目标 bean 的stack name来实现自定义栈 。而在服务器端,自定义栈可以在一个新的中进行定义 。个别 EJB 的关联客户机stack name和服务器栈可以在 EJB 的注释中指定 。或者,如果您不能或是不想修改源代码的话,这些信息可以在 EJB 的部署描述符中指定或者跳过 。清单 9 展示了一个删减的用于此例的 ejb3--aop.xml 文件:
清单 9. 经过删减的 EJB 3 AOP 配置
............
这种性能数据收集方法可以一箭双标 。首先,它可以告诉您从客户机的角度看,一个 EJB 目前的性能是多少 。再者,如果性能滞后的话 , 传输时间可以很好地指明是否是由客户机和服务器间的网络连接速度缓慢而导致的 。图 4 展示了总运行时间和上/下传输指标,该指标是从客户机度量的,度量方法是在客户机和服务器之间使用一个人为减缓的网络连接来突出显示传输时间:
图 4. 上下文客户机截取程序性能指标
使用客户端截取程序时,客户机截取程序类本身必须处于客户机应用程序的类路径中 。或者一定要启用从服务器载入的远程类,这样才能够在启动时将客户端截取程序及其依赖项下载到客户机上 。如果您的客户机系统时钟不是完全与服务器系统时钟同步的话,您就会得到与两个时钟的时间差大小成正比的特殊结果 。
中的截取程序
尽管 Java EE 提供丰富的正交无缝截取方法,但很多流行的非 Java EE 容器同样支持隐式的和显式的截取 。我之所以使用容器这个词是想暗指某种使用或鼓励使用松散耦合的框架 。只要不使用紧密耦合,就能够实现截取 。这种类型的框架通常称为依赖注入或者 of (IoC)架构 。它们让您能够在外部定义个别组件如何 “粘合” 在一起,而不是硬编码组件,从而实现组件间的之间通信 。我将使用流行的 IoC 框架(参见参考资料)中的跟踪截取程序来查看性能数据的收集,以此结束对截取的讨论 。
让您能够使用普通初始 Java 对象(Plain Old Java ,POJO)来构建应用程序 。POJO 仅包含业务逻辑,而框架添加了构建企业应用程序所需的内容 。如果在最初编写 Java 应用程序时没有考虑插装的话,的分层架构是很有用处的 。虽然将应用程序架构备份到并非一无是处 , 但除一系列的 Java EE 和 AOP 集成外,还有的 POJO 管理特性足以将普通硬连接的 Java 类委托给的容器管理功能 。您可以通过截取添加性能插装,无需修改目标类的源代码 。
通常被描述为 IoC 容器,这是因为它颠倒了 Java 应用程序的传统拓扑结构 。在传统的拓扑中 , 会有一个中心的程序或控制线程按照程序载入全部需要的组件和依赖项 。容器用 IoC 载入几个组件 , 并依照外部配置管理组件间的依赖项 。此处的依赖项管理称为依赖项注入,因为依赖项(如 )是通过容器注入组件的;组件无需寻找到它们自己的依赖项 。为了进行插装,容器的配置可以轻易修改 , 从而将截取程序插入到这些组件间的 “结缔组织” 中 。图 5 解释了该概念:
图 5.和截取概观
现在我将要展示一个简单的用截取的例子 。它涉及一个类,该类是一个基本的数据访问对象(data,DAO)模式类 , 它实现了一个定义了名为 Map get(...pks)的方法的DAO接口 。该接口要求我传入一组主键作为完整的对象,DAO 实现将返回对象的Map 。这个代码中的依赖项的列表太长了,无法在此详细说明 。可以肯定地说,它没有提供插装的供应,并且不使用任何种类的对象关系映射(-,ORM)框架 。图 6 描述出了该类结构的轮廓 。参见下载,获取此处提及的工件的完整源代码和文本文件 。
图 6.类
在由 .xml 文件配置时被部署到容器,清单 10 中展示了该文件的一小部分:
清单 10.例子的基本容器配置
truetrue
被部署的还有其他几个对象 。这些组件通过引用它们的id来描述,这些 bean id 在清单 10 中的每一个 bean 元素中都可以看得见:
优化的截取程序
标准截取程序和清单 10中的优化的截取程序之间的差异就在于优化的代理的附加的
true
属性 。没有这个属性,代理使用java.lang..Proxy的反射来调用截取程序 。使用了这个选项,就可以使用名为CGLIB的字节码插装库为一个直接(非反射的)调用者创建字节码 。在这样的情况下,优化了字节码的解决方案通常会比动态代理的性能好,但是由于最近的 JVM 中的 Java 反射有了相当大的进步,因此情况大为不同 。
容器是从类中引导出来的 。它还会启动一个测试循环,针对四个目标调用DAO.get:
通过一个名为org...的接口实现这些类型的截取程序 。要实现的方法只有一个:( )。对象提供了两个关键项:带有某种上下文(即正在被截取的方法名)的跟踪程序和方法,该方法将调用向前引领到指定目标 。
清单 11 展示了ptor类的方法 。在这种情况下是不需要属性的 , 但是我还是添加了这个属性,目的是为这个例子提供辅助的上下文 。对于一个多用途的截取程序实现 , 跟踪程序通常都会将类名添加到跟踪上下文,这样所有被截取的类中的所有方法都会被跟踪到单独的 APM 名称空间中 。
清单 11.ptor类的方法
public Object invoke(MethodInvocation invocation) throws Throwable {String methodName = invocation.getMethod().getName();tracer.startThreadInfoCapture(WAIT+BLOCK);Object returnValue = http://www.kingceram.com/post/invocation.proceed();tracer.endThreadInfoCapture("Spring", "DAO", interceptorName, methodName);tracer.traceIncident("Spring", "DAO", interceptorName, methodName, "Responses Per Interval");return returnValue;}
类是这个例子的主入口点 。它初始化bean 工厂 , 然后开始一个长的循环,从而将负载置于每一个 bean 中 。清单 12 展示了该循环的代码 。注意由于和不是通过的截取程序插装的,所以我在循环中手动添加了插装 。
清单 12. 缩略的循环
Map emps = null;DAO daoIntercepted = (DAO) bf.getBean("empDao");DAO daoOptimizedIntercepted = (DAO) bf.getBean("empDaoOptimized");DAO daoNoInterceptor = (DAO) bf.getBean("EmployeeDAO");DataSource dataSource = (DataSource) bf.getBean("DataSource");DAO daoDirect = new EmpDAOImpl();// Not Spring Managed, so dependency is set manuallydaoDirect.setDataSource(dataSource);for(int i = 0; i < 100000; i++) {emps = daoIntercepted.get(empIds);log("(Interceptor) Acquired ", emps.size(), " Employees");emps = daoOptimizedIntercepted.get(empIds);log("(Optimized Interceptor) Acquired ", emps.size(), "Employees");tracer.startThreadInfoCapture(WAIT+BLOCK);emps = daoNoInterceptor.get(empIds);log("(Non Intercepted) Acquired ", emps.size(), " Employees");tracer.endThreadInfoCapture("Spring", "DAO","No Interceptor DAO", "get");tracer.traceIncident("Spring", "DAO", "No Interceptor DAO", "get", "Responses Per Interval");tracer.startThreadInfoCapture(WAIT+BLOCK);emps = daoDirect.get(empIds);log("(Direct) Acquired ", emps.size(), " Employees");tracer.endThreadInfoCapture("Spring", "DAO","Direct", "get");tracer.traceIncident("Spring", "DAO", "Direct","get", "Responses Per Interval");}
由 APM 系统报告的结果展示出了几个类似的项 。表 1 表明了来自每一个bean 的调用在测试运行中的平均运行时间:
表 1.截取程序测试运行结果bean 平均运行时间(ms) 最小运行时间(ms) 最大运行时间(ms) 计数
直接
145
124
906
5110
优化的截取程序
145
125
906
5110
无截取程序
145
124
891
5110
截取程序
155
125
952
5110
图 7 显示了在 APM 中为测试用例创建的指标树 。
图 7.截取程序在测试运行中的 APM 指标树
图 8 以图表的形式显示了该数据:
图 8.截取程序测试运行结果
很明显,这些结果相当紧密地聚集在了一起,但有一些模式显现了出来 。优化的截取程序的确稍微胜过了未优化的截取程序 。然而,在这个测试运行中只运行了一个线程,所以比较分析的用处并不大 。在下一节中,我将详述这个测验用例并实现多个线程 。
通过类包装实现的 JDBC 插装
我发现造成大多数典型企业 Java 应用程序的慢性性能问题的根本原因在于数据库接口 。通过 JDBC 的数据库调用或许是最普通的从 JVM 到外部服务的调用,目的是获取 JVM 中在本地不可用的数据集或资源 。所以问题的起因在于数据库接口也不足为奇 。逻辑上,在这种场景中可能出现问题的是数据库客户机、数据库本身或者两者兼有 。然而,很多数据库的面向客户机的应用程序被许多性能反模式所困扰,包括:
我当然不会违背每一个实例中的应用程序代码和设计 , 在本系列的第 3 部分中,我将展示监控数据库以进行性能统计的方法 。但是基本上最有效的解决方案往往在客户机一边 。因此,要监控 Java 应用程序中的数据库接口性能,最好的监控目标就是 JDBC 。
我将展示如何使用类包装的概念插装 JDBC 客户机 。类包装背后的理念是:目标类可以包装在一层插装代码中,后者具有与被包装的类相同的外部行为 。这些场景的难题就在于怎样可以在不改变依赖结构的情况下,无缝地引入被包装的类 。
在这个例子中,我利用了 JDBC 首先是一个完全由接口定义的 API 这一事实:规范包括的具体类很少 , 而且 JDBC 的架构排除了直接紧密耦合到特定于数据库供应商提供的类的必要性 。JDBC 的具体实现是被隐式加载的 , 而且源代码很少直接引用这些具体类 。正因为如此,您可以定义一个全新的无实现的 JDBC 驱动程序 , 无需将所有针对它的调用全部委托给下面的 “真正的” 驱动程序,并在过程中收集性能数据 。
我构建了一个名为的实现,它足可以展示性能数据收集和支持前面的 例子中的测试用例 。图 9 展示了的总体工作方式:
图 9.概览
载入 JDBC 驱动程序的标准过程需要两项:驱动程序的类名和连接的目标数据库的 JDBC URL 。驱动程序的加载程序载入驱动程序类(可能是通过调用Class.()) 。大多数 JDBC 驱动程序会在类加载时用 .sql.注册自己 。然后驱动程序加载程序将 JDBC URL 传入 JDBC 驱动程序的一个实例中,以测试驱动程序是否接受该 URL 。假定 URL 被接受 , 加载程序就能够对驱动程序调用并返回一个java.sql. 。
包装的驱动程序的类名为org..jdbc. 。当被实例化时 , 它会从类路径中载入一个名为 -.xml 的配置文件 。该文件包含插装配置项 , 配置项使用与目标驱动程序相关的形象化()名称索引:
的基本前提是配置 JDBC 客户机应用程序,让它使用 “被转换的()” JDBC URL,其他任何的 JDBC 驱动程序(包括以插装为目标的)都无法识别这个 JDBC URL,因此除了以外,不接受其他的 JDBC 驱动程序 。将会识别被转换的 URL、内部载入目标驱动程序并将其与被转换的 URL 关联 。此时 , 被转换的 URL 被 “解除转换”,并会被委托给内部驱动程序以获取与目标数据库的真正的连接 。然后这个真正的连接被包装在on中 , 返回给请求应用程序 。munge 算法是很基本的算法,只要它能够使目标 “真正的” JDBC 驱动程序完全无法识别 JDBC URL 。否则的话,真正的驱动程序可能会绕过 。在这个例子中,我将jdbc:://:5432/真正的 JDBC URL 转换为jdbc:!!:://:5432/ 。
“真正的” 驱动程序的类名和可选类路径配置项的作用是允许查找和载入驱动程序类 , 这样它就能够被包装和委托了 。跟踪程序模式配置项是一组正则表达式,它们指导如何为目标数据库确定跟踪名称空间 。这些表达式被应用于 “真正的” JDBC URL,并被利用,这样跟踪程序就能够给按目标数据库划分的 APM 系统提供性能指标 。由于用于多个(可能是不同的)数据库,因此按目标系统库进行划分是很重要的,这样收集的指标就可以按目标数据库进行分组了 。例如,一个jdbc:://:5432/的 JDBC URL 可能会生成一个, 的名称空间 。
清单 13 展示了一个样例 -.xml 文件 , 它使用了映射到8.3 JDBC的的形象化的名称:
清单 13. 样例 -.xml 文件
jdbc:postgresql:org.postgresql.DriverC:\Postgres\psqlJDBC\postgresql-8.3-603.jdbc3.jar:([a-zA-Z0-9]+):.*\/\/.*\/([\S]+)
该部分实现受到了一个名为 P6Spy 的开源产品的启发(参见参考资料) 。
为了展示的使用方法,我创建了一个新的 测试用例的加强版 。新的配置文件是 -jdbc-.xml,新的入口点类是 。该测试用例包含几个其他的对比测试点,所以为了更明确一些,一些命名约定被更新了 。我还加强了测试用例使其成为多线程的,这样就会在收集的指标中创建各种有趣的行为 。而且 , 为了使之富于变化 , DAO的参数可以随机化 。
我为这个新测试用例添加了如下的跟踪加强:
清单 14 显示了 -jdbc-.xml 文件中的新 bean 定义的实例 。注意在. 中定义的 JDBC URL 使用了约定 。
清单 14. -jdbc-.xml 片段
truetruetruetrue
图 10 显示了这个测试用例的 APM 指标树:
图 10. 插装的 JDBC 指标树
有了这个例子中的大量数据 , 就可以使用一些具体例子展示线程BLOCK和WAIT的起因 。在每一个循环末尾的一个简单的语句.().join(100)周围添加了一个(WAIT+BLOCK)跟踪 。依照 APM 系统,这显示为一个平均为 103 ms 的线程等待 。所以把一个线程置于等待某事发生的等待状态时,它会导致一段等待时间 。相反,当线程试图从获取连接时,它在访问一个紧密同步的资源,而随着竞争连接的线程数的增加,DAO.get方法会明确显示出增加了的线程阻塞数 。
这个测试用例显示了由于添加了插装的和非插装的数据源而导致的另外几个DAO. 实例 。表 2 展示了更新了的对比场景和数值结果的列表:
表 2. 插装的 JDBC 测试运行结果测试用例 平均运行时间(ms) 最小运行时间(ms) 最大运行时间(ms) 计数
直接访问,原始 JDBC
78
12187
直接访问 , 插装的JDBC
27
281
8509
无截取程序bean,原始 JDBC
15
125
12187
无截取程序bean,插装的 JDBC
35
157
8511
插装的bean,原始 JDBC
16
125
12189
插装的bean,插装的 JDBC
36
250
8511
优化的插装bean,原始 JDBC
15
203
12188
优化的插装bean,插装的 JDBC
35
187
8511
这些结果显示出了一些有趣的模式,但有一点很明了:插装的 JDBC 显然要比原始 JDBC 慢 。这一点告诫我们一定要竭尽所能改进和调试插装 。在这个基本的 JDBC 插装示例中,造成性能差异的原因是使用了插入式跟踪、较长的代码路径以及创建了大量额外的对象(用来执行一系列查询) 。如果我想在高性能环境中使用这个方法,则需要对这个代码库执行更多的工作!使用插装的DAO. 会有另外一个明显但不是很严重的影响 。这还是要归因于反射调用中的额外开销、较长的代码路径和跟踪活动 。跟踪适配器看起来好像也能使用一些调优 , 但事实是所有的插装都会导致某种程度的开销 。图 11 显示了此测试的运行时间结果:
图 11. 插装的 JDBC 结果
本节最后将介绍上升到数据库级的线程阻塞时间 。这些数据库级的统计数字代表所有收集到的每个时间间隔内数据库调用指标的总计值 。运行时间为平均值,但是计数(每个时间间隔内的响应、阻塞和等待)为每个时间间隔内的总数 。在这个测试用例中,总计的每个时间间隔内的平均阻塞时间为零,但是在图 12 中,您可以观察到一些 APM 可视化工具的一个特性 。虽然平均值是零,但是每一个时间间隔都有一个最大(和最?。┒潦?。在这个图中,我的 APM 显示了一个空白的零行,它既表明了平均值也表明了最大值:
图 12. JDBC 总计阻塞时间
在本文的最后一节中,我将介绍最后一个不改变源代码插装 Java 类的技巧:字节码插装 。
字节码插装
到此为止,我向您展示的不基于源代码的插装都涉及到添加对象并经常延长代码执行路径,使它比跟踪代码本身的执行还要长 。在字节码插装(BCI)技巧中,字节码被直接插入到一个 Java 类中,从而获得类最初不支持的功能 。对于希望修改类而不触及源代码,或者希望在运行时动态修改类定义的开发人员 , 这个过程可以实现多种用途 。我将向您展示如何使用 BCI 来将性能监控插装注入到类中 。
不同的 BCI 框架可以以不同的方式达到这个目的 。有一个简单的可以在方法级实现插装的技巧:重新命名目标方法,并使用包含跟踪指令并调用初始(重命名的)方法的原始签名插入一个新方法 。一个名为 JRat 的开源 BCI 工具演示了一个技巧,该技巧专门为方法执行收集运行时间,因此要比通用的 BCI AOP 工具(参见参考资料)简短 。我将一个 JRat 项目的例子压缩成了清单 15 所示的内容:
清单 15. 使用 BCI 的插装方法示例
////The Original Method//public class MyClass {public Object doSomething() {// do something}}////The New and Old Method//public class MyClass {private static final MethodHandler handler = HandlerFactory.getHandler(...);// The instrumented methodpublic Object doSomething() {handler.onMethodStart(this);long startTime = Clock.getTime();try {Object result = real_renamed_doSomething(); // call your methodhandler.onMethodFinish(this, Clock.getTime() - startTime, null);} catch(Throwable e) {handler.onMethodFinish(this, Clock.getTime() - startTime, e);throw e;}}// The renamed original methodpublic Object real_renamed_doSomething() {// do something}}
实现 BCI 的两个常用策略为:
动态 BCI 的优势之一就在于提供了灵活性 。动态 BCI 通常都是依照一组被配置的指令(通常位于一个文件中)执行 。虽然它支持热交换,但修改插装只需要升级该文件和 JVM 周期就可以了 。尽管动态 BCI 很简单,但我还是要先分析静态插装过程 。
静态 BCI
在这个例子中,我将使用静态 BCI 来插装类 。我将使用 JBoss AOP,一个开源 BCI 框架(参见参考资料) 。
第一步:定义我要用来收集方法调用性能数据的截取程序,因为这个类将会被静态编入类的字节码中 。在这种情况下,JBoss 接口与我为定义的截取程序是相同的,不同的是导入的类名 。这个例子使用的截取程序是org..aop. 。第二步:使用与定义 EJB 3 截取程序的 jboss-aop.xml 相同的语法定义 jboss-aop.xml 文件 。清单 16 显示了该文件:
清单 16. 静态 BCI jboss-aop.xml 文件
get(..))">
第三步:使用 JBoss 提供的名为 Aop (aopc)的工具来执行静态插装过程 。用 Ant 脚本来完成这个过程是最简单的 。清单 17 展示了 Ant 任务和编译器输出的代码片断,该片断表明我定义的切入点与目标类相匹配:
清单 17. 任务和输出
Output:[aopc] [trying to transform] org.runtimemonitoring.spring.EmpDAOImpl[aopc] [debug] javassist.CtMethod@955a8255[public transient get([Ljava/lang/Integer;)Ljava/util/Map;] matches pointcut:execution(public * $instanceof{org.runtimemonitoring.spring.DAO}->get(..))
定义于 jboss-aop.xml 文件的切入点和清单 16中定义的切入点一样实现了一个专用于 AOP 的语法,实现该语法的目的是为了提供一个表达力强的通配符语言来笼统地或是明确地定义切入点目标 。实质上一个方法的任一标识属性都可以从类和包名映射到注释并返回类型 。在清单 17中,我指定org...DAO的任何实例中的任何名为get的公共方法都应被作为目标 。因此,由于org...是惟一符合这个标准的具体类,所以只有这个类被插装了 。
到此为止,插装就结束了 。要运行启用了这个插装的测试用例,就必须在启动 JVM 时用诸如-.aop.path=[]/jboss-aop.xml这样的 JVM 参数把 jboss-aop.xml 文件的位置定义在系统属性中 。这样做的前提是您可以获得一些灵活性,因为 jboss-aop.xml 首先在构建时的静态插装中使用 , 然后再在运行时使用 , 这是由于您一开始可以插装任意一个类 , 但在运行时却仅能激活特定类 。为测试用例生成的 APM 系统指标树现在包含了的指标 。图 13 展示了这个树:
图 13. 静态 BCI 指标树
虽然静态插装的确可以提供某种灵活性,但是若非静态处理这些类(这很费力),就无法为插装激活它们,这一点终究是有限制性的 。而且,一旦类被静态插装,它们就只能为插装时定义的截取程序激活 。在下面的例子中,我将用动态 BCI 重复这个测试用例 。
动态 BCI
完成动态 BCI 的方法很多,但是使用 Java 1.接口有着一个很明显的优势 。在此我将在更高的层面简要描述这个接口;想要深入了解关于这个主题的知识,请参见所著的文章 “构建自己的分析工具”(参见参考资料) 。
通过两个结构启用运行时动态 BCI 。首先,当用-:a JAR file(这里的命名的 JAR 文件包含一个实现)启动 JVM 时 , JVM 调用了在一个特殊清单条目中定义的类的一个void ( args,inst)方法 。正如名称所暗示的,这个方法是在主 Java 应用程序入口点前被调用的,该入口点允许调用的类优先访问它 , 从而开始修改载入的类 。关于这点它是通过注册(第二个结构)实例来实现的 。接口负责从类加载程序有效截取调用并动态重写载入类的字节码 。的单个方法 —— 被传入要重定义的类和包含类的字节码的字节数组 。然后方法实现各种修改,并返回一个包含修改的(或插装的)类的字节码的新字节数组 。这种模型允许快速有效地传输类 , 并且与前面的一些方法不同,它不需要本地组件参与工作 。
实现测试用例中的动态 BCI 有两步:首先,必须重新编译org...类 , 将上面的测试用例中的静态 BCI 移除 。其次,JVM 启动选项需要保留-.aop.path=[]/jboss-aop.xml选项,并且要按如下的方式添加选项:
-javaagent:[directory name]/jboss-aop-jdk50.jar
清单 18 展示了一个稍微修改过的 jboss-aop.xml 文件,它说明了动态 BCI 的优势:
清单 18. 缩减的动态 BCI jboss-aop.xml 文件
get(..))">prepareStatement(..))">pointcut="execution(public * $instanceof{java.sql.PreparedStatement}->executeQuery(..))">
动态 BCI 的好处之一就是可以插装任何类,包括第三方库,所以清单 18 展示了java.sql.所有实例的插装 。然而它更强大的能力是可以把任何(但可用的)截取程序应用到定义的切入点 。例如,org..aop.是一个普通的但却与有些不同的截取程序 。截取程序的整个库(在 AOP 用语中常指方面())都可以被开发,并可以通过开源提供商获得 。这些方面库可以提供广泛的透视图,根据您想要应用的插装类型、要插装的 API 的不同,或者两者均不同,这些透视图的用途也不一样 。
图 14 展示了其他指标的指标树 。注意通过使用中的提供者 , 有几个类实现了java.sql接口 。
图 14. 动态 BCI 指标树
对比一下插装技术和使用 BCI 插装的驱动程序的性能差异,BCI 方法最大的优点就很明了了 。这点在清单 15 中有所展示,清单 15 展示了.的对比运行时间:
图 15. BCI 对比包装性能
第 2 部分结束语
在这篇文章中我介绍了很多种插装 Java 应用程序的方式,目的是为了跟踪 APM 系统的性能监控数据 。我所展现的这些技巧都不需要修改原始源代码 。到底哪一个方法更合适要视情况而定,但是可以确定的是 BCI 已经成为了主流 。APM 系统是内部开发的、开源的、商用的系统 , 可以用它来为 Java 性能管理实现 BCI , 要想实现性能良好且高度可用的系统,APM 系统必不可少 。
【Java 运行时监控,第 2 部分: 编译后插装和性能监控】本系列的第三部分也是最后一部分将介绍监控 JVM 外部资源的方式,包括主机和它们的操作系统以及诸如数据库和通信系统这样的远程服务 。它还总结了应用程序性能管理的其他问题,诸如数据管理、数据可视化、报告和警报 。