class文件解析

1,508 阅读8分钟

前言

操作java字节码,免不了要对字节码文件有一个详细的认识。本文主要记录学习小册 《JVM字节码从入门到精通》 的笔记,以供参考。

查看class文件

public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

打开命令行,将此文件通过 javac 命令编译成 jvm 能识别的 class 文件

javac Hello.java

然后用 xxd 命令以 16 进制的方式查看这个 class 文件

xxd Hello.class

16进制如下:

00000000: cafe babe 0000 0034 001d 0a00 0600 0f09  .......4........
00000010: 0010 0011 0800 120a 0013 0014 0700 1507  ................
00000020: 0016 0100 063c 696e 6974 3e01 0003 2829  .....<init>...()
00000030: 5601 0004 436f 6465 0100 0f4c 696e 654e  V...Code...LineN
00000040: 756d 6265 7254 6162 6c65 0100 046d 6169  umberTable...mai
00000050: 6e01 0016 285b 4c6a 6176 612f 6c61 6e67  n...([Ljava/lang
00000060: 2f53 7472 696e 673b 2956 0100 0a53 6f75  /String;)V...Sou
00000070: 7263 6546 696c 6501 000a 4865 6c6c 6f2e  rceFile...Hello.
00000080: 6a61 7661 0c00 0700 0807 0017 0c00 1800  java............
00000090: 1901 000b 4865 6c6c 6f20 576f 726c 6407  ....Hello World.
000000a0: 001a 0c00 1b00 1c01 0021 636f 6d2f 7869  .........!com/xi
000000b0: 6173 6d2f 6173 6d64 656d 6f2f 636c 6173  asm/asmdemo/clas
000000c0: 7374 6573 742f 4865 6c6c 6f01 0010 6a61  stest/Hello...ja
000000d0: 7661 2f6c 616e 672f 4f62 6a65 6374 0100  va/lang/Object..
000000e0: 106a 6176 612f 6c61 6e67 2f53 7973 7465  .java/lang/Syste
000000f0: 6d01 0003 6f75 7401 0015 4c6a 6176 612f  m...out...Ljava/
00000100: 696f 2f50 7269 6e74 5374 7265 616d 3b01  io/PrintStream;.
00000110: 0013 6a61 7661 2f69 6f2f 5072 696e 7453  ..java/io/PrintS
00000120: 7472 6561 6d01 0007 7072 696e 746c 6e01  tream...println.
00000130: 0015 284c 6a61 7661 2f6c 616e 672f 5374  ..(Ljava/lang/St
00000140: 7269 6e67 3b29 5600 2100 0500 0600 0000  ring;)V.!.......
00000150: 0000 0200 0100 0700 0800 0100 0900 0000  ................
00000160: 1d00 0100 0100 0000 052a b700 01b1 0000  .........*......
00000170: 0001 000a 0000 0006 0001 0000 0003 0009  ................
00000180: 000b 000c 0001 0009 0000 0025 0002 0001  ...........%....
00000190: 0000 0009 b200 0212 03b6 0004 b100 0000  ................
000001a0: 0100 0a00 0000 0a00 0200 0000 0500 0800  ................
000001b0: 0600 0100 0d00 0000 0200 0e              ...........

一个字节是8位,两个十六进制数表示一个字节。

很多文件都以魔数来进行文件类型的区分,class 文件的头四个字节称为魔数,是0xCAFEBABE,这个魔数是 jvm 识别 class文件的标识,虚拟机在加载class 文件前会先检查这四个字节,如果不是则拒绝加载。

class文件是二进制块,想直接看懂它比较难,javap 命令可以窥探 class 文件内部细节,其中 javap -c xxx 是用来对class文件进行反编译

xiasmdeMacBook-Pro:test xiasm$ javap -c Hello
警告: 二进制文件Hello包含com.xiasm.asmdemo.classtest.Hello

1  Compiled from "Hello.java"
2  public class com.xiasm.asmdemo.classtest.Hello {
3    public com.xiasm.asmdemo.classtest.Hello();
4      Code:
5         0: aload_0
6         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
7         4: return
8
9    public static void main(java.lang.String[]);
10     Code:
11        0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
12        3: ldc           #3                  // String Hello World
13        5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
14        8: return
15 }

上面代码前面的数字表示从方法开始算起的字节码偏移量

代码前面的行号是我自己加上的,可以看到,3-7行虽然没有写Hello类的构造函数,但是编译器还是为我们自动加上了。

  • 第5行:aload_x 操作码用来把 对象引用 加载到 操作数栈,非静态的函数都有第一个默认参数,那就是 this,这里的 aload_0 就是把 this 入栈
  • 第6行:invokespecial #1 invokespecial指令调用实例初始化方法、私有方法、父类方法,#1 指的是常量池中的第一个,这里是方法引用java/lang/Object."":()V,也即构造器函数
  • 第7行:return,这个操作码属于 ireturn、lreturn、freturn、dreturn、areturn 和 return 操作码组中的一员,其中 i 表示 int,返回整数,同类的还有 l 表示 long,f 表示 float,d 表示 double,a 表示 对象引用。没有前缀类型字母的 return 表示返回 void

到此,构造器函数就结束了,接下来是 main 函数:

  • 第11行:getstatic #2 getstatic获取指定类的静态域,并将其值压入栈顶,#2 代表常量池中的第 2 个,这里表示的是java/lang/System.out:Ljava/io/PrintStream;,其实就是java.lang.System 类的静态变量 out(类型是 PrintStream)
  • 第12行:ldc #3 ldc表示将int, float或String型常量值从常量池中推送至栈顶,#3 代表常量池的第三个(字符串 Hello, World)
  • 第13行:invokevirtual #4 invokevirutal 指令调用一个对象的实例方法,#4表示 PrintStream.println(String) 函数引用,并把栈顶两个元素出栈

class文件结构剖析

理解 class 文件结构是理解字节码的基石,class 文件结构比较复杂。

Java 虚拟机规定义了 u1、u2、u4 三种数据结构来表示 1、2、4 字节无符号整数,相同类型的若干条数据集合用表(table)的形式来存储。表是一个变长的结构,由代表长度的表头(n)和 紧随着的 n 个数据项组成。class 文件采用类似 C 语言的结构体来存储数据,如下所示:

ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

class文件由下面十个部分组成

  • 魔数(Magic Number)
  • 版本号(Minor&Major Version)
  • 常量池(Constant Pool)
  • 类访问标记(Access Flags)
  • 类索引(This Class)
  • 超类索引(Super Class)
  • 接口表索引(Interfaces)
  • 字段表(Fields)
  • 方法表(Methods)
  • 属性表(Attributes)

魔数、主副版本号

这里的主版本是 52(0x34),虚拟机解析这个类时就知道这是一个 Java 8 编译出的类,如果类文件的版本号高于 JVM 自身的版本号,加载该类会被直接抛出java.lang.UnsupportedClassVersionError异常

常量池

常量池是class文件中最复杂的数据结构,它是一个变长度的数据项

    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];

