曹工說Spring Boot源碼(26)-- 學習字節碼也太難了,實在不能忍受了,寫了個小小的字節碼執行引擎


曹工說Spring Boot源碼(26)-- 學習字節碼也太難了,實在不能忍受了,寫了個小小的字節碼執行引擎

寫在前面的話

相關背景及資源:

曹工說Spring Boot源碼(1)-- Bean Definition到底是什么,附spring思維導圖分享

曹工說Spring Boot源碼(2)-- Bean Definition到底是什么,咱們對着接口,逐個方法講解

曹工說Spring Boot源碼(3)-- 手動注冊Bean Definition不比游戲好玩嗎,我們來試一下

曹工說Spring Boot源碼(4)-- 我是怎么自定義ApplicationContext,從json文件讀取bean definition的?

曹工說Spring Boot源碼(5)-- 怎么從properties文件讀取bean

曹工說Spring Boot源碼(6)-- Spring怎么從xml文件里解析bean的

曹工說Spring Boot源碼(7)-- Spring解析xml文件,到底從中得到了什么(上)

曹工說Spring Boot源碼(8)-- Spring解析xml文件,到底從中得到了什么(util命名空間)

曹工說Spring Boot源碼(9)-- Spring解析xml文件,到底從中得到了什么(context命名空間上)

曹工說Spring Boot源碼(10)-- Spring解析xml文件,到底從中得到了什么(context:annotation-config 解析)

曹工說Spring Boot源碼(11)-- context:component-scan,你真的會用嗎(這次來說說它的奇技淫巧)

曹工說Spring Boot源碼(12)-- Spring解析xml文件,到底從中得到了什么(context:component-scan完整解析)

曹工說Spring Boot源碼(13)-- AspectJ的運行時織入(Load-Time-Weaving),基本內容是講清楚了(附源碼)

曹工說Spring Boot源碼(14)-- AspectJ的Load-Time-Weaving的兩種實現方式細細講解,以及怎么和Spring Instrumentation集成

曹工說Spring Boot源碼(15)-- Spring從xml文件里到底得到了什么(context:load-time-weaver 完整解析)

曹工說Spring Boot源碼(16)-- Spring從xml文件里到底得到了什么(aop:config完整解析【上】)

曹工說Spring Boot源碼(17)-- Spring從xml文件里到底得到了什么(aop:config完整解析【中】)

曹工說Spring Boot源碼(18)-- Spring AOP源碼分析三部曲,終於快講完了 (aop:config完整解析【下】)

曹工說Spring Boot源碼(19)-- Spring 帶給我們的工具利器,創建代理不用愁(ProxyFactory)

曹工說Spring Boot源碼(20)-- 碼網恢恢,疏而不漏,如何記錄Spring RedisTemplate每次操作日志

曹工說Spring Boot源碼(21)-- 為了讓大家理解Spring Aop利器ProxyFactory,我已經拼了

曹工說Spring Boot源碼(22)-- 你說我Spring Aop依賴AspectJ,我依賴它什么了

曹工說Spring Boot源碼(23)-- ASM又立功了,Spring原來是這么遞歸獲取注解的元注解的

曹工說Spring Boot源碼(24)-- Spring注解掃描的瑞士軍刀,asm技術實戰(上)

曹工說Spring Boot源碼(25)-- Spring注解掃描的瑞士軍刀,ASM + Java Instrumentation,順便提提Jar包破解

工程代碼地址 思維導圖地址

工程結構圖:

概要

本來,這兩三講,不是和asm有些關系嗎,但是asm難的地方,從來不在他自身,而是難在如何讀懂字節碼。我給大家舉個例子,如下這個簡單的類:

public class CheckAndSet {
    private int f;

    public void checkAndSetF(int f) {
        if (f >= 0) {
            this.f = f;
        } else {
            throw new IllegalArgumentException();
        }
    }

    public boolean checkAndSetF1(int f) {
        boolean a = true;
        boolean b = f >= 0;
        return b;
    }
}

我們假設要用asm來寫出這個代碼,要怎么寫?可以利用我們上一講提到的asm插件:ASM ByteCode Outline來輔助,但是,如果不懂字節碼,還是有很多坑的,一時半會趟不出來那種。字節碼這個東西,如果始終繞不開的話,那還是要學。

上面那個簡單的類,用javap -v CheckAndSet.class 來反編譯的話,checkAndSetF1方法,會生成如下的字節碼:

public boolean checkAndSetF1(int);
    descriptor: (I)Z
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=4, args_size=2
         0: iconst_1
         1: istore_2
         2: iload_1
         3: iflt          10
         6: iconst_1
         7: goto          11
        10: iconst_0
        11: istore_3
        12: iload_3
        13: ireturn
        

