软妹手把手教你 javap 反编译分解代码,授人以鱼不如授人以渔

 2023-02-17
原文作者:小龙飞 原文地址:https://juejin.cn/post/6875156481499463694

Toc

Class文件结构

我之前写了一篇关于class文件重要性的,并且从宏观角度解释了下class文件的构成,文章直通车(www.juejin.im/post/684490…)

这篇我们就深入的了解一下class文件的各项内容,先看看字节码的样子。

202301011707189301.png

202301011707194832.png 以下对字节码的分析,就以这个简单的例子为主。所有的字节码都是cafe babe开头,JAVA一直给咖啡代言,可说是咖啡的忠实粉丝了,就像我爱大幂幂一样,撒花~

202301011707213303.png

class文件可真是个小机灵鬼,正是class文件+JVM组合,各种语言编写的代码只要能编译成JVM可以 正确识别的class文件,就可以运行在JVM上面,才使得JAVA语言乃至所有可以运行在JVM上的语言实现了平台无关性,JVM更是可以向语言无关性发展,class文件的使命就是教JVM怎么运行,运行什么

class 文件是一组以8位字节为基础单位的十六进制流,中间没有任何分隔符,细细品这句话。正是因为class文件是流式的,中间没有任何分隔符所以class文件里面的数据项在顺序和数量上面是严格限定的,每个字节的含义,长度,先后顺序,都不允许改变,因为JVM靠的就是上面所说的长度,先后顺序等这些信息来翻译class文件,哪些内容是一组信息哪些符号是另外一组信息,清楚了这一点,我们再来看class文件的设计就会更加的清晰啦~。

class文件采用类似于C语言结构体的伪结构体来存储数据,class文件是包含了虚拟机指令,符号表以及其他辅助信息这三大内容,还是这张表,包含了任意class文件的所有内容。

202301011707223244.png

简单介绍一下class文件结构的这张表内容:

两种数据类型:无符号数和表

  1. 无符号数属于基本数据类型(Java类中也有基本数据类型),以 u1,u2,u4,u8这种来代表1个字节,2个字节,4个字节,8个字节的无符号数,可以用来描述数字,索引引用,数量值或者字符串值;
  2. 表就跟Java类中的对象引用类型一样,对象属性可以是基本数据类型(对应U1,U2无符号数),也可以是其他的对象(对应其他的表),Java工程项目中参数实体通常以"_Param"结尾(class文件的表都习惯以“_info”结尾);
  3. 上图中的顺序,就是Class文件严格要求的顺序;
  4. 各个计数器主要是用来描述表里面数据个数,例如方法计数器的值是methods_count,代表方法表method——info里面有“methods_count”个方法;

JAVA代码千变万化,然而所有的内容却都归纳在了区区一张表里面?弄不懂这张表,誓不当程序员!!!但愿不会啪啪打脸。

202301011707230585.png

Class魔数和版本

每个Class文件的头4个字节成为魔数(Magic Number),它唯一的作用是确定这个文件是否为一个能被虚拟机接受的Class文件。值为:0xCAFEBABE(咖啡宝贝)

202301011707240016.png

紧接魔数的4个字节是Class文件的版本号: 第5-6字节是次版本号(Minor Version), 第7-8字节是主版本号(Major Version)

202301011707245637.png

    J2SE 8 = 52 (0x34 hex)
    J2SE 7 = 51 (0x33 hex)
    J2SE 6.0 = 50 (0x32 hex)
    J2SE 5.0 = 49 (0x31 hex)
    JDK 1.4 = 48 (0x30 hex)
    JDK 1.3 = 47 (0x2F hex)
    JDK 1.2 = 46 (0x2E hex)
    JDK 1.1 = 45 (0x2D hex)

这是十六进制分别对应的JDK版本号,十六进制的34换算成十进制是52,对应jdk1.8,由于本人用的是JDK1.8所以此处是34。高版本的JDK能向下兼容低版本的class文件,但不能运行比他高版本的 class文件。

202301011707250628.png

常量池

