深入Java自动化探针技术的原理和实践

转至作者 蒋志伟:深入Java自动化探针技术的原理和实践

前言

建议阅读时间 30~40分钟

读者需要对Java JVM 一定了解,文章会系统的介绍Java 探针核心原理和技术实现,总结目前一些主流的框架方案。同时,接下来我会分享一篇关于 OpenTelemetry 开发Java 探针的文章,而OpenTelemetry 源代码的核心实现正好基于本篇的知识。

如果喜欢文章的内容,欢迎分享留言
文章涉及技术概念

JVMTI、Java Agent、ASM、Java Instrumentation、Byte Buddy、Javassist、JVM Attach、JPLISAgent、Java Byte-Code

JVMTI 技术

JVM在设计之初,就考虑到了虚拟机状态的监控、程序 Debug、线程和内存分析等功能。在JDK1.5 之前,JVM规范就定义了JVMPI(Java Virtual Machine Profiler Interface)也就是JVM分析接口以及JVMDI(Java Virtual Machine Debug Interface)也就是JVM调试接口,JDK1.5 以及以后的版本,这两套接口合并成了一套,也就是Java Virtual Machine Tool Interface,就是JVMTI 。通过JVMTI 可以探查JVM内部的一些运行状态,甚至控制JVM应用程序的执行。

JVMTI 大体声明支持以下功能:

1. Java Heap与GC
获取所有类的信息,对象信息,对象引用关系,Full GC开始/结束,对象回收事件等。
2. 线程与堆栈
获取所有线程的信息,线程组信息,控制线程(start,suspend,resume,interrupt…), 
Thread Monitor(Lock),得到线程堆栈,控制出栈,方法强制返回,方法栈本地变量等。
3. Class & Object & Method & Field 元信息
Class信息,符号表,方法表,fields信息,Method信息等,Object信息。
redefine class(hotswap), retransform class
这里看到JVMTI 设计强大地方,他可以重新定义类对象!后面会聊到。
4. 工具类
线程CPU 消耗,ClassLoader路径修改,系统属性获取等

这里需要注意的是

  • • JVMTI是一套JVM的接口规范,不同的JVM实现方式可以不同,有的JVM提供了拓展性的功能,比如 openJ9,当然也可能存在JVM不提供这个接口的实现。

  • • JVMTI提供的是Native方式调用的API,也就是常说的JNI方式。JVMTI接口用C/C++的语言提供,最终以动态链接库的形式由JVM加载并运行 使用JNI方式调用JVMTI接口访问目标虚拟机的大体流程图

    深入Java自动化探针技术的原理和实践_第1张图片

其中jvmti.h头文件中定义了JVMTI接口提供的方法,我们简单看看JDK 7 里面源代码一些重要方法

https://github.com/openjdk-mirror/jdk7u-jdk/blob/master/src/share/javavm/export/jvmti.h

  /*   64 : Get Method Name 
(and Signature) */
  jvmtiError (JNICALL *GetMethodName) (jvmtiEnv* env,
    jmethodID method,
    char** name_ptr,
    char** signature_ptr,
    char** generic_ptr);

  /*   65 : Get Method Declaring Class */
  jvmtiError (JNICALL *GetMethodDeclaringClass) (jvmtiEnv* env,
    jmethodID method,
    jclass* declaring_class_ptr);

  /*   66 : Get Method Modifiers */
  jvmtiError (JNICALL *GetMethodModifiers) (jvmtiEnv* env,
    jmethodID method,
    jint* modifiers_ptr);

JVMTI API 里序号 64、65 方法是从JVM中获取程序定义所有方法和相关类。66 比较有意思了,它获取修改后的方法,这说明借助JVMTI 可以动态修改Java 程序的方法

  /*   152 : Retransform Classes */
  jvmtiError (JNICALL *RetransformClasses) (jvmtiEnv* env,
    jint class_count,
    const jclass* classes);

序号152 是一个非常重要的方法,JVMTI的 RetransformClasses函数来完成类的重定义过程,这也说明 JVMTI 可以修改Java程序整个类

// class_count - pre-checked to be greater than or equal to 0
// class_definitions - pre-checked for NULL
jvmtiError JvmtiEnv::RedefineClasses(jint class_count, const jvmtiClassDefinition* class_definitions) {
//TODO: add locking
  VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_redefine);
  VMThread::execute(&op);
  return (op.check_error());
} /* end RedefineClasses */