這些字節碼看起來,是不是摳腦殼?怎么知道字節碼對應的意思呢,這個當然是看文檔。

JVM虛擬機規范.pdf

或者

https://docs.oracle.com/javase/specs/jvms/se10/html/jvms-4.html#jvms-4.1

針對第一個pdf,大家可以從后往前查找(pdf最后附了個所有字節碼指令的介紹),如:

再往上查找,還會有詳細的說明:

靠着這個文檔,我開始了逐行手動計算:執行這個字節碼之前,棧和本地變量表是什么樣的;執行這個指令后,棧和本地變量表是什么樣的。過程,那是相當痛苦,大概和下面的圖差不多(圖片來源於網絡,我只是拿來描述下):

我可能還要原始一點,圖也沒畫,直接在notepad++里,記錄執行每一步之后,本地變量表和操作數棧的情況。這樣的效率真的太低了,而且看一會,我就忘了。。

然后我覺得,這個東西,好像可以寫個程序來幫我執行,無非就是一條條地執行字節碼,然后維護一個本地變量list,維護一個棧;執行字節碼的時候,我就照着字節碼的意思來做:要取本地變量我就取本地變量,要入棧我就入棧,要出棧我就出棧,反正文檔很詳細嘛,照着來即可。

說干就干。

效果展示

最終實現出來,效果如下,可以展示每一步的字節碼和執行之后的本地變量表和操作數棧的狀態。
比如執行如下方法:

    public void checkAndSetF(int f) {
        if (f >= 0) {
            this.f = f;
        } else {
            throw new IllegalArgumentException();
        }
    }

字節碼:

  public void checkAndSetF(int);
    descriptor: (I)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: iload_1
         1: iflt          12
         4: aload_0
         5: iload_1
         6: putfield      #2                  // Field f:I
         9: goto          20
        12: new           #3                  // class java/lang/IllegalArgumentException
        15: dup
        16: invokespecial #4                  // Method java/lang/IllegalArgumentException."<init>":()V
        19: athrow
        20: return

執行效果:

大致思路與實現

  • 編譯目標class,我這里拿前面的CheckAndSet.class舉例

  • javap -v CheckAndSet.class > a.txt,后續我們就會讀取a.txt來獲取方法的指令集合

  • 編寫字節碼執行引擎,一條一條地執行字節碼

用javap -v來反編譯class,可以拿到class的字節碼,大概有兩塊東西比較重要:

  1. 方法的指令集合,這是我們最需要的東西,我拿一條指令來舉例:

    public void checkAndSetF(int);
        descriptor: (I)V
        flags: ACC_PUBLIC
        Code:
          stack=2, locals=2, args_size=2
             0: iload_1
             1: iflt          12
             4: aload_0
             5: iload_1
             6: putfield      #2                  // Field f:I
             9: goto          20
            12: new           #3                  // class java/lang/IllegalArgumentException
            15: dup
            16: invokespecial #4                  // Method java/lang/IllegalArgumentException."<init>":()V
            19: athrow
            20: return
    

    比如,其中的 6: putfield #2 // Field f:I這條,其中,真正的指令,其實只有下面這部分:

    6: putfield      #2                  
    

    剩下的// Field f:I是javap給我們提供的注釋,真正的class中是沒有這部分的。那么,

    6: putfield      #2                  
    

    要怎么看呢,其中的#2是什么鬼意思?別慌,接着看另一塊很重要的東西:常量池。

  2. 常量池

    Constant pool:
       #1 = Methodref          #6.#26         // java/lang/Object."<init>":()V
       #2 = Fieldref           #5.#27         // com/yn/sample/CheckAndSet.f:I
       #3 = Class              #28            // java/lang/IllegalArgumentException
       ...
       #5 = Class              #29            // com/yn/sample/CheckAndSet
       ...
       #27 = NameAndType        #7:#8          // f:I
    

    前面的#2,就是上面的:

    #2 = Fieldref           #5.#27         // com/yn/sample/CheckAndSet.f:I
    

    其中,// com/yn/sample/CheckAndSet.f:I也是注釋,前面的#5.#27 才是class中真實存在的。

    不管怎么說,大家反正也知道#2的意思,就是CheckAndSetf這個field
    有了這兩塊東西,基本可以開搞了。

單條指令的執行

比如,我要執行:

6: putfield      #2 