常量池代表Class文件中的仓库资源,紧接着主次版本号之后就是常量池入口,由于常量池中常量的数据 是不固定的,所以在常量池的入口放置了一项u2类型的数据,代表常量池容量计数值,从1开始,字节码里面是0x002d(即十进制的45个,代表有44项常量,索引值范围1~44,第0项空了出来,这样做目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表示 不引用任何一个常量池项目 的目的)。

常量池主要存放两大类常量:字面量;符号引用。

  1. 字面量接近Java语言层面的常量概念,如文本字符串、声明为final的常量值等;
  2. 符号引用包含三类常量:
  • 类和接口的全限定名 org.springframework.....Bean
  • 字段的名称和描述符 private/public/protected
  • 方法的名称和描述符 private/public/protected

202301011707263989.png u1类型表示的标志tag(1~18)代表当前这个常量属于哪种常量类型,如10代表了类中方法的符号引用,回到我们的字节码截图里面,他的标志位是0x0a,对应到表中就是10即此类型的常量代表一个类中方法的符号引用。

看图哇事,这玩意繁琐又多,且都是_info结尾,每一项都有自己的结构,主要是字面量,和字段,类,接口方法的符号引用,谁都往里参合了一脚啊这是。

2023010117072720210.png

2023010117072799111.png

标志位为10的 CONSTANT_Methodref_info 的结构

    类型    名称    		数量
    u1     tag    			 1
    u2     name_index		 1
    u2     name_index        1

name_index就是图中的index,是一个索引值代表了这个类或者接口的全限定名,字节码中name_index都占2个u,的值分别是0x0009(十进制值为9),0x001d(十进制值为29),根据表可知分别是指向声明方法的类描述符以及指向名称及类型描述符的索引;

然后字节码是0x09,查表得知此9代表字段的符号引用Fieldref,结构和CONSTANT_Methodref_info一样,依次推算可得到所有的44个常量的内容以及索引。

这里借助javap看看其他的情况,javap -verbose TestJVM

    Classfile /Users/zengzhiqin/Desktop/daima/leetcode/out/production/leetcode/TestJVM.class
      Last modified 2020-9-20; size 731 bytes
      MD5 checksum 73a774d54f51805cb2319a2133c47c04
      Compiled from "TestJVM.java"
    public class TestJVM
      minor version: 0
      major version: 52
      flags: ACC_PUBLIC, ACC_SUPER
    Constant pool:
       #1 = Methodref          #9.#29         // java/lang/Object."<init>":()V
       #2 = Fieldref           #5.#30         // TestJVM.a:I
       #3 = Fieldref           #5.#31         // TestJVM.b:I
       #4 = Fieldref           #32.#33        // java/lang/System.out:Ljava/io/PrintStream;
       #5 = Class              #34            // TestJVM
       #6 = Methodref          #5.#29         // TestJVM."<init>":()V
       #7 = Methodref          #5.#35         // TestJVM.multi:()I
       #8 = Methodref          #36.#37        // java/io/PrintStream.println:(I)V
       #9 = Class              #38            // java/lang/Object
      #10 = Utf8               a
      #11 = Utf8               I
      #12 = Utf8               b
      #13 = Utf8               <init>
      #14 = Utf8               ()V
      #15 = Utf8               Code
      #16 = Utf8               LineNumberTable
      #17 = Utf8               LocalVariableTable
      #18 = Utf8               this
      #19 = Utf8               LTestJVM;
      #20 = Utf8               add
      #21 = Utf8               ()I
      #22 = Utf8               multi
      #23 = Utf8               main
      #24 = Utf8               ([Ljava/lang/String;)V
      #25 = Utf8               args
      #26 = Utf8               [Ljava/lang/String;
      #27 = Utf8               SourceFile
      #28 = Utf8               TestJVM.java
      #29 = NameAndType        #13:#14        // "<init>":()V
      #30 = NameAndType        #10:#11        // a:I
      #31 = NameAndType        #12:#11        // b:I
      #32 = Class              #39            // java/lang/System
      #33 = NameAndType        #40:#41        // out:Ljava/io/PrintStream;
      #34 = Utf8               TestJVM
      #35 = NameAndType        #22:#21        // multi:()I
      #36 = Class              #42            // java/io/PrintStream
      #37 = NameAndType        #43:#44        // println:(I)V
      #38 = Utf8               java/lang/Object
      #39 = Utf8               java/lang/System
      #40 = Utf8               out
      #41 = Utf8               Ljava/io/PrintStream;
      #42 = Utf8               java/io/PrintStream
      #43 = Utf8               println
      #44 = Utf8               (I)V

