字节码和字节码加强

字节码

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,监控到类和方法级别的状态信息。