zl程序教程

您现在的位置是:首页 >  Java

当前栏目

Java agent超详细知识梳理

2023-02-19 12:17:49 时间

​一、简介

1 开篇

在梳理SkyWalking agent的plugin、elasticsearch的plugin、arthas​等技术的原理时,发现他们的底层原理很多是相同的。这类工具都用到了Java agent、类加载、类隔离等技术,在此进行归类梳理。

本篇将梳理Java agent相关内容。在此先把这些技术整体的关系梳理如下:

图片

二、 Java agent 使用场景

Java agent​是基于JVMTI(JVM Tool Interface​)实现,后面的内容将对Java agent和JVMTI做详细介绍。

Java agent​ 技术结合 Java Intrumentation API 可以实现类修改、热加载等功能。

Java agent和JVMTI 技术的常见应用场景:

图片

三、Java agent 示例

我们先用一个 Java agent​ 实现方法开始和结束时打印日志的简单例子来实践一下,通过示例,可以很快对后面 Java agent 技术有初步的理解。

1.Java agent 实现方法开始和结束时打印日志

1.1 开发 agent

创建 demo-javaagent 工程,目录结构如下:

图片

新建pom.xml​,引入javassist​用来修改目标类的字节码,增加自定义代码。通过maven-assembly-plugin插件打包自定义的 agent jar。

<?xml versinotallow="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.example</groupId>
<artifactId>demo-javaagent</artifactId>
<version>1.0</version>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>

<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.25.0-GA</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.1</version>
<configuration>
<descriptorRefs>
<!--将应用的所有依赖包都打到jar包中。如果依赖的是 jar 包,jar 包会被解压开,平铺到最终的 uber-jar 里去。输出格式为 jar-->
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<!-- 设置manifest配置文件-->
<manifestEntries>
<!--Premain-Class: 代表 Agent 静态加载时会调用的类全路径名。-->
<Premain-Class>demo.MethodAgentMain</Premain-Class>
<!--Agent-Class: 代表 Agent 动态加载时会调用的类全路径名。-->
<Agent-Class>demo.MethodAgentMain</Agent-Class>
<!--Can-Redefine-Classes: 是否可进行类定义。-->
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<!--Can-Retransform-Classes: 是否可进行类转换。-->
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<!--绑定到package生命周期阶段上-->
<phase>package</phase>
<goals>
<!--绑定到package生命周期阶段上-->
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
</plugins>
</build>
</project>

其中重点关注重点部分

<manifestEntries>
<!--Premain-Class: 代表 Agent 静态加载时会调用的类全路径名。-->
<Premain-Class>demo.MethodAgentMain</Premain-Class>
<!--Agent-Class: 代表 Agent 动态加载时会调用的类全路径名。-->
<Agent-Class>demo.MethodAgentMain</Agent-Class>
<!--Can-Redefine-Classes: 是否可进行类定义。-->
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<!--Can-Retransform-Classes: 是否可进行类转换。-->
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>

编写 agent 核心代码 MethodAgentMain.java,我们使用了premain()​静态加载方式,agentmain​动态加载方式。并用到了Instrumentation​类结合javassist代码生成库进行字节码的修改。

package demo;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.Modifier;

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

