Java字节码技术(二)字节码增强之ASM、JavaAssist、Agent、Instrumentation

  • Post author:
  • Post category:java




前言

在上篇文章

Java字节码技术(一)

中已经介绍了Java中字节码相关的基础概念。我们知道,Java代码转换后的JVM指令存在Code区中。如果能对Code区的指令进行新增、修改,即能达到增强字节码的效果。

字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术

先贴上文中Demo的代码地址

https://github.com/hosaos/JavaLearningDemo/tree/master/aop-demo

部分内容借鉴自

字节码增强技术探索



从AOP说起

用过Spring的同学肯定对AOP的概念不陌生,AOP能使得程序能在代码前后进行一些功能织入(如日志记录,执行耗时统计等)。

其特点是通过代理方式对目标类在运行期间织入功能。

AOP的实现技术有如下几种

在这里插入图片描述

AOP的实现离不开代理这个概念,什么是代理?我的理解就是:把对目标类的方法A的调用交由代理类来执行,在代理类的B方法中对目标类的方法A进行调用,同时在B方法中加入代理(增强)逻辑

以实例来说明,假设有如下接口AopDemoService,sayHello方法简单输出一句话,

要在原有输出的前后增加输出”start”,”end”




静态代理

程序运行前就已经存在代理类的字节码文件,代理类和原始类的关系在运行前就已经确定

public interface AopDemoService {
    void sayHello();
}
public class AopDemoServiceImpl implements AopDemoService  {
    @Override
    public void sayHello() {
        System.out.println("hello this is aop demo service");
    }
}


AopDemoProxy

即为静态代理类

public class AopDemoProxy implements AopDemoService {
    private AopDemoService targetService = new AopDemoServiceImpl();
    @Override
    public void sayHello() {
        System.out.println("start");
        targetService.sayHello();
        System.out.println("end");
    }
}


缺点:不灵活,每一个需要被代理的目标类,都需要实现一个代理类,增加类、增加方法都需要对代理类代码进行修改




动态代理



JavaProxy

动态代理解决了静态代理的问题,程序运行期间通过JVM反射等机制动态生成代理类,代理类和目标类的关系是运行时才确定的

Java中的动态代理,需要自定义实现一个

InvocationHandler

类,在其invoke方法中原有逻辑进行增强

public class DynamicHandler implements InvocationHandler {
    private Object target;

    public DynamicHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    	//编写增强逻辑
        System.out.println("start");
        Object result = method.invoke(target, args);
        System.out.println("end");
        return result;
    }
}

再通过

Proxy.newProxyInstance

创建代理类

public class JavaproxyDemo {

    public static void main(String[] args) {
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        //被代理的接口
        Class[] classes = new Class[]{AopDemoService.class};
        InvocationHandler handler = new DynamicHandler(new AopDemoServiceImpl());

        AopDemoService proxyService = (AopDemoService) Proxy.newProxyInstance(classLoader, classes, handler);
        proxyService.sayHello();
    }

}


缺点:实现类必须实现接口,Java中的动态代理通过传入的接口来反射生成一个新的类,在新的类中调用InvocationHandler.invoke对方法进行代理




CGLIB

当某个类没有实现某个接口时,可以通过

CGLIB来创建一个继承实现类的子类,用Asm库动态修改子类的代码来实现AOP效果

先定义一个类,不实现任何接口,其中有2个方法,一个普通方法,一个final修饰的方法

@Service
public class AopDemoServiceWithoutInterface {
    public void sayHello() {
        System.out.println("hello(normal method)");
    }

    public final void sayHelloFinal() {
        System.out.println("hello(final method)");
    }
}

demo如下,首先实现一个

MethodInterceptor

对方法进行拦截、增强,在目标方法调用前后加入输出语句

public class CglibMethodInterceptor implements MethodInterceptor {
    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println("start");
        Object result = methodProxy.invokeSuper(o, objects);
        System.out.println("end");
        return result;
    }
}

再调用

Enhancer

来动态生成子类,分别调用其普通方法和用final修饰的方法

public class CglibDemo {

    public static void main(String[] args) {
        CglibMethodInterceptor methodInterceptor = new CglibMethodInterceptor();
        Enhancer enhancer = new Enhancer();
        // TODO: 指定父类
        enhancer.setSuperclass(AopDemoServiceWithoutInterface.class);
        //指定方法拦截器
        enhancer.setCallback(methodInterceptor);

        AopDemoServiceWithoutInterface proxy = (AopDemoServiceWithoutInterface)enhancer.create();
		proxy.sayHello();
		System.out.println("------------");
        proxy.sayHelloFinal();
    }
}

结果如下

在这里插入图片描述

可以看到普通方法被增强了,前后输出了start、end。但是final修饰的方法并没有达到预期效果