主要分为两部分,一个是常量池大小,一个是常量池的集合数据

  • 常量池大小的由两个字节表示。假设为值为 n,常量池真正有效的索引是 1 ~ n-1。0 属于保留索引,用来表示不指向任何常量池项。
  • 常量池项(cp_info)集合,最多包含 n-1 个。为什么是最多呢?Long 和 Double 类型的常量会占用两个索引位置,如果常量池包含了这两种类型,实际的常量池项的元素个数比 n-1 要小。每一个常量池项都由两部分构成,下文会说到

Java 虚拟机目前一共定义了 14 种常量类型,这些常量名都以 "CONSTANT" 开头,以 "info" 结尾,如下表所示:

常量类型 常量值
CONSTANT_Utf8_info 1
CONSTANT_Integer_info 3
CONSTANT_Float_info 4
CONSTANT_Long_info 5
CONSTANT_Double_info 6
CONSTANT_Class_info 7
CONSTANT_String_info 8
CONSTANT_Fieldref_info 9
CONSTANT_Methodref_info 10
CONSTANT_InterfaceMethodref_info 11
CONSTANT_NameAndType_info 12
CONSTANT_MethodHandle_info 15
CONSTANT_MethodType_info 16
CONSTANT_InvokeDynamic_info 18

每个常量项都由两部分构成,表示类型的 tag 和表示 内容 的字节数组

cp_info {
    u1 tag;
    u1 info[];
}

再看calss文件的十六进制表示

常量池大小占2个字节,所以就是0x001d,转换成10进制就是29

