前言
本文是针对java字节码相关入门知识的分享,耐心看完本文你会获得:
- 知道Java字节码是什么样的
- 能看懂简单的字节码
我也是每天在不断的学习,深知学习这类原理性知识是真的枯燥无味,而且稍不注意就触及到了知识盲区,很难再继续往下看,所以几乎每次都是从入门到放弃,这其中最大的原因就是学习的过程没有得到合理的正反馈
所以我觉得需要写一篇真正意义上的入门分享系列,用尽量短的篇幅,尽量白话的语言,让对字节码完全没有认知的童鞋能有入门的理解,至少不再畏惧字节码
因为我也是边学习边分享,难免可能会有讲解出错或者写的不好的地方,加上每个人的基础认知也不太一样,如果您有更好的建议,欢迎评论区指出,期望能跟大家一起进步
什么是字节码
我们都知道平时写的java,kotlin等都是便于我们理解的高级语言,而具体到不同的操作系统平台,它们不认识这些语言,只认识0,1这样的二进制机器指令,所以就需要有编译或者解释这么一个过程把高级语言进行一次转换
而相同的逻辑代码,对应到不同的系统平台,二进制指令也是不相同的,打个比方说,同样是一段代码a=1,对应到A操作系统的二进制指令可能是01101100,而对应到B操作系统的二进制指令却是11000011,所以如果我们只编译一次,肯定是不能同时在各个系统平台都跑起来的
那问题来了,为什么java可以一次编译,到处运行了?其实原理很简单,引入一个中间商,让中间商承上启下就好了,而引入的这个中间商就是字节码和虚拟机,java源码经javac编译之后生成的是统一格式的字节码,jvm虚拟机再根据统一规范去读取这个字节码,并解释成对应的二进制指令让机器去执行
在java中,我们用javac去编译源文件,编译之后就会生成一个class字节码文件,因为文件中的内容全是16进制的数据组成,而jvm都是按照两个16进制来读取解释,也就是一个字节的长度,所以称他为字节码文件
字节码到底长啥样
前面提到了,class字节码是有统一规范结构的,这样才能被正确的解释执行,所以下面就开始看看字节码文件的结构到底是什么样的
下面我会尽可能一步步解读一个简单的class文件的关键内容,不会很深(关键深的也不会...),我觉得都是入门级别的,大家也肯定可以学会的,很简单
我们先写一个简单的demo
public class TestByte {
public int a = 101;
public final int b = 102;
public String str = "strValue";
public int TestFunc() {
a = 103;
return a;
}
}
代码非常简单,我们把它编译成class文件,然后用文本编辑器打开它(我用的Sublime Text3)
cafe babe 0000 0037 001e 0a00 0700 1709
0006 0018 0900 0600 1908 001a 0900 0600
1b07 001c 0700 1d01 0001 6101 0001 4901
0001 6201 000d 436f 6e73 7461 6e74 5661
6c75 6503 0000 0066 0100 0373 7472 0100
124c 6a61 7661 2f6c 616e 672f 5374 7269
6e67 3b01 0006 3c69 6e69 743e 0100 0328
2956 0100 0443 6f64 6501 000f 4c69 6e65
4e75 6d62 6572 5461 626c 6501 0008 5465
7374 4675 6e63 0100 0328 2949 0100 0a53
6f75 7263 6546 696c 6501 000d 5465 7374
4279 7465 2e6a 6176 610c 000f 0010 0c00
0800 090c 000a 0009 0100 0873 7472 5661
6c75 650c 000d 000e 0100 2063 6f6d 2f65
7861 6d70 6c65 2f67 7261 646c 6573 7475
6479 2f54 6573 7442 7974 6501 0010 6a61
7661 2f6c 616e 672f 4f62 6a65 6374 0021
0006 0007 0000 0003 0001 0008 0009 0000
0011 000a 0009 0001 000b 0000 0002 000c
0001 000d 000e 0000 0002 0001 000f 0010
0001 0011 0000 003b 0002 0001 0000 0017
2ab7 0001 2a10 65b5 0002 2a10 66b5 0003
2a12 04b5 0005 b100 0000 0100 1200 0000
1200 0400 0000 0900 0400 0b00 0a00 0d00
1000 0f00 0100 1300 1400 0100 1100 0000
2700 0200 0100 0000 0b2a 1067 b500 022a
b400 02ac 0000 0001 0012 0000 000a 0002
0000 0012 0006 0013 0001 0015 0000 0002
0016
果然,文件中的内容全都是16进制的,这tm谁能看懂,不过莫慌,这篇文章我不会去长篇大论的把每一个16进制都翻译出我们看得懂的内容,其实翻译就是查字典的过程,因为字节码有统一的规范,规定了那一部分表示什么样的内容,真的需要看的时候,查一查就可以了,这个很多文章都有介绍
例如可以参考下Java字节码解读
这里我们直接借助javap工具反编译class,就会看到字节码翻译之后的内容,这个结果是自动做了一些格式化处理的,方便我们阅读,接下来我们也是围绕翻译之后的内容进行讲解
Last modified 2022年3月7日; size 489 bytes
MD5 checksum 103b1f4864c339a9479bc47392e00319
Compiled from "TestByte.java"
public class com.example.gradlestudy.TestByte
minor version: 0
major version: 55
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // com/example/gradlestudy/TestByte
super_class: #6 // java/lang/Object
interfaces: 0, fields: 3, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #6.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #7.#24 // com/example/gradlestudy/TestByte.a:I
#3 = Fieldref #7.#25 // com/example/gradlestudy/TestByte.b:I
#4 = String #26 // strValue
#5 = Fieldref #7.#27 // com/example/gradlestudy/TestByte.str:Ljava/lang/String;
#6 = Class #28 // java/lang/Object
#7 = Class #29 // com/example/gradlestudy/TestByte
#8 = Utf8 a
#9 = Utf8 I
#10 = Utf8 b
#11 = Utf8 ConstantValue
#12 = Integer 102
#13 = Utf8 str
#14 = Utf8 Ljava/lang/String;
#15 = Utf8 <init>
#16 = Utf8 ()V
#17 = Utf8 Code
#18 = Utf8 LineNumberTable
#19 = Utf8 funcWithParam
#20 = Utf8 (J)V
#21 = Utf8 SourceFile
#22 = Utf8 TestByte.java
#23 = NameAndType #15:#16 // "<init>":()V
#24 = NameAndType #8:#9 // a:I
#25 = NameAndType #10:#9 // b:I
#26 = Utf8 strValue
#27 = NameAndType #13:#14 // str:Ljava/lang/String;
#28 = Utf8 java/lang/Object
#29 = Utf8 com/example/gradlestudy/TestByte
{
public int a;
descriptor: I
flags: (0x0001) ACC_PUBLIC
public final int b;
descriptor: I
flags: (0x0011) ACC_PUBLIC, ACC_FINAL
ConstantValue: int 102
public java.lang.String str;
descriptor: Ljava/lang/String;
flags: (0x0001) ACC_PUBLIC
public com.example.gradlestudy.TestByte();
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: bipush 101
7: putfield #2 // Field a:I
10: aload_0
11: bipush 102
13: putfield #3 // Field b:I
16: aload_0
17: ldc #4 // String strValue
19: putfield #5 // Field str:Ljava/lang/String;
22: return
LineNumberTable:
line 9: 0
line 11: 4
line 13: 10
line 15: 16
public void funcWithParam(long);
descriptor: (J)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=7, args_size=2
0: new #6 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_3
8: iconst_1
9: istore 4
11: iconst_2
12: istore 5
14: iload 4
16: iload 5
18: iadd
19: istore 6
21: aload_0
22: iload 6
24: putfield #2 // Field a:I
27: return
LineNumberTable:
line 18: 0
line 19: 8
line 20: 11
line 21: 14
line 22: 21
line 24: 27
}
SourceFile: "TestByte.java"
Tips:
javap的常用命令
javap -c class文件名,会反编译输出class文件包含的字段,函数汇编指令等
javap -v class文件名,会输出比较详细的反编译内容,包含常量池这些,上面用的就是-v指令
也可以java --help看看都有什么可选的参数,推荐都动手试一下就知道都有什么用了
class字节码中需要了解的内容
| 名称 | 含义 |
|---|---|
| magic | 魔数 |
| version | 版本号 |
| constant_pool | 常量池 |
| access_flags | 访问标志 |
| this_class | 当前类索引 |
| super_class | 父类索引 |
| interfaces | 接口索引 |
| fields | 字段信息 |
| methods | 方法信息 |
| attributes | 属性信息 |
魔数,版本号(了解就行)
魔数
所有的class字节码文件开头的四个字节都是固定的魔数,固定为OXCAFEBABE,看看上面的16进制字节码内容的开头就能对上
魔数用于JVM去判断是否为class字节码文件,为什么不通过文件后缀名去判断呢?因为后缀是能随意修改的,灰常不安全
版本号
表示编译该class文件的java版本,由四个字节表示,javap翻译之后的结果如图红框的内容
major version: 55,表示主版本号,对应的java版本是java11
常量池,索引(需要理解)
常量池
常量池在字节码中是很重要的一部分,其他部分的内容基本都是通过索引指向常量池中的具体内容,很明显常量池的作用是节省空间,重复利用相同的取值
常量池中的常量主要包含两大类型,字面量和符号引用
字面量主要包括文本字符串,声明为final的常量值等,例如图中编号为#12的常量,对应的就是代码中声明为final的字段b的值102;
符号引用主要包括类,接口的全限定名,字段名称,描述符(即类型),方法的名称和描述符(即返回类型),例如图中编号为#1的常量,对应了构造函数的类,函数名,描述符(返回值)
数据类型的表示
字节码中采用的是字符串来描述数据类型
- 原始数据类型:包括byte,char,double,float,int,long,short,boolean,对应到字节码中的描述就依次变成了,B,C,D,F,I,J,S,Z
- 引用数据类型:也就是类和接口的表示方法,使用(大写的L+类的全路径名+;)表示,其中类的全路径名就是把包名中的分隔符"."替换为“/”,例如String的表示就是Ljava/lang/String;
- 数组:数组也是引用类型,使用[+类型表示,例如int数组,[I, String数组,[Ljava/lang/String;
当前类索引,父类索引,接口等信息
红框中的内容包含了当前类的访问标志,例如是否为public,以及继承的父类和实现的接口列表,具体的类名称都是通过索引来表示,索引指向前面讲到的常量池,另外还包括方法数量,字段数量等摘要信息
字段表集合
字段表即描述类和接口中声明的变量
其中descriptor称为描述符,即返回值; flags是字段修饰符,标识public,final这些标志
注意到字段b相比其他的几个多了一行信息,ConstantValue,这个其实就是字段的属性,下面简单介绍下这个属性
属性
如果你看过其他字节码相关的文章,应该有注意到属性概念,但说实话我刚开始看到的时候是懵B的,因为都说在字段表中最后包含了属性,但是我在demo中看到的属性都是空的,直到我写了一个final的字段,终于算是搞明白了
其实属性就是一种附加信息,例如上面的demo,有final修饰的常量字段,在编译之后,就会有对应的属性信息,而ConstantValue就是一种属性
常见的属性主要包括:
- ConstantValue,只会出现在字段表中,描述的就是常量成员的值
- Code,只会出现在方法表(下面会重点讲到)中,用于描述方法的内容翻译成的字节码指令
- Exceptions,当函数抛出异常时,就会在方法表中存在这个属性
- LineNumberTable:Java源码的行号和字节码指令对应的关系
- LocalVariableTable:局部变量数组,用于保存局部变量名,变量定义所在的行
方法表(重点掌握)
方法表里面的内容非常重要,理解这一块是字节码学习的关键,可以帮我们解答很多原理上的疑惑,这部分可能稍微有点不好理解,如果感觉看不懂建议多看几遍,问题不大
可以看到,这里是有3个方法的,TestByte是默认的构造函数,testFunc和funcWithParam是我们自己定义的
把源码放在这里,方便对照查看
栈帧
要想搞清楚方法表的内容,首先必须要理解栈帧
在JVM中,每个线程都会对应一个虚拟机栈,虚拟机栈的作用就是执行方法,每一个方法的执行都会作为一个栈帧压入到虚拟机栈中,当方法执行完成返回之后又会出栈
所以一个栈帧内会对应一个方法执行所需要的所有数据,包含了局部变量表,操作数栈,动态链接,方法返回地址,其他附加信息,可以看看下面这个图加深理解
其中需要重点关注局部变量表和操作数栈
局部变量表
局部变量表其实就是一个数组,数组里面存储了当前方法所有的局部变量,包括方法的入参,方法内部定义的局部变量
操作数栈
操作数栈是用来具体执行每一步操作的,根据字节码的指令,将数据入栈,使用完成之后又出栈
下面把方法表分成两个大的部分来讲解,一块是方法的静态信息,用来描述方法的基本信息,另外一块是指令码,用来描述方法具体的逻辑操作
方法静态信息
方法的摘要信息包含了descriptor,flags,以及Code里面的stack,locals,args_size,LineNumberTable
-
descriptor 描述方法的参数和返回值,括号里描述参数类型,紧跟括号的描述返回值,例如“(J)V”的意思就是参数为long类型,返回类型为空
-
flags 描述方法的访问标志,例如是否为public,是否为静态等等,这里的ACC_PUBLIC对应表示方法为public的
-
stack 操作数栈的最大深度,执行指令的过程中所需要的最大栈深度
-
locals 局部变量的个数,单位是slot,long和double类型的都占两个slot,其他的包括引用变量都只占一个,每个实例方法都至少有一个,就是this,也就是当前对象,这样才能在方法内任意使用类的其他字段,方法,这里locals为7,因为入参this占一个,long参数占两个,方法内的局部变量一个对象占一个,三个int值占3个,所以加起来一共就是7
-
args_size 入参个数,很明显这里是2,this和一个long型的参数
指令码
划重点,指令码是核心中的核心,每一行表示一个具体的操作指令
一行操作指令主要包括了两个信息,一个是指令码,一个是操作数,指令码的长度为1个字节,所以jvm里面指令码的个数是有限的,不超过256个,操作数可以有多个
例如上图中,istore,bipush这些都是具体的指令,而bipush后面的100就是操作数
下面主要介绍一些常见简单的指令码,实际应用中,指令有很多种,就需要去查表了,查表可以看下 指令集合
加载和存储指令
加载指得是将数据压入操作数栈中,而存储是将数据出栈存储在局部变量表中
加载的数据来源有两种,分为加载常量和从局部变量表加载
1.加载常量
- iconst:将[-1,5]的int值入栈
- bipush:将一个字节能代表的int值[-128,127]入栈
- sipush:将两个字节能代表的int值[-32768-32767]入栈
2.加载局部变量
- iload:加载一个int值入栈,
- aload:加载一个引用变量:
- fload:加载一个float值,其他类型以此类推 这个指令后面跟的操作数指的是局部变量在局部变量表中的slot位置
3.存储到局部变量
- istore:存储一个int值
- astore:存储一个引用变量,以此类推 同样这个指令后面跟的操作数指的是局部变量表中的slot位置
下面对照demo看下具体的例子
- 第8行,iconst_1,加载一个常量int值1入操作数栈
- 第9行,istore 4,把当前栈顶的值存入slot索引为4的局部变量
- 第12行,iload 4,加载局部变量表中slot索引为4的变量
tips: iconst_1这种形式为wide扩展,相当于iconst 1;另外store和load也都支持wide扩展,不过都只支持有限个数,store和load的操作数如果超过3就不支持wide形式了,具体有哪些支持,查表就知道啦
运算指令
对数据进行运算,例如两个数相加的指令iadd,前面的i表示int型,其它类型以此内推
例如在上图demo中,第18行iadd,就是把当前栈顶的两个数据进行相加,运算的结果再入栈
对象创建和访问指令
- new:创建对象,并生成一个指向该对象的引用入栈
- getfield,putfield,getstatic,putstatic,获取对象中字段的数据或给字段设置新的值
方法调用和返回
- invokespecial:调用一些特殊的方法,例如类的初始化方法
- invokestatic:调用静态方法
- ireturn:返回一个整型数,其他类型以此内推,如果函数无返回值直接就是return
很容易发现指令码的一个规律,涉及到加载存储或者运算数据的,都是类型+具体操作
下面看看我们demo中方法的逐行解析
// 函数源码:
public void funcWithParam(long param) {
Object o = new Object();
int a1 = 1;
int b1 = 2;
int c1 = a1 + b1;
a = c1;
}
字节码指令
0: new #6 // 实例一个对象
3: dup // 复制一份栈顶对象的引用并入栈
4: invokespecial #1 // 操作数栈顶的对象引用出栈,调用类的构造方法
7: astore_3 // 将对象引用出栈并存入局部变量表中
8: iconst_1 // 常量1入栈
9: istore 4 // 将常量1出栈存入局部变量表,slot索引为4
11: iconst_2 // 常量2入栈
12: istore 5 // 将常量2出栈并存入局部变量表
14: iload 4 // 将slot索引为4的局部变量加载入栈
16: iload 5 // 将slot索引为5的局部变量加载入栈
18: iadd // 对操作数栈顶的两个数据出栈,进行加操作,结果再入栈
19: istore 6 // 将操作数栈顶的数据出栈并存入局部变量表,slot索引为6
21: aload_0 // 加载slot索引为0的局部变量并入栈,实际就是this
22: iload 6 // 加载slot索引为6的局部变量并入栈
24: putfield #2 // 执行字段写入操作
27: return // 返回
tips:
dup指令的作用
new命令的作用就是创建一个对象实例,并将指向该对象的引用压入操作数栈顶
如果在new之后没有dup命令,直接就是invokespecial,那么invokespecial会消耗掉操作数栈顶的引用,也就是会把这个引用值出栈,而这时,这个新new的对象的引用还没有被存入到局部变量表中,那这样在这个方法内就永远无法访问到这个对象了,所以dup一下,就是把栈顶的引用复制了一份,并且又压入栈中,此时栈里面就会有有两个引用数据,invokespecial消耗一个,然后紧接着astore_3又消耗一个,并存入到局部变量表中
操作数栈和局部变量
可能有很多人还是不太理解操作数栈的最大深度和局部变量是怎么回事,再来个更简单的例子看看
// 源码
public void funTest() {
Object o = new Object();
int a1 = 1;
}
// 字节码
public void funTest();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: new #6 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: iconst_1
9: istore_2
10: return
LineNumberTable:
line 27: 0
line 28: 8
line 29: 10
上面这个函数的最大操作数栈深度为2,局部变量的数量为3,这个到底是怎么计算的?不妨看看下面这个图就明白了,图中分解了一步步执行指令的过程
本文主要列举了简单的指令操作,指令还有很多,但也没有必要去硬记,都是查表就行,具体情况具体分析就好了
以上就是字节码入门的全部内容了,感谢您的阅读,码字不易,您的点赞支持是我持续分享的动力,如果有任何疑问也欢迎评论指出
参考大佬文章