一.简介
Java 虚拟机和字节码的存储格式是实现语言的平台无关性的基础,也就是说 Java 虚拟机并不是 Java 语言所特有的,虚拟机可以和任何能编译成 Class 文件的语言关联,因此只要遵循 Class 文件的语法和结构化的约束,就可以在 虚拟机上运行。
二.Class 文件
1.简介
不管是哪一种语言,Java 虚拟机最终执行的都是 “Class 文件”,任何一个 “Class 文件” 都对应着唯一一个类或者接口的定义的信息,这个” class 文件” 可以是编译器生成的,也可以是通过类加载器直接生成的。这里的双引号要表明的是 “Class 文件”并不一定是以常见文件的形式存在,因为一个类或者接口可以从类加载器生成,但是所有的类和接口最终都会在方法区保存。
2.文件结构
Class 文件的中采用一种伪结构来存储数据,这种结构只有两种数据类型:
- 无符号数,用于代表基本的数据类型,可以描述数字,UTF-8 字符等,以 u1 ,u2 , u4 ,u8 代表 1 个字节,2 个字节,4 个字节, 8 个字节的无符号数。
- 表,由多个无符号数组成,或者由其他表构成的复合数据类型,常以 “_info” 结尾。
Class 文件的格式
1 | ClassFile { |
(1)魔数
确定一个文件是否为一个能被虚拟机接受的 Class 文件。固定值是 0xCAFEBABE,CafeBabe 咖啡宝贝,Java 咖啡 …
(2)主次版本号
Java 的版本号。高版本的JDK 能向下兼容以前版本的 Class 文件。
(3)常量池
常量池是 Class 文件中最重要的资源,占据的文件空间最大,长度不固定。
主要存放两大类的常量,字面量,和符号引用。
- 字面量,通常一个类中引用的一些字符串,或者声明为final 的常量值。
符号引用,一个类中的方法或者字段都有一个对应的内存入口地址,但是 Class 文件并不会直接保存这些地址,而是在运行时通过符号引用去解析定位到具体的内存入口地址,因而 Java 就可以产生运行时类型等一些动态特性,符号引用主要有三种产量:
1.类和接口的全限定名,java.lang.Object 对应的全限定名为 java/lang/Object
2.字段的简单名称和字段的描述符,简单名称就是名字,比如 int i ,那么简单名就是 i,字段的描述符就是描述字段的数据类型,通过一种简略的方式,比如 byte 就用 B 代替,char 就用 C 代替,对象类型就用 “L+classname +; ” ,数组类型就 “[+ 元素类型”
3.方法的简单名称和方法的描述符,简单名同样是名字,比如 get(), 简单名就是 get ,方法的描述符就是方法的参数列表(数量、类型以及顺序)和返回值,比如 ++Object m(byte i, char d, Thread t) {..} ==> BCLjava/lang/Thread;)Ljava/lang/Object;++
(4)访问标志
用于识别一些类或者接口层次的访问信息,包括这个class 是类还是接口,
是否有public , abstract ,final 等标识。
(5)类索引,父类索引,接口索引集合
- 类索引,确定这个类的全限定名
- 父类索引,确定父类的全限定名
- 接口索引集合,即接口的个数和具体的接口内容,描述实现了哪些接口
(6)字段表集合
用于描述接口或者类中声明的变量,不包括从父类继承的,字段包括类变量和实例变量字段,所有的字段通过字段个数,和字段表集合表示,一个字段表的结构如下1
2
3
4
5
6
7field_info {
u2 access_flags; //访问标识
u2 name_index; //字段简单名索引,
u2 descriptor_index; //字段描述描述符索引
u2 attributes_count; //属性个数
attribute_info attributes[attributes_count]; //属性表的具体内容
}
- 访问标识,字段表确定的是字段有什么修饰,public ,private 等
- 字段简单名索引,字段描述描述符索引就是对常量池中字段的简单名和字段描述符的引用
- 属性(通过属性个数和属性表集合表示),包含字段的一些额外信息,比如 ConstantValue 属性就是赋予字段的初始值,没有就默认为 0 .
(7)方法表集合
用于描述接口或者类中声明的变量,不包括从父类继承的,字段包括类变量和实例变量1
2
3
4
5
6
7method_info {
u2 access_flags; //访问标识
u2 name_index; //方法简单名称索引
u2 descriptor_index; //方法描述符符索引
u2 attributes_count; //属性个数
attribute_info attributes[attributes_count]; //属性表的具体内容
}
- 访问标识,字段表确定的是字段有什么修饰,public ,private 等
- 方法简单名索引,方法描述描述符索引就是对常量池中方法的简单名和方法描述符的引用
- 属性表,包含方法的一些额外信息,比如 Code 属性就是方法中代码的具体内容。Exceptions 属性是方法抛出的异常。
(8)属性表集合
一个字段中有 ConstantValue 属性,一个方法中有 Code,Exceptions 等属性,同样在一个类或者接口中也有一些额外的信息。
三.类加载机制
类加载机制就是 虚拟机把描述类或接口的数据从 Class 文件记载到内存,并对数据进行校验,转换解析和初始化最终形成可以被虚拟机直接使用的 Java 类型。有了类加载,在运行期间通过类型的动态加载和动态链接就可以实现了 Java 语言的动态扩展。
1.类加载的过程
类加载包括七个阶段
类加载的过程并不是严格按照上面的顺序进行,有可能一个过程未全部完成就可以进入下一个过程,并且解析阶段能在初始化前也可能在初始化后,但是初始化的时候,加载验证准备是一定完成了的。
(1)加载
通过一个类的全限定名来获取这个类的二进制流,一个 “Class 文件”可以从多个渠道获取,不一定是编写的 Java 程序,通常由一下渠道:
- 从 zip ,jar, 等包读取
- 从网络获取
- 运行时计算生成,动态代理技术
- 由其他文件生成
- 从数据库中读取等
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的 java.lang.class 对象,作为方法区这个类的各种数据的访问接口,这个对象是存放在方法区的,不是 Java 堆。
对于数组,数组的元素是通过类加载器创建的,但是对于数组对象本身,是由虚拟机创建的。
(2)连接-验证
class 文件可以从各个途径而来,因此仅仅靠编译器验证是不够的,大致的验证的类型有;
- 文件格式验证,检查 Class 文件格式的规范
- 元数据验证,主要检查类的继承关系,修饰等
- 字节码验证,检查程序语法,逻辑等。
- 符号引用验证,对类引用到的本类以外的信息进行匹配校验
确保解析动作能够正常执行。
(3)连接-准备
构造与这个类相关联的一个方法表,每个元素都是当前类和父类非私有实例方法的引用。
为类的静态变量分配内存并设置初始值的阶段,这些变量使用的内存都在方法区。对于 static 修饰的,初始化为 0 ,false ,null 等,而对于 static final 则可以初始化为 前面所说的字段表中的属性 ConstantValue ,也就是指定的初始值。1
2public static int i; 初始化为 0
public static final int j = 1; 初始为 1 ,1 存放在字段表中的 ConstantValue 属性。
(4)连接-解析
解析阶段将常量池中的符号引用替换为直接引用的过程。符号引用是以一组符号来描述所引用的目标,就是前面说的类和接口,字段,方法的符号引用。直接引用是可以直接指向目标的指针。之前说过解析可能在初始化前也可能初始化后,就是因为对于一些需要呈现多态特性的类型比如重写方法,有可能在运行的时候才能确定其最终的目标类或者目标方法,这个过程就是通过解析完成的,将未确定的变为确定的。
1.类或接口解析
- 是数组类型,先加载数组元素的类型,然后虚拟机再生成一个数组对象
- 不是数组类型将需要解析的类的全限定名传递给当前类的类加载器去加载这个类
解析完成之前还需要进行符号引用验证确定是否有访问权限。
2.字段解析
- 先解析字段所属的类
- 先对字段所属的类进行查找,是否包含简单名称和字段描述匹配的字段
- 没有就从实现的接口按继承关系查找
- 没有就按继承关系从 父类查找
查找失败就抛出异常,查找成功就放回引用,对这个字段进行权限验证。
3.方法解析
类方法解析
- 先解析方法所属的类
- 判断方法是不是类中方法,不是就抛出异常
- 在方法所属的类中查找
- 没有就从父类中查找
- 没有就从继承接口中查找,找到就证明这是一个抽象类,并抛出异常
查找失败同样抛出异常,成功就直接返回引用。
接口方法解析
- 先解析方法所属的接口
- 判断方法是不是接口方法,不是就抛出异常
- 在方法所属的接口中查找
- 没有就从父接口中查找
查找失败同样抛出异常,成功就直接返回引用。
(5)初始化
初始化就是就职执行一些静态变量的赋值或者静态语句块中的语句,也就是 < clinit > 方法 “class init”。编译器会收集类变量赋值和静态语句块语句,按顺序执行,因此在这个过程中静态语句块但是可以对后定义 static 变量赋值,但是不能访问。
- 虚拟机保证子类的< clinit > 执行之前,父类的< clinit >已经执行
- 执行接口的 < clinit >不会执行父接口的< clinit >,接口的实现类也不会执行接口的 < clinit >
- 如果一个类或者接口没有静态语句块或者类变量赋值操作可以不生成 < clinit >
- 虚拟机保证
在多线程的环境下能被正确的加锁,同步
1 | public class Test{ |
2.类加载器
类加载器的作用就是通过一个类的全限定名,来获取描述此类的二进制字节流。
类加载器的种类
- 启动类加载器,负责加载lib 库中,或者指定路径中的类,启动类加载器无法被 java 程序直接应用。
- 扩展类加载器,负责加载lib/ext 或者指定的路径。
- 应用程序类加载器,系统类加载器,负责加载用户路径上指定的类库。
对于这三种类加载器的使用使用了双亲委派模式:
- 如果一个类加载器收到类加载的请求,首先不会尝试加载这个类,而是交给父类,如果父加载器无法完成,子加载器才会尝试自己加载.
- 类随着加载器一起具备了一种有优先级的层次关系,因此 Object 总是同一个类的加载器加载,因此在各个环境中都是同一个类.
对于同一个类的比较,只有在同一个类的加载器前提下才能讨论是不是同一个类。也就是两个类即使名字一样,来自同一个 Class 文件,只要是被不同的类加载器加载,那么这两个类就一定不相等。
四.执行子系统
java 程序的执行实际上也就是一个个方法的执行过程,从 main 方法开始,运行,输出结果,对于虚拟机也是如此,Java 虚拟机的执行引擎就是输入字节码文件,处理字节码过程,输出执行的过程。方法的执行相关的数据结构就是虚拟机栈(或本地方法栈)。
1.运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构
是虚拟机栈的栈元素,方法的开始到执行完成都对应着一个栈帧的入栈和出栈。
- 局部变量表,用于存放方法参数和方法内部定义的局部变量,以变量槽(Slot)为单位,一个变量槽可以存放一个 32 位的数据类型,boolean ,byte,char 等。在方法执行的时候,虚拟机使用局部变量表完成参数值到参数变量表的传递。
- 操作数栈,是一个后入先出的结构,在方法执行的时候各种指令字节码会往这个操作数栈入栈或者出栈。
- 动态链接,每个栈帧包含了一个指向运行时常量池中该方法符号引用的引用,这个引用是为了方法调用的动态连接。一个符号引用在运行时被转换为直接引用,因此通过动态连接就可以找到具体的方法。
- 方法返回地址,当一个方法正常返回时有可能有或者返回值,返回地址可以是程序计数器的值。当有异常抛出的时候不会产生返回值,返回地址由异常处理器确定。
2.方法的调用
方法调用的过程就是确定被调用方法的具体版本,因为 java 中存在着重写,重载,因此有可能在运行期间才能确定一个目标方法的直接引用。可以将方法的直接引用的确定划分为两个中情况:解析和分派。
- 解析,指的是在编译器就可以确定直接引用的方法,该方法在 java 程序中可以唯一确定,即不是重载,重写方法。
- 分派,可分为两种静态分派和动态分派
- 静态分派,对应重载方法,虽然是重载方法,但是该方法的直接引用可以根据传递的参数就直接确定目标方法,因此,对于重载方法,静态分派的过程实际也是在编译器就可以确定直接引用的方法的过程。
- 动态分配,对于重写的方法,因为对调用者进行类型判断,所以只有在方法执行的时候根据实际类型去确定方法的引用。
对于解析和静态指派也叫做静态绑定,而动态分配也叫动态绑定。
1.方法的指令
在 Java 虚拟机中,一共有 5 条方法调用的字节码指令:
虚方法:除了静态方法,私有实例方法,实例构造器,父类方法,final 修饰的方法以外的方法称为虚方法。
2.解析
只要能被 invokestatic,invokespecial 调用的方法都可以在解析过程中完成将符号引用解析为直接引用,除此之外还有被 final 修饰的方法,虽然 final 修饰的方法是通过 invokesvirtual 指令调用的,但是 final 方法不能被继承,因此也是可以唯一确定的。总的来说,解析过程的方法包括:
静态方法,私有实例方法,实例构造器,super 父类方法 以及final 修饰的方法。
3.静态分派(确定重载方法)
重载即同一个类中或者子类中多个名字相同,但参数类型不同相同的方法
重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入的参数的声明的类型来选取重载方法。具体规则如下:
- 1.在重载方法中,不考虑基本类型的自动装拆箱和可变长参数,进行选取;
- 2.如果没有,就允许自动装拆箱但不允许可变长参数下选取;
- 3.如果没有,就允许自动装拆箱以及可变长参数下选取。
如果在同一阶段有多个适配方法方法就根据继承关系选取最为贴切的方法。
4.动态分派(确定重写方法)
Java 中非私有实例方法会被编译成 invokevirtual 指令,而接口方法被编译成 invokeinterface 指令,对于这两种指令,java 虚拟机都要确定调用者的动态类型,来确定目标方法。对于静态绑定,直接引用指向的是一个具体的目标方法,而对于动态绑定,直接引用指向的是一个方法表的索引。
在前面类加载中连接 -准备过程说过,在类加载时会构造一个方法表,在这个方法表中,子类的方法表包含父类方法表中所有方法,且子类方法表中的索引值与重写的父类方法中的索引相同,对于重写的方法的入口地址,子类中重写的方法的地址是子类实现的方法的入口地址。例如:
父类 A 中的方法表
| 索引 | 方法 | 地址 |
| —- | ———————— | —- |
| 0 | A.toString() | 0000 |
| 1 | A. name() 这是个抽象方法 | 0001 |
子类 a1 中的方法表
| 索引 | 方法 | 地址 |
| —- | ———— | —- |
| 0 | A.toString() | 0000 |
| 1 | a1. name() | 0002 |
子类 a2 中的方法表
| 索引 | 方法 | 地址 |
| —- | ————— | —- |
| 0 | A.toString() | 0000 |
| 1 | a2. name() | 0003 |
| 2 | a2.changeName() | 0004 |
对于重写方法的确定实际上就是在执行指令的时候访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值对应的目标方法。进而将符号引用替换为对应的索引,根据索引和对应的调用类型,就可以确定方法的地址。