Java 執行時監控,第 2 部分: 編譯後插裝和效能監控
通過擷取進行 Java 插裝
擷取 的基本前提是通過一個擷取構造和收集傳入的入站與出站呼叫資訊,對特定的呼叫模式進行轉換。一個基本的擷取程式的實現會:
- 獲取對入站呼叫請求的當前時間。
- 取回出站響應的當前時間。
- 將執行時間作為兩次度量的增量計算出來。
- 將呼叫的執行時間提交給應用程式效能管理(APM)系統。
圖 1 展示了該流程:
圖 1. 效能資料收集擷取程式的基本流程
清晰的界限
變更管理的愛好者可能會對通過原始碼實現變更和通過配置實現變更之間的差異持有爭議。誠然,“程式碼”、XML 和 “指令碼” 之間的界限變得有些模糊了。但是下面兩個變更之間還存在明顯的界限:
- 需要改變原始碼的變更,接著還要編譯、打包,有時還會涉及到一系列看起來無休止的預部署過程
- 對(未改變的)二進位制程式碼外部的資源所作的變更
這兩種變更之間最主要的差異是實現前滾(roll-forward)和後滾(roll-back)的簡單性。在某些情況下,這種差異可能在理論上說不通,或者可能低估了某些環境的複雜度或變更過程的嚴格性。
很多諸如 Java Platform 和 Enterprise Edition(Java EE)這樣的 Java 框架都包括對擷取棧的核心支援,服務的呼叫可以在擷取棧中通過一系列預處理和後處理元件來進行傳遞。有了這些棧就可以很好地將插裝注入到執行路徑中,這樣做的好處有二:第一,無需修改目標類的原始碼;第二,只要將擷取程式類插入到 JVM 的類路徑中並修改元件的部署描述符,這樣就把插裝擷取程式插入到了執行流程中。
擷取的核心指標
擷取程式所收集的一個典型的指標就是執行時間。其他的指標同樣適合擷取模式。我將介紹支援這些指標的 ITracer
介面的兩個新的方面,所以在這裡我要轉下話題,先簡要論述一下這些指標。
使用擷取程式時需要收集的典型指標有:
- 執行時間:完成一個執行的平均時鐘時間。
- 每個時間間隔內的呼叫:呼叫目標的次數。
- 每個時間間隔內的響應:目標響應呼叫的次數。
- 每個時間間隔內的異常l:目標呼叫導致異常的次數。
- 併發性:併發執行目標的執行緒數。
還有兩個 ThreadMXBean
指標可以選擇,但它們的作用有限,而且收整合本會高一些:
-
執行 CPU 時間
- 阻塞/等待計數和時間:等待表示由具體執行緒排程導致的同步或者等待。阻塞常見於執行等待資源時,如響應來自遠端資料庫的 Java 資料庫連線(Java Database Connectivity,JDBC)呼叫(至於這些指標的用處,請參見本文的 JDBC 插裝 部分)。
為了澄清 ThreadMXBean
指標的收集方法,清單
1 快速回顧了基於原始碼的插裝。在這個例子中,我針對 heavilyInstrumentedMethod
方法實現了大量插裝。
清單 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 引入了兩個新的構造:
-
ThreadInfoCapture
方法:ThreadInfoCapture
方法對於獲取執行時間和目標呼叫前後的ThreadMXBean
指標增量都很有幫助。startThreadInfoCapture
為當前執行緒捕獲基準,而endThreadInfoCapture
計算出增量和趨勢。由於這些指標永遠都是遞增的,所以必須事先確定一個基準,再根據它計算出之後的差量。但這個場景不適用於跟蹤程式的增量功能,這是因為每一個執行緒的絕對值都是不同的,而且執行中的 JVM 中的執行緒也不是保持不變的。另外還要注意跟蹤程式使用了一個棧來儲存基準,所以您可以(小心地)巢狀呼叫。要收集這個資料可是要費一番力氣。圖 2 展示了收集各種ThreadMXBean
指標所需要的相對執行時間:圖 2. 收集
ThreadMXBean
指標所需的相對成本
雖然如果小心使用呼叫的話,收集這些指標的總開銷不會很大,但是仍然需要遵循在記錄日誌時需要考慮的一些事項,例如不要在緊湊迴圈(tight loop)內進行。 -
併發性:要跟蹤在特定時間內通過這個程式碼的執行緒數,需要建立一個計數器,該計數器既要是執行緒安全的又要對目標類的所有例項可用 — 在本例為
AtomicInteger
類。此種情況比較麻煩,因為有時可能多個類載入器都載入了該類,致使計數器無法精確計數,從而導致度量錯誤。解決這個問題的辦法為:將併發計數器儲存在 JVM 的一個特定的受保護位置中,諸如平臺代理中的 MBean。
併發性只有在插裝目標是多執行緒的或者共用的情況下可用,但是它是非常重要的指標,這一點我將在稍後介紹 Enterprise JavaBean(EJB)擷取程式時進一步闡述。EJB 擷取程式是我接下來要論述的幾個基於擷取的插裝示例的第一個,借鑑了 清單 1 中檢視的跟蹤方法。
EJB 3 擷取程式
釋出了 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(InvocationContext ic)
的方法
清單 2 展示了樣例 EJB 的擷取方法:
清單 2. EJB 3 擷取程式方法
@AroundInvoke public Object trace(InvocationContext ctx) throws Exception { Object returnValue = 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 = 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 中有以下幾點需要注意:
-
@AroundInvoke
註釋通過封裝 EJB 呼叫而將方法標記為一個擷取程式。 - 方法呼叫一直沿著棧傳遞呼叫,可能傳遞到最終目標,或到下一個擷取程式。因此,要在呼叫該方法前確定度量基準,在呼叫後跟蹤它。
-
傳入跟蹤方法的
InvocationContext
為擷取程式提供全部有關呼叫的元資料,包括:- 目標物件
- 目標方法名
- 所傳遞的引數
從插裝的角度看,這些擷取程式最有用之處在於您可以通過修改部署描述符而將它們應用於 EJB。清單 3 展示了樣例 EJB 的 ejb-jar.xml 部署描述符:
清單 3. EJB 3 擷取程式部署描述符
<ejb-jar xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/ejb-jar_3_0.xsd" version="3.0"> <interceptors> <interceptor> <interceptor-class> org.runtimemonitoring.interceptors.ejb.EJBTracingInterceptor </interceptor-class> <around-invoke> <method-name>trace</method-name> </around-invoke> </interceptor> </interceptors> <assembly-descriptor> <interceptor-binding> <ejb-name>AA4H-HibernateService</ejb-name> <interceptor-class> org.runtimemonitoring.interceptors.ejb.EJBTracingInterceptor </interceptor-class> </interceptor-binding> </assembly-descriptor> </ejb-jar>
正如我在前面所提到過的,插裝擷取程式對於基於上下文或者基於範圍/閾值的跟蹤是有用的。而 InvocationContext
中的
EJB 呼叫引數值是可用的,這加強了插裝擷取程式的作用。這些值可以用於跟蹤範圍或其他上下文的複合名稱。考慮一下包含有issueRemoteOperation(String
region、Command command)
方法的 org.myco.regional.RemoteManagement
類中的
EJB 呼叫。EJB 接受一個命令,然後遠端呼叫根據域識別的伺服器。在這個場景中,區域伺服器遍佈於一個廣泛的地理區域,每一個區域服務都有自己的 WAN 特性。這裡呈現的模式與 第
1 部分 中的 payroll-processing 例子類似,這是因為如果沒有明確命令到底被分配到哪一個區域的話,確定一個 EJB 呼叫的執行時間是很困難的。您可能已經預料到,從距離一個洲遠的區域呼叫的執行時間要比從隔壁呼叫的執行時間要長的多。但是您是可以從 InvocationContext
引數確定區域的,因此您只需將區域程式碼新增到跟蹤複合名稱並按區域劃分效能資料,如清單
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
Servlet 過濾器擷取程式
Java Servlet API 提供了一個叫做過濾器(filter)的構造,它與 EJB 3 擷取程式非常類似,含有無需原始碼的注入和元資料可用性。清單 5 展示了一個過濾器的 doFilter
方法,帶有縮略了的插裝。指標的複合名由過濾器類名和請求的統一資源識別符號(Uniform
Resource Identifier,URI)構建:
清單 5. servlet 過濾器擷取程式方法
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. servlet 過濾器部署描述符
<web-app > <filter> <filter-name>ITraceFilter</filter-name> <display-name>ITraceFilter</display-name> <filter-class>org.myco.http.ITraceFilter</filter-class> </filter> <filter-mapping> <filter-name>ITraceFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app>
EJB 客戶端擷取程式與上下文傳遞
前面的例子側重於伺服器端元件,但一些諸如客戶端擷取這樣的插裝實現方法也是存在的。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
方法
/** * 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 = clientInvoke((MethodInvocation)invocation); long clientResponseTime = System.currentTimeMillis(); Map<String, Serializable> 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 中交付,但效果不是很好。所以我實現了擷取程式,從而將上下文負載作為請求的附加呼叫引數來傳遞。並且將響應物件編組為一個Object[2]
陣列;第一項是
“real” 結果,第二項為上下文。在這兩種情況下,被編組的物件都被對應的擷取程式解組,所以請求方和服務端點都能獲得它們所需要的型別。
伺服器端擷取程式在概念上是類似的,不同的是為了避免使例子過於複雜,它使用了本地執行緒來檢查 reentrancy
—
相同的請求處理執行緒在同一遠端呼叫中不只一次呼叫相同的 EJB(和擷取程式)。該擷取程式忽略了除第一個請求之外的所有請求的跟蹤和上下文處理。清單 8 展示了伺服器端擷取程式的 invoke
方法:
清單 8. 伺服器端擷取程式的 invoke
方法
/** * 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<String, Serializable> context = getInvocationContext(mi); context.put(SERVER_RECEIVED_TIME, currentTime); Object returnValue = serverInvoke((MethodInvocation)mi); context.put(SERVER_RESPOND_TIME, System.currentTimeMillis()); return addContextReturnValue(returnValue); } finally { reentrancy.set(false); } } else { return invocation.invokeNext(); } }
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 配置
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE aop PUBLIC "-//JBoss//DTD JBOSS AOP 1.0//EN" "http://labs.jboss.com/portal/jbossaop/dtd/jboss-aop_1_0.dtd"> <aop> . . <interceptor class="org.runtimemonitoring.ejb.interceptors.ClientContextualInterceptor" scope="PER_VM"/> . . <stack name="StatelessSessionClientInterceptors"> <interceptor-ref name="org.runtimemonitoring.ejb.interceptors.ClientCont