文章目录
虚拟机执行子系统
![Java虚拟机架构图](https://i-blog.csdnimg.cn/blog_migrate/3f1e4e240025576e62194409c9fc1be1.png)
- 类文件结构
- 虚拟机类加载机制
- 虚拟机字节码执行引擎
1 类文件结构
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
许多Java虚拟机的执行引擎在执行Java代码时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码来执行)
1.1 概述
Java虚拟机实现语言无关性的基础:虚拟机和字节码存储格式,譬如,javac 编译器负责把Java程序编译成虚拟机所需要的字节码文件,jrubyc编译器负责把JRuby编译成虚拟机所需要的字节码文件,虚拟机丝毫不关心这些字节码来自于那些程序设计语言。《Java虚拟机规范》中要求在Class文件必须应用许多强制性的语法和结构化约束。
1.2 Class类文件的结构
Class文件是一组以8字节为基础单位的二进制流,各个数据项按照严格的顺序紧凑的排列在文件中,中间没有添加任何分隔符。Class文件中存在 “无符号数” 和 “表” 这两种伪结构。无符号数可以用来描述:数字、索引引用、数量值和UTF-8编码构成的字符串值。表 是由多个无符号数或者其他表构成的复合数据类型,整个Class文件本质上也可以看成是一张表。按照下表所示数据项严格顺序排列构成。
类型 | 名称 | 数量 |
---|---|---|
u4 | magic | 1 |
u2 | minor_version | 1 |
u2 | major_version | 1 |
u2 | constant_pool_count | 1 |
cp_info | constant_pool | constant_pool_count-1 |
u2 | access_flags | 1 |
u2 | this_class | 1 |
u2 | super_class | 1 |
u2 | interfaces_count | 1 |
u2 | interfaces | interfaces_count |
u2 | fields_count | 1 |
field_info | fields | fields_count |
u2 | methods_count | 1 |
method_info | methods | methods_counts |
u2 | attributes_count | 1 |
attribute_info | attrbutes | attributes_count |
1.3 Class文件中部分数据项解释
1.3.1 常量池
常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据。有意思的是,对常量池容量的计数是 从 1 开始的,这是为了如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义 。除此之外,Class文件其他集合类型都是从 0 开始计数。
常量池主要存放:编译期生成的字面量(Literal)和 符号引用(Symbolic References)
字面量:文本字符串、被声明为final的常量值、基本数据类型
符号引用:类和结构的完全限定名、字段名称和描述符、方法名称和描述符
常量池中每一项常量都是一个表,截止到 JDK13 常量表中分别有17中不同类型的常量。这17类表都有一个共同的特点就是,表结构起始的第一位是个u1类型的标志位,代表着当前常量属于哪种常量类型。
1.3.2 类索引、父类索引和接口索引列表
类索引和父类索引都是一个u2类型的数据,接口索引列表是一组u2类型的数据的集合,接口索引集合的入口第一项u2类型的数据为接口计数器,表明索引表的容量。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VT67HLhw-1597889547057)(https://i.loli.net/2020/07/27/f7MFyniXrWDGOJZ.png)]
如果Class文件中类索引、父类索引和接口索引内容是:00 01 00 03 00 00
,则与之对应的利用javap命令计算出来的常量池内容是:
const #1 = class #2 // org/fenixsoft/clazz/TestClss
const #2 = Asciz org/fenixsoft/clazz/TestClss
const #3 = class #4 // java/lang/Object
const #4 = Asciz java/lang/Object
1.3.3 方法表集合
首先瞅一眼方法表的总体结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | accrss_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
其中attribute_info 尤为重要,方法里的java代码经过javac编译成字节码指令之后,就存放在方法属性表集合中一个名为 “Code” 的属性里。
Code 属性:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | max_stack | 1 |
u2 | max_locals | 1 |
u4 | code_length | 1 |
u1 | code | code_length |
u2 | exception_table_length | 1 |
exception_info | exception_table | exception_table_length |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
注意:
-
操作数栈和局部变量表直接决定了一个方法的栈帧所耗费的内存。
-
变量槽是虚拟机为局部变量分配内存的最小单位,只有
double
和long
需要两个变量槽。 -
并不是方法中使用了几个局部变量,就把这些变量所占变量槽数量之和作为
max_locals
的值,Java虚拟机将局部变量所占的变量槽进行重用,当代码执行超过一个局部变量的作用域时,这个局部变量所占的变量槽就可以被其他局部变量所占用,javac编译器根据同时生存的局部变量的最大值计算出max_locals
的值。 -
Java虚拟机执行字节码是基于栈的体系结构。
2 虚拟机类加载机制
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
2.1 概述
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
Java语言里,类型的加载、连接、初始化都是在程序运行期间完成的。
2.2 类加载的时机
上图:
![类的生命周期](https://i-blog.csdnimg.cn/blog_migrate/03eb5974976d624deb6864afb8569cb0.png)
-
类型的加载过程必须按照这种顺序按部就班地开始,但是解析阶段不一定,它在某些情况下可以在初始化结束后再开始,这是为了支持动态绑定。注意:按部就班的开始,并不意味着按部就班的完成,这几个阶段通常是交织在一起的。
-
严格规定有且只有一下6种情况立即对进行初始化。
- 遇到
new
、getstatic
、putstatic
、invokestatic
这四条字节码指令时。new
关键字实例化对象时- 读取或设置一个类型的静态字段(被final修饰、已在编译期就把结果放在常量池的静态字段除外)
- 调用一个类型的静态方法时
- 使用
java.lang.reflect
包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化 - 当初始化类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化
- 当虚拟机启动时,先初始化包含
main()
方法的那个类 - 当使用 JDK7 新加入的动态语言支持时
- 当一个接口定义了 JDK8 新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,则该接口要在其之前被初始化
- 遇到
-
被动引用不会引发初始化
-
/* 通过子类引用父类的静态字段,不会引发子类初始化,只会触发父类的初始化 */ public class SuperClass{ static{ System.out.println("SuperClass init"); } public static int value = 123; } public class SubClass extends SuperClass{ static{ System.out.println("SubClass init"); } } public class NotInitialization{ public static void main(Stirng[] args){ System.out.println(SubClass.value); } } /*输出: SuperClass init */
-
/* 通过数组定义引用类,不会触发类的初始化 */ public class NotInitialization{ public static void main(Stirng[] args){ SuperClass[] sca = new SuperClass[10]; } }
-
/* 常量在编译阶段就会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化 */ public class ConstClass{ static{ System.out.println("ConstClass init"); } public static final String HELLOWORLD = "hello world"; } public class NotInitialization{ public static void main(Stirng[] args){ System.out.println(ConstClass.HELLOWORLD); } } /* 输出中并没有"ConstClass init" 这是因为常量传播优化的缘故,直接在编译阶段把常量的值"hello world"直接存储在 NotInitialization的常量池中了,转化为了对自身常量池的引用。 */
-
-
接口有所不同,当一个接口进行初始化时,并不要求其父接口全部初始化了,只有真正使用到父类接口时(如引用父类接口中的常量)才会初始化。
2.3 类加载器
类加载器通过类的全限定名来获取描述该类的二进制字节流,这个动作发生在Java虚拟机外部。
2.3.1 类与类加载器
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,比较两个类是否“相等”,只有在这两个类室是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件。被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就是必定不相等的。
2.3.2 双亲委派模型
自从jdk1.2以来,Java一直保持着三层累加器、双亲委派的类加载架构。
![类加载器](https://i-blog.csdnimg.cn/blog_migrate/a7d4e069f4868defcce52278a7c177f6.png)
-
启动类加载器:负责加载存放在
JAVA_HOME\lib
目录,或者被-Xbootclasspath
参数所指定的路径中存放的,而且是Java虚拟机能够识别(按照文件名进行识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib
目录中也不会被加载)的类库加载到虚拟机内存中。以null值代表引导类加载器(启动类加载器)。 -
扩展类加载器:负责加载
JAVA_HOME\lib\ext
目录中,或被java.ext.dirs
系统变量所指定的路径中所有的类库。 -
应用程序累加器:负责加载用户类路径(ClassPath)上所有的类库。扩展类加载器和应用程序累加器都是继承自抽象类
java.lang.ClassLoader
。 -
双亲委派模型的工作流程:当一个类加载器收到一个类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给他的父类,每一个层次的加载器都是如此,因此所有的请求最终都会传送到最顶层的启动类加载器中 ,只有当父加载器无法完成这个请求时,子加载器才会尝试自己去完成加载。
-
双亲委派模型的优缺点:
- 优点:可以保证java核心类库的安全,即保证由引导类加载器加载的类不能被用户随便替换,用户不能自己随便定义一个二进制名也为 java.lang.String 的类来替换java核心类库的java.lang.String类,否则会抛出ClassCastException。
- 缺点:java核心类库中定义的类是不能使用系统类加载器定义的类。而java提供了很多服务提供者接口(Service Provider Interface SPI),许可第三方来实现这些类的接口。第三方开发的类通常是由应用类加载器在类路径下(classpath)来找到并且定义的。引导类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给系统类加载器,因为它是系统类加载器的祖先类加载器。也就是说,类加载器的双亲委托模型无法解决这个问题,这是双亲委托模型的缺点。
-
线程上下文加载器:为了解决双亲 委派模型的缺点,Java设计团队引入了线程上下文加载器,这其实是一种“舞弊”的行为,因为这是一种父类加载器请求子类加载器完成类加载的行为,仿佛破坏了双亲委派模型的一般规则。但是Java中涉及SPI 的加载几乎都是采用这种方式,譬如:JNDI、JDBC、JCE、JAXB、JBI 等。
2.4 类加载的过程
详细介绍类加载的全过程,即加载、验证、准备、解析和初始化。
2.4.1 加载
- 加载阶段,Java虚拟机完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在堆内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
- 数组类的加载稍显特殊:数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但是数组类的元素类型最终还是靠类加载器来完成加载,并遵守以下规则:
- 如果是引用类型,就递归采用本节中的类加载过程来加载这个组件类型,这个数组被标识在加载该组件类型的类加载器的类名称空间上。
- 如果是基本数据类型,Java虚拟机会把数组标识为与引导类加载器相关联。
- 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,则默认为public。
2.4.2 验证
这一阶段的目的是确保Class文件的字节流包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息不会危害虚拟机的安全。
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
2.4.3 准备
准备阶段是正式为静态变量分配内存并设置初始值的阶段**,JDK8及以后类变量会随着Class对象一起存放在Java堆中。注意:准备阶段进行内存分配的仅包括类变量,而不包括实例变量**(也是定义在方法、构造方法和代码块之外),实例变量将会在对象实例化时随着对象一起分配在Java堆中。如果类变量被final
修饰,那么在准备阶段就会被初始化其指定值。
2.4.4 解析
解析阶段是Java虚拟机将常量池内的符号引用转化为直接引用的过程
- 类或接口解析
- 字段解析
- 方法解析
- 接口方法解析:字段、方法和接口方法解析都是首先通过解析
class_index
来确定其属于哪个类或接口。
2.4.5 初始化
- 初始化阶段就是执行类构造器
<clinit>()
方法的过程,<clinit>()
由javac编译器自动生成。 <clinit>()
是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,静态代码块只能访问到定义在静态语句块之前的变量,定义在静态代码块之后的变量,它可以赋值,但是不能访问。- Java虚拟机保证在子类
<clinit>()
方法执行前,父类的<clinit>()
已经执行完毕,所以Java虚拟机第一个执行<clinit>()
方法的类型肯定是java.lang.object - Java虚拟机必须保证一个类的
<clinit>()
方法在多线程的环境下也能安全的执行,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()
方法,其他线程都将被阻塞。
3 虚拟机字节码执行引擎
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
Java虚拟机把方法作为最小执行单元
3.1 概述
Java虚拟机执行引擎在执行字节码的时候,通常有两种选择,一种是解释执行(通过解释器),另一种是编译执行(通过即时编译器产生本地代码执行)。Java虚拟机输入的是字节码二进制字节流,处理过程是字节码解析执行的等效过程,输出是结果。
3.2 运行时栈帧结构
栈帧是Java虚拟机栈的栈元素,栈帧储存局部变量表、操作数栈、动态连接和方法返回地址等信息。在编译期,需要多大的局部变量表,需要多深的操作数栈就已经被分析出来了,并且被写在了方法表的Code
属性中。
3.2.1 局部变量表
-
局部变量表存放的是方法参数和方法内部存放的局部变量。方法吃Code属性的
max_locals
数据项中确定了该方法所需分配的局部变量表的最大容量。 -
reference 类型表示一个对象实例的引用,Java虚拟机可以通过它一是查找到在Java堆中的数据存放的起始地址或索引,二是找到对象所属数据类型在方法区中的存储的类型信息。
-
Java虚拟机通过索引定位的方式使用局部变量表,如果执行的是实例方法(不被static所修饰),那么局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中通过
this
关键字来访问。
3.2.2 操作数栈
- 操作数栈是一个后入先出的栈,同局部变量表一样,操作数栈的最大深度也是在编译器的时候被写入到Code属性的
max_stacks
之中。 - 一个方法刚开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容。
3.2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一 次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
3.2.4 方法返回地址
- 两种方式退出方法:一是执行引擎遇到任意一个方法返回的字节码指令,二是方法执行过程中遇到了异常,只要在本方法发异常表中没有搜索到匹配的异常处理器,就会导致退出。
- 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压人调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
3.3 方法调用
方法调用阶段唯一的任务就是确定被调用方法的版本。
四个方法调用指令:
invokestatic
:用于调用静态方法
invokespecial
:用于调用实例构造器()方法、私有方法和父类中的方法
invokevirtual
:用于调用所有虚方法
invokeinterface
:用于调用接口方法
invokedynamic
3.3.1 解析
- 方法的调用版本在编译期就已经确定下来,运行期不再改变。
- 只要能够被
invokestatic
和invokespecial
指令调用的方法都可以在解析阶段确定唯一的调用版本,Java语言里主要有5种:静态方法、私有方法、实例构造器、父类方法、被final修饰的方法,这5种方法在类加载的时候就可以把符号引用解析为该方法的直接引用,这些方法统称为“非虚方法”,其他方法就是“虚方法”。
3.3.2 分派
Java语言是一个静态多分派、动态单分派的语言。解析和分派不是二选一的关系,譬如静态方法也可以有重载版本,选择重载版本的过程也是静态分派。
-
静态分派
-
静态分派最典型的应用就是——重载(Overload)
public class StaticDispatch{ static abstract class Human{} static class Man extends Human{} static class Woman extends Human{} public void sayHello(Human guy){ System.out.println("hello guy"); } public void sayHello(Man guy){ System.out.println("hello man"); } public void sayHello(Man guy){ System.out.println("hello woman"); } public static void main(String[] args){ Human man = new Man(); Human woman = new Woman(); StaticDispatch sr = new StaticDispatch(); sr.sayHello(man); sr.sayHello(woman); } } /*输出: hello guy hello guy */
-
我们把代码中的 Human 称为 变量的静态类型,后面的 Man 称为变量的实际类型,静态类型是在编译期就确定下来的,而实际类型在运行期才可以确定下来。
-
在main方法中,调用两次sayHello方法,在确定了方法的接受对象是 sr 的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型,虚拟机在重载时是通过参数的 静态类型而不是实际类型,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本,因此选择sayHello(Human)作为调用目标,并把这个方法的符号引用写到main方法里的两条
invokevirtual
指令的参数中。
-
-
动态分派
-
动态分派最典型的应用就是——重写(Override)
public class DynamicDispatch{ static abstract class Human{ protected abstract void sayHello(); } static class Man extends Human{ protected void sayHello(){ System.out.println("man say hello"); } } static class Woman extends Human{ protected void sayHello(){ System.out.println("woman say hello"); } } public static void main(String args[]){ Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = new Woman(); man.sayHello(); } } /*输出: man say hello woman say hello woman say hello */
-
我们通过javap查看这段代码的字节码,其中方法调用部分如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kdV1vftD-1597889547061)(https://i.loli.net/2020/08/12/PiLNTvFyaHs2JfO.png)]
-
我们看到17行21行方法调用指令,都是
invokevirtual
指令,并且指令的参数也是一样的,都是Human.sayhHello()的符号引用。提醒一下,上一节中静态分派也是invokevirtual
指令。所以我们有必要来了解一下invokevirtual
的执行流程。如下:- 找到操作数栈顶的第一个元素锁指向的对象的实际类型,记作C。
- 如果在C 中找到与常量中的描述符和简单名称都相符的方法,则进行权限 校验,若通过,就直接返回这个方法的直接引用。
- 否则,按照继承关系从下往上依次对C 的父类进行第二步的搜索和校验过程。
- 没有找到合适的方法,抛出异常。
-
字段没有多态性,子类中的字段会遮蔽父类的同名字段。看一个面试题:
public class FieldHasNoPolymorphic{ static class Father{ public int money = 1 //这是一个实例变量 public Father(){ money = 2; showMeTheMoney(); } public void showMeTheMoney(){ System.out.println("I am Father,i have $"+ money); } } static class Son extends Father{ public int money = 3; public Son(){ money = 4; showMeTheMoney(); } public void showMeTheMoney(){ System.out.println("I am Son,i have $"+ money); } } public static void main(String args[]){ Father gay = new Son(); System.out.println("This gay has $"+ gay.money); } } /*输出: I am Son,i have $0 I am Son,i have $4 This gay has $2 */
-
解析:Son类在创建的时候,首先隐式的调用了Father的构造函数,但是Father构造函数中对showMeTheMoney是一个虚方法的调用,实际执行Son:: showMeTheMoney()方法,所以输出“I am son”,虽然父类的money已经被初始化为 2 了,但是Son:: showMeTheMoney() 访问的确实Son中的money字段,这时候结果自然是0,因为它要等到构造函数执行时才进行初始化。main()函数最后一句通过静态类型访问到了父类的money字段。
-
-
虚方法表
ay = new Son();
System.out.println(“This gay has $”+ gay.money);
}
}
/*输出:
I am Son,i have $0
I am Son,i have $4
This gay has $2
*/
```
- 解析:Son类在创建的时候,首先隐式的调用了Father的构造函数,但是Father构造函数中对showMeTheMoney是一个虚方法的调用,实际执行Son:: showMeTheMoney()方法,所以输出“I am son”,虽然父类的money已经被初始化为 2 了,但是Son:: showMeTheMoney() 访问的确实Son中的money字段,这时候结果自然是0,因为它要等到构造函数执行时才进行初始化。main()函数最后一句通过静态类型访问到了父类的money字段。
-
虚方法表
动态分派的执行频率是很高的,动态分派的方法版本选择需要在运行时在接受者类型的方法元数据中反复的搜索合适的目标方法,因此为了提高效率,虚拟机会在方法区建立一个虚方法表,使用虚方法表索引来代替元数据查找。虚方法表中存放着各个方法的**实际入口地址。**虚方法表一般在类加载的连接阶段进行初始化。