深入理解Java虚拟机——虚拟机执行子系统

一.类文件结构

Class文件是一组以8位字节为基础单位的二进制流,各数据项严格按顺序排列其中,中间没有添加任何分隔符。根据JAVA虚拟机规范的规定,CLASS文件格式采用一种类似C语言结构体的伪结构来存储,这种伪结构中只有两种数据类型:无符号数和表

1.无符号数属于基本的数据类型,以u1,u2,u4,u8来分别表示一个字节,两个字节,四个字节和8个字节的无符号数,无符号数用来描述数字,索引引用,数量值或按照UTF8编码构成字符串数

2.表是由多个无符号数或其他表作为数据项构成的复合数据类型,所有表都习惯性的以”_info”结尾,表用于描述有层次关系的复合结构的数据。整个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

field_count

1

field_info

fields

field_count

u2

method_count

1

method_info

methods

method_count

u2

attribute_count

1

attribute_info

attributes

attribte_count

魔数(magic)(4个字节)

0xCAFEBABE

次版本号(2个字节)

主版本号(2个字节)

高版本号的JDK可以向下兼容,但不能运行版本高于自己的CLASS文件。

常量池计数器(2个字节)

计数器1而不是0开始,0x0016,十进制为22,代表有21个常量。索引为1~21.没有使用0索引是因为在后面某些指向常量池的索引可以通过0索引表示不引用任何一个常量池项目的意思。

常量池

