Java字节码
概述
学习 Java 的都知道,我们所编写的 .java
代码文件通过编译将会生成 .class
文件,最初的方式就是通过 JDK 的 javac
指令来编译,再通过 java
命令执行 main 方法所在的类,从而执行我们的 Java 程序。而在这中间所生成的 .class 文件中的内容,就是 JVM 可以处理运行的字节码(Byte Code),它由 JVM 解释为对应系统可运行的机器指令,这也是我们的 Java 程序能够做到一处编译处处执行的原理。
什么是字节码
Java之所以可以“一次编译,到处运行”。
- 一是因为JVM针对各种操作系统、平台都进行了定制。
- 二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class文件)供JVM使用。
因此,也可以看出字节码对于Java生态的重要性。之所以被称之为字节码,是因为字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。在Java中一般是用javac命令编译源代码为字节码文件,一个.java
文件从编译到运行的示例如下:
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/67be77d245e74805be81e050b5eb75af.png)
对于 Java 开发人员来说,平时需要阅读 Byte Code 的场景比较少,但和阅读框架源码能够了解到框架的设计思路一样,阅读 Java Byte Code 也有利于我们理解 Java 一些深层的东西,提高我们解决问题的能力。能够阅读 Byte Code 也有利于我们去理解 Kotlin 或其它运行在 JVM 上的语言,是如何扩展 Java 所没有的特性或语法。
字节码文件结构
首先我们先编写一个简单的 Java 代码作为演示例子,然后编译这个 Hello.java
文件得到 Hello.class
文件。我们知道 .class 是二进制文件,它无法被直接查看,当然我们可以通过一些二进制文件查看工具来阅读里面的内容。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/25b6989d7338434ca82f5771f842f2be.png)
编译后生成.class
文件,打开后是一堆十六进制数,按字节为单位进行分割后展示如上图右侧部分所示。上文提及过,JVM对于字节码是有规范要求的,那么看似杂乱的十六进制符合什么结构呢?JVM规范要求每一个字节码文件都要由十部分按照固定的顺序组成,整体结构如下图:
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/8557eb341b594a7babf24cded06712a0.png)
魔数
一个符合标准的 .class
文件是以 CA FE BA BE
开头,这个四个字节均为魔数,JVM 根据这个开头来判断一个文件是否可能为 .class
文件,如果是才会继续执行。
有趣的是,魔数的固定值是Java之父James Gosling制定的,为CafeBabe(咖啡宝贝),而Java的图标为一杯咖啡。
版本号
魔数后面四个字节 00 00 00 34
是版本号,前两个字节为次版本号,后两个字节为主版本号,在对主版本号进行转换可以得到 52,该序号对应的Java 版本为1.8。
常量池(Constant Pool)
在版本号后面则是常量池(Constant Pool),它包含常量池计数器和常量池数据区两个部分。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/c4e11f9e7d5a4d28b46cd1a54091d013.png)
- 常量池计数器(constant_pool_count):由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。图2中示例代码的字节码前10个字节如下图所示,将十六进制的24转化为十进制值为36,排除掉下标“0”,也就是说,这个类文件中共有35个常量。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/68dba6c2b54a49528679a5a94bd7fd04.png)
访问标志
常量池结束之后的两个字节,描述该Class是类还是接口,以及是否被public
、abstract
、final
等修饰符修饰。JVM规范规定了如下图的访问标志(Access_Flag)。
需要注意的是,JVM并没有穷举所有的访问标志,而是使用按位或操作来进行描述的,比如某个类的修饰符为public final
,则对应的访问修饰符的值为ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/3fb74dec377a499da7d54e404e230202.png)
当前类名
访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
父类名称
当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。
接口信息
父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的n个字节是所有接口名称的字符串常量的索引值。
字段表
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。
字段表也分为两部分,
- 第一部分为两个字节,描述字段个数;
- 第二部分是每个字段的详细信息fields_info。
字段表结构如下图
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/fde7ba28c7384086abf4263d023b8579.png)
方法表
字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,如下图所示:
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/d7cc19924c674e0da3b894c71edc5f63.png)
方法的权限修饰符依然可以通过访问标志
查询得到,方法名和方法的描述符都是常量池中的索引值,可以通过索引值在常量池中找到。
当我们拥有一个 .class
文件时,我们可以通过 javap
来将字节码指令转换为助记符,这个命令有一些参数,你可以通过 javap -help
来查看所有参数的说明,这里为了显示尽量详细的内容,使用 javap -verbose
,其效果如下,但由于内容太长,我们不一次性展示所有内容,而是分区域来进行阅读。
而“方法的属性”这一部分较为复杂,直接借助javap -verbose
将其反编译为人可以读懂的信息进行解读,如下图所示。可以看到属性中包括以下三个部分:
-
Code区:源代码对应的JVM指令操作码,在进行字节码增强时重点操作的就是“Code区”这一部分。
args_size 是参数数量,在主函数中,因为有 args 这个参数,所以在这里 args_size 为 1;
locals 是该方法中的本地变量有多少个,在我们的主函数里面有定义了 3 个变量,加上一个参数,因此有 4 个变量;
stack 是方法在执行过程中,操作数栈中最大深度,这个在之后讲解指令执行过程时可以看出。
在这一行信息之后是字节码指令,一条指令包括偏移量以及执行的指令码,PC Register 利用偏移量来判断指令执行位置。
-
LineNumberTable:行号表,将Code区的操作码和源代码中的行号对应,Debug时会起到作用(源代码走一行,需要走多少个JVM指令操作码)。
LineNumberTable:
line 3: 0
line 4: 2
line 5: 4
line 6: 10
line 7: 17
line 3: 0 代表 Java 源码文件中的第三行代码从偏移量为 0 的位置开始,而继续往下看可以看到第四行代码从偏移量为 2 的位置开始,也就是说第三行代码所对应的字节码指令有 iconst_1 和 istore_1 两条。这也可以让 JVM 执行指令出现错误时,帮助我们定位到对应的源码位置。
-
LocalVariableTable:本地变量表,包含This和局部变量,之所以可以在每一个方法内部都可以调用This,是因为JVM将This作为每一个方法的第一个参数隐式进行传入。当然,这是针对非Static方法而言。
第一个属性 start 为这个变量可见的起始偏移位置,它的值必须是在Code 中存在的偏移量值。
第二个属性 length 为该变量的有效长度,在这个例子中,我们的变量直到方法末尾都有效,因此你会发现 start + lenth 的值都是 18 (方法中执行的指令数)。当我们在一个局部的代码块里面声明一个变量,那么它的有效期长度将会更短。
Slot 为变量在 local variable 中的位置,这可以帮助我们在指令中确定对应的变量,而 Name 则是变量名,Signature 为该变量的类型。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/ad18294238db42758bc180e4e78b5e59.png)
附加属性表
字节码的最后一部分,该项存放了在该文件中类或接口所定义属性的基本信息。
操作数栈和字节码
JVM的指令集是基于栈而不是寄存器,基于栈可以具备很好的跨平台性(因为寄存器指令集往往和硬件挂钩),但缺点在于,要完成同样的操作,基于栈的实现需要更多指令才能完成(因为栈只是一个FILO结构,需要频繁压栈出栈)。另外,由于栈是在内存实现的,而寄存器是在CPU的高速缓存区,相较而言,基于栈的速度要慢很多,这也是为了跨平台性而做出的牺牲。
我们在上文所说的操作码或者操作集合,其实控制的就是这个JVM的操作数栈。为了更直观地感受操作码是如何控制操作数栈的,以及理解常量池、变量表的作用,将add()方法的对操作数栈的操作制作为GIF,如下图14所示,图中仅截取了常量池中被引用的部分,以指令iconst_2开始到ireturn结束。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/img_convert/ff720c5dbedb7a1700f0f44667d5f2a2.gif)
JVM 内存结构
我们的Java程序在运行时是通过 main()
方法启动,它是程序的入口,我们的进程在启动时会为该方法创建一个主线程来执行代码。当我们使用多线程时,那么程序的进程将会拥有多个线程。每个线程的资源都拥有独自的资源,当然它们也可以共享进程的资源,那么在 JVM 中,根据资源的可用范围,可将内存区域分为线程独占和线程共享两个类别。JVM内存布局
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/img_convert/fcf3e0135d903e4a4a8ed46e96629987.png)
对于每一个线程,都可将其拥有的内存空间分为 PC Register、Native Method Stack、JVM Stack 这3个区域,这3个区域对于线程来说都是独占的,其它线程无法进行访问。
-
PC Register 用于记录当前线程指令的执行位置。由于一个进程可能有多个线程,而CPU会在不同线程之间切换,为了能够记录各个线程的当前执行的指令,每个线程都需要有一个 PC Register,来保证各个线程都可以进行独立运算。
-
JVM Stack 用于存放调用方法时压入栈的栈帧。相信学过数据结构的对栈应该不陌生,JVM Stack 压入的单位为栈帧(Frame),用于存储数据、动态链接、方法返回值和调度异常等。每次调用一个方法都会创建一个新的栈帧压入 JVM Stack 来存储该方法的信息,当该方法调用完成时,对应的栈帧也会跟着被销毁。一个栈帧都有自己的局部变量数组、操作数栈、对当前方法类的运行常量池的引用。
-
Native Method Stack 则是用于调用操作系统本地方法时使用的栈空间。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/img_convert/bae6ad91168c90ce8ef5645261b34a29.png)
每个线程都可用访问的内存空间为线程共享区域,它包含 Head 和 Method Area 两个部分,Head 用于存放实例对象,也是 GC 回收的主要区域,而 Method Area 用于存放类结构与静态变量。
现在我们初步了解了 JVM 内存的布局,那么接下来可以继续看指令的执行过程了。
指令的执行过程
由于 Java 程序从 main()
方法开始,我们也是从这个方法的指令开始进行分析。
假设程序运行 0 号指令前的状态如下,在 mian 方法栈帧里面,有着 operand stack(操作数栈),它的最大长度为 2(与 Code 下的 stack 的值一致),此外还有一个 local variable(本地变量表)来存放变量的值,其中下标为 0 的变量为主方法的参数 args,我们直接用这个字符串填充在那里来做一个标识(实际的值可能是一个空数组)。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/img_convert/befa07a08cd1864584022e39370ba2e7.png)
接下来我们一步步执行方法中的指令,在这里我们先对出现的几个指令做一个简单的介绍:
-
iconst_<i>
放一个 int 常量(-1, 0, 1, 2, 3, 4 or 5) 到 operand stack 中
-
istore_<n>
从 operand stack 中获取一个 int 到 local variable 的 n 中
-
iload_<n>
从 local variable 中读取 int 变量 n 的值到操作数栈中
-
invokestatic
调用一个 class 的 static 方法
-
getstatic
从 class 中获取一个 static 字段
-
invokevirtual
调用一个实例方法,基于类的调度
-
return
从方法中返回一个 void,ireturn
从方法中返回 operand stack 栈顶的 int
更多的指令与详细的说明请查看文章最后参考中的官方指令文档
现在我们开始分析指令的执行,我们在上面知道了,我们的 Java 代码所对应的指令分别是偏移量为 0 和 1 的两个,最开始执行的是 0: iconst_1
,该指令会把 int 常量 1 放置到 operand stack 中,之后执行的是 1: istore_1
,把 operand stack 栈顶的 int 常量取出放到 local variable 下标为 1 的变量中,该过程图示如下。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/img_convert/d78969a399d0d6076eadd8bdc1f713f2.png)
我们可以通过查看 LocalVariableTable 得知下标为 1 的变量在我们的 Java 程序中是 int 变量 a,因此上面这两条指令常量 1 赋值给变量 a。同样的,后面两条指令则是将常量 1 赋值给变量 b。这里要注意,操作数栈的数是被取出操作,被取出的数将不会继续在 operand stack 里面。
执行完 0~3 这 4 条指令后,就来到了本例中最为关键的方法调用了。在执行 iload_1
和 iload_2
后,operand stack 中将会存放着变量 a 和 b 的值,作为 invokestatic
调用函数时传入的参数。
而执行到 invokestatic #2
这个指令的时候,该指令为调用一个 class 的 static 方法,也就是调用常量池中 #2
的方法,该方法为 Hello.add:(II)I
。
当执行 invokestatic 时会依次读取 operand stack 的数据作为方法的参数,并创建一个新的栈帧来执行方法,将数据放到 local variable 对应变量位置。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/img_convert/edc3f75393d789d4685cd0b3167481c0.png)
之后开始执行 add()
方法中的指令,首先执行的是两个 iload
指令,将 loca variable 对应下标的变量的值放到 operand stack 中,之后执行 iadd
取出 operand stack 中的值并进行加法运算,再把结果放到,最后执行 ireturn 取出 operand stack 顶部的 int 值进行返回。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/img_convert/61674de9a60ced7620d983b2f549e0cf.png)
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/img_convert/187365cdf840d58968107df6cc5b3f58.png)
当执行完 ireturn
后,add 方法也就执行完成了,对应的栈帧也会跟着销毁。之后回到 main 方法中继续往下执行,到 istore_3
指令,该指令将栈顶的 int 值取出放到了 local variable 中 Solt 为 3 的地方,这样执行完 4~9 这几条指令后就完成了我们代码中的 int c = add(a, b);
这一行代码。那么接下来就是执行 System.out.println(c);
对应的指令将 2 打印到控制台了。
到这里其实我们就已经知道如何去阅读我们代码生成的 Byte Code 了,这里我就不继续往下分析本文例子的代码了,阅读过程中如果遇到了没见过的指令,我们可以在 Oracle 官方指令文档里面查阅对应的说明。
查看字节码工具
如果每次查看反编译后的字节码都使用javap命令的话,好非常繁琐。这里推荐一个Idea插件:jclasslib
。使用效果如图15所示,代码编译后在菜单栏”View”中选择”Show Bytecode With jclasslib”,可以很直观地看到当前字节码文件的类信息、常量池、方法区等信息。
![Java字节码介绍 - Java技术债务](https://img-blog.csdnimg.cn/9e15722ffe31424f809b5f5191f3c0f5.png)
--------------------------------------欢迎叨扰此地址---------------------------------------
本文作者:Java技术债务
原文链接:https://cuizb.top/myblog/article/1671634067
版权声明: 本博客所有文章除特别声明外,均采用 CC BY 3.0 CN协议进行许可。转载请署名作者且注明文章出处。
参考
- 字节码增强技术探索:https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html
- 一文看懂 JVM 内存布局及 GC 原理:https://www.infoq.cn/article/3wyretkqrhivtw4frmr3
- Oracle 官方说明文档:https://docs.oracle.com/javase/specs/jvms/se16/html/jvms-4.html#jvms-4.10
- Oracle 官方指令文档:https://docs.oracle.com/javase/specs/jvms/se16/html/jvms-6.html