public class MethodAgentMain {

/** 被转换的类 */
public static final String TRANSFORM_CLASS = "org.example.agent.AgentTest";

/** 静态加载。Java agent指定的premain方法,会在main方法之前被调用 */
public static void premain(String args, Instrumentation instrumentation){
System.out.println("premain start!");
addTransformer(instrumentation);
System.out.println("premain end!");
}

/** 动态加载。Java agent指定的premain方法,会在main方法之前被调用 */
public static void agentmain(String args, Instrumentation instrumentation){
System.out.println("agentmain start!");
addTransformer(instrumentation);
Class<?>[] classes = instrumentation.getAllLoadedClasses();
if (classes != null){
for (Class<?> c: classes) {
if (c.isInterface() ||c.isAnnotation() ||c.isArray() ||c.isEnum()){
continue;
}
if (c.getName().equals(TRANSFORM_CLASS)) {
try {
System.out.println("retransformClasses start, class: " + c.getName());
/*
* retransformClasses()对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
* retransformClasses()可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
*/
instrumentation.retransformClasses(c);
System.out.println("retransformClasses end, class: " + c.getName());
} catch (UnmodifiableClassException e) {
System.out.println("retransformClasses error, class: " + c.getName() + ", ex:" + e);
e.printStackTrace();
}
}
}
}
System.out.println("agentmain end!");
}

private static void addTransformer (Instrumentation instrumentation){
/* Instrumentation提供的addTransformer方法,在类加载时会回调ClassFileTransformer接口 */
instrumentation.addTransformer(new ClassFileTransformer() {
public byte[] transform(ClassLoader l,String className, Class<?> c,ProtectionDomain pd, byte[] b){
try {
className = className.replace("/", ".");
if (className.equals(TRANSFORM_CLASS)) {
final ClassPool classPool = ClassPool.getDefault();
final CtClass clazz = classPool.get(TRANSFORM_CLASS);

for (CtMethod method : clazz.getMethods()) {
/*
* Modifier.isNative(methods[i].getModifiers())过滤本地方法,否则会报
* javassist.CannotCompileException: no method body at javassist.CtBehavior.addLocalVariable()
* 报错原因如下
* 来自Stack Overflow网友解答
* Native methods cannot be instrumented because they have no bytecodes.
* However if native method prefix is supported ( Transformer.isNativeMethodPrefixSupported() )
* then you can use Transformer.setNativeMethodPrefix() to wrap a native method call inside a non-native call
* which can then be instrumented
*/
if (Modifier.isNative(method.getModifiers())) {
continue;
}

method.insertBefore("System.out.println(\"" + clazz.getSimpleName() + "."
+ method.getName() + " start.\");");
method.insertAfter("System.out.println(\"" + clazz.getSimpleName() + "."
+ method.getName() + " end.\");", false);
}

return clazz.toBytecode();
}
} catch (Exception e) {
e.printStackTrace();
}

return null;
}
}, true);
}
}

编译打包:

执行 mvn clean package 编译打包,最终打包生成了 agent jar 包,结果示例:

图片

1.1.1 编写验证 agent 功能的测试类

创建 agent-example 工程,目录结构如下:

图片

编写测试 agent 功能的类 AgentTest.java

package org.example.agent;

public class AgentTest {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
System.out.println("process result: " + process());
Thread.sleep(5000);
}
}

public static String process(){
System.out.println("process!");
return "success";
}
}

1.2 使用 java agent 静态加载方式实现

在 IDEA 的 Run/Debug Configurations 中,点击 Modify options,勾选上 add VM options,在 VM options 栏增加 -javaagent:/工程的父目录/demo-javaagent/demo-javaagent/target/demo-javaagent-1.0-jar-with-dependencies.jar

运行 Main.java 的 main 方法,可以看到控制台日志:

premain start!
premain end!
AgentTest.main start.
AgentTest.process start.
process!
AgentTest.process end.
process result: success
AgentTest.process start.
process!
AgentTest.process end.
.......省略重复的部分......

其中AgentTest.main start AgentTest.process start 等日志是我们自己写的 java agent 实现的功能,实现了方法运行开始和结束时打印日志。

1.3 使用 java agent 动态加载方式实现

动态加载不是通过 -javaagent: 的方式实现,而是通过 Attach API 的方式。

编写调用 Attach API 的测试类

package org.example.agent;

import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;

import java.util.List;

public class AttachMain {

public static void main(String[] args) throws Exception {
List<VirtualMachineDescriptor> listBefore = VirtualMachine.list();
// agentmain()方法所在jar包
String jar = "/Users/terry/Gits/agent/java-agent-group/demo-javaagent/demo-javaagent/target/demo-javaagent-1.0-jar-with-dependencies.jar";

for (VirtualMachineDescriptor virtualMachineDescriptor : VirtualMachine.list()) {
// 针对指定名称的JVM实例
if (virtualMachineDescriptor.displayName().equals("org.example.agent.AgentTest")) {
System.out.println("将对该进程的vm进行增强:org.example.agent.AgentTest的vm进程, pid=" + virtualMachineDescriptor.id());
// attach到新JVM
VirtualMachine vm = VirtualMachine.attach(virtualMachineDescriptor);
// 加载agentmain所在的jar包
vm.loadAgent(jar);
// detach
vm.detach();
}
}
}
}

先直接运行 org.example.agent.AgentTest#main​,注意不用加 -javaagent: 启动参数。

process!
process result: success
process!
process result: success
process!
process result: success
.......省略重复的部分......

约 15 秒后,再运行 org.example.agent.AttachMain#main​,可以看到 org.example.agent.AttachMain#main 打印的日志:

找到了org.example.agent.AgentTest的vm进程, pid=67398

之后可以看到 org.example.agent.AgentTest#main打印的日志中多了记录方法运行开始和结束的内容。

.......省略重复的部分......
process!
process result: success
agentmain start!
process!
process result: success
agentmain end!
process!
process result: success
process!
process result: success
.......省略重复的部分......

1.4 小结

可以看到静态加载或动态加载相同的 agent,都能实现了记录记录方法运行开始和结束日志的功能。

我们可以稍微扩展一下,打印方法的入参、返回值,也可以实现替换 class,实现热加载的功能。

四、Instrumentation

1.Instrumentation API 介绍

Instrumentation是 Java 提供的 JVM 接口,该接口提供了一系列查看和操作 Java 类定义的方法,例如修改类的字节码、向 classLoader 的 classpath 下加入 jar 文件等。使得开发者可以通过 Java 语言来操作和监控 JVM 内部的一些状态,进而实现 Java 程序的监控分析,甚至实现一些特殊功能(如 AOP、热部署)。

Instrumentation 的一些主要方法如下:

public interface Instrumentation {
/**
* 注册一个Transformer,从此之后的类加载都会被Transformer拦截。
* Transformer可以直接对类的字节码byte[]进行修改
*/
void addTransformer(ClassFileTransformer transformer);

/**
* 对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
* retransformClasses可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
*/
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

/**
* 获取一个对象的大小
*/
long getObjectSize(Object objectToSize);

/**
* 将一个jar加入到bootstrap classloader的 classpath里
*/
void appendToBootstrapClassLoaderSearch(JarFile jarfile);

/**
* 获取当前被JVM加载的所有类对象
*/
Class[] getAllLoadedClasses();
}

其中最常用的方法是addTransformer(ClassFileTransformer transformer)​,这个方法可以在类加载时做拦截,对输入的类的字节码进行修改,其参数是一个ClassFileTransformer接口,定义如下:

public interface ClassFileTransformer {

/**
* 传入参数表示一个即将被加载的类,包括了classloader,classname和字节码byte[]
* 返回值为需要被修改后的字节码byte[]
*/
byte[]
transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
}

addTransformer​方法配置之后,后续的类加载都会被Transformer拦截。

对于已经加载过的类,可以执行retransformClasses​来重新触发这个Transformer​的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。

2.Instrumentation 的局限性

在运行时,我们可以通过Instrumentation的redefineClasses​方法进行类重定义,在redefineClasses方法上有一段注释需要特别注意:

     * The redefinition may change method bodies, the constant pool and attributes.
* The redefinition must not add, remove or rename fields or methods, change the
* signatures of methods, or change inheritance. These restrictions maybe be
* lifted in future versions. The class file bytes are not checked, verified and installed
* until after the transformations have been applied, if the resultant bytes are in
* error this method will throw an exception.

这里面提到,我们不可以增加、删除或者重命名字段和方法,改变方法的签名或者类的继承关系。认识到这一点很重要,当我们通过 ASM 获取到增强的字节码之后,如果增强后的字节码没有遵守这些规则,那么调用redefineClasses方法来进行类的重定义就会失败。

五、Java agent

主流的 JVM 都提供了Instrumentation​的实现,但是鉴于Instrumentation​的特殊功能,并不适合直接提供在 JDK 的runtime里,而更适合出现在 Java 程序的外层,以上帝视角在合适的时机出现。

因此如果想使用Instrumentation功能,拿到 Instrumentation 实例,我们必须通过 Java agent。

Java agent​是一种特殊的 Java 程序(Jar 文件),它是Instrumentation​的客户端。与普通 Java 程序通过 main 方法启动不同,agent 并不是一个可以单独启动的程序,而必须依附在一个 Java 应用程序(JVM)上,与它运行在同一个进程中,通过Instrumentation API与虚拟机交互。

Java agent与Instrumentation​密不可分,二者也需要在一起使用。因为Instrumentation​的实例会作为参数注入到Java agent的启动方法中。

1.Java agent 的格式

1.1 premain 和 agentmain

Java agent 以 jar 包的形式部署在 JVM 中,jar 文件的 manifest 需要指定 agent 的类名。根据不同的启动时机,agent 类需要实现不同的方法(二选一)。

(1) JVM 启动时加载

[1] public static void premain(String agentArgs, Instrumentation inst);
[2] public static void premain(String agentArgs);

JVM 将首先寻找[1],如果没有发现[1],再寻找[2]。

