我们无时无刻不在与Java类相处,我们看到的永远是它华丽、简洁的被糖包裹着的外表。它里面真的如外表一样简单吗?本文就为你剥开它的糖皮,从字节码的角度看看它的内在。
主要内容:
- javap的使用
- 类文件结构
- 分析反编译的汇编代码文件
剥开类的皮
写一个简单的java
程序Helloworld.java
:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
使用命令 hexdump Helloworld.class
查看16进制字节码:
字节码文件的结构非常紧凑,没有任何冗余的信息,连分隔符都没有(上图中的空格分隔符是为了方便查看设计的,实际是不存在的),它采用的是固定的文件结构和数据类型来实现对内容的分割的。
比如前四个字节cafebabe
就表示class
文件,第5,6个字节0000
是次版本号,第7,8个字节0037
是主版本号。
16进制格式的字节码是可以分析出所有信息,但这对我们也太不友好了。有没有其他适合分析的工具呢?
javap
javap
是jdk
自带的反解析工具。它的作用就是根据class
字节码文件,反解析出当前类对应的code区
(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。
这些信息中,有些信息(如本地变量表、指令和代码行偏移量映射表、常量池中方法的参数名称等等)需要在使用javac
编译成class
文件时,指定参数才能输出,比如,直接javac xx.java
,就不会在生成对应的局部变量表等信息,如果你使用javac -g xx.java
就可以生成所有相关信息了。
javap
的用法格式:
javap <options> <classes> // classes就是你要反编译的class文件
options 一般常用的是 -v 、-l、 -c 三个选项:
- javap -v :不仅会输出行号、本地变量表信息、反编译汇编代码,还会输出当前类用到的常量池等信息。
- javap -l :会输出行号和本地变量表信息。
- javap -c :会对当前class字节码进行反编译生成汇编代码。
使用javap -c [xxx.class]
反编译,可以查看当前class
字节码反编译生成汇编代码:
Compiled from "HelloWorld.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
}
或者可以使用javap -v [xxx.class]
,看到更详细的字节码信息(不仅会输出行号、本地变量表信息、反编译汇编代码,还会输出当前类用到的常量池等信息):
Last modified 2022年4月22日; size 426 bytes
MD5 checksum f0683dc308df53b3b902c277ff029c9a
Compiled from "HelloWorld.java"
public class HelloWorld
minor version: 0
major version: 55
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #5 // HelloWorld
super_class: #6 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
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 // HelloWorld
#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 HelloWorld.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 HelloWorld
#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
{
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 5: 0
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 #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
LineNumberTable:
line 7: 0
line 8: 8
}
SourceFile: "HelloWorld.java"
一个5行的Java代码
,对字节码文件反编译后,居然这么多的信息,可见JVM在背后做了其他“不易为人知”的事。
类文件结构
要想弄清楚JVM
背后对类做了什么,看懂反编译生成的汇编代码。需要先了解类文件结构的知识:
1.魔数
前四个字节表示class
文件:cafebabe
使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过而且不会引起混淆。
2.class文件版本号
魔数后的4个字节是Class文件
的版本号:第5和第6个字节是次版本号,第7和第8个字节是主版本号
3.常量池
在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
(1)字面量:接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值
(2)符号引用:属于编译原理方面的概念,包含以下内容:
-
被模块导出或者开放的包
-
类和接口的全限定名
-
字段的名称和描述符
-
方法的名称和描述符
-
方法句柄和方法类型
-
动态调用点和动态常量
(3)常量表:常量池中每一项常量都是一个表
- 普通数据类型:比如
int
和float
类型,都是5(1+4)个字节。1个字节的tag
表示类型,4个字节的bytes
表示值
-
引用数据类型:共3个字节。1个字节的
tag
表示类型,2个字节的name_index
表示在常量池中的位置
4.访问标志
常量池后的两个字节表示访问标志,用于识别一些类或者接口层次的访问信息。例如:
- Class是类还是接口
- 是否定义为public类型
- 是否定义为abstract类型
- 如果是类的话,是否被声明为final
5.类索引、父类索引,接口索引集合
按顺序排列在访问标志之后。类索引和父类索引用两个u2类型的索引值。接口索引集合,入口的第一项u2类型的数据为接口计数器,表示索引表的容量
6.字段表集合
字段表集合用于描述接口或者类中声明的变量。
字段可以包括的修饰符有字段的作用域,是实例变量还是类变量,可变性,并发可见性、可否被序列化、字段数据类型、字段名称。
而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
(1)字段访问标志
(2)描述符标识字符含义
7.方法表集合
Class文件
存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式。
(1)方法访问标志
和字段表有一些不同。比如方法表的访问标志中没有了ACC_VOLATILE
标志和ACC_TRANSIENT
标志。
因为关键字和transient关键字不能修饰方法。synchronized、native、strictfp和abstract
关键字可以修饰方法,方法表的访问标志中也相应地增加了ACC_SYNCHRONIZED
、ACC_NATIVE
、ACC_STRICTFP
和ACC_ABSTRACT
标志。
8.属性表集合
Class
文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。
再看反编译的汇编代码文件
稍微了解类文件的结构后,我们再回头看反编译的汇编代码文件,直接通过注释来解释一些重要的代码信息:
Last modified 2022年4月22日; size 426 bytes
MD5 checksum f0683dc308df53b3b902c277ff029c9a
Compiled from "HelloWorld.java"
public class HelloWorld
minor version: 0 // 次版本号
major version: 55 // 主版本号
flags: (0x0021) ACC_PUBLIC, ACC_SUPER // 访问标识
this_class: #5 // HelloWorld
super_class: #6 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
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 // HelloWorld
#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 HelloWorld.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 HelloWorld
#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
{
public HelloWorld(); // 构造方法,源码并没有写,所以是JVM自动生成的
descriptor: ()V // 方法描述:无参数的,void返回值
flags: (0x0001) ACC_PUBLIC // 方法标识
Code: // Code区
stack=1, locals=1, args_size=1 // stack: 操作数栈的最大深度1 locals: 局部变量表的长度1 args_size: 方法接收参数的个数1
0: aload_0 // this压栈
1: invokespecial #1 // 调用init方法:Method java/lang/Object."<init>":()V
4: return // void函数返回
LineNumberTable:
line 5: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V // main方法描述:String类型的参数,void返回值
flags: (0x0009) ACC_PUBLIC, ACC_STATIC // 方法标识:public & static类型
Code: // Code区
stack=2, locals=1, args_size=1 // stack: 操作数栈的最大深度2 locals: 局部变量表的长度1 args_size: 方法接收参数的个数1
0: getstatic #2 // 类中获取静态字段:Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // 把常量池中的#3项取出并压入栈:常量池中找#3发现是 Hello World!
5: invokevirtual #4 // 调用方法:Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return // void函数返回
LineNumberTable:
line 7: 0
line 8: 8
}
SourceFile: "HelloWorld.java"
这个HelloWorld程序虽然简单,但五脏基本俱全。又几个大部分这里拎出来介绍一下:
常量池
#序号
作用类似于数组下标,通过该标识就可以找到对应的常量。
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 // HelloWorld
#6 = Class #22 // java/lang/Object
...省略
init方法
我们的源码HelloWorld
,并没有写构造方法,但通过字节码发现存在构造方法,它是JVM
为我们自动生成的,内部会调用Object
的init
方法。
值得注意的是:表面我们看构造方法描述中是没有参数的,但实际上我们看方法接收参数的个数确实1,这不是互相矛盾,很奇怪吗?
是这样的,这个接收参数就是我们常用的this
,构造方法中默认是会带进去的,所以我们看到接收参数实际是1个。
public HelloWorld(); // 构造方法,源码并没有写,所以是JVM自动生成的
descriptor: ()V // 方法描述:无参数的,void返回值
flags: (0x0001) ACC_PUBLIC // 方法标识
Code: // Code区
stack=1, locals=1, args_size=1 // stack: 操作数栈的最大深度1 locals: 局部变量表的长度1 args_size: 方法接收参数的个数1
0: aload_0 // this压栈
1: invokespecial #1 // 调用init方法:Method java/lang/Object."<init>":()V
4: return // void函数返回
main方法
重点来到我们的主代码部分main方法。方法描述、方法标识比较简单,重点在于Code区
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V // main方法描述:String类型的参数,void返回值
flags: (0x0009) ACC_PUBLIC, ACC_STATIC // 方法标识:public & static类型
Code: // Code区
stack=2, locals=1, args_size=1 // stack: 操作数栈的最大深度2 locals: 局部变量表的长度1 args_size: 方法接收参数的个数1
0: getstatic #2 // 类中获取静态字段:Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // 把常量池中的#3项取出并压入栈:常量池中找#3发现是 Hello World!
5: invokevirtual #4 // 调用方法:Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return // void函数返回
Code区
的结构大致如下:
偏移量:指令 常量池引用
1: invokespecial #1
偏移量(字节码中是不存在的)用来标识指令移动的偏移量,后面跟着的是JVM字节码指令,指令后面跟着的是常量池的引用。
例如ldc #3
,常量池中的#3项取出并压入栈,#3
对应的就是Hello World!
常量
JVM指令集
一个字节可以表示0-256,所以最多可以表示256条指令。目前实际有201个指令。
字节码指令的分类,可以从两个维度进行:一是指令的功能,二是指令操作的数据类型。详细的指令集可参考:
通过查阅字节码指令表,慢慢就可以分析出来程序是怎么通过指令一步步执行的了。如果想了解具体的执行过程,可以参考这篇博客:
参考
- 《深入理解Java虚拟机(第三版)》
- 字节码的执行过程分析
- JVM 虚拟机字节码指令表
- 通过javap命令分析java汇编指令