【JVM】类文件结构+虚拟机类加载机制+虚拟机字节码执行引擎 学习笔记

一、类文件结构

平台无关性

实现语言的无关性的基础是虚拟机和字节码储存格式。

Class 类文件的结构

任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或者接口不一定都定义在文件里(譬如类或接口也可以通过类加载器直接生成)。

Class文件是一组以8位字节为基础单位的二进制流。中间无任何分隔符

文件格式:伪结构(无符号数和表)

无符号数属于基本的数据类型,描述数字,索引引用,数量值,或者按照UTF-8编码构成的字符串值。

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯地以“_info”结尾。表用于描述有层次关系的符合结构的数据。

魔数与Class文件的版本

每个Class文件的前4个字节为魔数,作用:确定这个文件是否能被虚拟机接受。

魔数不可变,拓展名可变。

之后的4个字节为版本号。

常量池

常量池入口在版本号之后。

Class文件中的资源仓库。

入口有一项数据代表常量池计数值,从1开始计数

常量池主要存放两大类常量:字面量和符号引用

字面量:文本字符串,final的常量

符号引用:类和接口的全限定名,字段的名称和描述符,方法的名称和描述符

常量池中的每一项常量都是一个表

分析字节码javap -verbose ClassName

访问标志

常量池之后是访问标志,识别一些类或者接口层次的访问信息:是类还是接口,是否是public,是abstract,是否声明为final

类索引,父类索引与接口索引集合

类索引和父类索引构成了接口索引集合

Class文件根据这三项确定类的继承关系

字段表集合

描述接口或者类中声明的变量

字段包括类级变量以及实例级变量,但不包含在方法内部声明的局部变量。

字段包含的信息(各信息要么有,要么没有)

  • 作用域,public、private、protected
  • 实例变量还是类变量,static
  • 可变性,final
  • 并发可见性,volatile、是否强制从主内存中读写
  • 可否被序列化,transient
  • 字段数据类型,基本类型、对象、数组
  • 字段名称
package org.soft.clazz
public class Test{
	private int m;
	public void inc(){
		return m + 1;
	}
}

全限定名:org/soft/clazz/Test

简单名称:minc

描述符:方法void inc()的描述符为()V

描述符:描述字段的数据类型,方法的参数列表(数量,类型,顺序),返回值

字段表中不会列出从超类或父类接口中继承而来的字段

方法表集合

结构:访问标志,名称索引,描述符索引,属性表集合

属性表集合

用于描述某些场景专有的信息

  1. Code属性

    Java方法体中的代码经过javac编译处理后,最终变为字节码质量存储在Code属性内。

    接口或者抽象类中的方法不存在COde属性

    max_stack 代表操作数栈

    max_locals代表局部变量所需的存储空间,单位Slot

    32位占1Solt,64位占2Solt

    方法参数,显式异常处理器的参数,方法体中定义的局部变量都需要用局部变量表来存放

    code_length和code用来存储Java源程序编译后产生的字节码指令


    如果把一个Java程序中的信息分为代码(Code,方法体中的Java代码)和元数据(Metadata,类、字段、方法定义及其他信息),在整个Class文件中,Code用于描述代码,其他用于描述元数据。


    Java虚拟机执行字节码是基于栈的体系结构

  2. Exceptions属性

    列举出方法中可能抛出的受查异常(Checked Exceptions),也就是方法描述时在throws关键字后列举的异常。

  3. LineNumberTable属性

    描述Java源码行号与字节码行号之间的对应关系。

    并不是运行时必需的属性,但会默认生成。

  4. LocalVariableTable属性

    描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系

  5. SourceFile属性

    记录生成这个Class文件的源码名称

  6. ConstantValue属性

    通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用该属性

  7. InnerClasses属性

    内部类和宿主类之间的关联

  8. Deprecated 和 Synthetic 属性

    属于标志类型的布尔属性,只存在有和没有的概念

    Deprecated 属性表示某个类,字段或者方法,不在被推荐使用,代码中使用@deprecated注解设置

    Synthetic属性表示此字段或方法并不是由Java源码直接产生的,而是由编译器自行添加的

  9. StackMapTable属性

  10. Signature属性

    记录泛型签名信息。

    在字节码(Code属性)中,泛型信息编译后被擦除,运行时无法通过反射获得泛型信息。

  11. BootstrapMethods属性

字节码指令

Java虚拟机采用面向操作数栈的架构,大多数指令都不含操作数,只有一个操作码

字节码与数据类型

Java虚拟机的操作码长度只有一个字节

加载和存储指令

用于将数据在栈帧中的局部变量表和操作数栈之间来回传输

运算指令

用于对两个操作数栈上的值进行某种特定运算,并把结构重新存入到操作栈顶。

类型转换指令

将两种不同的数值类型进行相互转换,显式操作

