简介

在JVM中运行中,类是通过classLoader加载.class文件进行生成的。在类加载器加载.class文件生成对应的类对象之前时,我们可以通过修改.class文件内容(就是字节码修改技术),达到修改类的目的。JDK提供了对字节码进行操作的一系列api,而使用这些api开发出的程序就可以称之为java agent。
java agent能做什么?
不修改目标应用达到代码增强的目的,就好像spring的aop一样,但是java agent是直接修改字节码,而不是通过创建代理类。例如skywalking就是使用java agent技术,为目标应用代码植入监控代码,监控代码进行数据统计上报的。这种方式实现了解耦,通用的功能。

探针说白了,就是在应用启动之前,比你的应用 main 方法更早启动的一个系统,它可以对你的系统的类进行拦截,你可以将它类比为一个更强大的 AOP 工具。

使用

入门例子

1.创建一个maven工程

Java探针技术实战 探针软件的功能是什么?_开发语言

2.创建DemoAgent类。

public class DemoAgent {
    /**
     * 该方法在main方法之前运行
     */
    public static void premain(String arg, Instrumentation instrumentation) {
        System.out.println("执行premain方法");
    }
}

3.创建TestCache类。

public class TestCache {
    public static void main(String[] args) {
        System.out.println("测试类");
    }
}

4.加入pom插件依赖

<build>
        <finalName>agent</finalName>
        <plugins>
            <plugin>
                <artifactId>maven-assembly-plugin</artifactId>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <appendAssemblyId>false</appendAssemblyId>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <outputDirectory>target</outputDirectory>
                    <archive>
                        <manifestEntries>
                            <!-- 包含premain方法的类 -->
                            <Premain-Class>DemoAgent</Premain-Class>
                            <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                            <addDefaultSpecificationEntries>true</addDefaultSpecificationEntries>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

如上依赖,注意,我去掉了我项目中的版本信息,你自己加上,另外,加了 maven-assembly-plugin 插件,指定了 DemoAgent 的位置,这非常重要,有点类似 SPI 的服务发现机制,如果没加,不会添加到 META-INF/MAINIFEST.MF  文件,也就不会加载 。

5.打包。

Java探针技术实战 探针软件的功能是什么?_Java探针技术实战_02

6.运行TestCache加入运行VM参数。

-javaagent:D:\idea\test\untitled\target\agent.jar

Java探针技术实战 探针软件的功能是什么?_局部变量_03

Java探针技术实战 探针软件的功能是什么?_Java探针技术实战_04

 

7.运行TestCache结果。

Java探针技术实战 探针软件的功能是什么?_Java探针技术实战_05

 

原理

两个重要的类 

Instrumentation: 由JDK提供的一个探针类,它会负责加载用户自定义的ClassFileTransformer。

public interface Instrumentation {
    //注册一个转换器,类加载事件会被注册的转换器所拦截
     void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
    //重新触发类加载
     void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
    //直接替换类的定义
     void redefineClasses(ClassDefinition... definitions) throws  ClassNotFoundException, UnmodifiableClassException;
}

ClassFileTransformer: 字节码转换类,jvm在加载class文件前会先调用它,对所有类加载器有效。

总结:JVM探针只是提供了一种让开发人员能够在类加载加载class文件前主动介入的一种方法,具体如何操作需要开发人员了解Java虚拟机规范以及字节码的相关知识。

栈帧与指令集

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

Java探针技术实战 探针软件的功能是什么?_java_06

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。并且在Java编译为Class文件时,就已经确定了该方法所需要分配的局部变量表的最大容量。局部变量表类似一个数组结构,虚拟机在访问局部变量表的时候会使用下标作为引用,普通方法的局部变量表中第0位索引默认是用于传递方法所属对象实例的引用this。

操作数栈(Operand Stack)和局部变量表一样,在编译时期就已经确定了该方法所需要分配的局部变量表的最大容量。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其它方法的时候是通过操作数栈来进行参数传递的。

动态链接(Dynamic Linking)每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支付方法调用过程中的动态连接。在类加载阶段中的解析阶段会将符号引用转为直接引用,这种转化也称为静态解析。另外的一部分将在每一次运行时期转化为直接引用,这部分称为动态连接。

返回地址:当一个方法开始执行后,只有2种方式可以退出这个方法,方法返回指令和异常退出。无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

JVM指令集并非是对Java语句的直接翻译,由于指令只使用1个字节表示,所以指令集最多只能包含256种指令。因此,一条Java语句一般会对应多条底层指令。每一条指令都有与之对应的助记符,我们可以通过官方资料查看它们对应关系:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html。为了帮助大家更加直观的理解字节码指令,我将通过三个用例分别解释。