常量池中主要存放两大类常量:字面量(Literal符号引用(Symbolic References

字面量的例子有文本字符串被声明为final的常量值等。

符号引用包含三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

JAVA代码在进行JAVAC编译时,并不像C和C++那样有连接这一步骤,而是在虚拟机加载CLASS文件的时候进行动态连接。也就是说,在CLASS文件中不会保存各个方法和字段的最终内存布局信息,因此这些字段和方法的符号引用不经过转换的话是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池获得对应的符号引用,在类创建时或运行时解析时解析并翻译到具体的内存地址中。

  常量池中每一项常量都是一个表,共有11,表开始的第一位都是一个字节的标志位,表明这个常量属于哪种类型

  JAVA程序中不能定义超过64KB英文字符的变量和方法名,否则无法编译

  使用JAVAP工具可以分析class文件字节码    javap -verbose TestClass

 访问标志

用于识别一些类或接口的访问信息,包括这个CLASS是类还是接口,是否为public,是否为abstract等

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

用来确定继承关系

字段表集合

用于描述接口或类中声明的变量。字段field包括了类级变量或实例级变量,但不包括在方法内部声明的变量。不包括方法内部声明的变量.描述了该字段是否是Public,private, protected, static,final,volatile,transient等

描述符用来描述字段的数据类型,方法的参数列表和返回值.用表示标识字符表示,对象类型用L+对象的全限定名表示

方法表集合

描述方法

包括访问标志,名称索引,描述符索引,属性表索引。方法中的代码储存在方法属性表集合中一个名为Code的属性中。

在JAVA虚拟机规范中,要重载一个方法,除了要与原方法具有相同的简单名称外,还要求必须拥有一个与原方法不同的特征签名,特征签名在JAVA代码层面和字节码层面有不同的定义。代码层面的签名只包括方法名称,参数顺序及参数类型,字节码层面还包括方法返回值和异常表。因此在CLASS文件中,如果两个方法有相同的名称和特征签名,但返回值不同,是合法的

属性表集合

属性表集合的限制较少,不要求各个属性表有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息。JAVA虚拟机在运行时会忽略掉它不认识的属性。JAVA虚拟机规范中预定义了9项虚拟机实现应当能识别的属性:

属性名称

使用位置

含义

Code

方法名

JAVA代码编译成的字节码指令

ConstantValue

字段表

final关键字定义的常量值

Deprecated

类,方法表,字段表

被声明为deprecated的方法和字段

Exceptions

方法表

方法抛出的异常

InnerClasses

类文件

内部类列表

LineNumberTable

Code属性

Java源码的行号与字节码指令的对应关系

LocalVariableTable

Code属性

方法的局部变量描述

SourceFile

类文件

源文件名称

Synthetic

类,方法表,字段表

标识方法或字段为编译器自动生成的

 Code属性

JAVA程序方法体中的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性中。

max_stack代表操作栈深度的最大值,在方法执行的任何时刻,操作数栈都不会超过这个深度,虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。

max_locals代表局部变量表所需的存储空间,单位为slot,对于byte,char,float,int,short,boolean,reference和returnAddress每个局部变量占用一个slot,而double和long需要两个slot.

并不是方法中用到了多少个局部变量,就把这些局部变量所占的Slot之和作为max_locals的值,原因是局部变量表中的slot可以重用,当代码执行超过一个局部变量的作用域时,这个局部变量所占用的slot就可以被其他局部变量所使用。这个值编译器会自动计算得出

code_length和code用来存储java源程序编译后生成的字节码指令,code_length代表字节码长度,code用于存储字节码指令的一系列字节流。字节码的每个指令就是一个字节。这样可以推出,一个字节最多可以代表256条指令,目前已经使用了约200。而code_length有4个字节,所以一个方法做多允许有65535条字节码指令如果超过这个限制,javac就会拒绝编译,一般JSP可能会这个原因导致失败

在任何实例方法中,都可以通过this关键字访问到此方法所属的对象,它的底层实现就是通过javac编译器在编译的时候把this关键字的访问转变为对一个普通方法参数的访问。因此,任何实例方法的参数Args_size最少是1,而且locals最少也是1.而静态方法就可以为0了。异常表实际是Java代码的一部分,start_pc行到end_pc行出现了类型为catch_type的异常,就转到第handler_pc行处理这四个参数就组成了异常表。对于finally的实现,实际上就是对catch字段和前面对于任意情况都运行的异常表记录

Exceptions属性

表示方法可能抛出number_of_exceptions种受查异常,每种受查异常使用一个exception_index_table项表示

LineNumberTable属性

用于描述java源代码行号与字节码行号直接的对应关系。可以用-g:none或-g:lines选项来取消或要求生成这项信息,主要影响是报错时对战是否显示出错的行号。同时debug时无法设置断点。

LocalVariableTable属性

用于描述栈帧中局部变量表中的变量与源码中定义的变量之间的关系。也可以选择开关,关闭后果就是报错时看不到变量名称

SourceFile属性

用于记录生成这个Class文件的源码文件名称。可选

ConstantValue属性

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

在JAVA中,int x=123;和static int x=123;的区别在于,非static类型的变量(实例变量)的赋值是在实例构造器<init>方法中进行的;而对于静态变量,则有两种方式可以选择在类构造器<clinit>方法中进行,或者使用ConstantValue属性来赋值。目前SUN JAVAC编译器的选择是如果同时使用finalstatic来修饰一个变量,并且这个变量的数据类型是基本类型或者String的话,就生成ConstantValue属性来进行初始化如果这个变量没有用final修饰,或者非以上类型,则选择在<clinit>中进行初始化

InnerClasses属性

用于记录内部类与宿主类之间的关系

Deprecated和synthetic属性

都属于标志类布尔属性。deprecated表示在代码中使用@deprecated注释进行设置。synthetic表示字段或方法不是java源码产生,而是编译器自行添加的。

二.虚拟机类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的JAVA类型,这就是虚拟机的类加载机制。类的加载和连接过程都是在程序运行期完成,为Java应用程序提供高度的灵活性。Java天生可以动态扩展的语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。

  类加载的生命周期包括:加载Loading验证Verification准备Preparation解析Resolution初始化Initialization使用Using卸载Unloading.

除解析阶段外,其他几个阶段的顺序都是固定的。解析阶段在某些情况下可以在初始化阶段之后再开始,这是为了支持JAVA语言的运行时绑定(动态绑定/晚期绑定)

       虚拟机规范严格规定了有且只有四种情况必须对类进行初始化(加载,验证,准备自动在之前开始)

  1. 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,如果类没有进行初始化,则先初始化。这4个字节码常见的出现场景是:使用new关键字实例化对象的时候读取或设置静态字段final修饰,已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 反射调用时
  3. 初始化一个类时,如果其父类还未初始化,则先出发父类初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类

这4种情况称为对类的主动引用,其他情况称为被动引用。以下是三种被动引用的情况:

1.对于访问静态字段,只有直接定义这个字段的类才被初始化,因此通过子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。但是对于HOTSPOT,会触发子类的加载

2.通过数组定义引用类,不会触发此类的初始化

3.常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

接口的加载和类加载过程稍有不同,接口不能有static代码段,但接口中还是会生成<clinit>()类构造器,用于初始化接口中所定义的成员变量。一个接口在初始化时,并不要求其父类也初始化了

类加载的过程

加载

在加载阶段,虚拟机需要完成以下三件事情:

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

事实上,这三条限定都不是很严格,比如第一条,并没有明确指出通过全限定名从哪里得到二进制流,由此就有很多不同的实现:

  • 在ZIP包中读取(JAR,EAR,WAR)
  • 从网络中获取(APPLET)
  • 运行时计算生成,这种场景使用的最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口申城$Proxy的代理类的二进制流
  • 由其它文件生成(jsp)
  • 从数据库中读取,有些中间件服务器(SAP NETWEAVER)

加载阶段完成后,虚拟机外部的二进制流就按照虚拟机所需的格式存储在方法区中,方法区中的数据存储格式由虚拟机实现自行定义。然后在JAVA堆中实例化一个java.lang.Class类对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并却不会危害虚拟机自身的安全。

一些在编译层面上可以控制的事情(比如超边界访问数组,跨类型进行类型对象转换存在时,编译器是拒绝工作的)可以通过直接修改class文件的方式进行破解,这就是验证阶段存在的原因

按照虚拟机规范,如果验证到输入的字节流不符合Class文件的存储格式,就抛出一个java.lang.VerifyError异常或其子类异常。

大致分成4个阶段的验证过程:文件格式验证元数据验证字节码验证符号引用验证

1.文件格式验证:比如是否以魔数开头,主次版本号是否在虚拟机可处理范围之内,常量池是否有不支持类型等。

经过这个阶段的验证之后,字节流才会进入内存的方法区进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构进行的

2.元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合JAVA语言规范的要求,这个阶段可能包括的验证点有:

这个类是否有父类,父类是否集成了不允许继承的类,如果不是抽象类是否实现了其父类或接口中要求实现的所有方法,类中的字段和父类是否有矛盾

3.字节码验证:最复杂的一个解读那,主要工作是进行数据流和控制流分析。这阶段对类的方法体进行校验分析,保证该方法在运行时不会做出危害JVM安全的行为,例如:

保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,保证跳转指令不会跳转到方法体以外的字节码指令上,保证方法体中的类型转换是有效的。。。

这个验证并不能保证一定安全(停机问题,通过程序去校验程序逻辑是无法做到绝对准确的)1.6加入StackMapTable功能对这个阶段做了优化,提高速度,但这个StackMapTable也可能被篡改,可以通过启动参数来关闭这个选项。

4.符号引用验证:这个阶段发生在虚拟机将符号引用转化为直接引用的时候。这个转化动作将在连接的第三个阶段—-解析阶段中发生。可以看作是对类自身以外的信息进行匹配性的校验。比如:符号引用中通过字符串描述的全限定名是否能找到对应的类,是否存在所描述的方法和字段;符号引用中的类、字段和方法的访问性是否可被当前类访问。它的目的是确保解析动作能正常执行。

可以使用启动参数-Xverify:none来关闭大部分类验证措施,缩短虚拟机类加载时间

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个时候内存分配的仅包括类变量(static变量),不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。其次是这里所说的初始值“通常情况下”是数据类型的零值(随后在初始化阶段生成定义的初值)。如果该变量被final修饰,将在编译时生成ConstantValue,这样在准备阶段将直接设置成该初值

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用在CLASS文件中它以CONSTANT_CLASS_INFO,CONSTANT_FIELDREF_INTO,

CONSTANT_METHODREF_INFO等类型的常量出现。

1.符号引用:(Symbolic References)符号引用以一组符号来描述所引用的目标,可以是任何形式的字面量,引用的目标并不一定已经加载到内存中,与虚拟机内存布局无关。

2.直接引用:(Direct References)直接引用可以是直接指向目标的指针,相对偏移量,或是一个能间接定位到目标的句柄。与虚拟机内存布局相关。

虚拟机规范并未规定解析阶段发生的具体时间,只要求了在执行anewarray,checkcast,

getfield,getstatic,instanceof,invokeinterface,invokespecial,invokestatic,invokevirtual,

multianewarray,new,putfield,putstatic这13个操作符号引用的字节码指令之前,先对它们使用的符号引用进行解析。

对同一个符号引用进行多次解析请求是很常见的事情。有的实现会进行缓存。

解析动作主要针对类/接口,字段,类方法,接口方法四类符号引用进行。分别对应于常量池的CONSTANT_CLASS_INFO,CONSTANT_FIELDREF_INFO,

CONSTANT_METHODREF_INFO,CONSTANT_INTERFACEMETHODREF_INFO四种类型。

类和接口的解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下三个步骤:

  1. 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C
  2. 如果C是数组类型,并且数组的元素类型是对象,则按照1的情况处理。如果元素类型不是对象,则由虚拟机生成一个代表此数组维度和元素的数组对象
  3. 如果上述步骤没有异常,C在虚拟机中世纪已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认C是否具有对D的访问权限,如果没有则抛出java.lang.IllegalAccessError异常。

字段解析:要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是这个字段所属的类或者接口,如果解析成功,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索:

  1. 如果C本身包含这个字段,就返回这个字段的直接引用,查找结束
  2. 否则,如果C中实现了接口,将会按照集成关系从善根倒下递归搜索各个接口和它的父接口,如果匹配,则返回这个字段的直接引用,查找结束
  3. 否则,如果C不是java.lang.Object,将会按照继承关系从上往下递归搜索父类,如果匹配,返回值额济引用,查找结束
  4. 查找失败,抛出java.lang.NoSuchFieldError异常。

如果成功返回了引用,将进行权限验证,不具备权限则抛出java.lang.IllegalAccessError

类方法解析:第一个步骤与字段解析一样,同样是需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,再按照以下步骤搜索:

  1. 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index索引的C是个接口,那直接抛出java.lang.IncompatibleClassChangeError异常
  2. 在C中查找是否有匹配,有则结束
  3. 否则,在C父类中查找匹配
  4. 在C接口列表和父接口中查找,如果存在匹配,说明C是抽象类,抛出java.lang.AbstractMethodError
  5. 抛出java.lang.NoSuchMethodError.

返回成功后检查权限验证,不通过则抛出java.lang.IllegalAccessError

接口方法解析:第一步一样,后面步骤:

  1. 与类方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError
  2. 在C中查找是否有匹配
  3. 在C的父接口中查找匹配
  4. 失败,抛出java.lang.NoSuchMethodError

由于接口方法都是默认public,所以不存在访问权限问题。

初始化

是类加载过程的最后一步,初始化阶段才真正开始执行类中定义的JAVA程序代码

准备阶段中,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的计划来赋值或者说,初始化阶段是执行类构造器<clinit>()方法的过程

  • <clinit>()方法是由编译器自动收集类中的所有类变量的复制动作和静态语句块中的语句合并而成。编译器收集的顺序和语句在源文件中出现的顺序一致静态语句块中只能访问到定义在它之前的变量,定义在它之后的变量,只能赋值,不能访问
  • <clinit>()方法与类的构造函数<init>()不同,不需要显式的调用父类构造器,虚拟机会保证父类的<clinit>()在子类的之前完成。因此,虚拟机执行的第一个<clinit>()方法肯定是java.lang.Object.
  • 由于父类<clinit>()方法先执行,也就意味着父类中定义的静态语句要优先于子类的变量赋值操作
  • <clinit>()方法并不是必须的,如果一个类没有静态语句块也没有对变量赋值操作,就不会生成
  • 接口中不能使用静态语句块,但仍有变量初始化赋值的操作,因此也会生成<clinit>()方法,但与类不同的是,接口的<clinit>()方法不需要执行父接口的<clinit>()方法只有当父几口中定义的变量被使用时,父接口才初始化,另外,接口的实现类在初始化时一样不会执行接口的<clinit>()方法
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都会阻塞,直到该方法执行完,如果在一个类的<clinit>()方法中有耗时很长的操作,可能会造成多个进程阻塞,在实际应用中,这种阻塞往往很隐蔽。

类加载器

通过一个类的全限定名来获取描述此类的二进制流,执行这个动作的代码模块成为“类加载器”。

两个类只有在同一个类加载器加载的前提下才有意思,否则即使两个类原子相同的Class文件,只要加载它们的加载器不同,那这两个类也是不相等的。这里的相等,包括equals,isAssignableFrom(),isInstance() instanceof等情况。

双亲委派模型

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

绝大部分JAVA程序都会使用到以下三种系统提供的类加载器:

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

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

双亲委派模型的工作过程是:当一个类加载器受到类加载请求,它首先不会自己尝试去加载这个类,而是把这个请求委派给自己的父类加载器去完成,只有当父加载器表示自己无法完成这个加载请求时,子加载器才会尝试自己去加载

这个模型的好处,就是保证某个范围的类一定是被某个类加载器所加载的,这就保证在程序中同一个类不会被不同的类加载器加载。这样做的一个主要的考量,就是从安全层面上,杜绝通过使用和JRE相同的类名冒充现有JRE的类达到替换的攻击方式。

破坏双亲委派模型

由于双亲委派模型是在JDK1.2后引入,而在JDK1.0时就存在java.lang.ClassLoader和类加载器。为了向前兼容,JDK1.2后的java.lang.ClassLoader添加了一个新的protected方法findClass(),再次之前,用户继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),该方法唯一逻辑就是调用自己的loadClass(). JDK1.2之后就不提倡重写loadClass()方法,而应当将自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就保证新写出来的类加载符合双亲委派规则的。(默认loadClass()方法会先调用父类加载,如果失败,再调用findClass()方法,如果在自己的类加载器实现中重写loadClass()方法,不调用父类加载,就会导致双亲委派模型失效).

第二次被破坏是由这个模型自身的缺陷造成的,如果基础类调用回用户的代码比如JNDI服务,它本身由启动类加载器加载,但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者代码,但启动类加载器并不会认识这些代码,为了解决这个问题,引入了线程上下文类加载器(Thread Context ClassLoader)。这个类可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。这个设置,实际上就是让父类可以通过指定子加载器来帮助自己加载类

第三次被破坏是由于用户对程序动态性的追求而导致的,比如HotSwap, HotDeployment事实上,当前业界事实上的JAVA模块坏标准OSGi也破坏了双亲委派模型,每一个程序模块(bundle)都有一个自己的类加载器,当需要替换一个bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。

 

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

执行引擎在执行JAVA代码的时候可以选择解释执行(通过解释器执行)和编译执行(通过即使编译器产生本地代码执行)两种选择。

运行时栈帧结构

栈帧(StackFrame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(VirtualMachine Stack)的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。每一个方法调用的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

一个线程中的方法调用链可能很长,很多方法都同时处于执行状态,对于执行引擎来说,活动线程中,只有栈顶的栈帧是有效的,成为Curent Stack Frame。 这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

局部变量表

用于存放方法参数和方法内部定义的局部变量,在编译成CLASS文件时,就在方法的CODE属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。一个Slot可以存放一个32位以内的数据类型,这些类型有boolean,byte,char,short,

int,float,reference和returnAddress。returnAddress是为字节码指令jsr,jsr_w和ret服务的,指向下一条字节码的地址。对于64位数据,JVM会以高位在前的方式分配两个连续的Slot空间

JVM通过索引定位的方式使用局部变量表,索引值的范围从0开始到局部变量表最大的SLOT数量。在方法执行时,JVM使用局部变量表完成参数值到参数变量列表的传递过程。如果是实例方法,那么局部变量表的第0位索引的SLOT默认是用于传递方法所属对象实例的引用,在方法中可以通过“this“访问到这个隐含的参数。其余参数则按照参数表的顺序排列,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用于分配其余SLOT。

       局部变量表中的SLOT是可以重用的,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的SLOT就可以交给其他变量使用

Java代码  

1. public static void main(String[] args)(){  

2.         {  

3.                 byte[] placeholder = new byte[641024 * 1024];  

4.         }  

5.         System.gc();  

6. }  

一般认为,在大括号结束后,placeholder已经处于可以回收的状态,因为变量作用域已经超过,此时调用System.gc()理应能回收之前分配的内存,但是实际上这块内存没有被回收这是因为局部变量表中SLOT还存在有关于placeholder数组对象的引用,如果在大括号中加入int a=0;语句,虽然看似无关,但该语句重写了局部变量表,使变量表中作为GC Roots的部分被覆盖,后面的回收操作就可以进行了。

       根据上面的例子,如果在某个方法中,方法前面分配了大量的内存给后面又用不到的变量,而后面的代码又有一些耗时很长的操作,应该把前面的变量置为null,,可以将变量对应的局部变量表清空。但是,赋为NULL的操作在经过JIT编译后,会被忽略掉,因此在这种情况下,是没有意义的。 

操作数栈

LIFO。操作数栈的最大深度在编译时写入到Code属性的max_stacks数据项中。

操作数栈的每一个元素可以是任意JAVA数据类型,32位数据占栈容量为164位占栈容量为2.

       当一个方法开始执行时,这个方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令向操作数栈中写入和提取内容。比如,加法的字节码指令iadd在运行时会将栈顶两个元素相加并出战,再将结果入栈。在编译器和校验阶段的保证下,操作数栈中元素的数据类型必须与字节码指令的序列严格匹配

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转换为直接引用,成为静态解析,另一部分会在每一次运行时转换为直接引用,成为动态连接

方法返回地址

有两种方式退出当前执行的方法,一是执行引擎遇到任意一个方法返回的字节码指令,这种方法称为正常完成出口。二是在方法执行过程中遇到无法处理的异常,这种方法称为异常完成出口

无论哪种方法,方法退出后,都需要返回到调用者的位置,正常退出时,调用者的PC计数器值可以作为返回地址,栈帧中很可能会保存这个计数器值,而异常退出时,返回地址要通过异常处理器表来确定。

       方法退出的过程实际上是将当前栈帧出战,并恢复上层方法的局部变量表和操作数栈把返回值压入调用者的操作数栈中。

方法调用

方法调用不等于方法执行,其唯一的任务就是确定调用哪一个具体方法

解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,而不是方法在实际运行时内存布局中的入口地址。

在类加载的解析阶段,一部分符号引用会被转化为直接引用,这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,且这个方法的调用版本在运行时是不可改变的(“编译期可知,运行期不可变”)。符合这个条件的有静态方法私有方法两大类。JVM提供了4条方法调用的字节码指令:

1.   invokestatic:调用静态方法

2.   invokespecial:调用实例构造器<init>方法,私有方法和父类方法

3.   invokevirtual:调用所有的虚方法

4.   invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

只要能被invokestaticinvokespecial调用的方法,都可以在解析阶段进行转化

除此以外(除静态方法,实例构造器,私有方法,父类方法以外)其他方法称为虚方法。

JAVA非虚方法除了invokestaticinvokespecial以外,还有一种就是final修饰的方法,因为该方法无法被覆盖,这种被final修饰的方法是用invokevirtual指令调用的。

分派

静态分派

Parent father =new Son();这句中,Parent被称为静态类型,Son称为实际类型。

虚拟机(编译器)重载时通过参数的静态类型作为判断依据所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。

在重载的情况下,很多时候重载版本并不是唯一的,而是寻找一个最合适的版本。比如存在多个重载方法的情况中,调用的目标顺序是一定的。

动态分派

它与重写(override)有着密切的关系。

相对于前面的重载中,引用类型对于具体调用哪个方法起决定性作用,在重写中,引用指向的对象的具体类型决定了调用的具体目标方法

Java代码  

1. Human man = new Man();  

2. Human woman = new Woman();  

3. man.sayHello();  

4. woman.sayHello();  

5. man = new Woman();  

6. man.sayHello();  

这样一段代码生成的字节码为:

Java代码  

1. 0new #16;  

2. 3; dup  

3. 4: invokespecial #18;  

4. 7; astore_1  

5. 8new #19;  

6. 11:dup  

7. 12:invokespecial #21;  

8. 15:astore_2  

9. 16:aload_1  

10.17:invokevirtual #22;  

11.20:aload_2  

12.21:invokevirtual #22;  

13.24:new #19;  

14.27:dup  

15.28:invokespecial #21;  

16.31:astore_1  

17.32:aload_1  

18.33:invokevirtual #22  

19.36:return  

 0-15行是准备工作,用于生成对象,初始化对象,并将两个实例存放在第一和第二个局部变量表slot中。

16和20行分别将刚创建的两个对象引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,成为接收者。

17和21行是方法调用指令,可见指令和参数都是一样的,都是invokevirtual常量池中第22项的常量—Human.sayHello()的符号引用,而结果是这两次调用的结果不同,原因是invokevirtual指令的运行时解析过程:

1.    找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C

2.   如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,通过则返回该方法直接引用,不通过抛出java.lang.IllegalAccessError

3.   否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证

4.   没找到合适方法,抛出java.lang.AbstractMethodError异常

由于第一步是解析成对象的实际类型,因此两次调用的结果不一样。这个顺序实际是:找到实际类型在该类型中搜索在该类型的继承结构中自下而上搜索抛出异常。

在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

单分派和多分派

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种:单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。

首先进行静态分派,生成相应的字节码,在常量池中生成对应的方法符号引用,这个过程根据了两个宗量进行选择(接收者和参数),因此静态分派是多分派类型。再进行动态分派,将符号引用变成直接引用时,只对方法的接收者进行选择,因此只有一个宗量,动态分派是单分派。

虚拟机动态分派的实现

由于动态分派是非常频繁的操作,因此在JVM具体实现中基于性能考虑,常常 做一些优化,最初那个用的“稳定优化”手段就是为类在方法去中建立一个虚方法表(vtable),于此对应,invokeinterface执行时也会用到接口方发表,itable。

虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表里的地址入口和父类相同方法的地址入口是一致的。如果子类重写了这个方法,子类方法表中的地址就会被替换为指向子类实现版本的入口地址。为了程序实现上的方百年,具有相同签名的方法,在父类、子类的虚方发表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更要查找的方法表即可。方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

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

Java编译器输出的指令流,基本上是一种基于栈的指令集架构,与之对应的是寄存器指令集架构。基于栈的指令集主要的优点是可移植性。而寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地受到硬件的约束。

 

    原文作者:java虚拟机
    原文地址: https://blog.csdn.net/yingminxing/article/details/8211103
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞

发表评论

电子邮件地址不会被公开。 必填项已用*标注