对象创建与访问指令

类实例和数组的创建指令不同

操作数栈管理指令

对操作数栈操作的指令

控制转移指令

从概念模型讲:控制转移指令就是在有条件或无条件的修改PC寄存器的值

方法调用和返回指令

方法调用与数据类型无关,方法返回指令是根据返回值的类型区分的

异常处理指令

显示抛出异常的操作(throw语句)

在Java虚拟机中,处理异常(catch语句)是采用异常表完成的。

同步指令

Java虚拟机支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。

二、虚拟机类加载机制

定义:

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。

类型的加载、连接、初始化过程是在程序运行时完成

Java是动态扩展的语言,特性:运行时加载,动态连接

类加载时机

生命周期

加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)

初始化:
  • 使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候
  • 读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候
  • 调用一个类的静态方法的时候
  • 对类进行反射调用的时候
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
  • 使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化
被动引用
  • 通过子类引用父类的静态字段,不会导致子类初始化
  • 通过数组定义来引用类,不会触发此类的初始化
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

类加载过程

1. 加载

  1. 通过一个类的全限定名类获取定义此类的二进制字节流
  2. 将这字节流所代表的静态存储结构转化为方法区运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

数组类本身不通过类加载器创建,由Java虚拟机直接创建。创建规则:

  • 如果数组的组件类型(指的是数组去掉一个维度的类型)是引用类型,那就递归采用上面的加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上被标识
  • 如果数组的组件类型不是引用类型(列如int[]组数),Java虚拟机将会把数组C标识为与引导类加载器关联
  • 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public

2. 验证

确保Class文件的字节流中包含的信息符合当前虚拟机的要求

验证阶段会完成下面4个阶段的检验动作:文件格式验证,元数据验证,字节码验证,符号引用验证。

(1)文件格式验证

第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括:

  1. 是否以魔数oxCAFEBABE开头

  2. 主、次版本号是否在当前虚拟机处理范围之内

  3. 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)

  4. 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量

  5. CONSTANT_Itf8_info 型的常量中是否有不符合UTF8编码的数据

  6. Class文件中各个部分及文件本身是否有被删除的或附加的其他信息

    。。。

这个阶段的验证时基于二进制字节流进行的,只有通过类这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流

(2)元数据验证
  1. 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类
  2. .这个类的父类是否继承了不允许被继承的类(被final修饰的类)
  3. 如果这个类不是抽象类,是否实现类其父类或接口之中要求实现的所有方法
  4. 类中的字段、方法是否与父类产生矛盾(列如覆盖类父类的final字段,或者出现不符合规则的方法重载,列如方法参数都一致,但返回值类型却不同等)

第二阶段的主要目的是对类元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息

(3)字节码验证

最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语言是合法的、符合逻辑的。

在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。

  1. 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,列如,列如在操作数栈放置类一个int类型的数据,使用时却按long类型来加载入本地变量表中

  2. 保证跳转指令不会跳转到方法体以外的字节码指令上

  3. 保证方法体中的类型转换时有效的,列如可以把一个子类对象赋值给父类数据类型,这个是安全的,但是吧父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的

(4)符号引用验证

发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生

  1. 符号引用中通过字符串描述的全限定名是否能找到相对应的类
  2. 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  3. 符号引用中的类、字段、方法的访问性是否可被当前类访问

对于虚拟机的类加载机制来说,验证阶段是非常重要的,但是不一定必要(因为对程序运行期没有影响)的阶段。如果全部代码都已经被反复使用和验证过,那么在实施阶段就可以考虑使用Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3. 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量都在方法区中进行分配。这个时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里说的初始值通常下是数据类型的零值。

假设public static int value = 123;那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行,但是如果使用final修饰,public static final int value=123;则在这个阶段其初始值设置为123。

4. 解析

解析阶段是虚拟机将常量池内符号引用替换为直接引用

符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。与内存布局无关,引用目标并不一定已经加载到内存中。

直接引用:直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。和内存布局有关,引用目标已经在内存中存在。

5. 初始化

类的初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才正真开始执行类中定义的Java程序代码(或者说是字节码)。

初始化过程是执行类构造器<clinit>()方法的过程,在jvm第一次加载class文件时调用,因为是类级别的,所以只加载一次,

  • <clinit>()方法是编译器自动收集类中所有类变量(static修饰的变量)和静态语句块(static{})中的语句合并产生的,编译器收集的顺序,是由源文件中的代码的顺序决定的。

  • 虚拟机保证子类的<clinit>()方法执行之前,父类的<clinit>()方法执行完毕。

  • 父类中定义的静态语句块要优于子类的变量复制操作。

  • <clinit>()方法对类或接口不是必需的,没有静态语句块和变量赋值操作的就没有<clinit>()方法。

  • 执行接口的<clinit>()方法不需要下执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会被初始化。另外,接口的实现类在初始化是也一样不会执行接口的<clinit>()方法。

  • 虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,知道活动线程执行<clinit>()方法完毕。如果一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。

    同一个类加载器下,一个类型只会初始化一次。

类加载器

双亲委派模型

只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader),使用C++实现,是虚拟机自身的一部分,HotSpot。另一种是所有其他的类加载器,使用JAVA实现,独立于JVM,并且全部继承自抽象类java.lang.ClassLoader.

  • 启动类加载器(Bootstrap ClassLoader),负责将存放在<JAVA+HOME>\lib目录中的,或者被-Xbootclasspath参数所制定的路径中的,并且是JVM识别的(仅按照文件名识别,如rt.jar,如果名字不符合,即使放在lib目录中也不会被加载),加载到虚拟机内存中,启动类加载器无法被JAVA程序直接引用。
  • 扩展类加载器,由sun.misc.Launcher$ExtClassLoader实现,负责加载\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader),由sun.misc.Launcher$AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般称它为系统类加载器。负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

类加载双亲委派模型
双亲委派模型要求除了顶层的启动加载类外,其余的类加载器都应当有自己的父类加载器。,这里类加载器之间的父子关系一般不会以继承的关系来实现,而是使用组合关系来复用父类加载器的代码。

双亲委派模型的工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都是应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

这样做的好处就是:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

实现

先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载器加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

破坏双亲委派模型

  • JDK 1.2 之前,双亲委派模型尚未出现,自定义类加载器需要重写loadClass()方法。
  • 自身缺陷导致,上层类加载器所加载的基础类无法调用回用户的代码,需要通过线程上下文类加载器解决,违背了双亲委派模型的一般性原则。
  • 用户对程序动态性的追求而导致的破坏,如OSGi实现模块化热部署,在自定义的类加载器机制中,存在平级的类加载器。

三、虚拟机字节码执行引擎

概述

执行引擎是Java虚拟机最核心的组成部分之一。Java虚拟机的执行引擎由自己实现,所以可以自行定制指令集与执行引擎的结构体系,并且能够执行哪些不被硬件支持的指令集格式。

所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用方法执行的数据结构

,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的 过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
栈帧结构

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数方法内定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表建立在线程的堆栈上,是线程的私有数据。

操作数栈

操作数栈(Qperand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

举个例子:整数的加法运行操作时,才操作数栈中最接近栈顶的两个元素已经存入了两个int型的值,当执行这个指令时,会将这两个int值出栈并相加,然后将结构入栈。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。我们知道Class
文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。

一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性使得Java由动态扩展能力,但也使方法调用过程变得复杂,需要在类加载期间,甚至是运行期间才能确定目标方法的直接引用。

解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的由静态方法,私有方法,实例构造器,父类方法,它们在类加载的时候就会把符号引用解析为该方法的直接引用。

分派

1. 静态分派

静态类型在编译期可知,而实际类型到运行期才确定下来。

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派最典型的应用就是方法重载。

2. 动态分配

在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。最典型的应用就是方法重写。

3. 单分派与多分派

方法的接收者、方法的参数都可以称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派。

单分派是根据一个宗量对目标方法进行选择的,多分派是根据多于一个的宗量对目标方法进行选择的。

Java在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

到JDK1.7时,Java语言还是静态多分派、动态单分派的语言,未来有可能支持动态多分派。

4. 虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。

其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与C++的虚函数表类似。

方法表结构

虚方法表中存放的是各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类中该方法相同,都指向父类的实现入口。虚方法表一般在类加载的连接阶段进行初始化。

动态类型语言支持

JDK7新增加了invokedynamic指令来是实现“动态类型语言”,为JDK8的Lambda表达式做技术准备。

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。

相对的,在编译期就进行类型检查过程的语言(如C++和Java等)就是最常用的静态类型语言。

动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待。

基于栈的字节码解释执行引擎

虚拟机如何调用方法的内容已经讲解完毕,现在我们来探讨虚拟机是如何执行方法中的字节码指令。

解释执行

Java语言经常被人们定位为 “解释执行”语言,在Java初生的JDK1.0时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。

再后来,Java也发展出来了直接生成本地代码的编译器[如何GCJ(GNU Compiler for the Java)],而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切

编译过程

Java语言中,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程,因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机内部,所以Java程序的编译就是半独立实现的。

基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,依赖操作数栈进行工作。与之相对应的另一套常用的指令集架构是基于寄存器的指令集依赖寄存器进行工作

基于栈的指令集主要的优点就是可移植,寄存器是由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

栈架构的指令集还有一些其他的优点,如代码相对更加紧凑编译器实现更加简单等。栈架构指令集的主要缺点是执行速度相对来说会稍微慢一些

参考资料:《深入理解Java虚拟机 第2版》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值