JMCR 字节码插桩(一)

简述

本文内容将介绍 Java 字节码相关知识,以及如何通过 javaagent 技术加上 ASM 框架进行插桩。

本文提纲

  • 字节码
  • javaagent
  • ASM 框架
  • ASM + javaagent 插桩实战

系列文章:
1. JMCR 简介
2. JMCR 字节码插桩(一)
3. JMCR 字节码插桩(二)
4. JMCR 约束求解原理
5. JMCR 线程调度

一、Java 字节码

本节内容中的 Java 字节码介绍不会占用太多篇幅,若有兴趣,可以参考《深入理解Java虚拟机》。

对于一个 Java 编程人员,字节码这个概念一定不会陌生,作为 Java 语言和 机器码之间做翻译的中间语言,我们每一次编译一个 Java 类文件的时候都会生成一个 .class 文件。


编译过程

而 .class 文件会被加载到虚拟机(JVM)内执行,这时我们的程序才运行起来。
首先来看一个字节码是什么样的,以HelloWorld编译后的字节码为例:
源代码:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello ByteCode!");
    }
}

字节码本身就是一串字节流,如果使用 16进制编码的话,它看起来会是这样的 ——

  Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F   
00000000: CA FE BA BE 00 00 00 34 00 1D 0A 00 06 00 0F 09    J~:>...4........
00000010: 00 10 00 11 08 00 12 0A 00 13 00 14 07 00 15 07    ................
00000020: 00 16 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29    .....<init>...()
00000030: 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E    V...Code...LineN
00000040: 75 6D 62 65 72 54 61 62 6C 65 01 00 04 6D 61 69    umberTable...mai
00000050: 6E 01 00 16 28 5B 4C 6A 61 76 61 2F 6C 61 6E 67    n...([Ljava/lang
//... 

更人性化一点的手段是使用 javap -v HelloWorld.class 命令进行查看,它将字节码按照其结构翻译成了英文:

javap

字节码中主要包含下面几个部分

  • 元信息,包括魔数(magic number)、Java版本号信息、类修饰符等
  • 常量池,用于存放各种字符串常量信息
  • 函数,包括函数的修饰符、函数操作栈大小、局部变量表大小、字节码指令、LineNumberTable等信息。


    字节码结构

首先需要了解,Java的函数传参是通过栈实现的,然后我们主要关注一下 HelloWorld.class 中 main 函数的字节码指令——

    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello ByteCode!
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

第一条 getStatic #2,意味着把常量池中 2 号字符串(System.out)指向的变量值压入栈中
第二条 ldc #3,将常量池中3号字符串 Hello ByteCode! 的指针压入栈中
第三条 invokevirtual #4,意味着执行常量池中 4 号字符串(java/io/PrintStream.println:(Ljava/lang/String;)V)所指向的函数。println有一个参数,因此会从栈顶中取出字符串Hello ByteCode!的指针,然后在栈顶取出函数执行者 Syste.out

阅读字节码可以帮我们打开新的大门,可以探寻一些编译器对于 Java 源码做的手脚,例如原始类型的拆箱装箱——


Source code

Byte code

字节码文件是一个描述类的数据结构,有着严谨的结构,一定以 0x CAFEBABE 开头,然后是主版本号、次版本号,然后是类的描述等等,每一个类型都有着固定的大小,其偏移量可以通过确定的规则计算出来。 JVM 中的解释器通过顺序读取字节码指令,逐条进行解释,最终生成可执行的二进制文件。

字节码结构

思考一下,如果,我们在字节码被 JVM 解释之前,对其进行修改...

二、javaagent 技术

  1. 什么是javaagent
    在 JDK 1.5 中,Java 引入了 java.lang.Instrument 包,该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型,而 javaagent 是其中的一个关键组件。这项技术多中 agentmain 多用于热部署,提供一个在运行时修改字节码并重新加载入虚拟机的功能;而 premain 则是在字节码在执行之前拦截并修改,再读入虚拟机中执行。
javaagent premain
  1. javaagent premain 的使用

首先新建一个类,类中声明一个 premain 方法如下:

class Instrumentor{
    public static void premain(String agentArgs, Instrumentation inst) {
         inst.addTransformer(new ClassFileTransformer() {
            public byte[] transform(ClassLoader loader,
                                    String className,
                                    Class<?> classBeingRedefined,
                                    ProtectionDomain protectionDomain,
                                    byte[] classfileBuffer) throws IllegalClassFormatException {
                return classfileBuffer;
            }
        });
    }
}

这个类里面我们定义了premain函数,为 Inst 加上了一个字节码转换器,在一个字节码被加载如虚拟机之前,会执行一边 trasform 方法,这个方法中的 classFileBuffer 参数就是变化之前的字节码,而我们可以再函数里面进行一顿操作,返回的是转换后的,载入虚拟机的字节码,这里我们原封不动的返回,并没有做任何操作。

创建一个 MANIFEST.MF 文件,指定刚刚写好的Premain-Class

Manifest-Version: 1.0
Premain-Class: Instrumentor
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Created-By: 1.8.0_101 (Oracle Corporation)

使用 IDEA 将这个 MANIFEST 打包成agent.jar
然后通过运行时加上 -javaagent agent.jar 参数即可。

三、ASM

现在我们已经有了字节码,可以使用 ASM框架 来操作和修改字节码。

ASM 库是一款基于 Java 字节码层面的代码分析和修改工具。ASM 可以直接生产二进制的 class 文件,也可以在类被加载入 JVM 之前动态修改类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

ASM 通过树这种数据结构来表示复杂的字节码结构,并利用 Push 模型来对树进行遍历,在遍历过程中对字节码进行修改。

在 ASM 中,提供了一个 ClassReader 类,这个类可以直接由字节数组或由 class 文件间接的获得字节码数据,它能正确的分析字节码,构建出抽象的树在内存中表示字节码。它会调用 accept 方法,这个方法接受一个实现了 ClassVisitor 接口的对象实例作为参数,然后依次调用 ClassVisitor 接口的各个方法。字节码空间上的偏移被转换成 visit 事件时间上调用的先后,所谓 visit 事件是指对各种不同 visit 函数的调用,ClassReader 知道如何调用各种 visit 函数。在这个过程中用户无法对操作进行干涉,所以遍历的算法是确定的,用户可以做的是提供不同的 Visitor 来对字节码树进行不同的修改。ClassVisitor 会产生一些子过程,比如 visitMethod 会返回一个实现 MethordVisitor 接口的实例,visitField 会返回一个实现 FieldVisitor 接口的实例,完成子过程后控制返回到父过程,继续访问下一节点。

流程

下面来一个使用 ASM 生成字节码文件,并修改类的一些属性的例子:

import org.apache.xpath.compiler.OpCodes;
import org.objectweb.asm.*;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;

public class HelloWorld implements Opcodes {

    public static void main(String[] args) throws IOException {


        ClassWriter cw = new ClassWriter(0);
        MethodVisitor mv;

        cw.visit(V1_7, ACC_PUBLIC + ACC_SUPER, "HelloWorld", null, "java/lang/Object", null);

        cw.visitSource("HelloWorld.java", null);

        //默认初始化构造器
        {
            mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
            mv.visitCode();
            Label l0 = new Label();
            mv.visitLabel(l0);
            mv.visitLineNumber(9, l0);
            mv.visitVarInsn(ALOAD, 0);
            mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V");
            mv.visitInsn(RETURN);
            Label l1 = new Label();
            mv.visitLabel(l1);
            mv.visitLocalVariable("this", "LHelloWorld;", null, l0, l1, 0);
            mv.visitMaxs(1, 1);
            mv.visitEnd();
        }

        //public static void main方法
        {
            mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
            mv.visitCode();
            Label l0 = new Label();
            mv.visitLabel(l0);
            mv.visitLineNumber(12, l0);
            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitLdcInsn("Hello Bytecode!");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V");
            Label l1 = new Label();
            mv.visitLabel(l1);
            mv.visitLineNumber(13, l1);
            mv.visitInsn(RETURN);
            Label l2 = new Label();
            mv.visitLabel(l2);
            mv.visitLocalVariable("args", "[Ljava/lang/String;", null, l0, l2, 0);
            mv.visitMaxs(2, 1);
            mv.visitEnd();
        }
        cw.visitEnd();

        byte[] code = cw.toByteArray();
        File file = new File("HelloWorld.class");
        FileOutputStream output = new FileOutputStream(file);
        output.write(code);
        output.close();
    }
}

上述代码会生成一个 HelloWorld 字节码文件。内容与文章开头的例子一致。然后我们对这个字节码文件使用 ASM 再进行修改,在打印“Hello World”之前和之后,分别记录当前的时间,计算时间差并打印。

public class HelloWorld implements Opcodes {

    public static void main(String[] args) throws IOException {
        File file = new File("HelloWorld.class");
        InputStream inputStream = new FileInputStream(file);
        byte[] source = new byte[inputStream.available()];
        int a = inputStream.read(source);
        if (a == -1) {
            System.err.println("文件读取问题");
            System.exit(1);
        }
        ClassReader cr = new ClassReader(source);
        ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
        cr.accept(new ClassVisitor(ASM5, cw) {
            @Override
            public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
                super.visit(version, access, "HelloWorldInstrumented", signature, superName, interfaces);//更改类的名称为 HelloWorldInstrumented
            }

            @Override
            public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
                MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
                mv = new AdviceAdapter(ASM5, mv, access, name, desc) {
                    @Overri![
](https://upload-images.jianshu.io/upload_images/8081626-b7627e062e0d0f11.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
de
                    public void visitMethodInsn(int opcode, String owner, String name, String desc) {
                        if (opcode == INVOKEVIRTUAL) {
                            mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J");
                            mv.visitVarInsn(LSTORE, 1);
                            super.visitMethodInsn(opcode, owner, name, desc);
                            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                            mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J");
                            mv.visitVarInsn(LLOAD, 1);
                            mv.visitInsn(LSUB);
                            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V");
                        }
                    }
                };
                return mv;
            }

        }, ClassReader.EXPAND_FRAMES);

        byte[] code = cw.toByteArray();
        file = new File("HelloWorldInstrumented.class");
        FileOutputStream output = new FileOutputStream(file);
        output.write(code);
        output.close();
    }
}

转换后的字节码反编译结果如下:


具体详细的 ASM 的 Api 和使用的方法建议参考官网,而具体的细节,例如字节码指令的名称、方法的名称如果记不住的话,推荐一款 IDEA 的插件 —— ASM Bytecode Outline,安装后,可以右键查看类的字节码、以及如何通过ASM的方式构造这个类出来。

四、ASM + javaagent 插桩实战

其实在前面两个小节,我们已经分别讲述了两个工具的使用,接下来就是怎么结合的问题了。其实也很简单,在第二小节的javaagent示例中,在transform方法中直接将拦截到的字节流返回了,而第三小节中,ASM 框架的输入输出都是文件。不难想到,只需将 ASM 操作写到transform方法中,以参数为输入,输出到返回之中就行了。

public static void premain(String agentArgs, Instrumentation inst) {
    inst.addTransformer((loader, className, classBeingRedefined, protectionDomain, classfileBuffer) -> {
    ClassReader classReader = new ClassReader(classfileBuffer);
    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_FRAMES);
    ClassVisitor myClassTransformer = new MyClassTransformer(classWriter); //自定义ClassVisitor
    classReader.accept(myClassTransformer,ClassReader.EXPAND_FRAMES);
    classfileBuffer = classWriter.toByteArray();
 
    return classfileBuffer;
});

不妨尝试一下使用这个技术对于现有的程序进行插桩,达到每一次对于变量访问时,都会打印相关信息。
代码参考:https://github.com/tjuwhy/MyJMCR

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343