对照一下可知,前面两个常量和我们算到的结果一致,我们看到图中出现了很多I,V,《init》,LineNumberTable等非人类能理解在代码里面也从未出现过的东西,这些都会被后面要说到的字段表,方法表,属性表引用到,用来描述一些不可名状的东西,不方便用固定字节表示的内容,例如方法的返回值是什么,有几个参数,每个参数类型是啥等等,也就是这些不确定的东西需要常量表的符号引用进行表达。

添加一个方法时,常量池中会增加4个常量;同理,添加字段也是如此,添加的内容有

  1. CONSTANT_Methodref_info 方法的符号引用
  2. 方法符号引用指向的CONSTANT_NameAndType_info 方法的部分符号引用
  3. 方法的名称
  4. 方法的描述符

访问标志

紧接着常量池之后的两个字节代表访问标志(access_flags),用于识别一些类或者接口层次的 访问信息,包括:这个Class是类还是接口、是否为public类型、是否为abstract类型、类是否声 明为final等。标志位及其含义如下表:

2023010117072886112.png

TestJVM这个类仅仅被public修饰了,因此其他的标志都为假,最终access_flags应为 0x0001|0x0020=0x0021,字节码中值内容确实是这个。

2023010117072960113.png

类索引、父类索引与接口索引集合

访问标志之后顺序排列类索引(this)、父类索引(super)、接口索引集合(interfaces)。 Class文件由这三项来确定这个类的集成关系。

2023010117073065514.png 类索引和父类索引 引用2个u2类型的索引值表示,他们各自指向一个类型为CONSTANT_Class_info 的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串,从而找到类。

  1. 类索引和父类索引都是u2类型的数据。

    2023010117073195715.png

JAVAP里面看到的这两个索引,分别是此类继承自Object基类,就无别的继承关系了。

2023010117073328816.png

  1. 接口索引集合入口第一项是u2类型的接口计数器(interfaces_count)表示索引表的容量(即实现了几个接口)。如果该类没用实现任何接口,则计数器值为0,后面的接口索引表不再占用任何字节,0x0000因为此类没有实现任何接口。

字段表集合

看到这里已经很累了吧,我写的都累了,安利给读者们一首小阿七的歌《不谓侠》,很好听啊~

接口索引集合后边的是字段计数器:用于标识有多少个字段,接着就是字段表集合。 字段表(field_info)用于描述接口或者类中声明的变量。

字段包括类级变量以及实例级变量。可以包括的信息有:

  1. 字段的作用域(public、private、protected修饰符)
  2. 实例变量还是类变量(static修饰符)
  3. 可变性(final)
  4. 并发可见性(volatile)
  5. 可否被序列化(transient)
  6. 字段数据类型(基本类型,对象,数组)
  7. 字段名称

各个修饰符都是布尔值,要么有要么没有,这个可以使用标志位表示;但字段叫什么名字、字段被定义成什么类型,都是无法固定的,所以只能引用常量池中的常量来描述。由字段的这些内容信息,抽象得到如下的字段表结构:

2023010117073403217.png name_index和descriptor_index都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符,关于全限定名、简单名称及描述符的区别

  1. 全限定名

ai/yunxi/vm/TestClasss类的全限定名, 仅仅是把类中的“.”替换成了“/”

  1. 简单名称

没有类型和参数修饰的方法或者字段名称 如:add()和int m简单名称就是:add、m

  1. 描述符

用来描述字段的数据类型、方法的参数列表(数量、类型及顺序)和返回值

字段访问标志

2023010117073461218.png

字节码中0x0002代表字段为private,字节码代表如下:

    u2                 0x0002 -> 第一个跟着的是fields_count,这个类只有2个字段表数据
    
    以下是字段表内容:
    u2                 0x0002   private为真,其他为假
    u2                 0x000a   字段名称name_index,由上面JAVAP常量表可知#10为a
    u2                 0x000b   字段描述符descriptor_index,由上面JAVAP常量表可知#11指								向常量池字符串I,这个描述符标识字符含义 标识基本类型int
    u2                 0x0000   attribute_count 属性表集合无属性,为0表示没有额外描述的信息
    attribute_info	   上面无内容,不占字节

描述符标识字符含义,上面的标识基本类型为I,即对应的下面表的基本类型int

2023010117073532119.png

由上面这些信息可以推导源代码定义的字段为 private int a;和源码相符。

方法表集合

懂了字段表之后,方法表结构几乎和字段表结构是一模一样的,通过访问标志、名称索引、描述符索引可清楚的表达方法的定义。除了一些标志位不同,毕竟有些修饰符可以修饰方法不能修饰字段,有些修饰符可以修饰字段但是方法没有,内容如下对比字段表标志有添加有删减有相同:

2023010117073607720.png

重载(Overload)一个方法:

  1. 要与原方法具有相同的简单名称
  2. 要与原方法有不同的特征签名(特征签名就是一个方法中各个参数在常量池中字段符号的引用集合,因为返回值不在特征签名里面,所以返回值不同作为重载条件)

2023010117073753321.png

    u2                 0x0004 -> 第一个跟着的是方法数量,这个类有4个方法表数据,分别是									add(),multi(),main()和构造器方法
    
    以下是第一个方法表内容:
    u2                 0x0001   public为真,其他为假
    u2                 0x000d   方法简单名称name_index,由上面JAVAP常量表可知#14为()v,v由描述							   符含义可知是特殊类型void,()代表无参数,即构造函数
    u2                 0x000e   方法描述符descriptor_index,由上面JAVAP常量表可知#15指									Code,Code之后再讲								
    u2 	   			   0x0001   attribute_count,属性表集合有一项属性用于存储一些额外信息
    attribute_info     0x000f   由JAVAP看到的指令,指向#15,即对应常量”Code“,说明此属性是方法的
    							字节码描述

2023010117073811822.png 第一个方法:

2023010117073877023.png

属性表集合

讲了大半年,还只是讲了字段,方法头这些内容可以通过访问标志,名称索引,方法描述符来表达清楚,这些都是些元数据,那么方法体上哪去了呢?这就要属性表出山啦!

有眼力见的朋友可能已经讲字段表和方法表的时候,就发现了属性表的踪影,用来描述某些场景专有信息的,与上面讲到的其他的数据项目不同的是,其他数据项目要求严格的顺序,长度和内容,属性表的限制是放养状态,不要求各个属性表具有严格的顺序,只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,JVM会运行时会忽略掉他不认识的。

Java程序方法体中的代码经过Javac编译处理后,最终变为字节码指令存储在Code属性中,Code属性出现在方法表的属性集合之中。但并非所有方法表都有Code属性,例如抽象类或接口。

code属性表结构如图:

2023010117073999824.png

  1. attribute_name_index指向CONSTANT_Utf8_info 类型常量的值固定为“Code”
  2. attribute_length标识属性值的总长度
  3. max_stack代表了操作数栈(Operand Stacks)深度的最大值
  4. max_locals代表了局部变量所表示的存储空间 单位:Slot
  5. code_length和code是用来存储Java源程序编译后产生的字节码指令,codelength代表字节码长度,code是用于存储字节码指令的一系列字节流。字节码指令,每个指令字节码代表的指令含义,是否需参数,是u1类型的单字节,取值范围是0x000xFF,即0255,一共可以表达256条指令,目前JVM规范已经定义了约200条指令了。

属性有很多的,JAVA虚拟机规范预定义了21项,我们平时能看到的都有

2023010117074129525.png

2023010117074239726.png

