Java-字节码增强技术 | 周末学习

3,044 阅读10分钟

「本文已参与周末学习计划 ,点击查看详情」

前言

前段时间在看算法这块的东西,看的我是头昏脑胀。所以这几天又捡起了 《深入理解Java虚拟机》 这本书,这次主要看的是书中的 第三部分,我一直崇尚知识是在不断的总结和不断地学习相互交叉,这样才能学以致用,用而有据。

概念

字节码

字节码指的是 Java 中的 .java 文件经过编译( javac )后生成的固定格式文件 .class 文件以供 JVM 使用。 之所以被称为字节码文件是因为字节码文件是由十六进制值组成,JVM 以两个十六进制值为一组,即一个字节进行读取。同时 JVM 也针对不同操作系统和平台进行优化,这也就是 Java 号称 一次编译,到处运行 的根本原因。

由此又可以引出一个问题,由于 JVM 规范的存在,那么只要我们最终可以生成符合 JVM 规范的字节码文件那就可以在 JVM 上运行了,这也就产生了其他运行在 JVM 上的语言(如 Scale、Kotlin、Groovy ),可以通过其他语言可以扩展 Java 所没有的特性和语法糖。

字节码增强

字节码增强指的是在 Java 字节码生成之后,对其进行修改,增强其功能,这种方式相当于对应用程序的二进制文件进行修改。字节码增强的应用场景主要是减少冗余代码,对开发人员屏蔽底层的实现细节。

实现机制

  • 通过创建原始类的一个子类,也就是动态创建这个类继承原有的类,从而扩展原有类的方法。
  • 直接修改原有类生成的 Class 文件,在许多类的跟踪过程中都会用到(运行时修改、类加载时修改字节码信息)。

基础

字节码格式

一个 .java 文件经过编译( javac )后就会生成 .class 文件。

如下所示,左边为原始代码,右边为编译后的字节码: de80a67bdb1c0a47d6adb3a0420a826d1235757.png

字节码文件解析:

  • 魔数:每个 Class 文件头 4 个字节代表魔数,它代表了这个文件是否是一个能被虚拟机接受的 Class 文件。魔数固定值为:CAFEBABE有趣的是,魔数的固定值是 Java 之父 James Gosling 制定的,为 CafeBabe(咖啡宝贝),而 Java 的图标为一杯咖啡。

  • 版本号:前两个字节代表次版本号 minorversion,后两个字节代表主版本号 majorversion。将四个字节的十六进制值转换为十进制就是对应的版本号。

  • 常量池:常量池的大小是不固定的,会根据类中常量的多少来确定。其中首选由一个 2 个字节十六进制的数来定义常量池长度,计算出常量池的十进制是多少,然后减一得出常量池的数量。

    常量池的类型:

    常量类型描述
    CONSTANT_Utf8_infotag标志位为1UTF-8编码的字符串
    CONSTANT_Integer_infotag标志位为3整形字面量
    CONSTANT_Float_infotag标志位为4浮点型字面量
    CONSTANT_Long_infotag标志位为5长整形字面量
    CONSTANT_Double_infotag标志位为6双精度字面量
    CONSTANT_Class_infotag标志位为7类或接口的符号引用
    CONSTANT_String_infotag标志位为8字符串类型的字面量
    CONSTANT_Fieldref_infotag标志位为9字段的符号引用
    CONSTANT_Methodref_infotag标志位为10类中方法的符号引用
    CONSTANT_InterfaceMethodref_infotag标志位为11接口中方法的符号引用
    CONSTANT_NameAndType_info tag标志位为12字段和方法的名称以及类型的符号引用
    CONSTANT_Method-Handle_infotag标志位为15方法句柄
    CONSTANT_Method-Type_infotag标志位为16方法类型
    CONSTANT_Invoke-Dynamic_infotag标志位为18动态方法调用点

    常量池分布: ac90457d635b90e2c08bf7659b0b7dfd50229.png

  • 访问标志:常量池结束之后的两个字节,描述该 Class 是类还是接口,以及是否被 Public、Abstract、Final 等修饰符修饰。 访问标志的类型:

    标志名称标志值含义
    ACC_PUBLIC0X0001public 类型
    ACC_PRIVATE0X0002private 类型
    ACC_FINAL0X0010声明为 final,只有类可以设置
    ACC_SUPER0X0020使用invokespecial字节码指令的新语意,invokespecial指令的语意在JDK1.0.2发生过改变,为了区别这条指令使用哪种语意,JDK1.0.2之后编译出来的类都为真
    ACC_INTERFACE0X0200接口
    ACC_ABSTRACT0X0400abstract 类型,对于接口或者抽象类来说,此标志值为真,其他类为假
    ACC_SYNTHETIC0X1000这个类并非由用户代码产生
    ACC_ANNOTATION0X2000注解
    ACC_ENUM0X4000枚举
  • 当前类索引:访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。

  • 父类索引:当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。

  • 接口索引:父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的 N 个字节是所有接口名称的字符串常量的索引值。

  • 字段表:字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。 0f795d2b2b28ce96b5963efb2e564e5a197874.png

  • 方法表:字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。 0f795d2b2b28ce96b5963efb2e564e5a197874.png

  • 属性表:字节码的最后一部分,该项存放了在该文件中类或接口所定义属性的基本信息。

