JVM-字节码增强技术

355 阅读8分钟

写在前面

该篇文档是对美团技术团队的分享《字节码增强技术的探索-赵泽恩》的简单整理(搬运🧱);源文档中对字节码文件、字节码增强技术分别做了阐述。

概述

Java 字节码增强技术是指在 Java 字节码层面对程序进行修改的技术,通常用于实现动态代理、AOP (面向切面编程)等功能,以及在运行时对类进行修改。

image.png

以上是一些常用的 Java 字节码增强技术的集合。

  • CGLIB CGLIB是一个代码生成库,它可以在运行时动态生成 Java 类的子类。CGLIB 可以用于实现动态代理、AOP,性能要比 JDK 自带的动态代理Java Proxy性能要高。
  • ASM ASM 是一个轻量级的 Java 字节码操作框架,它可以用于生成、修改字节码。ASM 提供了许多工具和 API,可以方便地对字节码进行操作。
  • AspectJ AspectJ 是一个基于 Java 语言的 AOP 框架,它可以在编译时或运行时对 Java 代码进行增强。AspectJ 支持在 Java 代码中定义切点和切面,并且可以使用注解或 XML 配置文件来指定增强规则。
  • Java Proxy JDK 自带的动态代理工具。
  • Javassist Javassist 是一个开源的 Java 字节码操作库,它提供了一组简单易用的 API,可以在运行时动态修改字节码。Javassist 还支持动态生成新的类和接口,并且可以在运行时加载、卸载这些类和接口。

ASM

对于需要手动操纵字节码的需求,可以使用ASM,它可以直接生成.class 文件,也可以在类被加载到 JVM 之前动态修改类行为。 ASM 的应用场景有 AOP(CGLIB)、热部署、修改其他 jar 包中的类等。接下来,文中将介绍 ASM 的两种 API,并使用 ASM 来实现一个比较粗糙的 AOP。

字节码文件的结构是由 JVM 固定的,所以很适用通过访问者模式对字节码进行修改。

image.png

ASM API

ASM 主要有两种 API:

  • ASM Core API
  • ASM Tree API

ASM Core API

ASM Core API 可以类比解析XML文件中的SAX方式,不需要把类的整个结构读取出来,就可以使用流的方式来处理字节码文件。这样的一个好处就是节约内存,但是编程的难度比较大。

ASM Core API 中主要有以下几个类:

  • ClassReader 用于读取.class文件。
  • ClassWriter 用于重新构建编译后的类,如修改类名、属性、方法,也可以用来生成新的类字节码文件。
  • Vistor 对于字节码文件中不同的内容存在不同的Vistor ,例如MethodVistorFieldVistorAnnotationVistor

ASM Tree API

ASM Tree API 可以类比解析XML文件中的DOM方式,把整个类的结构读取到内存中,但是编程比较简单。Tree API 通过各种 Node 类来映射字节码中的各个区域。

ASM 实现增强示例

下面通过 ASM Core API 来增强Base类,在Base类中的process()方法中“process”输出行的前后分别输出“start”、”end”。

public class Base 
{
    public void process(){ 
        // 增强的地方. 
        System.out.println("process"); 
        // 增强的地方. 
    } 
}

为了利用ASM实现AOP,需要自定义ClassVisitor类和MethodVisitor的实现,用于对字节码的编历、修改。实现如下:

import org.objectweb.asm.ClassVisitor; 
import org.objectweb.asm.MethodVisitor; 
import org.objectweb.asm.Opcodes; 

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类中有两个方法: 
        // 1. 无参构造 
        // 2. 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); 
    } 
}

MyClassVisitor继承自ClassVisitor,用于对字节码的观察。它还包含一个内部类MyMethodVisitor,继承自MethodVisitor用于对类内方法的观察。

另外定义了一个Generator类,作为代码增强的入口。在这个类中组织ClassReader实例、ClassWriter实例,以及自定义的Vistor实例完成字节码修改增强的逻辑。

import org.objectweb.asm.ClassReader; 
import org.objectweb.asm.ClassVisitor; 
import org.objectweb.asm.ClassWriter; 
public class Generator { 
    public static void main(String[] args) throws Exception { 
        // 1.读取 
        ClassReader classReader = new ClassReader("meituan/bytecode/asm/Base"); 
        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS); 
        // 2.处理 
        ClassVisitor classVisitor = new MyClassVisitor(classWriter); 
        classReader.accept(classVisitor, ClassReader.SKIP_DEBUG); 
        byte[] data = classWriter.toByteArray(); 
        // 3.输出 
        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!!!!!"); 
    } 
}

ASM 工具

在使用ASM进行代码增强编码时,有难度的事需要使用一些列visitXXXXInsn() 方法来写对应的助记符。ASM社区提供了工具 ASM ByteCode Outline

安装后,右键选择“Show Bytecode Outline”,在新标签页中选择“ASMified”,就可以看到这个类中的代码对应的ASM写法了。图中上下两个红框分别对应AOP中的前置逻辑、后置逻辑,将这两块直接复制到visitor中的visitMethod()以及visitInsn()方法中就可以了。

image.png

Javassist

ASM是在指令层上操作字节码,使用起来比较晦涩;相对的,Javassist是源代码层上操作字节码,其优点在于可读性强、简单。 Javassist 核心类主要有以下四个:

  • ClassPool 用来保存CtClass的一个数据结构,使用HashTable实现。
  • CtClass 编译时类(Compile-Time Class)信息,它是一个class文件在代码中的抽象。
  • CtMethod
  • CtField