缺点:不能对final修饰的类或方法进行增强




字节码增强实现AOP

既然CGLIB使用asm库动态修改子类的代码来实现AOP效果,那么能不能直接使用操作字节码的框架,来修改原有的字节码来达到增强效果呢?ASM和JavaAssist两个框架提供了修改字节码的功能



ASM

对于需要手动操纵字节码的需求,可以使用ASM,它可以直接生产 .class字节码文件,也可以在

类被加载入JVM之前

动态修改类行为。ASM的应用场景有AOP(Cglib就是基于ASM)、热部署、修改其他jar包中的类等

先看ASM对字节码操作的过程图

在这里插入图片描述

过程如下

  1. 先通过ClassReader读取编译好的.class文件
  2. 其通过访问者模式(Visitor)对字节码进行修改,常见的Visitor类有:对方法进行修改的MethodVisitor,或者对变量进行修改的FieldVisitor等
  3. 通过ClassWriter重新构建编译修改后的字节码文件、或者将修改后的字节码文件输出到文件中

既然Visitor是修改字节码的关键,看下如何基于ASM实现AOP功能,先看Visitor的实现

public class MyClassVisitor extends ClassVisitor implements Opcodes {
    public MyClassVisitor( ClassVisitor classVisitor) {
        super(ASM5, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {
        MethodVisitor methodVisitor = cv.visitMethod(i, s, s1, s2, strings);
        // TODO: 过滤掉构造函数及sayHello方法
        if (s.equals("<init>") || s.equals("sayHello")) {
            return methodVisitor;
        }
        return new MyMethodVisitor(methodVisitor);
    }

    class MyMethodVisitor extends MethodVisitor implements Opcodes{

        public MyMethodVisitor(MethodVisitor methodVisitor) {
            super(ASM5, methodVisitor);
        }

        @Override
        public void 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);
            super.visitCode();
        }

        @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.visitLdcInsn("end");
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            }
            super.visitInsn(opcode);
        }
    }
}
  1. 首先是MyClassVisitor,MyClassVisitor继承自ClassVisitor,用以观察某个类的字节码文件,其中visitMethod方法用于判断当前读取到字节码文件的哪个方法了,当读取到我们想进行增强的方法时,交给MyMethodVisitor对原方法进行增强
  2. MyMethodVisitor负责对具体方法进行增强,visitCode会在某个方法被访问时调用,故前置增强逻辑在此编写,visitInsn会在无参数的指令的执行时调用,退出语句return被调用时就会调用visitInsn方法,因此,后置增强逻辑可以写在这里
  3. 至于具体的增强指令visitFieldInsn,visitMethodInsn,并不是用java语句级别的,需要对字节码指令有一定了解,后面会介绍如何编写

Visitor介绍完了,就需要调用ClassReader、ClassWriter来读取原有的字节码文件,交由Visitor处理,并由ClassWriter来进行输出字节码了

代码如下

public class AsmDemo {
    public static void main(String[] args) throws IOException {
        // TODO: 读取字节码
        ClassReader classReader = new ClassReader("aop/demo/service/AopDemoServiceWithoutInterface");
        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        // TODO: 字节码增强
        ClassVisitor classVisitor = new MyClassVisitor(classWriter);
        classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
        byte[] data = classWriter.toByteArray();
        // TODO: 输出字节码到class文件
        File f = new File("/Users/chenyin/IdeaProjects/JavaLearningDemo/aop-demo/aop-service/target/classes/aop/demo/service/AopDemoServiceWithoutInterface.class");
        FileOutputStream fout = new FileOutputStream(f);
        fout.write(data);
        fout.close();
    }

}

执行后发现字节码已经被替换了,如果新建一个测试类调用AopDemoServiceWithoutInterface#sayHelloFinal方法,就能看到增强的效果了

在这里插入图片描述

至此,ASM框架修改字节码的整体思路就已经清晰了,其中很关键的一步就编写各种Visitor中的字节码增强指令,如图

在这里插入图片描述

但是不会写咋办?

介绍一个IDEA下的插件:

ASM Bytecode Outline

这个插件能够将Java代码转换成ASM中的指令实现,啥意思?ASM指令不会写?没关系,先写Java代码,再转换成ASM指令。比如,我先在原代码的前后加入自己的增强逻辑,如图

在这里插入图片描述

右键,show ByteCode outline,红色框内指令代码,即为我们需要的ASM指令,拷贝到对应ASM Vistor中即可

在这里插入图片描述




JavaAssist

ASM虽然可以达到修改字节码的效果,但是代码实现上更偏底层,是一个个虚拟机指令的组合,不好理解、记忆,和Java语言的编程习惯有较大差距。

利用Javassist实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。