(2) JVM 运行时加载

[1] public static void agentmain(String agentArgs, Instrumentation inst);
[2] public static void agentmain(String agentArgs);

与 premain()一致,JVM 将首先寻找[1],如果没有发现[1],再寻找[2]。

1.2 指定 MANIFEST.MF

可以通过 maven plugin 配置,示例:

<!-- 设置manifest配置文件-->
<manifestEntries>
<!--Premain-Class: 代表 Agent 静态加载时会调用的类全路径名。-->
<Premain-Class>demo.MethodAgentMain</Premain-Class>
<!--Agent-Class: 代表 Agent 动态加载时会调用的类全路径名。-->
<Agent-Class>demo.MethodAgentMain</Agent-Class>
<!--Can-Redefine-Classes: 是否可进行类定义。-->
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<!--Can-Retransform-Classes: 是否可进行类转换。-->
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>

生成的 MANIFEST.MF,示例:

Premain-Class: demo.MethodAgentMain
Built-By: terry
Agent-Class: demo.MethodAgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true

2.Java agent 的加载

2.1 Java agent 与 ClassLoader

Java agent 的包先会被加入到 system class path 中,然后 agent 的类会被system calss loader(默认AppClassLoader)所加载,和应用代码的真实 classLoader 无关。例如:

当启动参数加上-javaagent:my-agent.jar​运行 SpringBoot 打包的 fatjar 时,fatjar 中应用代码和 lib 中的嵌套 jar 是由 org.springframework.boot.loader.LaunchedURLClassLoader​ 加载,但这个 my-agent.jar 依然是在system calss loader(默认AppClassLoader)​中加载,而非 org.springframework.boot.loader.LaunchedURLClassLoader 加载。

该类加载逻辑非常重要,在使用 Java agent 时如果遇到ClassNotFoundException、NoClassDefFoundError,很大可能就是与该加载逻辑有关。

2.2 静态加载、动态加载 Java agent

Java agent 支持静态加载和动态加载。

2.2.1 静态加载 Java agent

静态加载,即 JVM 启动时加载,对应的是 premain()​ 方法。通过 vm 启动参数-javaagent将 agent jar 挂载到目标 JVM 程序,随目标 JVM 程序一起启动。

(1) -javaagent 启动参数

  • 其中 -javaagent​格式:"-javaagent:<jarpath>[=<option>]"。[=<option>]​部分可以指定 agent 的参数,可以传递到premain(String agentArgs, Instrumentation inst)​方法的agentArgs入参中。支持可以定义多个 agent,按指定顺序先后执行。

示例: java -javaagent:agent1.jar=key1=value1&key2=value2 -javaagent:agent2.jar -jar Test.jar

其中加载顺序为(1) agent1.jar (2) agent2.jar。**注意:不同的顺序可能会导致 agent 对类的修改存在冲突,在实际项目中用到了​pinpoint和SkyWalking​的 agent,当通过-javaagent​先挂载 pinpoint​的 agent ,后挂载 SkyWalking​**的 agent,出现 SkyWalking​对类的增强发生异常的情况,而先挂载SkyWalking的 agent 则无问题。

  • agent1.jar 的premain(String agentArgs, Instrumentation inst)​方法的agentArgs​值为key1=value1&key2=value2。

(2) premain()方法

  • premain()方法会在程序 main 方法执行之前被调用,此时大部分 Java 类都没有被加载("大部分"是因为,agent 类本身和它依赖的类还是无法避免的会先加载的),是一个对类加载埋点做手脚(addTransformer)的好机会。
  • 如果此时 premain 方法执行失败或抛出异常,那么 JVM 的启动会被终止。
  • premain() 中一般会编写如下步骤:

注册类的ClassFileTransformer,在类加载的时候会自动更新对应的类的字节码

写法示例:

// Java agent指定的premain方法,会在main方法之前被调用
public static void premain(String args, Instrumentation inst){
// Instrumentation提供的addTransformer方法,在类加载时会回调ClassFileTransformer接口
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
// TODO 字节码修改
byte[] transformed = null;
return transformed;
}
});
}

(3) 静态加载执行流程

agent 中的 class 由 system calss loader(默认AppClassLoader) 加载,premain() 方法会调用 Instrumentation API,然后 Instrumentation API 调用 JVMTI(JVMTI 的内容将在后面补充),在需要加载的类需要被加载时,会回调 JVMTI,然后回调 Instrumentation API,触发 ClassFileTransformer.transform(),最终修改 class 的字节码。

