字節碼操縱技術探秘

lsbj2048 8年前發布 | 11K 次閱讀 Java Java開發

大家可能已經非常熟悉下面的處理流程:將一個“.java”文件輸入到Java編譯器中(可能會使用javac,也可能像ANT、Maven或Gradle這樣的構建工具),編譯器對其進行分析,最終生成一個或多個“.class”文件。

圖1:什么是Java字節碼?

如果從命令行中運行構建,并啟用verbose的話,我們能夠看到解析文件直到生成“.class”文件這一過程的輸出。

javac -verbose src/com/example/spring2gx/BankTransactions.java

所生成的“.class”文件包含了字節碼,本質上來講它就是Java虛擬機(Java virtual machine,JVM)所使用的指令,當程序運行時,它會由Java運行時類加載器進行加載。

在本文中,我們將會研究Java字節碼以及如何對其進行操縱,并探討人們為何想要這樣做。

字節碼操縱框架

最為流行的字節碼操縱框架包括:

本文將會主要關注Javassist和ASM。

我們為什么應該關注字節碼操縱呢?

很多常用的Java庫,如Spring和Hibernate,以及大多數的JVM語言甚至我們的IDE,都用到了字節碼操縱框架。另外,它也確實非常有趣,所以這是一項很有價值的技術,掌握它之后,我們就能完成一些靠其他技術很難實現或無法完成的任務。一旦學會之后,我們的發揮空間將是無限的!

一個很重要的使用場景就是程序分析。例如,流行的bug定位工具FindBugs在底層就使用了ASM來分析字節碼并定位bug。有一些軟件商店會有一定的代碼復雜性規則,比如方法中if/else語句的最大數量以及方法的最大長度。靜態分析工具會分析我們的字節碼來確定代碼的復雜性。

另外一個常見的使用場景就是類生成功能。例如,ORM框架一般都會基于我們的類定義使用代理的機制。或者,在考慮實現應用的安全性時,可能會提供一種語法來添加授權的注解。在這樣的場景下,都能很好地運用字節碼操縱技術。

像Scala、Groovy和Grails這樣的JVM語言都使用了字節碼操縱框架。

考慮這樣一種場景,我們需要轉換庫中的類,這些類我們并沒有源碼,這樣的任務通常會由Java profiler來執行。例如,在New Relic,采用了字節碼instrumentation技術實現了對方法執行的計時。

借助字節碼操縱,我們可以優化或混淆代碼,甚至可以引入一些功能,比如為應用添加重要的日志。本文將會關注一個日志樣例,這個樣例提供使用這些字節碼操縱框架的基本工具。

我們的樣例

Sue負責一家銀行的ATM編程,她有了一項新的需求:針對一些指定的重要操作,在日志中添加關鍵的數據。

如下是一個簡化的銀行交易類,它允許用戶通過用戶名和密碼進行登錄、進行一些處理、提取一些錢,然后打印“交易完成”。這里的重要操作就是登錄和提款。

public void login(String password, String accountId, String userName) { 
// 登錄邏輯 
} 
public void withdraw(String accountId, Double moneyToRemove) { 
// 交易邏輯 
}

為了簡化編碼,Sue會為這些方法調用創建一個@ImportantLog注解,這個注解所包含的輸入參數代表了希望記錄的方法參數索引。借助這一點,她就可以為login和withdraw方法添加注解了。

/** 
* 方法注解,用于識別 
* 重要的方法,這些方法的調用需要進行日志記錄。 
*/ 
public @interface ImportantLog { 
/** 
* 需要進行日志記錄的方法參數索引。 
* 例如,如果有名為 
* hello(int paramA, int paramB, int paramC)的方法,我們 
* 希望以日志的形式記錄paramA和paramC的值,那么fields 
* 應該是["0","2"]。如果我們只想記錄 
* paramB的值,那么fields將會是["1"]。 
*/ 
String[] fields(); 
}

對于login方法,Sue希望記錄賬戶Id和用戶名,那么她的fields應該設置為“1”和“2”(她不希望將密碼展現出來!)。對于withdraw方法,她的fields應該設置為“0”和“1”,因為她希望輸出前兩個域:賬戶ID以及要提取的金額,其審計日志理想情況下應該包含如下的內容:

要實現該功能,Sue將會使用Java agent技術。Java agent是在JDK 1.5中引入的,它允許我們在處于運行狀態的JVM中,修改組成類的字節,在這個過程中,并不需要這些類的源碼。

在沒有agent的時候,Sue的程序的正常執行流程是這樣的:

  1. 在某個主類上運行Java,這個類會由一個類加載器進行加載;
  2. 調用該類的main方法,它會調用預先定義好的處理過程;
  3. 打印“交易完成”。

在引入Java agent之后,會發生幾件額外的事情——但是,在此之前,我們先看一下創建agent都需要些什么。agent必須要包含一個類,這個類要具有一個名為premain的方法。這個類必須要打包為JAR文件,這個包中還需要包含一個正確的manifest文件,在manifest文件中要有一個名為Premain-Class的條目。在啟動的時候,必須要設置一個啟動項,指向該JAR文件的路徑,這樣的話,JVM才能知道這個agent:

java -javaagent:/to/agent.jar com/example/spring2gx/BankTransactions

在premain方法中,我們可以注冊一個Transformer,它會在每個類加載的時候,捕獲它的字節,進行所需的修改,然后返回修改后的字節。在Sue的樣例中,Transformer會捕獲BankTransaction,在這里她會作出修改并返回修改后的字節,這也就是類加載器所加載的字節,main方法將會執行原有的功能,除此之外還會增加Sue所需的日志增強。

當agent類加載后,它的premain方法會在應用程序的main方法之前被調用。

圖2:使用Java agent的過程。

我們最好來看一個樣例。

Agent類不需要實現任何接口,但是它必須要包含一個premain方法,如下所示:

Transformer類包含了一個transform方法,它的簽名會接受ClassLoader、類名、要重定義的類所對應的Class對象、定義權限的ProtectionDomain以及這個類的原始字節。如果從transform方法中返回null的話,將會告訴運行時環境我們并沒有對這個類進行變更。

如果要修改類的字節的話,我們需要在transform中提供字節碼操縱的邏輯并返回修改后的字節。

Javassist

Javassist(“Java Programming Assistant”的縮寫形式)是JBoss的子項目,包含了高層級的基于對象的API,同時也包含了低層級更接近字節碼的API。基于對象的API社區更為活躍,這也是本文所關注的焦點。讀者可以參考 Javassist站點 以獲取完整的使用指南。

在Javassist中,進行類表述的基本單元是CtClass(即“編譯時的類”,compile time class)。組成程序的這些類會存儲在一個ClassPool中,它本質上就是CtClass實例的一個容器。

ClassPool的實現使用了一個HashMap,其中key是類的名稱,而value是對應的CtClass對象。

正常的Java類都會包含域、構造器以及方法。在CtClass中,分別與之對應的是CtField、CtConstructor和CtMethod。要定位某個CtClass,我們可以根據名稱從ClassPool中獲取,然后通過CtClass得到任意的方法,并做出我們的修改。

圖3

CtMethod中包含了相關方法的代碼行。我們可以借助insertBefore命令在方法開始的地方插入代碼。Javassist非常棒的一點在于我們所編寫的是純Java,只不過需要提醒一下:Java代碼必須要以引用字符串的形式來實現。但是,大多數的人都會同意這種方式要比處理字節碼好得多!(盡管如此,如果你碰巧喜歡直接處理字節碼的話,那么可以關注本文ASM相關的內容。)JVM包含了一個字節碼驗證器(verifier),以防止出現不合法的字節碼。如果在你的Javassist代碼中,所使用的Java是非法的,那么在運行時字節碼驗證器會拒絕它。

與insertBefore類似,還有一個名為insertAfter的方法,借助它我們可以在相關方法的結尾處插入代碼。我們還可以使用insertAt方法,從而在相關方法的中間插入代碼,或者使用addCatch添加catch語句。

現在,讓我們打開IDE,然后編碼實現這個日志特性,首先從Agent(包含premain)和ClassTransformer開始。

package com.example.spring2gx.agent;
public class Agent {
 public static void premain(String args, Instrumentation inst) {
   System.out.println("Starting the agent");
   inst.addTransformer(new ImportantLogClassTransformer());
 }
}