从一个简单的加法函数开始,我们可以使用javac将.java文件编译成.class,再通过javap -c查看它的字节码文件

public int add(int x, int y) {  
    return x + y;  
}
public add(II)I
    ILOAD 1 // 将局部变量表中#1变量入栈
    ILOAD 2 // 将局部变量表中#2变量入栈
    IADD // 调用整型数相加(两个数出栈,再将结果入栈)
    IRETURN // 返回栈顶的结果
    MAXSTACK = 2 // 最大栈数2
    MAXLOCALS = 3 // 最大本地变量数3

第一行是它的函数签名,2~7行的注释分别是对指令的解释。ILOAD,IADD,IRETURN分别是整型数的入栈,加法和返回操作。大家可以将add方法修改为静态函数后重新编译,看看MAXLOCALS是否有变化。

接下来我们把函数变得复杂一些,尝试对函数的执行时间做一个计算并输出。

public int add(int x, int y) {  
  long t = System.nanoTime();  
  int ret = x + y;  
  t = System.nanoTime() - t;  
  System.out.println(t);  
  return ret;  
}
public add(II)I
    INVOKESTATIC java/lang/System.nanoTime ()J // 调用静态函数,结果long入栈
    LSTORE 3 // 将栈顶的long保存到局部变量#3
    ILOAD 1
    ILOAD 2
    IADD
    ISTORE 5 // 将栈顶的int保存到局部变量#5
    INVOKESTATIC java/lang/System.nanoTime ()J
    LLOAD 3 // 局部变量#3入栈
    LSUB // 从栈顶弹出两个long相减
    LSTORE 3 // 结果保存到变量#3
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream; // 获取静态引用
    LLOAD 3 // 局部变量#3入栈
    INVOKEVIRTUAL java/io/PrintStream.println (J)V // 调用函数
    ILOAD 5 //  局部变量#5入栈
    IRETURN
    MAXSTACK = 4
    MAXLOCALS = 6

第2行结尾的J表示函数返回值是long类型。第14行结尾的V表示println函数的返回值是void。第12行到第14行的指令对应代码的System.out.println(t),特别需要注意的是INVOKEVIRTUAL指令实际上需要从操作数栈获取两个数,第一个数是在执行了GETSTATIC后入栈的对象引用。

我们再次修改函数,这一次我们引入比较和循环语句,尽管代码的逻辑不太正常,但这并不妨碍我们理解。

public int add(int x, int y) {  
   if(x > 1) {  
       return x + y;  
   }  
   for(int i = 0; i < y; i++) {  
       x ++;  
   }  
   return x - y;  
}
public add(II)I
    ILOAD 1
    ICONST_1 // 将一个常整型数1入栈
    IF_ICMPLE L0 // 比较如果操两个操作数是小于等于的关系则成立,否则跳转到L0的位置继续
    ILOAD 1
    ILOAD 2
    IADD
    IRETURN
   L0
    ICONST_0 // 将常整型数0入栈
    ISTORE 3 // 栈顶数保存到局部变量#3
   L1
    ILOAD 3
    ILOAD 2
    IF_ICMPGE L2 // 比较栈顶的两个操作数是否是大于等于的关系,如果不成立则跳转到L2
    IINC 1 1 // 局部变量#1 自增1
    IINC 3 1 // 局部变量#3 自增1
    GOTO L1 // 跳转到L1执行
   L2
    ILOAD 1
    ILOAD 2
    ISUB
    IRETURN
    MAXSTACK = 2
    MAXLOCALS = 4

当我们使用字节码直接操作虚拟机中的底层代码的时候,基本上就是通过改变局部变量表和操作数栈来改变程序的逻辑。还记得根据Java虚拟机规范,MAXSTACK和MAXLOCALS是在.java文件被编译成.class就被确定下来的吗,如果我们要对方法做出修改势必会引入新的局部变量,这时就难免需要对MAXSTACK和MAXLOCALS做重新计算。好在目前流行的字节码框架已经可以自动帮助我们完成这项任务。

ASM框架

ASM是一个比较硬核的字节码框架,也是转换效率最高的工具。下面是常用类的介绍:

1. ClassReader
按照Java虚拟机规范(JVMS)中定义的方式来解析class文件中的内容,在遇到合适的字段时调用ClassVisitor中相对应的方法。

