史上最全的ASM原理解析與應(yīng)用

ASM簡介

  • ASM是一個操作Java字節(jié)碼類庫,其操作的對象是字節(jié)碼數(shù)據(jù),處理字節(jié)碼方式是“拆分-修改-合并”
    1. 將.class文件拆分成多個部分;
    2. 對某一個部分的信息進行修改;
    3. 將多個部分重新組織成一個新的.class文件;
  • 版本發(fā)展:Java語言在不斷發(fā)展,那么,ASM版本也要不斷發(fā)展來跟得上Java的發(fā)展;在選擇ASM版本的時候,要注意它支持的Java版本,來確保兼容性;
  • 學(xué)習(xí)目標:使用ASM實現(xiàn)1)無狀態(tài)轉(zhuǎn)換:查找方法;替換方法; 2)有狀態(tài)轉(zhuǎn)換:獲取Hybrid使用Action集合,RN使用modules集合數(shù)據(jù);
  • 既然要操作字節(jié)碼數(shù)據(jù),我們需要了解字節(jié)碼存儲結(jié)構(gòu):The class File Format

ClassFile

  • 既然ASM操作的是.class文件,則我們需要了解class文件結(jié)構(gòu):在.class文件中,存儲的是ByteCode數(shù)據(jù),但是,這些ByteCode數(shù)據(jù)并不是雜亂無章的,而是遵循一定的數(shù)據(jù)結(jié)構(gòu),這些結(jié)構(gòu)定義在 Java Virtual Machine Specification中的The class File Format,如下所示。
ClassFile {
    u4             magic;  //魔數(shù)
    u2             minor_version; //次版本號
    u2             major_version; //主版本號
    u2             constant_pool_count; //常量池容量:從1開始,0:不引用任何一個常量池數(shù)據(jù)
    cp_info        constant_pool[constant_pool_count-1]; //常量數(shù)據(jù),數(shù)據(jù)構(gòu)成有17種,以首位u1表示tag類型
    u2             access_flags; //訪問標記,識別類或接口的訪問信息如:ACC_PUBLIC;ACC_ABSTRACT,由于每個標記占用二進制位不同,使用|表示交集;
    u2             this_class; //當前類索引:常量池中偏移量指向一個類型為CONSTANT_Class_info的類描述符常量
    u2             super_class; //當前類的父類索引:java單繼承機制
    u2             interfaces_count; //當前類實現(xiàn)的接口計數(shù)器
    u2             interfaces[interfaces_count]; //接口索引表:常量池中偏移量
    u2             fields_count;  //字段計數(shù)器
    field_info     fields[fields_count]; //字段表:由類級變量static和實例變量(全局),不包括局部變量
    u2             methods_count; //方法數(shù)
    method_info    methods[methods_count]; //方法表
    u2             attributes_count; //屬性數(shù)
    attribute_info attributes[attributes_count]; //屬性表
}
  • u* : 表示占用字節(jié)數(shù),有u1,u2,u4,u8等構(gòu)成;

字段表

  • field_info字段表格式如下:
