Java Agent 學習筆記:使用 Byte Buddy

原文: http://nullwy.me/2018/10/java...
若是以爲個人文章對你有用,請隨意讚揚

Java 從 1.5 開始提供了 java.lang.instrumentdoc)包,該包爲檢測(instrument) Java 程序提供 API,好比用於監控、收集性能信息、診斷問題。經過 java.lang.instrument 實現工具被稱爲 Java Agent。Java Agent 能夠修改類文件的字節碼,一般是,在字節碼方法插入額外的字節碼來完成檢測。關於如何使用 java.lang.instrument 包,能夠參考 javadoc 的包描述(en, zh)。html

開發 Java Agent 的涉及的要點以下圖所示 [ref ]java

Java Agent

Java Agent 支持兩種方式加載,啓動時加載,即在 JVM 程序啓動時在命令行指定一個選項來啓動代理;啓動後加載,這種方式使用從 JDK 1.6 開始提供的 Attach API 來動態加載代理。git

<!--more-->github

啓動時加載 agent

最簡單的例子

如今建立命名爲 proj-demo 的 gradle 項目,目錄佈局以下:正則表達式

$ tree proj-demo
proj-demo
├── build.gradle
└── src
    ├── main
    │   └── java
    │       └── com
    │           └── demo
    │               └── App.java
    └── test
        └── java

7 directories, 2 files

com.demo.App 類的實現:apache

public class App {

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            System.out.println(getGreeting());
            Thread.sleep(1000L);
        }
    }

    public static String getGreeting() {
        return "hello world";
    }
}

運行 com.demo.App,每隔 1 秒輸出 hello worldapi

$ gradle build
$ java -cp "target/classes/java/main" com.demo.App
hello world
hello world

如今建立名稱爲 proj-premain 的 gradle 項目,com.demo.MyPremain 類實現 premain 方法:oracle

package com.demo;

public class MyPremain {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println(agentArgs);
    }
}

META-INF/MANIFEST.MF 文件指定 Premain-Class 屬性:app

jar {
    manifest {
        attributes 'Premain-Class': 'com.demo.MyPremain'
    }
    from {
        configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
    }
}

打包生成 proj-premain.jar,這個 jar 包就是 javaagent 代理。如今來試試運行 com.demo.App 時,啓動這個 javaagent 代理。根據 javadoc 的描述,能夠將如下選項添加到命令行來啓動代理:dom

-javaagent:jarpath[=options]

指定 -javaagent:"proj-premain.jar=hello agent",傳入的 agentArgshello agent,再次運行 com.demo.App

$ java -javaagent:"proj-premain.jar=hello agent" -cp "target/classes/java/main" com.demo.App
hello agent
hello world
hello world

能夠看到,在運行 main 以前,運行了 premain 方法,即先輸出 hello agent,每隔 1 秒輸出 hello world

修改字節碼

在實現 premain 時,除了能獲取 agentArgs 參數,還能獲取 Instrumentation 實例。Instrumentation 類提供 addTransformer 方法,用於註冊提供的轉換器 ClassFileTransformer

// 註冊提供的轉換器
void addTransformer(ClassFileTransformer transformer)

ClassFileTransformer 是抽象接口,惟一須要實現的是 transform 方法。在轉換器使用 addTransformer 註冊以後,每次定義新類時(調用 ClassLoader.defineClass)都將調用該轉換器的 transform 方法。該方法簽名以下:

// 此方法的實現能夠轉換提供的類文件,並返回一個新的替換類文件
byte[] transform(ClassLoader loader,
                 String className,
                 Class<?> classBeingRedefined,
                 ProtectionDomain protectionDomain,
                 byte[] classfileBuffer)
                 throws IllegalClassFormatException

