排錯時若能自動記錄每個方法的進入/離開參數與回傳值,將大幅縮短除錯時間。傳統手工插入 log4j 語句既繁瑣又易遺漏。Java 5 引入的 Java Instrumentation API 允許透過 Java Agent 在類載入前操作位元碼,實現無侵入式自動化日誌植入。
transform() 方法接收原始位元碼,回傳修改後位元碼Premain-Class 與 Boot-Class-Path(載入 Javassist 等依賴)package com.runjva.instrumentation;
import java.lang.instrument.*;
import java.util.*;
import javassist.*;
public class LoggerAgent implements ClassFileTransformer {
public static void premain(String agentArgs, Instrumentation inst) {
// 解析參數,例如 -javaagent:loggeragent.jar=time
if (agentArgs != null) {
String[] args = agentArgs.split(",");
Set<String> argSet = new HashSet<>(Arrays.asList(args));
if (argSet.contains("time")) {
System.out.println("Start at " + new Date());
Runtime.getRuntime().addShutdownHook(new Thread(() ->
System.out.println("Stop at " + new Date())));
}
}
inst.addTransformer(new LoggerAgent());
}
// 排除系統類別避免效能影響
private static final String[] IGNORE_PREFIX = {"sun/", "java/", "javax/"};
private static final String LOGGER_FIELD = "private static java.util.logging.Logger _log;";
private static final String IF_LOG = "if (_log.isLoggable(java.util.logging.Level.INFO))";
@Override
public byte[] transform(ClassLoader loader, String className, Class> clazz,
ProtectionDomain domain, byte[] bytes) {
for (String prefix : IGNORE_PREFIX) {
if (className.startsWith(prefix)) return bytes;
}
return doClass(className, clazz, bytes);
}
private byte[] doClass(String name, Class> clazz, byte[] bytes) {
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = null;
try {
ctClass = pool.makeClass(new java.io.ByteArrayInputStream(bytes));
if (!ctClass.isInterface()) {
// 1. 加入靜態 Logger 欄位
CtField field = CtField.make(LOGGER_FIELD, ctClass);
String getLogger = "java.util.logging.Logger.getLogger(\""
+ name.replace('/', '.') + ".class.getName());";
ctClass.addField(field, getLogger);
// 2. 處理每個非空方法
for (CtBehavior method : ctClass.getDeclaredBehaviors()) {
if (!method.isEmpty()) doMethod(method);
}
return ctClass.toBytecode();
}
} catch (Exception e) {
System.err.println("Could not instrument " + name + ": " + e.getMessage());
} finally {
if (ctClass != null) ctClass.detach();
}
return bytes;
}
private void doMethod(CtBehavior method) throws NotFoundException, CannotCompileException {
String signature = JavassistHelper.getSignature(method); // 方法簽名含參數
String returnValue = JavassistHelper.returnValue(method); // 回傳值片段
// 植入進入日誌
method.insertBefore(IF_LOG + "_log.info(\">> " + signature + "\");");
// 植入離開日誌(含回傳值)
method.insertAfter(IF_LOG + "_log.info(\"<< " + signature + returnValue + "\");");
}
}
負責產生方法簽名字串(含參數值)與回傳值擷取位元碼,利用 Javassist 編譯 Java 片段插入。完整程式碼請參考附件原始碼。
Premain-Class: com.runjva.instrumentation.LoggerAgent
Boot-Class-Path: ../lib/javassist.jar
<target name="withAgent">
<java fork="yes" classpath="bin" classname="com.runjva.demo.HelloWorld">
<jvmarg value="-javaagent:dist/loggeragent.jar=time"/>
</java>
</target>
原始程式:
Hello World
植入 Agent 後:
Start at Fri Apr 18 21:13:53 CEST 2008
18-04-2008 21:13:54 com.runjva.demo.HelloWorld main
INFO: >> main(args=[])
Hello World
18-04-2008 21:13:54 com.runjva.demo.HelloWorld main
INFO: << main(args=[])
Stop at Fri Apr 18 21:13:54 CEST 2008
| 優點 | 缺點/限制 |
|---|---|
| 零侵入原始碼、編譯後類別檔 | 啟動效能稍有影響(類載入期處理) |
| 日誌自動同步程式碼變更 | 大量日誌輸出可能影響執行效能 |
| 支援任意第三方庫類別 | 需注意 final/private 方法無法插桿 |
| 生產環境可動態開關 | 複雜回傳值序列化需額外處理 |
適用場景:生產環境疑難雜症排查、效能分析熱點方法、審計追蹤關鍵業務流程。