Expert Beginner

Learning by doing

0%

Java instrumentation - hello javaagent

想直接看程式碼hello-javaagent Projecthello-attach Project的話,直接點連結即可。

hello-javaagent Project講用-javaagent的方式使用java instrumentation的hello world project。這是個multi module maven project。有兩個module一個是client一個是agent部分。另外,還有一個run.xml的ant script裡面有兩個task用來執行client,一個task有啟動agent一個沒有,可以感覺一下差異。

hello-attach project則是使用attach的方式,有三個project,client subproject是用spring boot寫的client,attach subproject是用來attach agent jar到target jvm,最後agent subproject是agent的本體。

Java Instrumentation

Instrumentation讓我們可以在既有(compiled)方法插入額外的bytecode,而達到收集使用中資料來進一步分析使用。instrumentation底層是透過JVM Tool Interface(JVMTI)來達成,JVMTI是JVM暴露出來的extension point。

在Java 5的時候讓我們可以在啟動的時候使用-javaagent的方式,指定agent。在Java 6的時候則更近一步提供了attach的方式,讓我們可以針對已啟動的JVM process執行agent的工作。

  • 啟動JVM時啟動代理 (hello-javaagent project)
  • 針對已啟動JVM啟動代理 (hello-attach project)

hello-javaagent

入口

入口class我們要提供premain()這個方法,需要兩個參數

1
public static void premain(String agentArgs, Instrumentation inst)

第一個參數是啟動agent時,可以由命令列給予agent的參數,第二個則是JVM會提供agent class使用的Instrumentation的入口。

Instrumentation Interface

  • void addTransformer(ClassFileTransformer transformer, boolean canRetransform)

註冊transformer

  • Class[] getAllLoadedClasses()

取得被load到JVM的所有class

  • void retransformClasses(Class<?>… classes) throws UnmodifiableClassException

針對已經loaded的classes重新在instrumentation機制由agent調整bytecode

Configuration

MANIFEST.MF

也可以用maven build file去產生,我選擇自己寫,然後在maven build file設定使用自己寫的MANIFEST.MF。

1
2
3
Premain-Class: main.AgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true

maven build file相關設定方式見Maven Assembly Plugin。

Maven Assembly Plugin

因為有ASM的依賴,所以打包jar的時候要連同依賴的library一起包進去才行。以前都用copy reference,改用assembly plugin做看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>${maven-assembly-plugin.version}</version>
<configuration>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>

Using ASM for manipulate bytecode

hello project的目的是在我們在意的類別裡在意的method被呼叫前和呼叫後在Console印個log下來。所以我們要做的事情是寫一個ClassFileTransformer,在這裡使用ASM處理bytecode。然後在premain()裡把我們寫的transformer加進去。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyClassTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if(!"app/MyTest".equals(className)) {
return classfileBuffer;
}
ClassReader classReader = new ClassReader(classfileBuffer);
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
ClassVisitor classVisitor = new MyClassVisitor(classWriter);
classReader.accept(classVisitor, ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
return classWriter.toByteArray();
}
}

這裡我們在意的類別是app.MyTest這個類別,所以在transformer裡遇到不在乎的class就把該class的bytecode直接回傳;如果遇到MyTest類別,就透過ASM來處理一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MyClassVisitor extends ClassVisitor {
public static final String NAME_CONSTRUCTOR = "<init>";
public static final String NAME_MAIN = "main";
public static final Set WhiteList = Collections.unmodifiableSet(Set.of(NAME_CONSTRUCTOR, NAME_MAIN));

public MyClassVisitor(ClassVisitor classVisitor) {
super(ASM8, classVisitor);
}

@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if(WhiteList.contains(name)) {
return methodVisitor;
}
return new MyMethodVisitor(ASM8, methodVisitor, access, name, descriptor);
}
}

跟書上範例不一樣的是我不想看main,所以遇到constructor和main的話就把原本的methodVisitor丟回去不處理,反之則回傳我們新寫的MyMethodVisitor的instance回去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MyMethodVisitor extends AdviceAdapter {
protected MyMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(api, methodVisitor, access, name, descriptor);
}

@Override
protected void onMethodEnter() {
super.onMethodEnter();
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn(">>> enter " + this.getName());
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}

@Override
protected void onMethodExit(int opcode) {
super.onMethodExit(opcode);
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn(">>> exit " + this.getName());
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
}

因為只是要在呼叫前後加一些bytecode進去,就可以利用asm-commons這包裡的AdviceAdapter來幫忙,AdviceAdapter提供了method enter/exit的方法給我們實作就可以達成目的。

這裡就只是很簡單的作出System.out.println(str)這樣的bytecode在原本method bytecode前後。

transformer都寫完就是在agent main裡面加進去