使用Javassist可以更方便实现上面例子中对Base类的增强操作。

import com.meituan.mtrace.agent.javassist.*; 
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 中,先加载了一个类,然后又对其进行字节码增强并重新加载会发生什么呢?模拟这种情况,只需要将上文中 Javassist 的 Demo 中main()方法的第一行添加Base b = new Base(),即在增强前就先让 JVM 加载Base类,然后在执行到c.toClass()方法时会抛出错误,如下图所示。跟进c.toClass()方法中,我们会发现它是在最后调用了ClassLoader的 native 方法defineClass()时报错。也就是说,JVM是不允许在运行时动态重载一个类。

image.png 显然,如果只能在类加载前对类进行强化,那字节码增强技术的使用场景就比较局限。

Instrument

Instrument 是 JVM 提供的一个可以修改已加载类的库,专门为 Java 语言编写的插桩服务提供支持。Instrument 的实现需要依赖 JVMTI 的 Attach API 机制。在 JDK1.6 之前,Instrument 只能在 JVM 刚启动开始加载类时生效,在 JDK1.6 之后,Instrument 支持了在运行时对类定义的修改。

Java 中的插桩服务是指在程序运行时动态地修改字节码,以实现对程序的监控、调试、性能分析等功能的技术。

要使用Instrument的类修改功能,需要实现它的ClassFileTransformer接口来定义一个类文件转换器。接口中的transform()方法会在类文件被加载时调用,而在transform()方法里,可以使用ASM、Javassist对传入的字节码进行改写,生成新的字节码数组并返回。

import java.lang.instrument.ClassFileTransformer; 
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,还需要借助 Java Agent 机制。在 JDK1.6 之后,Instrumentation 可以做启动后的 Instrument、本地代码(Native Code)的Instrument,以及动态改变 Classpath 等。

import java.lang.instrument.Instrumentation; 
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!"); 
        } 
    } 
}

在以上代码中,将Transformer添加到了Instrumentation中。这样当 Agent 被 Attach 到 JVM 中时,就会执行Transformer中的代码增强操作。

JVMTI & Agent & Attach API

JPDA(Java Platform Debugger Architecture)是 Java 平台提供的一组调试接口和协议,用于支持 Java 程序的远程调试和动态调试。JPDA 主要包括三个组件:

  • Java 虚拟机工具接口(Java Virtual Machine Tool Interface,JVMTI)
    JVMTI 是一组 Java API,用于在 Java 虚拟机层面上实现调试器。JVMTI 可以用于获取 Java 虚拟机的状态信息、监控程序的执行、控制程序的行为等操作。
  • Java 远程调试协议(Java Debug Wire Protocol,JDWP)
    JDWP 是一种协议,用于在 Java 程序和调试器之间进行通信。JDWP 可以在本地或远程环境中使用,它支持多种传输协议,包括 TCP/IP、共享内存等。
  • Java 调试器接口(Java Debug Interface,JDI)
    JDI 是一组 Java API,用于在 Java 程序中启动和控制调试器。JDI 提供了一组类和接口,可以用于在程序运行时获取程序状态、执行代码、设置断点等操作。

image.png

JPDA 支持在程序运行时动态地修改类的定义、增加或删除方法等操作,以实现动态调试和修改。所以我们可以借助 JVMTI 的部分能力帮助实现运行时动态类的重载。通过 JVMTI,可以实现对 JVM 的多种操作,它通过接口注册各种事件勾子,在JVM事件触发时,同时触发预定义的勾子,以实现对各个 JVM 事件的响应,事件包括类文件加载、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、GC 开始和结束、方法调用进入和退出、临界区竞争与等待、VM 启动与退出等等。

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

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

Attach API 的作用是提供 JVM 进程间通信的能力,比如说使用jstackjmap命令Dump指定pid进程就是使用了 Attach API。

如何使用 Attach API 将自定义的 Agent Attach 到运行中的 JVM ?可以使用如下步骤:

  • 定义Agent
  • 定义MANIFEST.MF,使用Agent-Class属性指定 Agent 入口。
Manifest-Version: 1.0 
Agent-Class: {{The-full-name-of-defined-Agent-Class}} 
...
  • 使用 Attach API
import com.sun.tools.attach.VirtualMachine; 
public class Attacher { 
    public static void main(String[] args) throws AttachNotSupportedException, 
                        IOException, 
                        AgentLoadException, AgentInitializationException { 
        // 需要Attach的目标 JVM pid. 
        String pid = ... 
        VirtualMachine vm = VirtualMachine.attach(pid); 
        // 需要Attach的Agent类所在的jar包路径. 
        String agent = ... 
        // 加载Agent. 
        vm.loadAgent(agent); 
    } 
}

使用场景

字节码增强技术的可使用范围就不再局限于JVM加载类前。通过上述几个类库,我们可以在运行时对JVM中的类进行修改并重载了。通过这种手段,可以做的事情就变得很多了:

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

总结

字节码增强技术相当于是一把打开运行时JVM的钥匙,利用它可以动态地对运行中的程序做修改,也可以跟踪JVM运行中程序的状态。此外,我们平时使用的动态代理、AOP也与字节码增强密切相关,它们实质上还是利用各种手段生成符合规范的字节码文件。综上所述,掌握字节码增强后可以高效地定位并快速修复一些棘手的问题(如线上性能问题、方法出现不可控的出入参需要紧急加日志等问题),也可以在开发中减少冗余代码,大大提高开发效率。