类字节码详解

2024/2/22 Java虚拟机【JVM】

# 1、JVM介绍

Java虚拟机(Java Virtual Machine,JVM)是一个虚拟的计算机,它是Java程序运行的环境。当您编写Java代码并将其编译为字节码时,JVM会负责解释和执行这些字节码。

在CPU层面看来,计算机中的所有操作都是由一条条指令汇集而成的。Java是一种高级语言,其逻辑只有人类能够理解,计算机无法直接识别。因此,为了让计算机能够执行Java代码,必须先将Java代码编译成字节码文件,这些字节码文件包含了计算机能够理解的指令序列。然后,JVM负责加载和执行这些字节码文件,将其转换为计算机硬件可以执行的指令,从而实现Java程序的运行。

  • 特点

  • Class字节码文件可以运行在不同平台的Java虚拟机(JVM),即可实现:一次编译,处处运行
  • JVM也不再只支持Java,由此衍生出了许多基于JVM的编程语言,如Groovy, Scala, Koltin等等。

各类编程语言编译后可运行在JVM

# 2、class字节码文件

Class文件是Java编译器编译Java源代码生成的二进制文件,其中包含了Java程序的字节码指令、常量池、方法和字段等信息。

# 2.1、class编译案例

  • 编写Java类
// Main.java
public class Main {
    
    private int m;
    
    public int inc() {
        return m + 1;
    }
}
1
2
3
4
5
6
7
8
9
  • 编译为class字节码文件
javac Main.java
1
  • 以文本的形式打开生成的class文件,内容如下:
cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 0009 4d61 696e 2e6a 6176 610c
0007 0008 0c00 0500 0601 0010 636f 6d2f
7268 7974 686d 372f 4d61 696e 0100 106a
6176 612f 6c61 6e67 2f4f 626a 6563 7400
2100 0300 0400 0000 0100 0200 0500 0600
0000 0200 0100 0700 0800 0100 0900 0000
1d00 0100 0100 0000 052a b700 01b1 0000
0001 000a 0000 0006 0001 0000 0003 0001
000b 000c 0001 0009 0000 001f 0002 0001
0000 0007 2ab4 0002 0460 ac00 0000 0100
0a00 0000 0600 0100 0000 0800 0100 0d00
0000 0200 0e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • class字节码文件简单分析

这里分析第一行:cafe babe 0000 0034 0013 0a00 0400 0f09

  • 魔数(Magic Number)cafe babe,这是Class文件的魔数,用于表示该文件是Java类文件。唯有以cafe babe开头的class文件方可被虚拟机所接受,这4个字节就是字节码文件的身份识别。
  • 版本号:0000 0034,这表示Class文件的版本号为52。java的版本号从45开始,除1.0和1.1都是使用45.x外,以后每升一个大版本,版本号加一。52-45=7,也就是加7个版本:1.1+0.7=1.8,即该class文件为JDK1.8版本的,也是我们常说的Java8版本。
  • 常量池(Constant Pool):0013,这表示常量池中有19个常量。
  • 访问标志(Access Flags):0a,这是类或接口的访问标志,比如0x01代表public,0x10代表final等。
  • 类索引:00 04,这是指向类的索引,指向常量池中的类信息。
  • 父类索引:00 0f,这是指向父类的索引,指向常量池中的父类信息。
  • 接口索引:09,这是实现的接口的个数。

# 2.2、反编译class文件

对于class字节码二进制文件我们来分析是很难的,所以我们需要将class字节码文件反编译为我们人类能够看懂的。

使用到java内置的一个反编译工具javap可以反编译字节码文件, 用法: javap <options> <classes>

  • 其中<options>选项包括:
-help  --help  -?        输出此用法消息
-version                 版本信息
-v  -verbose             输出附加信息
-l                       输出行号和本地变量表
-public                  仅显示公共类和成员
-protected               显示受保护的/公共类和成员
-package                 显示程序包/受保护的/公共类和成员 (默认)
-p  -private             显示所有类和成员
-c                       对代码进行反汇编
-s                       输出内部类型签名
-sysinfo                 显示正在处理的类的系统信息 (路径, 大小, 日期, MD5 散列)
-constants               显示最终常量
-classpath <path>        指定查找用户类文件的位置
-cp <path>               指定查找用户类文件的位置
-bootclasspath <path>    覆盖引导类文件的位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 输入命令javap -verbose -p Main.class查看输出内容:
Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class  // Class文件当前所在位置
  Last modified 2024-2-22;   // 最后修改时间
  size 362 bytes             // 文件大小
  MD5 checksum 4aed8540b098992663b7ba08c65312de // MD5值
  Compiled from "Main.java"       // 编译自哪个文件
public class com.rhythm7.Main     // 类的全限定名
  minor version: 0                // jdk次版本号
  major version: 52               // jdk主版本号
  flags: ACC_PUBLIC, ACC_SUPER    // 访问标志,后面有详细解析
Constant pool:                    // 常量池
   #1 = Methodref          #4.#18         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#19         // com/rhythm7/Main.m:I
   #3 = Class              #20            // com/rhythm7/Main
   #4 = Class              #21            // java/lang/Object
   #5 = Utf8               m
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/rhythm7/Main;
  #14 = Utf8               inc
  #15 = Utf8               ()I
  #16 = Utf8               SourceFile
  #17 = Utf8               Main.java
  #18 = NameAndType        #7:#8          // "<init>":()V
  #19 = NameAndType        #5:#6          // m:I
  #20 = Utf8               com/rhythm7/Main
  #21 = Utf8               java/lang/Object
{                          // {}为方法表集合
  // 声明了一个私有变量m,类型为int,返回值为int
  private int m;           
    descriptor: I
    flags: ACC_PRIVATE      

  // 构造方法:Main(),返回值为void, 公开方法。
  public com.rhythm7.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/rhythm7/Main;
  
  // 方法inc()
  public int inc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field m:I
         4: iconst_1
         5: iadd
         6: ireturn
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   Lcom/rhythm7/Main;
}
SourceFile: "Main.java" // 源码文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

# 2.3、反编译class文件信息分析

解析反编译前面部分

Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class  // Class文件当前所在位置
  Last modified 2024-2-22;   // 最后修改时间
  size 362 bytes             // 文件大小
  MD5 checksum 4aed8540b098992663b7ba08c65312de // MD5值
  Compiled from "Main.java"       // 编译自哪个文件
public class com.rhythm7.Main     // 类的全限定名
  minor version: 0                // jdk次版本号
  major version: 52               // jdk主版本号
  flags: ACC_PUBLIC, ACC_SUPER    // 访问标志
1
2
3
4
5
6
7
8
9

对于前面这部分都比较好理解,其实对应的就是class编译案例中的部分,这里主要介绍一下 flags访问标志,标志的含义如下:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 是否为Public类型
ACC_FINAL 0x0010 是否被声明为final,只有类可以设置
ACC_SUPER 0x0020 是否允许使用invokespecial字节码指令的新语义,类的 super 关系是通过 invokespecial 指令来处理的。
ACC_INTERFACE 0x0200 标志这是一个接口
ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,此标志值为真,其他类型为假
ACC_SYNTHETIC 0x1000 标志这个类并非由用户代码产生
ACC_ANNOTATION 0x2000 标志这是一个注解
ACC_ENUM 0x4000 标志这是一个枚举

# 2.4、常量池

常量池(Constant pool)可以理解为class文件中的资源仓库,主要存放两大常量:字面量和符号引用

  • 字面量:也就是java中的常量概念

  • 字符串
  • final常量
  • 符号引用

  • 类和接口的全限定名
  • 字段的名称和描述符号
  • 方法的名称和描述符
  • class字节码文件中的常量池
Constant pool:                    // 常量池
   #1 = Methodref          #4.#18         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#19         // com/rhythm7/Main.m:I
   #3 = Class              #20            // com/rhythm7/Main
   #4 = Class              #21            // java/lang/Object
   #5 = Utf8               m
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/rhythm7/Main;
  #14 = Utf8               inc
  #15 = Utf8               ()I
  #16 = Utf8               SourceFile
  #17 = Utf8               Main.java
  #18 = NameAndType        #7:#8          // "<init>":()V
  #19 = NameAndType        #5:#6          // m:I
  #20 = Utf8               com/rhythm7/Main
  #21 = Utf8               java/lang/Object
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

JVM是在加载Class文件的时候才进行的动态链接,也就是说这些字段和方法符号引用只有在运行期转换后才能获得真正的内存入口地址。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析并翻译到具体的内存地址中。

  • 解析第一个常量
// #1 是常量池中的第一个常量,它是一个 Methodref 类型的常量,指向常量池中索引为 4 和 18 的常量。
#1 = Methodref          #4.#18
1
2

完整拼接

#1 = Methodref          #4.#18         // java/lang/Object."<init>":()V
#4 = Class              #21            // java/lang/Object
#7 = Utf8               <init>
#8 = Utf8               ()V
#18 = NameAndType        #7:#8          // "<init>":()V
#21 = Utf8               java/lang/Object
1
2
3
4
5
6

拼接结果

java/lang/Object."<init>":()V
1

这段可以理解为该类的实例构造器的声明,由于Main类没有重写构造方法,所以调用的是父类的构造方法。此处也说明了Main类的直接父类是Object。 该方法默认返回值是V, 也就是void,无返回值。

  • 解析第二个常量