1
2
3
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new MyClassTransformer(), true);
}

Cleint

跟書上的例子一樣

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package app;

public class MyTest {
public static void main(String[] args) {
new MyTest().foo();
}

public void foo() {
bar1();
bar2();
}

private void bar1() {
System.out.println("running bar1...");
}

private void bar2() {
System.out.println("running bar2...");
}
}

Execute it!!!

執行方式就是在java指令加上-javaagent your_agent_jar,或參考run.xml裡的run-with-javaagent這個target的內容。

1
2
3
4
5
6
7
<property name="AGENT_JAR" value="agent/target/agent-0.0.1-jar-with-dependencies.jar" />

<target name="run-with-javaagent">
<java jar="client/target/client-0.0.1.jar" fork="true">
<jvmarg value="-javaagent:${AGENT_JAR}" />
</java>
</target>

執行結果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
D:\lab\java-basic\instrumentation\hello-javaagent>ant -f run.xml run-with-javaagent
Buildfile: D:\lab\java-basic\instrumentation\hello-javaagent\run.xml

run-with-javaagent:
[java] >>> enter foo
[java] >>> enter bar1
[java] running bar1...
[java] >>> exit bar1
[java] >>> enter bar2
[java] running bar2...
[java] >>> exit bar2
[java] >>> exit foo

BUILD SUCCESSFUL
Total time: 0 seconds

hello-attach

入口

入口class我們要提供agentmain()這個方法,需要兩個參數

1
public static void agentmain(String agentArgs, Instrumentation inst)

第一個參數是啟動agent時,可以由命令列給予agent的參數,第二個則是JVM會提供agent class使用的Instrumentation的入口。

Configuration

就把原本的Premain-Class換成Agent-Class

1
2
3
Agent-Class: main.AgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true

Using ASM for manipulate bytecode

agentmain內容,將transform加到instrumentation內之外,還要透過retransformClasses()把目標類別重新transform他們的bytecode。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
System.out.println("Agent Running...");
inst.addTransformer(new MyClassTransformer(), true);
Class[] classes = inst.getAllLoadedClasses();
for(Class clz : classes) {
//System.out.println(clz.getName());
if(clz.getName().equals("com.example.client.controller.HelloController")) {
System.out.println("Reloading: " + clz.getName());
inst.retransformClasses(clz);
break;
}
}
}

後面利用ASM Core API作的事情就跟之前專案類似,就不貼了。

Attach

使用attach的方式需要target JVM的process id,可以透過jps查詢或者這裡我使用一個簡單的Spring Boot程式當作target,啟動Spring Boot Application預設會把process id吐到Console裡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
System.out.println("Attach agent to pid");
String pid = args[0];
String agent = args[1];
System.out.println("pid: " + args[0]);
System.out.println("agent: " + agent);
VirtualMachine vm = VirtualMachine.attach(pid);
try {
vm.loadAgent(agent);
System.out.println("Done.");
} finally {
vm.detach();
}
}

loadAgent()的路徑是target virtual machine的檔案系統裡的agent jar file路徑。見JavaDoc

Cleint

用Spring Boot做一個簡單的hello api當作測試的client。可以觀察到agent執行時,agent相關程式印到Console的log會在client執行的Console印出來。

Execute it!!!

attach agent

1
2
3
4
5
6
D:\lab\java-basic\instrumentation\hello-attach\attach>java -jar target\attach-0.0.1.jar 17828 D:\lab\java-basic\instrumentation\hello-attach\agent\targe
t\agent-0.0.1-jar-with-dependencies.jar
Attach agent to pid
pid: 17828
agent: D:\lab\java-basic\instrumentation\hello-attach\agent\target\agent-0.0.1-jar-with-dependencies.jar
Done.

Srping boot console after attach agent

1
2
3
4
5
6
2020-09-14 13:15:00.687  INFO 17828 --- [  restartedMain] com.example.client.ClientApplication     : Started ClientApplication in 2.632 seconds (JVM running for 3.352)
Agent Running...
Reloading: com.example.client.controller.HelloController
<init>
hello
Modify hello()

呼叫hello api之後看到server log會印出我們agent添加的程式碼執行的log

1
2
3
4
5
6
7
8
9
10
11
2020-09-14 13:15:00.687  INFO 17828 --- [  restartedMain] com.example.client.ClientApplication     : Started ClientApplication in 2.632 seconds (JVM running for 3.352)
Agent Running...
Reloading: com.example.client.controller.HelloController
<init>
hello
Modify hello()
2020-09-14 13:16:52.757 INFO 17828 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2020-09-14 13:16:52.757 INFO 17828 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2020-09-14 13:16:52.770 INFO 17828 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 13 ms
>>> enter hello
>>> exit hello