其中最重要的是ClassPool、CtClass、CtMethod、CtField这四个类

  • ClassPool:保存CtClass的池子,通过classPool.get(类全路径名)来获取CtClass
  • CtClass:编译时类信息,它是一个class文件在代码中的抽象表现形式
  • CtMethod:对应类中的方法
  • CtField:对应类中的属性、变量

Demo如下,比较简单

public class JavaassistDemo {
    public static void main(String[] args) throws Exception {
        // TODO: 获取ClassPool
        ClassPool classPool = ClassPool.getDefault();
        CtClass ctClass = classPool.get("aop.demo.service.AopDemoServiceWithoutInterface");
        // TODO: 获取sayHelloFinal方法
        CtMethod ctMethod = ctClass.getDeclaredMethod("sayHelloFinal");
        // TODO: 方法前后进行增强
        ctMethod.insertBefore("{ System.out.println(\"start\");}");
        ctMethod.insertAfter("{ System.out.println(\"end\"); }");
        // TODO: CtClass对应的字节码加载到JVM里
        Class c = ctClass.toClass();
		//反射生成增强后的类
        AopDemoServiceWithoutInterface aopDemoServiceWithoutInterface = (AopDemoServiceWithoutInterface) c.newInstance();
        aopDemoServiceWithoutInterface.sayHelloFinal();
    }

}

相比ASM,JavaAssist的方式可以说更加简单、易懂




运行时类加载

但是JavaAssist又有什么缺点?

上面ASM和JavaAssist的Demo,都有一个共同点:

两者例子中的目标类都没有被提前加载到JVM中

,如果只能在类加载前对类中字节码进行修改,那将失去其存在意义,毕竟大部分运行的Java系统,都是在运行状态的线上系统。

先尝试下,在JVM提前加载了类的情况下,使用JavaAssist对字节码进行修改会发生什么,在上面的demo中加入如下语句,模拟类提前加载的情况

在这里插入图片描述

执行报错,错误信息如下

在这里插入图片描述

其报错原因是因为:

JVM是不允许在运行时动态重载一个类的

那么如何实现在JVM运行时去动态的重新加载类呢?

这就牵扯出了另外一个Java下的类库接口:

java.lang.instrument.Instrumentation




Instrumentation接口


instrument是JVM提供的一个可以修改已加载类的类库

。它需要依赖JVMTI的Attach API机制实现,在JDK 1.6之后,instrument支持了在运行时对类定义的修改。要使用instrument的类修改功能,我们需要实现它提供的ClassFileTransformer接口,定义一个类文件转换器。接口中的transform()方法会在类文件被加载时调用

先看下其关键方法

public interface Instrumentation {
	//添加一个类文件转换器
	void addTransformer(ClassFileTransformer transformer);
	//重新加载一个类,加载时触发ClassFileTransformer接口
	void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
}

我们需要实现ClassFileTransformer接口,并在自定义的transform方法中,利用ASM或者JavaAssist等字节码操作框架对类的字节码进行修改,修改后返回字节码的

byte[]数组

自定义实现如下ClassFileTransformer,过滤掉类名不是AopDemoServiceWithoutInterface的类,同时使用JavaAssist对AopDemoServiceWithoutInterface进行增强

public class MyClassTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        if (!className.equals("aop/demo/service/AopDemoServiceWithoutInterface")) {
            return null;
        }
        try {
            System.out.println("MyClassTransformer,当前类名:" + className);
            ClassPool classPool = ClassPool.getDefault();
            CtClass ctClass = classPool.get("aop.demo.service.AopDemoServiceWithoutInterface");
            CtMethod ctMethod = ctClass.getDeclaredMethod("sayHelloFinal");
            ctMethod.insertBefore("{ System.out.println(\"start\");}");
            ctMethod.insertAfter("{ System.out.println(\"end\"); }");
            return ctClass.toBytecode();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}



JavaAgent

光有Instrumentation接口还不够,如何将其注入到一个正在运行JVM的进程中去呢?我们还需要自定义一个Agent,借助Agent的能力将Instrumentation注入到运行的JVM中

Agent是JVMTI的一种实现,Agent有两种启动方式

  1. 一是随Java进程启动而启动,经常见到的java -agentlib就是这种方式;
  2. 二是运行时载入,通过attach API,将模块(jar包)动态地Attach到指定进程id的Java进程内



PremainClass随JVM进程启动

先介绍以vm命令java -agentlib注入agent的方式,看下如何实现一个自定义的JavaAgent,并将JavaAssist字节码增强内容以零侵入的方式嵌入到运行的JVM进程中

IDEA中先新建一个web工程,模拟类提前加载到JVM进程中的情况,其中TestController中调用

aopDemoServiceWithoutInterface.sayHelloFinal()

进行输出

