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參數,在服務啟動時進行使用。具體的例子如下:

Java 字節碼增強技術

其中具體的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

執行結果如下:

Java 字節碼增強技術

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");
 }
} 

執行結果如下:

Java 字節碼增強技術

2 字節碼修改

2.1 字節碼文件

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

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中。具體的截圖:

Java 字節碼增強技術

Java 字節碼增強技術

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

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

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

Java 字節碼增強技術

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);

具體的實現效果如下:

Java 字節碼增強技術

3 接口耗時統計

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

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

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

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


分享到:


相關文章: