# 引言
在本篇文章中,我會通過幾個簡單的程式來說明 agent 的使用,最後在實戰環節我會通過 asm 位元組碼框架來實現一個小工具,用於在程式執行中採集指定方法的引數和返回值。有關 asm 位元組碼的內容不是本文的重點,不會過多的闡述,不明白的同學可以自己 google 下。
# 簡介Java agent 提供了一種在載入位元組碼時,對位元組碼進行修改的方式。他共有兩種方式執行,一種是在 main 方法執行之前,通過 premain 來實現,另一種是在程式執行中,通過 attach api 來實現。
在介紹 agent 之前,先給大家簡單說下 Instrumentation 。它是 JDK1.5 提供的 API ,用於攔截類載入事件,並對位元組碼進行修改,它的主要方法如下:
public interface Instrumentation { //註冊一個轉換器,類載入事件會被註冊的轉換器所攔截 void addTransformer(ClassFileTransformer transformer, boolean canRetransform); //重新觸發類載入 void retransformClasses(Class<?>... classes) throws UnmodifiableClassException; //直接替換類的定義 void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;}
# premainpremain 是在 main 方法之前執行的方法,也是最常見的 agent 方式。執行時需要將 agent 程式打包成 jar 包,並在啟動時新增命令來執行,如下文所示:
java -javaagent:agent.jar=xunche HelloWorld
premain 共提供以下 2 種過載方法, Jvm 啟動時會先嚐試使用第一種方法,若沒有會使用第二種方法:
public static void premain(String agentArgs, Instrumentation inst);public static void premain(String agentArgs);
一個簡單的例子
下面我們通過一個程式來簡單說明下 premain 的使用,首先我們準備下測試程式碼,測試程式碼比較簡單,執行 main 方法並輸出 hello world 。
package org.xunche.app;public class HelloWorld { public static void main(String[] args) { System.out.println("Hello World"); }}
接下來我們看下 agent 的程式碼,執行 premain 方法並輸出我們傳入的引數。
package org.xunche.agent;public class HelloAgent { public static void premain(String args) { System.out.println("Hello Agent: " + args); }}
為了能夠 agent 能夠執行,我們需要將 META-INF/MANIFEST.MF 檔案中的 Premain- Class 為我們編寫的 agent 路徑,然後通過以下方式將其打包成 jar 包,當然你也可以使用 idea 直接匯出 jar 包。
echo 'Premain-Class: org.xunche.agent.HelloAgent' > manifest.mfjavac org/xunche/agent/HelloAgent.javajavac org/xunche/app/HelloWorld.javajar cvmf manifest.mf hello-agent.jar org/
接下來,我們編譯下並執行下測試程式碼,這裡為了測試簡單,我將編譯後的 class 和 agent 的 jar 包放在了同級目錄下
java -javaagent:hello-agent.jar=xunche org/xunche/app/HelloWorld
可以看到輸出結果如下,agent中的premain方法有限於main方法執行
package org.xunche.app;public class HelloWorld { public static void main(String[] args) { System.out.println("Hello World"); }}
稍微複雜點的例子通過上面的例子,是否對 agent 有個簡單的了解呢?
下面我們來看個稍微複雜點,我們通過 agent 來實現一個方法監控的功能。思路大致是這樣的,若是非 jdk 的方法,我們通過 asm 在方法的執行入口和執行出口處,植入幾行記錄時間戳的程式碼,當方法結束後,通過時間戳來獲取方法的耗時。
首先還是看下測試程式碼,邏輯很簡單, main 方法執行時呼叫 sayHi 方法,輸出 hi , xunche ,並隨機睡眠一段時間。
package org.xunche.agent;public class HelloAgent { public static void premain(String args) { System.out.println("Hello Agent: " + args); }}
接下來我們藉助 asm 來植入我們自己的程式碼,在 jvm 載入類的時候,為類的每個方法加上統計方法呼叫耗時的程式碼,程式碼如下,這裡的 asm 我使用了 jdk 自帶的,當然你也可以使用官方的 asm 類庫。
package org.xunche.agent;import jdk.internal.org.objectweb.asm.*;import jdk.internal.org.objectweb.asm.commons.AdviceAdapter;import java.lang.instrument.ClassFileTransformer;import java.lang.instrument.Instrumentation;import java.security.ProtectionDomain;public class TimeAgent { public static void premain(String args, Instrumentation instrumentation) { instrumentation.addTransformer(new TimeClassFileTransformer()); } private static class TimeClassFileTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { if (className.startsWith("java") || className.startsWith("jdk") || className.startsWith("javax") || className.startsWith("sun") || className.startsWith("com/sun")|| className.startsWith("org/xunche/agent")) { //return null或者執行異常會執行原來的位元組碼 return null; } System.out.println("loaded class: " + className); ClassReader reader = new ClassReader(classfileBuffer); ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); reader.accept(new TimeClassVisitor(writer), ClassReader.EXPAND_FRAMES); return writer.toByteArray(); } } public static class TimeClassVisitor extends ClassVisitor { public TimeClassVisitor(ClassVisitor classVisitor) { super(Opcodes.ASM5, classVisitor); } @Override public MethodVisitor visitMethod(int methodAccess, String methodName, String methodDesc, String signature, String[] exceptions) { MethodVisitor methodVisitor = cv.visitMethod(methodAccess, methodName, methodDesc, signature, exceptions); return new TimeAdviceAdapter(Opcodes.ASM5, methodVisitor, methodAccess, methodName, methodDesc); } } public static class TimeAdviceAdapter extends AdviceAdapter { private String methodName; protected TimeAdviceAdapter(int api, MethodVisitor methodVisitor, int methodAccess, String methodName, String methodDesc) { super(api, methodVisitor, methodAccess, methodName, methodDesc); this.methodName = methodName; } @Override protected void onMethodEnter() { //在方法入口處植入 if ("<init>".equals(methodName)|| "<clinit>".equals(methodName)) { return; } mv.visitTypeInsn(NEW, "java/lang/StringBuilder"); mv.visitInsn(DUP); mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false); mv.visitVarInsn(ALOAD, 0); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Object", "getClass", "()Ljava/lang/Class;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitLdcInsn("."); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitLdcInsn(methodName); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false); mv.visitMethodInsn(INVOKESTATIC, "org/xunche/agent/TimeHolder", "start", "(Ljava/lang/String;)V", false); } @Override protected void onMethodExit(int i) { //在方法出口植入 if ("<init>".equals(methodName) || "<clinit>".equals(methodName)) { return; } mv.visitTypeInsn(NEW, "java/lang/StringBuilder"); mv.visitInsn(DUP); mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false); mv.visitVarInsn(ALOAD, 0); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Object", "getClass", "()Ljava/lang/Class;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitLdcInsn("."); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitLdcInsn(methodName); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false); mv.visitVarInsn(ASTORE, 1); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitTypeInsn(NEW, "java/lang/StringBuilder"); mv.visitInsn(DUP); mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false); mv.visitVarInsn(ALOAD, 1); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitLdcInsn(": "); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); mv.visitVarInsn(ALOAD, 1); mv.visitMethodInsn(INVOKESTATIC, "org/xunche/agent/TimeHolder", "cost", "(Ljava/lang/String;)J", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); } }}
上述的程式碼略長, asm 的部分可以略過。我們通過 instrumentation.addTransformer 註冊一個轉換器,轉換器重寫了 transform 方法,方法入參中的 classfileBuffer 表示的是原始的位元組碼,方法返回值表示的是真正要進行載入的位元組碼。
onMethodEnter 方法中的程式碼含義是呼叫 TimeHolder 的 start 方法並傳入當前的方法名。
onMethodExit 方法中的程式碼含義是呼叫 TimeHolder 的 cost 方法並傳入當前的方法名,並列印 cost 方法的返回值。
下面來看下 TimeHolder 的程式碼:
package org.xunche.agent;import java.util.HashMap;import java.util.Map;public class TimeHolder { private static Map<String, Long> timeCache = new HashMap<>(); public static void start(String method) { timeCache.put(method, System.currentTimeMillis()); } public static long cost(String method) { return System.currentTimeMillis() - timeCache.get(method); }}
至此,agent 的程式碼編寫完成,有關 asm 的部分不是本章的重點,日後再單獨推出一篇有關 asm 的文章。通過在類載入時植入我們監控的程式碼後,下面我們來看看,經過 asm 修改後的程式碼是怎樣的。可以看到,與最開始的測試程式碼相比,每個方法都加入了我們統計方法耗時的程式碼。
package org.xunche.app;import org.xunche.agent.TimeHolder;public class HelloXunChe { public HelloXunChe() { } public static void main(String[] args) throws InterruptedException { TimeHolder.start(args.getClass().getName() + "." + "main"); HelloXunChe helloXunChe = new HelloXunChe(); helloXunChe.sayHi(); HelloXunChe helloXunChe = args.getClass().getName() + "." + "main"; System.out.println(helloXunChe + ": " + TimeHolder.cost(helloXunChe)); } public void sayHi() throws InterruptedException { TimeHolder.start(this.getClass().getName() + "." + "sayHi"); System.out.println("hi, xunche"); this.sleep(); String var1 = this.getClass().getName() + "." + "sayHi"; System.out.println(var1 + ": " + TimeHolder.cost(var1)); } public void sleep() throws InterruptedException { TimeHolder.start(this.getClass().getName() + "." + "sleep"); Thread.sleep((long)(Math.random() * 200.0D)); String var1 = this.getClass().getName() + "." + "sleep"; System.out.println(var1 + ": " + TimeHolder.cost(var1)); }}
# agentmain上面的 premain 是通過 agetn 在應用啟動前,對位元組碼進行修改,來實現我們想要的功能。實際上 jdk 提供了 attach api ,通過這個 api ,我們可以訪問已經啟動的 Java 程序。並通過 agentmain 方法來攔截類載入。下面我們來通過實戰來具體說明下 agentmain 。
實戰本次實戰的目標是實現一個小工具,其目標是能遠端採集已經處於執行中的 Java 程序的方法呼叫資訊。聽起來像不像 BTrace ,實際上 BTrace 也是這麼實現的。只不過因為時間關係,本次的實戰程式碼寫的比較簡陋,大家不必關注細節,看下實現的思路就好。
具體的實現思路如下:
agent 對指定類的方法進行位元組碼的修改,採集方法的入參和返回值。並通過 socket 將請求和返回傳送到服務端服務端通過 attach api 訪問執行中的 Java 程序,並載入 agent ,使 agent 程式能對目標程序生效服務端載入 agent 時指定需要採集的類和方法服務端開啟一個埠,接受目標程序的請求資訊老規矩,先看測試程式碼,測試程式碼很簡單,每隔 100ms 執行一次 sayHi 方法,並隨機隨眠一段時間。
package org.xunche.app;public class HelloTraceAgent { public static void main(String[] args) throws InterruptedException { HelloTraceAgent helloTraceAgent = new HelloTraceAgent(); while (true) { helloTraceAgent.sayHi("xunche"); Thread.sleep(100); } } public String sayHi(String name) throws InterruptedException { sleep(); String hi = "hi, " + name + ", " + System.currentTimeMillis(); return hi; } public void sleep() throws InterruptedException { Thread.sleep((long) (Math.random() * 200)); }}
接下看 agent 程式碼,思路同監控方法耗時差不多,在方法出口處,通過 asm 植入採集方法入參和返回值的程式碼,並通過 Sender 將資訊通過 socket 傳送到服務端,程式碼如下:
package org.xunche.agent;import jdk.internal.org.objectweb.asm.*;import jdk.internal.org.objectweb.asm.commons.AdviceAdapter;import java.lang.instrument.ClassFileTransformer;import java.lang.instrument.Instrumentation;import java.lang.instrument.UnmodifiableClassException;import java.security.ProtectionDomain;public class TraceAgent { public static void agentmain(String args, Instrumentation instrumentation) throws ClassNotFoundException, UnmodifiableClassException { if (args == null) { return; } int index = args.lastIndexOf("."); if (index != -1) { String className = args.substring(0, index); String methodName = args.substring(index + 1); //目的碼已經載入,需要重新觸發載入流程,才會通過註冊的轉換器進行轉換 instrumentation.addTransformer(new TraceClassFileTransformer(className.replace(".", "/"), methodName), true); instrumentation.retransformClasses(Class.forName(className)); } } public static class TraceClassFileTransformer implements ClassFileTransformer { private String traceClassName; private String traceMethodName; public TraceClassFileTransformer(String traceClassName, String traceMethodName) { this.traceClassName = traceClassName; this.traceMethodName = traceMethodName; } @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { //過濾掉Jdk、agent、非指定類的方法 if (className.startsWith("java") || className.startsWith("jdk") || className.startsWith("javax") || className.startsWith("sun") || className.startsWith("com/sun") || className.startsWith("org/xunche/agent") || !className.equals(traceClassName)) { //return null會執行原來的位元組碼 return null; } ClassReader reader = new ClassReader(classfileBuffer); ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); reader.accept(new TraceVisitor(className, traceMethodName, writer), ClassReader.EXPAND_FRAMES); return writer.toByteArray(); } } public static class TraceVisitor extends ClassVisitor { private String className; private String traceMethodName; public TraceVisitor(String className, String traceMethodName, ClassVisitor classVisitor) { super(Opcodes.ASM5, classVisitor); this.className = className; this.traceMethodName = traceMethodName; } @Override public MethodVisitor visitMethod(int methodAccess, String methodName, String methodDesc, String signature, String[] exceptions) { MethodVisitor methodVisitor = cv.visitMethod(methodAccess, methodName, methodDesc, signature, exceptions); if (traceMethodName.equals(methodName)) { return new TraceAdviceAdapter(className, methodVisitor, methodAccess, methodName, methodDesc); } return methodVisitor; } } private static class TraceAdviceAdapter extends AdviceAdapter { private final String className; private final String methodName; private final Type[] methodArgs; private final String[] parameterNames; private final int[] lvtSlotIndex; protected TraceAdviceAdapter(String className, MethodVisitor methodVisitor, int methodAccess, String methodName, String methodDesc) { super(Opcodes.ASM5, methodVisitor, methodAccess, methodName, methodDesc); this.className = className; this.methodName = methodName; this.methodArgs = Type.getArgumentTypes(methodDesc); this.parameterNames = new String[this.methodArgs.length]; this.lvtSlotIndex = computeLvtSlotIndices(isStatic(methodAccess), this.methodArgs); } @Override public void visitLocalVariable(String name, String description, String signature, Label start, Label end, int index) { for (int i = 0; i < this.lvtSlotIndex.length; ++i) { if (this.lvtSlotIndex[i] == index) { this.parameterNames[i] = name; } } } @Override protected void onMethodExit(int opcode) { //排除構造方法和靜態程式碼塊 if ("<init>".equals(methodName) || "<clinit>".equals(methodName)) { return; } if (opcode == RETURN) { push((Type) null); } else if (opcode == LRETURN || opcode == DRETURN) { dup2(); box(Type.getReturnType(methodDesc)); } else { dup(); box(Type.getReturnType(methodDesc)); } Type objectType = Type.getObjectType("java/lang/Object"); push(lvtSlotIndex.length); newArray(objectType); for (int j = 0; j < lvtSlotIndex.length; j++) { int index = lvtSlotIndex[j]; Type type = methodArgs[j]; dup(); push(j); mv.visitVarInsn(ALOAD, index); box(type); arrayStore(objectType); } visitLdcInsn(className.replace("/", ".")); visitLdcInsn(methodName); mv.visitMethodInsn(INVOKESTATIC, "org/xunche/agent/Sender", "send", "(Ljava/lang/Object;[Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;)V", false); } private static int[] computeLvtSlotIndices(boolean isStatic, Type[] paramTypes) { int[] lvtIndex = new int[paramTypes.length]; int nextIndex = isStatic ? 0 : 1; for (int i = 0; i < paramTypes.length; ++i) { lvtIndex[i] = nextIndex; if (isWideType(paramTypes[i])) { nextIndex += 2; } else { ++nextIndex; } } return lvtIndex; } private static boolean isWideType(Type aType) { return aType == Type.LONG_TYPE || aType == Type.DOUBLE_TYPE; } private static boolean isStatic(int access) { return (access & 8) > 0; } }}
以上就是 agent 的程式碼, onMethodExit 方法中的程式碼含義是獲取請求引數和返回引數並呼叫 Sender.send 方法。這裡的訪問本地變量表的程式碼參考了 Spring 的 LocalVariableTableParameterNameDiscoverer ,感興趣的同學可以自己研究下。接下來看下 Sender 中的程式碼:
public class Sender { private static final int SERVER_PORT = 9876; public static void send(Object response, Object[] request, String className, String methodName) { Message message = new Message(response, request, className, methodName); try { Socket socket = new Socket("localhost", SERVER_PORT); socket.getOutputStream().write(message.toString().getBytes()); socket.close(); } catch (IOException e) { e.printStackTrace(); } } private static class Message { private Object response; private Object[] request; private String className; private String methodName; public Message(Object response, Object[] request, String className, String methodName) { this.response = response; this.request = request; this.className = className; this.methodName = methodName; } @Override public String toString() { return "Message{" + "response=" + response + ", request=" + Arrays.toString(request) + ", className='" + className + '\\'' + ", methodName='" + methodName + '\\'' + '}'; } }}
Sender 中的程式碼不復雜,一看就懂,就不多說了。下面我們來看下服務端的程式碼,服務端要實現開啟一個埠監聽,接受請求資訊,以及使用 attach api 載入 agent 。
package org.xunche.app;import com.sun.tools.attach.AgentInitializationException;import com.sun.tools.attach.AgentLoadException;import com.sun.tools.attach.AttachNotSupportedException;import com.sun.tools.attach.VirtualMachine;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStream;import java.io.InputStreamReader;import java.net.ServerSocket;import java.net.Socket;public class TraceAgentMain { private static final int SERVER_PORT = 9876; public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException { new Server().start(); //attach的程序 VirtualMachine vm = VirtualMachine.attach("85241"); //載入agent並指明需要採集資訊的類和方法 vm.loadAgent("trace-agent.jar", "org.xunche.app.HelloTraceAgent.sayHi"); vm.detach(); } private static class Server implements Runnable { @Override public void run() { try { ServerSocket serverSocket = new ServerSocket(SERVER_PORT); while (true) { Socket socket = serverSocket.accept(); InputStream input = socket.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(input)); System.out.println("receive message:" + reader.readLine()); } } catch (IOException e) { e.printStackTrace(); } } public void start() { Thread thread = new Thread(this); thread.start(); } }}
執行上面的程式,可以看到服務端收到了 org.xunche.app.HelloTraceAgent.sayHi 的請求和返回資訊。
receive message:Message{response=hi, xunche, 1581599464436, request=[xunche], className='org.xunche.app.HelloTraceAgent', methodName='sayHi'}
# 小結
在本章內容中,為大家介紹了 agent 的基本使用包括 premain 和 agentmain 。並通過 agentmain 實現了一個採集執行時方法呼叫資訊的小工具,當然由於篇幅和時間問題,程式碼寫的比較隨意,大家多體會體會思路。實際上, agent 的作用遠不止文章中介紹的這些,像 BTrace、arms、springloaded 等中也都有用到 agent 。