还是javap -verbose TestJVM 将所有剩下的指令展示出来,可以看到方法的描述和调用

    常量表前面已经贴出了
    {
     public int b;
       descriptor: I
       flags: ACC_PUBLIC
    
     public TestJVM();
       descriptor: ()V
       flags: ACC_PUBLIC
       Code:
         stack=2, locals=1, args_size=1
            0: aload_0
            1: invokespecial #1                  // Method java/lang/Object."<init>":()V
            4: aload_0
            5: iconst_3
            6: putfield      #2                  // Field a:I
            9: aload_0
           10: iconst_4
           11: putfield      #3                  // Field b:I
           14: return
         LineNumberTable:
           line 5: 0
           line 6: 4
           line 7: 9
         LocalVariableTable:
           Start  Length  Slot  Name   Signature
               0      15     0  this   LTestJVM;
    
     public int add();
       descriptor: ()I
       flags: ACC_PUBLIC
       Code:
         stack=2, locals=1, args_size=1
            0: aload_0
            1: getfield      #2                  // Field a:I
            4: aload_0
            5: getfield      #3                  // Field b:I
            8: iadd
            9: ireturn
         LineNumberTable:
           line 10: 0
         LocalVariableTable:
           Start  Length  Slot  Name   Signature
               0      10     0  this   LTestJVM;
    
     public int multi();
       descriptor: ()I
       flags: ACC_PUBLIC
       Code:
         stack=2, locals=1, args_size=1
            0: aload_0
            1: getfield      #2                  // Field a:I
            4: aload_0
            5: getfield      #3                  // Field b:I
            8: imul
            9: ireturn
         LineNumberTable:
           line 14: 0
         LocalVariableTable:
           Start  Length  Slot  Name   Signature
               0      10     0  this   LTestJVM;
    
     public static void main(java.lang.String[]);
       descriptor: ([Ljava/lang/String;)V
       flags: ACC_PUBLIC, ACC_STATIC
       Code:
         stack=3, locals=1, args_size=1
            0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
            3: new           #5                  // class TestJVM
            6: dup
            7: invokespecial #6                  // Method "<init>":()V
           10: invokevirtual #7                  // Method multi:()I
           13: invokevirtual #8                  // Method java/io/PrintStream.println:(I)V
           16: return
         LineNumberTable:
           line 18: 0
           line 20: 16
         LocalVariableTable:
           Start  Length  Slot  Name   Signature
               0      17     0  args   [Ljava/lang/String;
    }
    SourceFile: "TestJVM.java"

可以看到一共四个方法,和我们之前看到字节码推论到的数目一样,args_size都为1,但是无论是实例构造器,还是add(),multi()方法都没有参数,这个的原因是:在任何的实例方法我们知道可以通过this.method()来进行调用,通过this来访问到此方法所属对象,他的实现就是通过javac编译器编译的时候把对this关键字的访问变成对一个普通方法参数的访问,然后再虚拟机调用实例方法时候自动传入此参数,因此在实例方法的局部变量表里面至少会存在一个指向当前对象实例的局部变量,局部变量表也会预留第一个slot位来存放对象实例的引用,其他的方法参数自然靠边站从1开始计算了。

字节码分析,从上面的方法属性表位置开始:

2023010117074382827.png attribute_name_index是一项指向CONSTANT_UTF8_INFO的常量索引,常量值固定为Code,代表了该属性的属性名称。

属性表之异常表

看一段包含异常语法的简单代码:

    /**
     * @author by zengzhiqin
     * 2020-09-13
     */
    public class TestException {
        public int inc() {
            int x;
            try {
                x = 1;
                return x;
            } catch (Exception e) {
                x = 2;
                return x;
            } finally {
                x= 3;
            }
        }
        
    
    }

再看其内容(字节码0~4行做的就是将证书1赋值给变量x,并且将x的值复制一份副本到最后一个本地变量表的slot中,这个slot里面的值再ireturn指令执行前将会被重读到操作栈顶,作为方法返回值使用,这个slot用returnValue表示):

    0: iconst_0        //常量0压入操作数栈
    1: istore_2        //弹出操作数栈栈顶元素,保存到局部变量表第2个位置
    2: iload_0         //第0个变量压入操作数栈顶
    3: iload_1         //第1个变量压入操作数栈顶
    4: iadd            //操作数栈中的前两个int相加,并将结果压入操作数栈顶
    5: istore_2        //弹出操作数栈栈顶元素,保存到局部变量表第2个位置
    6: iload_2         //加载局部变量表的第2个变量到操作数栈顶
    7: ireturn         //返回
    8:aload           //从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶

上面是一些需要用的的指令的相关解释

    zengzhiqin@cengzhiqindeMacBook-Pro  ~/Desktop/daima/leetcode/src  javap -c TestException
    Compiled from "TestException.java"
    public class TestException {
      public TestException();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."<init>":()V
           4: return
    
      public int inc();
        Code:
           0: iconst_1     // try中x=1,1压入操作数栈
           1: istore_1     //将1从操作数栈存储到局部变量表第一个位置,x=1
           2: iload_1     // 加载局部变量表第一个位置元素到操作数栈顶
           3: istore_2    // 弹出操作数栈顶元素1,保存到局部变量表第2个位置
           4: iconst_3     // finally块中x=3,将3压入操作数栈
           5: istore_1     // 弹出栈顶元素3,将其保存到局部变量表第1个位置
           6: iload_2     // 将变量表第2个位置值1放到栈顶,准备给ireturn返回
           7: ireturn      // 正常情况下返回 1 正确吻合~
           
           8: astore_2    // 给catch中定义的 Exception e 赋值,存储在slot 2中
           9: iconst_2    // catch 中 x=2,2压入操作数栈
          10: istore_1    // 弹出栈顶的2,保存到slot 1
          11: iload_1    // 局部变量表第1个位置的2压入栈顶
          12: istore_3   // 弹出栈顶元素2保存到局部变量第3个位置
          13: iconst_3    // finally中x=3,将3压入操作数栈
          14: istore_1    // 将3放到局部变量表第1个位置,准备给ireturn返回
          15: iload_3     // 加载局部变量表第3个位置的值2到栈顶
          16: ireturn     // 返回栈顶元素2  catch异常返回2 正确,吻合
          
          17: astore        4     // 如果出现了不属于java.lang.Exception及其子类异常走到这里
          19: iconst_3    // finally块中x=3,将3压入操作数栈
          20: istore_1     // 将3存储到局部变量表第1个位置      
          21: aload         4     // 将异常引用放在栈顶,并且抛出
          23: athrow              // 抛出异常
        Exception table:
           from    to  target type
               0     4     8   Class java/lang/Exception
               0     4    17   any
               8    13    17   any
              17    19    17   any
    }

这里可初步推测,Java虚拟机执行字节码是基于栈的体系结构,执行过程可以看我上一篇的讲解~懒得贴链接了。

异常的执行过程,finally 代码块会在所有正常及异常的路径上都复制一份,在这段字节码中,iconst_3 就是对应着 finally 代码块,共三份,所以即便在 try 或者 catch 代码块中有 return 语句,最终还是会会执行 finally 代码块中的内容,这段代码毫无疑问是返回1,如果在finally里面加上return X,那么就是返回3了,这个return什么值的原因是这样来滴!!!

我们可以看到异常表,归纳出异常表结构:

2023010117074514728.png

2023010117074579729.png

  • 字节码0-4行所做的操作数就是将整数1赋值给变量x
  • 如果这时没有出现异常,则会继续走到第5-7行
  • 如果出现了异常,PC寄存器指针转到第8行
  • 如果0-4行出现任何异常,则跳转17行
  • 如果8-13行出现任何异常,则跳转17行
  • 如果17-19行出现任何异常,则跳转17行

可知,异常表实际上是JAVA代码的一部分,编译器使用异常表而不是简单命令来实现JAVA异常以及finally处理机制的。

异常是平时最常用的,其他的属性大家有兴趣再去深入了解亦可,写到这里本可爱是真的很累,而你百分之八十的几率是直接跳着看到我这句话的,路过的小哥哥们随手点个赞吧,我是《阿甘的码路》号主阿甘,有兴趣可以关注我呀~~~

2023010117074633230.png