JVMTI 服务有两种打开方式:

  • • 在Java进程启动的时候通过 -agentpath:=方式启动,path-to-agent对应的JVMTI 接口实现的动态库文件的绝对路径,后面可以追加JVMTI 程序需要的参数。Linux动态库文件的后缀为 .so

  • • 运行时挂载 Attach ,然后加载JVMTI 接口实现的动态库文件 JVMTI 的Agent、Attach 用C、C++编写,具体感兴趣想实践的朋友可以看看下面的例子

https://github.com/liuzhengyang/jvmti_examples

用C、C++实现JVMTI 功能对大部分Java 工程师的确强人所难。于是,Sun 公司出了 Java Agent,一个用Java 实现JVMTI 的方案,方案相当优雅和容易上手。

Java Agent 技术由来

Java Agent 直译为 Java 代理,中文圈也流行另外一个称呼 Java 探针 Probe 技术。

它在 JDK1.5 引入,是一种可以动态修改 Java 字节码的技术。Java 类编译后形成字节码被 JVM 执行,在 JVM 在执行这些字节码之前获取这些字节码的信息,并且通过字节码转换器

ClassFileTransformer 对这些字节码进行修改,以此来完成一些额外的功能。

Java Agent 是一个不能独立运行 jar 包,它通过依附于目标程序的 JVM 进程,进行工作

//Java Agent 和目标进程一起启动模式
java -javaagent:myagent.jar=mode=test Test

Agent 启动拦截提供两种方式:一种是程序运行前:在Main方法执行之前,通过一个叫 premain方法来执行

启动时需要在目标程序的启动参数中添加 -javaagent参数,Java Agent 内部通过注册 ClassFileTransformer ,这个转化器在Java 程序 Main方法前加了一层拦截器。在类加载之前,完成对字节码修改

深入Java自动化探针技术的原理和实践_第2张图片Premain 完整工作流程图

另一种是程序运行中修改,需通过JVM中 Attach技术实现,Attach的实现也是基于 JVMTI

总结下,Java Agent 具备以下的能力

  • • Java Agent 能够在加载 Java 字节码之前拦截并对字节码进行修改;

  • • Java Agent 能够在 Jvm 运行期间修改已经加载的字节码;

    Java Agent 的价值

Java Agent 成熟的技术架构,有着对字节码通用的重写能力。它应用场景是非常广泛

  • • IDE 的调试功能,例如 Eclipse、IntelliJ IDEA

  • • 热部署功能,例如 JRebel、XRebel、spring-loaded

  • • 各种线上诊断工具,例如 Btrace、Greys,国内阿里的 Arthas

  • • 各种性能分析工具,例如 Visual VM、JConsole 等

  • • 全链路性能检测工具,例如 OpenTelemetry、Skywalking、Pinpoint等 接下来,我们用案例实现性能检测工具的Java Agent 探针

Java Agent 和 JVMTI 关系

我们大致了解下Java Agent 底层源代码实现过程

深入Java自动化探针技术的原理和实践_第3张图片

首先弄清几个概念,我发现网上总结特别乱

JVMTIAgent

JVMTIAgent 是一个动态库,它可以利用JVMTI暴露出的一些接口来实现一些特殊的功能。我们常用Eclipse、Idea等IDE 工具代码调试就是利用它。Java Agent 也是利用了其中一个JVMTIAgent 来实现的。在Linux里面,这个库叫做libinstrument.so,在BSD系统中叫做libinstrument.dylib,该动态链接库在{JAVA_HOME}/jre/lib/目录下。因为源代码里面add_init_agent函数里面传递进去的是一个叫做 instrument的字符串,所以也称它为instrument 动态库,对应启动Agent 称为 Instrument Agent

Instrument 动态链接库

Instrument 支持使用Java Instrumentation API 来编写Java Agent。

Java Instrumentation : 在Jdk1.5 后,Java语言中提供的调用动态库的 Java API 接口 。

在Instrument 中有一个非常重要的类称为:JPLISAgent(Java Programming Language Instrumentation Services Agent),它的作用是初始化所有通过Java Instrumentation API编写的Agent。很容易猜到,Java Instrumentation API 其实就是底层调用JVMTI 。

JVMTIAgent 包含这个几个基本函数

JNIEXPORT jint JNICALL
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
 
JNIEXPORT jint JNICALL
Agent_OnAttach(JavaVM* vm, char* options, void* reserved);

