JVM之class文件结构剖析

0.前言

在Java编译过程中,源代码首先会被编译器编译成为字节码文件,这些文件的后缀名为.class。然而,尽管.class文件在Java编程中占有重要地位,但是大多数Java开发者对其内部的结构和工作原理并不是非常了解。所以抽时间我整理一下,和大家一起学习进步。

本文旨在对Java的.class文件结构进行详细的剖析,让我们一起了解其内部的工作机制。希望大家在阅读本文后, 对Java虚拟机有更多的了解,并在日后的编程工作中更加得心应手。

1. 引言

在了解JVM Class文件结构剖析之前,我们需要对两个基础知识进行了解一下。java 编译原理Class文件在Java编译过程中的角色

1.1 Java编译原理基础

在这里插入图片描述
在Java编译过程中,对于每个阶段的示例如下:

  1. 词法分析
    词法分析:这个阶段会将源代码分割成一系列的词元(Token)。每个词元都是源代码中的一个最小有意义的独立部分,例如关键字(如public, class等)、标识符、运算符等。
    源代码:
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

词法分析后,会得到一系列的词元(Token),如:public, class, HelloWorld, {, public, static, void, main, (, String, [, ], args, ), {, System, ., out, ., println, (, "Hello, World!", ), ;, }, }

  1. 语法分析
    语法分析:语法分析阶段会根据词元,构建出抽象语法树(Abstract Syntax Tree, AST)。AST是一种用于表示程序结构的树形结构,树的每个节点都代表程序代码中的一个构造(例如声明,表达式等)。

语法分析阶段生成的抽象语法树(AST)涵盖源代码的结构信息。以下是HelloWorld类的简化版AST:

  ClassDeclaration
  ├── Modifier: public
  ├── Name: HelloWorld
  └── MethodDeclaration
      ├── Modifier: public
      ├── Modifier: static
      ├── ReturnType: void
      ├── Name: main
      ├── Parameter: String[] args
      └── Block
          └── MethodInvocation
              ├── Name: System.out.println
              └── Argument: "Hello, World!"
  1. 语义分析
    语义分析:这个阶段会检查源代码的语义是否正确。例如类型检查,确保赋值和操作符等的类型正确性。此外,语义分析还包括符号解析,将代码中的变量和类型名称解析为内部的符号引用。

举一例错误的赋值语句:

public class Test {
    public static void main(String[] args) {
        int a = "hello";
    }
}

在语义分析阶段,编译器会检查变量类型是否正确。在这个例子中,尝试将字符串"hello"赋值给整型变量a,这是错误的,编译器会提示类型不匹配的错误。

  1. 代码生成
    代码生成:在所有分析完成后,编译器会生成字节码,字节码是一种中间代码,可以在Java虚拟机(JVM)上执行。

在代码生成阶段,编译器会将以上的源代码转化为以下的Java字节码:

public class HelloWorld {
  public HelloWorld();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #3                  // String Hello, World!
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}

这是.class文件的内容,可以通过javap -c HelloWorld命令查看。

1.2 Class文件在Java编译过程中的角色

  1. 字节码的载体

    Class文件包含了Java程序的字节码,也就是Java源代码编译之后的结果。Java编译器(如javac)会将Java源代码转化为字节码,字节码是一种中间语言,它比源代码更接近于机器语言。字节码的存在使得Java程序可以在任何安装了Java虚拟机的平台上运行,因为Java虚拟机能够解释和执行字节码。

  2. 实现跨平台特性

    字节码是一种平台无关的中间表示形式,它不依赖于特定的硬件和操作系统。这意味着只要一个设备安装了Java虚拟机,那么这个设备就能够执行Java程序,无论这个设备使用的是什么操作系统。这就是Java的"一次编写,到处运行"的理念。

  3. 动态加载和链接

    Java虚拟机在运行时会动态地加载Class文件。也就是说,Java虚拟机并不会一次性加载所有的Class文件,而是在程序运行过程中,当需要使用到某个类时,才会将这个类的Class文件加载进内存。这种动态加载的机制提高了内存的使用效率。

    除了加载,Java虚拟机还会进行链接。链接是指将Class文件中的符号引用替换为直接引用的过程。符号引用是一种抽象的引用,它可以是任何形式的字符集,用来描述被引用的信息。直接引用则是指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄。

  4. 存储元数据

    Class文件中包含了Java类或接口的元数据信息。这些信息包括类名,方法名,字段名以及它们的访问修饰符等。这些元数据在运行时可以被Java虚拟机用于反射、泛型等操作。

    反射的操作包括获取Class实例,获取类的构造方法,字段和方法,创建类的实例,调用方法,访问字段等。

    泛型则是Java语言提供的一种代码抽象和复用机制。Java的泛型是在编译器这个层次来实现的,也就是说,Java的泛型仅仅是给编译器Java源码用的,确保数据的类型安全。而字节码文件中,是不包含泛型中的类型信息的。在编译器编译Java源码到字节码时,会将源码中的泛型擦除,而在需要的地方插入类型强制转换的代码来确保类型正确。

2. Class文件的整体结构

参考Oracle 官方文档 Chapter 4. The class File Format https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html
在这里插入图片描述

2.1 Class 文件组成

部分名称描述
魔数魔数是Class文件的头四个字节,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。Java的魔数固定为0xCAFEBABE
版本号版本号是Class文件的次要版本号和主要版本号,它们都占用两个字节。主要版本号确定了Class文件的版本,比如JDK 1.1的主要版本号为45,而JDK 1.2~1.8的主要版本号则分别为46~52。
常量池常量池是Class结构的一部分,它用于存放编译期生成的各种字面量和符号引用。这部分内容将在类加载后进入方法区中。
访问标志访问标志用于识别一些类或接口层次的访问信息,包括:是否为public,是否为abstract,是否为interface等。
类索引、父类索引类索引和父类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
接口索引集合接口索引集合用于描述这个类实现了哪些接口,这些接口将被初始化到一个列表中。
字段表字段表用于描述接口或类中声明的变量,变量包括类级别的类变量,还有就是实例变量,没有方法体中的局部变量。
方法表方法表用于描述类或接口中声明的方法。
属性表属性表用于描述某个类,方法和字段的附加信息。比如,可以设置一个字段是否被废弃(Deprecated);还可以设置方法的字节码等。

每个部分都承担着其自身的职责,真正让Java有"一次编写,到处运行"特性的就是字节码,字节码是存储在方法表的code属性里的。

以下是一个简单的Java程序示例,我们将逐步解析其生成的Class文件各部分:

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

编译上述Java程序会得到一个HelloWorld.class文件,这个文件包含了字节码和其它相关信息。我们使用JDK自带的javap工具来解析这个Class文件:

javap -verbose HelloWorld

以下是解析结果的一部分:

Classfile /C:/HelloWorld.class
  Last modified Mar 11, 2021; size 434 bytes
  MD5 checksum 63bf0338975b5720bf154f48d8a5db14
public class HelloWorld
  minor version: 0
  major version: 59
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #7                          // HelloWorld
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Class              #8             // HelloWorld
   #8 = Utf8               HelloWorld
   #9 = Utf8               Code
  #10 = Methodref          #11.#12        // java/lang/System.out:Ljava/io/PrintStream;
  #11 = Class              #13            // java/lang/System
  #12 = NameAndType        #14:#15        // out:Ljava/io/PrintStream;
  #13 = Utf8               java/lang/System
  #14 = Utf8               out
  #15 = Utf8               Ljava/io/PrintStream;
  #16 = String             #17            // Hello, World!
  #17 = Utf8               Hello, World!
  #18 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #19 = Class              #21            // java/io/PrintStream
  #20 = NameAndType        #22:#23        // println:(Ljava/lang/String;)V
  #21 = Utf8               java/io/PrintStream
  #22 = Utf8               println
  #23 = Utf8               (Ljava/lang/String;)V
  #24 = Utf8               main
  #25 = Utf8               ([Ljava/lang/String;)V
  #26 = Utf8               SourceFile
  #27 = Utf8               HelloWorld.java
  #28 = Utf8               LineNumberTable
  #29 = Utf8               LocalVariableTable
  #30 = Utf8               this
  #31 = Utf8               LHelloWorld;
  #32 = Utf8               args
  #33 = Utf8               [Ljava/lang/String;
{
  public HelloWorld();
    descriptor: ()V
    flags: (0x0001) 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 1: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LHelloWorld;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang.String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #10                 // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #16                 // String Hello, World!
         5: invokevirtual #18                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 3: 0
        line 4: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"

从中我们可以看到:

  • 魔数、版本号:javap工具并未直接显示,但我们知道每个Class文件的开头四个字节就是魔数,而紧接着的4个字节代表版本号。

  • 常量池:在"Constant pool"部分,包含了本程序中所使用到的各种常量和符号引用。

  • 访问标志:在"flags"部分显示,ACC_PUBLIC表示这是一个public类,ACC_SUPER是Java编译器为了支持某些编译优化而设定的。

  • 在这里插入图片描述

  • 类索引、父类索引:在"this_class"和"super_class"部分显示,分别表示这个类自身和其父类在常量池中的索引。

  • 接口索引集合:本例没有实现任何接口,所以此部分为空。

  • 字段表:本例没有定义任何字段,所以此部分为空。

  • 方法表:在"Code"部分可以看到两个方法的字节码,一个是默认的构造函数<init>,一个是我们定义的main方法。

  • 属性表:在"SourceFile"部分显示,表示这个Class文件对应的源文件名。

3. Class文件的详细解析

在这里插入图片描述

3.1 魔数与版本号的作用和意义

魔数(Magic Number)是用来标识文件格式的一种约定,Java Class文件的魔数是0xCAFEBABE。当Java虚拟机加载Class文件时,首先会检查这个魔数,如果不是0xCAFEBABE,那么JVM会拒绝加载这个文件。

版本号(Version Number)包括主版本号和次版本号,主版本号变化通常代表JVM做了不兼容的修改,次版本号变化则表示JVM做了向后兼容的修改。JVM在加载Class文件时,也会检查版本号,如果文件的版本高于JVM的版本,那么JVM会拒绝加载这个文件。

3.2 常量池的结构和作用

常量池(Constant Pool)是Java Class文件结构的一个重要部分,它主要存储两类常量:字面量(Literal)和符号引用(Symbolic Reference)。字面量包括文本字符串、声明为final的常量值等;符号引用则包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。

常量池的主要作用是为Java Class文件中的字面量和符号引用提供统一的存储空间,当Java虚拟机执行Class文件中的字节码时,会使用到常量池中的数据。

3.3 访问标志的含义和可能的值

访问标志(Access Flags)用于标识类或接口的访问权限和特性。如public(0x0001)、final(0x0010)、super(0x0020)、interface(0x0200)、abstract(0x0400)等。

3.4 类索引、父类索引和接口索引集合的作用和构成

类索引、父类索引和接口索引集合用于确定类的继承关系。类索引代表当前类,父类索引代表当前类的直接父类,接口索引集合代表当前类实现的所有接口。

3.5 字段表和方法表

字段表(Field Table)用于描述类或接口中声明的变量,包括字段的名称、描述符、访问标志等信息。方法表(Method Table)用于描述类或接口中声明的方法,包括方法的名称、描述符、访问标志、字节码等信息。

3.6 属性表的详细解析

属性表(Attribute Table)用于描述类、字段和方法的附加信息,例如源文件名(SourceFile)、字段的常量值(ConstantValue)、方法的字节码(Code)、已废弃信息(Deprecated)等。不同的属性有不同的内部结构,但都遵循“属性名索引-属性长度-属性信息”这样的基本格式。

4. Class文件在JVM中的角色

4.1 Class文件在JVM中的生命周期

Java虚拟机(JVM)对Class文件的处理可以分为以下几个阶段:

  1. 加载(Loading): 在这个阶段,JVM从本地磁盘、网络或者其他来源加载.class文件。加载完成之后,JVM将会创建一个表示这个类的java.lang.Class对象。

  2. 验证(Verification): 为了确保Class文件的正确性,JVM会对加载进来的字节码文件进行严格的验证,包括文件格式验证、元数据验证、字节码验证、符号引用验证等。如果验证失败,JVM将抛出java.lang.VerifyError异常。

  3. 准备(Preparation): 在这个阶段,JVM会为类变量(static变量)分配内存,并设置类变量的初始值。同时,JVM还会为类的方法表、接口表等分配内存。

  4. 解析(Resolution): 这个阶段主要执行符号引用到直接引用的转换。JVM将会解析类中的方法、字段、类、接口等的符号引用,将其替换为直接引用。

  5. 初始化(Initialization): 初始化阶段主要执行类构造器<clinit>方法,以及静态变量的赋值操作。这个阶段会确保类的初始化过程是线程安全的。

  6. 使用(Using): 类成功加载、验证、解析和初始化之后,就可以被程序使用了。这时,程序可以创建类的实例、调用方法、访问字段等。

  7. 卸载(Unloading): 当类不再被引用,并且在垃圾收集器运行后被标记为可回收时,JVM会卸载这个类。此时,JVM会释放与类相关的所有内存资源。

4.2 JVM是如何加载和使用Class文件的

JVM使用类加载器(ClassLoader)来加载和管理Class文件。类加载器负责将Class文件的字节码加载到内存中,并且执行验证、准备、解析等操作。在这个过程中,类加载器还会处理类之间的依赖关系,保证类的正确加载。

当程序需要使用一个类时,类加载器会首先查找是否已经加载过这个类。如果没有加载过,类加载器会根据类的全限定名(包名+类名)加载类的字节码,然后执行整个生命周期的操作。

一旦类被加载到JVM中,它就可以被程序使用。程序可以通过创建类的实例、调用方法、访问字段等方式使用这个类。

4.3 Class文件在动态链接、加载和运行机制中的作用

Class文件是Java程序在JVM中的载体。它包含了类的所有信息,包括类的属性、方法、常量池等。在Java程序执行的过程中,JVM通过动态链接、加载和运行机制来管理和使用Class文件。

  • 动态链接:JVM在运行时将符号引用替换为直接引用的过程称为动态链接。Class文件中的符号引用包括方法引用、字段引用、类引用等。动态链接保证了Java代码可以在不同的环境下运行。

  • 加载:JVM通过类加载器将Class文件加载到内存中。在这个过程中,JVM会执行验证、准备、解析等操作,确保类的正确性和可用性。

  • 运行:加载完成的Class文件可以被程序使用。在运行期间,JVM会执行类的方法、访问字段、创建实例等操作。同时,JVM还会处理异常、垃圾回收等任务,保证程序的正常运行。

5. javap 命令详解

在上面的示例介绍中我们使用了一个命令

javap -verbose HelloWorld

那么我单独拉出来,给大家聊一下这个命令的作用。

javap 是Java虚拟机自带的反编译工具,用于从命令行解析class文件,打印出字节码、常量、构造方法、函数等等的信息。以下是javap命令的一些常用选项:

  • -help:查看帮助文档
  • -version:输出javap的版本信息
  • -v-verbose:详细输出,输出额外的更为详细的信息,比如常量池信息、版本信息、字节码指令等
  • -l:输出行号和本地变量表
  • -public:只显示public的类和成员
  • -protected:只显示protected的类和成员
  • -package:只显示package的类和成员
  • -private:显示所有的类和成员
  • -c:对代码进行反汇编
  • -s:输出内部类型签名
  • -sysinfo:用于输出系统信息,包括主版本号、次版本号等
  • -constants:显示最终常量

也可以混合使用多个选项,如javap -private -c HelloWorld可以显示HelloWorld中所有的类和成员,以及反汇编出的字节码。

假设我们有以下的一个简单的Java类HelloWorld.java

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

首先,我们需要使用javac命令编译这个Java文件,生成对应的Class文件:

javac HelloWorld.java

然后,我们就可以使用javap命令查看生成的Class文件的内容了。我们先只查看Class文件的概述:

javap HelloWorld

输出如下:

Compiled from "HelloWorld.java"
public class HelloWorld {
  public HelloWorld();
  public static void main(java.lang.String[]);
}

这里我们可以看到,javap默认输出了Class文件的类名,以及其中的方法。

接下来,我们使用 -v 参数打印Class文件的详细内容:

javap -v HelloWorld

输出如下:

Classfile /path/to/HelloWorld.class
  Last modified Apr 15, 2021; size 455 bytes
  MD5 checksum 9d422a48a6a88f5c6ed5d6eb7cce5015
  Compiled from "HelloWorld.java"
public class HelloWorld
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#10         // java/lang/Object."<init>":()V
   #2 = Fieldref           #11.#12        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #13            // Hello, world!
   #4 = Class              #14            // java/lang/Object
   #5 = Class              #15            // HelloWorld
   #6 = Methodref          #16.#17        // java/io/PrintStream.println:(Ljava/lang/String;)V
  ...
  ...

这个输出包含了HelloWorld Class文件的很多详细信息,如版本号、访问标志、常量池等等。例如,我们可以在常量池中看到Hello, world!这个字符串常量。

6. 十六进制编辑器 010 Editor 查看Class 文件

参考 https://bbs.kanxue.com/thread-257797.htm
参考 https://blog.csdn.net/freeking101/article/details/102908538

在这里插入图片描述
以下是您可以手动应用模板的方法,以防文件扩展名不是原始扩展名

在这里插入图片描述
这是模板结果的样子:

在这里插入图片描述
在十六进制/ASCII转储下方,显示了模板结果:一组嵌套字段,与.class文件的内部结构相匹配。例如,我在这里选择的第一个字段是u4 magic,它是一个.class文件的魔数标头:CAFEBABE。
在这里插入图片描述

7. 使用jclasslib 工具查看字节码

jclasslib 是一个功能丰富的字节码查看器,用于查看和分析 Java class 文件的结构。

  1. 下载和安装 jclasslib

官网 https://jclasslib.org/ 免费开源

  1. 打开 jclasslib

    安装完成后,打开 jclasslib。会看到一个简洁的界面。
    在这里插入图片描述
    在这里插入图片描述

  2. 打开 class 文件

    点击菜单栏的 File > Open... 选项,浏览的文件系统找到要查看的 .class 文件,选择它然后点击 Open

    或者,也可以直接把 .class 文件拖拽到 jclasslib 的窗口中。
    在这里插入图片描述

  3. 查看字节码

    成功打开 .class 文件后,会在左侧看到一个树形结构,表示了 .class 文件的结构。可以点击树形结构的各个节点查看详细的信息。

    右侧的窗口会根据选中的节点显示对应的详细信息。例如,如果选中了 constant_pool 节点,右侧就会显示常量池的所有内容。

    jclasslib 还提供了一个 Hex viewer 选项卡,可以在这里查看每个节点的原始字节码。

8.参考文档

https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冰点.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值