field_info {
    u2             access_flags; //字段的訪問標記
    u2             name_index;  //字段的名稱
    u2             descriptor_index; //字段的描述符
    u2             attributes_count; //字段屬性
    attribute_info attributes[attributes_count];
}
  1. 全限定名:類的全限定名是將類全名的.全部替換為/,如java.lang.String ,全限定名java/lang/String

  2. 簡單名稱:方法main() 簡單名稱為main,全局變量num為名稱num

  3. 描述符:基本數(shù)據(jù)類型及void的由大寫字母表示,對象類型有L+全限定名=> Ljava/lang/String;表示String字段描述符;對于數(shù)組類型根據(jù)維度在前面加上“[”,如int[] => [I ; String[] => [Ljava/lang/String;


    desc_asm.png
  4. attribute_info:字段的屬性表,存儲一些額外信息;

方法表

  • 方法表格式如下,方法的描述與字段的描述采用了幾乎完全一致的方式
method_info {
    u2             access_flags;  //訪問標記
    u2             name_index;  //方法簡單名稱常量池索引
    u2             descriptor_index; //方法描述符索引
    u2             attributes_count; //方法屬性表
    attribute_info attributes[attributes_count];
}
  • 注意class文件中編譯器添加的實例構(gòu)造器<init>類構(gòu)造器<clint>兩個方法

屬性表

  • Class文件、字段表、方法表都可以攜帶自己的屬性表集合,以描述某些場景專有的信息,參考如下事例表示:
public class HelloWorld {

    public void foo() {
        int i = 0;
        int j = 0;
        if (i > 0) {
            int k = 0;
        }
        int l = 0;
    }
}

//使用javap -v 獲取foo字節(jié)碼code數(shù)據(jù)如下:
public void foo();
    descriptor: ()V //方法描述符
    flags: (0x0001) ACC_PUBLIC //方法訪問標記
    Code:  033C033D 1B9E0005 033E033E B1
      stack=1, locals=4, args_size=1  //stack:操作數(shù)棧深度,locals:局部變量表,args_size:方法參數(shù)的個數(shù),包括方法參數(shù)、this
         0: iconst_0
         1: istore_1
         2: iconst_0
         3: istore_2
         4: iload_1    // 1B
          /**
          * ifle 字節(jié)碼 9E -->后面u2類型字段為 0005 表示偏移量,當前5 + 偏移量 = 10
          *  10由code字節(jié)碼偏移量6,7位置,code屬性中字節(jié)碼長度由u4表示,
          *   但是《Java虛擬機規(guī)范》中明確限制了一個方法不允許超過65535條字節(jié)碼指令,即實際只使用了u2的長度,如果超過這個限制,Javac編譯器就會拒絕編譯,因此這里使用u2表示跳轉(zhuǎn)字節(jié)碼偏移量
          */
         5: ifle          10       //9E 0005
         8: iconst_0     //03
         9: istore_3
        10: iconst_0
        11: istore_3
        12: return
      LineNumberTable: //源碼行數(shù)與code中偏移量對應(yīng)關(guān)系,常用于log中輸出日志
        line 11: 0
        line 12: 2
        line 13: 4
        line 14: 8
        line 18: 10
        line 20: 12
      LocalVariableTable:  //描述棧幀中局部變量表的變量與Java源碼中定義的變量之間的關(guān)系
        //start:局部變量的生命周期開始的字節(jié)碼偏移量
        //Length:其作用范圍覆蓋的長度,兩者結(jié)合起來就是這個局部變量在字節(jié)碼之中的作用域范圍
        //Slot: 局部變量表位置:對應(yīng)上方最大locals=4,根據(jù)字節(jié)碼可以驗證stack最大為1
        Start  Length  Slot  Name   Signature  
            0      13     0  this   Lsample/HelloWorld;
            2      11     1     i   I
            4       9     2     j   I
           12       1     3     l   I
      StackMapTable: number_of_entries = 1  //棧映射幀Stack Map Frame個數(shù):1
      
      //虛擬機類加載的字節(jié)碼驗證階段被新類型檢查驗證器使用,目的代替以前比較消耗性能的基于數(shù)據(jù)流分析的類型推導(dǎo)驗證器;其中記錄的是一個方法中操作數(shù)棧與局部變量區(qū)的類型在一些特定位置的狀態(tài)。
        frame_type = 253 /* append */  //基本塊開頭處的狀態(tài):frame_type = 251,表示多了2個局部變量,append 增加變量,chop 減少變量
        offset_delta = 10  //棧映射幀的code偏移量為10,記錄的是if跳轉(zhuǎn)語句
        locals = [ int, int ]  //增量設(shè)置,進入時有默認Frame局部變量區(qū):[this],在10位置變量k已經(jīng)過了作用域局部變量區(qū):[ this, int, int],增量為 locals = [ int, int ]
  • 這里我們主要關(guān)注StackMapTable中包含的棧映射楨:它有什么用呢?
    • 在使用ASM的classWriter修改字節(jié)碼時構(gòu)造函數(shù)如下,flags屬性一般使用COMPUTE_FRAMES,其于COMPUTE_MAXS,默認0有何區(qū)別呢?
    public ClassWriter(ClassReader classReader, int flags)
    
    • 默認值0:ASM即不會自動計算max stacks和max locals,也不會自動計算stack map frames;
    • COMPUTE_MAXS:ASM只會自動計算max stacks和max locals,但不會自動計算stack map frames;
    • COMPUTE_FRAMES:ASM即會自動計算max stacks和max locals,也會自動計算stack map frames;

ASM組成

  • 組成結(jié)構(gòu)上來說,ASM分成兩部分,一部分為Core API,另一部分為Tree API
    • Core API包括asm.jar、asm-util.jar和asm-commons.jar
    • Tree API包括asm-tree.jar和asm-analysis.jar
  • 我們常用的是asm.jar中的ClassReader,classVisitor,ClassWrite這三個類,他們的關(guān)系如下:


    asm_relation_chart.png

ClassReader拆分

  • 主要負責讀取.class文件里的內(nèi)容,然后拆分成各個不同的部分;如何實現(xiàn)呢?
public class ClassReader {
        
    //真實的數(shù)據(jù)部分
    final byte[] classFileBuffer;
    
    //數(shù)據(jù)的索引信息:標識了classFileBuffer中數(shù)據(jù)里包含的常量池位置
    private final int[] cpInfoOffsets;
    
    //標記訪問標識(access flag)在classFileBuffer中的位置信息
    public final int header;
   
    /**
    * 全局變量初始化
    * classFileOffset :默認為0,起始位置
    */
    ClassReader(byte[] classFileBuffer, int classFileOffset, boolean checkClassVersion) {
        this.classFileBuffer = classFileBuffer; //class文件數(shù)據(jù)字節(jié)數(shù)組
        this.b = classFileBuffer;
        if (checkClassVersion && this.readShort(classFileOffset + 6) > 60) {
            throw new IllegalArgumentException("Unsupported class file major version " + this.readShort(classFileOffset + 6));
        } else {
                //讀取第8個字節(jié)位置:常量池大小constant_pool_count
            int constantPoolCount = this.readUnsignedShort(classFileOffset + 8);
            this.cpInfoOffsets = new int[constantPoolCount];
                        
                        //當前常量池起始位置:注意ClassFile由1開始,保留0位置用于未指定任何數(shù)據(jù)
            int currentCpInfoIndex = 1;
            
            //起始偏移量,首位常量池位置:(魔數(shù)u4,次版本號u2,主版本號u2,常量池大小u2)
            int currentCpInfoOffset = classFileOffset + 10; //從第10個字節(jié)開始保存常量

            
            //當前各個常量的偏移量
            int cpInfoSize;
            for(hasConstantDynamic = false; currentCpInfoIndex < constantPoolCount; currentCpInfoOffset += cpInfoSize) {        
                    /**
                    * 常量池的數(shù)據(jù):u1:表示當前數(shù)據(jù)類型
                    CONSTANT_Utf8_info {
                        u1 tag;  // == 1
                        u2 length; 
                        u1 bytes[length];
                } 
                    */
                this.cpInfoOffsets[currentCpInfoIndex++] = currentCpInfoOffset + 1; //去掉u1數(shù)據(jù)類型保存常量數(shù)據(jù)
                switch(classFileBuffer[currentCpInfoOffset]) { //currentCpInfoOffset記錄的為當前常量類型tag
                case 1:  //字符串計算字符串長度作為偏移量cpInfoSize
                    //tag:u1 length:u2 = 3 加上Short位的length表示bytes數(shù)組長度的
                    cpInfoSize = 3 + this.readUnsignedShort(currentCpInfoOffset + 1);  
                  
                    ...
                    break;
                    ...
                }
                /**
                * currentCpInfoOffset:常量池數(shù)據(jù)已經(jīng)全部遍歷完存入cpInfoOffsets中,此時位置為:access_flags
                */
                this.header = currentCpInfoOffset;
  • int[] cpInfoOffsets:由class文件往后讀取8個字節(jié),在classFile中可知(魔數(shù)u4,次版本號u2,主版本號u2)constant_pool_count即常量池大小為cpInfoOffsets數(shù)組大小,數(shù)組中數(shù)據(jù)為當前常量在classFile中的偏移量,用于快速獲取常量;
  • header:存儲當前類的access_flags標識位在字節(jié)碼數(shù)組中位置:快速定位到當前類,父類,字段,方法等數(shù)據(jù)內(nèi)容,如何驗證,看一下accept()方法:
public void accept(ClassVisitor classVisitor, Attribute[] attributePrototypes, int parsingOptions) {
        ...
        int currentOffset = this.header; //currentOffset指定位置為:u2 access_flags
        int accessFlags = this.readUnsignedShort(currentOffset); //獲取類標識位 Short
        String thisClass = this.readClass(currentOffset + 2, charBuffer); // +2獲取當前類索引 this_class
        String superClass = this.readClass(currentOffset + 4, charBuffer); // +4 獲取當前父類索引 super_class
        String[] interfaces = new String[this.readUnsignedShort(currentOffset + 6)]; //接口集合數(shù)據(jù):大小 + 6
        currentOffset += 8; //u2:access_flags, u2:this_class, u2:super_class , u2:interfaces_count
                
                //獲取實現(xiàn)接口數(shù)據(jù)
                int innerClassesOffset;
        for(innerClassesOffset = 0; innerClassesOffset < interfaces.length; ++innerClassesOffset) {
            interfaces[innerClassesOffset] = this.readClass(currentOffset, charBuffer);
            currentOffset += 2; //interfaces[] 數(shù)組:數(shù)據(jù)類型u2
        }
        
        ...
        //獲取屬性表位置:attribute_info
        int currentAttributeOffset = this.getFirstAttributeOffset();
                //屬性表個數(shù):attributes_count
        int fieldsCount;
        for(fieldsCount = this.readUnsignedShort(currentAttributeOffset - 2); fieldsCount > 0; --fieldsCount) {
            String attributeName = this.readUTF8(currentAttributeOffset, charBuffer);
            int attributeLength = this.readInt(currentAttributeOffset + 2);
            currentAttributeOffset += 6;
            ...
            if ("Signature".equals(attributeName)) { //若當前類屬性有泛型,則讀取其信息
                signature = this.readUTF8(currentAttributeOffset, charBuffer);
            } 
                        ...
            currentAttributeOffset += attributeLength;
                        
        }
        
        
        //調(diào)用visit方法,每個類只會調(diào)用一次,參數(shù)為我們讀取到字節(jié)碼數(shù)據(jù)
    classVisitor.visit(this.readInt(this.cpInfoOffsets[1] - 7), accessFlags, thisClass, signature, superClass, interfaces);
        ...
    
    //獲取filed字段個數(shù)
    fieldsCount = this.readUnsignedShort(currentOffset);
        //readField() 調(diào)用classVisitor.visitField()方法
    for(currentOffset += 2; fieldsCount-- > 0; currentOffset = this.readField(classVisitor, context, currentOffset)) {}
        //獲取method方法個數(shù)
    methodsCount = this.readUnsignedShort(currentOffset);
        //readMethod() 調(diào)用classVisitor.visitMethod()方法
    for(currentOffset += 2; methodsCount-- > 0; currentOffset = this.readMethod(classVisitor, context, currentOffset)) {}

    classVisitor.visitEnd();    
  • accept()方法調(diào)用:
    • 根據(jù)header快速獲取類標識位,當前類索引,父類索引,接口數(shù)據(jù),屬性表等數(shù)據(jù)后調(diào)用classVisitor.visit()方法,這也就解釋了為何classVisitor.visit()及classVisitor.visitEnd()只會調(diào)用一次,且一個在前,一個在最后調(diào)用;
    • 根據(jù)字段個數(shù),調(diào)用readField中對字段解析調(diào)用classVisitor.visitField()方法
    • 根據(jù)方法個數(shù),調(diào)用readMethod中對字段解析調(diào)用classVisitor.visitMethod()方法
private int readField(ClassVisitor classVisitor, Context context, int fieldInfoOffset) {
        //descriptor :字段描述符 int:I ; constantValue :字段默認值
    FieldVisitor fieldVisitor = classVisitor.visitField(accessFlags, name, descriptor, signature,constantValue);
    ...
    fieldVisitor.visitEnd();
    return currentOffset;
}

private int readMethod(ClassVisitor classVisitor, Context context, int methodInfoOffset) {
        //調(diào)用classVisitor.visitMethod()掃描類中每一個方法
    MethodVisitor methodVisitor = classVisitor.visitMethod(context.currentMethodAccessFlags, context.currentMethodName, context.currentMethodDescriptor, signatureIndex == 0 ? null : this.readUtf(signatureIndex, charBuffer), exceptions);
    ...
    //獲取方法注解
    if (annotationDefaultOffset != 0) {
        AnnotationVisitor annotationVisitor = methodVisitor.visitAnnotationDefault();
        this.readElementValue(annotationVisitor, annotationDefaultOffset, (String)null, charBuffer);
        if (annotationVisitor != null) {
            annotationVisitor.visitEnd();
        }
    }
    ...
    //如果方法存在code屬性
    if (codeOffset != 0) {
        methodVisitor.visitCode();
        this.readCode(methodVisitor, context, codeOffset);
    }

    methodVisitor.visitEnd();
    return currentOffset;
}
  • MethodVisitor:調(diào)用順序由classVisitor.accept() -> classVisitor.visitMethod() -> methodVisitor.visitCode() -> readCode() ->methodVisitor.visitEnd()
    • methodVisitor.visitCode()方法起始位置調(diào)用,methodVisitor.visitEnd()方法結(jié)束時調(diào)用,且只會調(diào)用一次
    • readCode() 讀取code中屬性值調(diào)用MethodVisitor對應(yīng)方法
private void readCode(MethodVisitor methodVisitor, Context context, int codeOffset) {
    byte[] classBuffer = this.classFileBuffer;
    //常用加載字符串字節(jié)碼命令編號:0x12 -> 18 ldc 表示int、float或String型常量從常量池推送至棧頂
    case 18:
            //調(diào)用visitLdcInsn獲取常量池中數(shù)據(jù)readConst讀取utf-8字符串
        methodVisitor.visitLdcInsn(this.readConst(classBuffer[currentOffset + 1] & 255, charBuffer));
        currentOffset += 2;
    break;
    ...
    
    case 178: //0xb2 getstatic
    case 179: //0xb3 putstatic
    case 180: //0xb4 getfield
    case 181: //0xb5 putfield
    case 182: //0xb6 invokevirtual
    case 183: //0xb7 invokespecial
    case 184: //0xb8 invokestatic
    case 185: //0xb9 invokeinterface
    
        typeAnnotationOffset = this.cpInfoOffsets[this.readUnsignedShort(currentOffset + 1)];
        targetType = this.cpInfoOffsets[this.readUnsignedShort(typeAnnotationOffset + 2)];
        annotationDescriptor = this.readClass(typeAnnotationOffset, charBuffer);
        descriptor = this.readUTF8(targetType, charBuffer);
        signature = this.readUTF8(targetType + 2, charBuffer);
        if (startPc < 182) { 
                //如果字節(jié)碼是對字段操作,調(diào)用methodVisitor.visitFieldInsn
            methodVisitor.visitFieldInsn(startPc, annotationDescriptor, descriptor, signature);
        } else {
                //如果字節(jié)碼是對方法操作,則調(diào)用methodVisitor.visitMethodInsn
            boolean isInterface = classBuffer[typeAnnotationOffset - 1] == 11;
            methodVisitor.visitMethodInsn(startPc, annotationDescriptor, descriptor, signature, isInterface);
        }

        if (startPc == 185) {
            currentOffset += 5;
        } else {
            currentOffset += 3;
        }
        break;
        ...
    //對方法棧楨中的最大操作數(shù)棧和最大局部變量表進行賦值     
    methodVisitor.visitMaxs(maxStack, maxLocals);
}
  • 對方法code中的字節(jié)碼進行解析,調(diào)用methodVisitor對應(yīng)方法,最后調(diào)用 methodVisitor.visitMaxs 設(shè)置棧楨數(shù)據(jù),若ClassWriter中設(shè)置了COMPUTE_FRAMES屬性,則visitMaxs設(shè)置無效;
  • 綜上:classReader對.class文件讀取調(diào)用accept(ClassVisitor cv)拆分成各個不同部分,將傳遞給cv對應(yīng)方法,其可以負責將各個不同的部分重新組合成一個完整的.class文件;

ClassWriter組合

  • 將各個不同的部分重新組合成一個完整的.class文件,如何實現(xiàn)呢?
public class ClassWriter extends ClassVisitor {
    public static final int COMPUTE_MAXS = 1;
    public static final int COMPUTE_FRAMES = 2;
    
    private int version; //版本號
    private final SymbolTable symbolTable; //常量池信息
     
    private int accessFlags;  //標識位
    private int thisClass;  //當前類索引
    private int superClass; //當前類父類索引
    private int interfaceCount; //接口數(shù)據(jù)
    private int[] interfaces;
    
    private FieldWriter firstField; //字段表
    private FieldWriter lastField;
    
    private MethodWriter firstMethod; //方法表
    private MethodWriter lastMethod;
    
    private Attribute firstAttribute; //屬性表
    
    //通過構(gòu)造函數(shù)封裝為SymbolTable對象:主要是解析類信息中,主要是常量池信息
    public ClassWriter(ClassReader classReader, int flags) {
        super(589824);
        this.symbolTable = classReader == null ? new SymbolTable(this) : new SymbolTable(this, classReader);
    }
    
    public final void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        this.version = version;
        this.accessFlags = access;
        //根據(jù)類名全限定名獲取在常量池中下標
        this.thisClass = this.symbolTable.setMajorVersionAndClassName(version & '\uffff', name);
        if (signature != null) {
            this.signatureIndex = this.symbolTable.addConstantUtf8(signature);
        }
            
        this.superClass = superName == null ? 0 : this.symbolTable.addConstantClass(superName).index;
        if (interfaces != null && interfaces.length > 0) {
            this.interfaceCount = interfaces.length;
            this.interfaces = new int[this.interfaceCount];

            for(int i = 0; i < this.interfaceCount; ++i) {
                this.interfaces[i] = this.symbolTable.addConstantClass(interfaces[i]).index;
            }
        }
    }
    //字段及方法通過鏈表連接,由firstField -> lastField; firstMethod -> lastMethod
     public final FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
        FieldWriter fieldWriter = new FieldWriter(this.symbolTable, access, name, descriptor, signature, value);
        if (this.firstField == null) {
            this.firstField = fieldWriter;
        } else {
            this.lastField.fv = fieldWriter;
        }
        return this.lastField = fieldWriter;
    }

    public final MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        ...
    }
    //屬性表也是通過鏈表
    public final void visitAttribute(Attribute attribute) {
        attribute.nextAttribute = this.firstAttribute;
        this.firstAttribute = attribute;
    }
}
  • ClassWriter通過構(gòu)造函數(shù)將傳入的ClassVisitor信息解析封裝為SymbolTable對象并將用到的classFile中數(shù)據(jù)保存為全局變量,字段field,method,Attribute等數(shù)據(jù)均由鏈表表示;

組裝.class文件

  • 組裝過程大致分為以下三步:
  1. 計算byte[]數(shù)組,即class文件大小size;
  2. 向byte數(shù)組中按照classFile格式添加對應(yīng)元素;
  3. 將byte[] 數(shù)據(jù)返回;
計算size
  • 按照classFile格式分析各個部分占用數(shù)據(jù)大小
public byte[] toByteArray() {
        /**
        * 24: u4 magic , 10個必須字段u2(minor_version, major_version, constant_pool_count, access_flags,
        *    this_class , super_class, interfaces_count, fields_count, methods_count, attributes_count)
        * 接口字段集合為 u2 * interfaceCount
        * 剩余未計算: cp_info , field_info , method_info , attribute_info
        */
    int size = 24 + 2 * this.interfaceCount;
    int fieldsCount = 0;
        
        /**
        * 鏈表計算字段占用大小 field_info
        */
    FieldWriter fieldWriter;
    for(fieldWriter = this.firstField; fieldWriter != null; fieldWriter = (FieldWriter)fieldWriter.fv) {
        ++fieldsCount;
        size += fieldWriter.computeFieldInfoSize();
    }

        /**
        * 鏈表計算方法占用大小 method_info
        */
    int methodsCount = 0;
    MethodWriter methodWriter;
    for(methodWriter = this.firstMethod; methodWriter != null; methodWriter = (MethodWriter)methodWriter.mv)      {
        ++methodsCount;
        size += methodWriter.computeMethodInfoSize();
    }
    
    /**
        * 計算屬性表占用大小 attribute_info
        */
    int attributesCount = 0;
    ......

    if (firstAttribute != null) {
        attributesCount += firstAttribute.getAttributeCount();
        size += firstAttribute.computeAttributesSize(symbolTable);
    }
    /**
        * 計算常量池占用大小 cp_info
        */
    size += this.symbolTable.getConstantPoolLength();
  • 計算數(shù)組大小
  1. 必要位: 由classFile格式可知總計有24個字節(jié),接口數(shù)據(jù)有 2*interfaceCount
  2. 其他位: 依次計算剩下的常量池,字段,方法,屬性大小
  3. 匯總以上數(shù)據(jù)獲取.class文件大小
添加數(shù)據(jù)
  • 嚴格按照classFile格式添加對應(yīng)數(shù)據(jù)
public byte[] toByteArray() { 
        ...
        //創(chuàng)建ByteVector存儲對象,大小為上方計算的size
    ByteVector result = new ByteVector(size);
    //添加魔數(shù),version int u4:次版本號+主版本號
    result.putInt(0xCAFEBABE).putInt(this.version);
    
    /**
    * 添加常量池數(shù)據(jù):常量池大小u2 + 常量數(shù)組內(nèi)容大小
    * void putConstantPool(ByteVector output) {
        output.putShort(this.constantPoolCount).putByteArray(this.constantPool.data, 0,this.constantPool.length);
    }
    */
    this.symbolTable.putConstantPool(result);
    
    int mask = (version & 0xFFFF) < Opcodes.V1_5 ? Opcodes.ACC_SYNTHETIC : 0;
    //添加類標識位,當前類索引,父類索引
    result.putShort(this.accessFlags & ~mask).putShort(this.thisClass).putShort(this.superClass);
    //添加接口長度
    result.putShort(this.interfaceCount);
        //添加接口數(shù)組
    for(int i = 0; i < this.interfaceCount; ++i) {
    result.putShort(this.interfaces[i]);
    }
        
        //添加字段長度u2
    result.putShort(fieldsCount);
        //循環(huán)添加字段信息
    for(fieldWriter = this.firstField; fieldWriter != null; fieldWriter = (FieldWriter)fieldWriter.fv) {
        fieldWriter.putFieldInfo(result);
    }
        
        //添加方法長度
    result.putShort(methodsCount);
    boolean hasFrames = false;
    boolean hasAsmInstructions = false;

    for(methodWriter = this.firstMethod; methodWriter != null; methodWriter = (MethodWriter)methodWriter.mv) {
        hasFrames |= methodWriter.hasFrames();
        hasAsmInstructions |= methodWriter.hasAsmInstructions();
        methodWriter.putMethodInfo(result);
    }
        
        //添加屬性長度
    result.putShort(attributesCount);
    
    ···
    //添加屬性表
    if (this.firstAttribute != null) {
        this.firstAttribute.putAttributes(this.symbolTable, result);
    }
  • 添加數(shù)據(jù):
  1. 創(chuàng)建大小為size的字節(jié)集合對象ByteVector
  2. 按照classFile格式由前往后依次添加元素
返回byte數(shù)據(jù)
  • 將獲取到的byte返回后重新寫入文件
public byte[] toByteArray() {
        ...
    // Third step: replace the ASM specific instructions, if any.
    if (hasAsmInstructions) { //如果有ASM特定說明,需要替換為JVM字節(jié)碼,否則JVM不認識的
      return replaceAsmInstructions(result.data, hasFrames);
    } else {
      return result.data;
    }
}

應(yīng)用

  • 通過以上分析,我們對ClassReader,ClassWriter,ClassVisitor運行原理有所了解,那有什么應(yīng)用點呢?這里我們解決三個問題:無狀態(tài)轉(zhuǎn)換:查找方法,替換方法,有狀態(tài)轉(zhuǎn)換:獲取Map<String , T>添加key集合

無狀態(tài)轉(zhuǎn)換

  • 轉(zhuǎn)換是局部的,不會依賴于在當前指令之前訪問的指令
查找方法
  • 給定一個方法包括(類信息,方法名及方法描述符)查找所有調(diào)用的地方,輸出類和方法集合
  1. 創(chuàng)建自定義MethodFindRefVisitor繼承自ClassVisitor,重寫visitMethod()判斷code中是否調(diào)用了指定查找信息
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
    boolean isAbstractMethod = (access & ACC_ABSTRACT) != 0;
    boolean isNativeMethod = (access & ACC_NATIVE) != 0;
    if (!isAbstractMethod && !isNativeMethod) {
        return new MethodFindRefAdaptor(api, null, owner, name, descriptor);
    }
    return null;
}
  1. 創(chuàng)建自定義MethodFindRefAdaptor繼承自MethodVisitor,對方法體code進行解析重寫visitMethodInsn判斷是否調(diào)用查找方法
@Override
public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
    // 首先,處理自己的代碼邏輯,判斷當前方法體中調(diào)用了指定查找的方法,則存儲當前類和方法信息
    if (methodOwner.equals(owner) && methodName.equals(name) && methodDesc.equals(descriptor)) {
        String info = String.format("%s.%s%s", currentMethodOwner, currentMethodName, currentMethodDesc);
        if (!resultList.contains(info)) {
        resultList.add(info);
        }
    }

    // 其次,調(diào)用父類的方法實現(xiàn)
    super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
}
  1. 在合適時機對獲取數(shù)據(jù)解析輸出打印或文件中
替換方法
  • 若想替換掉某一條instruction,應(yīng)該如何實現(xiàn)呢?當然是首先找到該instruction,然后在同樣的位置替換成另一個instruction就可以啦!

    • 需要特別注意: 在替換instruction過程中,operand stack(棧楨數(shù)據(jù))在修改前和修改后是一致的
  • 沒有什么比示例更加方便的說明以上operand stack前后保持一致含義

public class HelloWorld {

    public void test(int a, int b){
        add(a , b); //調(diào)用非靜態(tài)方法
        getDesc("HelloWorld"); //調(diào)用靜態(tài)方法
    }
    private int add(int a , int b){
        return a + b;
    }
    public static String getDesc(String clazzName){
        return "current class " + clazzName;
    }
}

//輸出字節(jié)碼如下
public void test(int, int);
    descriptor: (II)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=3  //局部變量表中位置: 0:this; 1:a ; 2:b
         0: aload_0   //加載this到操作數(shù)棧
         1: iload_1   //加載a到操作數(shù)棧
         2: iload_2   //加載b到操作數(shù)棧
         3: invokespecial #2                  // Method add:(II)I  調(diào)用非靜態(tài)方法add
         6: pop
         7: ldc           #3                  // String HelloWorld
         9: invokestatic  #4                  // Method getDesc:(Ljava/lang/String;)Ljava/lang/String;
        12: pop
        13: return
        
//asm代碼如下
{
  methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "test", "(II)V", null, null);
  methodVisitor.visitCode();
  methodVisitor.visitVarInsn(ALOAD, 0);
  methodVisitor.visitVarInsn(ILOAD, 1);
  methodVisitor.visitVarInsn(ILOAD, 2);
  methodVisitor.visitMethodInsn(INVOKESPECIAL, "sample/HelloWorld", "add", "(II)I", false); //調(diào)用non-static add方法,上面三個visitVarInsn方法是方法所需參數(shù)
  methodVisitor.visitInsn(POP);
  
  methodVisitor.visitLdcInsn("HelloWorld");
  methodVisitor.visitMethodInsn(INVOKESTATIC, "sample/HelloWorld", "getDesc", "(Ljava/lang/String;)Ljava/lang/String;", false); //調(diào)用static getDesc需要一個參數(shù)
  methodVisitor.visitInsn(POP);
  methodVisitor.visitInsn(RETURN);
  methodVisitor.visitMaxs(3, 3);
  methodVisitor.visitEnd();
}
  • Java虛擬機規(guī)范中定義如下:
    • invokespecial: 在操作數(shù)棧中obj之后必須跟隨n個參數(shù)值,他們的數(shù)量,數(shù)據(jù)類型和順序都必須與方法描述符保持一致,若調(diào)用不是本地方法,n個參數(shù)值和obj將從操作數(shù)棧中出棧,方法調(diào)用時,將在java虛擬機棧中創(chuàng)建一個新的棧楨,obj和連續(xù)的n個參數(shù)值將存儲到新棧楨的局部變量表中,obj存儲到局部變量表0,n個參數(shù)依次往后,新棧楨創(chuàng)建后就成為當前棧楨,JVM的PC寄存器指向待調(diào)用方法中首條指令,程序就從這里開始繼續(xù)執(zhí)行;
    • invokestatic: 在操作數(shù)棧中必須包含連續(xù)n個參數(shù)值,這些參數(shù)的數(shù)量,數(shù)據(jù)類型和順序都必須遵循實例方法的描述符,若調(diào)用不是本地方法,n個參數(shù)值將從操作數(shù)棧中出棧,方法調(diào)用時,將在java虛擬機棧中創(chuàng)建一個新的棧楨,連續(xù)的n個參數(shù)值將存儲到新棧楨的局部變量表中,新棧楨創(chuàng)建后就成為當前棧楨,JVM的PC寄存器指向待調(diào)用方法中首條指令,程序就從這里開始繼續(xù)執(zhí)行;
  • invokespecial調(diào)用add方法時會將已經(jīng)加載到操作數(shù)棧上的b , a, this依次出棧消耗掉,因此當我們替換該instruction時的方法也是需要消耗掉操作數(shù)棧上的 b, a, this數(shù)據(jù),否則后續(xù)執(zhí)行可能會報錯;
  • invokestatic調(diào)用之前只執(zhí)行了ldc將常量池中3位置加載到操作數(shù)棧后執(zhí)行g(shù)etDesc方法,只消耗了該String對象;
  • 使用工具類AnalyzerAdapter 源碼對上方test方法的每行Instruction執(zhí)行時棧楨中局部變量表和操作數(shù)棧數(shù)據(jù)如下
test:(II)V                                         局部變量表         | 操作數(shù)棧
                               // {this, int, int} | {}
0000: aload_0                  // {this, int, int} | {this}
0001: iload_1                  // {this, int, int} | {this, int}
0002: iload_2                  // {this, int, int} | {this, int, int} 
0003: invokespecial   #2       // {this, int, int} | {int} //將操作數(shù)棧上數(shù)據(jù)消耗掉后存儲返回值
0006: pop                      // {this, int, int} | {}
0007: ldc             #3       // {this, int, int} | {String}
0009: invokestatic    #4       // {this, int, int} | {String} //消耗掉String后存儲返回值
0012: pop                      // {this, int, int} | {}
0013: return                   // {} | {}
  • 通過以上觀察,對方法替換分為靜態(tài)方法和非靜態(tài)方法
//自定義MethodReplaceInvokeAdapter extends MethodVisitor替換visitMethodInsn方法
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    if (oldOwner.equals(owner) && oldMethodName.equals(name) && oldMethodDesc.equals(desc)){
        //這里是我們自定義替換的方法
        super.visitMethodInsn(newOpcode , newOwner , newMethodName , newMethodDesc , false);
    }else{
        super.visitMethodInsn(opcode, owner, name, desc, itf);
    }
}

/**
* 調(diào)用區(qū)別newMethodDesc方法描述符是否需要消耗掉this
*/
//靜態(tài)方法替換,描述符中不添加this
 ClassVisitor cv = new MethodReplaceInvokeVisitor(Opcodes.ASM9, cw,
                "sample/HelloWorld", "getDesc", "(Ljava/lang/String;)Ljava/lang/String;",
                Opcodes.INVOKESTATIC, "com/asm/method/ReplaceMethodManager", "getDesc", "(Ljava/lang/String;)Ljava/lang/String;"); 
//非靜態(tài)方法替換
ClassVisitor cv = new MethodReplaceInvokeVisitor(Opcodes.ASM9, cw, "sample/HelloWorld", "add", "(II)I", Opcodes.INVOKESTATIC, "com/asm/method/ReplaceMethodManager", "add", "(Lsample/HelloWorld;II)I");
  • 由于替換方法由我們定義,一般使用的是靜態(tài)方法newOpcode設(shè)為Opcodes.INVOKESTATIC,最后一個參數(shù)是boolean isInterface,若為true表示調(diào)用為一個接口里的方法,若為false表示調(diào)用為一個類中方法,由于操作我們擁有自主權(quán),一般寫成在一個類里的static方法

有狀態(tài)轉(zhuǎn)換

  • The stateful transformation require memorizing some state about the instructions that have been visited before the current one. This requires storing state inside the method adapter 有狀態(tài)轉(zhuǎn)換需要記住一些在當前指令之前訪問過的指令狀態(tài),這需要在方法適配器中存儲狀態(tài)
  • 官方示例:
  1. 刪除指令: 移除ICONST_0 IADD。例如,int d = c + 0;與int d = c;兩者效果是一樣的,所以+ 0的部分可以刪除掉
  2. 刪除指令: 移除ALOAD_0 ALOAD_0 GETFIELD PUTFIELD。例如,this.val = this.val;,將字段的值賦值給字段本身,無實質(zhì)意義
  • 為何說stateless transformation實現(xiàn)起來比較容易,而stateful transformation會實現(xiàn)起來比較困難呢?
    • stateless transformation不依賴于其他Instruction,只需關(guān)注自身,因此實現(xiàn)起來比較簡單
    • stateful transformation 依賴其他一條或多條Instruction同時判斷,多個指令是一個組合,不能輕易拆散。
  • 實現(xiàn)步驟一般分為三步:
    1. 將問題轉(zhuǎn)換成Instruciton指令,然后對多個指令組合的特征或遵循的模式進行總結(jié);
    2. 根據(jù)總結(jié)的特征或模式對指令進行識別,在識別的過程中,每一條Instruction的加入都會引起原有狀態(tài)state的變化,這就是stateful的含義;
    3. 識別成功之后,要對Class文件進行轉(zhuǎn)換,這就對應(yīng)transformation部分,無非就是對Instruction的內(nèi)容進行增刪改等操作;
  • 如何根據(jù)識別記錄狀態(tài)(state)變化呢,這里就要用到state machine(狀態(tài)機)
state machine
  • 有限狀態(tài)機(Finite-state machine,FSM),又稱有限狀態(tài)自動機,簡稱狀態(tài)機,是表示有限個狀態(tài)以及在這些狀態(tài)之間的轉(zhuǎn)移和動作等行為的數(shù)學(xué)模型 , FSM是一種算法思想,簡單而言,有限狀態(tài)機由一組狀態(tài)、一個初始狀態(tài)、輸入和根據(jù)輸入及現(xiàn)有狀態(tài)轉(zhuǎn)換為下一個狀態(tài)的轉(zhuǎn)換函數(shù)組成,其作用主要是描述對象在它的生命周期內(nèi)所經(jīng)歷的狀態(tài)序列,以及如何響應(yīng)來自外界的各種事件。

  • 對于ASM來說,一般設(shè)定一個統(tǒng)一原始的state machine,這里命名為MethodPatternAdapter類

    • class info:MethodPatternAdapter抽象類,繼承自MethodVisitor
    • Fields: 其中定義兩個字段,一個常量SEEN_NOTHING表示“初始狀態(tài)” ,一個state字段用于記錄不斷變化的狀態(tài)
    • Methods:MethodPatternAdapter類中定義的visitXxxInsn()方法,都會去調(diào)用一個自定義的visitInsn()方法:該方法是一個抽象方法,作用就是讓所有其他狀態(tài)(state)都回歸到“初始狀態(tài)”;
  1. 創(chuàng)建MethodPatternAdapter抽象類,visitXxxInsn方法中調(diào)用visitInsn
public abstract class MethodPatternAdapter extends MethodVisitor {
    protected final static int SEEN_NOTHING = 0; //初始狀態(tài)
    protected int state; //記錄狀態(tài)變化

    public MethodPatternAdapter(int api, MethodVisitor methodVisitor) {
        super(api, methodVisitor);
    }

        @Override
    public void visitLdcInsn(Object value) {
        visitInsn();
        super.visitLdcInsn(value);
    }
    
    @Override
    public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
        visitInsn();
        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
    }
    ......
    
     protected abstract void visitInsn();
  1. 將問題轉(zhuǎn)換成Instruciton指令,然后對多個指令組合的特征或遵循的模式進行總結(jié):如下Hybrid添加action示例中如何獲取action列表
Map<String, Class<? extends RegisteredActionCtrl>> actions = new HashMap<>();
actions.put(CarPublishBackParser.ACTION, CarPublishBackActionCtrl.class);
actions.put(CarPublishGuideParser.ACTION, CarPublishGuideActionCtrl.class);
Hybrid.add(actions);

//轉(zhuǎn)換為asm代碼如下
methodVisitor.visitCode();
methodVisitor.visitTypeInsn(NEW, "java/util/HashMap");
methodVisitor.visitInsn(DUP);
methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/util/HashMap", "<init>", "()V", false);
methodVisitor.visitVarInsn(ASTORE, 0);
methodVisitor.visitVarInsn(ALOAD, 0);

//添加第一個
methodVisitor.visitLdcInsn("publish_car_go_back");
methodVisitor.visitLdcInsn(Type.getType("Lcom/wuba/carLib/manager/CarPublishBackActionCtrl;"));
methodVisitor.visitMethodInsn(INVOKEINTERFACE, "java/util/Map", "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", true);

methodVisitor.visitInsn(POP);
methodVisitor.visitVarInsn(ALOAD, 0);

//添加第二個
methodVisitor.visitLdcInsn("show_publish_leadingpage");
methodVisitor.visitLdcInsn(Type.getType("Lcom/wuba/carLib/manager/CarPublishGuideActionCtrl;"));
methodVisitor.visitMethodInsn(INVOKEINTERFACE, "java/util/Map", "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", true);

methodVisitor.visitInsn(POP);

a. 通過以上觀察,對于actions.put方法調(diào)用實際上是三個instruction:visitLdcInsn(key), visitLdcInsn(value),visitMethodInsn

b. 總共調(diào)用三個方法,我們只需要在添加兩個狀態(tài)即可滿足,狀態(tài)轉(zhuǎn)換如下圖所示

//調(diào)用visitLdcInsn(key)后的狀態(tài)
private static final int SEEN_LDCTYPE = 1
//調(diào)用visitLdcInsn(value)后的狀態(tài)
private static final int SEEN_LDC_METHOD = 2
state_transforme.png
  1. 狀態(tài)轉(zhuǎn)移清楚后,寫代碼就很輕松了
public class ReadActionMapMethod extends MethodPatternAdapter {

    /**
     * 需要校驗的字節(jié)碼順序:Map.put()/HashMap.put()
     */
    private static final int SEEN_LDCTYPE = 1

    private static final int SEEN_LDC_METHOD = 2
    //當前存儲業(yè)務(wù)線
    private String businessLine
    /**
     * 當前第一步緩存數(shù)據(jù)
     */
    private String mMapKey
    /**
     * 當前第二步緩存數(shù)據(jù)
     */
    private Type mMapValue

    protected ReadActionMapMethod(int api, MethodVisitor methodVisitor, String businessLine) {
        super(api, methodVisitor)
        this.businessLine = businessLine
    }

    @Override
    void visitLdcInsn(Object value) {

        switch (state) {
            case SEEN_NOTHING:
                if (value instanceof String) {
                    state = SEEN_LDCTYPE
                    mMapKey = value
                    mv.visitLdcInsn(value)
                    return
                }
                break
            case SEEN_LDCTYPE:
                if (value instanceof Type) {
                    state = SEEN_LDC_METHOD
                    mMapValue = value
                    mv.visitLdcInsn(value)
                    return
                }
                break

        }
        super.visitLdcInsn(value)
    }

    @Override
    protected void visitInsn() {
//        switch (state) {
//
//            case SEEN_LDCTYPE:
//                //將攔截的數(shù)據(jù)發(fā)送出去
//                mv.visitLdcInsn(mMapKey)
//                break
//
//            case SEEN_LDC_METHOD:
//                //將攔截的數(shù)據(jù)發(fā)送出去
//                mv.visitLdcInsn(mMapKey)
//                mv.visitLdcInsn(mMapValue)
//                break
//        }
        state = SEEN_NOTHING

    }

    @Override
    void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
        switch (state) {
            case SEEN_LDC_METHOD:
                boolean flag = ((opcode == Opcodes.INVOKEVIRTUAL && owner == "java/util/HashMap" && name == "put")
                        || (opcode == Opcodes.INVOKEINTERFACE && owner == "java/util/Map" && name == "put"))
                if (flag) { //需要存儲數(shù)據(jù)啦
                    HybridActionManager.install.addAction(businessLine, mMapKey)
                }
                break
        }
        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)

    }

思考:

  1. 當前只是檢查三個狀態(tài),如何提升準確性?
  • 存儲上方獲取的String -> Type對應(yīng)值(類的符號引用),在此掃描獲取Type class文件,解析其是否實現(xiàn)Action接口RegisteredActionCtrl,若實現(xiàn)該接口在添加,提高準確性;若想了解這部分內(nèi)容,請聯(lián)系我提供demo事例中對RN module字段獲取內(nèi)容,先獲取具體類型再根據(jù)是否添加類注解,重寫getName方法等確定字段值;
@Override
protected List<ModuleSpec> createWubaNativeModules(final ReactApplicationContextWrapper reactApplicationContext) {
       List<ModuleSpec> moduleSpecList = new ArrayList<ModuleSpec>();
       moduleSpecList.add(new ModuleSpec(new Provider<NativeModule>() {
           @Override
           public NativeModule get() {
               return new WBSingleSelector(reactApplicationContext);
           }
       },WBSingleSelector.class.getName()));
return moduleSpecList;

//解析字節(jié)碼指令:校驗狀態(tài)共計四步
methodVisitor.visitLdcInsn(Type.getType("Lcom/wuba/wubaaction/rn/selector/WBMultiUnlinkSelector;"));
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false);
methodVisitor.visitMethodInsn(INVOKESPECIAL, "com/facebook/react/bridge/ModuleSpec", "<init>", "(Ljavax/inject/Provider;Ljava/lang/String;)V", false);
methodVisitor.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "add", "(Ljava/lang/Object;)Z", true);

//viewModule注入
@Override
  protected List<WubaViewManager> createWubaViewManagers(ReactApplicationContextWrapper reactApplicationContext) {
          List<WubaViewManager> list = new ArrayList<WubaViewManager>();
          list.add(new WBPublishLoadingView());
          list.add(new WBErrorView());
          return list;
      }
      
  //解析字節(jié)碼指令:校驗狀態(tài)共計四步
  methodVisitor.visitTypeInsn(NEW, "com/wuba/wubaaction/rn/selector/view/WBPublishLoadingView");
  methodVisitor.visitInsn(DUP);
  methodVisitor.visitMethodInsn(INVOKESPECIAL, "com/wuba/wubaaction/rn/selector/view/WBPublishLoadingView", "<init>", "()V", false);
  methodVisitor.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "add", "(Ljava/lang/Object;)Z", true);
  1. 當引用一些業(yè)務(wù)庫中對異常只簡單捕獲并未輸出堆棧信息導(dǎo)致問題查找困難時,也可以嘗試使用ASM對catch()函數(shù)校驗后增加自定義邏輯:輸出堆棧信息或調(diào)用自定義方法等;
    catch.png

總結(jié)

  • ASM操作字節(jié)碼文件,其前提是熟記classFile文件格式,其各個數(shù)據(jù)項嚴格按順序排列,沒有任何分隔符,對照該文件格式在看ASM中的各個操作才能一目了然;
  • 若文章對你有幫助,歡迎添加好友一起交流!

參考資料

  1. 《深入理解Java虛擬機:JVM高級特性與最佳實踐(第3版)》周志明
  2. ASM4使用中英文手冊 中文版 英文版
  3. jvm字節(jié)碼指令集
  4. Oracle: The Java Virtual Machine Specification, Java SE 8 Edition
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容