Java 字節碼增強技術

1 JavaAgent技術

Java Agent 直譯過來叫做 Java 代理,還有另一種稱呼叫做 Java 探針。Java Agent 最終以 jar 包的形式存在。主要包含兩個部分,一部分是實現代碼,一部分是配置文件。

配置文件放在 META-INF 目錄下,文件名為 MANIFEST.MF 。包括以下配置項:

Manifest-Version: 版本號 Created-By: 創作者 Agent-Class: agentmain 方法所在類 Can-Redefine-Classes: 是否可以實現類的重定義 Can-Retransform-Classes: 是否可以實現字節碼替換 Premain-Class: premain 方法所在類

1.1 靜態加載

靜態加載的方式是通過-javaagent參數,在服務啟動時進行使用。具體的例子如下:

其中具體的TestAgent:

public class TestAgent { public static void premain(String args) { System.out.println("premain"); } }

Matifest.MF的格式內容:

Manifest-Version: 1.0 Premain-Class: com.study.code.TestAgent Can-Redefine-Classes: true

打包後,啟動另外一個jar,將當前的代理追加上去。具體的如下:

java -javaagent:/data/code/demo/target/agent.jar -jar target/simple.jar

執行結果如下:

1.2 動態attach

上面的過程中有一個問題,如果服務已經啟動了,怎麼去針對已經運行的服務進行監控,這裡JDK提供了動態的Attach技術。實現邏輯如下

public class TestAgent { public static void premain(String args) { System.out.println("premain"); } public static void agentmain(String agentArgs, Instrumentation inst){ System.out.println("agentmain"); Arrays.asList(inst.getAllLoadedClasses()).forEach(System.out::println); } }

增加了一個方法:

public static void agentmain(String agentArgs, Instrumentation inst)

動態的attach到目標JVM上後,會調用這個方法。靜態啟動時增加-javaagent默認會調用premain方法。

首先需要對代理打包為jar包,代碼對應的pom中build如下:

agent org.apache.maven.plugins maven-compiler-plugin 1.8 1.8 org.apache.maven.plugins maven-shade-plugin 2.4.3 package shade org.apache.maven.plugins maven-jar-plugin com.study.code.TestAgent com.study.code.TestAgent tools.jar true

這個時候代理用到的包已經準備好了,需要將這個jar植入到目標JVM中:

public class AttachAgent { public static void main(String[] args) throws Exception{ VirtualMachine vm = VirtualMachine.attach("498"); vm.loadAgent("/data/code/demo/target/agent.jar"); } }

其中VirtualMachine代碼一個JVM進程,提供了部分API可以對當前的JVM進行一些植入操作。具體的將當前所有的虛擬機進程全部列舉出來,然後根據輸入的進程編碼來植入探針打印所有的類,代碼如下:

public class AttachAgent { public static void main(String[] args) throws Exception{ //獲取所有的虛擬機進程 List list = AttachProvider.providers(); list.forEach(item ->item.listVirtualMachines().stream().filter(pid -> !pid.displayName().contains("IntelliJ IDEA")) .forEach(pid -> System.out.println(pid.id() + " " + pid.displayName()))); //等待用戶輸入,輸入後,選擇需要執行的進程號,然後植入相應的代理程序 Scanner scanner = new Scanner(System.in); VirtualMachine vm = VirtualMachine.attach(scanner.next()); vm.loadAgent("/data/code/demo/target/agent.jar"); } }

執行結果如下:

2 字節碼修改

2.1 字節碼文件

java源碼編譯後是字節碼文件,詳細見第一篇文章,字節碼文件的具體結構如下:

JVM加載並執行字節碼文件,也就是說在JVM加載執行時,我們可以對字節碼文件進行修改,增加一些自定義的代碼進去。以下會從接口耗時統計這個點開始,介紹兩個字節碼文件的修改工具。

2.2 Javassist

Javassist 是一個開源的分析、編輯和創建Java字節碼的類庫。其主要的優點,在於簡單,而且快速。直接使用 java 編碼的形式,而不需要了解虛擬機指令,就能動態改變類的結構,或者動態生成類。Javassist 中最為重要的是 ClassPool,CtClass ,CtMethod 以及 CtField 這幾個類。

下面針對寫的測試類,在方法中打印出當前方法的耗時,具體的邏輯如下:

修改TestAgent類:

public class TestAgent { public static void premain(String args) { System.out.println("premain"); } public static void agentmain(String agentArgs, Instrumentation inst) { System.out.println("agentmain"); // Arrays.asList(inst.getAllLoadedClasses()).forEach(System.out::println); //需要監控的類 transformClass("com.spring.boot.aop.test.SpringBootAopApplication", inst); } private static void transformClass(String clazz, Instrumentation ins) { Arrays.asList(ins.getAllLoadedClasses()).stream() .filter(item -> item.getName().equalsIgnoreCase(clazz)) .forEach(item -> { transform(item, item.getClassLoader(), ins); }); } private static void transform(Class> clazz, ClassLoader classLoader, Instrumentation ins) { try { System.out.println(clazz.getSimpleName()); ins.addTransformer(new SimpleTransformer(clazz.getName(), classLoader), true); ins.retransformClasses(clazz); } catch (UnmodifiableClassException e) { e.printStackTrace(); } } }

監聽com.spring.boot.aop.test.SpringBootAopApplication這個類,並使用SimpleTransformer來修改其中的hello方法,在方法中追加內容,打印當前方法的耗時。具體的SimpleTransformer為:

public class SimpleTransformer implements ClassFileTransformer { private String clazz; private ClassLoader classLoader; public SimpleTransformer(String clazz, ClassLoader classLoader) { this.clazz = clazz; this.classLoader = classLoader; } @Override public byte[] transform(ClassLoader loader, String className, Class> classBeingRedefined, ProtectionDomain protectionDomain, byte[] byteCode) throws IllegalClassFormatException { String target = this.clazz.replaceAll("\\.", "/"); if (!className.equals(target) || !loader.equals(classLoader)) { return byteCode; } try { ClassPool pool = ClassPool.getDefault(); CtClass ctClass = pool.get(clazz); CtMethod ctMethod = ctClass.getDeclaredMethod("hello"); // 開始時間 ctMethod.addLocalVariable("start", CtClass.longType); ctMethod.insertBefore("start = System.nanoTime();"); //結束時間 ctMethod.insertAfter("System.out.println(System.nanoTime() - start);"); //轉換為新的字節碼 byteCode = ctClass.toBytecode(); ctClass.detach(); } catch (Exception e) { e.printStackTrace(); } return byteCode; } }

將代理內容繼續打成一個jar包後,使用AttachAgent來根據輸入植入到某個JVM中。具體的截圖:

目標JVM進程是一個spring的測試項目,其中的controller的方法為:

@RequestMapping("/test") public String hello() { return "Hello Spring Boot"; }

調用該接口,查看輸出結果如下:

2.3 ASM技術

ASM Core API可以類比解析XML文件中的SAX方式,不需要把這個類的整個結構讀取進來,就可以用流式的方法來處理字節碼文件。好處是非常節約內存,但是編程難度較大。然而出於性能考慮,一般情況下編程都使用Core API。在Core API中有以下幾個關鍵類:

ClassReader:用於讀取已經編譯好的.class文件。ClassWriter:用於重新構建編譯後的類,如修改類名、屬性以及方法,也可以生成新的類的字節碼文件。各種Visitor類:如上所述,CoreAPI根據字節碼從上到下依次處理,對於字節碼文件中不同的區域有不同的Visitor,比如用於訪問方法的MethodVisitor、用於訪問類變量的FieldVisitor、用於訪問註解的AnnotationVisitor等。為了實現AOP,重點要使用的是MethodVisitor。

基於上面的SimpleTransformer進行修改,具體的如下:

@Override public byte[] transform(ClassLoader loader, String className, Class> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { String target = this.clazz.replaceAll("\\.", "/"); if (!className.equals(target) || !loader.equals(classLoader)) { return classfileBuffer; } //讀取相關字節碼信息 ClassReader classReader = new ClassReader(classfileBuffer); ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); //處理 ClassVisitor classVisitor = new SimpleClassVistor(classWriter); classReader.accept(classVisitor, ClassReader.SKIP_FRAMES); return classWriter.toByteArray(); } class SimpleClassVistor extends ClassVisitor implements Opcodes { public SimpleClassVistor(ClassVisitor cv) { super(ASM7, cv); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { cv.visit(version, access, name, signature, superName, interfaces); } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); System.out.println(name); if (!name.equals("") && mv != null) { mv = new SimpleMethodVisitor(mv); } return mv; } } class SimpleMethodVisitor extends MethodVisitor implements Opcodes { public SimpleMethodVisitor(MethodVisitor mv) { super(Opcodes.ASM7, mv); } @Override public void visitCode() { super.visitCode(); mv.visitMethodInsn( Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); mv.visitVarInsn(LSTORE,Type.LONG); } @Override public void visitInsn(int opcode) { if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) || opcode == Opcodes.ATHROW) { mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitMethodInsn( Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); mv.visitVarInsn(LLOAD,Type.LONG); mv.visitInsn(LSUB); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false); } mv.visitInsn(opcode); } }

在執行方法之前,插入一個變量,並將其壓入棧。

mv.visitMethodInsn( Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); mv.visitVarInsn(LSTORE,Type.LONG);

在方法返回前,增加一個變量,同時定義為Long類型,在將兩個變量相減後打印出來。

mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); mv.visitVarInsn(LLOAD,Type.LONG); mv.visitInsn(LSUB);

具體的實現效果如下:

3 接口耗時統計

上面通過兩種字節碼增強的技術來實現了接口耗時的打印。但是會發現其實需要三個邏輯才能實現:

1 agent的代碼打包 2 將agent植入到目標JVM中 3 目標JVM進程中執行某個方法,將植入後的信息打印出來

可以將agent的代碼和植入目標JVM的過程整合到一起,同時在植入的過程中通過ASM或者是自定義的方式植入端口監聽,這樣就可以支持當前attach進程和目標JVM之間的通信,將耗時信息從目標JVM取到attach進程中。

線上服務如果是接口服務,日誌的刷新等會非常快,同時一些觀察日誌不方便在目標進程中查看,這個時候需要可以在植入的同時將統計的結果從目標JVM拉回來進行分析,同時也可以實現一些自定義的展示形式和彙總。