#2 = Fieldref           #3.#19         // com/rhythm7/Main.m:I
#3 = Class              #20            // com/rhythm7/Main
#5 = Utf8               m
#6 = Utf8               I
#19 = NameAndType        #5:#6          // m:I
#20 = Utf8               com/rhythm7/Main
1
2
3
4
5
6

此处声明了一个字段m,类型为I, I即是int类型。

  • 字节码的类型
标识字符 含义
B 基本类型 byte
C 基本类型 char
D 基本类型 double
F 基本类型 float
I 基本类型 int
J 基本类型 long
S 基本类型 short
Z 基本类型 boolean
V 特殊类型 void
L 对象类型,以分号结尾,如 Ljava/lang/Object;

# 2.5、方法表集合

方法表集合:常量池之后,以{}集合形式表现的则为方法表集合。 包括的变量和方法。

  • 变量
private int m;       // 私有的整型成员变量 m
  descriptor: I      // 返回值int
  flags: ACC_PRIVATE // 成员变量的访问标志,为私有
1
2
3

声明了一个私有变量m,类型为int,返回值为int。

  • 方法

这里只解析一个Main构造方法

public com.rhythm7.Main();   // 构造方法:Main()
   descriptor: ()V           // 返回值为void
   flags: ACC_PUBLIC         // 公开方法
   Code:
     stack=1, locals=1, args_size=1
        // attribute_info
        0: aload_0
        1: invokespecial #1                  // Method java/lang/Object."<init>":()V
        4: return
     LineNumberTable:
       line 3: 0
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           0       5     0  this   Lcom/rhythm7/Main;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Code详解

  • stack: 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为1
  • locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。方法参数(包括实例方法中的隐藏参数this),显示异常处理器的参数(try catch中的catch块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals的大小并不一定等于所有局部变量所占的Slot之和,因为局部变量中的Slot是可以重用的。
  • args_size: 方法参数的个数,这里是1,因为每个实例方法都会有一个隐藏参数this
  • attribute_info: 方法体内容,0,1,4为字节码"行号",该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的java/lang/Object."":()V, 然后执行返回语句,结束方法。
  • LineNumberTable: 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。可以使用 -g:none 或-g:lines选项来取消或要求生成这项信息,如果选择不生成LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
  • LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是arg0, arg1这样的占位符。 start 表示该局部变量在哪一行开始可见,length表示可见行数,Slot代表所在帧栈位置,Name是变量名称,然后是类型签名。

# 2.6、类名

最后为源码文件

SourceFile: "Main.java"
1

# 2.7、类索引、父类索引、接口索引

类索引、父类索引和接口索引是在Java类文件中常见的结构,用于描述类的继承关系和接口实现。

  • 类索引(Class Index):类索引用于指向当前类的信息,在常量池中通过一个 Class 类型的常量来表示。该常量包含了类的全限定名等信息。
  • 父类索引(Superclass Index):父类索引用于指向当前类的父类信息,在常量池中通过一个 Class 类型的常量来表示。通过父类索引,可以建立类之间的继承关系。
  • 接口索引(Interface Index):接口索引用于指向当前类实现的接口信息,在常量池中通过一个 Utf8 类型的常量表示接口的全限定名。一个类可以实现多个接口,因此会有多个接口索引。

此处的class即为类索引

#3 = Class              #20            // com/rhythm7/Main
#20 = Utf8               com/rhythm7/Main
1
2

# 2.8、字节码文件结构总结

主要需要掌握如下内容:

  • 魔数:字节码文件的身份识别。
  • class版本信息:版本号认识。
  • 常量池:字面量和符号引用。
  • 访问标志:flags访问标志。
  • 类索引、父类索引、接口索引:用于描述类的继承关系和接口实现。
  • 字段表属性:用于描述接口或类中声明的的变量。比如变量的作用域(public、private、protected)、是否是静态变量(static)、可变性(final)、数据类型(基本类型、对象、数组)等等
  • 方法表属性:与字段表类似,不过方法表属性描述的是方法的类型、作用域等等。
  • 属性表属性:用于描述某些场景专有的信息。比如字段表中特殊的属性、方法表中特殊的属性等等。

# 3、字节码的增强技术

class字节码文件一旦被编译后可以运行在JVM中了,但有时会有运行时需要对JVM中的类进行修改并重载的需要,所以就需要对字节码的增强技术进行理解。本文暂不介绍,有兴趣的可以去了解下:

  • 热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
  • Mock:测试时候对某些服务做Mock。
  • 性能诊断工具:比如bTrace就是利用Instrument,实现无侵入地跟踪一个正在运行的JVM,监控到类和方法级别的状态信息。

参考:JVM 基础 - 类字节码详解 (opens new window)