JNIEXPORT void JNICALL
Agent_OnUnload(JavaVM *vm); 
  • • Agent_OnLoad如果Agent 是在目标JVM 启动时加载(通过VM 参数 -agentpath:=方式),在启动过程中会去执行Agent 里Agent_OnLoad函数

  • • Agent_OnAttach如果Agent 通过Attach 方法启动,执行Attach 的JVM会给目标JVM 进程发送Load 命令来加载 Agent,在加载过程中调用就是 Agent_OnAttach函数

  • • Agent_OnUnload在Agent 做卸载的时候调用 Instrument 实现了Agent_OnLoadAgent_OnAttach 两方法,所以Java Agent 既可以在JVM启动时,也就是加载 Java 字节码之前启动,也可以在 JVM 运行时启动,这很有价值。

大致画了一下 Java Agent 和 JVMTI 的关系

深入Java自动化探针技术的原理和实践_第4张图片

Java Instrument Package

上面提到实现Agent 需要 Instrument 动态链接库支持。Java 语言中也提供了调用动态库的 Java API 接口 Instrumentation。有了 Instrumentation,开发者可以轻松使用Java语言操作字节码,来实现Java Agent 相关功能,Instrument Package 大致结构

java.lang.instrument.*
java.lang.instrument.Instrumentation;
public interface Instrumentation {}

Instrumentation 工作原理

SUN工具包(sun.instrument.InstrumentationImpl)编写了一些Native 方法,JDK里提供了这些Native方法的实现类(jdk\src\share\instrument\JPLISAgent.c),通过JNI 方式访问JVMTI提供的方法,这些方法就是定义在jvmti.h头文件中。

Instrumentation 核心功能

Instrumentation 接口有一个最重要方法 addTransformer,它用于添加多个ClassFileTransformer。类似下面 Java Agent 实现的例子

深入Java自动化探针技术的原理和实践_第5张图片

ClassFileTransformer 中文类转换器,ClassFileTransformer提供了tranform()方法,用于对加载的类进行增强重定义,返回新的类字节码流。

Instrumentation 有一个TransformerInfo 数组保存ClassFileTransformer,像拦截器链表一样,顺序的进行字节码的重定义。

// 说明:添加ClassFileTransformer
// 第一个参数:transformer,类转换器
// 第二个参数:canRetransform,经过transformer转换过的类是否允许再次转换
void Instrumentation.addTransformer(ClassFileTransformer transformer, boolean canRetransform)

// 说明:对类字节码进行增强,返回新的类字节码定义
// 第一个参数:loader,类加载器
// 第二个参数:className,内部定义的类全路径
// 第三个参数:classBeingRedefined,待重定义/转换的类
// 第四个参数:protectionDomain,保护域
// 第五个参数:classfileBuffer,待重定义/转换的类字节码(不要直接在这个classfileBuffer对象上修改,需拷贝后进行)
// 注:若不进行任何增强,当前方法返回null即可,若需要增强转换,则需要先拷贝一份classfileBuffer,在拷贝上进行增强转换,然后返回拷贝。
byte[] ClassFileTransformer.transform(ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte classfileBuffer)

下面我们写Java Agent时候,就会实现这两个重要的方法。通过 Instrument API 方式使用到了JVMTI提供部分功能,对开发者来说,主要提供的是对JVM加载的类字节码进行增强操作,等价于有了全局、动态修改Java程序代码的能力。

总结

到这里,是不是想到:我们是否可以通过Agent ,在所有类方法中插入额外的字节码,这些字节码功能就是获取程序内部数据,然后上报给某个地方。是的,我们很多通用的Java 监控、Debug、日志记录工具就是基于它来实现的。而且,插入的字节码是附加的,这些更变不会修改原来程序正常逻辑和状态,只是会有一些性能损耗,对应用程序本身基本是安全可靠的。

JVMTI 和 Java Instrument 对比


JVMTI方式 Java Instrument API
性能 独立进程,不受目标JVM影响 在目标JVM内,GC时会受到影响
功能性 方法众多,功能非常全面 字节码的操作
易用性 需要掌握C/C++,以及JNI开发相关知识 Java代码开发,上手快

Agent启动方式

程序运行前加载

目标JVM 启动时指定-javaagent:xxx.jar参数来启动 Java Agent, 这里 xxx.jar 是探针的JAR包. 比如 OpenTelemetry 运行Java 探针的指令

java -javaagent:path/to/opentelemetry-javaagent.jar \
     -jar myapp.jar

程序启动时,优先加载Java Agent,执行里面的 premain方法。这个时候,其实大部分的类没有被加载。

Jar 打包规则

探针的 JAR包需要做以下配置

JAR包里MANIFEST.MF文件添加一个Premain-Class属性,指定一个实现了premain方法的类,加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项

MANIFEST.MF 大致如下配置

PreMain-Class: AgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true

premain 方法声明

// JVM启动时调用,其执行时Class 还未加载到JVM
public static void premain(String agentArgs, Instrumentation inst);
public static void premain(String agentArgs);

下面是我们实现premain 方法的一个测试类 

深入Java自动化探针技术的原理和实践_第6张图片

Premain 方法工作原理

目标JVM 启动时,运行JNI 的 Agent_OnLoad 函数,执行如下步骤:

  • • 创建 InstrumentationImpl 对象

  • • 监听 ClassFileLoadHook 事件

  • • 调用 InstrumentationImpl 的 loadClassAndCallPremain 方法,此方法里去Agent Jar 包中找到MANIFEST.MF声明的Premain-Class类,执行类里面的 premain方法

  • • premain方法里面,我们可以调用Java Instrumentation API 完成字节码增强功能了 

    深入Java自动化探针技术的原理和实践_第7张图片

复习Java Byte-code 字节码概念

维基百科字节码中文解释

字节码(英语:Bytecode)通常指的是已经经过编译,但与特定机器代码无关,需要解释器转译后才能成为机器代码的中间代码。字节码通常不像源码一样可以让人阅读,而是编码后的数值常量、引用、指令等构成的序列。字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机。编译器将源码编译成字节码,特定平台上的虚拟机将字节码转译为可以直接执行的指令。字节码的典型应用为Java bytecode 

深入Java自动化探针技术的原理和实践_第8张图片Java 程序运行原理

Java Byte-code: Java语言写出的源代码首先需要编译成class文件,即字节码文件,然后被JVM加载并运行,每个class文件具有如下固定的数据格式

ClassFile {
    u4             magic;           // 魔数,固定为0xCAFEBABE
    u2             minor_version;   // 次版本
    u2             major_version;   // 主版本,常见版本:52对应1.8,51对应1.7,其他依次类推
    u2             constant_pool_count;                     // 常量池个数
    cp_info        constant_pool[constant_pool_count-1];    // 常量池定义
    u2             access_flags;    // 访问标志:ACC_PUBLIC, ACC_INTERFACE, ACC_ABSTRACT等
    u2             this_class;      // 类索引
    u2             super_class;     // 父类索引
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

class文件总是一个魔数开头,后面跟着版本号,然后就是常量定义、访问标志、类索引、父类索引、接口个数和索引表、字段个数和索引表、方法个数和索引表、属性个数和索引表。

字节码增强技术

Agent 本质是通过操作字节码,动态修改运行时Java对象。

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

字节码增强技术的实现有很多方式,简单整理下目前比较成熟的一些操作字节码的框架

深入Java自动化探针技术的原理和实践_第9张图片

JDK动态代理运行期动态的创建代理类,只支持接口;

ASM一个 Java 字节码操控框架。它能够以二进制形式修改已有类或者动态生成类。不过ASM在创建class字节码的过程中,操纵的级别是底层JVM的汇编指令级别,这要求ASM使用者要对class组织结构和JVM汇编指令有一定的了解;

Javassist一个开源的分析、编辑和创建Java字节码的类库(源码级别的类库)。Javassist是Jboss的一个子项目,其主要的优点,在于简单,而且快速。直接使用Java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构,或者动态生成类;

Byte Buddy是一个较高层级的抽象的字节码操作工具,相较于ASM 而言。Byte Buddy 本身也是基于 ASM API 实现的。Byte Buddy以出色的性能,被著名的框架和工具(例如Mockito,Hibernate,Jackson,Google的Bazel构建系统等)使用

ASM

ASM 可以直接产生二进制.class文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为(也就是生成的代码可以覆盖原来的类也可以是原始类的子类)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

不过ASM在创建class 字节码的过程中,操纵的级别是底层JVM的汇编指令级别,这要求ASM使用者要对class 组织结构和JVM汇编指令有一定的了解。ASM提供了两组API: Core API 和Tree API,Core API是基于访问者模式来操作类的,而Tree是基于树节点来操作类的

简单写一个ASM 运行例子

public class ASMDemo extends ClassLoader{
    public static  T getProxy(Class clazz) throws Exception {

        ClassReader classReader = new ClassReader(clazz.getName());
        ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);
        classReader.accept(new ClassVisitor(ASM5, classWriter) {
            @Override
            public MethodVisitor visitMethod(int access, final String name, String descriptor, String signature, String[] exceptions) {
                // 方法过滤
                if (!"hi".equals(name))
                    return super.visitMethod(access, name, descriptor, signature, exceptions);
                final MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
                return new AdviceAdapter(ASM5, methodVisitor, access, name, descriptor) {
                    @Override
                    protected void onMethodEnter() {
                        // 执行指令;获取静态属性
                        methodVisitor.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                        // 加载常量 load constant
                        methodVisitor.visitLdcInsn("方法名: "+name + "  你被代理了,By ASM!");
                        // 在进入方法前,修改class,打印提示
                        methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
                        super.onMethodEnter();
                    }
                };
            }
        }, ClassReader.EXPAND_FRAMES);
        byte[] bytes = classWriter.toByteArray();
        return (T) new ASMDemo().defineClass(clazz.getName(), bytes, 0, bytes.length).newInstance();
    }
}  

