一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情
本系列专栏:JVM专栏
前言
前面我们大致说了JVM是如何让我们写的Java程序生效的,比如加载、链接和初始化,但是很多细节没有提及到,而这些细节的前提是必须要了解Java字节码即class文件。
为什么这个如此重要呢 理解JVM的运行机制是一方面,熟悉了Java字节码之后,我们可以使用各种方式来修改Java字节码,只要修改后的字节码符合JVM规范即可,而这个技术叫做字节码增强技术,在各种框架、热更新中都有使用,所以了解Java字节码非常重要。
正文
关于这个部分知识,其实我之前有说Android中的dex文件也是这种格式,即都是16进制的文件,里面我们使用了010 Editor工具来查看dex文件,可以查看之前文章:
Android Dex文件详解 - 掘金 (juejin.cn)
而.class文件为什么被称作Java字节码,因为它是16进制值组成,而且JVM以2个16进制值为一组即字节为单位进行读取,所以就称之为Java字节码。
字节码结构
其实字节码结构是固定的,分析字节码和分析dex文件一样,所以我们这里就大概过一遍其中的结构和主要作用。
我先写个简单的代码,然后使用javac进行编译,得到dex文件:
public class ByteCodeDemo{
private int a = 1;
public int add(){
int b = 2;
int c = a + b;
System.out.println(c);
return c;
}
}
然后其class文件:
看过前面dex文件解析的同学看这应该很熟悉,就是一大串的十六进制数,而根据JVM规范,这些十六进制数可以分为下面10个部分:
之前我们的dex文件是使用边分析结构边找到十六进制文件中对应的值,而我们这次分析class文件,直接使用javap指令把class文件反解析为可阅读文件,这样的话也更符合我们平时调试代码的做法。
进行javap -v -p来反解析class文件,得到如下可阅读内容:
public class ByteCodeDemo
minor version: 0
major version: 55
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #5 // ByteCodeDemo
super_class: #6 // java/lang/Object
interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #6.#17 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#18 // ByteCodeDemo.a:I
#3 = Fieldref #19.#20 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Methodref #21.#22 // java/io/PrintStream.println:(I)V
#5 = Class #23 // ByteCodeDemo
#6 = Class #24 // java/lang/Object
#7 = Utf8 a
#8 = Utf8 I
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 add
#14 = Utf8 ()I
#15 = Utf8 SourceFile
#16 = Utf8 ByteCodeDemo.java
#17 = NameAndType #9:#10 // "<init>":()V
#18 = NameAndType #7:#8 // a:I
#19 = Class #25 // java/lang/System
#20 = NameAndType #26:#27 // out:Ljava/io/PrintStream;
#21 = Class #28 // java/io/PrintStream
#22 = NameAndType #29:#30 // println:(I)V
#23 = Utf8 ByteCodeDemo
#24 = Utf8 java/lang/Object
#25 = Utf8 java/lang/System
#26 = Utf8 out
#27 = Utf8 Ljava/io/PrintStream;
#28 = Utf8 java/io/PrintStream
#29 = Utf8 println
#30 = Utf8 (I)V
{
private int a;
descriptor: I
flags: (0x0002) ACC_PRIVATE
public ByteCodeDemo();
descriptor: ()V
flags: (0x0001) 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: iconst_1
6: putfield #2 // Field a:I
9: return
LineNumberTable:
line 1: 0
line 2: 4
public int add();
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: iconst_2
1: istore_1
2: aload_0
3: getfield #2 // Field a:I
6: iload_1
7: iadd
8: istore_2
9: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
12: iload_2
13: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
16: iload_2
17: ireturn
LineNumberTable:
line 5: 0
line 6: 2
line 7: 9
line 8: 16
}
我们根据这个容易阅读的内容来说一下Java字节码的结构组成。
魔数
所有.class文件的前4个字节都是魔数,其值是固定的:0XCAFEBABE,这是用来判断一个文件是否是.class文件。
版本号
魔数之后的4个字节就是版本号,分别代表次版本号和主版本号,比如上面得到的主版本号是55,也就是Java 11。
常量池
紧接着在版本号后面是常量池,常量池中存储2类常量:字面量和符号引用。字面量为代码中声明为final的常量值,而符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符。
这个概念十分重要,尤其是符号引用,因为在前面文章中,我们知道在JVM进行链接前,这些都是符号引用,而在链接解析后才能替换成实际引用,在运行时进行执行,所以这些符号引用有啥,我们来看一下。
常量池部分的数据结构如下:
这里分为2个部分,分别是计数器和数据区,计数器就是常量池中元素的个数,而数据区中的数据分为下面几种:
刚开始看的话肯定会一头雾水,这些东西太多了,其实不然,我们就不分析十六进制数据了,我们直接来分析反编译后的就容易理解了,我们来看一下:
Constant pool:
#1 = Methodref #6.#17 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#18 // ByteCodeDemo.a:I
#3 = Fieldref #19.#20 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Methodref #21.#22 // java/io/PrintStream.println:(I)V
#5 = Class #23 // ByteCodeDemo
#6 = Class #24 // java/lang/Object
#7 = Utf8 a
#8 = Utf8 I
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 add
#14 = Utf8 ()I
#15 = Utf8 SourceFile
#16 = Utf8 ByteCodeDemo.java
#17 = NameAndType #9:#10 // "<init>":()V
#18 = NameAndType #7:#8 // a:I
#19 = Class #25 // java/lang/System
#20 = NameAndType #26:#27 // out:Ljava/io/PrintStream;
#21 = Class #28 // java/io/PrintStream
#22 = NameAndType #29:#30 // println:(I)V
#23 = Utf8 ByteCodeDemo
#24 = Utf8 java/lang/Object
#25 = Utf8 java/lang/System
#26 = Utf8 out
#27 = Utf8 Ljava/io/PrintStream;
#28 = Utf8 java/io/PrintStream
#29 = Utf8 println
#30 = Utf8 (I)V
我们来看一下 #1 中的数据:
#1 = Methodref #6.#17 // java/lang/Object."<init>":()V
这里的类型是 Methodref,由上面可知或者其名字就知道这是方法的符号引用,而根据前面常量池的类型我们可知该类型前2个字节指向声明方法的类描述符的索引,而后2个字节则是名称及类型描述符的索引,所以其值是 #6.#17 这2个也是索引,我们来看一下这2个索引代表的值:
#6 = Class #24 // java/lang/Object
#17 = NameAndType #9:#10 // "<init>":()V
会发现#6的类型是Class,我们立马去查阅一下:类型为Class的是指向全限定名常量项的索引,所以首先它是一个索引,而值是#24代表的值:
#24 = Utf8 java/lang/Object
即#6的Class表示的就是java.lang.Object这个类的索引。
再分析一下#17,它的类型是NameAndType,查阅可知:前2个字节指向该字段或者方法名称常量项的索引,后2个字节指向该字段或者方法描述符常量项的索引,即名称和描述符,我们来看看#9和#10:
#9 = Utf8 <init>
#10 = Utf8 ()V
我们就知道这个方法是<init>,参数是空,返回值是void,这里你或许会对这些V或者啥的简写有点陌生,我们可以查看下图:
这是基本数据类型在JVM内部的符号,
扯远了,到这里我们就可以分析出第一个常量值代表的是啥意思了,它就是Object类的inint方法的名称和描述符,以此类推,其他常量池中的值我们也可以一一推断出。
访问标志
常量池结束后的2个字节,描述该class是类还是接口,以及是被public、final等啥修饰符修饰的,对应的就是:
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
当前类名
再然后的2个字节,描述的是当前类的全限定名,这里值直接就可以使用常量池中的索引,对应的是:
this_class: #5 // ByteCodeDemo
父类名称
再后2个字节描述的是父类的全限定名,同样是使用索引:
super_class: #6 // java/lang/Object
接口信息
紧接着的2个字节是接口计数器,描述了该类或者父类实现的接口数量:
interfaces: 0, fields: 1, methods: 2, attributes: 1
字段表
这部分数据是字段表,用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。
字段表也是2个部分,前面是字段个数,后面是字段的信息:
会发现其实也是很简单,主要就是权限、字段名、描述符等,对应的值如下:
private int a;
descriptor: I
flags: (0x0002) ACC_PRIVATE
这也就表示的是类中private int a;
方法表
字段表后面就是方法表,方法表是字节码的重头戏,它包含的信息比较多,在十六进制中表示如下:
我们还是借助反编译后的文章来看一下:
public int add();
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: iconst_2
1: istore_1
2: aload_0
3: getfield #2 // Field a:I
6: iload_1
7: iadd
8: istore_2
9: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
12: iload_2
13: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
16: iload_2
17: ireturn
LineNumberTable:
line 5: 0
line 6: 2
line 7: 9
line 8: 16
这里Code区就是源代码对应的JVM指令操作码,而各种高级技术就是对这部分进行修改。
这里涉及到栈帧操作,我们本章内容先不说了,内容有点多,我们放到下篇文章继续。
总结
看完本篇文章,你或许就再也不会对class文件表示惧怕,对于方法表中的JVM操作码,我们下篇文章来仔细研究,这样就可以完全掌握class文件了。