JVM探針與位元組碼技術
- 2021 年 10 月 3 日
- 筆記
JVM探針是自jdk1.5以來,由虛擬機提供的一套監控類載入器和符合虛擬機規範的代理介面,結合位元組碼指令能夠讓開發者實現無侵入的監控功能。如:監控生產環境中的函數調用情況或動態增加日誌輸出等等。雖然在常規的業務中不會有太多用武之地,但是作為一項高級的技術手段也應該是資深開發人員的必備技能之一。同時,它也是企業級開發和生產環境部署不可或缺的技術方案,是對當下流行的APM的一種補充,因為使用探針技術能夠實現比常規APM平台更細粒度的監控。
哪些方面適合使用探針技術:
(1) 如果你發現生產環境上有些問題無法在測試或開發環境中復現
(2) 如果你希望在不修改源碼的情況下為你的應用添加一些輸出日誌
(3) 如果在剛發布的生產包中發現了一個bug,而你又不希望被它阻斷,希望有一個臨時的補救措施
一、JVM探針:Instrumentation
使用探針只需要一條附加選項:-javaagent:<jar 路徑>[=<選項>],作為探針(代理)的jar包必須滿足兩個條件:1. MANIFEST.MF文件需要增加Premain-Class項,說明啟動類。2. 啟動類必須聲明一個靜態函數,它的入參是: String和Instrumentation。因此,一個常見的啟動類可能像這樣:
package aa.bb.cc; public class PremainAgent { public static void premain(String agentArgs, Instrumentation inst) { // TODO } }
MANIFEST.MF
premain-class: aa.bb.cc.PremainAgent
如果使用maven作為構建工具,需要在pom文件中添加構建插件
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.2.0</version> <configuration> <archive> <manifestEntries> <premain-class>aa.bb.cc.PremainAgent</premain-class> </manifestEntries> </archive> </configuration> </plugin>
如果你還引入了其它依賴希望同時打包,那麼你應該使用assembly插件替代
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>2.4</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifestEntries> <Premain-Class><package>.PremainAgent</Premain-Class> <Can-Redefine-Classes>true</Can-Redefine-Classes> <Can-Retransform-Classes>true</Can-Retransform-Classes> </manifestEntries> </archive> </configuration> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions>
</plugin>
兩個重要的類
Instrumentation: 由JDK提供的一個探針類,它會負責載入用戶自定義的ClassFileTransformer
ClassFileTransformer: 位元組碼轉換類,jvm在載入class文件前會先調用它,對所有類載入器有效
具體用法稍後會做詳細介紹。
總結:JVM探針只是提供了一種讓開發人員能夠在類載入載入class文件前主動介入的一種方法,具體如何操作需要開發人員了解Java虛擬機規範以及位元組碼的相關知識。
二、棧幀與指令集
棧幀(Stack Frame)是用於支援虛擬機進行方法調用和方法執行的數據結構。它是虛擬機運行時數據區中的虛擬機棧的棧元素。棧幀存儲了方法的局部變數表、操作數棧、動態連接和方法返回地址等資訊。每一個方法從調用開始至執行完成的過程,都對應著一個棧幀在虛擬機裡面從入棧到出棧的過程。
在編譯程式程式碼的時候,棧幀中需要多大的局部變數表,多深的操作數棧都已經完全確定了。因此一個棧幀需要分配多少記憶體,不會受到程式運行期變數數據的影響,而僅僅取決於具體的虛擬機實現。
局部變數表(Local Variable Table)是一組變數值存儲空間,用於存放方法參數和方法內部定義的局部變數。並且在Java編譯為Class文件時,就已經確定了該方法所需要分配的局部變數表的最大容量。局部變數表類似一個數組結構,虛擬機在訪問局部變數表的時候會使用下標作為引用,普通方法的局部變數表中第0位索引默認是用於傳遞方法所屬對象實例的引用this。
操作數棧(Operand Stack)和局部變數表一樣,在編譯時期就已經確定了該方法所需要分配的局部變數表的最大容量。當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法執行的過程中,會有各種位元組碼指令往操作數棧中寫入和提取內容,也就是出棧/入棧操作。例如,在做算術運算的時候是通過操作數棧來進行的,又或者在調用其它方法的時候是通過操作數棧來進行參數傳遞的。
動態鏈接(Dynamic Linking)每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支付方法調用過程中的動態連接。在類載入階段中的解析階段會將符號引用轉為直接引用,這種轉化也稱為靜態解析。另外的一部分將在每一次運行時期轉化為直接引用,這部分稱為動態連接。
返回地址:當一個方法開始執行後,只有2種方式可以退出這個方法,方法返回指令和異常退出。無論採用任何退出方式,在方法退出之後,都需要返回到方法被調用的位置,程式才能繼續執行,方法返回時可能需要在棧幀中保存一些資訊。一般來說,方法正常退出時,調用者的PC計數器的值可以作為返回地址,棧幀中會保存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會保存這部分資訊。
JVM指令集並非是對Java語句的直接翻譯,由於指令只使用1個位元組表示,所以指令集最多只能包含256種指令。因此,一條Java語句一般會對應多條底層指令。每一條指令都有與之對應的助記符,我們可以通過官方資料查看它們對應關係://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html。為了幫助大家更加直觀的理解位元組碼指令,我將通過三個用例分別解釋。
從一個簡單的加法函數開始,我們可以使用javac將.java文件編譯成.class,再通過javap -c查看它的位元組碼文件
public int add(int x, int y) { return x + y; }
1 public add(II)I 2 ILOAD 1 // 將局部變數表中#1變數入棧 3 ILOAD 2 // 將局部變數表中#2變數入棧 4 IADD // 調用整型數相加(兩個數出棧,再將結果入棧) 5 IRETURN // 返回棧頂的結果 6 MAXSTACK = 2 // 最大棧數2 7 MAXLOCALS = 3 // 最大本地變數數3
第一行是它的函數簽名,2~7行的注釋分別是對指令的解釋。ILOAD,IADD,IRETURN分別是整型數的入棧,加法和返回操作。大家可以將add方法修改為靜態函數後重新編譯,看看MAXLOCALS是否有變化。
接下來我們把函數變得複雜一些,嘗試對函數的執行時間做一個計算並輸出
public int add(int x, int y) { long t = System.nanoTime(); int ret = x + y; t = System.nanoTime() - t; System.out.println(t); return ret; }
1 public add(II)I 2 INVOKESTATIC java/lang/System.nanoTime ()J // 調用靜態函數,結果long入棧 3 LSTORE 3 // 將棧頂的long保存到局部變數#3 4 ILOAD 1 5 ILOAD 2 6 IADD 7 ISTORE 5 // 將棧頂的int保存到局部變數#5 8 INVOKESTATIC java/lang/System.nanoTime ()J 9 LLOAD 3 // 局部變數#3入棧 10 LSUB // 從棧頂彈出兩個long相減 11 LSTORE 3 // 結果保存到變數#3 12 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; // 獲取靜態引用 13 LLOAD 3 // 局部變數#3入棧 14 INVOKEVIRTUAL java/io/PrintStream.println (J)V // 調用函數 15 ILOAD 5 // 局部變數#5入棧 16 IRETURN 17 MAXSTACK = 4 18 MAXLOCALS = 6
第2行結尾的J表示函數返回值是long類型。第14行結尾的V表示println函數的返回值是void。第12行到第14行的指令對應程式碼的System.out.println(t),特別需要注意的是INVOKEVIRTUAL指令實際上需要從操作數棧獲取兩個數,第一個數是在執行了GETSTATIC後入棧的對象引用。
我們再次修改函數,這一次我們引入比較和循環語句,儘管程式碼的邏輯不太正常,但這並不妨礙我們理解
public int add(int x, int y) { if(x > 1) { return x + y; } for(int i = 0; i < y; i++) { x ++; } return x - y; }
1 public add(II)I 2 ILOAD 1 3 ICONST_1 // 將一個常整型數1入棧 4 IF_ICMPLE L0 // 比較如果操兩個操作數是小於等於的關係則成立,否則跳轉到L0的位置繼續 5 ILOAD 1 6 ILOAD 2 7 IADD 8 IRETURN 9 L0 10 ICONST_0 // 將常整型數0入棧 11 ISTORE 3 // 棧頂數保存到局部變數#3 12 L1 13 ILOAD 3 14 ILOAD 2 15 IF_ICMPGE L2 // 比較棧頂的兩個操作數是否是大於等於的關係,如果不成立則跳轉到L2 16 IINC 1 1 // 局部變數#1 自增1 17 IINC 3 1 // 局部變數#3 自增1 18 GOTO L1 // 跳轉到L1執行 19 L2 20 ILOAD 1 21 ILOAD 2 22 ISUB 23 IRETURN 24 MAXSTACK = 2 25 MAXLOCALS = 4
當我們使用位元組碼直接操作虛擬機中的底層程式碼的時候,基本上就是通過改變局部變數表和操作數棧來改變程式的邏輯。還記得根據Java虛擬機規範,MAXSTACK和MAXLOCALS是在.java文件被編譯成.class就被確定下來的嗎,如果我們要對方法做出修改勢必會引入新的局部變數,這時就難免需要對MAXSTACK和MAXLOCALS做重新計算。好在目前流行的位元組碼框架已經可以自動幫助我們完成這項任務。
三、ASM框架
ASM是一個比較硬核的位元組碼框架,也是轉換效率最高的工具。下面是常用類的介紹:
1. ClassReader
按照Java虛擬機規範(JVMS)中定義的方式來解析class文件中的內容,在遇到合適的欄位時調用ClassVisitor中相對應的方法。
ClassReader(final byte[] classFile)
構造方法,通過class位元組碼數據載入
ClassReader(final String className) throws IOException
通過class全路徑名從ClassLoader載入
2. ClassVisitor
java中類的訪問者,提供一系列方法由ClassReader調用。調用的順序如下:visit -> visitSource -> visitModule -> visitNestHost -> visitOuterClass -> visitAnnotation -> visitTypeAnnotation -> visitAttribute -> visitNestMember -> visitPermittedSubclass -> visitInnerClass -> visitRecordComponent -> visitField -> visitMethod -> visitEnd
3. ClassWriter
ClassVisitor的子類,通過它生成最後的位元組碼。並且它可以幫助重新計算MAXSTACK和MAXLOCALS
4. ModuleVisitor
Java中模組的訪問者,作為ClassVisitor.visitModule方法的返回值
5. AnnotationVisitor
Java中註解的訪問者,作為ClassVisito中visitTypeAnnotation和visitTypeAnnotation的返回值
6. FieldVisitor
Java中欄位的訪問者,作為ClassVisito.visitField的返回值
7. MethodVisitor
Java中方法的訪問者,作為ClassVisito.visitMethod的返回值
- visitMethodInsn 方法調用指令
- visitVarInsn 局部變數調用指令
- visitInsn(int) 訪問一個零參數要求的位元組碼指令,如LSUB
- visitLdcInsn 把一個常量放到棧頂
- visitInvokeDynamicInsn 動態方法調用
- visitFieldInsn 調用/訪問某個欄位
8. AnalyzerAdapter
MethodVisitor的子類,使用它重新計算最大操作數棧(MAXSTACK)
9. LocalVariablesSorter
MethodVisitor的子類,使用它重新計算局部變數表(MAXLOCALS)的索引
- newLocal 創建局部變數
通過IDEA的Plugins安裝ASM Bytecode Viewer Support Kotlin,我們可以藉助這個插件來幫助我們生成大部分程式碼,具體用法這裡就贅述了。
總結:有了以上知識基礎,我們可以完成一個簡單的demo來感受探針和位元組碼技術的強大。
一個計算函數執行時間的完整用例
1. 在IDEA中創建一個典型的maven工程
2.編寫pom文件
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="//maven.apache.org/POM/4.0.0" xmlns:xsi="//www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="//maven.apache.org/POM/4.0.0 //maven.apache.org/xsd/maven-4.0.0.xsd"> <groupId>com.learnhow.study</groupId> <version>1.0</version> <packaging>jar</packaging> <artifactId>agent</artifactId> <dependencies> <dependency> <groupId>org.ow2.asm</groupId> <artifactId>asm</artifactId> <version>9.2</version> </dependency> <dependency> <groupId>org.ow2.asm</groupId> <artifactId>asm-commons</artifactId> <version>9.2</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>[your jdk version]</source> <target>[your jdk version]</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>2.4</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifestEntries> <Premain-Class>[your package].PremainAgent</Premain-Class> <Can-Redefine-Classes>true</Can-Redefine-Classes> <Can-Retransform-Classes>true</Can-Retransform-Classes> </manifestEntries> </archive> </configuration> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>
帶[]的部分請換成你的本地環境。
3.PremainAgent類
public class PremainAgent { public static void premain(String agentArgs, Instrumentation inst) { inst.addTransformer(new XClassFileTransformer()); } }
4.XClassFileTransformer類
public class XClassFileTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { try { ClassReader cr = new ClassReader(classfileBuffer); ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); cr.accept(new NanoTimerClassVisitor(cw), ClassReader.SKIP_DEBUG); byte[] cc = cw.toByteArray(); return cc; } catch (IOException e) { } return null; } }
transform方法返回null或者new byte[0]表示對當前位元組碼文件不進行修改。ClassWriter.COMPUTE_MAXS表示框架會自動計算MAXSTACK和MAXLOCALS,ClassReader.SKIP_DEBUG表示當位元組碼中包含調試資訊的時候,會忽略不會觸發回調。
5.NanoTimerClassVisitor類
public class NanoTimerClassVisitor extends ClassVisitor { private String className; public NanoTimerClassVisitor(ClassVisitor classVisitor) { super(ASM9, classVisitor); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { this.className = name; super.visit(version, access, name, signature, superName, interfaces); } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); if (Objects.nonNull(mv) && !name.equals("<init>") && !name.equals("<clinit>")) { NanoTimerMethodVisitor methodVisitor = new NanoTimerMethodVisitor(mv, className, access, name, descriptor); return methodVisitor.refactor(); } return mv; } class NanoTimerMethodVisitor extends MethodVisitor { private AnalyzerAdapter analyzerAdapter; private LocalVariablesSorter localVariablesSorter; private int timeOpcode; private int outOpcode; private String className; private int methodAccess; private String methodName; private String methodDescriptor; public NanoTimerMethodVisitor(MethodVisitor methodVisitor, String className, int methodAccess, String methodName, String methodDescriptor) { super(ASM9, methodVisitor); this.className = className; this.methodAccess = methodAccess; this.methodName = methodName; this.methodDescriptor = methodDescriptor; // 使用AnalyzerAdapter計算最大操作數棧 analyzerAdapter = new AnalyzerAdapter(className, methodAccess, methodName, methodDescriptor, this); // LocalVariablesSorter重新計算局部變數的索引並自動更新位元組碼中的索引引用 localVariablesSorter = new LocalVariablesSorter(methodAccess, methodDescriptor, analyzerAdapter); } public MethodVisitor refactor() { return localVariablesSorter; } @Override public void visitCode() { super.visitCode(); mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); timeOpcode = localVariablesSorter.newLocal(Type.LONG_TYPE); mv.visitVarInsn(LSTORE, timeOpcode); } @Override public void visitInsn(int opcode) { if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) { mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); mv.visitVarInsn(LLOAD, timeOpcode); mv.visitInsn(LSUB); mv.visitVarInsn(LSTORE, timeOpcode); mv.visitLdcInsn(className + "." + methodName + "(ns):"); outOpcode = localVariablesSorter.newLocal(Type.getType(String.class)); mv.visitVarInsn(ASTORE, outOpcode); mv.visitVarInsn(ALOAD, outOpcode); mv.visitVarInsn(LLOAD, timeOpcode); mv.visitInvokeDynamicInsn("makeConcatWithConstants", "(Ljava/lang/String;J)Ljava/lang/String;", new Handle(Opcodes.H_INVOKESTATIC, "java/lang/invoke/StringConcatFactory", "makeConcatWithConstants", "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;", false), new Object[]{"\u0001\u0001"}); mv.visitVarInsn(ASTORE, outOpcode); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitVarInsn(ALOAD, outOpcode); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); } super.visitInsn(opcode); } } }
6. 通過assembly插件對項目進行打包生成:agent-1.0-jar-with-dependencies.jar
7. 運行一個目標項目,並添加虛擬機指令-javaagent,就可以看到執行效果
如何查看生成後的程式碼
計算函數執行時間是一個非常簡單的功能,我們很容易一次性寫正確。但是如果需要代理的邏輯比較複雜,而探針程式又不像普通程式一樣方便做斷點調試。我們如何才能夠很方便知道生成的程式碼是否正確呢?這裡告訴大家一個訣竅。回到我們XClassFileTransformer類,增加兩行程式碼:
1 public class XClassFileTransformer implements ClassFileTransformer { 2 @Override 3 public byte[] transform(ClassLoader loader, 4 String className, 5 Class<?> classBeingRedefined, 6 ProtectionDomain protectionDomain, 7 byte[] classfileBuffer) throws IllegalClassFormatException { 8 try { 9 ClassReader cr = new ClassReader(classfileBuffer); 10 ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); 11 cr.accept(new NanoTimerClassVisitor(cw), ClassReader.SKIP_DEBUG); 12 byte[] cc = cw.toByteArray(); 13 FileOutputStream fos = new FileOutputStream("./cc.class"); 14 fos.write(cc); 15 return cc; 16 } catch (IOException e) { 17 18 } 19 return null; 20 } 21 }
第13、14行程式碼的功能是將生成的位元組碼輸出到本地文件中,然後我們通過IDEA打開這個.class文件,看看新增加的程式碼是否如我們預期的那樣。
總結:JVM代理髮生在類載入器載入.class文件前,因此我們能夠動態修改位元組碼。通過ASM這類位元組碼框架,使得開發人員即使對位元組碼指令不是很熟悉依然能夠操作。當然,Java的探針技術除了和被代理的項目同時啟動以外還提供了一種熱部署的方案,受篇幅限制不再贅述,如果大家有興趣可以給我留言。