一次编写,到处运行,.class文件功不可没
java在刚刚诞生的时候有一个很著名的口号,叫做"write once,run anywhere"(一次编写,到处运行),这句话体现了java语言跨平台的特性,但在先前就没有跨平台的语言了吗?答案是否定的。在c语言里也有一句话叫做"一次编写,到处编译",也就是说编写了c语言的源文件,然后拿到不同操作系统分别进行编译,再运行,看起来也是跨平台的,但这必须有一个前提,就是c语言源文件中使用的都必须是标准的类库,而因为c语言标准类库中实现的接口很少 ,所以各个系统厂家为自己的系统设计了不同的类库,导致了在windows平台下调用windows类库接口没问题,但换到linux上就不行了,就无法做到跨平台性。
没有加一层解决不了的事,如果有就加两层。以c语言为代表的语言,它们会直接把我们编写的程序直接编译为二进制的本地机器码,而又因为像上文所说的,不同系统厂家又有自己的类库,所以导致了编写的程序与操作系统耦合在了一起,就无法跨平台。但是java不一样,它会先把源代码——.java文件,然后把 .java 文件编译为 .class 文件,再把 .class 文件交给java虚拟机处理,相比c语言,它中间多了一层编译为 .class 文件的过程,这个 .class 文件是统一的,它可以运行在不同的java虚拟机上(每个不同系统都设计了对应的虚拟机),所以说java虚拟机不是跨平台的,但 .class 文件时跨平台的,而因为 .class 文件时经过源文件编译出来的,所以导致了java这门语言时跨平台的。更宏观的来说,只要一门语言能被编译为标准的 .class 文件并运行在java虚拟机上,那么这门语言就具备了跨平台的特性,像Kotlin、Clojure等。
类文件结构的总览
下面放一幅图,来看看.class文件里到底有什么
魔数与版本号
在.class文件开头的四个字节,用16进制表示,分别是"CA FE BA BE",它被用于标识这是一个.class文件。
不同版本的jdk支持着不同的.class文件版本号,高版本的jdk可以兼容低版本的版本号,但低版本的jdk不能往上兼容运行高版本的Class文件
JDK版本号 | Class文件版本号 |
---|---|
JDK7 | 51.0 |
JDK8 | 52.0 |
JDK9 | 53.0 |
JDK10 | 54.0 |
JDK11 | 55.0 |
无符号数与表
无符号数和表都是类文件中的数据结构并且只有这两种。其中无符号数是类文件的基本数据结构,表是由多个无符号数或者表构成的更大的数据结构,其实有点类似于c语言的结构体。
- 无符号数:以u1(一个字节)、u2(两个字节)、u4、u8表示,它可以用来描述数字、索引引用、数量值或者由utf-8编码成的字符串
- 表:由多个无符号数或者其它表组成的更大的数据结构,通常以_info结尾。
常量池
它是Class文件第一个表结构的项目,需要提醒一下,类文件中的每个大的结构(常量池、访问标志…)都会有一个容器计数(是一个u2类型),一般是从0开始的,但对于常量池很特殊,它是从1开始的。
常量池主要存放:字面量和符合引用。
- 字面量:像文本字符串、被声明为final的常量值等。
- 符号引用:包括类和接口的全限定名称、字段和方法的描述符、方法句柄和方法类型等。
截至JDK13,常量池一共包括了13中类型的常量,其中每一个类型的常量都是一个表。下面列举常量池的项目类型
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_Utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Interger_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_Dynamic_info | 17 | 表示一个动态计算常量 |
下面我们来看看两个简单的常量类型
CONSTANT_Class_info,它用来表示一个类或接口的描述符,下面是它的结构
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | name_index | 1 |
其中name_index指向了一个CONSTANT_Utf8_info,代表了一个类或接口的全限定名称
CONSTANT_String_info,用来表示utf-8的字符串
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
length表示的是字符串有多少个字节,而bytes是使用缩略编码表示的utf-8的字符串。下面是17中类型的常量的总结
访问标记
access_flags(访问标记),它是紧随常量池的两个字节,用于描述这个Class是否被final修饰、是否是一个接口、是否是一个注解等。标识符如下
类索引、父类索引于接口索引集合
类索引和父类索引都是u2类型的数据,它们各指向一个CONSTANT_Class_info常量类型(除了Object外,所有类都有父类),在接口集合前会有一个u2类型的接口计数器,用来记录对应的Class实现了几个接口,如果没有则为0。
字段表集合
它表示了类或者接口中声明的变量,就是在java中的字段,不包括方法中的局部变量。字段表集合包含了字段的修饰符信息(public、private、static、volatile…)、字段类型和名称,当然在一开始也会有一个计数器来记录有多少个字段
下面是字段表的结构
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
- access_flags代表着访问修饰符,它有下图几种标识
-
name_index:对常量池的引用,标识该字段的简单名称
-
descriptor_index:指向常量池的描述符,代表字段的类型
-
attributes:引用属性表集合,储存额外的信息。(下面会讲到属性表集合)
方法表集合
它跟字段表集合的结构一致
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
- descriptor_index:这个与字段表集合的有点不同,它指向的常量池描述符描述了方法的参数列表和返回值。所以在这里总结一下:描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
属性表集合
属性表集合里有很多属性表,如下图
在这里挑相对重要的Code属性来讲
Code属性
下面是Code属性表的结构
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | max_stack | 1 |
u2 | max_locals | 1 |
u4 | code_length | 1 |
u1 | code | code_length |
u2 | exception_table_length | 1 |
exception_info | exception_table | exception_table_length |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
Code里面主要保存着方法里代码编译后变成的字节码指令
-
max_stack:操作数栈,虚拟机会根据这个值来分配栈帧中的操作栈的深度
-
max_locals:代表着局部变量表的储存空间。储存空间的单位是槽位,对于不超32位的数据类型,占用一个槽,而超过了32位的,如long、double就占两个槽。当然,并不是方法里面声明了几个局部变量,为了节省空间,javac在编译时会重用槽位,主要时根据变量的作用域范围决定的,根据同时生存的最大局部变量数量和类型计算出max_locals的大小。
-
code:这是重头戏,用于储存方法编译后生成的字节码指令,基本的字节码指令会在稍后提到。
下面为了学习字节码指令,我们写一个简单的类,如下
public class JVMTest {
public void testMethod(){
int a;
try {
a = 10/3;
}catch (Exception e){
a = 10;
}finally {
a = 100;
}
}
}
我们先用javac命令编译,然后再用 javap -verbose xxx 命令显示出字节码文件,如下图
Classfile /D:/IDEAProject/jdk11reading/src/com/test/JVM/JVMTest.class
Last modified 2021-10-30; size 426 bytes
MD5 checksum 6a2994261bb809af15cf00631ecb56ba
Compiled from "JVMTest.java"
public class com.test.JVM.JVMTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
//下面代表的就是常量池
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#2 = Class #16 // java/lang/Exception
#3 = Class #17 // com/test/JVM/JVMTest
#4 = Class #18 // java/lang/Object //它指向了CONSTANT_Utf8_info所代表的utf-8编码字符串
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 testMethod
#10 = Utf8 StackMapTable
#11 = Class #16 // java/lang/Exception
#12 = Class #19 // java/lang/Throwable
#13 = Utf8 SourceFile
#14 = Utf8 JVMTest.java
#15 = NameAndType #5:#6 // "<init>":()V
#16 = Utf8 java/lang/Exception
#17 = Utf8 com/test/JVM/JVMTest
#18 = Utf8 java/lang/Object
#19 = Utf8 java/lang/Throwable
{
public com.test.JVM.JVMTest();
//这个是该方法的描述符
//括号里面是方法参数,括号后面的V代表返回值是void
descriptor: ()V
//它的访问访问标志是 public
flags: ACC_PUBLIC
//下面就是该方法的属性Code,储存着字节码指令
//可以先看下面对字节码的讲解,然后再回过头来看下面的字节码
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V //它指向了方法描述符“#1”,表示的是构造方法
4: return
LineNumberTable:
line 3: 0
public void testMethod();
descriptor: ()V
flags: ACC_PUBLIC
Code:
//这里操作栈深最大为1、局部变量表大小为4、方法传入的参数为1
//大家有没有疑问,明明该方法没有传入参数,为什么args_size=1 ?
//是因为我们在构造方法或者普通的非静态方法中是可以用this关键字的
//而这个关键字引用的是实例对象本身,所以这些方法都把实例对象当作参数传入
//所以我们就可以在这些方法中调用this
stack=1, locals=4, args_size=1
0: iconst_3
1: istore_1
2: bipush 100
4: istore_1
5: goto 24
8: astore_2
9: bipush 10
11: istore_1
12: bipush 100
14: istore_1
15: goto 24
18: astore_3
19: bipush 100
21: istore_1
22: aload_3
23: athrow
24: return
//下面这个是异常表
//如果方法中用了try{}catch(){}语句,那么就会生成一个异常表(异常表不是必须的)
//异常表总共有三行,它们对应的意思分别是
//1.如果try语句块(对应字节码指令第0行到第2行)出现了Exception类或者其子类的异常,则跳到catch语句块执行(第8行)
//2.如果try语句块内没有出现Exception及其子类的异常,那么跳到finnal语句块(第18行)
//3.catch语句块无论任何情况下,最后会跳到finnal语句块执行
Exception table:
from to target type
0 2 8 Class java/lang/Exception
0 2 18 any
8 12 18 any
LineNumberTable:
line 7: 0
line 11: 2
line 12: 5
line 8: 8
line 9: 9
line 11: 12
line 12: 15
line 11: 18
line 12: 22
line 13: 24
StackMapTable: number_of_entries = 3
frame_type = 72 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ][]
frame_type = 73 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
frame_type = 252 /* append */
offset_delta = 5
locals = [ int ]
}
SourceFile: "JVMTest.java"
字节码指令简单介绍
字节码指令由两个部分构成,前面是一个字节的、代表着特定操作含义的数字,而后面跟随零或多个参数,代表该操作所需要的参数。
字节码指令可以分为一下几类,因为篇幅有限,只能大概总结一下,详细的请看《深入理解Java虚拟机》或者《Java虚拟机规范》
加载和存储指令
加载和存储指令主要用于操作数栈和局部变量表之前数据的传输。
- 比如将一个局部变量加载到操作数栈,用iload、iload、lload等(load前面的代表操作的数据类型)
- 如果将操作栈中的数据存入局部变量表中用 istore、lstore等
- 将常量加载到操作数栈中:bipush、iconst_m1、iconst_<i>等
方法调用和返回指令
字面意思,这类指令用来执行方法调用
- invokespectial:用于调用一些特殊的实例方法、比如实例初始化方法、私有方法和父类方法
- invokevirtual:用于调用实例方法。
ConstantValue属性
这个属性是用来通知虚拟机对静态属性进行赋值的。Oracle对javac编译的实现规定,对于一个由final或者static修饰的字段,会为其生成对应的ConstantValue来负责变量的初始化,否则只能通过类构造器 ()方法来初始化赋值,下面是它的结构
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | constantvalue_index | 1 |
constantvalue_index代表了对常量池的一个字面量引用,比如CONSTANT_Float_info、CONSTANT_String_info等
小结
通过阅读这篇文章可以快速了解Class文件内的构造,本文是基于笔者在阅读《深入理解Java虚拟机后》后对其的总结并结合了自己的感悟和想法。