ClassReader(final byte[] classFile)
构造方法,通过class字节码数据加载
ClassReader(final String className) throws IOException
通过class全路径名从ClassLoader加载

2. ClassVisitor

java中类的访问者,提供一系列方法由ClassReader调用。调用的顺序如下:visit -> visitSource -> visitModule -> visitNestHost -> visitOuterClass -> visitAnnotation -> visitTypeAnnotation -> visitAttribute -> visitNestMember -> visitPermittedSubclass -> visitInnerClass -> visitRecordComponent -> visitField -> visitMethod -> visitEnd

3. ClassWriter

ClassVisitor的子类,通过它生成最后的字节码。并且它可以帮助重新计算MAXSTACK和MAXLOCALS

4. ModuleVisitor

Java中模块的访问者,作为ClassVisitor.visitModule方法的返回值

5. AnnotationVisitor

Java中注解的访问者,作为ClassVisito中visitTypeAnnotation和visitTypeAnnotation的返回值

6. FieldVisitor

Java中字段的访问者,作为ClassVisito.visitField的返回值

7. MethodVisitor

Java中方法的访问者,作为ClassVisito.visitMethod的返回值

visitMethodInsn 方法调用指令
visitVarInsn 局部变量调用指令
visitInsn(int) 访问一个零参数要求的字节码指令,如LSUB
visitLdcInsn 把一个常量放到栈顶
visitInvokeDynamicInsn 动态方法调用
visitFieldInsn 调用/访问某个字段
8. AnalyzerAdapter

MethodVisitor的子类,使用它重新计算最大操作数栈(MAXSTACK)

9. LocalVariablesSorter

MethodVisitor的子类,使用它重新计算局部变量表(MAXLOCALS)的索引

newLocal 创建局部变量
通过IDEA的Plugins安装ASM Bytecode Viewer Support Kotlin,我们可以借助这个插件来帮助我们生成大部分代码,具体用法这里就赘述了。

一个计算函数执行时间的完整用例

1.加入pom依赖

<dependencies>
        <dependency>
            <groupId>org.ow2.asm</groupId>
            <artifactId>asm</artifactId>
            <version>9.2</version>
        </dependency>
        <dependency>
            <groupId>org.ow2.asm</groupId>
            <artifactId>asm-commons</artifactId>
            <version>9.2</version>
        </dependency>
        <!-- Oshi 监听服务器状态-->
        <dependency>
            <groupId>com.github.oshi</groupId>
            <artifactId>oshi-core</artifactId>
            <version>5.6.1</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>agent</finalName>
        <plugins>
            <plugin>
                <artifactId>maven-assembly-plugin</artifactId>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <appendAssemblyId>false</appendAssemblyId>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <outputDirectory>target</outputDirectory>
                    <archive>
                        <manifestEntries>
                            <Premain-Class>DemoAgent</Premain-Class>
                            <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                            <addDefaultSpecificationEntries>true</addDefaultSpecificationEntries>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>


2.DemoAgent类。


import java.lang.instrument.Instrumentation;

public class DemoAgent {
    /**
     * 该方法在main方法之前运行
     */
    public static void premain(String arg, Instrumentation instrumentation) {
        instrumentation.addTransformer(new XClassFileTransformer());
    }
}

3.XClassFileTransformer类。

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

public class XClassFileTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader,
                            String className,
                            Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,
                            byte[] classfileBuffer){
        try {
            ClassReader cr = new ClassReader(classfileBuffer);
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
            cr.accept(new NanoTimerClassVisitor(cw), ClassReader.SKIP_DEBUG);
            byte[] cc = cw.toByteArray();
            return cc;
        } catch (Exception e) {
            return null;
        }
    }
}

transform方法返回null或者new byte[0]表示对当前字节码文件不进行修改。ClassWriter.COMPUTE_MAXS表示框架会自动计算MAXSTACK和MAXLOCALS,ClassReader.SKIP_DEBUG表示当字节码中包含调试信息的时候,会忽略不会触发回调。

4.NanoTimerClassVisitor类。

import org.objectweb.asm.*;
import org.objectweb.asm.commons.AnalyzerAdapter;
import org.objectweb.asm.commons.LocalVariablesSorter;

import java.util.Objects;

import static org.objectweb.asm.Opcodes.*;

public class NanoTimerClassVisitor extends ClassVisitor {
    private String className;

    public NanoTimerClassVisitor(ClassVisitor classVisitor) {
        super(ASM9, classVisitor);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        this.className = name;
        super.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        if (Objects.nonNull(mv) && !name.equals("<init>") && !name.equals("<clinit>")) {
            NanoTimerMethodVisitor methodVisitor = new NanoTimerMethodVisitor(mv, className, access, name, descriptor);
            return methodVisitor.refactor();
        }
        return mv;
    }