查看类文件的常量池,可以在 javap 命令上加上 -v 选项,下面是 Hello.class文件的常量池

xiasmdeMacBook-Pro:test xiasm$ javap -v Hello
警告: 二进制文件Hello包含com.xiasm.asmdemo.classtest.Hello
Classfile /Users/xiasm/Desktop/asm_test/Hello.class
  Last modified 2020-1-16; size 443 bytes
  MD5 checksum a9f2551fb88a0a34395ac7cf0a0eedd3
  Compiled from "Hello.java"
public class com.xiasm.asmdemo.classtest.Hello
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #18            // Hello World
   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #21            // com/xiasm/asmdemo/classtest/Hello
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               Hello.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Class              #23            // java/lang/System
  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
  #18 = Utf8               Hello World
  #19 = Class              #26            // java/io/PrintStream
  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
  #21 = Utf8               com/xiasm/asmdemo/classtest/Hello
  #22 = Utf8               java/lang/Object
  #23 = Utf8               java/lang/System
  #24 = Utf8               out
  #25 = Utf8               Ljava/io/PrintStream;
  #26 = Utf8               java/io/PrintStream
  #27 = Utf8               println
  #28 = Utf8               (Ljava/lang/String;)V
  
  //... 省略其他信息
SourceFile: "Hello.java"

Hello.java文件里没有Long或Double类型的常量,所以n-1=28个常量项,没毛病。

类访问标记

常量池之后存储的是访问标记(Access flags),用来标识一个类是是不是final、abstract 等,由两个字节表示总共可以有 16 个标记位可供使用,目前只使用了其中的 8 个,如下:

类、超类、接口索引表

这三个部分用来确定类的继承关系,this_class 表示类索引,super_name 表示父类索引,interfaces 表示类或者接口的直接父接口。以 this_class 为例,它是一个两字节组成。

字段表

紧随接口索引表之后的是字段表(Fields),类中定义的字段会被存储到这个集合中,包括类中定义的静态和非静态的字段,不包括方法内部定义的变量,字段表也是一个变长结构,如下图所示:

每个字段 field_info 的格式如下:

field_info {
    u2             access_flags; 
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

字段结构分为四部分:

  • access_flags:表示字段的访问标记,是 public、private 还是 protected,是否是 static,是否是 final 等
  • name_index:字段名的索引值,指向常量池的的字符串常量
  • descriptor_index:字段描述符的索引,指向常量池的字符串常量
  • attributes_count、attribute_info:表示属性的个数和属性集合

方法表

在字段表后面的是方法表,类中定义的方法会被存储在这里,与前面介绍的字段表很类似,方法表也是一个变长结构

{
    u2             methods_count;
    method_info    methods[methods_count];
}

由表示方法个数的 methods_count 和对应个数的方法项集合组成,如下图所示:

方法 method_info 结构分为四部分:

  • access_flags:表示方法的访问标记,是 public、private 还是 protected,是否是 static,是否是 final 等
  • name_index:方法名的索引值,指向常量池的的字符串常量
  • descriptor_index:方法描述符的索引,指向常量池的字符串常量
  • attributes_count、attribute_info:表示方法相关属性的个数和属性集合,包含了很多有用的信息,比如方法内部的字节码就是存放在 Code 属性中

属性表

在方法表之后的结构是 class 文件的最后一步部分属性表。属性出现的地方比较广泛,不止出现在字段和方法中,在顶层的 class 文件中也会出现。注意,此属性表存放的不是我们理解的类里面的成员属性,而是class文件定义的属性,如 ConstantValue 属性、Code 属性等。

字节码/操作码 概述

Java 虚拟机的指令由一个字节长度的操作码(opcode)和紧随其后的可选的操作数(operand)构成。“字节码”这个名字的由来也是因为操作码的长度用一个字节表示

<opcode> [<operand1>, <operand2>]

比如将整型常量 100 压栈到栈顶的指令是bipush 100,其中 bipush 就是操作码,100 就是操作数。

因为操作码长度只有 1 个字节长度,这使得编译后的字节码文件非常小巧紧凑,但同时也直接限制了整个 JVM 操作码指令集的数量最多只能有 256 个,目前已经使用了 200+

字节码并不是某种虚拟 CPU 的机器码,而是一种介于源码和机器码中间的一种抽象表示方法,不过字节码通过 JIT(Just in time)技术可以被进一步编译成机器码。