操做字節碼可使用 ASM、Apache BCEL、Javassist、cglib、Byte Buddy 等庫。下面示例代碼,使用 BCEL 庫實現名爲 GreetingTransformer 轉換器。該轉換器實現的邏輯就是,將 com.demo.App.getGreeting() 方法輸出的 hello world,替換爲輸出 premain 方法的傳入的參數 agentArgs

public class MyPremain {
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new GreetingTransformer(agentArgs));
    }
}
import org.apache.bcel.*;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class GreetingTransformer implements ClassFileTransformer {
    private String agentArgs;

    public GreetingTransformer(String agentArgs) {
        this.agentArgs = agentArgs;
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        if (!className.equals("com/demo/App")) {
            return classfileBuffer;
        }
        try {
            JavaClass clazz = Repository.lookupClass(className);
            ClassGen cg = new ClassGen(clazz);
            ConstantPoolGen cp = cg.getConstantPool();
            for (Method method : clazz.getMethods()) {
                if (method.getName().equals("getGreeting")) {
                    MethodGen mg = new MethodGen(method, cg.getClassName(), cp);
                    InstructionList il = new InstructionList();
                    il.append(new PUSH(cp, this.agentArgs));
                    il.append(InstructionFactory.createReturn(Type.STRING));
                    mg.setInstructionList(il);
                    mg.setMaxStack();
                    mg.setMaxLocals();
                    cg.replaceMethod(method, mg.getMethod());
                }
            }
            return cg.getJavaClass().getBytes();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }
}

啓動後加載 agent

最先 JDK 1.5發佈 java.lang.instrument 包時,agent 是必須在 JVM 啓動時,經過命令行選項附着(attach)上去。但在 JVM 正常運行時,加載 agent 沒有意義,只有出現問題,須要診斷才須要附着 agent。JDK 1.6 實現了 attach-on-demand(按需附着) JDK-[4882798 ],可使用 Attach API 動態加載 agent [oracle blog, javadoc ]。這個 Attach API 在 tools.jar 中。JVM 啓動時默認不加載這個 jar 包,須要在 classpath 中額外指定。使用 Attach API 動態加載 agent 的示例代碼以下:

import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;

public class AgentLoader {

    public static void main(String[] args) throws Exception {
        if (args.length < 2) {
            System.err.println("Usage: java -cp .:$JAVA_HOME/lib/tools.jar"
                    + " com.demo.AgentLoader <pid/name> <agent> [options]");
            System.exit(0);
        }

        String jvmPid = args[0];
        String agentJar = args[1];
        String options = args.length > 2 ? args[2] : null;
        for (VirtualMachineDescriptor jvm : VirtualMachine.list()) {
            if (jvm.displayName().contains(args[0])) {
                jvmPid = jvm.id();
                break;
            }
        }

        VirtualMachine jvm = VirtualMachine.attach(jvmPid);
        jvm.loadAgent(agentJar, options);
        jvm.detach();
    }
}

啓動時加載 agent,-javaagent 傳入的 jar 包須要在 MANIFEST.MF 中包含 Premain-Class 屬性,此屬性的值是 代理類 的名稱,而且這個 代理類 要實現 premain 靜態方法。啓動後加載 agent 也是相似,經過 Agent-Class 屬性指定 代理類代理類 要實現 agentemain 靜態方法。agent 被加載後,JVM 將嘗試調用 agentmain 方法。

上文提到每次定義新類(調用 ClassLoader.defineClass)時,都將調用該轉換器的 transform 方法。對於已經定義加載的類,須要使用重定義類(調用 Instrumentation.redefineClass)或重轉換類(調用 Instrumentation.retransformClass)。

// 註冊提供的轉換器。若是 canRetransform 爲 true,那麼重轉換類時也將調用該轉換器
void addTransformer(ClassFileTransformer transformer, boolean canRetransform)
// 使用提供的類文件重定義提供的類集。新的類文件字節,經過 ClassDefinition 傳入
void redefineClasses(ClassDefinition... definitions)
                     throws ClassNotFoundException, UnmodifiableClassException
// 重轉換提供的類集。對於每一個添加時 canRetransform 設爲 true 的轉換器,在這些轉換器中調用 transform 方法 
void retransformClasses(Class<?>... classes)
                        throws UnmodifiableClassException

重定義類(redefineClass)從 JDK 1.5 開始支持,而重轉換類(retransformClass)是 JDK 1.6 引入。相對來講,重轉換類能力更強,當存在多個轉換器時,重轉換將由 transform 調用鏈組成,而重定義類沒法組成調用鏈。重定義類能實現的邏輯,重轉換類一樣能完成,因此保留重定義類方法(Instrumentation.redefineClass)可能只是爲了向後兼容 [stackoverflow ]。

實現 agentmain 的示例代碼以下,其中 GreetingTransformer 轉換器的類定義和上文同樣。

public class MyAgentMain {

    public static void agentmain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new GreetingTransformer(agentArgs), true);
        try {
            Class clazz = Class.forName("com.demo.App");
            if (inst.isModifiableClass(clazz)) {
                inst.retransformClasses(clazz);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

MANIFEST.MF 文件配置:

jar {
    manifest {
        attributes 'Agent-Class': 'com.demo.MyAgentMain'
        attributes 'Can-Redefine-Classes' : true
        attributes 'Can-Retransform-Classes' : true
    }
    from {
        configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
    }
}

須要注意的是,和定義新類不一樣,重定義類和重轉換類,可能會更改方法體、常量池和屬性,但不得添加、移除、重命名字段或方法;不得更改方法簽名、繼承關係 [javadoc ]。這個限制未來可能會經過 「JEP 159: Enhanced Class Redefinition」 移除 [ref ]。

使用 Byte Buddy

Byte Buddy(home, github, javadoc),運行時的代碼生成和操做庫,2015 年得到 Oracle 官方 Duke's Choice award,提供高級別的建立和修改 Java 類文件的 API,使用這個庫時,不須要了解字節碼。另外,對 Java Agent 的開發 Byte Buddy 也有很好的支持,能夠參考 Byte Buddy 做者 Rafael Winterhalter 寫的介紹文章 [ref1, ref2 ]。

上文使用 BCEL 實現的 GreetingTransformer,如今改用 Byte Buddy,會變得很是簡單。實現 premain 示例代碼:

public static void premain(String agentArgs, Instrumentation inst) {
    new AgentBuilder.Default()
            .type(ElementMatchers.named("com.demo.App"))
            .transform(new AgentBuilder.Transformer() {
                @Override
                public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder,
                                                        TypeDescription typeDescription,
                                                        ClassLoader classLoader,
                                                        JavaModule module) {
                    return builder.method(ElementMatchers.named("getGreeting"))
                            .intercept(FixedValue.value(agentArgs));
                }
            }).installOn(inst);
}

實現 agentmain

public static void agentmain(String agentArgs, Instrumentation inst) {
    new AgentBuilder.Default()
            .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
            .disableClassFormatChanges()
            .type(ElementMatchers.named("com.demo.App"))
            .transform(new AgentBuilder.Transformer() {
                @Override
                public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder,
                                                        TypeDescription typeDescription,
                                                        ClassLoader classLoader,
                                                        JavaModule module) {
                    return builder.method(ElementMatchers.named("getGreeting"))
                            .intercept(FixedValue.value(agentArgs));
                }
            }).installOn(inst);
}

另外,Byte Buddy 對 Attach API 做了封裝,屏蔽了對 tools.jar 的加載,能夠直接使用 ByteBuddyAgent 類:

ByteBuddyAgent.attach(new File(agentJar), jvmPid, options);

上文中的 AgentLoader,可使用這個 API 簡化,實現的完整示例參見 AgentLoader2

實現性能計時器

Byte Buddy 的 github 的 README 文檔提供了一個性能計時攔截器的代碼示例,能對某個方法的運行耗時作統計。如今咱們來看下是如何實現的。假設 com.demo.App2 類以下:

public class App2 {

    public static void main(String[] args) {
        while (true) {
            System.out.println(getGreeting());
        }
    }

    public static String getGreeting() {
        try {
            Thread.sleep((long) (1000 * Math.random()));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "hello world";
    }
}

使用 Byte Buddy 實現計時攔截器的 agent,以下:

public class TimerAgent {

    public static void premain(String agentArgs, Instrumentation inst) {
        new AgentBuilder.Default()
                .type(ElementMatchers.any())
                .transform((builder, type, classLoader, module) ->
                        builder.method(ElementMatchers.nameMatches(agentArgs))
                                .intercept(MethodDelegation.to(TimingInterceptor.class)))
                .installOn(inst);
    }
}
public class TimingInterceptor {

    @RuntimeType
    public static Object intercept(@Origin Method method, @SuperCall Callable<?> callable) throws Exception {
        long start = System.currentTimeMillis();
        try {
            return callable.call();
        } finally {
            System.out.println(method + " took " + (System.currentTimeMillis() - start) + "ms");
        }
    }
}

getGreeting 方法進行性能剖析,運行結果以下:

$ java -javaagent:"proj-byte-buddy.jar=get.*" -cp "target/classes/java/main" com.demo.App2
public static java.lang.String com.demo.App2.getGreeting() took 694ms
hello world
public static java.lang.String com.demo.App2.getGreeting() took 507ms
hello world

示例代碼中的 premain 參數 agentArgs 用於指定須要剖析性能的方法名,支持正則表達式。當實際參數傳入 get.* 時,匹配到 getGreeting 方法。上面的示例,使用的是 Byte Buddy 的方法委託 Method Delegation API [javadoc ]。Delegation API 實現原理就是,將被攔截的方法委託到另外一個辦法上,以下左圖所示(圖片來自 Rafael Winterhalter 的 slides)。這種寫法會修改被代理類的類定義格式,只能用在啓動時加載 agent,即 premain 方式代理。

若要經過 Byte Buddy 實現啓動後動態加載 agent,官方提供了 Advice API [javadoc ]。Advice API 實現原理上是,在被攔截方法內部的開始和結尾添加代碼,以下右圖所示。這樣只更改了方法體,不更改方法簽名,也沒添加額外的方法,符合重定義類(redefineClass)和重轉換類(retransformClass)的限制。

delegation vs. advice

如今來看下使用 Advice API 實現性能定時器的代碼示例:

public class TimingAdvice {

    @Advice.OnMethodEnter
    public static long enter() {
        return System.currentTimeMillis();
    }

    @Advice.OnMethodExit
    public static void exit(@Advice.Origin Method method, @Advice.Enter long start) {
        long duration = System.currentTimeMillis() - start;
        System.out.println(method + " took " + duration + "ms");
    }
}
public static void agentmain(String agentArgs, Instrumentation inst) {
    new AgentBuilder.Default()
            .disableClassFormatChanges()
            .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
//          .with(AgentBuilder.Listener.StreamWriting.toSystemOut())
            .type(ElementMatchers.any())
            .transform((builder, type, classLoader, module) ->
                    builder.visit(Advice.to(TimingAdvice.class)
                            .on(ElementMatchers.nameMatches(agentArgs))))
            .installOn(inst);
}

若對 com.demo.App 類,動態加載這個 Advice API 實現的 agent,getGreeting() 方法將會被重定義爲(真正的實現可能稍有不一樣,但原理一致):

public static String getGreeting() {
    long $start = System.nanoTime();
    String $result = "hello world";
    long $duration = System.nanoTime() – $start;
    System.out.println("App.getGreeting()" + " took " + $duration + "ms");
    return $result;
}

附註:本文中提到的代碼,能夠在 github 上訪問獲得,javaagent-demo

參考資料

相關文章
相關標籤/搜索