利用#2拿到要執行指令的field(利用反射),然后再從棧里,彈出來:目標對象、要設置的field的入參。就可以像下面這樣執行了:

	Field field;		
	...
          
	/**
         * 從堆棧依次出棧:
         * value,objectref
         */
        Object value = context.getOperandStack().removeLast();
        Object target = context.getOperandStack().removeLast();
        try {
            field.set(target,value);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }

執行引擎核心邏輯與指令的執行順序控制

本來,我一開始是直接遍歷某個方法的指令集的:

public boolean checkAndSetF1(int);

descriptor: (I)Z
flags: ACC_PUBLIC
Code:
  stack=1, locals=4, args_size=2
     0: iconst_1
     1: istore_2
     2: iload_1
     3: iflt          10
     6: iconst_1
     7: goto          11
    10: iconst_0
    11: istore_3
    12: iload_3
    13: ireturn

就是按順序執行,0 1 2 ...13 。但是這是有bug的,因為我忽略了下面這種跳轉指令:

     3: iflt          10
	 ...
     7: goto          11

所以,后來我改成了,將這個指令集合,弄成一個鏈表,每個指令中,維護下一條指令的引用。

@Data
public class MethodInstructionVO {
    /**
     * 序列號
     */
    private String sequenceNumber;

    /**
     * 操作碼
     */
    private String opcode;

    /**
     * 操作碼的說明
     */
    private String opCodeDesc;

    /**
     * 操作數
     */
    private String operand;

    /**
     * 操作數的說明
     */
    private String comment;

    /**
     * 按順序執行的情況下的下一條指令,比如,javap反編譯后,字節碼如下:
     *          0: iconst_1
     *          1: istore_2
     *          2: iload_1
     *          3: iflt          10
     *          6: iconst_1
     *          7: goto          11
     * 那么,0: iconst_1 這條指令的nextInstruction就會執行偏移為1的那個;
     */
    @JSONField(serialize = false)
    MethodInstructionVO nextInstruction;
}

上面的最后一個字段,就是用來指向下一條指令的。默認就是指向下一條,比如:

  stack=1, locals=4, args_size=2
     0: iconst_1     -- next指向 1
     1: istore_2     -- next指向 2
     2: iload_1      -- next指向 3,最后一條的next為null

