本博客主要参考周志明老师的《深刻理解Java虚拟机》第二版java
读书是一种跟大神的交流。阅读《深刻理解Java虚拟机》受益不浅,对Java虚拟机有初步的认识。这里写博客主要出于如下三个目的:一方面是记录,方便往后阅读;一方面是加深对内容的理解;一方面是分享给你们,但愿对你们有帮助。程序员
《深刻理解Java虚拟机》全书总结以下:web
序号 | 内容 | 连接地址 |
---|---|---|
1 | 深刻理解Java虚拟机-走近Java | http://www.javashuo.com/article/p-wmquqpab-n.html |
2 | 深刻理解Java虚拟机-Java内存区域与内存溢出异常 | http://www.javashuo.com/article/p-qbxkmyli-a.html |
3 | 深刻理解Java虚拟机-垃圾回收器与内存分配策略 | http://www.javashuo.com/article/p-oivmnobw-ds.html |
4 | 深刻理解Java虚拟机-虚拟机执行子系统 | http://www.javashuo.com/article/p-qaeyzgca-a.html |
5 | 深刻理解Java虚拟机-程序编译与代码优化 | http://www.javashuo.com/article/p-gmrxlpja-o.html |
6 | 深刻理解Java虚拟机-高效并发 | http://www.javashuo.com/article/p-yuiduguo-b.html |
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,倒是编程语言发展的一大步。编程
咱们所编写的每一行代码,要在机器上运行,最终都须要编译成二进制的机器码 CPU 才能识别。可是因为虚拟机的存在,屏蔽了操做系统与 CPU 指令集的差别性,相似于 Java 这种创建在虚拟机之上的编程语言一般会编译成一种中间格式的文件来存储,好比咱们今天要聊的字节码(ByteCode)文件。数组
Java 虚拟机的设计者在设计之初就考虑并实现了其它语言在 Java 虚拟机上运行的可能性。因此并非只有 Java 语言可以跑在 Java 虚拟机上,时至今日诸如 Kotlin、Groovy、Jython、JRuby 等一大批 JVM 语言都可以在 Java 虚拟机上运行。它们和 Java 语言同样都会被编译器编译成字节码文件,而后由虚拟机来执行。因此说类文件(字节码文件)具备语言无关性。安全
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据严格按照顺序紧凑的排列在 Class 文件中,中间无任何分隔符,这使得整个 Class 文件中存储的内容几乎所有都是程序运行的必要数据,没有空隙存在。当遇到须要占用 8 位字节以上空间的数据项时,会按照高位在前的方式分割成若干个 8 位字节进行存储。网络
Java 虚拟机规范规定 Class 文件格式采用一种相似与 C 语言结构体的伪结构体来存储数据,这种伪结构体中只有两种数据类型:无符号数和表。数据结构
类型 | 名称 | 数量 |
---|---|---|
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_count |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
Class 文件中存储的字节严格按照上表中的顺序紧凑的排列在一块儿。哪一个字节表明什么含义,长度是多少,前后顺序如何都是被严格限制的,不容许有任何改变。架构
每一个 Class 文件的头 4 个字节称为魔数(Magic Number),它的惟一做用是肯定这个文件是否为一个能被虚拟机接收的 Calss 文件。之因此使用魔数而不是文件后缀名来进行识别主要是基于安全性的考虑,由于文件后缀名是能够随意更改的。Class 文件的魔数值为「0xCAFEBABE」。并发
紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 两个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。高版本的 JDK 可以向下兼容低版本的 Class 文件,虚拟机会拒绝执行超过其版本号的 Class 文件。
主版本号以后是常量池入口,常量池能够理解为 Class 文件之中的资源仓库,它是 Class 文件结构中与其余项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一,同是它仍是 Class 文件中第一个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,因此在常量池入口须要放置一个 u2 类型的数据来表示常量池的容量「constant_pool_count」,和计算机科学中计数的方法不同,这个容量是从 1 开始而不是从 0 开始计数。之因此将第 0 项常量空出来是为了知足后面某些指向常量池的索引值的数据在特定状况下须要表达「不引用任何一个常量池项目」的含义,这种状况能够把索引值置为 0 来表示。
Class 文件结构中只有常量池的容量计数是从 1 开始的,其它集合类型,包括接口索引集合、字段表集合、方法表集合等容量计数都是从 0 开始。
常量池中主要存放两大类常量:字面量和符号引用。
紧接着常量池以后的两个字节表明访问标志(access_flag),这个标志用于识别一些类或者接口层次的访问信息,包括这个 Class 是类仍是接口;是否认义为 public 类型;是否认义为 abstract 类型;若是是类的话,是否被申明为 final 等。具体的标志位以及标志的含义见下表:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为 public 类型 |
ACC_FINAL | 0x0010 | 是否被声明为 final,只有类可设置 |
ACC_SUPER | 0x0020 | 是否容许使用 invokespecial 字节码指令的新语意,invokespecial 指令的语意在 JKD 1.0.2 中发生过改变,微聊区别这条指令使用哪一种语意,JDK 1.0.2 编译出来的类的这个标志都必须为真 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型,对于接口或者抽象类来讲,此标志值为真,其它类值为假 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并不是由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
access_flags 中一共有 16 个标志位可使用,当前只定义了其中的 8 个,没有使用到的标志位要求一概为 0。
类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合(interfaces)是一组 u2 类型的数据集合,Class 文件中由这三项数据来肯定这个类的继承关系。
字段表集合(field_info)用于描述接口或者类中声明的变量。字段(field)包括类变量和实例变量,但不包括方法内部声明的局部变量。下面咱们看看字段表的结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flag | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
字段修饰符放在 access_flags 中,它与类中的 access_flag 很是类似,都是一个 u2 的数据类型。
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 字段是否为 public |
ACC_PRIVATE | 0x0002 | 字段是否为 private |
ACC_PROTECTED | 0x0004 | 字段是否为 protected |
ACC_STATIC | 0x0008 | 字段是否为 static |
ACC_FINAL | 0x0010 | 字段是否为 final |
ACC_VOLATILE | 0x0040 | 字段是否为 volatile |
ACC_TRANSIENT | 0x0080 | 字段是否为 transient |
ACC_SYNTHETIC | 0x1000 | 字段是否由编译器自动生成 |
ACC_ENUM | 0x4000 | 字段是否为 enum |
Class 文件中对方法的描述和对字段的描述是彻底一致的,方法表中的结构和字段表的结构同样。
由于 volatile 关键字和 transient 关键字不能修饰方法,因此方法表的访问标志中没有 ACC_VOLATILE 和 ACC_TRANSIENT。与之相对的,synchronizes、native、strictfp 和 abstract 关键字能够修饰方法,因此方法表的访问标志中增长了 ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 标志。
对于方法里的代码,通过编译器编译成字节码指令后,存放在方法属性表中一个名为「Code」的属性里面。
在 Class 文件、字段表、方法表中均可以携带本身的属性表(attribute_info)集合,用于描述某些场景专有的信息。
属性表集合不像 Class 文件中的其它数据项要求这么严格,不强制要求各属性表的顺序,而且只要不与已有属性名重复,任何人实现的编译器均可以向属性表中写入本身定义的属性信息,Java 虚拟机在运行时会略掉它不认识的属性。
感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》
感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》
感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》
咱们的源代码通过编译器编译成字节码以后,最终都须要加载到虚拟机以后才能运行。虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终造成能够被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
与编译时须要进行链接工做的语言不一样,Java 语言中类的加载、链接和初始化都是在程序运行期间完成的,这种策略虽然会让类加载时增长一些性能开销,可是会为 Java 应用程序提供高度的灵活性,Java 里天生可动态扩展的语言特性就是依赖运行期间动态加载和动态链接的特色实现的。
例如,一个面向接口的应用程序,能够等到运行时再指定实际的实现类;用户能够经过 Java 预约义的和自定义的类加载器,让一个本地的应用程序运行从网络上或其它地方加载一个二进制流做为程序代码的一部分。
类从被虚拟机从加载到卸载,整个生命周期包含:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为链接(Linking)。这 7 个阶段的发生顺序以下图:
上图中加载、验证、准备、初始化和卸载 5 个阶段的顺序是肯定的,类的加载过程必须按照这种顺序循序渐进的开始「注意,这里说的是循序渐进的开始,并不要求前一阶段执行完才能进入下一阶段」,而解析阶段则不必定:它在某些状况下能够在初始化阶段以后再开始,这是为了支持 Java 的动态绑定。
虚拟机规范中对于何时开始类加载过程的第一节点「加载」并无强制约束。可是对于「初始化」阶段,虚拟机则是严格规定了有且只有如下 5 种状况,若是类没有进行初始化,则必须当即对类进行「初始化」(加载、验证、准备天然须要在此以前开始):
「有且只有」以上 5 种场景会触发类的初始化,这 5 种场景中的行为称为对一个类的主动引用。除此以外,全部引用类的方式都不会触发初始化,称为被动引用。好比以下几种场景就是被动引用:
这里的「加载」是指「类加载」过程的一个阶段。在加载阶段,虚拟机须要完成如下 3 件事:
验证是链接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。验证阶段大体上会完成下面 4 个阶段的检验动做:
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配。这个阶段中有两个容易产生混淆的概念须要强调下:
public static int value = 123;
那么变量 value
在准备阶段事后的初始值为 0 而不是 123,由于这个时候还没有执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译以后,存放于类构造器 () 方法之中,因此把 value 赋值为 123 的动做将在初始化阶段才会执行。这里提到,在「一般状况」下初始值是零值,那相对的会有一些「特殊状况」:若是类字段的字段属性表中存在 ConstantsValue 属性,那在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指的值。假设上面的类变量 value 的定义变为 public static final int value = 123;
,编译时 JavaC 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。前面提到过不少次符号引用和直接引用,那么到底什么是符号引用和直接引用呢?
类初始化阶段是类加载过程当中的最后一步,前面的类加载过程当中,除了在加载阶段用户应用程序能够经过自定义类加载器参与以外,其他动做彻底是由虚拟机主导和控制的。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。初始阶段是执行类构造器 () 方法的过程。
虚拟机设计团队把类加载阶段中的「经过一个类的全限定名来获取描述此类的二进制字节流」这个动做放到 Java 虚拟机外部去实现,以便让应用程序本身决定如何去获取所须要的类。实现这个动做的代码模块称为「类加载器」。
类加载器:类加载器负责加载程序中的类型(类和接口),并赋予惟一的名字予以标识。
对于任意一个类,都须要由加载它的类加载器和这个类自己一同确立其在 Java 虚拟机的惟一性,每一个类加载器都拥有一个独立的类名称空间。也就是说:比较两个类是否「相等」,只要在这两个类是由同一个类加载器加载的前提下才有意义,不然,即便这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不一样,那这两个类就一定不相等。
从 Java 虚拟机的角度来说,只存在两种不一样的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 来实现,是虚拟机自身的一部分;另外一种就是全部其余的类加载器,这些类加载器都由 Java 来实现,独立于虚拟机外部,而且全都继承自抽象类 java.lang.ClassLoader
。
从 Java 开发者的角度来看,类加载器能够划分为:
sun.misc.Launcher$ExtClassLoader
实现,它负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中的全部类库,开发者能够直接使用扩展类加载器;sun.misc.Launcher$App-ClassLoader
实现。getSystemClassLoader()
方法返回的就是这个类加载器,所以也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者能够直接使用这个类加载器,若是应用程序中没有自定义过本身的类加载器,通常状况下这个就是程序中默认的类加载器。咱们的应用程序都是由这 3 种类加载器互相配合进行加载的,在必要时还能够本身定义类加载器。它们的关系以下图所示:
上图中所呈现出的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器之外,其他的类加载器都应当有本身的父类加载器。
Bootstrap Classloader
是在Java
虚拟机启动后初始化的。Bootstrap Classloader
负责加载 ExtClassLoader
,而且将 ExtClassLoader
的父加载器设置为 Bootstrap Classloader
Bootstrap Classloader
加载完 ExtClassLoader
后,就会加载 AppClassLoader
,而且将 AppClassLoader
的父加载器指定为 ExtClassLoader
。Class Loader | 实现方式 | 具体实现类 | 负责加载的目标 |
---|---|---|---|
Bootstrap Loader | C++ | 由C++实现 | %JAVA_HOME%/jre/lib/rt.jar 以及-Xbootclasspath 参数指定的路径以及中的类库 |
Extension ClassLoader | Java | sun.misc.Launcher$ExtClassLoader | %JAVA_HOME%/jre/lib/ext 路径下以及java.ext.dirs 系统变量指定的路径中类库 |
Application ClassLoader | Java | sun.misc.Launcher$AppClassLoader | Classpath 以及-classpath 、-cp 指定目录所指定的位置的类或者是jar 文档,它也是Java 程序默认的类加载器 |
每一个类装载器都有一个本身的命名空间用来保存已装载的类。当一个类装载器装载一个类时,它会经过保存在命名空间里的类全局限定名(Fully Qualified Class Name
) 进行搜索来检测这个类是否已经被加载了。
JVM
及 Dalvik
对类惟一的识别是 ClassLoader id
+ PackageName
+ ClassName
,因此一个运行程序中是有可能存在两个包名和类名彻底一致的类的。而且若是这两个类不是由一个 ClassLoader
加载,是没法将一个类的实例强转为另一个类的,这就是 ClassLoader
隔离性。
为了解决类加载器的隔离问题,JVM
引入了双亲委托机制。
双亲委派模型的工做过程是这样的:若是一个类加载器收到了类加载的请求,它首先不会本身去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每个层次的类加载器都是如此,所以全部的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈本身没法完成这个类加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试本身去加载。
这样作的好处就是 Java 类随着它的类加载器一块儿具有了一种带有优先级的层次关系。例如 java.lang.Object,它放在 rt.jar 中,不管哪个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,所以 Object 类在程序的各类类加载器环境中都是同一个类。相反,若是没有使用双亲委派模型,由各个类加载器自行去加载的话,若是用户本身编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不一样的 Object 类,Java 类型体系中最基本的行为也就没法保证了。
双亲委派模型对于保证 Java 程序运行的稳定性很重要,但它的实现很简单,实现双亲委派模型的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法中,逻辑很清晰:先检查是否已经被加载过,若没有则调用父类加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器做为父加载器。若是父类加载失败,抛出 ClassNotFoundException 异常后,再调用本身的 findClass() 方法进行加载。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 首先,检查请求的类是否是已经被加载过 Class<?> c = findLoadedClass(name); if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 若是父类抛出 ClassNotFoundException 说明父类加载器没法完成加载 } if (c == null) { // 若是父类加载器没法加载,则调用本身的 findClass 方法来进行类加载 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }
关于类文件结构和类加载就经过连续的两篇文章介绍到这里了,下一篇咱们来聊聊「虚拟机的字节码执行引擎」。
感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》
执行引擎是 Java 虚拟机最核心的组成部分之一。「虚拟机」是相对于「物理机」的概念,这两种机器都有代码执行的能力,区别是物理机的执行引擎是直接创建在处理器、硬件、指令集和操做系统层面上的,而虚拟机执行引擎是由本身实现的,所以能够自行制定指令集与执行引擎的结构体系,而且可以执行那些不被硬件直接支持的指令集格式。
在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各类虚拟机执行引擎的统一外观(Facade)。在不一样的虚拟机实现里,执行引擎在执行 Java 代码的时候可能会有解释执行(经过解释器执行)和编译执行(经过即时编译器产生本地代码执行)两种方式,也可能二者都有,甚至还可能会包含几个不一样级别的编译器执行引擎。但从外观上来看,全部 Java 虚拟机的执行引擎是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量、操做数栈、动态连接和方法返回地址等信息。每个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
每个栈帧都包括了局部变量表、操做数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码时,栈帧中须要多大的局部变量表,多深的操做数栈都已经彻底肯定了,而且写入到方法表的 Code 属性之中,所以一个栈帧须要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,不少方法都处于执行状态。对于执行引擎来讲,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法成为当前方法。执行引擎运行的全部字节码指令对当前栈帧进行操做,在概念模型上,典型的栈帧结构以下图:
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序中编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中肯定了该方法所须要分配的局部变量表的最大容量。
操做数栈(Operand Stack)是一个后进先出栈。同局部变量表同样,操做数栈的最大深度也在编译阶段写入到 Code 属性的 max_stacks 数据项中。操做数栈的每个元素能够是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任什么时候候,操做数栈的深度都不会超过 max_stacks 数据项中设定的最大值。
一个方法刚开始执行的时候,该方法的操做数栈是空的,在方法的执行过程当中,会有各类字节码指令往操做数栈中写入和提取内容,也就是入栈和出栈操做。
每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程当中的动态连接(Dynamic Linking)。Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用做为参数,这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化成为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态链接。
当一个方法开始执行后,只有两种方式能够退出这个方法。
一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层方法的调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。
另外一种退出方式是,在方法执行过程当中遇到了异常,而且这个异常没有在方法体内获得处理,不管是 Java 虚拟机内部产生的异常,仍是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会致使方法退出。这种称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给上层调用者产生任何返回值的。
不管采用何种退出方式,在方法退出后都须要返回到方法被调用的位置,程序才能继续执行,方法返回时可能须要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。通常来讲,方法正常退出时,调用者的 PC 计数器的值能够做为返回地址,栈帧中极可能会保存这个计数器值。而方法异常退出时,返回地址是要经过异常处理器表来肯定的,栈帧中通常不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,所以退出时可能执行的操做有:恢复上次方法的局部变量表和操做数栈,把返回值(若是有的话)压入调用者栈帧的操做数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
虚拟机规范容许具体的虚拟机实现增长一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息彻底取决于具体的虚拟机实现。实际开发中,通常会把动态链接、方法返回地址与其余附加信息所有归为一类,成为栈帧信息。
方法调用并不等同于方法执行,方法调用阶段惟一的任务就是肯定被调用方法的版本(即调用哪个方法),暂时还不涉及方法内部的具体运行过程。
在程序运行时,进行方法调用是最为广泛、频繁的操做。前面说过 Class 文件的编译过程是不包含传统编译中的链接步骤的,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在运行时内存布局中的入口地址(至关于以前说的直接引用)。这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,须要在类加载期间,甚至到运行期间才能肯定目标方法的直接引用。
全部方法调用中的目标方法在 Class 文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是方法在程序真正运行以前就有一个可肯定的调用版本,而且这个方法的调用版本在运行期是不可改变的。话句话说,调用目标在程序代码写好、编译器进行编译时就必须肯定下来。这类方法的调用称为解析(Resolution)。
Java 语言中符合「编译器可知,运行期不可变」这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特色决定了它们都不可能经过继承或者别的方式重写其它版本,所以它们都适合在类加载阶段解析。
与之相应的是,在 Java 虚拟机里提供了 5 条方法调用字节码指令,分别是:
只要能被 invokestatic 和 invokespecial 指令调用的方法,均可以在解析阶段中肯定惟一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在加载的时候就会把符号引用解析为直接引用。这些方法能够称为非虚方法,与之相反,其它方法称为虚方法(final 方法除外)。
Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法以外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,可是因为它没法被覆盖,没有其它版本,因此也无需对方法接受者进行多态选择,又或者说多态选择的结果确定是惟一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法。
解析调用必定是个静态过程,在编译期间就能彻底肯定,在类装载的解析阶段就会把涉及的符号引用所有转变为可肯定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则多是静态的也多是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合状况,下面咱们再看看虚拟机中的方法分派是如何进行的。
面向对象有三个基本特征,封装、继承和多态。这里要说的分派将会揭示多态特征的一些最基本的体现,如「重载」和「重写」在 Java 虚拟机中是如何实现的?虚拟机是如何肯定正确目标方法的?
在开始介绍静态分派前咱们先看一段代码。
/** * 方法静态分派演示 */ public class StaticDispatch { private static abstract class Human { } private static class Man extends Human { } private static class Woman extends Human { } private void sayHello(Human guy) { System.out.println("Hello, guy!"); } private void sayHello(Man man) { System.out.println("Hello, man!"); } private void sayHello(Woman woman) { System.out.println("Hello, woman!"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch dispatch = new StaticDispatch(); dispatch.sayHello(man); dispatch.sayHello(woman); } }
运行后这段程序的输出结果以下:
Hello, guy! Hello, guy!
稍有经验的 Java 程序员都能得出上述结论,但为何咱们传递给 sayHello() 方法的实际参数类型是 Man 和 Woman,虚拟机在执行程序时选择的倒是 Human 的重载呢?要理解这个问题,咱们先弄清两个概念。
Human man = new Man();
上面这段代码中的「Human」称为变量的静态类型(Static Type),或者叫作外观类型(Apparent Type),后面的「Man」称为变量为实际类型(Actual Type),静态类型和实际类型在程序中均可以发生一些变化,区别是静态类型的变化仅发生在使用时,变量自己的静态类型不会被改变,而且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可肯定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
弄清了这两个概念,再来看 StaticDispatch 类中 main() 方法里的两次 sayHello() 调用,在方法接受者已经肯定是对象「dispatch」的前提下,使用哪一个重载版本,就彻底取决于传入参数的数量和数据类型。代码中定义了两个静态类型相同可是实际类型不一样的变量,可是虚拟机(准确的说是编译器)在重载时是经过参数的静态类型而不是实际类型做为断定依据的。而且静态类型是编译期可知的,所以在编译阶段, Javac 编译器会根据参数的静态类型决定使用哪一个重载版本,因此选择了 sayHello(Human) 做为调用目标,并把这个方法的符号引用写到 man() 方法里的两条 invokevirtual 指令的参数中。
全部依赖静态类型来定位方法执行版本的分派动做称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,所以肯定静态分派的动做实际上不是由虚拟机来执行的。
另外,编译器虽然能肯定方法的重载版本,可是不少状况下这个重载版本并非「惟一」的,所以每每只能肯定一个「更加合适」的版本。产生这种状况的主要缘由是字面量不须要定义,因此字面量没有显示的静态类型,它的静态类型只能经过语言上的规则去理解和推断。下面的代码展现了什么叫「更加合适」的版本。
public class Overlaod { static void sayHello(Object arg) { System.out.println("Hello, Object!"); } static void sayHello(int arg) { System.out.println("Hello, int!"); } static void sayHello(long arg) { System.out.println("Hello, long!"); } static void sayHello(Character arg) { System.out.println("Hello, Character!"); } static void sayHello(char arg) { System.out.println("Hello, char!"); } static void sayHello(char... arg) { System.out.println("Hello, char...!"); } static void sayHello(Serializable arg) { System.out.println("Hello, Serializable!"); } public static void main(String[] args) { sayHello('a'); } }
上面代码的运行结果为:
Hello, char!
这很好理解,‘a’ 是一个 char 类型的数据,天然会寻找参数类型为 char 的重载方法,若是注释掉 sayHello(chat arg) 方法,那么输出结果将会变为:
Hello, int!
这时发生了一次类型转换, ‘a’ 除了能够表明一个字符,还能够表明数字 97,由于字符 ‘a’ 的 Unicode 数值为十进制数字 97,所以参数类型为 int 的重载方法也是合适的。咱们继续注释掉 sayHello(int arg) 方法,输出变为:
Hello, long!
这时发生了两次类型转换,‘a’ 转型为整数 97 以后,进一步转型为长整型 97L,匹配了参数类型为 long 的重载方法。咱们继续注释掉 sayHello(long arg) 方法,输出变为:
Hello, Character!
这时发生了一次自动装箱, ‘a’ 被包装为它的封装类型 java.lang.Character,因此匹配到了类型为 Character 的重载方法,继续注释掉 sayHello(Character arg) 方法,输出变为:
Hello, Serializable!
这里输出之因此为「Hello, Serializable!」,是由于 java.lang.Serializable 是 java.lang.Character 类实现的一个接口,当自动装箱后发现仍是找不到装箱类,可是找到了装箱类实现了的接口类型,因此紧接着又发生了一次自动转换。char 能够转型为 int,可是 Character 是绝对不会转型为 Integer 的,他只能安全的转型为它实现的接口或父类。Character 还实现了另一个接口 java.lang.Comparable,若是同时出现两个参数分别为 Serializable 和 Comparable 的重载方法,那它们在此时的优先级是同样的。编译器没法肯定要自动转型为哪一种类型,会提示类型模糊,拒绝编译。程序必须在调用时显示的指定字面量的静态类型,如:sayHello((Comparable) ‘a’),才能编译经过。继续注释掉 sayHello(Serializable arg) 方法,输出变为:
Hello, Object!
这时是 char 装箱后转型为父类了,若是有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即便方法调用的入参值为 null,这个规则依然适用。继续注释掉 sayHello(Serializable arg) 方法,输出变为:
Hello, char...!
7 个重载方法以及被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时字符 ‘a’ 被当成了一个数组元素。
前面介绍的这一系列过程演示了编译期间选择静态分派目标的过程,这个过程也是 Java 语言实现方法重载的本质。
动态分派和多态性的另外一个重要体现「重写(Override)」有着密切的关联,咱们依旧经过代码来理解什么是动态分派。
/** * 方法动态分派演示 */ public class DynamicDispatch { static abstract class Human { abstract void sayHello(); } static class Man extends Human { @Override void sayHello() { System.out.println("Man say hello!"); } } static class Woman extends Human { @Override 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!
对于上面的代码,虚拟机是如何肯定要调用哪一个方法的呢?显然这里再也不经过静态类型来决定了,由于静态类型一样都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时执行了不一样的行为,而且变量 man 在两次调用中执行了不一样的方法。致使这个结果的缘由是由于它们的实际类型不一样。对于虚拟机是如何经过实际类型来分派方法执行版本的,这里咱们就不作介绍了,有兴趣的能够去看看原著。
咱们把这种在运行期根据实际类型来肯定方法执行版本的分派称为动态分派。
方法的接收者和方法的参数统称为方法的宗量,这个定义最先来源于《Java 与模式》一书。根据分派基于多少宗量,可将分派划分为单分派和多分派。
单分派是根据一个宗量来肯定方法的执行版本;多分派则是根据多余一个宗量来肯定方法的执行版本。
咱们依旧经过代码来理解(代码以著名的 3Q 大战做为背景):
/** * 单分派、多分派演示 */ public class Dispatch { static class QQ { } static class QiHu360 { } static class Father { public void hardChoice(QQ qq) { System.out.println("Father choice QQ!"); } public void hardChoice(QiHu360 qiHu360) { System.out.println("Father choice 360!"); } } static class Son extends Father { @Override public void hardChoice(QQ qq) { System.out.println("Son choice QQ!"); } @Override public void hardChoice(QiHu360 qiHu360) { System.out.println("Son choice 360!"); } } public static void main(String[] args) { Father father = new Father(); Father son = new Son(); father.hardChoice(new QQ()); son.hardChoice(new QiHu360()); } }
代码输出结果:
Father choice QQ! Son choice 360!
咱们先来看看编译阶段编译器的选择过程,也就是静态分派过程。这个时候选择目标方法的依据有两点:一是静态类型是 Father 仍是 Son;二是方法入参是 QQ 仍是 QiHu360。由于是根据两个宗量进行选择的,因此 Java 语言的静态分派属于多分派。
再看看运行阶段虚拟机的选择过程,也就是动态分派的过程。在执行 son.hardChoice(new QiHu360()) 时,因为编译期已经肯定目标方法的签名必须为 hardChoice(QiHu360),这时参数的静态类型、实际类型都不会对方法的选择形成任何影响,惟一能够影响虚拟机选择的因数只有此方法的接收者的实际类型是 Father 仍是 Son。由于只有一个宗量做为选择依据,因此 Java 语言的动态分派属于单分派。
综上所述,Java 语言是一门静态多分派、动态单分派的语言。
感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》
虚拟机如何调用方法已经介绍完了,下面咱们来看看虚拟机是如何执行方法中的字节码指令的。
Java 语言常被人们定义成「解释执行」的语言,但随着 JIT 以及可直接将 Java 代码编译成本地代码的编译器的出现,这种说法就不对了。只有肯定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行仍是编译执行才会比较确切。
不管是解释执行仍是编译执行,不管是物理机仍是虚拟机,对于应用程序,机器都不可能像人同样阅读、理解,而后得到执行能力。大部分的程序代码到物理机的目标代码或者虚拟机执行的指令以前,都须要通过下图中的各个步骤。下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;中间那条分支,则是解释执行的过程。
现在,基于物理机、Java 虚拟机或者非 Java 的其它高级语言虚拟机的语言,大多都会遵循这种基于现代编译原理的思路,在执行前先对程序源代码进行词法分析和语法分析处理,把源代码转化为抽象语法树。对于一门具体语言的实现来讲,词法分析、语法分析以致后面的优化器和目标代码生成器均可以选择独立于执行引擎,造成一个完整意义的编译器去实现,这类表明是 C/C++。也能够为一个半独立的编译器,这类表明是 Java。又或者把这些步骤和执行所有封装在一个封闭的黑匣子中,如大多数的 JavaScript 执行器。
Java 语言中,Javac 编译器完成了程序代码通过词法分析、语法分析到抽象语法树、再遍历语法树生成字节码指令流的过程。由于这一部分动做是在 Java 虚拟机以外进行的,而解释器在虚拟机的内部,因此 Java 程序的编译就是半独立的实现。
许多 Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(经过解释器执行)和编译执行(经过即时编译器产生本地代码执行)两种选择。而对于最新的 Android 版本的执行模式则是 AOT + JIT + 解释执行,关于这方面咱们后面有机会再聊。
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构。基于栈的指令集主要的优势就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件约束。栈架构的指令集还有一些其余优势,好比相对更加紧凑(字节码中每一个字节就对应一条指令,而多地址指令集中还须要存放参数)、编译实现更加简单(不须要考虑空间分配的问题,全部空间都是在栈上操做)等。
栈架构指令集的主要缺点是执行速度相对来讲会稍慢一些。全部主流物理机的指令集都是寄存器架构也从侧面印证了这一点。
虽然栈架构指令集的代码很是紧凑,可是完成相同功能须要的指令集数量通常会比寄存器架构多,由于出栈、入栈操做自己就产生了至关多的指令数量。更重要的是,栈实如今内存中,频繁的栈访问也意味着频繁的内存访问,相对于处理器来讲,内存始终是执行速度的瓶颈。因为指令数量和内存访问的缘由,因此致使了栈架构指令集的执行速度会相对较慢。
正是基于上述缘由,Android 虚拟机中采用了基于寄存器的指令集架构。不过有一点不一样的是,前面说的是物理机上的寄存器,而 Android 上指的是虚拟机上的寄存器。
感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》
感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》