图片

(4) ClassFileTransformer.transform()

ClassFileTransformer.transform() 和 ClassLoader.load()的关系

下面是一次 ClassFileTransformer.transform()执行时的方法调用栈,

transform:38, MethodAgentMain$1 (demo)
transform:188, TransformerManager (sun.instrument)
transform:428, InstrumentationImpl (sun.instrument)
defineClass1:-1, ClassLoader (java.lang)
defineClass:760, ClassLoader (java.lang)
defineClass:142, SecureClassLoader (java.security)
defineClass:467, URLClassLoader (java.net)
access$100:73, URLClassLoader (java.net)
run:368, URLClassLoader$1 (java.net)
run:362, URLClassLoader$1 (java.net)
doPrivileged:-1, AccessController (java.security)
findClass:361, URLClassLoader (java.net)
loadClass:424, ClassLoader (java.lang)
loadClass:331, Launcher$AppClassLoader (sun.misc)
loadClass:357, ClassLoader (java.lang)
checkAndLoadMain:495, LauncherHelper (sun.launcher)

可以看到 ClassLoader.load()​加载类时,ClassLoader.load()​会调用ClassLoader.findClass(),ClassLoader.findClass()​会调用ClassLoader.defefineClass(),ClassLoader.defefineClass()​最终会执行ClassFileTransformer.transform(),ClassFileTransformer.transform()​可以对类进行修改。所以ClassLoader.load()最终加载 agent 修改后 Class 对象。

下面是精简后的 ClassLoader.load() 核心代码:

protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 判断是否已经加载过了,如果没有,则进行load
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// findClass()内部最终会调用 Java agent 中 ClassFileTransformer.transform()
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

ClassFileTransformer.transform() 和 字节码增强

ClassFileTransformer.transform()​ 中可以对指定的类进行增强,我们可以选择的代码生成库修改字节码对类进行增强,比如ASM​, CGLIB​, Byte Buddy​, Javassist。

2.2.2 动态加载 Java agent

静态加载,即 JVM 启动后的任意时间点(即运行时),通过Attach API​动态地加载 Java agent,对应的是 agentmain() 方法。

Attach API部分将在后面的章节进行说明。

agentmain()方法

对于 VM 启动后加载的Java agent​,其agentmain()​方法会在加载之时立即执行。如果agentmain执行失败或抛出异常,JVM 会忽略掉错误,不会影响到正在 running 的 Java 程序。

一般 agentmain() 中会编写如下步骤:

  • 注册类的ClassFileTransformer
  • 调用retransformClasses 方法对指定的类进行重加载

六、JVMTI

1.JVMTI 介绍

JVMTI​ (JVM Tool Interface)是 Java 虚拟机对外提供的 Native 编程接口,通过 JVMTI ,外部进程可以获取到运行时 JVM 的诸多信息,比如线程、GC 等。

JVMTI 是一套 Native 接口,在 Java SE 5 之前,要实现一个 Agent 只能通过编写 Native 代码来实现。从 Java SE 5 开始,可以使用 Java 的Instrumentation 接口(java.lang.instrument)来编写 Agent。无论是通过 Native 的方式还是通过 Java Instrumentation 接口的方式来编写 Agent,它们的工作都是借助 JVMTI 来进行完成。

启动方式

JVMTI和Instumentation API的作用很相似,都是一套 JVM 操作和监控的接口,且都需要通过 agent 来启动:

Instumentation API​需要打包成 jar,并通过 Java agent 加载(对应启动参数: -javaagent)

JVMTI 需要打包成动态链接库(随操作系统,如.dll/.so 文件),并通过 JVMTI agent 加载(对应启动参数:-agentlib/-agentpath)

2.加载时机

启动时(Agent_OnLoad)和运行时 Attach(Agent_OnAttach)

3.功能

Instumentation API 可以支持 Java 语言实现 agent 功能,但是 JVMTI 功能比 Instumentation API 更强大,它支持:

获取所有线程、查看线程状态、线程调用栈、查看线程组、中断线程、查看线程持有和等待的锁、获取线程的 CPU 时间、甚至将一个运行中的方法强制返回值……

  • 获取 Class、Method、Field 的各种信息,类的详细信息、方法体的字节码和行号、向 Bootstrap/System Class Loader 添加 jar、修改 System Property……
  • 堆内存的遍历和对象获取、获取局部变量的值、监测成员变量的值……
  • 各种事件的 callback 函数,事件包括:类文件加载、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、gc 开始和结束、方法调用进入和退出、临界区竞争与等待、VM 启动与退出……
  • 设置与取消断点、监听断点进入事件、单步执行事件……

