Java Agent 学习笔记

Java 从 1.5 开始提供了 java.lang.instrumentdoc)包,该包为检测(instrument) Java 程序提供 API,比如用于监控、收集性能信息、诊断问题。通过 java.lang.instrument 实现工具被称为 Java Agent。Java Agent 可以修改类文件的字节码,通常是,在字节码方法插入额外的字节码来完成检测。关于如何使用 java.lang.instrument 包,可以参考 javadoc 的包描述(en, zh)。

开发 Java Agent 的涉及的要点如下图所示 [ ref ]
Java Agent

Java Agent 支持两种方式加载,启动时加载,即在 JVM 程序启动时在命令行指定一个选项来启动代理;启动后加载,这种方式使用从 JDK 1.6 开始提供的 Attach API 来动态加载代理。

启动时加载 agent

最简单的例子

现在创建命名为 proj-demo 的 gradle 项目,目录布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ tree proj-demo
proj-demo
├── build.gradle
└── src
├── main
│   └── java
│   └── com
│   └── demo
│   └── App.java
└── test
└── java

7 directories, 2 files

com.demo.App 类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class App {

public static void main(String[] args) throws InterruptedException {
while (true) {
System.out.println(getGreeting());
Thread.sleep(1000L);
}
}

public static String getGreeting() {
return "hello world";
}
}

运行 com.demo.App,每隔 1 秒输出 hello world

1
2
3
4
$ gradle build
$ java -cp "target/classes/java/main" com.demo.App
hello world
hello world

现在创建名称为 proj-premain 的 gradle 项目,com.demo.MyPremain 类实现 premain 方法:

1
2
3
4
5
6
7
package com.demo;

public class MyPremain {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println(agentArgs);
}
}

META-INF/MANIFEST.MF 文件指定 Premain-Class 属性:

1
2
3
4
5
6
7
8
jar {
manifest {
attributes 'Premain-Class': 'com.demo.MyPremain'
}
from {
configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
}
}

打包生成 proj-premain.jar,这个 jar 包就是 javaagent 代理。现在来试试运行 com.demo.App 时,启动这个 javaagent 代理。根据 javadoc 的描述,可以将以下选项添加到命令行来启动代理:

1
-javaagent:jarpath[=options]

指定 -javaagent:"proj-premain.jar=hello agent",传入的 agentArgshello agent,再次运行 com.demo.App

1
2
3
4
$ java -javaagent:"proj-premain.jar=hello agent" -cp "target/classes/java/main" com.demo.App
hello agent
hello world
hello world

可以看到,在运行 main 之前,运行了 premain 方法,即先输出 hello agent,每隔 1 秒输出 hello world

修改字节码

在实现 premain 时,除了能获取 agentArgs 参数,还能获取 Instrumentation 实例。Instrumentation 类提供 addTransformer 方法,用于注册提供的转换器 ClassFileTransformer

1
2
// 注册提供的转换器
void addTransformer(ClassFileTransformer transformer)

ClassFileTransformer 是抽象接口,唯一需要实现的是 transform 方法。在转换器使用 addTransformer 注册之后,每次定义新类时(调用 ClassLoader.defineClass)都将调用该转换器的 transform 方法。该方法签名如下:

1
2
3
4
5
6
7
// 此方法的实现可以转换提供的类文件,并返回一个新的替换类文件
byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException

操作字节码可以使用 ASM、Apache BCEL、Javassist、cglib、Byte Buddy 等库。下面示例代码,使用 BCEL 库实现名为 GreetingTransformer 转换器。该转换器实现的逻辑就是,将 com.demo.App.getGreeting() 方法输出的 hello world,替换为输出 premain 方法的传入的参数 agentArgs

1
2
3
4
5
public class MyPremain {
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new GreetingTransformer(agentArgs));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import org.apache.bcel.*;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class GreetingTransformer implements ClassFileTransformer {
private String agentArgs;

public GreetingTransformer(String agentArgs) {
this.agentArgs = agentArgs;
}

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (!className.equals("com/demo/App")) {
return classfileBuffer;
}
try {
JavaClass clazz = Repository.lookupClass(className);
ClassGen cg = new ClassGen(clazz);
ConstantPoolGen cp = cg.getConstantPool();
for (Method method : clazz.getMethods()) {
if (method.getName().equals("getGreeting")) {
MethodGen mg = new MethodGen(method, cg.getClassName(), cp);
InstructionList il = new InstructionList();
il.append(new PUSH(cp, this.agentArgs));
il.append(InstructionFactory.createReturn(Type.STRING));
mg.setInstructionList(il);
mg.setMaxStack();
mg.setMaxLocals();
cg.replaceMethod(method, mg.getMethod());
}
}
return cg.getJavaClass().getBytes();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
return null;
}
}

启动后加载 agent

最早 JDK 1.5发布 java.lang.instrument 包时,agent 是必须在 JVM 启动时,通过命令行选项附着(attach)上去。但在 JVM 正常运行时,加载 agent 没有意义,只有出现问题,需要诊断才需要附着 agent。JDK 1.6 实现了 attach-on-demand(按需附着)[ JDK-4882798 ],可以使用 Attach API 动态加载 agent [ oracle blog, javadoc ]。这个 Attach API 在 tools.jar 中。JVM 启动时默认不加载这个 jar 包,需要在 classpath 中额外指定。使用 Attach API 动态加载 agent 的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;

public class AgentLoader {

public static void main(String[] args) throws Exception {
if (args.length < 2) {
System.err.println("Usage: java -cp .:$JAVA_HOME/lib/tools.jar"
+ " com.demo.AgentLoader <pid/name> <agent> [options]");
System.exit(0);
}

String jvmPid = args[0];
String agentJar = args[1];
String options = args.length > 2 ? args[2] : null;
for (VirtualMachineDescriptor jvm : VirtualMachine.list()) {
if (jvm.displayName().contains(args[0])) {
jvmPid = jvm.id();
break;
}
}

VirtualMachine jvm = VirtualMachine.attach(jvmPid);
jvm.loadAgent(agentJar, options);
jvm.detach();
}
}

启动时加载 agent,-javaagent 传入的 jar 包需要在 MANIFEST.MF 中包含 Premain-Class 属性,此属性的值是 代理类 的名称,并且这个 代理类 要实现 premain 静态方法。启动后加载 agent 也是类似,通过 Agent-Class 属性指定 代理类代理类 要实现 agentemain 静态方法。agent 被加载后,JVM 将尝试调用 agentmain 方法。

上文提到每次定义新类(调用 ClassLoader.defineClass)时,都将调用该转换器的 transform 方法。对于已经定义加载的类,需要使用重定义类(调用 Instrumentation.redefineClass)或重转换类(调用 Instrumentation.retransformClass)。

1
2
3
4
5
6
7
8
// 注册提供的转换器。如果 canRetransform 为 true,那么重转换类时也将调用该转换器
void addTransformer(ClassFileTransformer transformer, boolean canRetransform)
// 使用提供的类文件重定义提供的类集。新的类文件字节,通过 ClassDefinition 传入
void redefineClasses(ClassDefinition... definitions)
throws ClassNotFoundException, UnmodifiableClassException
// 重转换提供的类集。对于每个添加时 canRetransform 设为 true 的转换器,在这些转换器中调用 transform 方法
void retransformClasses(Class<?>... classes)
throws UnmodifiableClassException

重定义类(redefineClass)从 JDK 1.5 开始支持,而重转换类(retransformClass)是 JDK 1.6 引入。相对来说,重转换类能力更强,当存在多个转换器时,重转换将由 transform 调用链组成,而重定义类无法组成调用链。重定义类能实现的逻辑,重转换类同样能完成,所以保留重定义类方法(Instrumentation.redefineClass)可能只是为了向后兼容 [ stackoverflow ]。