在这里插入图片描述

默认不加Agent启动时,接口输出如下

在这里插入图片描述

定义如下类JavaAgent,指定其方法名为premain,并调用instrumentation新增一个自定义的ClassFileTransformer,意味着JVM进程启动时若参数中指定了该Agent,则会触发transform接口对类进行增强

public class JavaAgent {

    public static void premain(String agentArgs, Instrumentation instrumentation) {
        instrumentation.addTransformer(new MyClassTransformer(), true);
    }
}

在/resources/META-INF/MANIFEST.MF中新增如下命令:

Premain-Class: aop.demo.agent.JavaAgent

指定了启动类为JavaAgent

Manifest-Version: 1.0
Premain-Class: aop.demo.agent.JavaAgent
Can-Redefine-Classes: true
Class-Path: javassist-3.20.0-GA.jar
Can-Retransform-Classes: true

利用IDEA的打包机制,打出一个Agent包

在这里插入图片描述

我本地测试时,打出的java-agent包全路径为:

/Users/chenyin/IdeaProjects/JavaLearningDemo/out/artifacts/java_agent_jar/java-agent.jar

重启web工程,加入如下vm参数:

-javaagent:/Users/chenyin/IdeaProjects/JavaLearningDemo/out/artifacts/java_agent_jar/java-agent.jar

在这里插入图片描述

重启后可以看到触发了自定义的transform接口

在这里插入图片描述

再次调用接口,输出如下,前后输出了增加的语句

在这里插入图片描述

这种方式已Agent+JavaAssist的方式实现了零侵入方式的AOP,其原理就是JVM会优先调用PreMain方法(即Agent中的方法),后面才会调用Main方法。

但是缺点也是显而易见的,

Agent必须随着JVM进程启动而加载的方式,不够灵活

假设现在线上机器某个类报了异常,我想多加一行日志输出语句,以这种方式,只能对Agent重新打包,并重新启动JVM进程重新注入Agent



AgentClass以Attach方法注入Agent

随着进程启动的Premain方式的Agent更偏向是一种初始化加载时的修改方式,而Attach API的loadAgent()方法,能够将打包好的Agent jar包动态Attach到目标JVM上,是一种运行时注入Agent、修改字节码的方式

在这里插入图片描述

市面上诸如Arthas、Btrace这种JVM监控工具即是基于这种思路实现

看下实现思路

先实现一个AttachAgent,其中也是往instrumentation接口中加入一个自定义的ClassFileTransformer,同时调用

retransformClasses

方法重新加载AopDemoServiceWithoutInterface类,来触发ClassFileTransformer中的transform方法对字节码进行修改

public class AttachAgent {

    public static void agentmain(String agentArgs, Instrumentation instrumentation) {
        instrumentation.addTransformer(new MyClassTransformer(), true);
        try {
            // TODO: 重新对类加载 触发MyClassTransformer
            instrumentation.retransformClasses(AopDemoServiceWithoutInterface.class);
            System.out.println("attach agent 加载完毕");
        } catch (UnmodifiableClassException e) {
            e.printStackTrace();
        }
    }
}

MANIFEST.MF文件内容如下

Manifest-Version: 1.0
Agent-Class: aop.demo.agent.AttachAgent
Can-Redefine-Classes: true
Class-Path: javassist-3.20.0-GA.jar
Can-Retransform-Classes: true

打包过程不再赘述

测试类中加入如下代码,pid为JVM运行的进程号,可以通过jps命令获取,调用VirtualMachine.loadAgent方法,能够将指定Agent注入到pid对应的JVM进程中

 @GetMapping("/attachAgentTest")
    public String attachAgentTest(String pid) throws Exception {
        if (StringUtils.isEmpty(pid)) {
            return "pid can not be empty";
        }
        VirtualMachine vm = VirtualMachine.attach(pid);
        vm.loadAgent("/Users/chenyin/IdeaProjects/JavaLearningDemo/out/artifacts/attach_agent_jar/attach-agent.jar");
        return "success";
    }

浏览器中输入http://localhost:8080/test/attachAgentTest?pid=JVM运行的pid。来调用attachAgentTest方法来将Agent注入到指定JVM中

在这里插入图片描述

再调用测试接口,此时输出内容如下

在这里插入图片描述



总结

本文先从AOP出发,从动态代理说起,说明了JavaProxy和CGLIB的优缺点,以此为出发点,说明了字节码修改框架ASM、JavaAssist通过修改字节码来实现一个简单的AOP DEMO。再从JVM不允许运行时修改字节码为出发点,引出了Instrumentation接口来做运行时字节码文件的修改,再通过Attach API来做到运行时注入一个自定义Agent来实现零侵入修改字节码文件。



版权声明:本文为hosaos原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。