    class NanoTimerMethodVisitor extends MethodVisitor {
        private AnalyzerAdapter analyzerAdapter;
        private LocalVariablesSorter localVariablesSorter;
        private int timeOpcode;
        private int outOpcode;
        private String className;
        private int methodAccess;
        private String methodName;
        private String methodDescriptor;

        public NanoTimerMethodVisitor(MethodVisitor methodVisitor, String className, int methodAccess,
                                      String methodName, String methodDescriptor) {
            super(ASM9, methodVisitor);
            this.className = className;
            this.methodAccess = methodAccess;
            this.methodName = methodName;
            this.methodDescriptor = methodDescriptor;
            // 使用AnalyzerAdapter计算最大操作数栈
            analyzerAdapter = new AnalyzerAdapter(className, methodAccess, methodName, methodDescriptor, this);
            // LocalVariablesSorter重新计算局部变量的索引并自动更新字节码中的索引引用
            localVariablesSorter = new LocalVariablesSorter(methodAccess, methodDescriptor, analyzerAdapter);
        }

        public MethodVisitor refactor() {
            return localVariablesSorter;
        }

        @Override
        public void visitCode() {
            super.visitCode();
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
            timeOpcode = localVariablesSorter.newLocal(Type.LONG_TYPE);
            mv.visitVarInsn(LSTORE, timeOpcode);
        }

        @Override
        public void visitInsn(int opcode) {
            if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
                mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
                mv.visitVarInsn(LLOAD, timeOpcode);
                mv.visitInsn(LSUB);
                mv.visitVarInsn(LSTORE, timeOpcode);

                mv.visitLdcInsn(className + "." + methodName + "(ns):");
                outOpcode = localVariablesSorter.newLocal(Type.getType(String.class));
                mv.visitVarInsn(ASTORE, outOpcode);

                mv.visitVarInsn(ALOAD, outOpcode);
                mv.visitVarInsn(LLOAD, timeOpcode);
                mv.visitInvokeDynamicInsn("makeConcatWithConstants", "(Ljava/lang/String;J)Ljava/lang/String;", new Handle(Opcodes.H_INVOKESTATIC, "java/lang/invoke/StringConcatFactory", "makeConcatWithConstants", "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;",false), new Object[]{"\u0001\u0001"});
                mv.visitVarInsn(ASTORE, outOpcode);

                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitVarInsn(ALOAD, outOpcode);
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            }
            super.visitInsn(opcode);
        }
    }
}

5.测试类TestCache。

public class TestCache {
    public static void main(String[] args) {
        System.out.println("测试类");
    }
}

6.打包。

Java探针技术实战 探针软件的功能是什么?_开发语言_07

7.运行代码。

-javaagent:D:\idea\test\untitled\target\agent.jar

Java探针技术实战 探针软件的功能是什么?_Java探针技术实战_08

8. 通过assembly插件对项目进行打包生成:untitled-1.0-SNAPSHOT-jar-with-dependencies.jar或者agent.jar

9. 运行一个目标项目,并添加虚拟机指令-javaagent,就可以看到执行效果。

如何查看生成后的代码

计算函数执行时间是一个非常简单的功能,我们很容易一次性写正确。但是如果需要代理的逻辑比较复杂,而探针程序又不像普通程序一样方便做断点调试。我们如何才能够很方便知道生成的代码是否正确呢?这里告诉大家一个诀窍。回到我们XClassFileTransformer类,增加两行代码:

public class XClassFileTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader,
                            String className,
                            Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            ClassReader cr = new ClassReader(classfileBuffer);
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
            cr.accept(new NanoTimerClassVisitor(cw), ClassReader.SKIP_DEBUG);
            byte[] cc = cw.toByteArray();
            FileOutputStream fos = new FileOutputStream("./cc.class");
            fos.write(cc);
            return cc;
        } catch (IOException e) {

        }
        return null;
    }
}

 

第13、14行代码的功能是将生成的字节码输出到本地文件中,然后我们通过IDEA打开这个.class文件,看看新增加的代码是否如我们预期的那样。

总结:JVM代理发生在类加载器加载.class文件前,因此我们能够动态修改字节码。通过ASM这类字节码框架,使得开发人员即使对字节码指令不是很熟悉依然能够操作。当然,Java的探针技术除了和被代理的项目同时启动以外还提供了一种热部署的方案。