我们通过ASM动态代理一个简单的测试接口和实现类

public class HelloImpl implements Hello{
    @Override
    public String hi(String msg) {
        return ("hello " + msg);
    }}
public interface Hello {
    public String hi(String msg);}

写一个测试程序,通过ASM 代理模式,增强字节码后调用方法的效果 

深入Java自动化探针技术的原理和实践_第10张图片

当然,基于ASM开发门槛比较高一些,你必须了解一定汇编原理和指令

Javassist

Javassist是一个开源的分析、编辑和创建Java字节码的类库。是由东京工业大学的数学和计算机科学系的 Shigeru Chiba (千叶滋)所创建的。它已加入了开放源代码JBoss 应用服务器项目,通过使用Javassist对字节码操作为JBoss实现动态"AOP"框架。

javassist 其主要的优点,在于简单,而且快速。直接使用java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构,或者动态生成类;参考资料

http://www.javassist.org/
https://github.com/jboss-javassist/javassist

下面我们有一个完整Java 探针实例用Javassist来实现

Byte Buddy

Byte Buddy是致力于解决字节码操作和 instrumentation API 的复杂性的开源框架。Byte Buddy 所声称的目标是将显式的字节码操作隐藏在一个类型安全的领域特定语言背后。通过使用 Byte Buddy,任何熟悉 Java 编程语言的人都容易地进行字节码操作

官网的示例展现了如何生成一个简单的类,这个类是 Object 的子类,并且重写了 toString 方法,用来返回“Hello World!”与原始的 ASM 类似,intercept 会告诉 Byte Buddy 为拦截到的指令提供方法实现

Class dynamicType = new ByteBuddy()
  .subclass(Object.class)
  .method(ElementMatchers.named("toString"))
  .intercept(FixedValue.value("Hello World!"))
  .make()
  .load(getClass().getClassLoader())
  .getLoaded();
System.out.println(dynamicType.getSimpleName());
// 输出:Object$ByteBuddy$ilIxkTl1
Demo 来源 bytebuddy.net

字节码增强工具对比

框架 ASM Javassist JDK Proxy Cglib ByteBuddy
起源时间 2002 1999 2000 2011 2014
增强方式 字节码指令 字节码指令和源码(注:源码文本) 源码 源码 源码
源码编译 NA 不支持 支持 支持 支持
Agent支持 支持 支持 不支持,依赖框架 不支持,依赖框架 支持
性能
维护状态 停止升级 停止维护 活跃
优点 超高性能,应用场景广泛 同时支持字节码指令和源码两种增强方式 JDK原生类库支持
零侵入,提供良好的API扩展编程
缺点 字节码指令对应用开发者不友好
场景非常局限,只适用于Java接口 已经不再维护,对于新版JDK17+支持不好,官网建议切换到ByteBuddy
应用场景 小,高性能,广泛用于语言级别

广泛用于框架场景 广泛用于Trace场景

一个完整的Java Agent探针实现过程

目标

实现一个简单性能工具,通过探针统计Java程序所有方法的执行时间 1、构建 Maven 项目工程,添加 MANIFEST.MF , 目录大致

深入Java自动化探针技术的原理和实践_第11张图片

在 MANIFEST.MF文件中定义Premain-Class属性,指定一个实现类。类中实现了Premain方法,这就是Java Agent 在类加载启动入口

Manifest-Version: 1.0
Premain-Class: com.laziobird.MyAgentDemo
Agent-Class: com.laziobird.MyAgentDemo
Can-Redefine-Classes: true
Can-Retransform-Classes: true
  • • Premain-Class包含Premain方法的类

  • • Can-Redefine-Classes为true时表示能够重新定义Class

  • • Can-Retransform-Classes为true时表示能够重新转换Class,实现字节码替换 2、构建Premain方法