字节码文件中格式释义:

  • u2、u4 分别代表有两个字节、四个字节。
  • Class 类文件的伪结构中只有两种数据类型:无符号数(unsigned quantity)和表(table)。
    无符号数属于基本的数据类型,u2、u4 分别代表两个字节和四个字节的无符号数。而其余的 cp_info、field_info、method_info、attribute_info 就是表。

字节码常用工具


字节码增强

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

12e1964581f38f04488dfc6d2f84f003110966.png

JDK 动态代理

JDK 动态代理是利用反射机制生成一个实现接口的匿名内部类,在调用具体方法之前调用 InvokeHandler 来处理。 接口类:

public interface Demo {
	public int add(int x, int y);	
}

接口实现类:

public class DemoImpl implements Demo {
	@Override
	public int add(int x, int y) {
		return x + y;
	}	
}

代理实现处理类:

public class JdkProxyFactory implements InvocationHandler {  
    private Object target; // 被代理对象  
    public JdkProxyFactory(Object target) {  
        this.target = target; // 在构造方法对象时,传入被代理对象  
    }  
    public Object createProxy() {  
        return Proxy.newProxyInstance(target.getClass().getClassLoader(), 
        	target.getClass().getInterfaces(), this); // 三个参数: 类加载器、 实现接口、 invocationhandler  
    }
    @Override  
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {  
        System.out.println("jdk proxy invoke test!");  
        return method.invoke(target, args);  
    }  
}  

缺点: 使用 JDK 动态代理,必须要求 target 目标类实现接口,如果没有实现接口,就不能使用 JDK 动态代理。这个在 Spring 中关于 AOP 的实现也是如此,如果没有该类继承接口就采用 Cglib 动态代理来实现。

ASM

ASM 可以直接修改 .class 字节码文件,也可以在类被加载入 JVM 之前动态修改类行为。

大致流程就是 ClassReader 读取原有的字节码文件,然后经过 Visitor 处理字节码文件,最后通过 ClassWriter 生产新的字节码文件并替换原有的字节码文件。

核心 API

  • ClassReader : 用于读取已经编译好的 .class 文件。
  • ClassWriter : 用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
  • Visitor : CoreAPI 根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的 Visitor,比如用于访问方法的 MethodVisitor、用于访问类变量的 FieldVisitor、用于访问注解的 AnnotationVisitor 等。

使用 ASM 实现 AOP

原有基础类:

public class A { 
	public void operation() { 
		System.out.println("operation A ..."); 
	} 
}

重写 Visitor 类:

public class MyClassVisitor extends ClassVisitor implements Opcodes {
    public MyClassVisitor(ClassVisitor cv) { super(ASM5, cv); }
    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
        if (!name.equals("<init>") && mv != null) {
            mv = new MyMethodVisitor(mv); // A 类中有两个方法:无参构造以及 operation 方法,这里不增强构造方法
        }
        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"); // 方法在,打印"end"
            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) {
                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn("end"); // 方法在返回之前,打印"end"
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            }
            System.out.println(opcode);
            mv.visitInsn(opcode);
        }
    }
}

Main 类:

public class Generator {
    public static void main(String[] args) throws Exception {
        // 读取
       	ClassReader classReader = new ClassReader("cn.vgbhfive.bytecodedemo.A");
        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("/bin/cn/vgbhfive/bytecodedemo/A.class");
        FileOutputStream fout = new FileOutputStream(f);
        fout.write(data);
        fout.close();
        System.out.println("generator A success!!!!!");
    }
}

输出:

start
operation A ...
end

Javassist

ASM 是在指令层次上操作字节码的,在看完前面这些东西并上手操作后最直观感受是在指令层次上操作字节码的框架实现起来比较晦涩。那么就有了另外一类框架:强调源代码层次操作字节码的框架 Javassist

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

核心 API

  • CtClass ( compile-time class ) : 编译时类信息,它是一个 Class 文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个 CtClass 对象,用来表示这个类文件。
  • ClassPool : 从开发视角来看,ClassPool 是一张保存 CtClass 信息的 HashMapkey 为类名,value 为类名对应的 CtClass 对象。
  • CtMethod : 类中的方法。
  • CtField : 类中的属性。

Demo

public class JavassistTest {
    public static void main(String[] args) throws NotFoundException, CannotCompileException, IllegalAccessException, InstantiationException, IOException {
        ClassPool cp = ClassPool.getDefault();
        CtClass aa = cp.get("cn.vgbhfive.bytecodedemo.A");
        CtMethod m = aa.getDeclaredMethod("operation");
        m.insertBefore("{ System.out.println(\"start\"); }");
        m.insertAfter("{ System.out.println(\"end\"); }");
        Class c = aa.toClass();
        aa.writeFile("F://workSpace/projects");
        A a = (A) c.newInstance();
        a.operation();
    }
}

输出:

start
operation A ...
end

运行时类的重载

在上面我们解决了如何利用字节码文件来重写类中的方法,那么这就引出了另一个问题,修改后的字节码文件我们又当如何在运行中的 JVM 中重新加载修改后的字节码文件呢?

Instrument

InstrumentJVM 提供的一个可以修改已加载类的类库,专门为 Java 语言编写的插桩服务提供支持。它需要依赖 JVMTIAttach API 机制实现。

若想使用 Instrument 的类修改功能,就需要实现它的 ClassFileTransformer 接口,重新定义一个类文件转换器。 接口中的 transform() 方法会在类文件被加载时调用,而这个方法中可以利用上文中的 ASMJavassist 对传入的字节码进行改写或替换,最后生成新的字节码数组然后返回。

新的类文件转换器:

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

在新的类文件转换器有了之后,我们还需要一个 Agent,借助 Agent 的力量将 Instrument 注入到 JVM 中去。当 AgentAttach 到一个 JVM 中时,就会执行类字节码替换并重载入 JVM 的操作。

public class TestAgent {
    public static void agentmain(String args, Instrumentation inst) {
        inst.addTransformer(new TestTransformer(), true); // 指定我们自己定义的 Transformer,在其中利用 Javassist 做字节码替换
        try {
            inst.retransformClasses(Base.class); // 重定义类并载入新的字节码
            System.out.println("Agent Load Done.");
        } catch (Exception e) {
            System.out.println("agent load failed!");
        }
    }
}

JVMTIJVM TOOL INTERFACEJVM 工具接口)是 JVM 提供的一套对 JVM 进行操作的工具接口。 通过 JVMTI 可以实现对 JVM 的多种操作。例如通过接口注册各种事件勾子函数,在 JVM 事件触发时,同时触发预定义的勾子函数,以实现对各个 JVM 事件的响应,事件包括类文件加载、 异常产生与捕获、线程启动和结束、进入和退出临界区、 成员变量修改、 GC 开始和结束、 方法调用进入和退出、 临界区竞争与等待、 VM 启动与退出等等。

Attach API 的作用是提供 JVM 进程间通信的能力。

这里并不是本章的重点,所以就不说明了,想看的可以自己去看看


常用场景

  • 热部署: 不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
  • Mock: 测试时候对某些服务做 Mock
  • 性能诊断工具: 比如 bTrace 就是利用 Instrument ,实现无侵入地跟踪一个正在运行的 JVM ,监控到类和方法级别的状态信息。

总结

对于编程需要时刻 保持谦恭 之心,善于提出问题,也能根据问题找到对应的解决思路,形成 闭环 。这样不断地闭环就会构建出自己的知识体系。
还有一点就是需要善于总结自己已经掌握的知识,达到 产出产能平衡


参考

字节码增强技术探索
《深入理解Java虚拟机》 第三部分
Java字节码技术(二)字节码增强之ASM、JavaAssist、Agent、Instrumentation


个人备注

此博客内容均为作者学习所做笔记,侵删! 若转作其他用途,请注明来源!