我记得开始学习Java的第一堂课时,我的大学老师是这样说的,Java号称是“一次编写,到处运行”,为什么有底气这样说,是因为Java程序并不是直接运行在操作系统上的,它通过不同操作系统上的Java虚拟机实现了“到处运行”的美好愿景。而且我的老师当时还说过,不止Java程序可以在Java虚拟机上运行,其他的程序也同样可以在Java虚拟机上运行。Java虚拟机并不认识具体的某种编程语言,而是编程语言要通过编译器编译成虚拟机认识的格式内容。那么虚拟机认识的格式内容就是本文主要讲述的一个神奇的东东–字节码文件。
本文主要内容是字节码文件的格式及字节码指令。
一、字节码文件
字节码是什么?
一开始我以为字节码是个非常复杂的东西,不修炼几千年根本无法了解其中的奥秘。但是剥丝抽茧后,我发现其实本质上就是一个特定格式排列各个字段信息的二进制流文件,用于虚拟机识别执行。根据Java虚拟机规范,字节码文件使用类似于C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型,无符号数和表。
下面首先来看一段为字节码而造的代码:
package com.earl.se.basics;
public class Demo5 implements Runnable {
private int number = 1;
@Override
public void run() {
System.out.println("Demo5 thread say hello byte code.");
}
public static void main(String[] args){
System.out.println("Main thread say hello byte code.");
new Thread(new Runnable() {
@Override
public void run() {
}
}).start();
}
}
当我通过javac命令编译了Demo5.java后,会生成字节码文件Demo5.class,通过十六进制编辑器WinHex将其打开,可以看到是如下的格式:
哈哈,仅仅是这么几行代码,生成的字节码文件看起来是不是都很头大,毕竟人脑还是适合阅读人类的文字而非机器所喜欢的文字。但是没办法,既然选择要正面刚虚拟机的知识,那么了解其内部的结构是必不可少的环节。接下来先介绍一下字节码文件中各个部分分别代表的意义,概念扫盲之后,其实是可以通过咱们开发常用的IDE来进行查看字节码文件的,这样是不是就显得轻松多了,嘿嘿~
字节码文件结构
字节码文件有两种数据格式:
- 无符号数属于基本数据类型,以u1,u2,u4,u8分别表示1个字节,2个字节,4个字节,8个字节的无符号数。
- 表是由多个无符号数或其他表作为数据项构成的复合数据类型。所有的表都是以“_info”结尾。
下表是按照字节码文件中各字段排列顺序的全部格式:
名称 | 类型 | 数量 |
---|---|---|
魔数(magic) | u4 | 1 |
子版本号(minor_version) | u2 | 1 |
主版本号(major_version) | u2 | 1 |
常量池计数值(constant_pool_count) | u2 | 1 |
常量池(constant_pool) | cp_info | constant_pool_count-1 |
访问标志(access_flag) | u2 | 1 |
类索引(this_class) | u2 | 1 |
父类索引(super_class) | u2 | 1 |
接口计数值(interfaces_count) | u2 | 1 |
接口索引集合(interfaces) | u2 | interface_count |
字段表集合计数器(fields_count) | u2 | 1 |
字段表集合(fields) | field_info | fields_count |
方法表集合计数器(methods_count) | u2 | 1 |
方法表集合(methods) | method_info | methods_count |
属性表集合计数器(attributes_count) | u2 | 1 |
属性表集合(attributes) | attribute_info | attributes_count |
下面依次来说明各个类型是什么含义:
每个字节码文件开头的4个字节称为魔数,作用是确定这个字节码文件是否是一个能被虚拟机接受的字节码文件。在上图字节码文件中看到的开始的四个字节0xCAFEBABE代表的就是Java。
接着魔数后的第5个和第6个两个字节表示子版本号,接下来的第7个和第8个字节表示主版本号。
接下来是常量池相关信息。由于常量池中常量的数量是不固定的,因此在常量池入口处设置了一个常量池计数值。常量池中主要存放的是字面量和符号引用。
字面量即如java语言中的常量概念。
符号引用则属于编译原理方面的概念,包括类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。
在字节码文件中并不存储各个方法,字段的最终内存布局信息,因此当虚拟机运行时,需要从常量池中获取到符号引用,在类创建或运行时在找到具体的内存地址。
接下来的两个字节代表访问标志,用于识别类或接口层次的访问信息,包括这个字节码文件对应的是类还是接口,是否定义为public类型,是否定义为abstract类型,类的话是否是final声明等。
接下来是类索引,父类索引和接口索引集合。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类全限定名,接口索引集合描述这个类实现了哪些接口(如果这个类是接口,那么就是继承了哪些接口)。接口计数值表示这个类或接口实现或继承的接口数量,如果没有实现或继承接口,这个值为0。
字段表集合计数器与字段表集合用来描述接口或类中声明的变量。
方法表集合计数器与方法表集合用来描述接口或类中声明的方法。
属性表集合计数器与属性表集合用来描述Class文件等在某些场景的专有信息。
通过IDE来查看字节码文件
扫盲结束,接下来讲讲如何通过我们常用的IDE来查看字节码文件。这里我选用的是IDEA,首先要安装字节码查看的插件。File->Settings->Plugins,在Marketplace中搜索jclasslib,然后安装jclasslib Bytecode viewer,重启IDEA即可完成安装。
接下来就可以使用jclasslib来查看字节码文件了,还是以Demo5.java为例,在IDEA中打开这个文件,然后点击View->Show Bytecode With jclasslib,这样就会看到如下图的展示:
这样看起来是不是就清楚很多了,右侧的窗口中将字节码文件中的各个字段都直观地展示出来,再无需我们手动去将十六进制转换为人类文字了。
二、字节码指令
字节码指令是一个字节长度的,代表着某种特定操作含义的数字操作码,由操作码及代表此操作所需操作参数构成。主要包含以下的一些指令操作:
- 加载和存储指令:用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,主要包含以下的操作:
- 将局部变量加载到操作栈,涉及指令有load指令
- 将一个数值从操作数栈存储到局部变量表,涉及指令有store指令
- 将一个常量加载到操作数栈,涉及指令有push指令,const指令
- 扩充局部变量表的访问索引指令,wide指令
- 运算指令:用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入操作栈顶。运算主要分为两种,对整型数据进行运算和对浮点型数据进行运算,主要包含以下操作:
- 加法指令:add
- 减法指令:sub
- 乘法指令:mul
- 除法指令:div
- 求余指令:rem
- 取反指令:neg
- 位移指令:shl,shr,ushr
- 按位或指令:or
- 按位与指令:and
- 按位异或指令:xor
- 局部变量自增指令:inc
- 比较指令:cmpg,cmpl,cmp
- 类型转换指令:用于将两种不同的数值类型进行相互转换,这些转换操作主要用于代码中的显式类型转换。
- 对于小类型转大类型的操作,虚拟机直接支持,无需转换指令。
- 对于大类型转小类型的操作,涉及指令有i2b,i2c,i2s,l2i,f2i,f2l,d2i,d2l,d2f。大转小的操作可能会导致精度丢失。
- 对象创建与访问指令,主要涉及以下操作:
- 创建类实例的指令:new
- 创建数组的指令:newarray,anewarray,multianewarray
- 访问类字段和实例字段指令:getfield,getstatic,putfield,putstatic
- 加载数组元素到操作数栈指令:aload
- 将操作数栈的值存储到数组元素指令:astore
- 取数组长度:arraylength
- 检查类实例类型指令:instanceof,checkcast