同事:你能跟我聊聊class文件么?(上篇)
1.前言
上次在《JAVA代码编译流程是怎样的?》一文中已经聊过了Java源码经过编译器的一系列转换最终生成标准的Class文件的过程,我们用一张图来简单地回顾一下:
Java为了实现“一次编写,到处运行”的跨平台特性,选取了Class文件这一中间格式来保证代码能在不同平台运行。Class文件中记录了源代码中类的字段、方法指令等重要信息。
Class文件可以在不同平台上的不同JVM中运行,它们最终生成的机器指令可能也是有差别的,但是,最终执行的结果一定要保证各平台一致。
有一点值得注意的是,虽然Java是与平台无关的语言,但并不意味着Java虚拟机(JVM)是各平台通用的,不同的平台上运行的JVM是有一定区别的,它们为用户屏蔽了各平台的一些差异。
我们今天要聊的就是源代码和JVM中间的这一座桥梁——Class文件。
还有一件事,记得我们在《JAVA代码编译流程是怎样的?》一文的最后提到的 字节码与Class文件的关系 吗?
在本文中,需要再次强调,字节码只是Class文件中众多组成部分的其中之一。
2.如何阅读Class文件
Class文件的本质其实是一个十六进制的文件,所以其实可以直接用十六进制的编辑器打开Class文件。
如果这么做,则会看到如下的画面:
这就是Class文件最质朴的模样,是不是看得直挠头,完全看不出跟源码的联系呀。
别急,今天就带大家把这块难啃的骨头一点一点都吸收消化了,保证大家看完后面的解析再回过头来看这串字符都会觉得眉清目秀的。
当然,工欲善其事,必先利其器。在学习开始之前,先介绍两个能够比较直观地查看Class文件的工具。
2.1 javap命令
javap
是jdk中自带的支持解析Class文件的工具,通过javap
命令,可以查看生成的Class文件的各个部分结构,先来个简单的例子:
// 源代码
package com.cc.demo;
public class Hello {
private final int a = 100;
int b = 101;
private final int c = 100;
float d = 100f;
public static void main(String[] args){
int e = 102;
}
}
根据源代码生成Class文件之后,我们使用javap
命令对其进行解析:
ZMac-C1WM:demo aobing$ javap Hello.class
Compiled from "Hello.java"
public class com.cc.demo.Hello {
public com.cc.demo.Hello();
public static void main(java.lang.String[]);
}
这样得到的解析结果显然太过于简单了,只显示了基本的类名、方法和参数等,显然无法满足我们解析Class文件的实际需求。
在上述这个例子中,源码中本来没有编写任何的构造函数,但在生成的Class文件中,已经为我们加上了默认的无参构造器。
我们在上一篇《JAVA代码编译流程是怎样的?》中提过,添加默认无参构造器的行为是在填充符号表时完成的。
怎么说呢,看到这里,你大概觉得javap
命令有点东西,但也没有很多东西。
其实因为我们出门没有忘记买装备了,没有发挥它真正的实力。
来,我们再次打开控制台,输入:
javap -help
然后就能见识到javap
的完全体了:
ZMac-C1WM:~ aobing$ javap -help
用法: javap <options> <classes>
其中, 可能的选项包括:
-help --help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath <path> 指定查找用户类文件的位置
-cp <path> 指定查找用户类文件的位置
-bootclasspath <path> 覆盖引导类文件的位置
一般用的比较多的有两个:-c
和-v
。
先看一下javap -c
的效果:
ZMac-C1WM:~ aobing$ javap -c Hello.class
Compiled from "Hello.java"
public class com.cc.demo.Hello {
int b;
float d;
public com.cc.demo.Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 100
7: putfield #2 // Field a:I
10: aload_0
11: bipush 101
13: putfield #3 // Field b:I
16: aload_0
17: bipush 100
19: putfield #4 // Field c:I
22: aload_0
23: ldc #5 // float 100.0f
25: putfield #6 // Field d:F
28: return
public static void main(java.lang.String[]);
Code:
0: bipush 102
2: istore_1
3: return
}
而javap -v
的效果是这样的:
ZMac-C1WM:~ aobing$ javap -v Hello.class
Classfile /Users/aobing/src/com/cc/demo/Hello.class
Last modified 2022-2-11; size 441 bytes
Compiled from "Hello.java"
public class com.cc.demo.Hello
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #8.#31 // java/lang/Object."<init>":()V
#2 = Fieldref #7.#32 // com/cc/demo/Hello.a:I
#3 = Fieldref #7.#33 // com/cc/demo/Hello.b:I
#4 = Fieldref #7.#34 // com/cc/demo/Hello.c:I
#5 = Float 100.0f
#6 = Fieldref #7.#35 // com/cc/demo/Hello.d:F
#7 = Class #36 // com/cc/demo/Hello
#8 = Class #37 // java/lang/Object
#9 = Utf8 a
#10 = Utf8 I
#11 = Utf8 ConstantValue
#12 = Integer 100
#13 = Utf8 b
#14 = Utf8 c
#15 = Utf8 d
#16 = Utf8 F
#17 = Utf8 <init>
#18 = Utf8 ()V
#19 = Utf8 Code
#20 = Utf8 LineNumberTable
#21 = Utf8 LocalVariableTable
#22 = Utf8 this
#23 = Utf8 Lcom/cc/demo/Hello;
#24 = Utf8 main
#25 = Utf8 ([Ljava/lang/String;)V
#26 = Utf8 args
#27 = Utf8 [Ljava/lang/String;
#28 = Utf8 e
#29 = Utf8 SourceFile
#30 = Utf8 Hello.java
#31 = NameAndType #17:#18 // "<init>":()V
#32 = NameAndType #9:#10 // a:I
#33 = NameAndType #13:#10 // b:I
#34 = NameAndType #14:#10 // c:I
#35 = NameAndType #15:#16 // d:F
#36 = Utf8 com/cc/demo/Hello
#37 = Utf8 java/lang/Object
{
int b;
descriptor: I
flags:
float d;
descriptor: F
flags:
public com.cc.demo.Hello();
descriptor: ()V
flags: 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: bipush 100
7: putfield #2 // Field a:I
10: aload_0
11: bipush 101
13: putfield #3 // Field b:I
16: aload_0
17: bipush 100
19: putfield #4 // Field c:I
22: aload_0
23: ldc #5 // float 100.0f
25: putfield #6 // Field d:F
28: return
LineNumberTable:
line 2: 0
line 3: 4
line 4: 10
line 5: 16
line 6: 22
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 this Lcom/cc/demo/Hello;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: bipush 102
2: istore_1
3: return
LineNumberTable:
line 8: 0
line 9: 3
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 args [Ljava/lang/String;
3 1 1 e I
}
SourceFile: "Hello.java"
这下东西是不是就多了起来呢,我们简单的对比一下javap -c
和javap -v
这两个命令的区别:
-
javap -c
命令得到的信息包括类的字段及方法名称,还有一部分就是我们最常说的字节码,记录的是方法中的一系列操作指令。 -
javap -v
命令得到的信息较为丰富不仅包含了字段和方法的具体信息(当然也包含了字节码),还包括了LineNumberTable和Constant Pool等Class文件中的详细信息。 - 有时候这两个命令会与
-p
参数结合使用,例如:javap -p -v
或者javap -p -c
,目的是显示所有类和成员,把private修饰的部分也展示出来。
tips:如果想使用
javap -v
命令看到局部变量表LocalVariableTable
,那么需要在Javac编译的时候就指定参数生成局部变量表,即在javac
的时候加上参数-g:vars
。如果直接使用
javac xx.java
最终生成的字节码中只有LineNumberTable
信息,要用javac -g:vars xx.java
命令来进行编译,再使用javap -v
命令就可以看到局部变量表信息了。
2.2 jclasslib Bytecode Viewer
关于查看Class文件的工具,网上有很多功能相似的产品,例如国外团队写的Java-Class-Viewer工具以及国内大神写的开源的 Classpy、ClassViewer等工具,它们都是非常不错的Class文件分析工具,但是种种原因导致这些项目最终都停止更新,不再维护。
我们主要还是抱着学习的目的,了解一下Class文件的结构。那么工具的易用性就很重要了,我们希望有一个简单易用的工具,不用折腾太多乱七八糟的配置,可以让我们秉持拿来主义,直接就能上手。
这里推荐的是IDEA里的插件jclasslib Bytecode Viewer
,直接在plugins里面安装一下就好啦。
这个插件还是不错的,免费,且简单易用,最重要的是可以直接在IDEA中对照着源码看字节码,使用感非常的nice~
对于这个工具的具体用法我们就不过多赘述,多点几下差不多就会了,重点还是后面的Class文件解析的部分。
3. Class文件结构概述
Class文件中的数据项按顺序存储在class文件中,相邻的项之间没有任何间隔,这样可以使得class文件紧凑且便于解析。
前面提到的Class文件解析的工具基本也都是根据这一特性开发的。因为Class文件中各个部分的顺序完全固定,只要知道各个部分占用空间的大小,按照顺序规范进行读取,就可以完成对Class文件的解析。
Class文件主要分为以下几个部分:
- 魔数(magic number)
- 版本号(minor&major version)
- 常量池(constant pool)
- 访问标记(access flag)
- 类索引(this class)
- 超类索引(super class)
- 接口表索引(interface)
- 字段表(field)
- 方法表(method)
- 属性表(attribute)
我们先看看class文件的基本结构:
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文件中的基本类型是以占用字节数命名的简单数据结构,例如,u1
、u2
、u4
,u8
三种数据结构分别表示占用1、2、4、8字节的无符号整数,还有一种稍复杂的数据结构则是表(table)。
在上面的结构示例中,可以看到,除了u1
、u2
、u4
之外的其他几个结构其实都是表,它们都以_info
结尾,并以独特的名字标识自己的类型,例如方法表的类型就是method_info
,常量池的类型就是cp_info
(cp指的是constant pool)。
Class文件中table类型的另一个特征是 紧跟着表数据之前会使用一个前置的容量计数器来记录表中元素的个数,这样便于明确表的范围。例如constant_pool
就是紧跟着constant_pool_count
出现的,constant_pool_count
记录的是constant_pool
的数据量大小。
这个记录是很重要的,因为Class文件中没有特定的开始和结束符号,只能通过这个count计数器,才知道对应的表占用多少空间,应该在什么位置结束。
3.1 魔数
识别一个文件的类型,最简单的办法就是识别其文件后缀,比如我们看到一个以.png
为后缀的文件,我们马上就判断这是一个png图片文件,知道需要用图片浏览器将其打开。
但如果只通过文件名后缀来判断文件的真实格式,未免有些轻率了。比如,如果我们将.png
文件的后缀改为.class
,我们再用javap
命令将其打开,会发生什么呢?
ZMac-C1WM:~ aobing$ ls
Hello.class Hello.java pngTest.class
ZMac-C1WM:~ aobing$ javap pngTest.class
错误: 读取pngTest.class时出现意外的文件结尾
会由于格式错误无法打开。
在读取Class文件时,最开始需要做的就是校验魔数是否正确,如果加载的Class文件不符合Java规范,那么就会抛出java.lang.ClassFormatError
的异常。
魔数用于对文件格式的二次校验,是判别文件格式的特殊标识,一般位于文件的开头位置,魔数本身没有什么限制,是可以由开发者自由定义的,只要保证不与其他文件格式的魔数重复。
魔数不是Class文件的专属,其他各类文件格式一般都定义了属于自己的魔数,比如png文件的魔数是89 50 4E 47
(十六进制),而Java的Class文件对应的魔数则是CA FE BA BE
(十六进制)。
还记得Java的图标吗,一杯咖啡,
而Class文件中的魔数CA FE BA BE
,既是对Java语言本身logo的呼应,也是一种专属于Java的别样浪漫。
至于为什么是CAFEBABE而不是CAFEBABY,那大概就是因为十六进制中没有Y这个字母吧。
加载Class文件时,最先需要做的就是检查开头的四个字节,如果这四个字节不是CAFEBABE,则直接抛出错误,不用做后续的操作。
3.2 版本号
紧跟魔数的后面四个字节就是版本号啦,版本号由副版本号(minor_version)+主版本号(major_version)构成(副版本号在前),比如用JDK8环境编译好的Class文件中,版本号展示如下:
十六进制的00 00 00 34
对应的十进制数字就是52,也就是说JDK8所对应的Class版本就是52,更多的版本对应如下图所示:
有时候运行代码提示JDK版本问题,就是在这一步检测到Class文件的版本号与当前的运行环境不一致。
只有jdk 1.1版本的副版本号为
00 03
,后续的版本都是副版本为00 00
,而且除1.1版本外,后续每次发布新版本时只变动主版本号,主版本号每次加1。
3.3 常量池
紧随版本号之后的部分是常量池,这一部分在Class文件中占比很高,也是Class文件中最复杂,最重要的部分之一。
在Java代码中,如果涉及到一些数字的操作,需要用到各类的指令。对于占用字节比较少的整数类型,这些简单数字的操作被枚举成了具体的指令,嵌入到了字节码中(后续会进行讲解)。
但对于一些比较大的数字(主要是那些无法用4个字节表示的类型,例如float、double等类型),则会被记录在常量池中,当需要使用这些操作数的时候,会根据索引值到常量池中来查取。
Class文件中,常量池是以表的形式存在的,因此它的前置还有一个用以表示常量池表大小的计数器,常量池整体的结构可以表示为:
{
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
}
constant_pool
的索引从1开始,但count计数的时候会把0的位置也记录数上,也就是说,如果constant_pool_count=5
,那么constant_pool
数组的有效索引为[1]-[4],而不是[0]-[4]。
另外值得一提的一点是,long类型与double类型的数据在cp_info中会占用两个索引的位置,因此constant_pool
中的元素个数可能比cp_info_count
的索引指示地要少。
constant_pool
中每一个cp_info
元素又可分为两个部分:
- tag:通常为1个字节,用于表示该常量项的类型;
- info:该常量项的具体内容,根据不同类型的实际数据占用不同的字节长度;
目前Java中总共支持14种tag,命名的规则是:CONSTANT_XXX_info
,其中XXX是具体的常量类型,可以是Integer、Float、String等。具体如下:
这里面东西比较多,我们挑一个有代表性的来讲讲,我们还是以开头那段代码为例:
package com.cc.demo;
public class Hello {
private final int a = 100;
int b = 101;
private final int c = 100;
float d = 100f;
public static void main(String[] args){
int e = 102;
}
}
编译好之后,我们用javap -p -v
命令解析其对应的Class文件,同时在IDEA中将jclasslib打开。javap -p -v Hello.class
的结果如下(加入-p
参数是为了将private
修饰的字段解析出来):
由于下面这个内容还会用到很多次,我们暂且将其命名为 例子1:
public class com.cc.demo.Hello
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #8.#31 // java/lang/Object."<init>":()V
#2 = Fieldref #7.#32 // com/cc/demo/Hello.a:I
#3 = Fieldref #7.#33 // com/cc/demo/Hello.b:I
#4 = Fieldref #7.#34 // com/cc/demo/Hello.c:I
#5 = Float 100.0f
#6 = Fieldref #7.#35 // com/cc/demo/Hello.d:F
#7 = Class #36 // com/cc/demo/Hello
#8 = Class #37 // java/lang/Object
#9 = Utf8 a
#10 = Utf8 I
#11 = Utf8 ConstantValue
#12 = Integer 100
#13 = Utf8 b
#14 = Utf8 c
#15 = Utf8 d
#16 = Utf8 F
#17 = Utf8 <init>
#18 = Utf8 ()V
#19 = Utf8 Code
#20 = Utf8 LineNumberTable
#21 = Utf8 LocalVariableTable
#22 = Utf8 this
#23 = Utf8 Lcom/cc/demo/Hello;
#24 = Utf8 main
#25 = Utf8 ([Ljava/lang/String;)V
#26 = Utf8 args
#27 = Utf8 [Ljava/lang/String;
#28 = Utf8 e
#29 = Utf8 SourceFile
#30 = Utf8 Hello.java
#31 = NameAndType #17:#18 // "<init>":()V
#32 = NameAndType #9:#10 // a:I
#33 = NameAndType #13:#10 // b:I
#34 = NameAndType #14:#10 // c:I
#35 = NameAndType #15:#16 // d:F
#36 = Utf8 com/cc/demo/Hello
#37 = Utf8 java/lang/Object
{
private final int a;
descriptor: I
flags: ACC_PRIVATE, ACC_FINAL
ConstantValue: int 100
int b;
descriptor: I
flags:
private final int c;
descriptor: I
flags: ACC_PRIVATE, ACC_FINAL
ConstantValue: int 100
float d;
descriptor: F
flags:
public com.cc.demo.Hello();
descriptor: ()V
flags: 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: bipush 100
7: putfield #2 // Field a:I
10: aload_0
11: bipush 101
13: putfield #3 // Field b:I
16: aload_0
17: bipush 100
19: putfield #4 // Field c:I
22: aload_0
23: ldc #5 // float 100.0f
25: putfield #6 // Field d:F
28: return
LineNumberTable:
line 2: 0
line 3: 4
line 4: 10
line 5: 16
line 6: 22
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 this Lcom/cc/demo/Hello;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: bipush 102
2: istore_1
3: return
LineNumberTable:
line 8: 0
line 9: 3
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 args [Ljava/lang/String;
3 1 1 e I
}
SourceFile: "Hello.java"
可以看到,在常量池Constant Pool中,只有一个Integer类型的常量,也就是#12 = Integer 100
。
前面说了,int类型占用的空间不超过4字节,按理来说是不会加入到常量池中的,而且在本类中a
,b
,c
三个变量都是整型的,为什么只有值100被加入到了常量池,变量b的值101怎么没有加入到常量池中?
这是由于当整型变量以final修饰时,它被声明为一个常量,此时才会加入常量池。
而未被final修饰的整型变量(本例中的变量b = 101),其值101就不会被加入到常量池中。
但是像float和double这种类型,无论是否声明为final,都会被加入到常量池中。如本例中的#5 = Float 100.0f
,就是变量d
的值被加入到常量池中。
深层的原因我们在后面讲到第8部分字段表时,会详细解答。
其次同一个常量值不会被重复保存在常量池,例如本例子中的a
和c
都被final修饰,值都是100,当100这个值被加到常量池中后,变量a
会指向该常量池的索引。
然后变量c
也被声明为常量的100,但此时,并不会将100的值在常量池中再保存一次,而是复用已经保存的常量值100,也就是说a
和c
指向常量池中的同一个索引位置。
这一点我们可以从jclasslib得到答案:
可以看到a
和c
的常量值对象都指向cp_info #12
,证明它们复用了同一个常量值。
我们根据已有信息来一次反推,十进制的100对应的是十六进制的64,这点我们从jclasslib中也可以得到一致的信息。
我们前面讲过,CONSTANT_Integer_info
的tag
是3,然后接的是四个字节的常量信息,表示用十六进制表示的常量值100,占用四个字节的空间,也就是00 00 00 64
。
因此我们期待以十六进制方式打开Class文件后能够得到final int a = 100
以03 00 00 00 64
这样的内容保存着(变量名可任意)。
事实是怎么样的呢,如下:
没错,这就是Class文件将100这个值保存在常量池中的样子。
你学会了吗。
未完待续!!
文章转载自公众号:敖丙