Java Agent :構建 SpringBoot 應用無痕調試注入器
前言
圖片
在生產環境中,線上問題定位往往面臨著巨大挑戰 —— 我們不能隨意重啟應用,更不能暫停服務進行調試。Java Agent技術為我們提供了一種優雅的解決方案,它允許我們在不修改源代碼、不重啟應用的情況下,對運行中的JVM進行字節碼增強,實現無痕調試。
什么是 Java Agent?
Java Agent是Java SE 5引入的一項技術,它本質上是一個特殊的JAR文件,能夠在主程序運行前或運行時動態修改類的字節碼。這種特性使得Java Agent非常適合實現:
- 應用監控與性能分析
- 線上問題診斷與調試
AOP編程(無侵入式)- 代碼覆蓋率分析
Java Agent有兩種加載方式:
- 啟動時加載:通過
-javaagent參數指定,在JVM啟動時加載 - 運行時加載:通過
Attach API動態附加到運行中的JVM進程
技術選型
實現Java Agent需要操作字節碼,目前主流的字節碼操作庫有:
ASM:輕量級、高性能,直接操作字節碼指令Javassist:更高層次的API,支持源碼級別的修改CGLIB:基于ASM,主要用于生成代理類
考慮到性能和靈活性,我們選擇ASM作為字節碼操作庫,它能讓我們更精細地控制字節碼生成過程,適合生產環境使用。
實現步驟
依賴配置
<?xml versinotallow="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocatinotallow="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.yian</groupId>
<artifactId>springboot-debug-agent</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<asm.version>9.3</asm.version>
</properties>
<dependencies>
<!-- ASM字節碼操作庫 -->
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>${asm.version}</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-commons</artifactId>
<version>${asm.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestEntries>
<!-- 啟動時加載的入口類 -->
<Premain-Class>com.yian.agent.DebugAgent</Premain-Class>
<!-- 運行時加載的入口類 -->
<Agent-Class>com.yian.agent.DebugAgent</Agent-Class>
<!-- 允許重定義類 -->
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<!-- 允許重轉換類 -->
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>Premain-Class:指定啟動時加載Agent的入口類Agent-Class:指定運行時加載Agent的入口類Can-Redefine-Classes和Can-Retransform-Classes:允許Agent重定義和轉換類
實現 Agent 入口類
Agent入口類需要實現premain(啟動時加載)和agentmain(運行時加載)方法:
/**
* Java Agent入口類,實現無痕調試注入功能
*/
public class DebugAgent {
private static final Logger logger = Logger.getLogger(DebugAgent.class.getName());
private static Instrumentation instrumentation;
/**
* JVM啟動時加載Agent的入口方法
*/
public static void premain(String agentArgs, Instrumentation inst) {
logger.info("DebugAgent premain 啟動...");
initialize(agentArgs, inst);
}
/**
* 運行時動態加載Agent的入口方法
*/
public static void agentmain(String agentArgs, Instrumentation inst) {
logger.info("DebugAgent agentmain 啟動...");
initialize(agentArgs, inst);
// 運行時加載需要觸發類重轉換
try {
Class<?>[] allLoadedClasses = inst.getAllLoadedClasses();
for (Class<?> clazz : allLoadedClasses) {
if (AgentConfig.shouldTransform(clazz.getName())) {
inst.retransformClasses(clazz);
logger.info("已重新轉換類: " + clazz.getName());
}
}
} catch (Exception e) {
logger.severe("類重轉換失敗: " + e.getMessage());
}
}
/**
* 初始化Agent
*/
private static void initialize(String agentArgs, Instrumentation inst) {
instrumentation = inst;
// 解析Agent參數
AgentConfig.parse(agentArgs);
// 添加類轉換器
inst.addTransformer(new MethodMonitorTransformer(), true);
logger.info("DebugAgent 初始化完成,配置: " + AgentConfig.getConfigInfo());
}
public static Instrumentation getInstrumentation() {
return instrumentation;
}
}入口類的核心職責:
- 接收并解析
Agent參數 - 初始化
Instrumentation實例 - 注冊類轉換器
- 運行時加載時觸發類重轉換
配置解析實現
我們需要靈活的配置機制,讓用戶可以指定需要監控的類和方法:
/**
* Agent配置類,解析和存儲注入規則
*/
public class AgentConfig {
private static final Logger logger = Logger.getLogger(AgentConfig.class.getName());
// 包含規則(正則表達式)
private static final List<Pattern> includePatterns = new ArrayList<>();
// 排除規則(正則表達式)
private static final List<Pattern> excludePatterns = new ArrayList<>();
// 日志文件路徑
private static String logFile;
/**
* 解析Agent參數
* 格式: include=com.yian.*;exclude=com.yian.test.*;logFile=/tmp/agent.log
*/
public static void parse(String agentArgs) {
if (agentArgs == null || agentArgs.trim().isEmpty()) {
logger.info("未指定Agent參數,使用默認配置");
// 添加默認規則,監控所有Spring組件
includePatterns.add(Pattern.compile("com\\.yian\\..*"));
return;
}
String[] configItems = agentArgs.split(";");
for (String item : configItems) {
String[] keyValue = item.split("=", 2);
if (keyValue.length != 2) continue;
String key = keyValue[0].trim();
String value = keyValue[1].trim();
switch (key) {
case"include":
includePatterns.add(Pattern.compile(convertToRegex(value)));
break;
case"exclude":
excludePatterns.add(Pattern.compile(convertToRegex(value)));
break;
case"logFile":
logFile = value;
break;
default:
logger.warning("未知的配置項: " + key);
}
}
// 如果沒有指定包含規則,添加默認規則
if (includePatterns.isEmpty()) {
includePatterns.add(Pattern.compile("com\\.yian\\..*"));
}
}
/**
* 將通配符表達式轉換為正則表達式
*/
private static String convertToRegex(String wildcard) {
return wildcard.replace(".", "\\.").replace("*", ".*").replace("?", ".");
}
/**
* 判斷類是否需要被轉換
*/
public static boolean shouldTransform(String className) {
// 將類名轉換為全限定名格式(例如:com/yian/MyClass -> com.yian.MyClass)
String qualifiedName = className.replace("/", ".");
// 檢查是否匹配排除規則
for (Pattern pattern : excludePatterns) {
if (pattern.matcher(qualifiedName).matches()) {
returnfalse;
}
}
// 檢查是否匹配包含規則
for (Pattern pattern : includePatterns) {
if (pattern.matcher(qualifiedName).matches()) {
returntrue;
}
}
returnfalse;
}
public static String getLogFile() {
return logFile;
}
public static String getConfigInfo() {
return String.format("include=%s, exclude=%s, logFile=%s",
includePatterns, excludePatterns, logFile);
}
}- 解析命令行參數(
include/exclude規則、日志路徑) - 通配符到正則表達式的轉換(方便用戶使用
*和?通配符) - 類匹配邏輯(決定哪些類需要被增強)
字節碼轉換器實現
類轉換器是Agent的核心,它決定了如何修改類的字節碼:
/**
* 類轉換器,負責將監控邏輯注入到目標類中
*/
public class MethodMonitorTransformer implements ClassFileTransformer {
private static final Logger logger = Logger.getLogger(MethodMonitorTransformer.class.getName());
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
// 判斷是否需要轉換該類
if (!AgentConfig.shouldTransform(className)) {
return null;
}
try {
logger.info("開始轉換類: " + className);
// 讀取類字節碼
ClassReader cr = new ClassReader(classfileBuffer);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
// 使用自定義的ClassVisitor處理類
DebugClassVisitor cv = new DebugClassVisitor(cw, className.replace("/", "."));
// 處理類,SKIP_DEBUG可以提高性能,不處理調試信息
cr.accept(cv, ClassReader.SKIP_DEBUG);
// 返回修改后的字節碼
return cw.toByteArray();
} catch (Exception e) {
logger.severe("轉換類 " + className + " 失敗: " + e.getMessage());
e.printStackTrace();
return null;
}
}
}轉換器的工作流程:
- 檢查類是否需要被轉換(基于
AgentConfig的規則) - 使用
ASM的ClassReader讀取類字節碼 - 創建
ClassWriter用于生成修改后的字節碼 - 使用自定義的
ClassVisitor處理類結構 - 返回修改后的字節碼
字節碼增強實現
最后,我們需要實現具體的字節碼增強邏輯,在方法調用前后插入監控代碼:
/**
* 自定義ClassVisitor,用于訪問類的方法并注入監控邏輯
*/
public class DebugClassVisitor extends ClassVisitor {
private final String className;
public DebugClassVisitor(ClassVisitor classVisitor, String className) {
super(Opcodes.ASM9, classVisitor);
this.className = className;
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
// 過濾掉構造方法和靜態初始化方法
if (name.equals("<init>") || name.equals("<clinit>")) {
return mv;
}
// 過濾掉native方法
if ((access & Opcodes.ACC_NATIVE) != 0) {
return mv;
}
// 使用自定義的MethodVisitor處理方法
return new DebugMethodVisitor(mv, className, name, descriptor, access);
}
}/**
* 自定義MethodVisitor,用于在方法執行前后注入監控邏輯
*/
public class DebugMethodVisitor extends AdviceAdapter {
private static final Logger logger = Logger.getLogger(DebugMethodVisitor.class.getName());
private final String className;
private final String methodName;
private final String methodDesc;
// 局部變量索引,用于存儲方法開始時間
private int startTimeVar;
// 用于標識是否是靜態方法
private final boolean isStatic;
protected DebugMethodVisitor(MethodVisitor methodVisitor, String className,
String methodName, String methodDesc, int access) {
super(Opcodes.ASM9, methodVisitor, access, methodName, methodDesc);
this.className = className;
this.methodName = methodName;
this.methodDesc = methodDesc;
this.isStatic = (access & Opcodes.ACC_STATIC) != 0;
}
/**
* 在方法進入時插入代碼
*/
@Override
protected void onMethodEnter() {
// 記錄方法開始時間
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
startTimeVar = newLocal(Type.LONG_TYPE);
mv.visitVarInsn(LSTORE, startTimeVar);
// 打印方法調用信息
mv.visitLdcInsn(className);
mv.visitLdcInsn(methodName);
mv.visitMethodInsn(INVOKESTATIC, "com/yian/agent/MethodMonitor",
"logMethodEnter", "(Ljava/lang/String;Ljava/lang/String;)V", false);
// 打印方法參數
printParameters();
}
/**
* 打印方法參數
*/
private void printParameters() {
Type[] argumentTypes = Type.getArgumentTypes(methodDesc);
// 創建參數數組
mv.visitIntInsn(BIPUSH, argumentTypes.length);
mv.visitTypeInsn(ANEWARRAY, "java/lang/Object");
int paramsArrayVar = newLocal(Type.getType(Object[].class));
mv.visitVarInsn(ASTORE, paramsArrayVar);
// 填充參數數組
int paramIndex = isStatic ? 0 : 1; // 非靜態方法第一個參數是this
for (int i = 0; i < argumentTypes.length; i++) {
Type type = argumentTypes[i];
int size = type.getSize();
// 加載數組和索引
mv.visitVarInsn(ALOAD, paramsArrayVar);
mv.visitIntInsn(BIPUSH, i);
// 加載參數值并裝箱
loadArg(paramIndex);
box(type);
// 存入數組
mv.visitInsn(AASTORE);
paramIndex += size;
}
// 調用日志方法記錄參數
mv.visitLdcInsn(className);
mv.visitLdcInsn(methodName);
mv.visitVarInsn(ALOAD, paramsArrayVar);
mv.visitMethodInsn(INVOKESTATIC, "com/yian/agent/MethodMonitor",
"logMethodParameters", "(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/Object;)V", false);
}
/**
* 在方法退出時插入代碼
*/
@Override
protected void onMethodExit(int opcode) {
// 計算方法執行時間
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
mv.visitVarInsn(LLOAD, startTimeVar);
mv.visitInsn(LSUB);
int durationVar = newLocal(Type.LONG_TYPE);
mv.visitVarInsn(LSTORE, durationVar);
// 如果是正常返回,記錄返回值
if ((opcode >= IRETURN && opcode <= RETURN)) {
// 復制返回值到棧頂(因為onMethodExit時返回值已經在棧上)
if (opcode != RETURN) { // 不是void返回
dupReturnValue(opcode);
box(Type.getReturnType(methodDesc));
// 記錄返回值
mv.visitLdcInsn(className);
mv.visitLdcInsn(methodName);
mv.visitVarInsn(LLOAD, durationVar);
mv.visitMethodInsn(INVOKESTATIC, "com/yian/agent/MethodMonitor",
"logMethodReturn", "(Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;)V", false);
} else {
// void返回
mv.visitLdcInsn(className);
mv.visitLdcInsn(methodName);
mv.visitVarInsn(LLOAD, durationVar);
mv.visitInsn(ACONST_NULL);
mv.visitMethodInsn(INVOKESTATIC, "com/yian/agent/MethodMonitor",
"logMethodReturn", "(Ljava/lang/String;Ljava/lang/String;JLjava/lang/Object;)V", false);
}
}
// 如果是異常退出,記錄異常信息
elseif (opcode == ATHROW) {
// 復制異常引用
mv.visitInsn(DUP);
// 記錄異常
mv.visitLdcInsn(className);
mv.visitLdcInsn(methodName);
mv.visitVarInsn(LLOAD, durationVar);
mv.visitMethodInsn(INVOKESTATIC, "com/yian/agent/MethodMonitor",
"logMethodException", "(Ljava/lang/String;Ljava/lang/String;JLjava/lang/Throwable;)V", false);
}
}
/**
* 復制返回值到棧頂
*/
private void dupReturnValue(int opcode) {
switch (opcode) {
case IRETURN:
case FRETURN:
mv.visitInsn(DUP);
break;
case LRETURN:
case DRETURN:
mv.visitInsn(DDUP);
break;
case ARETURN:
mv.visitInsn(DUP);
break;
default:
// 不處理void返回
}
}
}/**
* 方法監控工具類,提供日志記錄功能
*/
public class MethodMonitor {
private static final Logger logger = Logger.getLogger(MethodMonitor.class.getName());
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
private static FileWriter logWriter;
static {
// 初始化日志寫入器
String logFile = AgentConfig.getLogFile();
if (logFile != null && !logFile.isEmpty()) {
try {
File file = new File(logFile);
// 創建父目錄
if (!file.getParentFile().exists()) {
file.getParentFile().mkdirs();
}
// 追加模式
logWriter = new FileWriter(file, true);
} catch (IOException e) {
logger.severe("初始化日志文件失敗: " + e.getMessage());
logWriter = null;
}
}
}
/**
* 記錄方法進入
*/
public static void logMethodEnter(String className, String methodName) {
String message = String.format("[%s] 方法進入: %s.%s()",
DATE_FORMAT.format(new Date()), className, methodName);
writeLog(message);
}
/**
* 記錄方法參數
*/
public static void logMethodParameters(String className, String methodName, Object[] params) {
StringBuilder sb = new StringBuilder();
sb.append("[參數]: ");
if (params != null) {
for (int i = 0; i < params.length; i++) {
sb.append("param").append(i).append("=")
.append(params[i] != null ? params[i].toString() : "null");
if (i < params.length - 1) {
sb.append(", ");
}
}
} else {
sb.append("無參數");
}
String message = String.format("[%s] %s.%s(): %s",
DATE_FORMAT.format(new Date()), className, methodName, sb.toString());
writeLog(message);
}
/**
* 記錄方法返回
*/
public static void logMethodReturn(String className, String methodName, long duration, Object returnValue) {
String returnStr = returnValue != null ? returnValue.toString() : "void";
String message = String.format("[%s] 方法返回: %s.%s() 執行時間: %dms, 返回值: %s",
DATE_FORMAT.format(new Date()), className, methodName, duration, returnStr);
writeLog(message);
}
/**
* 記錄方法異常
*/
public static void logMethodException(String className, String methodName, long duration, Throwable throwable) {
String exceptionMsg = throwable != null ? throwable.getClass().getName() + ": " + throwable.getMessage() : "未知異常";
String message = String.format("[%s] 方法異常: %s.%s() 執行時間: %dms, 異常: %s",
DATE_FORMAT.format(new Date()), className, methodName, duration, exceptionMsg);
writeLog(message);
// 打印異常堆棧
if (throwable != null) {
throwable.printStackTrace();
}
}
/**
* 寫入日志到文件或控制臺
*/
private static void writeLog(String message) {
System.out.println(message); // 同時輸出到控制臺
if (logWriter != null) {
try {
logWriter.write(message + "\n");
logWriter.flush();
} catch (IOException e) {
logger.severe("寫入日志失敗: " + e.getMessage());
}
}
}
}字節碼增強的核心邏輯:
- 使用
DebugClassVisitor遍歷類中的所有方法 - 對每個非構造方法使用
DebugMethodVisitor進行處理 - 在方法進入時(
onMethodEnter):
記錄開始時間
打印方法調用信息
收集并打印方法參數
- 在方法退出時(
onMethodExit): - 計算執行時間
- 記錄返回值(正常退出)
- 記錄異常信息(異常退出)
MethodMonitor類提供了實際的日志記錄功能,支持輸出到控制臺和文件。
使用方法
在啟動SpringBoot應用時通過-javaagent參數指定Agent:
java -javaagent:/path/to/springboot-debug-agent-1.0-SNAPSHOT-jar-with-dependencies.jar="include=com.yian.service.*;logFile=/tmp/debug.log" -jar your-springboot-app.jar對于已經運行的應用,可以使用jattach工具動態附加Agent:
# 安裝jattach(如果未安裝)
# Ubuntu: sudo apt install jattach
# CentOS: yum install jattach
# 動態附加Agent
jattach <pid> load instrument false "/path/to/springboot-debug-agent-1.0-SNAPSHOT-jar-with-dependencies.


























