字节码和字节码增强

自作多情 提交于 2019-11-29 09:40:48

字节码

Java的一次编写到处运行就是靠的字节码技术,java通过javac命令编译源代码为字节码文件,流程如下:

通过字节码,可以进行各种AOP增强,比如ORM,热部署机制等。字节码有其规范,可以帮助其他JVM语言在JVM体系下运行,比如Scala,Groovy,Kotlin等。

字节码组成

魔数

所有.class文件的前四个字节都是魔数,魔数值是固定的0xCAFEBABE(咖啡杯)。JVM根据关键字判断一个文件是否是一个.class文件,是的话才会继续进行操作。

版本号

版本号为魔数之后的四个字节,前两个表示次版本号,后两个表示主版本号。

常量池

在版本号之后的字节为常量池。常量池中存储两类常量:字面量和符号引用。

字面量表示代码中声明为final的常量值,符号引用如类和接口的全限名,字段名称和描述符,方法名称和描述符。 常量池分为两部分:常量池计数器和常量池数据区。

访问标志

常量池之后的两个字节描述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,监控到类和方法级别的状态信息。
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!