4.JVMTI 与 Java agent

Java agent 是基于 JVMTI 实现,核心部分是 ClassFileLoadHook和TransFormClassFile。

ClassFileLoadHook​是一个 JVMTI 事件,该事件是 Instrumentation agent 的一个核心事件,主要是在读取字节码文件回调时调用,内部调用了TransFormClassFile的函数。

TransFormClassFile​的主要作用是调用java.lang.instrument.ClassFileTransformer的tranform​方法,该方法由开发者实现,通过Instrumentation的addTransformer方法进行注册。

在字节码文件加载的时候,会触发ClassFileLoadHook​事件,该事件调用TransFormClassFile​,通过经由Instrumentation​ 的 addTransformer 注册的方法完成整体的字节码修改。

对于已加载的类,需要调用retransformClass​函数,然后经由redefineClasses​函数,在读取已加载的字节码文件后,若该字节码文件对应的类关注了ClassFileLoadHook​事件,则调用ClassFileLoadHook事件。后续流程与类加载时字节码替换一致。

七、Attach API

前文提到,Java agent 动态加载是通过 Attach API 实现。

1.Attach API 介绍

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

日常很多工作都是通过 Attach API 实现的,示例:

JDK 自带的一些命令,如:jstack 打印线程栈、jps 列出 Java 进程、jmap 做内存 dump 等功能

Arthas、Greys、btrace 等监控诊断产品,通过 attach 目标 JVM 进程发送指定命令,可以实现方法调用等方面的监控。

2.Attach API 用法

由于是进程间通讯,那代表着使用 Attach API 的程序需要是一个独立的 Java 程序,通过 attach 目标进程,与其进行通讯。下面的代码表示了向进程 pid 为 1234 的 JVM 发起通讯,加载一个名为 agent.jar 的 Java agent。

// VirtualMachine等相关Class位于JDK的tools.jar
VirtualMachine vm = VirtualMachine.attach("1234"); // 1234表示目标JVM进程pid
try {
vm.loadAgent(".../javaagent.jar"); // 指定agent的jar包路径,发送给目标进程
} finally {
// attach 动作的相反的行为,从 JVM 上面解除一个代理
vm.detach();
}

vm.loadAgent 之后,相应的 agent 就会被目标 JVM 进程加载,并执行 agentmain() 方法。

执行的流程图:

图片

3.Attach API 原理

以 Hotspot 虚拟机,Linux 系统为例。当 external process(attach 发起的进程)执行 VirtualMachine.attach 时,需要通过操作系统提供的进程通信方法,例如信号、socket,进行握手和通信。其具体内部实现流程如下所示:

图片

上面提到了两个文件:

  • .attach_pidXXX 后面的 XXX 代表 pid,例如 pid 为 1234 则文件名为.attach_pid1234。该文件目的是给目标 JVM 一个标记,表示触发 SIGQUIT 信号的是 attach 请求。这样目标 JVM 才可以把 SIGQUIT 信号当做 attach 连接请求,再来做初始化。其默认全路径为/proc/XXX/cwd/.attach_pidXXX,若创建失败则使用/tmp/attach_pidXXX
  • .java_pidXXX 后面的 XXX 代表 pid,例如 pid 为 1234 则文件名为.java_pid1234。由于 Unix domain socket 通讯是基于文件的,该文件就是表示 external process 与 target VM 进行 socket 通信所使用的文件,如果存在说明目标 JVM 已经做好连接准备。其默认全路径为/proc/XXX/cwd/.java_pidXXX,若创建失败则使用/tmp/java_pidXXX

VirtualMachine.attach 动作类似 TCP 创建连接的三次握手,目的就是搭建 attach 通信的连接。而后面执行的操作,例如 vm.loadAgent,其实就是向这个 socket 写入数据流,接收方 target VM 会针对不同的传入数据来做不同的处理。

文章参考

  • 谈谈 Java Intrumentation 和相关应用
  • Java 动态调试技术原理及实践

作者介绍

原文作者:Spurs 蒋

原文链接:https://juejin.cn/post/7157684112122183693

本文转载自微信公众号「架构染色」,可以通过以下二维码关注。转载本文请联系【架构染色】公众号作者。