package com.example.spring2gx.agent;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class ImportantLogClassTransformer
   implements ClassFileTransformer {

 public byte[] transform(ClassLoader loader, String className,
                Class classBeingRedefined, ProtectionDomain protectionDomain,
                byte[] classfileBuffer) throws IllegalClassFormatException {
// 在這里操縱字節
     return modified_bytes;
 }

為了添加審計日志,首先要實現transform,將類的字節轉換為CtClass對象。然后,我們可以迭代它的方法,并捕獲其中帶有@ImportantLogin注解的方法,獲取要進行日志記錄的輸入參數索引,并將相關的代碼插入到方法開頭的位置上。

public byte[] transform(ClassLoader loader, String className, 
                        Class classBeingRedefined, 
                        ProtectionDomain protectionDomain, 
                        byte[] cfbuffer) throws IllegalClassFormatException { 
// 將字節數組轉換為CtClass對象
    pool.insertClassPath(new ByteArrayClassPath(className,classfileBuffer)); 
// 將路徑斜線轉換為點號
    CtClass cclass = pool.get(className.replaceAll("/", ".")); 
    if (!cclass.isFrozen()) { 
// 檢查CtClass的每個方法是否有@ImportantLog注解
      for (CtMethod currentMethod : cclass.getDeclaredMethods()) { 
// 查找@ImportantLog注解
        Annotation annotation = getAnnotation(currentMethod); 
        if (annotation != null) { 
// 如果方法上有@ImportantLog注解的話,那么
// 獲得重要方法的參數索引
          List parameterIndexes = getParamIndexes(annotation); 
// 在方法的開頭位置添加日志語句
          currentMethod.insertBefore(
                 createJavaString(currentMethod, className, parameterIndexes)); 
        } 
      } 
      return cclass.toBytecode(); 
    } 
    return null; 
  }

Javassist注解可以聲明為“不可見的(invisible)”和“可見的(visible)”。不可見的注解只會在類加載和編譯期可見,它們在聲明時需要將RententionPolicy.CLASS參數傳遞到注解中。可見注解(RententionPolicy.RUNTIME)在運行期會加載,并且是可見的。對于本例來說,我們只在編譯期需要這些屬性,因此將其設為不可見的。

getAnnotation方法會掃描@ImportantLog注解,如果找不到注解的話,將會返回null。

private Annotation getAnnotation(CtMethod method) { 
    MethodInfo methodInfo = method.getMethodInfo(); 
    AnnotationsAttribute attInfo = (AnnotationsAttribute) methodInfo 
      .getAttribute(AnnotationsAttribute.invisibleTag); 
    if (attInfo != null) { 
      return attInfo.getAnnotation("com.example.spring.mains.ImportantLog"); 
    } 
    return null; 
  }

在得到注解之后,我們就可以檢索參數索引了。通過使用Javassist的ArrayMemberValue,會以字符串數組的形式返回成員field的值,然后我們就可以對其進行遍歷,從而獲取在注解中所嵌入的field索引。

private List getParamIndexes(Annotation annotation) { 
    ArrayMemberValue fields =
                    (ArrayMemberValue) annotation.getMemberValue(“fields”); 
    if (fields != null) { 
      MemberValue[] values = (MemberValue[]) fields.getValue(); 
      List parameterIndexes = new ArrayList(); 
      for (MemberValue val : values) { 
        parameterIndexes.add(((StringMemberValue) val).getValue()); 
      } 
      return parameterIndexes; 
    } 
    return Collections.emptyList(); 
  }

最后,我們可以借助createJavaString在某個位置插入日志語句。

1     private String createJavaString(CtMethod currentMethod, 
2                  String className, List indexParameters) { 
3       StringBuilder sb = new StringBuilder(); 
4       sb.append("{StringBuilder sb = new StringBuilder"); 
5       sb.append("(\"A call was made to method '\");"); 
6       sb.append("sb.append(\""); 
7       sb.append(currentMethod.getName()); 
8       sb.append("\");sb.append(\"' on class '\");"); 
9       sb.append("sb.append(\""); 
10      sb.append(className); 
11      sb.append("\");sb.append(\"'.\");"); 
12      sb.append("sb.append(\"\\n Important params:\");"); 
13   
14      for (String index : indexParameters) { 
15        try { 
16          int localVar = Integer.parseInt(index) + 1; 
17          sb.append("sb.append(\"\\n Index: \");"); 
18          sb.append("sb.append(\""); 
19          sb.append(index); 
20          sb.append("\");sb.append(\" value: \");"); 
21          sb.append("sb.append($" + localVar + ");"); 
22        } catch (NumberFormatException e) { 
23          e.printStackTrace(); 
24        } 
25      } 
26      sb.append("System.out.println(sb.toString());}"); 
27      return sb.toString(); 
28    } 
29  }

