JVM 程式碼誰做主?
對 Debug 的好奇
初學 Java 時,我對 IDEA 的 Debug 非常好奇,不止是它能檢視斷點的上下文環境,更神奇的是我可以在斷點處使用它的 Evaluate 功能直接執行某些命令,進行一些計算或改變當前變數。
剛開始語法不熟經常寫錯程式碼,重新打包部署一次程式碼耗時很長,我就直接面向 Debug 開發。在要編寫的方法開始處打一個斷點,在 Evaluate 框內一次次地執行方法函式不停地調整程式碼,沒問題後再將程式碼複製出來放到 IDEA 裡,再進行下一個方法的編寫,這樣就跟寫 PHP 類似的解釋性語言一樣,寫完即執行,非常方便。
但 Java 是靜態語言,執行之前是要先進行編譯的,難道我寫的這些程式碼是被實時編譯又”注入”到我正在 Debug 的服務裡了嗎?
隨著對 Java 的愈加熟悉,我也瞭解了反射、位元組碼等技術,直到前些天的週會分享,有位同事分享了 Btrace 的使用和實現,提到了 Java 的 ASM 框架和 JVM TI 介面。 Btrace 修改程式碼能力的實現與 Debug 的 Evaluate 有很多相似之處,這大大吸引了我。分享就像一個引子,從中學到的東西只是皮毛,要了解它還是要自己研究。於是自己檢視資料並寫程式碼學習了下其具體實現。
轉載隨意,請註明來源地址: ofollow,noindex" target="_blank">https://zhenbianshu.github.io ,文章持續修訂。
ASM
實現 Evaluate 要解決的第一個問題就是怎麼改變原有程式碼的行為,它的實現在 Java 裡被稱為動態位元組碼技術。
動態生成位元組碼
我們知道,我們編寫的 Java 程式碼都是要被編譯成位元組碼後才能放到 JVM 裡執行的,而位元組碼一旦被載入到虛擬機器中,就可以被解釋執行。
位元組碼檔案(.class)就是普通的二進位制檔案,它是通過 Java 編譯器生成的。而只要是檔案就可以被改變,如果我們用特定的規則解析了原有的位元組碼檔案,對它進行修改或者乾脆重新定義,這不就可以改變程式碼行為了麼。
Java 生態裡有很多可以動態生成位元組碼的技術,像 BCEL、Javassist、ASM、CGLib 等,它們各有自己的優勢。有的使用複雜卻功能強大、有的簡單確也效能些差。
ASM 框架
ASM 是它們中最強大的一個,使用它可以動態修改類、方法,甚至可以重新定義類,連 CGLib 底層都是用 ASM 實現的。
當然,它的使用門檻也很高,使用它需要對 Java 的位元組碼檔案有所瞭解,熟悉 JVM 的編譯指令。雖然我對 JVM 的位元組碼語法不熟,但有大神開發了可以在 IDEA 裡檢視位元組碼的外掛: ASM Bytecode Outline
,在要檢視的類檔案裡右鍵選擇 Show bytecode Outline
即可以右側的工具欄檢視我們要生成的位元組碼。對照著示例,我們就可以很輕鬆地寫出操作位元組碼的 Java 程式碼了。
而切到 ASMified
標籤欄,我們甚至可以直接獲取到 ASM 的使用程式碼。
常用方法
在 ASM 的程式碼實現裡,最明顯的就是訪問者模式,ASM 將對程式碼的讀取和操作都包裝成一個訪問者,在解析 JVM 載入到的位元組碼時呼叫。
ClassReader 是 ASM 程式碼的入口,通過它解析二進位制位元組碼,例項化時它時,我們需要傳入一個 ClassVisitor,在這個 Visitor 裡,我們可以實現 visitMethod()/visitAnnotation()
等方法,用以定義對類結構(如方法、欄位、註解)的訪問方法。
而 ClassWriter 介面繼承了 ClassVisitor 介面,我們在例項化類訪問器時,將 ClassWriter “注入” 到裡面,以實現對類寫入的宣告。
Instrument
介紹
位元組碼是修改完了,可是 JVM 在執行時會使用自己的類載入器載入位元組碼檔案,載入後並不會理會我們做出的修改,要想實現對現有類的修改,我們還需要搭配 Java 的另一個庫 instrument
。
instrument 是 JVM 提供的一個可以修改已載入類檔案的類庫。1.6以前,instrument 只能在 JVM 剛啟動開始載入類時生效,之後,instrument 更是支援了在執行時對類定義的修改。
使用
要使用 instrument 的類修改功能,我們需要實現它的 ClassFileTransformer
介面定義一個類檔案轉換器。它唯一的一個 transform()
方法會在類檔案被載入時呼叫,在 transform 方法裡,我們可以對傳入的二進位制位元組碼進行改寫或替換,生成新的位元組碼陣列後返回,JVM 會使用 transform 方法返回的位元組碼資料進行類的載入。
JVM TI
定義完了位元組碼的修改和重定義方法,但我們怎麼才能讓 JVM 能夠呼叫我們提供的類轉換器呢?這裡又要介紹到 JVM TI 了。
介紹
JVM TI(JVM Tool Interface)JVM 工具介面是 JVM 提供的一個非常強大的對 JVM 操作的工具介面,通過這個介面,我們可以實現對 JVM 多種元件的操作,從 JVMTM Tool Interface 這裡我們認識到 JVM TI 的強大,它包括了對虛擬機器堆記憶體、類、執行緒等各個方面的管理介面。
JVM TI 通過事件機制,通過介面註冊各種事件勾子,在 JVM 事件觸發時同時觸發預定義的勾子,以實現對各個 JVM 事件的感知和反應。
Agent
Agent 是 JVM TI 實現的一種方式。我們在編譯 C 專案裡連結靜態庫,將靜態庫的功能注入到專案裡,從而才可以在專案裡引用庫裡的函式。我們可以將 agent 類比為 C 裡的靜態庫,我們也可以用 C 或 C++ 來實現,將其編譯為 dll 或 so 檔案,在啟動 JVM 時啟動。
這時再來思考 Debug 的實現,我們在啟動被 Debug 的 JVM 時,必須新增引數 -agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:3333
,而 -agentlib 選項就指定了我們要載入的 Java Agent,jdwp 是 agent 的名字,在 linux 系統中,我們可以在 jre 目錄下找到 jdwp.so 庫檔案。
Java 的除錯體系 jdpa 組成,從高到低分別為 jdi->jdwp->jvmti
,我們通過 JDI 介面傳送除錯指令,而 jdwp 就相當於一個通道,幫我們翻譯 JDI 指令到 JVM TI,最底層的 JVM TI 最終實現對 JVM 的操作。
使用
JVM TI 的 agent 使用很簡單,在啟動 agent 時新增 -agent 引數指定我們要載入的 agent jar包即可。
而要實現程式碼的修改,我們需要實現一個 instrument agent,它可以通過在一個類裡新增 premain()
或 agentmain()
方法來實現。而要實現 1.6 以上的動態 instrument 功能,實現 agentmain 方法即可。
在 agentmain 方法裡,我們呼叫 Instrumentation.retransformClasses()
方法實現對目標類的重定義。
另外往一個正在執行的 JVM 裡動態新增 agent,還需要用到 JVM 的 attach 功能,Sun 公司的 tools.jar 包裡包含的 VirtualMachine
類提供了 attach 一個本地 JVM 的功能,它需要我們傳入一個本地 JVM 的 pid, tools.jar 可以在 jre 目錄下找到。
agent生成
另外,我們還需要注意 agent 的打包,它需要指定一個 Agent-Class 引數指定我們的包括 agentmain 方法的類,可以算是指定入口類吧。
此外,還需要配置 MANIFEST.MF
檔案的一些引數,允許我們重新定義類。如果你的 agent 實現還需要引用一些其他類庫時,還需要將這些類庫都打包到此 jar 包中,下面是我的 pom 檔案配置。
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <configuration> <archive> <manifestEntries> <Agent-Class>asm.TestAgent</Agent-Class> <Can-Redefine-Classes>true</Can-Redefine-Classes> <Can-Retransform-Classes>true</Can-Retransform-Classes> <Manifest-Version>1.0</Manifest-Version> <Permissions>all-permissions</Permissions> </manifestEntries> </archive> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> </configuration> </plugin> </plugins> </build>
另外在打包時需要使用 mvn assembly:assembl
命令生成 jar-with-dependencies 作為 agent。
程式碼實現
我在測試時寫了一個用以上技術實現了一個簡單的位元組碼動態修改的 Demo。
被修改的類
TransformTarget 是要被修改的目標類,正常執行時,它會三秒輸出一次 “hello”。
public class TransformTarget { public static void main(String[] args) { while (true) { try { Thread.sleep(3000L); } catch (Exception e) { break; } printSomething(); } } public static void printSomething() { System.out.println("hello"); } }
Agent
Agent 是執行修改類的主體,它使用 ASM 修改 TransformTarget 類的方法,並使用 instrument 包將修改提交給 JVM。
入口類,也是代理的 Agent-Class。
public class TestAgent { public static void agentmain(String args, Instrumentation inst) { inst.addTransformer(new TestTransformer(), true); try { inst.retransformClasses(TransformTarget.class); System.out.println("Agent Load Done."); } catch (Exception e) { System.out.println("agent load failed!"); } } }
執行位元組碼修改和轉換的類。
public class TestTransformer implements ClassFileTransformer { public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { System.out.println("Transforming " + className); ClassReader reader = new ClassReader(classfileBuffer); ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES); ClassVisitor classVisitor = new TestClassVisitor(Opcodes.ASM5, classWriter); reader.accept(classVisitor, ClassReader.SKIP_DEBUG); return classWriter.toByteArray(); } class TestClassVisitor extends ClassVisitor implements Opcodes { TestClassVisitor(int api, ClassVisitor classVisitor) { super(api, classVisitor); } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); if (name.equals("printSomething")) { mv.visitCode(); Label l0 = new Label(); mv.visitLabel(l0); mv.visitLineNumber(19, l0); mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("bytecode replaced!"); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); Label l1 = new Label(); mv.visitLabel(l1); mv.visitLineNumber(20, l1); mv.visitInsn(Opcodes.RETURN); mv.visitMaxs(2, 0); mv.visitEnd(); TransformTarget.printSomething(); } return mv; } } }
Attacher
使用 tools.jar 裡方法將 agent 動態載入到目標 JVM 的類。
public class Attacher { public static void main(String[] args) throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException { VirtualMachine vm = VirtualMachine.attach("34242"); // 目標 JVM pid vm.loadAgent("/path/to/agent.jar"); } }
這樣,先啟動 TransformTarget 類,獲取到 pid 後將其傳入 Attacher 裡,並指定 agent jar,將 agent attach 到 TransformTarget 中,原來輸出的 “hello” 就變成我們想要修改的 “bytecode replaced!” 了。
小結
掌握了位元組碼的動態修改技術後,再回頭看 Btrace 的原理就更清晰了,稍微摸索一下我們也可以實現一個簡版的。另外很多大牛實現的各種 Java 效能分析工具的技術棧也不外如此,瞭解了這些,未來我們也可以寫出適合自己的工具,至少能對別人的工具進行修改~
不得不說 Java 的生態真的非常繁榮,當真是博大精神,查閱一個模組的資料時能總引出一大堆新的概念,永遠有學不完的新東西。
關於本文有什麼疑問可以在下面留言交流,如果您覺得本文對您有幫助,歡迎關注我的微博 或 GitHub 。
參考: