java–字节码增强–2.2–Agent–Instrument
代码位置
https://gitee.com/DanShenGuiZu/learnDemo/tree/master/code_expand_learn
1、介绍
- Instrument 是一个基于 JVMTI 接口的,以代理方式 连接和访问JVM 的 一个 Agent。
- 主要内容有
- 静态Instrument
- 动态Instrument
2.1、Instrumentation 核心API
- addTransformer()/removeTransformer() 方法:注册/注销一个 ClassFileTransformer 类的实例,该 Transformer 会在类加载的时候被调用,可用于修改类定义修改类的字节码。
- redefineClasses() 方法:该方法针对的是已经加载的类,它会对传入的类进行重新定义。
- getAllLoadedClasses()方法:返回当前 JVM 已加载的所有类。
- getInitiatedClasses() 方法:返回当前 JVM 已经初始化的类。
- getObjectSize()方法:获取参数指定的对象的大小。
2、静态Instrument
2.1、agent 参数
在命令行 输入 java可以看到相应的参数
C:\Users\26372>java
用法: java [-options] class [args...]
(执行类)
或 java [-options] -jar jarfile [args...]
(执行 jar 文件)
其中选项包括:
-d32 使用 32 位数据模型 (如果可用)
-d64 使用 64 位数据模型 (如果可用)
-server 选择 "server" VM
默认 VM 是 server.
-cp <目录和 zip/jar 文件的类搜索路径>
-classpath <目录和 zip/jar 文件的类搜索路径>
用 ; 分隔的目录, JAR 档案
和 ZIP 档案列表, 用于搜索类文件。
-D<名称>=<值>
设置系统属性
-verbose:[class|gc|jni]
启用详细输出
-version 输出产品版本并退出
-version:<值>
警告: 此功能已过时, 将在
未来发行版中删除。
需要指定的版本才能运行
-showversion 输出产品版本并继续
-jre-restrict-search | -no-jre-restrict-search
警告: 此功能已过时, 将在
未来发行版中删除。
在版本搜索中包括/排除用户专用 JRE
-? -help 输出此帮助消息
-X 输出非标准选项的帮助
-ea[:<packagename>...|:<classname>]
-enableassertions[:<packagename>...|:<classname>]
按指定的粒度启用断言
-da[:<packagename>...|:<classname>]
-disableassertions[:<packagename>...|:<classname>]
禁用具有指定粒度的断言
-esa | -enablesystemassertions
启用系统断言
-dsa | -disablesystemassertions
禁用系统断言
-agentlib:<libname>[=<选项>]
加载本机代理库 <libname>, 例如 -agentlib:hprof
另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
加载 Java 编程语言代理, 请参阅 java.lang.instrument
-splash:<imagepath>
使用指定的图像显示启动屏幕
agent相关如下
-agentlib:<libname>[=<选项>]
加载本机代理库 <libname>, 例如 -agentlib:hprof
另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
加载 Java 编程语言代理, 请参阅 java.lang.instrument
案例:
-javaagent:myAgent01.jar=123
那么参数就是:123
通过 -javaagent 参数可以指定一个特定的 jar 包来启动 Instrumentation 代理程序。
2.2、premain()方法
-javaagent命令要求指定的类中必须要有premain()方法,并且必须满足以下两种格式
public static void premain(String agentArgs, Instrumentation inst)
public static void premain(String agentArgs)
- JVM启动时 会优先加载带
Instrumentation
签名的方法- 第一个加载成功,就忽略第二个
- 第一个加载失败,加载第二个
- JVM启动时 会先执行 premain 方法,大部分类加载都会通过该方法,注意:是大部分,不是所有。遗漏的主要是系统类,因为很多系统类 要先于 agent 执行,而用户类的加载肯定是会被拦截的。
- premain方法 在 main方法启动前拦截大部分类的加载活动,既然可以拦截类的加载,就可以结合第三方的字节码编译工具,比如ASM,javassist,cglib等等来改写实现类。
2.2.1、agentArgs
- 随同 -javaagent一起传入。
- 这个参数是一个字符串而不是一个字符串数组,如果程序参数有多个,程序需要自行解析这个字符串。
2.2.2、Inst
- 是 Instrumentation 的实例
- 由 JVM 自动传入,接口中集中了几乎所有的功能方法,例如类定义的转换和操作等等。
2.3、案例
2.3.1、agentTest
package fei.zhou.agenttest.Test;
public class HelloService {
public String doBusiness() {
String result = "hello";
System.out.println(result); //模拟业务操作
return result;
}
}
package fei.zhou.agenttest.Test;
public class Test {
public static void main(String[] args) throws InterruptedException {
HelloService service = new HelloService();
while (true) {
Thread.sleep(1000);
service.doBusiness();
}
}
}
2.3.2、myAgent01
package fei.zhou.myagent01;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
//通过ClassFileTransformer接口,可以在类加载之前,重写字节码
public class MyTransformer implements ClassFileTransformer {
/**
* 参数:
* loader - 定义要转换的类加载器;如果是引导加载器,则为 null
* className - 完全限定类内部形式的类名称和 The Java Virtual Machine Specification 中定义的接口名称。例如,"java/util/List"。
* classBeingRedefined - 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
* protectionDomain - 要定义或重定义的类的保护域
* classfileBuffer - 类文件格式的输入字节缓冲区(不得修改)
* 返回:
* 一个格式良好的类文件缓冲区(转换的结果),如果未执行转换,则返回 null。
* 抛出:
* IllegalClassFormatException - 如果输入不表示一个格式良好的类文件
*/
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
try {
if (!className.equals("fei/zhou/agenttest/Test/HelloService")) {
return null; // 如果返回null则字节码不会被修改
}
System.out.println("对HelloService字节码 开始增强");
//借助JavaAssist工具,进行字节码插桩
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("fei.zhou.agenttest.Test.HelloService");
CtMethod personFly = cc.getDeclaredMethod("doBusiness");
//在目标方法前后,插入代码
personFly.insertBefore("System.out.println(\"--- before doBusiness ---\");");
personFly.insertAfter("System.out.println(\"--- after doBusiness ---\");");
System.out.println("对HelloService字节码 结束增强");
return cc.toBytecode();
} catch (Exception e) {
System.out.println("异常:" + e.getMessage());
}
return null;
}
}
package fei.zhou.myagent01;
import java.lang.instrument.Instrumentation;
public class PreMainAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("agentArgs : " + agentArgs);
//加入自定义转换器
inst.addTransformer(new MyTransformer(), true);
}
}
2.3.3、定义一个MANIFEST.MF文件,用于指明premain的入口在哪里
Premain-Class: 包含 premain 方法的类(类的全路径名)
Agent-Class: 包含 agentmain 方法的类(类的全路径名)
Boot-Class-Path: 设置引导类加载器搜索的路径列表。查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理 JAR 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径。(可选)
Can-Redefine-Classes: true表示能重定义此代理所需的类,默认值为 false(可选)
Can-Retransform-Classes: true 表示能重转换此代理所需的类,默认值为 false (可选)
Can-Set-Native-Method-Prefix: true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)
# 注意:最后一行是空行,不能省略。
如果不去手动指定,打包时可以通过maven生成一个MANIFREST.MF文件,JavaAgent的信息可以在maven插件中指定
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<!--给jar包起的别名-->
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<!--自动添加META-INF/MANIFEST.MF -->
<manifestEntries>
<Premain-Class>fei.zhou.myagent01.PreMainAgent</Premain-Class>
<Agent-Class>fei.zhou.myagent01.AgentMainAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<id>one-jar</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
2.3.4、依赖
<!--java Agent begin-->
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.29.2-GA</version>
</dependency>
<!--java Agent begin-->
2.3.5、测试
myAgent01,打成jar包
启动测试类,并设置agent参数
# java -javaagent:Agent.jar的全路径 Main类
-javaagent:D:\java\workSpace-learn\learnDemo\code_expand_learn\myAgent01\target\myAgent01-1.0-jar-with-dependencies.jar
结果
agentArgs : null
对HelloService字节码 开始增强
对HelloService字节码 结束增强
--- before doBusiness ---
hello
--- after doBusiness ---
--- before doBusiness ---
hello
--- after doBusiness ---
--- before doBusiness ---
hello
--- after doBusiness ---
--- before doBusiness ---
可见,在业务代码执行的前后,确实插入了额外代码。
3、动态Instrument
静态Instrument需要把Agent程序提前写好,与应用实例一并启动,所作的 Instrumentation 也仅限于 main 函数执行前,这样的方式存在一定的局限性。
Instrumentation 当中,提供了一个新的代理操作方法:agentmain,可以在 main 函数开始运行之后再运行。
3.1、agentmain()方法
public static void agentmain (String agentArgs, Instrumentation inst);
public static void agentmain (String agentArgs);
- agentmain 方法中带Instrumentation参数的方法也比不带优先级更高。
- 第一个加载成功,就忽略第二个
- 第一个加载失败,加载第二个
- 开发者必须在 manifest 文件里面设置Agent-Class来指定包含 agentmain 函数的类。
3.2、Attach 机制
跟 premain 不同的是,agentmain 需要在 main 函数开始运行后才启动,通过 Attach 机制实现。
Attach API 用来向目标 JVM “附着”(Attach)代理工具程序的。有了它,开发者可以方便的监控一个 JVM,运行一个外加的代理程序。
首先,Attach 机制对外提供了一种进程间的通信能力,能让一个进程传递命令给 JVM;
其次,Attach 机制内置一些重要功能,可供外部进程调用。比如大家最熟悉的 jstack 就是要依赖这个机制来工作。
3.2.1、Attach 机制的核心组件
-
核心组件是:Attach Listener
-
Attach Listener 是 JVM 内部的一个线程,主要工作是监听和接收客户端进程 发起的命令(客户端 通过 Attach 提供的通信机制发起命令),如下图所示
-
Attach Listener线程的主要工作是串流程,流程步骤包括:接收客户端命令、解析命令、查找命令执行器、执行命令等等。
3.3、Attach API
只有 2个主要的类,都在 com.sun.tools.attach 包里面。
- VirtualMachine类
- VirtualMachineDescriptor类
3.3.1、VirtualMachine类
- 代表一个 Java 虚拟机,也就是程序需要监控的目标虚拟机
- 提供了获取系统信息(比如内存dump、线程dump,类信息统计)、加载代理程序、Attach 和 Detach 等方法 。
3.3.2、VirtualMachineDescriptor类
- 一个描述虚拟机的容器类
- 通过VirtualMachine类的attach(pid)方法,便可以attach到一个运行中的java进程上,之后便可以通loadAgent(agentJarPath)来将agent的jar包注入到对应的进程,然后对应的进程会调用agentmain方法。
既然是两个进程之间通信,那肯定建立了连接,VirtualMachine.attach动作类似TCP创建连接的三次握手,目的就是搭建attach通信的连接。而后面执行的操作,例如vm.loadAgent,其实就是向这个socket写入数据流,接收方target VM会针对不同的传入数据来做不同的处理。
3.4、案例
3.4.1、代码
编写agentmain入口类,然后使用maven插件打包生成MANIFEST.MF
package fei.zhou.myagent01;
import java.lang.instrument.Instrumentation;
public class AgentMainAgent {
public static void agentmain(String agentArgs, Instrumentation inst) throws Exception {
System.out.println("agentArgs2 : " + agentArgs);
inst.addTransformer(new MyTransformer(), true);
// inst.retransformClasses(BusinessService.class);
inst.retransformClasses(Class.forName("fei.zhou.agenttest.Test.HelloService"));//指明哪些类需要重新加载
}
}
package fei.zhou.myagent01.Test;
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
import java.util.List;
/**
* @Description 动态Instrument
**/
public class Test_AgentMainAgent {
public static void main(String[] args) throws Exception {
//获取当前系统中所有 运行中的 虚拟机
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : list) {
//如果虚拟机的名称为 xxx 则 该虚拟机为目标虚拟机,获取该虚拟机的 pid
//然后加载 agent.jar 发送给该虚拟机
System.out.println(vmd.displayName());
if (vmd.displayName().endsWith("fei.zhou.agenttest.Test.Test")) {
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
virtualMachine.loadAgent("D:\\java\\workSpace-learn\\learnDemo\\code_expand_learn\\myAgent01\\target\\myAgent01-1.0-jar-with-dependencies.jar");
Thread.sleep(10000L);
virtualMachine.detach();
}
}
}
}
使用maven插件打包生成MANIFEST.MF
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<!--给jar包起的别名-->
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<!--自动添加META-INF/MANIFEST.MF -->
<manifestEntries>
<Premain-Class>fei.zhou.myagent01.PreMainAgent</Premain-Class>
<Agent-Class>fei.zhou.myagent01.AgentMainAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<id>one-jar</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
3.4.2、测试
先运行模拟业务的main函数
再运行Test_AgentMainAgent类
可见,在main函数刚启动时,没有attach外部的agent程序,执行的是原始代码,agent启动后,导致HelloService重新加载,插入的额外代码。
4、应用场景
从上面提到的字节码转换器的两种执行方式来看,可以实现如下功能:
- Java Agent 能够在加载 Java 字节码之前进行拦截并对字节码进行修改;
- 在 Jvm 运行期间修改已经加载的字节码;
因此,通过以上两点即可实现在一些框架或是技术的采集点进行字节码修改,对应用进行监控(比如通过JVM CPU Profiler 从CPU、Memory、Thread、Classes、GC等多个方面对程序进行动态分析),或是对执行指定方法或接口时做一些额外操作,比如打印日志、打印方法执行时间、采集方法的入参和结果等;
4.1、应用性能监控(APM)
在微服务大行其道的环境下,分布式系统的逻辑结构变得越来越复杂。这给系统性能分析和问题定位带来了非常大的挑战。
基于JVMTI的APM能够解决分布式架构和微服务带来的监控和运维上的挑战。APM通过汇聚业务系统各处理环节的实时数据,分析业务系统各事务处理的交易路径和处理时间,实现对应用的全链路性能监测。开源的Skywalking、Pinpoint、ZipKin、 Hawkular, 商业的 AppDynamics、OneAPM、Google Dapper等都是个中好手。
4.2、classPath动态增补
我们知道,通过设置系统参数或者通过虚拟机启动参数,我们可以设置一个虚拟机运行时的 boot class 加载路径(-Xbootclasspath)和 system class(-cp)加载路径。
然而,我们也许有时候要需要把某些 jar 加载到 bootclasspath 之中,而我们无法应用上述两个方法;或者我们需要在虚拟机启动之后来加载某些 jar 进入 bootclasspath。
为了实现这几点,我们需要
首先:我们依然需要确认虚拟机已经支持这个功能。
然后:在 premain/agantmain 之中加上需要的 classpath。
最后:在premain/agentmain方法中使用 appendToBootstrapClassLoaderSearch/appendToSystemClassLoaderSearch来完成这个任务。
4.3、class 文件加密
有时一些涉及到关键技术的 class 文件或者 jar 包我们不希望对外暴露,因而需要进行加密。
使用一些常规的手段(例如使用混淆器或者自定义类加载器)来对 class 文件进行加密很容易被反编译,反编译后的代码虽然增加了阅读的难度,但花费一些功夫也是可以读懂的。
使用 JVMTI 我们可以将解密的代码封装成 .dll 或 .so 文件。这些文件想要反编译就很麻烦了,另外还能加壳。解密代码不能被破解,从而也就保护了我们想要加密的 class 文件。
4.4、其他使用 Agent技术 的开源项目
4.4.1、Arthas(阿里,开源)
Java诊断工具。
在线排查问题,无需重启
动态跟踪 Java 代码
实时监控 JVM 状态。
4.4.2、JVM SandBox(阿里,开源)
实时、无侵入、动态可插拔的字节码增强框架
用于线上故障定位、系统流控、动态日志等场景
4.4.3、Skywalking(开源)
针对服务的调用链路、JVM 基础监控信息进行采集。
4.4.4、jvm-profiler(Uber,开源)
通过 Java Agent 采集 JVM CPU、Memory、IO等指标并发送给 Kafka、Console 以及可以自定义的发送器。
4.4.5、BTrace(sun,开源)
一款java 动态、安全追踪工具
可以不停机的情况下监控线上情况
做到最少的侵入,占用最少的系统资源。
5、局限性
大多数情况下,我们使用Instrumentation都是使用其字节码插桩的功能,或者笼统说就是类重定义(Class Redefine)的功能,但是有以下的局限性
5.1、问题1
premain和agentmain两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。
5.2、问题2
类的字节码修改称为类转换(Class Transform),类转换调用的是 重定义Instrumentation#redefineClasses()方法,此方法有以下限制:
- 新类和老类的父类必须相同;
- 新类和老类实现的接口数也要相同,并且是相同的接口;
- 新类和老类访问符必须一致。
- 新类和老类字段数和字段名要一致;
- 新类和老类新增或删除的方法必须是private static/final修饰的;
- 可以修改方法体。