Java字节码文件结构解析

557 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情

本系列专栏:JVM专栏

前言

前面我们大致说了JVM是如何让我们写的Java程序生效的,比如加载、链接和初始化,但是很多细节没有提及到,而这些细节的前提是必须要了解Java字节码即class文件。

为什么这个如此重要呢 理解JVM的运行机制是一方面,熟悉了Java字节码之后,我们可以使用各种方式来修改Java字节码,只要修改后的字节码符合JVM规范即可,而这个技术叫做字节码增强技术,在各种框架、热更新中都有使用,所以了解Java字节码非常重要。

正文

关于这个部分知识,其实我之前有说Android中的dex文件也是这种格式,即都是16进制的文件,里面我们使用了010 Editor工具来查看dex文件,可以查看之前文章:

Android Dex文件详解 - 掘金 (juejin.cn)

而.class文件为什么被称作Java字节码,因为它是16进制值组成,而且JVM以2个16进制值为一组即字节为单位进行读取,所以就称之为Java字节码。

字节码结构

其实字节码结构是固定的,分析字节码和分析dex文件一样,所以我们这里就大概过一遍其中的结构和主要作用。

我先写个简单的代码,然后使用javac进行编译,得到dex文件:

public class ByteCodeDemo{
	private int a = 1;

	public int add(){
		int b = 2;
		int c = a + b;
		System.out.println(c);
		return c;
	}

}

然后其class文件:

image.png

看过前面dex文件解析的同学看这应该很熟悉,就是一大串的十六进制数,而根据JVM规范,这些十六进制数可以分为下面10个部分:

image.png

之前我们的dex文件是使用边分析结构边找到十六进制文件中对应的值,而我们这次分析class文件,直接使用javap指令把class文件反解析为可阅读文件,这样的话也更符合我们平时调试代码的做法。

进行javap -v -p来反解析class文件,得到如下可阅读内容:

public class ByteCodeDemo
  minor version: 0
  major version: 55
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #5                          // ByteCodeDemo
  super_class: #6                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #6.#17         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#18         // ByteCodeDemo.a:I
   #3 = Fieldref           #19.#20        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #21.#22        // java/io/PrintStream.println:(I)V
   #5 = Class              #23            // ByteCodeDemo
   #6 = Class              #24            // java/lang/Object
   #7 = Utf8               a
   #8 = Utf8               I
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               add
  #14 = Utf8               ()I
  #15 = Utf8               SourceFile
  #16 = Utf8               ByteCodeDemo.java
  #17 = NameAndType        #9:#10         // "<init>":()V
  #18 = NameAndType        #7:#8          // a:I
  #19 = Class              #25            // java/lang/System
  #20 = NameAndType        #26:#27        // out:Ljava/io/PrintStream;
  #21 = Class              #28            // java/io/PrintStream
  #22 = NameAndType        #29:#30        // println:(I)V
  #23 = Utf8               ByteCodeDemo
  #24 = Utf8               java/lang/Object
  #25 = Utf8               java/lang/System
  #26 = Utf8               out
  #27 = Utf8               Ljava/io/PrintStream;
  #28 = Utf8               java/io/PrintStream
  #29 = Utf8               println
  #30 = Utf8               (I)V
{
  private int a;
    descriptor: I
    flags: (0x0002) ACC_PRIVATE

  public ByteCodeDemo();
    descriptor: ()V
    flags: (0x0001) 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_1
         6: putfield      #2                  // Field a:I
         9: return
      LineNumberTable:
        line 1: 0
        line 2: 4

  public int add();
    descriptor: ()I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_2
         1: istore_1
         2: aload_0
         3: getfield      #2                  // Field a:I
         6: iload_1
         7: iadd
         8: istore_2
         9: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        12: iload_2
        13: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        16: iload_2
        17: ireturn
      LineNumberTable:
        line 5: 0
        line 6: 2
        line 7: 9
        line 8: 16
}

我们根据这个容易阅读的内容来说一下Java字节码的结构组成。

魔数

所有.class文件的前4个字节都是魔数,其值是固定的:0XCAFEBABE,这是用来判断一个文件是否是.class文件。

版本号

魔数之后的4个字节就是版本号,分别代表次版本号和主版本号,比如上面得到的主版本号是55,也就是Java 11。

常量池

紧接着在版本号后面是常量池,常量池中存储2类常量:字面量和符号引用。字面量为代码中声明为final的常量值,而符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符

这个概念十分重要,尤其是符号引用,因为在前面文章中,我们知道在JVM进行链接前,这些都是符号引用,而在链接解析后才能替换成实际引用,在运行时进行执行,所以这些符号引用有啥,我们来看一下。

常量池部分的数据结构如下:

image.png

这里分为2个部分,分别是计数器和数据区,计数器就是常量池中元素的个数,而数据区中的数据分为下面几种:

image.png

刚开始看的话肯定会一头雾水,这些东西太多了,其实不然,我们就不分析十六进制数据了,我们直接来分析反编译后的就容易理解了,我们来看一下:

Constant pool:
   #1 = Methodref          #6.#17         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#18         // ByteCodeDemo.a:I
   #3 = Fieldref           #19.#20        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #21.#22        // java/io/PrintStream.println:(I)V
   #5 = Class              #23            // ByteCodeDemo
   #6 = Class              #24            // java/lang/Object
   #7 = Utf8               a
   #8 = Utf8               I
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               add
  #14 = Utf8               ()I
  #15 = Utf8               SourceFile
  #16 = Utf8               ByteCodeDemo.java
  #17 = NameAndType        #9:#10         // "<init>":()V
  #18 = NameAndType        #7:#8          // a:I
  #19 = Class              #25            // java/lang/System
  #20 = NameAndType        #26:#27        // out:Ljava/io/PrintStream;
  #21 = Class              #28            // java/io/PrintStream
  #22 = NameAndType        #29:#30        // println:(I)V
  #23 = Utf8               ByteCodeDemo
  #24 = Utf8               java/lang/Object
  #25 = Utf8               java/lang/System
  #26 = Utf8               out
  #27 = Utf8               Ljava/io/PrintStream;
  #28 = Utf8               java/io/PrintStream
  #29 = Utf8               println
  #30 = Utf8               (I)V

我们来看一下 #1 中的数据:

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

这里的类型是 Methodref,由上面可知或者其名字就知道这是方法的符号引用,而根据前面常量池的类型我们可知该类型前2个字节指向声明方法的类描述符的索引,而后2个字节则是名称及类型描述符的索引,所以其值是 #6.#17 这2个也是索引,我们来看一下这2个索引代表的值:

#6 = Class #24 // java/lang/Object

#17 = NameAndType #9:#10 // "<init>":()V

会发现#6的类型是Class,我们立马去查阅一下:类型为Class的是指向全限定名常量项的索引,所以首先它是一个索引,而值是#24代表的值:

#24 = Utf8 java/lang/Object

即#6的Class表示的就是java.lang.Object这个类的索引。

再分析一下#17,它的类型是NameAndType,查阅可知:前2个字节指向该字段或者方法名称常量项的索引,后2个字节指向该字段或者方法描述符常量项的索引,即名称和描述符,我们来看看#9和#10:

#9 = Utf8 <init>

#10 = Utf8 ()V

我们就知道这个方法是<init>,参数是空,返回值是void,这里你或许会对这些V或者啥的简写有点陌生,我们可以查看下图:

这是基本数据类型在JVM内部的符号, image.png

扯远了,到这里我们就可以分析出第一个常量值代表的是啥意思了,它就是Object类的inint方法的名称和描述符,以此类推,其他常量池中的值我们也可以一一推断出。

访问标志

常量池结束后的2个字节,描述该class是类还是接口,以及是被public、final等啥修饰符修饰的,对应的就是:

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

当前类名

再然后的2个字节,描述的是当前类的全限定名,这里值直接就可以使用常量池中的索引,对应的是:

this_class: #5 // ByteCodeDemo

父类名称

再后2个字节描述的是父类的全限定名,同样是使用索引:

super_class: #6 // java/lang/Object

接口信息

紧接着的2个字节是接口计数器,描述了该类或者父类实现的接口数量:

interfaces: 0, fields: 1, methods: 2, attributes: 1

字段表

这部分数据是字段表,用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。

字段表也是2个部分,前面是字段个数,后面是字段的信息:

image.png

会发现其实也是很简单,主要就是权限、字段名、描述符等,对应的值如下:

private int a; 
    descriptor: I 
    flags: (0x0002) ACC_PRIVATE

这也就表示的是类中private int a;

方法表

字段表后面就是方法表,方法表是字节码的重头戏,它包含的信息比较多,在十六进制中表示如下:

image.png

我们还是借助反编译后的文章来看一下:

public int add();
    descriptor: ()I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_2
         1: istore_1
         2: aload_0
         3: getfield      #2                  // Field a:I
         6: iload_1
         7: iadd
         8: istore_2
         9: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        12: iload_2
        13: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        16: iload_2
        17: ireturn
      LineNumberTable:
        line 5: 0
        line 6: 2
        line 7: 9
        line 8: 16

这里Code区就是源代码对应的JVM指令操作码,而各种高级技术就是对这部分进行修改。

这里涉及到栈帧操作,我们本章内容先不说了,内容有点多,我们放到下篇文章继续。

总结

看完本篇文章,你或许就再也不会对class文件表示惧怕,对于方法表中的JVM操作码,我们下篇文章来仔细研究,这样就可以完全掌握class文件了。