字節碼和字節碼加強

字節碼

Java的一次編寫處處運行就是靠的字節碼技術,java經過javac命令編譯源代碼爲字節碼文件,流程以下:java

經過字節碼,能夠進行各類AOP加強,好比ORM,熱部署機制等。字節碼有其規範,能夠幫助其餘JVM語言在JVM體系下運行,好比Scala,Groovy,Kotlin等。數據庫

字節碼組成

魔數apache

全部.class文件的前四個字節都是魔數,魔數值是固定的0xCAFEBABE(咖啡杯)。JVM根據關鍵字判斷一個文件是不是一個.class文件,是的話纔會繼續進行操做。編程

版本號api

版本號爲魔數以後的四個字節,前兩個表示次版本號,後兩個表示主版本號。數組

常量池服務器

在版本號以後的字節爲常量池。常量池中存儲兩類常量:字面量和符號引用。框架

字面量表示代碼中聲明爲final的常量值,符號引用如類和接口的全限名,字段名稱和描述符,方法名稱和描述符。 常量池分爲兩部分:常量池計數器和常量池數據區。jvm

訪問標誌maven

常量池以後的兩個字節描述Class是類仍是接口,及是否被public,abstract,final等修飾。

當前類名

訪問標誌以後的兩個字節,描述的是類的全限名,這兩個字節保存的值爲常量池中的索引值,根據索引值在常量池中找到這個類的全限名。

父類名稱

當前類名後的兩個字節,描述父類的全限名,同上,保存的是常量池中的索引值。

接口信息

父類名稱以後的兩個字節的接口計數器,描述了該類或父類實現的接口數量。緊接着的n個字節是全部接口名稱的字符串常量池索引值。

字段表

字段表用於描述類和接口中聲明的變量,包括類級別的變量和實例變量,可是不包含方法內部聲明的局部變量。

方法表

字段表後爲方法表,由兩部分組成,第一部分兩個字節描述方法的個數,第二部分每一個方法詳細信息。

附加屬性表

字節碼最後一部分,保存了文件中類或接口所定義屬性的基本信息。

工具和框架

經過idea插件jclasslib能夠查看字節碼。

字節碼加強

字節碼加強就是對目標字節碼進行修改或者動態生成新字節碼文件的技術。

ASM

asm能夠直接產生.class字節碼文件,也能夠在類被加載到jvm以前動態修改類行爲。 經常使用於AOP,cglib就是基於asm實現的,還能夠實現熱部署,修改jar包中類的能力。

舉個例子,咱們實如今方法調用先後加上新邏輯。

public class Base {
    public void process(){
        System.out.println("process");
    }
}

經過asm實現aop,建立MyClassVisitor類Generator類

public class Generator {
    public static void main(String[] args) throws Exception {
		//讀取
        ClassReader classReader = new ClassReader("meituan/bytecode/asm/Base");
        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        //處理
        ClassVisitor classVisitor = new MyClassVisitor(classWriter);
        classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
        byte[] data = classWriter.toByteArray();
        //輸出
        File f = new File("operation-server/target/classes/meituan/bytecode/asm/Base.class");
        FileOutputStream fout = new FileOutputStream(f);
        fout.write(data);
        fout.close();
        System.out.println("now generator cc success!!!!!");
    }
}
public class MyClassVisitor extends ClassVisitor implements Opcodes {
    public MyClassVisitor(ClassVisitor cv) {
        super(ASM5, 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);
        //Base類中有兩個方法:無參構造以及process方法,這裏不加強構造方法
        if (!name.equals("<init>") && mv != null) {
            mv = new MyMethodVisitor(mv);
        }
        return mv;
    }
    class MyMethodVisitor extends MethodVisitor implements Opcodes {
        public MyMethodVisitor(MethodVisitor mv) {
            super(Opcodes.ASM5, mv);
        }

        @Override
        public void visitCode() {
            super.visitCode();
            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitLdcInsn("start");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        }
        @Override
        public void visitInsn(int opcode) {
            if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)
                    || opcode == Opcodes.ATHROW) {
                //方法在返回以前,打印"end"
                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn("end");
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            }
            mv.visitInsn(opcode);
        }
    }
}

基於此實現了字節碼的修改,步驟以下:

  1. 經過MyClassVistor類的visitMethod方法,判斷當前字節碼讀到哪一個方法了。
  2. 進入到MyMethodVistor的visitCode方法,會在asm開始訪問某一個方法code區時被調用,從新visitCode方法,將aop中前置邏輯放在這裏。
  3. MyMethodVisitor繼續讀取字節碼指令。

Javassist

asm在指令層次上操做字節碼,使用起來比較晦澀。能夠採用代碼層次的字節碼框架Javassist。

使用Javassist實現字節碼加強時,不須要關注字節碼結構不須要了解虛擬機指令,關注java編程便可動態改變或生產類結構,主要用到如下幾類:

  1. CtClass:編譯時類信息,是一個class文件在代碼中抽象的表現形式,能夠經過全限名獲取一個CtClass對象,用來表示這個類文件。
  2. ClassPool:ClassPool是保存了CtClass信息的hashtable,key爲類名,value爲類的CtClass對象,當須要對某個類修改時,經過pool.getCtClass("classname")得到對應的CtClass。
  3. CtMethod,CtFild:對應類中的方法和屬性。

看個例子:

public class JavassistTest {
    public static void main(String[] args) throws NotFoundException, CannotCompileException, IllegalAccessException, InstantiationException, IOException {
        ClassPool cp = ClassPool.getDefault();
        CtClass cc = cp.get("meituan.bytecode.javassist.Base");
        CtMethod m = cc.getDeclaredMethod("process");
        m.insertBefore("{ System.out.println(\"start\"); }");
        m.insertAfter("{ System.out.println(\"end\"); }");
        Class c = cc.toClass();
        cc.writeFile("/Users/zen/projects");
        Base h = (Base)c.newInstance();
        h.process();
    }
}

對於已加載類進行加強

若是隻是在類加載前對類進行強化,字節碼的做用就比較窄類,咱們但願對於持續運行已經加載的全部的JVM類,能夠經過字節碼加強技術對類行爲進行替換並從新加載。

舉個例子,每五秒調用一次process()方法:

public class Base {
    public static void main(String[] args) {
        String name = ManagementFactory.getRuntimeMXBean().getName();
        String s = name.split("@")[0];
        //打印當前Pid
        System.out.println("pid:"+s);
        while (true) {
            try {
                Thread.sleep(5000L);
            } catch (Exception e) {
                break;
            }
            process();
        }
    }

    public static void process() {
        System.out.println("process");
    }
}

使用Javassist:

<dependency>
    <groupId>javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.12.1.GA</version>
</dependency>

入口類上面也說了,要實現 agentmain 和 premain 兩個方法。這兩個方法的運行時機不同。這要從 Java Agent 的使用方式來講了,Java Agent 有兩種啓動方式,一種是以 JVM 啓動參數 -javaagent:xxx.jar 的形式隨着 JVM 一塊兒啓動,這種狀況下,會調用 premain方法,而且是在主進程的 main方法以前執行。另一種是以 loadAgent 方法動態 attach 到目標 JVM 上,這種狀況下,會執行 agentmain方法。

public class MyCustomAgent {
    /**
     * jvm 參數形式啓動,運行此方法
     * @param agentArgs
     * @param inst
     */
    public static void premain(String agentArgs, Instrumentation inst){
        System.out.println("premain");
        customLogic(inst);
    }

    /**
     * 動態 attach 方式啓動,運行此方法
     * @param agentArgs
     * @param inst
     */
    public static void agentmain(String agentArgs, Instrumentation inst){
        System.out.println("agentmain");
        customLogic(inst);
    }

    /**
     * 打印全部已加載的類名稱
     * 修改字節碼
     * @param inst
     */
    private static void customLogic(Instrumentation inst){
        inst.addTransformer(new MyTransformer(), true);
        Class[] classes = inst.getAllLoadedClasses();
        for(Class cls :classes){
            System.out.println(cls.getName());
        }
    }
}

每一個方法都有參數agentArgsinst,agentArgs是啓動Java Agent時帶進來的參數,好比-javaagent:xxx.jar agentArgs。 inst是對於字節碼修改和程序監控實現的Instrumentation。好比經過inst.getAllLoadedClasses()能夠實現獲取全部已加載的類。

inst.addTransformer 實現類字節碼修改:

public class MyTransformer implements ClassFileTransformer {

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        System.out.println("正在加載類:"+ className);
        if (!"kite/attachapi/Person".equals(className)){
            return classfileBuffer;
        }

        CtClass cl = null;
        try {
            ClassPool classPool = ClassPool.getDefault();
            cl = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));
            CtMethod ctMethod = cl.getDeclaredMethod("test");
            System.out.println("獲取方法名稱:"+ ctMethod.getName());

            ctMethod.insertBefore("System.out.println(\" 動態插入的打印語句 \");");
            ctMethod.insertAfter("System.out.println($_);");

            byte[] transformed = cl.toBytecode();
            return transformed;
        }catch (Exception e){
            e.printStackTrace();

        }
        return classfileBuffer;
    }
}

邏輯就是碰到加載的類是kite.attachapi.Person的時候,在其中的test方法開始時插入一條打印語句,內容爲"動態插入的打印語句"。在test方法結尾處,打印返回值,其中$_就是返回值,這是 javassist 裏特定的標示符。

MANIFEST.MF 配置文件: 在目錄 resources/META-INF/ 下建立文件名爲 MANIFEST.MF 的文件,在其中加入以下的配置內容。

Manifest-Version: 1.0
Created-By: fengzheng
Agent-Class: kite.lab.custom.agent.MyCustomAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: kite.lab.custom.agent.MyCustomAgent

以後對Java Agent打包成jar:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-assembly-plugin</artifactId>
            <configuration>
                <archive>
                    <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
                </archive>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
            </configuration>
        </plugin>
    </plugins>
</build>

用的是 maven 的 maven-assembly-plugin 插件,注意其中要用 manifestFile 指定 MANIFEST.MF 所在路徑,而後指定 jar-with-dependencies ,將依賴包打進去。

上面這是一種打包方式,須要單獨的 MANIFEST.MF 配合,還有一種方式,不須要在項目中單獨的添加 MANIFEST.MF 配置文件,徹底在 pom 文件中配置上便可。

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-assembly-plugin</artifactId>
            <executions>
                <execution>
                    <goals>
                        <goal>attached</goal>
                    </goals>
                    <phase>package</phase>
                    <configuration>
                        <descriptorRefs>
                            <descriptorRef>jar-with-dependencies</descriptorRef>
                        </descriptorRefs>
                        <archive>
                            <manifestEntries>
                                <Premain-Class>kite.agent.vmargsmethod.MyAgent</Premain-Class>
                                <Agent-Class>kite.agent.vmargsmethod.MyAgent</Agent-Class>
                                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                                <Can-Retransform-Classes>true</Can-Retransform-Classes>
                            </manifestEntries>
                        </archive>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

這種方式是將 MANIFEST.MF 的內容所有寫做 pom 配置中,打包的時候就會自動將配置信息生成 MANIFEST.MF 配置文件打進包裏。

運行打包命令 接下來就簡單了,執行一條 maven 命令便可。

mvn assembly:assembly

最後打出來的 jar 包默認是以「項目名稱-版本號-jar-with-dependencies.jar」這樣的格式生成到 target 目錄下。

運行打包好的 Java Agent 首先寫一個簡單的測試項目,用來做爲目標 JVM,稍後會以兩種方式將 Java Agent 掛到這個測試項目上。

public class RunJvm {

    public static void main(String[] args){
        System.out.println("按數字鍵 1 調用測試方法");
        while (true) {
            Scanner reader = new Scanner(System.in);
            int number = reader.nextInt();
            if(number==1){
                Person person = new Person();
                person.test();
            }
        }
    }
}

以上只有一個簡單的 main 方法,用 while 的方式保證線程不退出,而且在輸入數字 1 的時候,調用 person.test()方法。

person類:

public class Person {

    public String test(){
        System.out.println("執行測試方法");
        return "I'm ok";
    }
}

以命令行的方式運行

由於項目是在 IDEA 裏建立的,爲了省事兒,我就直接在 IDEA 的 「Run/Debug Configurations」里加參數了。

-javaagent:/java-agent路徑/lab-custom-agent-1.0-SNAPSHOT-jar-with-dependencies.jar

而後直接運行就能夠看到效果了,會看到加載的類名稱。而後輸入數字鍵 "1",會看到字節碼修改後的內容。

以動態 attach 的方式運行

測試以前先要把這個測試項目跑起來,並把以前的參數去掉。運行後,找到這個它的進程id,通常利用jps -l便可。

動態 attach 的方式是須要代碼實現的,實現代碼以下:

public class AttachAgent {

    public static void main(String[] args) throws Exception{
        VirtualMachine vm = VirtualMachine.attach("pid(進程號)");
        vm.loadAgent("java-agent路徑/lab-custom-agent-1.0-SNAPSHOT-jar-with-dependencies.jar");
    }
}

運行上面的 main 方法 並在測試程序中輸入「1」,會獲得上圖一樣的結果。

發現了沒,咱們到這裏實現的簡單的功能是否是和 BTrace 和 Arthas 有點像呢。咱們攔截了指定的一個方法,並在這個方法裏插入了代碼並且拿到了返回結果。若是把方法名稱變成可配置項,而且把返回結果保存到一個公共位置,例如一個內存數據庫,是否是咱們就能夠像 Arthas 那樣輕鬆的檢測線上問題了呢。固然了,Arthas 要複雜的多,但原理是同樣的。

sun.management.Agent 的實現 不知道你平時有沒有用過 visualVM 或者 JConsole 之類的工具,其實,它們就是用了 management-agent.jar 這個Java Agent 來實現的。若是咱們但願 Java 服務容許遠程查看 JVM 信息,每每會配置上一下這些參數:

-Dcom.sun.management.jmxremote
-Djava.rmi.server.hostname=192.168.1.1
-Dcom.sun.management.jmxremote.port=9999
-Dcom.sun.management.jmxremote.rmi.port=9999
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false

這些參數都是 management-agent.jar 定義的。

咱們進到 management-agent.jar 包下,看到只有一個 MANIFEST.MF 配置文件,配置內容爲:

Manifest-Version: 1.0
Created-By: 1.7.0_07 (Oracle Corporation)
Agent-Class: sun.management.Agent
Premain-Class: sun.management.Agent

能夠看到入口 class 爲 sun.management.Agent,進到這個類裏面能夠找到 agentmain 和 premain,並能夠看到它們的邏輯。在這個類的開始,能看到咱們前面對服務開啓遠程 JVM 監控須要開啓的那些參數定義。

Instrument

Instrument 是jvm提供的一個能夠修改已加載類的類庫,實現java插樁。須要依賴JVMTI的Attach API機制。實現運行時對類定義的修改。 須要實現ClassFileTransformer接口,定義一個類文件轉換器,接口中的transform方法在類文件被加載時調用,在transform方法中能夠經過asm或javassist對傳入的字節碼進行替換或改寫,生成新的字節碼數組後返回。

public class TestTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        System.out.println("Transforming " + className);
        try {
            ClassPool cp = ClassPool.getDefault();
            CtClass cc = cp.get("meituan.bytecode.jvmti.Base");
            CtMethod m = cc.getDeclaredMethod("process");
            m.insertBefore("{ System.out.println(\"start\"); }");
            m.insertAfter("{ System.out.println(\"end\"); }");
            return cc.toBytecode();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

有了Transformer,如何將其注入到運行的jvm呢?還須要定義一個Agent,藉助Agent能力將Instrument注入到JVM中。

Agent被Attach到一個jvm中時,會執行類字節碼替換並重載入JVM的操做,Agent代碼以下:

public class TestAgent {
    public static void agentmain(String args, Instrumentation inst) {
        //指定咱們本身定義的Transformer,在其中利用Javassist作字節碼替換
        inst.addTransformer(new TestTransformer(), true);
        try {
            //重定義類並載入新的字節碼
            inst.retransformClasses(Base.class);
            System.out.println("Agent Load Done.");
        } catch (Exception e) {
            System.out.println("agent load failed!");
        }
    }
}

JVMTI&Agetn&Attach API

JVM TI是JVM提供的一套JVM進行操做的工具接口,能夠實現對JVM多種操做,經過接口註冊各類事件勾子,在JVM事件觸發時,觸發勾子。實現對JVM事件的相應。 事件包括:類文件加載,異常產生捕獲,線程啓動和結束,進入和退出臨界區,成員變量修改,GC開始和結束,方法調用進入和退出,臨界區競爭和等待,VM啓動和退出等。

Agent就是對JVMTI的一種實現,Agent有兩種啓動方式:隨Java進程啓動(java -agentlib),運行時載入(attach API將模塊jar包動態的Attach到指定進程id的java進程內)。

Attach API做用是提供JVM進程間通訊能力,好比讓另外一個JVM進程把線上服務器線程Dump出來,會運行jstack或jmap的進程,並傳遞pid參數,告訴對哪一個進程進行線程Dump,就是Attach API作的事情。

咱們經過Attach API對loadAgent方法將Agent jar包動態Attach到目標JVM上,步驟以下:

  1. 定義Agent,在其中實現AgentMain方法,相似於TestAgent類。
  2. 將TestAgent類打成一個包含MANIFEST.MF的jar,MANIFEST.MF文件中將Agent-Class屬性指定爲TestAgent的全限名。
  3. 利用Attach API將打包好的jar包Attach到指定JVM pid上。

public class Attacher {
    public static void main(String[] args) throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
        // 傳入目標 JVM pid
        VirtualMachine vm = VirtualMachine.attach("39333");
        vm.loadAgent("/Users/zen/operation_server_jar/operation-server.jar");
    }
}

因爲在MANIFEST.MF中指定了Agent-Class,因此在Attach後,目標JVM在運行時會走到TestAgent類中定義的agentmain()方法,在這個方法中,利用Instrumentation將指定類字節碼經過定義的類轉化器TestTransformet作Base類的字節碼替換(經過javassist),並完成類從新加載。實現了JVM運行時,改變類字節碼並從新載入類信息的目的。

效果以下:

先運行Base中的main方法,啓動一個jvm,在控制檯每隔5秒輸出一次process。接着執行Attacher中的main方法,將前一個JVM的PID傳入。此時打開前一個main方法控制檯,看到如今每隔5秒輸出process先後分別輸出了start和end,就是說完成了運行時的字節碼加強,並從新載入了這個類。

字節碼場景

總體上咱們瞭解類字節碼使用範圍不只侷限於JVM類加載以前了,經過幾個類庫能夠在運行時對類進行修改並重載,咱們能夠實現以下功能:

  1. 熱部署:在不對服務進行部署的狀況下實現對線上服務作修改,增長打點,增長日誌等操做。
  2. Mock數據:對某些服務進行Mock。
  3. 性能診斷:好比bTrace利用Instrument實現無侵入追蹤正在運行的JVM,監控到類和方法級別的狀態信息。
相關文章
相關標籤/搜索