public class MyAgentDemo {
    // JVM 启动时,Agent修改字节码
    public static void premain(String args, Instrumentation inst) {
        System.out.println(" premain agent loaded !");
        inst.addTransformer(new PreMainTransformerDemo());
        System.out.println(" agent addTransformer start !");
    }
 ....   
}

我们实现Premain方法类叫 MyAgentDemo,里面添加一个类转化器 PreMainTransformerDemo,这个转化器具体来实现统计方法调用时间 3、编写类转换器

在编写类转化器时,我们通过Javassist 来具体操作字节码,首先pom.xml 里面添加依赖


   org.javassist
   javassist
   3.25.0-GA

接下来具体实现

public class PreMainTransformerDemo implements ClassFileTransformer{
   final static String prefix = "\nlong startTime = System.currentTimeMillis();\n";
   final static String postfix = "\nlong endTime = System.currentTimeMillis();\n";
   @Override
   public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined,
                           ProtectionDomain protectionDomain, byte[] classfileBuffer){
       // className 默认格式 com/laziobird 替换 com.laziobird
       className = className.replace("/", ".");
       //java自带的方法不进行处理,不是特别类的方法也不处理
       if(className.startsWith("java") || className.startsWith("sun")|| !className.contains("com.laziobird")){
           return null;
       }
       CtClass ctclass = null;
       try {
           // 使用全称,用于取得字节码类<使用javassist>
           ctclass = ClassPool.getDefault().get(className);
           for(CtMethod ctMethod : ctclass.getDeclaredMethods()){
               String methodName = ctMethod.getName();
               // 新定义一个方法叫做比如sayHello$old
               String newMethodName = methodName + "$old";
               // 将原来的方法名字修改
               ctMethod.setName(newMethodName);
               // 创建新的方法,复制原来的方法,名字为原来的名字
               CtMethod newMethod = CtNewMethod.copy(ctMethod, methodName, ctclass, null);
               // 构建新的方法体
               StringBuilder bodyStr = new StringBuilder();
               bodyStr.append("{");
               bodyStr.append("System.out.println(\"==============Enter Method: " + className + "." + methodName + " ==============\");");
               //方法执行前,定义一个时间变量,记录方法开始前时间
               bodyStr.append(prefix);
               bodyStr.append(newMethodName + "($$);\n");// 调用原有代码,类似于method();($$)表示所有的参数
               //定义方法完成时间变量
               bodyStr.append(postfix);
               //方法完成后,运算方法执行时间
               bodyStr.append("System.out.println(\"==============Exit Method: " + className + "." + methodName + " Cost:\" +(endTime - startTime) +\"ms " + "===\");");
               bodyStr.append("}");
               // 新方法字节码替换原来的方法字节码
               newMethod.setBody(bodyStr.toString());
               ctclass.addMethod(newMethod);// 增加新方法
           }
           //返回新的字节流
           return ctclass.toBytecode();
       } catch (Exception e) {
           e.printStackTrace();
       }
       return null;
   }

程序等价于:指定Java类下所有方法进行了如下转换,重新生成字节码加载执行

深入Java自动化探针技术的原理和实践_第12张图片

4、打包生成Java Agent的Jar 包

pom.xml配置好maven assembly,进行编译打包

深入Java自动化探针技术的原理和实践_第13张图片

5、写一个Java测试程序,验证探针是否生效

AgentTest 有两个简单方法testtestB

为了演示,其中testB 调用了另外一个类ClassC 的 methodD方法。

可以看到,类包名是 com.laziobird,刚才的Agent 只会对com.laziobird 的类起作用

