1. 动态追踪技术
动态追踪技术是可以不用重启线上java项目来进行问题排查的技术。比如Arthas就属于一种动态追踪工具,它提供的monitor, trace, watch命令就是用动态追踪技术实现的。
Arthas工具的基础,就是Java Agent技术,可以利用他来构建一个附加的代理程序,用来协助检测性能,还可以替换一些现有的功能,甚至jdk的类也能修改,就像JVM级别的AOP功能。
我们使用Arthas工具,觉得它很强大,对于性能调优它能提供不少帮助,那么阿里是怎么实现的呢?当我们研究技术到一定深度的时候我们就不能满足于使用了,我会思考它是怎么实现的,我们在工作中能不能开发出类似的辅助工具来帮助自己和同事呢?我们来试试吧。
2.Java Agent技术
Java agent是java1.5后引入的新特性,其主要作用是在class被加载前对其拦截,插入我们监听的字节码,也叫字节码插桩。作为JVM的AOP,就需要有AOP的功能,Java Agent提供了两个类似AOP的功能。
- premain: 可以在main运行之前进行一些操作(Java的入口是main方法)
- agentmain: 控制类运行时的行为(Arthas使用的就是这种)
在JVM中,只会调用其中一个。
要构建一个agent程序,大体可以分为以下步骤:
- 1.使用字节码增加工具,编写增强代码
- 2.在manifest中指定Premain-Class/Agent-Class属性
- 3.使用参数加载或者使用attach方式改变app项目中的内容。
我们这里用的字节码增强工具是javassist,它的工作流程如下
2.1 Premain
Premain是在JVM加载类之前拦截并修改字节码,不能热修改。
我们先来玩一个Premain的实例
2.1.1 编写Agent
Java Agent体现方式是一个jar包,我们创建一个maven工程/Maven module,打包成一个jar包即可。我这里把premain代码写成一个Maven module, 它的测试代码放在另一个module(app module)下
加入javassist依赖, 我们需要用javassist完成字节码增强
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.28.0-GA</version>
</dependency>
</dependencies>
创建一个简单的打印信息的测试类
package com.sandwich.premain.test;
/**
* @author 公众号:IT三明治
* @date 2022/3/23
* vm options:
* -javaagent:D:\MavenRepository\org\sandwich\premain\1.0-SNAPSHOT\premain-1.0-SNAPSHOT.jar
* -Xbootclasspath/a:D:\MavenRepository\org\javassist\javassist\3.28.0-GA\javassist-3.28.0-GA.jar
*/
public class App {
public static void main(String[] args) {
printMsg("Hello Sandwich");
}
private static void printMsg(String message) {
System.out.println(message);
}
}
创建一个普通的java类, 添加premain方法, 参数要包含Instrumentation。
package com.sandwich;
import java.lang.instrument.Instrumentation;
/**
* @author 公众号:IT三明治
* @date 2022/3/23
*/
public class AgentByPremain {
public static void premain(String agentOps, Instrumentation instrumentation) {
System.out.println("======> premain started");
//Transformer: Agent就靠这个来变异,它是核心方法
instrumentation.addTransformer(new PremainTransformer());
}
}
2.1.2 编写Transformer
我们在这里统计某个方法的执行时间,使用JavaAssist工具来增强字节码。
比如app项目中App类的printMsg方法。
那么需要如下步骤:
编写一个Agent类,实现ClassFileTransformer接口, 然后在transform方法中实现以下逻辑:
- 1.获取App类的字节码
- 2.获取printMsg方法的字节码
- 3.在方法前后,加入时间统计
- 4.把修改后的字节码返回。
package com.sandwich;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
/**
* @author 公众号:IT三明治
* @date 2022/3/23
*/
public class PremainTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
String loadName = className.replace("/", ".");
if (className.endsWith("App")) {
try {
//javassist 完成字节码增强(打印方法的执行时间<纳秒>)
CtClass ctClass = ClassPool.getDefault().get(loadName);
CtMethod ctMethod = ctClass.getDeclaredMethod("printMsg");
ctMethod.addLocalVariable("_startTime", CtClass.longType);
ctMethod.insertBefore("_startTime = System.nanoTime();");
ctMethod.insertAfter("System.out.println(\"cost: \" + (System.nanoTime() - _startTime) + \"ns\");");
return ctClass.toBytecode();
} catch (Exception e) {
e.printStackTrace();
}
}
return classfileBuffer;
}
}
2.1.3 打包Agent
创建MANIFEST.MF文件(让外界知晓)
路径:src/main/resources/META-INF/MANIFEST.MF
maven打包会覆盖这个文件,所以我们在pom.xml中用manifestFile为它指定为保留文件
<?xml version="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">
<parent>
<artifactId>javaagent</artifactId>
<groupId>org.sandwich</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>premain</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.sandwich</groupId>
<artifactId>app</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.28.0-GA</version>
<type>bundle</type>
</dependency>
</dependencies>
<build>
<plugins>
<!--防止type = bundle的依赖报错-->
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<!--防止我指定的MANIFEST被覆盖-->
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
执行mvn install打包安装到本地代码库
得到本地代码库jar包的地址
2.1.4 Premain代理方式启动应用
在jvm启动时启用代理。思路如下
java -javaagent:agent.jar App
在idea中,我们把参数放到jvm options里面执行
-javaagent:D:\MavenRepository\org\sandwich\premain\1.0-SNAPSHOT\premain-1.0-SNAPSHOT.jar
执行结果出了点意外
premain入口进去了,但是并没有计算方法的执行时间,也没有抛出异常
接下来我们查查原因
把Agent实现的Exception改成Throwable
重新install preagent的jar再执行App的main
这次异常信息被catch到了,原来是找不到这个类javassist/ClassPool。
这个明明是已经通过maven引入的依赖,并且已经下载到本地的
我通过其他途径修复了这个问题
先copy这个依赖包的本地路径
然后在vm options那里再添加以下参数
-Xbootclasspath/a:D:\MavenRepository\org\javassist\javassist\3.28.0-GA\javassist-3.28.0-GA.jar
重新执行可以得到以下结果
由此可见,通过增强后方法执行前后的信息都被改变了,得到了这个方法的执行时间。
把测试代码改循环,再执行
public class App {
public static void main(String[] args) throws InterruptedException {
while (true) {
printMsg("Hello Sandwich");
Thread.sleep(2000);
}
}
private static void printMsg(String message) {
System.out.println(message);
}
}
就可以查看这个字节码被修改后并非一次性生效的,在这个代码的执行周期里,它都被修改了
2.2 Agentmain
Agentmain可以在运行时将agent附加到到任意的虚拟机中来修改字节码,并且修改后可以立马更新,不需要重新加载类,因此可以实现热修改,并且比自定义类加载器更方便。
由于agent main方式无法像premain方式那样在命令行指定代理jar,因此需要借助Attach Tools API
接下来我们先要熟悉一下attach tool
熟悉Attach tool
先把attach tool使用的jar包放到一个指定路径下(这个地址并没有一定要局限在哪里,只要你能找得到它,我把它放到jdk的lib目录下)
然后添加这个jdk的依赖
<dependencies>
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8.0</version>
<scope>system</scope>
<systemPath>C:\Program Files\Java\jdk1.8.0_191\lib\tools.jar</systemPath>
</dependency>
</dependencies>
执行以下死循环程序不关闭
public class App {
public static void main(String[] args) throws InterruptedException {
while (true) {
printMsg("Hello Sandwich");
Thread.sleep(2000);
}
}
private static void printMsg(String message) {
System.out.println(message);
}
}
然后用以下代码去attach jvm
package com.sandwich.jvmattach.test;
import com.sun.tools.attach.*;
import java.io.IOException;
import java.util.List;
import java.util.Properties;
/**
* @author 公众号:IT三明治
* @date 2022/3/23
*/
public class JvmAttach {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor vmd :
list) {
if (vmd.displayName().endsWith("com.sandwich.premain.test.App")) {
System.out.println("Process pid: " + vmd.id());
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
Properties properties = virtualMachine.getSystemProperties();
String javaVersion = properties.getProperty("java.version");
System.out.println("Java version: " + javaVersion);
System.out.println("Java properties: " + properties);
virtualMachine.detach();
}
}
}
}
输出结果如下:
这个pid跟我们用jps查看到的是一样的
attach tool可以让我们附着到vm进程上,然后对vm进行操作。
到这里是不是跟Arthas attach到一个进程,然后读取它的各种数据十分相似?其实原理是一样的。
接下来我们正式来完成一个agentmain的测试实例
2.2.1 编写一个测试程序
写一个Runnable task,简单地用它来模拟一个不断被调用的api
package com.sandwich.agentmain.test;
import java.util.concurrent.TimeUnit;
/**
* @author 公众号:IT三明治
* @date 2022/3/23
*/
public class RunnableTask implements Runnable{
@Override
public void run() {
System.out.println("Running...");
}
public void shout() {
System.out.println("Shouting...");
}
public static void main(String[] args) throws InterruptedException {
RunnableTask task = new RunnableTask();
while (true) {
task.run();
TimeUnit.SECONDS.sleep(2);
task.shout();
TimeUnit.SECONDS.sleep(2);
}
}
}
这个测试类放在一个叫app的module下,这个module主要放测试代码
2.2.2 增加一个agentmain的module
这个module最终我要把它编译成agentmain的jar。
agentmain的目录结构如下
接下来我介绍一下它的创建顺序。
添加javassist依赖
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.28.0-GA</version>
<type>bundle</type>
</dependency>
添加测试类的依赖
因为我把测试类放在另一个module里面,模拟在其他jar里面,这个类需要被retransform
<dependency>
<groupId>org.sandwich</groupId>
<artifactId>app</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
添加maven-bundle-plugin
因为javassist的type是bundle,添加这个plugin是为了防止javassist下载报错
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
</plugin>
添加agentmain核心类
package com.sandwich;
import com.sandwich.agentmain.test.RunnableTask;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
/**
* @author 公众号:IT三明治
* @date 2022/3/23
*/
public class AgentByAgentMain {
public static void agentmain(String agentOps, Instrumentation instrumentation) throws UnmodifiableClassException {
System.out.println("======> agentmain started: " + agentOps);
instrumentation.addTransformer(new AgentmainTransformer(agentOps), true);
instrumentation.retransformClasses(RunnableTask.class);
}
}
添加Transformer
package com.sandwich;
import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
/**
* @author 公众号:IT三明治
* @date 2022/3/23
*/
public class AgentmainTransformer implements ClassFileTransformer {
private final String targetClassName;
public AgentmainTransformer(String targetClassName) {
this.targetClassName = targetClassName;
}
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
className = className.replaceAll("/", ".");
if (!className.equals(targetClassName)) {
return null;
}
try {
ClassPool classPool = ClassPool.getDefault();
classPool.insertClassPath(className);
CtClass ctClass = classPool.get(className);
for (CtMethod ctMethod : ctClass.getDeclaredMethods()) {
if (Modifier.isPublic(ctMethod.getModifiers()) && !ctMethod.getName().equals("main")) {
// 修改字节码
ctMethod.addLocalVariable("begin", CtClass.longType);
ctMethod.addLocalVariable("end", CtClass.longType);
ctMethod.insertBefore("begin = System.nanoTime();");
ctMethod.insertAfter("end = System.nanoTime();");
ctMethod.insertAfter("System.out.println(\"方法" + ctMethod.getName() + "耗时\"+ (end - begin) +\"ns\");");
}
}
ctClass.detach();
return ctClass.toBytecode();
} catch (Exception e) {
e.printStackTrace();
}
return classfileBuffer;
}
}
添加maven-jar-plugin
用这个plugin来个性化定制jar包
ps: 这里实现Agent-Class跟前面Premain-Class写在MANIFEST.MF不太一样,其实他们是可以写成一样的实现方式的,我这样写只是为了让大家了解更多的实现方法。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.6</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Agent-Class>com.sandwich.AgentByAgentMain</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
mvn install得到jar包
这个配置其实最终也会生成一个MANIFEST.MF文件
以下是文件里面的内容
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Built-By: Sandwich
Agent-Class: com.sandwich.AgentByAgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Class-Path: app-1.0-SNAPSHOT.jar javassist-3.28.0-GA.jar
Created-By: Apache Maven 3.6.0
Build-Jdk: 1.8.0_191
Boot-Class-Path: D:/MavenRepository/org/javassist/javassist/3.28.0-GA/
javassist-3.28.0-GA.jar
到这里agentmain代理的jar已经完成了。
2.2.3 编写attach测试代码
package com.sandwich.agentmain.test;
import com.sun.tools.attach.*;
import java.io.IOException;
/**
* @author 公众号:IT三明治
* @date 2022/3/23
*/
public class AttachTest {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
for (VirtualMachineDescriptor descriptor: VirtualMachine.list()) {
if (descriptor.displayName().equals("com.sandwich.agentmain.test.RunnableTask")) {
VirtualMachine virtualMachine = VirtualMachine.attach(descriptor.id());
virtualMachine.loadAgent("D:\\MavenRepository\\org\\sandwich\\agentmain\\1.0-SNAPSHOT\\agentmain-1.0-SNAPSHOT.jar", "com.sandwich.agentmain.test.RunnableTask");
virtualMachine.detach();
}
}
}
}
2.3.4 调试测试类以及错误排查
先启动RunnableTask
再运动attach程序
跑完后发现已经进入了agentmain的入口了,但是并不是我期待的结果,因为没有开始计算方法执行时间。
把Exception改成Throwable
重新执行mvn install得到新package,再分别执行RunnableTask和AttachTest
可以看到这里也出错了
还是找不到javassist,如果我们只是从maven依赖的角度分析的话,我们明明是在依赖添加了javassist的,为什么会找不到它的类呢?
做这个java agent测试,坑还真多,需要很有耐心去解决才行。
我怀疑是类加载的原因,让我们来追踪一下类加载情况
在RunnableTask和AttachTest分别添加追踪类加载的vm option.
-XX:+TraceClassLoading
再分别先后执行RunnableTask和AttachTest,观察打印结果
执行RunnableTask并搜索类加载日志
并无javassist相关的类被加载
再执行AttachTest并搜索类加载日志
只能在RunnableTask下搜到javassist not found的异常,并没有javassist 相关类被加载。
AgentmainTransformer也是进入agentmain核心代码才会被加载。
如果需要调用javassist相关的类,那么它必须在AgentmainTransformer加载前就已经完成加载。
我们要怎么才能让javassist先被加载呢?
按照我之前写的双亲委派模型,类的加载顺序如下
以下是类加载器原码
它首先使用parent尝试进行类加载,parent失败后才轮到自己。
这里的类加载器优先顺序是Bootstrap class loader>Extension class loader>Application class loader>Custom class loader
所以我们要先确定AgentmainTransformer的当前类加载器
改造一下代码先
重新编译先后执行RunnableTask和AttachTest
可见AgentmainTransformer当前的类加载器和transform进来的类加载器是同一个,都是Application class loader。
这样就好办了,我们想办法把javassist的包丢到它前面的class loader加载就好了。
我直接把javassist的包丢到Extension class loader加载的路径试试
重新先后执行RunnableTask和AttachTest得到如下结果
javassist果然被加载了,然后RunnableTask被从VM_RedefineClasses重新加载
字节码也被修改成功了,除了main方法外的方法都被统计执行时间了。
2.3.5 javassist not found的其他解决方案
把Extension class loader加载路径下的javassist先删除,我们再试试其他解决方案
C:\Program Files\Java\jdk1.8.0_191\jre\lib\ext\javassist-3.28.0-GA.jar
在agentmain下的pom加上下面一行
Boot-Class-Path
A list of paths to be searched by the bootstrap class loader. Paths represent directories or libraries (commonly referred to as JAR or zip libraries on many platforms). These paths are searched by the bootstrap class loader after the platform specific mechanisms of locating a class have failed. Paths are searched in the order listed. Paths in the list are separated by one or more spaces. A path takes the syntax of the path component of a hierarchical URI. The path is absolute if it begins with a slash character ('/'), otherwise it is relative. 官方文档 ...
由此可见这个配置可以指定bootstrap class loader去到指定的路径下加载jar包。它的优先级更高。
重新编译,再先后执行RunnableTask和AttachTest
可见javassist不但被加载了,而且拥有最高优先级,第一个被加载的就是它
字节码同样被修改成功并且成功统计除main外所有方法的执行时间。
3 总结
我们利用java agent技术成功修改了目标字节码,完成对目标代码的动态修改,追踪。其实作为 Java的动态追踪技术,站在比较底层的角度上来说,底层无非就是基于ASM、Java Attach API、Instrument开发的创建。 Arthas都是针对这些技术做封装而已。
我已经上传完整测试代码到公众号: IT三明治,如果需要请关注并回复:1002,下载测试,希望能避免你重走我踩过的坑。