目录
一、引言
二、二进制字节码与操作码助记符
三、字节码主要指令
1.加载或存储指令
2.运算指令
3.类型转换指令
4.对象创建和访问指令
5.操作栈管理指令
6.方法调用与返回指令
7.同步指令
四、源码转字节码过程
1.词法解析
2.语法解析
3.语义分析
五、字节码执行过程
0 与 1 是计算机仅能识别的信号 , 经过 0 与 1 的不同组合产生了数字之上的操作。另外,通过不同的组合亦产生了各种字符。同样,可以通过不同的组合产生不同的机器指令。在不同的时代,不同的厂商,机器指令组成的集合是不同的。但毕竟 CPU 是底层基础硬件,指令集通常以扩展兼容的方式向前不断演进。而机器码是离 CPU 指令集最近的编码 , 是 CPU 可以直接解读的指令,因此机器码肯定是与底层硬件系统耦合的。
如果某个程序因为不同的硬件平台需要编写多套代码,这是十分令人崩溃的。Java 的使命就是一次编写、到处执行 。 在不同操作系统、不同硬件平台上,均可以不用修改代码即可顺畅地执行,如何实现跨平台?有一个声音在天空中回响 :计算机工程领域的任何问题都可以通过增加个中间层来解决。因此,中间码应运而生,即"字节码" ( Bytecode)。
Java 所有的指令有 200 个左右,一个字节(8 位)可以存储 256 种不同的指令信息,一个这样的字节称为字节码(Bytecode)。
在代码的执行过程中,JVM 将字节码解释执行,屏蔽对底层操作系统的依赖;JVM 也可以将字节码编译执行,如果是热点代码,会通过JIT动态地编译为机器码,提高执行效率。
如下图某类的二进制字节码所示,十六进制表示的二进制流通常是一个操作指令。起始的4个字节非常特殊,即绿色框的 cafe babe 是 Gosling(Java创始人) 定义的一个魔法数,意思是 Coffee Baby (看看Java图标就知道了),其十进制值为 3405691582。它的作用是标志该文件是一个 Java 类文件,如果没有识别到该标志,说明该文件不是 Java 类文件或者文件已受损,无法进行加载。那么有人会问,文件类型可以通过文件名后缀来判断啊?是的,但是文件名是可以修改的(包括后缀),那么为了保证文件的安全性,将文件类型写在文件内部来保证不被篡改。而红色框代表当前版本号, 0x37 的十进制为 55,是 JDK11的内部版本号。
纯数字的字节码阅读起来像天书一样难,当初汇编语言为了改进机器语言,使用助记符来替代对应的数字指令。JVM在字节码上也设计了一套操作码助记符,使用特殊单词来标记这些数字。如 ICONST_0 代表 00000011 ,即十六进制数为 0x03,ALOAD_0 代表 00101010 ,即 0x2a;POP 代表 01010111,即 0x57 。ICONST 和ALOAD 的首字母表示具体的数据类型,如 A 代表引用类型变量, I 代表 int 类型相关操作,其他类型均是其类型的首字母,例如 FLOAD_0 、 LLOAD_0、 FCONST_0等。
在某个栈帧中,通过指令操作数据在虚拟机栈的局部变量表与操作栈之间来回传输,常见指令如下:
(1)将局部变量加载到操作栈中。 如 ILOAD (将 int 类型的局部变量压入栈)和 ALOAD (将对象引用的局部变量压入栈)等
(2)从操作栈顶存储到局部变量表。 如 ISTORE、 ASTORE等。
(3)将常量加载到操作栈顶(极为高频)。 如 ICONST、BIPUSH 、 SIPUSH 、 LDC 等。
• ICONST 加载的是 -1 ~ 5 的数(ICONST 与 BIPUSH 的加载界限)
• BIPUSH,即 Byte Immediate PUSH ,加载 -128 ~ 127 之间的数
• SIPUSH,即 Short Immediate PUSH ,加载 -32768 ~ 32767 之间的数
• LDC,即 Load Constant ,在 -2147483648 ~ 2147483647 或者是字符串时,JVM 采用 LDC 指令压入栈中
public class A {
public static void main ( String[] args ) {
int a=0;
int b =a + 1 ;
}
}
javac A.java # 编译java文件,这样会生成A.class( 字节码文件)
javap -c A.class # 显示字节码文件(A.class) 详情
Compiled from "A.java"
public class A {
public A(); # 这里是构造方法,如果我们在程序里没有写构造方法, javac会为我们创建一个
Code:
0: aload_0 #将this 放到operand stack
1: invokespecial #1 // Method java/lang/Object."":()V ,将stack上的this给 object 的
4: return
public static void main(java.lang.String[]); #这里是我们的程序入口main
Code:
0: iconst_0 # 将常量0 加载到操作数栈上
1: istore_1 # 将栈顶操作数弹出回复到第一个本地变量, 也就是java 代码里边a 变量
2: iload_1 # 把第一个变量 a 加载到栈顶
3: iconst_1 # 把常数1 加载到栈顶
4: iadd # 执行add操作,add需要两个操作数,那么他会弹出两个栈顶的元素进行计算,并把计算结果放到栈顶
5: istore_2 # 把栈顶元素恢复到第二个本地变量( 也就是b) 中
# 这里可以看到 实现b=a+1 使用了从2-5,共四条字节码指令
6: return
}
对两个操作栈帧上的值进行运算,并把结果写入操作栈顶,如 IADD 、 IMUL 等
显式转换两种不同的数值类型。如 I2L 、 D2F 等
根据类进行对象的创建、初始化、方法调用相关指令,常见指令如下:
(1)创建对象指令。 如 NEW 、NEWARRAY 等。
(2)访问属性指令。如 GETFIELD 、PUTFIELD 、GETSTATIC 等。
(3)检查实例类型指令。 如 INSTANCEOF、CHECKCAST 等。
JVM 提供了直接控制操作栈的指令,常见指令如下:
(1)出栈操作。如 POP 即一个元素,POP2 即两个元素。
(2)复制栈顶元素并压入栈。 如 DUP。
常见指令如下:
(1) INVOKEYIRTUAL 指令:调用对象的实例方法
(2) INVOKESPECIAL 指令:调用实例初始化方法、私有方法、父类方法等
(3) INVOKESTATIC 指令:调用类静态方法
(4) RETURN 指令:返回 VOID 类型
JVM 使用方法结构中的 ACC_SYNCHRONIZED 标志同步方法,指令集中有MONITORENTER 和 MONITOREXIT 支持 synchronized 语义。除字节码指令外 , 还包含 一 些额外信息。例如, LINENUMBER 存储了字节码与源码行号的对应关系 , 方便调试的时候正确地定位到代码的所在行;LOCALVARIABLE 存储当前方法中使用到的局部变量表。
我们编写好的.java 文件是源代码文件,并不能交给机器直接执行,需要将其编译成为字节码甚至是机器码文件。那么静态编译器如何把源码转化成字节码呢?如下图
词法解析阶段,是通过空格分隔出单词 、操作符、控制符等信息,将其形成 token 信息流,传递给语法解析器;
语法解析阶段,把词法解析得到的 token 信息流按照 Java 语法规则组装成一棵语法树,如上图总虚线框所示;
语义分析阶段,需要检查关键字的使用是否合理、类型是否匹配、作用域是否正确等;
当语义分析完成之后,即可生成字节码。
字节码必须通过类加载过程加载到 JVM 环境后,才可以执行。执行有三种模式:第一,解释执行;第二, JIT 编译执行;第三,JIT 编译与解释混合执行(主流JVM默认执行模式)。混合执行模式的优势在于解释器在启动时先解释执行,省去编译时间。随着时间推进,JVM 通过热点代码统计分析,识别高频的方法调用、循环体、公共模块等,基于强大的 JlT 动态编译技术,将热点代码转换成机器码,直接交给 CPU执行。
(1)解释器(Interpreter):读取、解释并逐一执行每一条字节码指令。因为解释器逐一解释和执行指令,因此它能够快速的解释每一个字节码,但对解释结果的执行速度较慢。所有的解释性语言都有类似的缺点。叫做字节码的语言本质上就像一个解释器一样运行。
(2)即时编译器(JIT: Just-In-Time):即时编译器的引入用来弥补解释器的不足。执行引擎先以解释器的方式运行,然后在合适的时机,即时编译器把字节码编译成机器码。然后执行引擎就不再解释方法的执行而是通过使用本地代码直接执行。执行本地代码较逐一解释执行每条指令在速度上有较大的提升,并且通过对本地代码的缓存,编译后的代码能具有更快的执行速度。
然而,即时编译器在编译代码时比逐一解释和执行每条指令更耗时,所以如果代码只会被执行一次,解释执行可能会具有更好的性能。所以,通过JIT编译器,将方法编译成机器码的触发阀值(可以理解为调用方法的次数,例如调1000次)将方法编译为机器码。如果一个被编译过的方法不再被频繁调用,也即不再是热点代码,Hotspot VM会把这些本地代码从缓存中删除并对其再次使用解释器模式执行。
JIT 的作用是将 Java 字节码动态地编译成可以直接发送给处理器指令执行的机器码。
简要流程如下图:
宏观流程如下图:
注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态(刚启动时),如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8 。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的 JVM 均是解释执行,还没有进行热点代码统计和 JIT 动态编译,导致机器启动之后,当前 1/2 发布成功的服务器马上全部宕机,此故障说明了 JIT 的存在。