Java位元組碼介紹及動態修改類
前言
對於Java位元組碼,它是在Java類的編譯過程產生的,即由.java原始檔到.class二進位制位元組碼檔案的過程。而Java類的載入又是通過類的名字獲取二進位制位元組流,然後在記憶體中將位元組流生成類物件。所以動態修改類的時機在於修改.class檔案,只要通過修改.class檔案的位元組碼,即可達到修改類的目的。修改位元組碼可以通過ASM這個開源框架實現,ASM是一個Java位元組碼引擎的庫,具體可以檢視官網,它可以通過操作位元組碼實現修改類或者生成類。
介紹
Java位元組碼的執行操作主要是在虛擬機器的棧執行,這個棧主要有區域性變量表,運算元棧等幾個部分。
(一)區域性變量表
主要用來儲存方法中的區域性變數,基本的儲存單位為slot(32位的儲存空間),所以long double的資料型別需要兩個slot, 當方法被呼叫時,引數會傳遞從0開始的區域性變量表的索引位置上,所以區域性變數最大的大小是在編譯期就決定的,特別需要注意的是如果呼叫的是例項方法,區域性變數第0個位置是例項物件的引用。
(二)運算元棧
主要用來當作位元組碼指令操作的出棧入棧的容器,例如變數的出棧入棧都是在運算元棧裡面進行的。
(三)指令
指令主要是由操作碼+運算元組成的,指令包括載入和儲存指令,運算指令和型別轉換指令,方法呼叫指令等等。指令所需要的操作,呼叫方法,賦值等,都是在運算元棧進行的。
過程
首先是導包,包的版本關係可以檢視釋出版本,這裡我匯入的是implementation "org.ow2.asm:asm:6.2"
。修改位元組碼主要需要以下這幾個類:ClassReader, ClassWriter, ClassVisitor, MethodVisitor。各個類的作用如下:
- ClassReader: 讀取類檔案
- ClassWriter: 繼承ClassVisitor 主要用來生成修改類之後的位元組
- ClassVisitor: 用於訪問修改類
- MethodVisitor: 用於訪問修改類的方法
一般用法如下:
try {
String classPath = "asmdemo/ModifyInstanceClass";
ClassReader classReader = new ClassReader(classPath);
ClassWriter classWriter = new ClassWriter(classReader, 0);
ClassVisitor classVisitor = new ClassVisitorDemo(classWriter);
classReader.accept(classVisitor, 0);
File file = new File(ROOT_SUFFIX + "ClassDynamicLoader/ASMProject/build/classes/java/main/asmdemo/ModifyInstanceClass.class");
FileOutputStream output = new FileOutputStream(file);
output.write(classWriter.toByteArray());
output.close();
} catch (IOException e) {
e.printStackTrace();
}
private static class ClassVisitorDemo extends ClassVisitor {
ClassVisitorDemo(ClassVisitor classVisitor) {
super(Opcodes.ASM5, classVisitor);
}
@Override
public void visitEnd() {
cv.visitField(Opcodes.ACC_PRIVATE, "timer", Type.getDescriptor(long.class), null, null);
MethodVisitor methodVisitor = cv.visitMethod(Opcodes.ACC_PUBLIC, "newFunc","()V", null,null);
methodVisitor.visitInsn(Opcodes.RETURN);
methodVisitor.visitMaxs(0,1);
super.visitEnd();
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions);
if (name.equals("print") && desc.equals("()V")) {
methodVisitor = new MethodVisitorHub.FirstMethodVisitor(methodVisitor);
} else if(name.equals("print") && desc.equals("(Ljava/lang/String;)V")) {
methodVisitor = new MethodVisitorHub.SecondMethodVisitor(methodVisitor);
} else if (name.equals("connectStr")) {
methodVisitor = new MethodVisitorHub.ThirdMethodVisitor(methodVisitor);
}
return methodVisitor;
}
}
先利用ClassReader讀取待修改的類檔案,然後基於Reader建立了對應的ClassWriter,再基於ClassWriter建立了對應的ClassVisitor, 再接著ClassReader委託ClassVisitor去讀取修改類,最後,建立檔案輸出流,利用ClassWriter生成的位元組,將重新生成的位元組碼寫回build目錄生成的class檔案,替換編譯生成的class檔案,這樣就可以達到修改類的目的。
用法
對於類的修改,主要關注ClassVisitor和MethodVisitor這兩個類即可,ClassVistor可以實現成員變數和方法的增加,MethodVisitor用於修改類方法的實現。在修改類方法的時候,我是先通過把原先的方法修改為預期的方法,然後通過javap命令對預期的方法產生的類檔案進行反編譯,檢視編譯器產生的位元組碼。命令如下:javap -v .class檔案路徑。 通過反編譯之後可以得到修改後的類的運算元棧和區域性變量表的最大大小,還有具體的位元組碼指令。下面開始看具體的使用。
MethodVIsitor一般通過實現visitCode visitInsan visitMaxs方法來實現類的修改。visitCode是方法的訪問開始;visitInsn可以訪問方法的操作指令,一般應用於在return指令之前插入程式碼;vistiMax則用於複寫運算元棧和區域性變量表的大小,因為類被修改,所以所需的棧和變量表大小可能會增加。下面是幾個具體的例子:
1. 在print()空方法中插入一行輸出 System.out.print("Hello World");
首先利用javap -v 編譯修改前的print方法,如下
![這裡寫圖片描述](https://img-blog.csdn.net/20180906151907501?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Rhb3N6dQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)
接著在print()方法增加 ` System.out.print("Hello World");`再執行javap -c反編譯
可以發現多了三個指令,並且stack即運算元棧增加了2。所以程式碼如下:
public static class FirstMethodVisitor extends MethodVisitor {
public FirstMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM5, mv);
}
/**
* 進入方法 插入System.out.print("hello world")這行程式碼
*/
@Override
public void visitCode() {
super.visitCode();
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("hello world");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "print", "(Ljava/lang/String;)V", false);
}
@Override
public void visitMaxs(int maxStack, int maxLocals) {
mv.visitMaxs(2,1);
}
}
上面的程式碼主要覆寫了visitMaxs,stack local數值是通過反編譯得到的,visitCode則是添加了三個指令。分析System.out.print可知,其實是通過System這個類獲取out這個變數,然後通過out呼叫print這個方法輸出“hello world”這個變數。
所以首先要獲取out,out是一個靜態變數,第一個指令是visitFieldInsn,顧名思義就是訪問成員的指令,第一個引數是操作碼,第二個引數是呼叫成員的類,第三個引數是成員的名稱,第四個引數是成員的型別,對號入座第一個指令,操作碼是獲取靜態變數,呼叫類是“java/lang/System”, 成員名是“out”, 型別通過反編譯可知是“Ljava/io/PrintStream;”。所以得出結論,第一個指令是通過System這個類獲取out這個靜態變數並且把變數入棧。
接著第二個指令visitLdcInsn是把常量推到運算元棧,這裡是把“hello world”入棧,
最後就是第三個指令visitMethodInsn,還是顧名思義是訪問方法的指令,第一個引數是操作碼,第二個引數是呼叫方法等的類,第三個引數是方法名,第四個引數是方法的返回型別和引數型別,第五個引數是呼叫方法的類是否是介面,對號入座,Opcodes.INVOKEVIRTUAL指的是呼叫的是例項方法,呼叫的類是out即“java/io/PrintStream”這個類,方法名是print,返回值是void對應“V”,引數是String對應“Ljava/lang/String; ”, 這些引數的對應型別都可以從反編譯得到。第三個指令需要兩個運算元,一個是執行方法的主體即out,第二個是引數即“hello world”,使用visitMethodInsn指令的時候,out “”hello world“依次從運算元棧出棧,剛剛好對應指令呼叫的引數順序。
攔截方法的入口在ClassVisitor,如下:
private static class ClassVisitorDemo extends ClassVisitor {
ClassVisitorDemo(ClassVisitor classVisitor) {
super(Opcodes.ASM5, classVisitor);
}
@Override
public void visitEnd() {
cv.visitField(Opcodes.ACC_PRIVATE, "timer", Type.getDescriptor(long.class), null, null);
MethodVisitor methodVisitor = cv.visitMethod(Opcodes.ACC_PUBLIC, "newFunc","()V", null,null);
methodVisitor.visitInsn(Opcodes.RETURN);
methodVisitor.visitMaxs(0,1);
super.visitEnd();
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions);
if (name.equals("print") && desc.equals("()V")) {
methodVisitor = new MethodVisitorHub.FirstMethodVisitor(methodVisitor);
} else if(name.equals("print") && desc.equals("(Ljava/lang/String;)V")) {
methodVisitor = new MethodVisitorHub.SecondMethodVisitor(methodVisitor);
} else if (name.equals("connectStr")) {
methodVisitor = new MethodVisitorHub.ThirdMethodVisitor(methodVisitor);
}
return methodVisitor;
}
}
在visitMethod中判斷方法名為print,則進行攔截注入自己建立的MethodVisitor即可。
到這裡已經分析完成,可以自信滿滿地執行程式碼了,但是要切記,不能在修改之前使用該類,如果使用了之後,類已經被載入,那麼修改之後的類不會被再次載入,也就無法發揮作用了。
2. 在print(String s )空方法中插入一行輸出 System.out.print(s)
分析的方法和上面的一樣,這裡的關鍵是讀取引數的值,反編譯之後可以發現使用了ALOAD這個指令,這個指令的作用是從區域性變量表讀取變數入棧,指令程式碼如下:
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(Opcodes.ALOAD, 1);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "print", "(Ljava/lang/String;)V", false);
visitVarInsn是讀取引數的指令,操作碼是ALOAD,後面的引數是指變量表的索引,上面也提到,如果是例項方法,區域性變量表的0索引是例項物件,所以這裡取了索引1。
3. 在connectStr()空方法列印執行消耗時間
修改前程式碼如下:
public void connectStr() {
String s = "";
for (int i = 0; i < 10000; i ++) {
s += i;
}
}
修改後程式碼如下:
public void connectStr() {
this.timer = -System.currentTimeMillis();
String s = "";
for(int i = 0; i < 10000; ++i) {
s = s + i;
}
this.timer += System.currentTimeMillis();
System.out.println(this.timer);
}
這裡的關鍵是在return前插入程式碼, 還有增加變數timer。具體的反編譯過程就不展示了,直接上程式碼:
public static class ThirdMethodVisitor extends MethodVisitor {
public ThirdMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM5, mv);
}
/**
* 進入方法
*/
@Override
public void visitCode() {
super.visitCode();
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
mv.visitInsn(Opcodes.LNEG);
mv.visitFieldInsn(Opcodes.PUTFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
}
/**
* return前插入程式碼
*/
@Override
public void visitInsn(int opcode) {
if (opcode == Opcodes.RETURN) {
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitInsn(Opcodes.DUP);
mv.visitFieldInsn(Opcodes.GETFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
mv.visitInsn(Opcodes.LADD);
mv.visitFieldInsn(Opcodes.PUTFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitFieldInsn(Opcodes.GETFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false);
}
super.visitInsn(opcode);
}
@Override
public void visitMaxs(int maxStack, int maxLocals) {
mv.visitMaxs(5, 3);
}
}
首先看visitCode方法, 做的事情就是this.timer = System.currentTimeMillis(),對這行程式碼進行拆分,就是獲取時間戳賦值給timer,對應底下的指令mv.visitVarInsn(Opcodes.ALOAD, 0) 先將例項物件入棧即我們用的變數this,接著訪問方法獲取系統時間戳然後執行LNEG取反入棧,最後在執行訪問方法的指令PUTFIELD把值賦給timer,需要的引數是時間戳和this變數,this變數用於訪問timer,時間戳則是賦值的變數。
接著看visitInsn方法,visitInsn可以攔截方法執行的指令做一些插入操作,在這裡我們需要做的事在return之前插入時間戳的計算和列印, 程式碼比較長如下:
if (opcode == Opcodes.RETURN) {
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitInsn(Opcodes.DUP);
mv.visitFieldInsn(Opcodes.GETFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
mv.visitInsn(Opcodes.LADD);
mv.visitFieldInsn(Opcodes.PUTFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitFieldInsn(Opcodes.GETFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false);
}
老規矩,拆解程式碼 this.timer += System.currentTimeMillis(), 需要取出timer的值,獲取時間戳,進行加法操作,然後結果賦值到timer,這裡需要用到兩個this變數,因為要訪問timer兩次,所以可以看到一個新的指令,DUP,DUP的意思就是複製棧頂變數然後入棧,也就是說拷貝多一份this變數,底下的指令已經分析過了,就不再贅述。
到這裡,還沒完成,因為timer變數還沒生成呢,類變數的生成就要依賴ClassVisitor了, 攔截ClassVisitor的visitEnd方法,動態增加變數,如下:
@Override
public void visitEnd() {
cv.visitField(Opcodes.ACC_PRIVATE, "timer", Type.getDescriptor(long.class), null, null);
super.visitEnd();
}
總結
這裡介紹的只是動態修改類的冰山一角,動態生成類的應用場景很多,像市面上的路由框架,熱修復框架,很多都是利用了動態修改類的方式進行程式碼的注入,所以路還很長,還需更加努力。