在我們的實現中,創建了一個StringBuilder,它首先拼接了一些報頭信息,緊接著是方法名和類名。需要注意的一件事情是,如果我們插入多行Java語句的話,需要將其用大括號括起來(參見第4行和第26行)。

(如果只有一條語句的話,那沒有必要使用括號。)

前文基本上已經全部涵蓋了使用Javassist添加審計日志的代碼。回顧一下,它的優勢在于:

  • 因為它使用我們所熟悉的Java語法,所以不需要學習字節碼;
  • 沒有太多的編碼工作要做;
  • Javassist已經有了很棒的文檔。

它的不足之處在于:

  • 不使用字節碼會限制它的功能;
  • Javassist會比其他的字節碼操縱框架更慢一些。

ASM

ASM最初是一個博士研究項目,在2002年開源。它的更新非常活躍,從5.x版本開始支持Java 8。ASM包含了一個基于事件的庫和一個基于對象的庫,分別類似于SAX和DOM XML解析器。

一個Java類是由很多組件組成的,包括超類、接口、屬性、域和方法。在使用ASM時,我們可以將其均視為事件。我們會提供一個ClassVisitor實現,通過它來解析類,當解析器遇到每個組件時,ClassVisitor上對應的“visitor”事件處理器方法會被調用(始終按照上述的順序)。

package com.sun.xml.internal.ws.org.objectweb.asm; 
   public interface ClassVisitor { 
       void visit(int version, int access, String name, String signature, 
                                  String superName, String[] interfaces);
       void visitSource(String source, String debug); 
       void visitOuterClass(String owner, String name, String desc); 
       AnnotationVisitor visitAnnotation(String desc, boolean visible); 
       void visitAttribute(Attribute attr); 
       void visitInnerClass(String name, String outerName, 
                            String innerName, int access); 
       FieldVisitor visitField(int access, String name, String desc, 
                               String signature, Object value); 
       MethodVisitor visitMethod(int access, String name, String desc, 
                                 String signature, String[] exceptions); 
       void visitEnd(); 
   }

接下來,我們將Sue的BankTransaction(在本文的開頭中進行了定義)傳遞到一個ClassReader中進行解析,以便對這種處理方式有個直觀的感覺。

同樣,我們需要從Agent premain開始:

import java.lang.instrument.Instrumentation; 
public class Agent { 
  public static void premain(String args, Instrumentation inst) { 
    System.out.println("Starting the agent"); 
    inst.addTransformer(new ImportantLogClassTransformer()); 
  } 
}

然后,將輸出的字節傳遞給不進行任何操作(no-op)的ClassWriter,將解析得到的字節全部寫回到字節數組中,得到一個重新生成的BankTransaction,它的行為與原始類的行為是完全一致的。

圖4

import jdk.internal.org.objectweb.asm.ClassReader; 
import jdk.internal.org.objectweb.asm.ClassWriter; 
import jdk.internal.org.objectweb.asm.ClassVisitor;

import java.lang.instrument.ClassFileTransformer; 
import java.lang.instrument.IllegalClassFormatException; 
import java.security.ProtectionDomain; 

public class ImportantLogClassTransformer implements ClassFileTransformer { 
  public byte[] transform(ClassLoader loader, String className, 
               Class classBeingRedefined, ProtectionDomain protectionDomain, 
               byte[] classfileBuffer) throws IllegalClassFormatException { 
    ClassReader cr = new ClassReader(classfileBuffer); 
    ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES); 
    cr.accept(cw, 0); 
    return cw.toByteArray(); 
  } 
}