实现 agentmain 的示例代码如下,其中 GreetingTransformer 转换器的类定义和上文一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyAgentMain {

public static void agentmain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new GreetingTransformer(agentArgs), true);
try {
Class clazz = Class.forName("com.demo.App");
if (inst.isModifiableClass(clazz)) {
inst.retransformClasses(clazz);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

MANIFEST.MF 文件配置:

1
2
3
4
5
6
7
8
9
10
jar {
manifest {
attributes 'Agent-Class': 'com.demo.MyAgentMain'
attributes 'Can-Redefine-Classes' : true
attributes 'Can-Retransform-Classes' : true
}
from {
configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
}
}

需要注意的是,和定义新类不同,重定义类和重转换类,可能会更改方法体、常量池和属性,但不得添加、移除、重命名字段或方法;不得更改方法签名、继承关系 [ javadoc ]。这个限制将来可能会通过 “JEP 159: Enhanced Class Redefinition” 移除 [ ref ]。

使用 Byte Buddy

Byte Buddy(home, github, javadoc),运行时的代码生成和操作库,2015 年获得 Oracle 官方 Duke's Choice award,提供高级别的创建和修改 Java 类文件的 API,使用这个库时,不需要了解字节码。另外,对 Java Agent 的开发 Byte Buddy 也有很好的支持,可以参考 Byte Buddy 作者 Rafael Winterhalter 写的介绍文章 [ ref1, ref2 ]。

上文使用 BCEL 实现的 GreetingTransformer,现在改用 Byte Buddy,会变得非常简单。实现 premain 示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void premain(String agentArgs, Instrumentation inst) {
new AgentBuilder.Default()
.type(ElementMatchers.named("com.demo.App"))
.transform(new AgentBuilder.Transformer() {
@Override
public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder,
TypeDescription typeDescription,
ClassLoader classLoader,
JavaModule module) {
return builder.method(ElementMatchers.named("getGreeting"))
.intercept(FixedValue.value(agentArgs));
}
}).installOn(inst);
}

实现 agentmain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void agentmain(String agentArgs, Instrumentation inst) {
new AgentBuilder.Default()
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.disableClassFormatChanges()
.type(ElementMatchers.named("com.demo.App"))
.transform(new AgentBuilder.Transformer() {
@Override
public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder,
TypeDescription typeDescription,
ClassLoader classLoader,
JavaModule module) {
return builder.method(ElementMatchers.named("getGreeting"))
.intercept(FixedValue.value(agentArgs));
}
}).installOn(inst);
}

另外,Byte Buddy 对 Attach API 作了封装,屏蔽了对 tools.jar 的加载,可以直接使用 ByteBuddyAgent 类:

1
ByteBuddyAgent.attach(new File(agentJar), jvmPid, options);

上文中的 AgentLoader,可以使用这个 API 简化,实现的完整示例参见 AgentLoader2

实现性能计时器

Byte Buddy 的 github 的 README 文档提供了一个性能计时拦截器的代码示例,能对某个方法的运行耗时做统计。现在我们来看下是如何实现的。假设 com.demo.App2 类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class App2 {

public static void main(String[] args) {
while (true) {
System.out.println(getGreeting());
}
}

public static String getGreeting() {
try {
Thread.sleep((long) (1000 * Math.random()));
} catch (InterruptedException e) {
e.printStackTrace();
}
return "hello world";
}
}

使用 Byte Buddy 实现计时拦截器的 agent,如下:

1
2
3
4
5
6
7
8
9
10
11
public class TimerAgent {

public static void premain(String agentArgs, Instrumentation inst) {
new AgentBuilder.Default()
.type(ElementMatchers.any())
.transform((builder, type, classLoader, module) ->
builder.method(ElementMatchers.nameMatches(agentArgs))
.intercept(MethodDelegation.to(TimingInterceptor.class)))
.installOn(inst);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
public class TimingInterceptor {

@RuntimeType
public static Object intercept(@Origin Method method, @SuperCall Callable<?> callable) throws Exception {
long start = System.currentTimeMillis();
try {
return callable.call();
} finally {
System.out.println(method + " took " + (System.currentTimeMillis() - start) + "ms");
}
}
}

getGreeting 方法进行性能剖析,运行结果如下:

1
2
3
4
5
$ java -javaagent:"proj-byte-buddy.jar=get.*" -cp "target/classes/java/main" com.demo.App2
public static java.lang.String com.demo.App2.getGreeting() took 694ms
hello world
public static java.lang.String com.demo.App2.getGreeting() took 507ms
hello world

示例代码中的 premain 参数 agentArgs 用于指定需要剖析性能的方法名,支持正则表达式。当实际参数传入 get.* 时,匹配到 getGreeting 方法。上面的示例,使用的是 Byte Buddy 的方法委托 Method Delegation API [ javadoc ]。Delegation API 实现原理就是,将被拦截的方法委托到另一个办法上,如下左图所示(图片来自 Rafael Winterhalter 的 slides)。这种写法会修改被代理类的类定义格式,只能用在启动时加载 agent,即 premain 方式代理。

若要通过 Byte Buddy 实现启动后动态加载 agent,官方提供了 Advice API [ javadoc ]。Advice API 实现原理上是,在被拦截方法内部的开始和结尾添加代码,如下右图所示。这样只更改了方法体,不更改方法签名,也没添加额外的方法,符合重定义类(redefineClass)和重转换类(retransformClass)的限制。

delegation vs. advice

现在来看下使用 Advice API 实现性能定时器的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TimingAdvice {

@Advice.OnMethodEnter
public static long enter() {
return System.currentTimeMillis();
}

@Advice.OnMethodExit
public static void exit(@Advice.Origin Method method, @Advice.Enter long start) {
long duration = System.currentTimeMillis() - start;
System.out.println(method + " took " + duration + "ms");
}
}
1
2
3
4
5
6
7
8
9
10
11
public static void agentmain(String agentArgs, Instrumentation inst) {
new AgentBuilder.Default()
.disableClassFormatChanges()
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
// .with(AgentBuilder.Listener.StreamWriting.toSystemOut())
.type(ElementMatchers.any())
.transform((builder, type, classLoader, module) ->
builder.visit(Advice.to(TimingAdvice.class)
.on(ElementMatchers.nameMatches(agentArgs))))
.installOn(inst);
}

若对 com.demo.App 类,动态加载这个 Advice API 实现的 agent,getGreeting() 方法将会被重定义为(真正的实现可能稍有不同,但原理一致):

1
2
3
4
5
6
7
public static String getGreeting() {
long $start = System.nanoTime();
String $result = "hello world";
long $duration = System.nanoTime() – $start;
System.out.println("App.getGreeting()" + " took " + $duration + "ms");
return $result;
}

实际应用案例

Java Agent 的实际应用案例很多,举些笔者实际工作中使用到的开源软件的应用案例。

微服务是目前流行的互联网架构,实施微服务架构其中用于观察分布式服务的 APM (应用性能管理)系统是必不可缺的一环。典型的 APM 系统,如 PinpointSkyWalking,为了减少的 Java 服务应用代码的入侵,底层实现上都采用 Java Agent 技术,在 Java 服务应用启动时加载 agent,进行字节码增强技术,实现分布式追踪、服务性能监控等特性。具体可参见 Pinpoint 文档和 SkyWalking 文档

Alibaba Java 诊断利器 Arthas,实现上使用了动态 Attach API,相关源代码参见 github。Arthas 4.0 开始支持 premain 方式启动时加载 agent,参见 issue #550


**附注:**本文中提到的代码,可以在 github 上访问得到,javaagent-demo

参考资料