package com.laziobird;
public class AgentTest {
    public void test() {
        System.out.println("hello the method: agentTest.test ");
    }
    public void testB() {
        ClassC c = new ClassC();
        c.methodD();
        System.out.println("hello the method: agentTest.testB ");
    }
    public static void main(String[] args) {
        AgentTest agentTest = new AgentTest();
        agentTest.test();
        agentTest.testB();
    }
}
package com.laziobird;
public class ClassC {
    public void methodD(){
        try {
            System.out.println(" methodD start!");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

我们给测试程序打成可执行的Jar包,Jar 指定默认运行的类是 AgentTest

深入Java自动化探针技术的原理和实践_第14张图片

运行测试程序,通过-javaagent启动我们写的 Java Agent 探针

java -javaagent:/path/agentdemo/target/javaagent-demo-0.0.1-SNAPSHOT-jar-with-dependencies.jar  
-jar  /path/gitproject/TestAgentDemo/out/artifacts/TestAgentDemo_jar/TestAgentDemo.jar

运行效果

深入Java自动化探针技术的原理和实践_第15张图片

程序运行时加载

在JDK1.6 版本中,Java Agent 支持了可以在JVM运行时动态修改 Java 字节码的能力。

这种能力需要JVM Attach 来实现

JVM Attach:简单来说就是JVM提供一种JVM进程间通信的机制。它能让一个进程传命令给另外一个进程,并让它执行内部的一些操作。

常见场景,比如做故障定位时,有可能我们觉得某些Java 的线程程序卡住了。于是想把一个JVM进程的线程Dump出来。那么我们会跑一个JStack的进程,然后传进程Id 参数,告诉Jstack指定哪个进程进行线程Dump。Attach 机制完成两个进程间如何通信和传输协议的定义

JVM Attach 实现原理

存在一个Attach Listener 线程,监听其他JVM的Attach 请求,其通信方式基于socket,JVM Attach机制底层从Kernel 到 Application 层完整流程图

深入Java自动化探针技术的原理和实践_第16张图片

具体C语言源代码实现,可以参考李嘉鹏这篇深入分享

http://lovestblog.cn/blog/2014/06/18/jvm-attach/?spm=ata.13261165.0.0.26d52428n8NoAy

Agentmain 工作原理

Java Agent在运行时和启动时加载机制其实很像,主要区别在Agent 进行字节码增强前,对于拦截入口不同而已。一个叫Premain,一个叫Agentmain 。这一点很好理解:启动时,Agent 直接通过启动参数-javaagent吸附于当前JVM 进程。运行时加载,其实当前JVM进程已经启动了。这时借助另一个JVM进程通信,调用Attach API 再把Agent 启动起来。后面的字节码修改和重加载的过程那就是一样的。

深入Java自动化探针技术的原理和实践_第17张图片

运行时Java Agent 配置

和启动时代理类似:

  • • JAR包的MANIFEST.MF清单文件中定义Agent-Class属性,指定一个实现类。加入Can-Redefine-Classes和 Can-Retransform-Classes 选项

  • • JAR包中包含清单文件中定义的这个类,类中包含agentmain方法,方法逻辑自己实现 JAR包内对应的MANIFEST.MF有如下配置

Agent-Class: com.laziobird.MyAgentDemo
Can-Redefine-Classes: true
Can-Retransform-Classes: true

需要注意:agentmain方式由于是采用Attach 机制,被代理的目标程序VM 已经先于Agent 启动,其所有类已经被加载完成。这个时候需要执行 Instrumentation 的 retransformClasses方法让类进入重新转换,重定义的过程:它会激活类执行ClassFileTransformer列表中的回调,完成字节码操作,最后让类加载器重新加载

Attach Agentmain 和 PreMain 对比

1、字节码增强的限制

虽然运行时Agent可以在JVM 运行时动态的修改某个类的字节码,但是为了保证JVM的正常运行,新定义的类相较于原来的类需要满足:

父类是同一个
实现的接口数也要相同,并且是相同的接口
类访问符必须一致
字段数和字段名要一致
新增或删除的方法必须是private static/final的
可以修改方法内部代码

2、显式调用重定义方法 因为JVM启动时,字节码 Class文件已经提前生成好再进行Class Load过程。但是JVM 运行后,把已经加载后的类动态修改 redefine an already loaded class,我们修改完类定义后,显式在内存中进行重加载 reload Class

Java Agent 显式给我们提供了retransformClasses方法,下面我摘取它的详细说明

void retransformClasses(Class... classes) {}
/**
Returns whether or not the current JVM configuration supports redefinition of classes. The ability to redefine an already loaded class is an optional capability of a JVM. Redefinition will only be supported if the Can-Redefine-Classes manifest attribute is set to true in the agent JAR file (as described in the package specification) and the JVM supports this capability. During a single instantiation of a single JVM, multiple calls to this method will always return the same answer.**/

一个基于Attach的Java Agent 探针实现过程

目标

实现一个简单性能工具,通过Java Agent 探针统计Java应用程序下所有方法的执行时间

1、还是之前 Maven 项目工程,在 MANIFEST.MF 文件中定义Agentmain-Class属性,指定一个实现类。类中实现了Agentmain方法,这就是Java Agent 在JVM运行时加载的启动入口

Agent-Class: com.laziobird.MyAgentDemo

2、构建Agentmain方法

public class MyAgentDemo {
  // JVM运行时,Agent修改字节码
  public static void agentmain(String args, Instrumentation inst) {
      System.out.println(" agentmain agent loaded !");
      Class[] allClass = inst.getAllLoadedClasses();
      for (Class c : allClass) {
          inst.addTransformer(new AgentMainTransformerDemo(), true);
          try {
          //agentmain 是JVM运行时,需要调用 retransformClasses 重定义类 !!
          inst.retransformClasses(c);
               } catch (UnmodifiableClassException e) {
                 throw new RuntimeException(e); }
          }    }
 ....   
}

我们在类 MyAgentDemo实现agentmain方法,里面添加一个类转化器 AgentMainTransformerDemo,这个转化器插入实现统计方法调用时间的字节码片段

public class AgentMainTransformerDemo implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        className = className.replace("/", ".");
        //这次我们用另外一种简洁API方法修改字节码
        if (className.contains("com.laziobird")) {
            try {
                // 得到类信息
                CtClass ctclass = ClassPool.getDefault().get(className);
                for (CtMethod ctMethod : ctclass.getDeclaredMethods()) {
                    // 方法内部声明局部变量
                    ctMethod.addLocalVariable("start", CtClass.longType);
                    // 方法前插入Java代码片段
                    ctMethod.insertBefore("start = System.currentTimeMillis();");
                    String methodName = ctMethod.getLongName();
                    ctMethod.insertAfter("System.out.println(\"" + methodName + " cost: \" + (System" +
                            ".currentTimeMillis() - start));");
                    // 方法结束尾部插入Java代码片段
                    return ctclass.toBytecode();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return null;
    }
}

3、重新打包生成新的Jar包 运行 maven assembly,进行编译打包

4、写测试的Java程序

类AgentAttachTest 定义一个方法,为了方便查看Attach 效果,我们让JVM 主进程一直循环执行这个方法。同时为了区分,通过随机数改变方法的运行时间。这样看到探针每次统计结果也不同。类的包名是com.laziobird,Agent 只会对com.laziobird 的类起作用

public void test(int x) {
    try {
        long sleepTime = x*1000;
        Thread.sleep(sleepTime);
        System.out.println("the method: AgentAttachTest.test | sleep time = " + sleepTime+ "ms");
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}
public static void main(String[] args) {
    AgentAttachTest agentTest = new AgentAttachTest();
    while (1==1){
        int x = new Random().nextInt(10);
        agentTest.test(x);
    }
}

5、编写一个演示 Attach 通信的JVM 程序,用于启动 Agent

public class AttachJVM {
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        // 获取运行中的JVM列表
        List vmList = VirtualMachine.list();
        // 我们编写探针的Jar包路径
        String agentJar = "/Users/jiangzhiwei/eclipse-workspace/agentdemo/target/javaagent-demo-0.0.1-SNAPSHOT-jar-with-dependencies.jar";
        for (VirtualMachineDescriptor vmd : vmList) {
            // 找到测试的JVM
            System.out.println("vmd name: "+vmd.displayName());
            if (vmd.displayName().endsWith("AgentAttachTest")) {
                // attach到目标ID的JVM上
                VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
                // agent指定jar包到已经attach的JVM上
                virtualMachine.loadAgent(agentJar);
                virtualMachine.detach();
}}}}

运行效果

1、运行测试的Java程序,为了方便,也可以不用打成Jar运行

深入Java自动化探针技术的原理和实践_第18张图片

2、我们启动Attach 的JVM程序。它主要动作:

1、通过Attach API,找到要监听的JVM进程,我们称为VirtualMachine

2、VirtualMachine 借助Attach API 的LoadAgent方法将Agent 加载进来

深入Java自动化探针技术的原理和实践_第19张图片

3、Agent 开始工作!我们回过头来看看探针在测试程序的运行效果

深入Java自动化探针技术的原理和实践_第20张图片

我们手写Java 探针在JVM 运行时也能动态改变字节码。

Github 案例地址

为了方便大家上手实践,我贡献案例到Github,其实基于Java Agent 性能诊断工具、链路分析的Java 探针基本都是类似实现,大部分区别在于字节码增强实现的差异。

当然,要求更高的性能和底层功能,可以直接编写C、C++的JVMT 动态链接库。

https://github.com/laziobird/java-agent-demo 

探针实现 

https://github.com/laziobird/java-agent-demo/tree/main/Agentdemo

测试程序 

https://github.com/laziobird/java-agent-demo/tree/main/TestAgentDemo 

Attach 用例 

https://github.com/laziobird/java-agent-demo/tree/main/JVMAttach 

本文的教程


你可能感兴趣的:(java,自动化,jvm)