文章目录
字节码简介
什么是字节码
Java的源代码中编译后会生成一个class文件,文件内容为一些JAVA虚拟机指令,这些指令的内容,由多个十六进制值组成,两个十六进制值为一组,例如:
Java虚拟机的指令由一个字节长度、代表某种特定操作含义的操作码(opcode)以及跟随其后的零个或多个的操作数(operand)构成。
Java之所以可以“一次编译,到处运行”,一是因为JVM针对各种操作系统、平台都进行了定制。二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class文件)供JVM使用。因此,也可以看出字节码对于Java生态的重要性。
为什么要学习字节码
对于开发人员,了解字节码可以更准确、直观地理解Java语言中更深层次的东西,比如通过字节码,可以很直观地看到Volatile关键字如何在字节码上生效。另外,字节码增强技术在Spring AOP、各种ORM框架、热部署中的应用屡见不鲜,深入理解其原理对于我们来说大有裨益。除此之外,由于JVM规范的存在,只要最终可以生成符合规范的字节码就可以在JVM上运行,因此这就给了各种运行在JVM上的语言(如Scala、Groovy、Kotlin)一种契机,可以扩展Java所没有的特性或者实现各种语法糖。理解字节码后再学习这些语言,可以“逆流而上”,从字节码视角看它的设计思路,学习起来也“易如反掌”。
如何解读字节码?
准备工作
编写如下代码,例如:
package com.java.jvm.bytecode;
public class IntTests {
public static void main(String[] args) {
int a=10;
int b=20;
int c=a+b;
System.out.println(c);
}
}
直接解读
IntTests.java的源代码编译后,可以通过notepad++(需要安装一下HEX-Editor插件)打开IntTests.class文件,文件内容默认是一种16进制的格式,例如:
JVM对于字节码是有规范要求的,看似杂乱的十六进制是符合一定结构规范的。JVM规范要求每一个字节码文件都要按照固定的顺序组成。
javap指令应用
在IntTests.class目录使用如下代码对类进行反编译,例如:
javap -verbose IntTests.class //可以使用javap –help查看帮助
编译后的内容如下:
Classfile /E:/TCGBIV/DEVCODES/JAVACODES/01-java/target/classes/com/java/jvm/bytecode/IntT
ests.class
Last modified 2022-4-30; size 604 bytes
MD5 checksum fa079575306a9cf10a1a61cfc4722e88
Compiled from "IntTests.java"
public class com.java.jvm.bytecode.IntTests
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #24.#25 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #26.#27 // java/io/PrintStream.println:(I)V
#4 = Class #28 // com/java/jvm/bytecode/IntTests
#5 = Class #29 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 Lcom/java/jvm/bytecode/IntTests;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 a
#18 = Utf8 I
#19 = Utf8 b
#20 = Utf8 c
#21 = Utf8 SourceFile
#22 = Utf8 IntTests.java
#23 = NameAndType #6:#7 // "<init>":()V
#24 = Class #30 // java/lang/System
#25 = NameAndType #31:#32 // out:Ljava/io/PrintStream;
#26 = Class #33 // java/io/PrintStream
#27 = NameAndType #34:#35 // println:(I)V
#28 = Utf8 com/java/jvm/bytecode/IntTests
#29 = Utf8 java/lang/Object
#30 = Utf8 java/lang/System
#31 = Utf8 out
#32 = Utf8 Ljava/io/PrintStream;
#33 = Utf8 java/io/PrintStream
#34 = Utf8 println
#35 = Utf8 (I)V
{
public com.java.jvm.bytecode.IntTests();
descriptor: ()V
flags: 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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/java/jvm/bytecode/IntTests;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintSt
ream;
13: iload_3
14: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 10
line 9: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 args [Ljava/lang/String;
3 15 1 a I
6 12 2 b I
10 8 3 c I
}
SourceFile: "IntTests.java"
为了帮助人们理解,反编译后看到的是十六进制操作码所对应的助记符,十六进制值操作码与助记符的对应关系,以及每一个操作码的用处可以查看Oracle官方文档进行了解,在需要用到时进行查阅即可。
jclasslib插件应用
如果每次查看反编译后的字节码都使用javap命令的话,会非常繁琐。这里推荐一个Idea插件,这个插件的名字为jclasslib。代码在编译后,我们可以在菜单栏”View”中选择”Show Bytecode With jclasslib”,可以很直观地看到当前字节码文件的类信息、常量池、方法区等信息。
字节码解读课堂练习
课堂练习:编写如下代码,并查看分析其字节码指令。
package com.java.jvm.bytecode;
public class IntCompareTests {
public static void main(String[] args) {
Integer a=20;
int b=20;
System.out.println(a==b);
}
}
基于idea的jclasslib插件对IntCompareTests类的自己码进行查看,如图所示:
课堂练习:编写如下代码,基于jclasslib查看字节码,并进行分析字符串是否相等。
package com.java.jvm.bytecode;
public class StringCompareTests {
public static void main(String[] args) {
String s1="hello"+"world";
String s2="helloworld";
String s3="hello";
String s4="world";
String s5=s3+s4;
System.out.println(s1==s2);
System.out.println(s1==s5);
}
}
课堂练习:编写如下代码,分析输出结果
package com.java.jvm.bytecode;
class Rectangle{//矩形
int x=10;
public Rectangle(){
doPrint();
this.x=20;
}
public void doPrint(){
System.out.println("Rectangle.x="+this.x);
}
}
class Square extends Rectangle{//正方形
int x=30;
public Square(){
doPrint();
this.x=40;
}
@Override
public void doPrint() {
System.out.println("Square.x="+this.x);
}
}
public class ExtendsTests {
public static void main(String[] args) {
Rectangle r=new Square();
System.out.println(r.x);
}
}
字节码结构分析(了解)
整体结构
一个class类文件的结构组成如下(u代表一个字节无符号int,其余info类型是复合结构):
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];
}
其中:
magic(魔数)
minor_version(次版本号)
major_version(主版本号)
constant_pool_count(常量池计数器)
constant_pool[constant_pool_count-1](常量池)
access_flags(类的访问标志)
this_class(当前类名索引值)
super_class(父类名索引值)
interfaces_count(接口计数)
interfaces[interfaces_count](接口数组)
fields_count(成员变量计数)
fields[fields_count](成员变量数组)
methods_count(方法计数)
methods[methods_count](方法数组)
attributes_count(属性计数)
attributes[attributes_count](属性数组)
魔数
所有.class文件的前四个字节都是魔数(Magic Number),是class文件的标识。
魔数的固定值为:0xCAFEBABE。
魔数放在文件开头,JVM可以根据文件魔数判断.class文件的合法性。
版本号
版本号为魔数之后的4个字节。例如,版本号“00 00 00 34”。
前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。例如,版本号“00 00 00 34”的次版本号转化为十进制为0,主版本号转化为十进制为52,在Oracle官网中查询序号52对应的主版本号为1.8,所以编译该文件的Java版本号为1.8.0。
版本号和JAVA编译器的对应关系如图所示:
说明,不同版本的Java编译器编译的class文件版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器编译生成的class文件,但是低版本的Java虚拟机不能执行由高版本编译器生成的class文件。否则JVM可能会抛出如下异常:
java.lang.UnsupportedClassVersionError
常量池
常量池(Constant Pool)整体上分为两部分,常量池计数器以及常量池数据区。
常量池计数器
版本号后面是常量池数量,用两个字节表示。如图所示:
常量池容量计数值从1开始,表示常量池有多少个常量,假如constant_pool_count=1表示有0个常量。这里的0024的值为36,其实常量数为35个。
索引值0用于表达不引用任何常量池。
常量池数据区
数据区是由constant_pool_count-1个cp_info结构组成,一个cp_info结构对应一个常量。在字节码中共有14种类型的cp_info,每种类型的结构都是固定的。
常量池中存储两类常量:字面量(Literal)与符号引用(Symbolic References)。字面量为代码中声明为Final的常量值,符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符。
常量池中的每个常量都是一个表,用于描述不同结构数据,例如:
其中,cp_info整体结构大同小异,都是先通过Tag来标识类型,然后后续n个字节来描述长度和(或)数据。先知其所以然,以后可以通过javap -verbose 类名命令,查看JVM反编译后的完整常量池,就可以看到反编译结果,将每一个cp_info结构的类型和值都很明确地呈现了出来。
访问标识
常量池结束之后的两个字节,描述该Class是类还是接口,以及是否被Public、Abstract、Final等修饰符修饰。
JVM规范规定了多个访问标志(Access_Flag)。需要注意的是,JVM并没有穷举所有的访问标志,而是使用按位或操作来进行描述的,比如某个类的修饰符为Public Final,则对应的访问修饰符的值为ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。
类型引用
在访问标记后,会指定该类的类别、父类类别以及实现的接口。
类索引:访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
父类索引:当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。
接口索引:父类名称后的两字节,描述了该类或父类实现了哪些接口,接口的数量以及所有接口名称的字符串常量的索引值。
字段表集合
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。
字段表也分为两部分,第一部分为两个字节,描述字段个数;第二部分是每个字段的详细信息fields_in。
方法表集合
字段表结束后为方法表,用于描述每个方法的信息。
方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性。
属性表集合
方法表集合之后是属性表集合,用于描述的是class文件所携带的辅助信息,比如class文件对应的源文件信息。了解即可。
总结(Summary)
本章节主要描述了字节码是什么,如何解读字节码以及字节码主要由哪及部分构成。