Java Instrumentation 包 Java Instrumentation 概述 Java Instrumentation 這個技術看起來非常神秘,很少有書會詳細介紹。但是有很多工具是基於 Instrumentation 來實現的: APM 產品: pinpoint、skywalking ...
Java Instrumentation 包
Java Instrumentation 概述
Java Instrumentation 這個技術看起來非常神秘,很少有書會詳細介紹。但是有很多工具是基於 Instrumentation 來實現的:
-
APM 產品: pinpoint、skywalking、newrelic、聽雲的 APM 產品等都基於 Instrumentation 實現 -
熱部署工具:Intellij idea 的 HotSwap、Jrebel 等 -
Java 診斷工具:Arthas、Btrace 等
由於對位元組碼修改功能的巨大需求,JDK 從 JDK5 版本開始引入了java.lang.instrument
包。它可以通過 addTransformer 方法設置一個 ClassFileTransformer,可以在這個 ClassFileTransformer 實現類的轉換。
JDK 1.5 支持靜態 Instrumentation,基本的思路是在 JVM 啟動的時候添加一個代理(javaagent),每個代理是一個 jar 包,其 MANIFEST.MF 文件里指定了代理類,這個代理類包含一個 premain 方法。JVM 在類載入時候會先執行代理類的 premain 方法,再執行 Java 程式本身的 main 方法,這就是 premain 名字的來源。在 premain 方法中可以對載入前的 class 文件進行修改。這種機制可以認為是虛擬機級別的 AOP,無需對原有應用做任何修改,就可以實現類的動態修改和增強。
從 JDK 1.6 開始支持更加強大的動態 Instrument,在JVM 啟動後通過 Attach API 遠程載入,後面會詳細介紹。
本文會分為 javaagent 和動態 Attach 兩個部分來介紹
Java Instrumentation 核心方法
Instrumentation 是 java.lang.instrument
包下的一個介面,這個介面的方法提供了註冊類文件轉換器、獲取所有已載入的類等功能,允許我們在對已載入和未載入的類進行修改,實現 AOP、性能監控等功能。
常用的方法如下:
/**
* 為 Instrumentation 註冊一個類文件轉換器,可以修改讀取類文件位元組碼
*/
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
/**
* 對JVM已經載入的類重新觸發類載入
*/
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
/**
* 獲取當前 JVM 載入的所有類對象
*/
Class[] getAllLoadedClasses()
它的 addTransformer 給 Instrumentation 註冊一個 transformer,transformer 是 ClassFileTransformer 介面的實例,這個介面就只有一個 transform 方法,調用 addTransformer 設置 transformer 以後,後續JVM 載入所有類之前都會被這個 transform 方法攔截,這個方法接收原類文件的位元組數組,返迴轉換過的位元組數組,在這個方法中可以做任意的類文件改寫。
下麵是一個空的 ClassFileTransformer 的實現:
public class MyClassTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classBytes) throws IllegalClassFormatException {
// 在這裡讀取、轉換類文件
return classBytes;
}
}
接下來我們來介紹本文的主角之一 javaagent。
Javaagent 介紹
Javaagent 是一個特殊的 jar 包,它並不能單獨啟動的,而必須依附於一個 JVM 進程,可以看作是 JVM 的一個寄生插件,使用 Instrumentation 的 API 用來讀取和改寫當前 JVM 的類文件。
Agent 的兩種使用方式
它有兩種使用方式:
-
在 JVM 啟動的時候載入,通過 javaagent 啟動參數 java -javaagent:myagent.jar MyMain,這種方式在程式 main 方法執行之前執行 agent 中的 premain 方法 -
在 JVM 啟動後 Attach,通過 Attach API 進行載入,這種方式會在 agent 載入以後執行 agentmain 方法 premain 和 agentmain 方法簽名如下:
public static void premain(String agentArgument, Instrumentation instrumentation) throws Exception
public static void agentmain(String agentArgument, Instrumentation instrumentation) throws Exception
這兩個方法都有兩個參數
-
第一個 agentArgument 是 agent 的啟動參數,可以在 JVM 啟動命令行中設置,比如
java -javaagent:<jarfile>=appId:agent-demo,agentType:singleJar test.jar
的情況下 agentArgument 的值為 "appId:agent-demo,agentType:singleJar"。 -
第二個 instrumentation 是 java.lang.instrument.Instrumentation 的實例,可以通過 addTransformer 方法設置一個 ClassFileTransformer。
第一種 premain 方式的載入時序如下:
Agent 打包
為了能夠以 javaagent 的方式運行 premain 和 agentmain 方法,我們需要將其打包成 jar 包,併在其中的 MANIFEST.MF 配置文件中,指定 Premain-class 等信息,一個典型的生成好的 MANIFEST.MF 內容如下
為了能夠以 javaagent 的方式運行 premain 和 agentmain 方法,我們需要將其打包成 jar 包,併在其中的 MANIFEST.MF 配置文件中,指定 Premain-class 等信息,一個典型的生成好的 MANIFEST.MF 內容如下
下麵是一個可以幫助生成上面 MANIFEST.MF 的 maven 配置
<build>
<finalName>my-javaagent</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Agent-Class>me.geek01.javaagent.AgentMain</Agent-Class>
<Premain-Class>me.geek01.javaagent.AgentMain</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
Agent 使用方式一:JVM 啟動參數
下麵使用 javaagent 實現簡單的函數調用棧跟蹤,以下麵的代碼為例:
public class MyTest {
public static void main(String[] args) {
new MyTest().foo();
}
public void foo() {
bar1();
bar2();
}
public void bar1() {
}
public void bar2() {
}
}
通過 javaagent 啟動參數的方式在每個函數進入和結束時都列印一行日誌,實現調用過程的追蹤的效果。
核心的方法 instrument 的邏輯如下:
public static class MyMethodVisitor extends AdviceAdapter {
@Override
protected void onMethodEnter() {
// 在方法開始處插入 <<<enter xxx
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("<<<enter " + this.getName());
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
super.onMethodEnter();
}
@Override
protected void onMethodExit(int opcode) {
super.onMethodExit(opcode);
// 在方法結束處插入 <<<exit xxx
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn(">>>exit " + this.getName());
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
}
把 agent 打包生成 my-trace-agent.jar,添加 agent 啟動 MyTest 類
java -javaagent:/path_to/my-trace-agent.jar MyTest
可以看到輸出結果如下:
<<<enter main
<<<enter foo
<<<enter bar1
>>>exit bar1
<<<enter bar2
>>>exit bar2
>>>exit foo
>>>exit main
通過上面的方式,我們在不修改 MyTest 類源碼的情況下實現了調用鏈跟蹤的效果。更加健壯和完善的調用鏈跟蹤實現會在後面的 APM 章節詳細介紹。
Agent 使用方式二:Attach API 使用
在 JDK5 中,開發者只能 JVM 啟動時指定一個 javaagent 在 premain 中操作位元組碼,Instrumentation 也僅限於 main 函數執行前,這樣的方式存在一定的局限性。從 JDK6 開始引入了動態 Attach Agent 的方案,除了在命令行中指定 javaagent,現在可以通過 Attach API 遠程載入。我們常用的 jstack、arthas 等工具都是通過 Attach 機制實現的。
接下來我們會結合跨進程通信中的信號和 Unix 域套接字來看 JVM Attach API 的實現原理
JVM Attach API 基本使用
下麵以一個實際的例子來演示動態 Attach API 的使用,代碼中有一個 main 方法,每隔 3s 輸出 foo 方法的返回值 100,接下來動態 Attach 上 MyTestMain 進程,修改 foo 的位元組碼,讓 foo 方法返回 50。
public class MyTestMain {
public static void main(String[] args) throws InterruptedException {
while (true) {
System.out.println(foo());
TimeUnit.SECONDS.sleep(3);
}
}
public static int foo() {
return 100; // 修改後 return 50;
}
}
步驟如下:
1、編寫 Attach Agent,對 foo 方法做註入,完整的代碼見:github.com/arthur-zhan…
動態 Attach 的 agent 與通過 JVM 啟動 javaagent 參數指定的 agent jar 包的方式有所不同,動態 Attach 的 agent 會執行 agentmain 方法,而不是 premain 方法。
public class AgentMain {
public static void agentmain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException {
System.out.println("agentmain called");
inst.addTransformer(new MyClassFileTransformer(), true);
Class classes[] = inst.getAllLoadedClasses();
for (int i = 0; i < classes.length; i++) {
if (classes[i].getName().equals("MyTestMain")) {
System.out.println("Reloading: " + classes[i].getName());
inst.retransformClasses(classes[i]);
break;
}
}
}
}
2、因為是跨進程通信,Attach 的發起端是一個獨立的 java 程式,這個 java 程式會調用 VirtualMachine.attach 方法開始和目標 JVM 進行跨進程通信。
public class MyAttachMain {
public static void main(String[] args) throws Exception {
VirtualMachine vm = VirtualMachine.attach(args[0]);
try {
vm.loadAgent("/path/to/agent.jar");
} finally {
vm.detach();
}
}
}
使用 jps 查詢到 MyTestMain 的進程 id,
java -cp /path/to/your/tools.jar:. MyAttachMain pid
可以看到 MyTestMain 的輸出的 foo 方法已經返回了 50。
java -cp . MyTestMain
100
100
100
agentmain called
Reloading: MyTestMain
50
50
50
JVM Attach 過程分析
執行 MyAttachMain,當指定一個不存在的 JVM 進程時,會出現如下的錯誤:
java -cp /path/to/your/tools.jar:. MyAttachMain 1234
Exception in thread "main" java.io.IOException: No such process
at sun.tools.attach.LinuxVirtualMachine.sendQuitTo(Native Method)
at sun.tools.attach.LinuxVirtualMachine.<init>(LinuxVirtualMachine.java:91)
at sun.tools.attach.LinuxAttachProvider.attachVirtualMachine(LinuxAttachProvider.java:63)
at com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:208)
at MyAttachMain.main(MyAttachMain.java:8)
可以看到 VirtualMachine.attach 最終調用了 sendQuitTo 方法,這是一個 native 的方法,底層就是發送了 SIGQUIT 信號給目標 JVM 進程。
前面信號部分我們介紹過,JVM 對 SIGQUIT 的預設行為是 dump 當前的線程堆棧,那為什麼調用 VirtualMachine.attach 沒有輸出調用棧堆棧呢?
對於 Attach 的發起方,假設目標進程為 12345,這部分的詳細的過程如下:
1、Attach 端檢查臨時文件目錄是否有 .java_pid12345 文件
這個文件是一個 UNIX 域套接字文件,由 Attach 成功以後的目標 JVM 進程生成。如果這個文件存在,說明正在 Attach 中,可以用這個 socket 進行下一步的通信。如果這個文件不存在則創建一個 .attach_pid12345 文件,這部分的偽代碼如下:
String tmpdir = "/tmp";
File socketFile = new File(tmpdir, ".java_pid" + pid);
if (socketFile.exists()) {
File attachFile = new File(tmpdir, ".attach_pid" + pid);
createAttachFile(attachFile.getPath());
}
2、Attach 端檢查如果沒有 .java_pid12345 文件,創建完 .attach_pid12345 文件以後發送 SIGQUIT 信號給目標 JVM。然後每隔 200ms 檢查一次 socket 文件是否已經生成,5s 以後還沒有生成則退出,如果有生成則進行 socket 通信
3、對於目標 JVM 進程而言,它的 Signal Dispatcher 線程收到 SIGQUIT 信號以後,會檢查 .attach_pid12345 文件是否存在。
-
目標 JVM 如果發現 .attach_pid12345 不存在,則認為這不是一個 attach 操作,執行預設行為,輸出當前所有線程的堆棧 -
目標 JVM 如果發現 .attach_pid12345 存在,則認為這是一個 attach 操作,會啟動 Attach Listener 線程,負責處理 Attach 請求,同時創建名為 .java_pid12345 的 socket 文件,監聽 socket。源碼中 /hotspot/src/share/vm/runtime/os.cpp
這一部分處理的邏輯如下:
#define SIGBREAK SIGQUIT
static void signal_thread_entry(JavaThread* thread, TRAPS) {
while (true) {
int sig;
{
switch (sig) {
case SIGBREAK: {
// Check if the signal is a trigger to start the Attach Listener - in that
// case don't print stack traces.
if (!DisableAttachMechanism && AttachListener::is_init_trigger()) {
continue;
}
...
// Print stack traces
}
}
AttachListener 的 is_init_trigger 在 .attach_pid12345 文件存在的情況下會新建 .java_pid12345 套接字文件,同時監聽此套接字,準備 Attach 端發送數據。
那 Attach 端和目標進程用 socket 傳遞了什麼信息呢?可以通過 strace 的方式看到 Attach 端究竟往 socket 裡面寫了什麼:
sudo strace -f java -cp /usr/local/jdk/lib/tools.jar:. MyAttachMain 12345 2> strace.out
...
5841 [pid 3869] socket(AF_LOCAL, SOCK_STREAM, 0) = 5
5842 [pid 3869] connect(5, {sa_family=AF_LOCAL, sun_path="/tmp/.java_pid12345"}, 110) = 0
5843 [pid 3869] write(5, "1", 1) = 1
5844 [pid 3869] write(5, "\0", 1) = 1
5845 [pid 3869] write(5, "load", 4) = 4
5846 [pid 3869] write(5, "\0", 1) = 1
5847 [pid 3869] write(5, "instrument", 10) = 10
5848 [pid 3869] write(5, "\0", 1) = 1
5849 [pid 3869] write(5, "false", 5) = 5
5850 [pid 3869] write(5, "\0", 1) = 1
5855 [pid 3869] write(5, "/home/ya/agent.jar"..., 18 <unfinished ...>
可以看到往 socket 寫入的內容如下:
1
\0
load
\0
instrument
\0
false
\0
/home/ya/agent.jar
\0
數據之間用 \0 字元分隔,第一行的 1 表示協議版本,接下來是發送指令 "load instrument false /home/ya/agent.jar" 給目標 JVM,目標 JVM 收到這些數據以後就可以載入相應的 agent jar 包進行位元組碼的改寫。
如果從 socket 的角度來看,VirtualMachine.attach 方法相當於三次握手建連,VirtualMachine.loadAgent 則是握手成功之後發送數據,VirtualMachine.detach 相當於四次揮手斷開連接。
這個過程如下圖所示:
小結
本文講解了 javaagent,一起來回顧一下要點:
-
第一,javaagent 是一個使用 instrumentation 的 API 用來改寫類文件的 jar 包,可以看作是 JVM 的一個寄生插件。 -
第二,javaagent 有兩個重要的入口類:Premain-Class 和 Agent-Class,分別對應入口函數 premain 和 agentmain,其中 agentmain 可以採用遠程 attach API 的方式遠程掛載另一個 JVM 進程。
作者|頂尖架構師棧
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/The-Entry-Point-for-Bytecode-Debugging---JVMs-Parasitic-Plugin-JavaAgent.html