現在,我們修改一下ClassWriter,讓它做一些更有用的事情,這需要添加一個ClassVisitor(名為LogMethodClassVisitor)來調用我們的事件處理方法,如visitField或visitMethod,在解析時遇到相關組件時就會調用這些方法。

圖5

public byte[] transform(ClassLoader loader, String className, 
             Class classBeingRedefined, ProtectionDomain protectionDomain, 
             byte[] classfileBuffer) throws IllegalClassFormatException { 
    ClassReader cr = new ClassReader(classfileBuffer); 
    ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES); 
    ClassVisitor cv = new LogMethodClassVisitor(cw, className); 
    cr.accept(cv, 0); 
    return cw.toByteArray(); 
  }

對于日志記錄的需求,我們想要檢查每個方法是否具有標示注解并添加特定的日志。我們只需要重寫ClassVisitor的visitMethod方法,讓它返回一個MethodVisitor,從而提供我們自己的實現。就像類是由多個組件組成的一樣,方法也是由多個組件組成的,對應著方法屬性、注解以及編譯后的代碼。ASM的MethodVisitor提供了一種鉤子(hook)機制,以便訪問方法中的每個操作碼(opcode),這樣我們就能以很細的粒度來進行修改。

public class LogMethodClassVisitor extends ClassVisitor { 
  private String className; 
  public LogMethodIfAnnotationVisitor(ClassVisitor cv, String className) { 
    super(Opcodes.ASM5, cv); 
    this.className = className; 
  } 
  @Override 
  public MethodVisitor visitMethod(int access, String name, String desc, 
                                   String signature, String[] exceptions) { 
// 將我們的邏輯放在這里 
  } 
}

同樣的,事件處理器會始終按照預先定義的相同順序來調用,所以在實際 訪問(visit) 代碼的時候,我們就已經得知了方法的所有屬性和注解。(順便說一下,我們還可以將多個MethodVisitor鏈接在一起,就像我們可以鏈接多個ClassVisitor實例一樣。)所以,在visitMethod方法中,我們將會通過PrintMessageMethodVisitor添加鉤子,重載visitAnnotation方法來獲取注解并插入任意所需的日志代碼。

我們的PrintMessageMethodVisitor重載了兩個方法。首先是visitAnnotation,所以可以檢查方法的@ImportantLog注解。如果存在這個注解的話,我們需要提取field屬性上的索引。當visitCode執行的時候,是否存在注解已經確定了,所以我們就可以添加特定的日志了。visitAnnotation的代碼以鉤子的形式添加到了AnnotationVisitor中,它能夠暴露@ImportantLog 注解上的field參數。

public AnnotationVisitor visitAnnotation(String desc, boolean visible) { 
    if ("Lcom/example/spring2gx/mains/ImportantLog;".equals(desc)) { 
      isAnnotationPresent = true; 
      return new AnnotationVisitor(Opcodes.ASM5, 
        super.visitAnnotation(desc, visible)) { 
        public AnnotationVisitor visitArray(String name) { 
          if (“fields”.equals(name)){ 
            return new AnnotationVisitor(Opcodes.ASM5, 
              super.visitArray(name)) { 
              public void visit(String name, Object value) { 
                parameterIndexes.add((String) value); 
                super.visit(name, value); 
              } 
            }; 
          }else{ 
            return super.visitArray(name); 
          } 
        } 
      }; 
    } 
    return super.visitAnnotation(desc, visible); 
  }

現在,我們來看一下visitCode方法。首先,它必須要檢查AnnotationVisitor是否有注解存在的標記。如果有的話,那么添加我們自己的字節碼。

public void visitCode() { 
    if (isAnnotationPresent) { 
// 創建string builder 
      mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out",  
                                           "Ljava/io/PrintStream;"); 
      mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder"); 
      mv.visitInsn(Opcodes.DUP); 
// 將所需的所有內容添加到string builder上 
      mv.visitLdcInsn("A call was made to method \""); 
      mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", 
                         "",  "(Ljava/lang/String;)V", false); 
      mv.visitLdcInsn(methodName); 
      mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", 
             "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); 

    }

這是ASM非常恐怖的一點——我們必須要編寫字節碼,所以要學習新的東西。這是一門相當簡單的語言,但是如果你只是想隨便了解一下的話,那么可以非常容易地借助javap得到現有的字節碼:

javap -c com/example/spring2gx/mains/PrintMessage

我推薦你在一個Java測試類中編寫你所需的代碼、對其進行編譯,然后通過javap -c來運行它,以便查看精確的字節碼。在上面的樣例代碼中,以藍字顯示的所有內容都是字節碼。其中的每一行都是一個單字節的操作碼,后面跟著零個或更多的參數。我們需要為目標代碼確定這些參數,它們通常可以通過對原始類執行javap-c -v進行抽取(這里-v代表的意思是verbose,將會展示常量池)。

我鼓勵讀者查閱一下 JVM規范 ,該規范定義了所有的字節碼。有一些操作,比如load和store(會將數據在操作棧和本地變量之間進行轉移),針對每種參數形式都進行了重載。例如,ILOAD會將一個整型值的本地變量推送到棧中,而LLOAD會對長整型執行相同的操作。

除此之外,還有像invokeVirtual、invokeSpecial、invokeStatic這樣的操作,以及最近新添加的invokeDynamic,它們分別用來調用標準的實例方法、構造器、靜態方法以及動態類型的JVM語言中的動態方法。另外,還有創建新對象的new操作符,以及復制棧頂操作數的指令。

總結起來,ASM的優勢在于:

  • 它的內存占用很小;
  • 它的運行通常會非常快;
  • 在網上,它的文檔很豐富;
  • 所有的操作碼都是可用的,所以可以通過它做很多的事情;
  • 有很多的社區支持。

它只有一個不足之處,但這是很大的不足:我們編寫的是字節碼,所以需要理解在幕后是如何運行的,這樣的話,開發人員所需要的時間就會增加。

我們學到了什么

  • 當我們處理字節碼操縱時,很重要的一點就是步子不要太大。不要編寫大量的字節碼并希望它們能夠立即通過驗證并且可以執行。每次只編寫一行,考慮一下在你的棧中會有什么,考慮一下局部變量,然后再寫下一行。如果它不能通過驗證器的校驗,那么每次只對一個地方進行修改,否則的話,你永遠無法讓其正常運行起來。另外,還需要記住,除了JVM的驗證器以外,ASM還維護了一個單獨的字節碼驗證器,所以我們最好運行這兩個驗證器,檢查你的字節碼是否能夠通過它們兩者的校驗。
  • 在修改類的時候,很重要的一點就是要考慮類加載機制。當我們使用Java agent的時候,它的transformer會在類加載進JVM時接觸到每個類,并不關心是哪個類加載器加載的它。所以,我們需要確保類加載器也能看到對應的對象,否則的話,就會遇到麻煩。
  • 如果你將Javassist與應用服務器組合使用時,應用服務器會有多個類加載器,注意類池(class pool)需要能夠訪問到你的類對象。我們可能需要為類池注冊一個新的類路徑,以確保它能訪問類對象。你可以將類池鏈接起來,就像Java將類加載器鏈接起來一樣,這樣,如果在類池中無法找到CTClass對象的話,它能夠訪問它的父類池進行查找。
  • 最后,還有很重要的一點需要注意,那就是JDK本身也有轉換類的功能,JDK已經轉換過的類會有一些特定的限制。我們可以修改方法的實現,但是與原始的轉換不同,此時,重新轉換就不允許改變類的結構了,例如添加新的域或方法,或者修改簽名。

字節碼操縱能夠讓我們的生活更加輕松。我們可以查找缺陷、添加日志(就像之前所討論的)、混淆源碼、執行像Spring或Hibernate這樣的預處理,甚至編寫自己的語言編譯器。我們還可以限制API調用、通過分析代碼來查看是否有多個線程訪問同一個集合、從數據庫中懶加載數據,并且還可以通過探測JAR包,尋找這些包的差異。

所以,我鼓勵你選擇某個字節碼操縱框架為友,也許有一天,它就能拯救你的工作。

 

 

來自:http://www.infoq.com/cn/articles/Living-Matrix-Bytecode-Manipulation

 

 本文由用戶 lsbj2048 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!