大概的核心執行框架如下:

		1. 
		MethodInstructionVO currentInstruction = instructionVOList.get(0);
		
        while (true) {
            // 2.
            ExecutorByOpCode executorByOpCode = executorByOpCodeMap.get(currentInstruction.getOpcode());
            if (executorByOpCode == null) {
                log.info("currentInstruction:{}", currentInstruction);
            }
            // 3.
            InstructionExecutionContext context = new InstructionExecutionContext();
            context.setTarget(target);
            context.setConstantPoolItems(constantPoolItems);
            context.setLocalVariables(localVariables);
            context.setOperandStack(operandStack);
            String desc = OpCodeEnum.getDescByNameIgnoreCase(currentInstruction.getOpcode());
            currentInstruction.setOpCodeDesc(desc);
            context.setInstructionVO(currentInstruction);

            /**
             * 4. 如果該字節碼執行后,返回值不為空,則表示,需要跳轉到其他指令執行
             */
            InstructionExecutionResult instructionExecutionResult =
                    executorByOpCode.execute(context);
            log.info("after {},\noperand stack:{},\nlocal variables:{}", JSONObject.toJSONString(currentInstruction, SerializerFeature.PrettyFormat),
                    operandStack, localVariables);

			// 5
            if (instructionExecutionResult == null) {
                currentInstruction = currentInstruction.getNextInstruction();
                if (currentInstruction == null) {
                    System.out.println("execute over---------------");
                    break;
                }
                continue;
            } else if (instructionExecutionResult.isReturnInstruction()) {
                // 6
                return instructionExecutionResult.getResult();
            } else if (instructionExecutionResult.isExceptional()) {
                // 7
                log.info("method execute over,throw exception:{}", instructionExecutionResult.getResult());
                throw (Throwable) instructionExecutionResult.getResult();
            }
          // 8
                String sequenceNum = instructionExecutionResult.getInstructionSequenceNum();
            currentInstruction = instructionVOHashMap.get(sequenceNum);
            log.info("will skip to {}", currentInstruction);
        }
  • 1處,默認獲取第一條指令

  • 2處,獲取指令對應的處理器,比如,獲取iconst_1指令對應的處理器

  • 3處,構造要傳入處理器的參數上下文,包括了當前指令、操作數棧、本地變量表、常量池等

  • 4處,調用第二步的處理器的execute方法,傳入第三步的參數;將執行結果賦值給局部變量

    instructionExecutionResult。

  • 5處,如果返回結果為null,說明不需要跳轉,則將當前指令的next,賦值給當前指令。

    if (instructionExecutionResult == null) {
                    currentInstruction = currentInstruction.getNextInstruction();
    
  • 6處,如果返回結果不為空,且是return指令,則直接返回結果

  • 7處,如果返回結果不為空,且是拋出了異常,則將異常繼續拋出

  • 8處,如果返回結果不為空,比如遇到goto 指令,處理器返回時,會在instructionExecutionResult的instructionSequenceNum字段,設置要跳轉到的指令;則查找到該指令,賦值給currentInstruction

如何根據字節碼指令,查找處理器

定義了一個通用的處理器:

public interface ExecutorByOpCode {
    String getOpCode();

    /**
     *
     * @param context
     * @return 如果需要跳轉,則返回要跳轉的指令的偏移量;否則返回null
     */
    InstructionExecutionResult execute(InstructionExecutionContext context);
}

然后,我這邊針對各種指令,寫了一堆實現類:

拿一個最簡單的iconst_0舉例:

@Component
public class ExecutorForIConst0 extends BaseExecutorForIConstN implements ExecutorByOpCode{

    @Override
    public String getOpCode() {
        return OpCodeEnum.iconst_0.name();
    }

    @Override
    public InstructionExecutionResult execute(InstructionExecutionContext context) {
        super.execute(context, 0);
        return null;
    }
}

public class BaseExecutorForIConstN {
	// 1 
    public void execute(InstructionExecutionContext context,Integer counter) {
        context.getOperandStack().addLast(counter);
    }
}
  • 1處,將常量0,壓入操作數棧。

每個字節碼處理器,都注解了@Component,然后在執行引擎類中,注入了全部的處理器:

@Component
@Slf4j
public class MethodExecutionEngine implements InitializingBean {
    ClassInfo classInfo;
	
    // 1
    @Autowired
    private List<ExecutorByOpCode> executorByOpCodes;
  	
  	private Map<String, ExecutorByOpCode> executorByOpCodeMap = new HashMap<>();
	
  // 2
  @Override
    public void afterPropertiesSet() throws Exception {
        if (executorByOpCodes != null) {
            for (ExecutorByOpCode executorByOpCode : executorByOpCodes) {
                executorByOpCodeMap.put(executorByOpCode.getOpCode().toLowerCase(), executorByOpCode);
            }

        }
    }
  • 1處,注入全部的處理器
  • 2處,將處理器寫入map,key:字節碼指令;value:處理器本身。
  • 后續執行引擎,就可以根據字節碼指令,查找到對應的處理器。

遍歷讀取文件所有行,采用visitor模式回調visitor接口

就是普通的讀文件,寫得比較隨意,讀成了行的集合。

	String filepath = "F:\\ownprojects\\all-simple-demo-in-work\\class-bytecode-analyse-engine\\target\\classes\\com\\yn\\sample\\a.txt";
        JavapClassFileParser javapClassFileParser = context.getBean(JavapClassFileParser.class);
        ClassInfo classInfo = javapClassFileParser.parse(filepath);

在parse方法內,代碼如下:

		// 1	
		lines = FileReaderUtil.readFile2Lines(filePath);
        if (CollectionUtils.isEmpty(lines)) {
            return null;
        }
		
		// 2
        ClassMethodCodeVisitor classMethodCodeVisitor = null;
        for (int i = 0; i < lines.size(); i++) {
            String currentLine = lines.get(i);
            if (i == 0) {
              ...
                
  • 1處,讀取文件,獲取全部行

  • 遍歷所有行,這塊寫得比較亂一點,比如,當前行包含了“Constant pool:”時,將當前解析狀態修改為常量池解析開始

    /**
     * 當本行包含Constant pool:時,接下來就是一堆的常量:
     * Constant pool:
     *    #1 = Methodref          #6.#25         //  java/lang/Object."<init>":()V
     *    #2 = Fieldref           #5.#26         //  com/yn/sample/CheckAndSet.f:I
     * 切換狀態到常量池解析開始的狀態
     */
    if (currentLine.contains("Constant pool:")) {
        classConstantPoolInfoVisitor.visitConstantPoolStarted();
        state = ParseStateEnum.CONSTANT_POOL_STARTED.state;
        continue;
    }
    

    下一次循環,就會進入解析狀態為常量池解析開始時的邏輯:

    if (state == ParseStateEnum.CONSTANT_POOL_STARTED.state) {
      // 1.
      ConstantPoolItem item = ParseEngineHelper.parseConstantPoolItem(currentLine);
      if (item == null) {
    	// 2.
        classConstantPoolInfoVisitor.visitConstantPoolEnd();
        state = ParseStateEnum.METHOD_INFO_STARTED.state;
        continue;
      } else {
        // 3
        classConstantPoolInfoVisitor.visitConstantPoolItem(item);
        continue;
      }
    }
    
    • 1處,當前行的格式應該為,

      #1 = Methodref #6.#26 // java/lang/Object."<init>":()V

      根據正則,解析當前行為如下結構:

      public class ConstantPoolItem {
          /**
           * 格式如:
           * #1
           */
          private String id;
      
          /**
           * 如:
           * Methodref
           */
          private ConstantPoolItemTypeEnum constantPoolItemTypeEnum;
      
          /**
           * #6.#25
           */
          private String value;
      
          /**
           * 對於value的注釋,因為value字段一般就是對常量池的id引用,
           * javap反編譯后,為了方便大家閱讀,這里會顯示為相應的常量
           */
          private String comment;
      }
      
    • 2處,如果返回的常量池對象為null,說明當前常量池解析結束,則修改解析狀態為:方法解析開始

    • 3處,如果解析出來了常量池對象,則回調visitor接口。

在解析過程中,會不斷回調我們的visitor接口,比如:

package com.yn.sample.visitor;

import com.yn.sample.domain.ConstantPoolItem;

import java.util.ArrayList;

public interface ClassConstantPoolInfoVisitor {
    /**
     * 常量池解析開始
     */
    void visitConstantPoolStarted();

    /**
     * 解析到每一個常量池對象時,回調本方法
     * @param constantPoolItem
     */
    void visitConstantPoolItem(ConstantPoolItem constantPoolItem);

    /**
     * 常量池解析結束
     */
    void visitConstantPoolEnd();

    /**
     * 獲取最終的常量池對象
     * @return
     */
    ArrayList<ConstantPoolItem> getConstantPoolItemList();
}

整體流程

  1. 讀取文件,獲取字節碼

    package com.yn.sample;
    
    
    @Component
    @ComponentScan("com.yn.sample")
    public class BootStrap {
        public static void main(String[] args) throws Throwable {
            AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(BootStrap.class);
            /**
             * 解析文件
             */
            String filepath = "F:\\ownprojects\\all-simple-demo-in-work\\class-bytecode-analyse-engine\\target\\classes\\com\\yn\\sample\\a.txt";
            JavapClassFileParser javapClassFileParser = context.getBean(JavapClassFileParser.class);
            ClassInfo classInfo = javapClassFileParser.parse(filepath);
    
        }
    }
    
    

    字節碼讀取后,存在classInfo中。

  2. 調用CheckAndSet類的實例的checkAndSetF(int)接口,參數為12,即,調用如下方法:

        public void checkAndSetF(int f) {
            if (f >= 0) {
                this.f = f;
            } else {
                throw new IllegalArgumentException();
            }
        }
    
  3. 構造本地變量list、操作數棧

    private Object doExecute(Object target, MethodInfo methodInfo,
                             List<ConstantPoolItem> constantPoolItems, List<Object> arguments) throws Throwable {
        List<MethodInstructionVO> instructionVOList = methodInfo.getInstructionVOList();
        /**
         * 構造next字段,將字節碼指令list轉變為鏈表
         */
        assemblyInstructionList2LinkedList(instructionVOList);
    
        /**
         * 本地變量表,按照從javap中解析出來的:
         *     Code:
         *       stack=1, locals=4, args_size=2
         * 來創建本地變量的堆棧
         */
        Integer localVariablesSize = methodInfo.getMethodCodeStackSizeAndLocalVariablesTableSize().getLocalVariablesSize();
        List<Object> localVariables = constructLocalVariableList(target, arguments, localVariablesSize);
    
        /**
         * 構造指令map,方便后續跳轉指令使用
         * key:指令的sequenceNum
         * value:指令
         */
        HashMap<String, MethodInstructionVO> instructionVOHashMap = new HashMap<>();
        for (MethodInstructionVO vo : instructionVOList) {
            instructionVOHashMap.put(vo.getSequenceNumber(), vo);
        }
    
    
        return null;
    }
    
  4. 調用執行引擎逐行解釋執行字節碼

    這部分參見前面,已經講過。

總結

源碼放在:

https://gitee.com/ckl111/class-bytecode-analyse-engine

目前沒實現的有:

  1. 方法調用方法,只支持調用單個方法。方法堆棧待實現。
  2. 很多其他各種指令

目前只能執行下面這個類中的方法,后續遇到其他字節碼指令,再慢慢加吧:

后續有時間再寫其他的吧,如果大家有興趣,可以自己寫。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM