深入理解java虚拟机-虚拟机执行子系统

1. class类文件结构

  1. class文件是以8字节为基础单位的二进制流,中间没有添加任何分隔符
  2. 根据java虚拟机规范的规定,class文件格式采用类似c语言结构体的伪结构来存储数据,这种结构只有两种类型:无符号数和表
  1. 无符号数属于基本的数据类型,以u1,u2,u4,u8来分别表示1个字节,2个字节,4个字节和8个字节的无符号数,无符号数用来描述数字,索引引用,数量值或按照UTF8编码构成字符串数
  2. 表是由多个无符号数或其他表作为数据项构成的复合数据类型,所有表都习惯性的以"_info"结尾,表用于描述有层次关系的复合结构的数据。整个CLASS文件本质上也是一张表。
  1. 魔数(4个字节):每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件,第5个字节和第6个字节是次版本号,第7个字节和第8个字节是主版本号

很多文件存储标准都是用魔数来进行身份识别,比如jpg,jpeg等在文件头都存着魔数
查看方式,使用十六进制编辑器打开可以看到

class文件格式
image

  1. 常量池计数器(2个字节)
      主版本之后的即为常量池,通常存放的是class文件的资源仓库
      常量池容量计数值,计数器从1而不是0开始,0x0016,十进制为22,代表有21个常量。索引为1~21.没有使用0索引是因为在后面某些指向常量池的索引可以通过0索引表示不引用任何一个常量池项目的意思。

这样做的目的是为了区分其他数据类型不引用常量池,比如接口索引集合,字段表索引集合,方法表集合等容量计数都是从0开始的

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

字面量:

  • 文本字符串
  • 声明未final的常量值

符号引用:

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

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

在这里插入图片描述

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

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

使用JAVAP工具可以分析class文件字节码

javap -verbose TestClass

  1. 访问标志

在常量池之后,紧接着2个字节为访问标志,用于识别一些类或接口的访问信息,包括这个class是类还是接口,是否为public,是否为abstract等

在这里插入图片描述

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

类索引和父类索引都是u2类型的数据,而接口索引集合是一组u2类型数据的组合,class文件中由这三项用来确定继承关系

类索引、父类索引和接口索引集合都是按照顺序排列在访问标志之后

  1. 字段表集合

用于描述接口或类中声明的变量。字段field包括了类级变量或实例级变量,但不包括在方法内部声明的变量。
字段修饰符(access_flags)结构:描述了该字段是否是Public,private, protected, static,final,volatile,transient等
在这里插入图片描述
在字段表结构之后有两项索引值:name_ index,descriptor_index,他们都是对常量池的引用,分别代表字段的简单名称以及字段和方法及描述符

  • 简单名称:没有类型和参数修饰的方法或者字段名称
  • 描述符:用来描述字段的数据类型,方法的参数列表(数量,类型,顺序),和返回值
    在这里插入图片描述

在java中字段是无法重载的,两个字段的数据类型,修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码文件来说,如果两个字段的描述符如果不一样的话,那么内容是可以重复的

  1. 方法表集合

描述方法

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

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

  1. 属性表集合

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

在这里插入图片描述
在这里插入图片描述
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关键字的访问转变为对一个普通方法参数的访问

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

InnerClasses属性

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

Deprecated和synthetic属性

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

  1. 字节码指令

共有十种指令

  1. 加载和存储指令

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

  1. 运算指令

用于对两个操作数栈上的值进行某种运算,并把结果重新存入的栈顶,可分为整形数据运算指令与浮点型数据运算指令

  1. 类型转换指令

将两种不同数值类型的进行相互转换,一般用户实现代码中显式的类型转换操作

  1. 对象的创建于访问指令

java虚拟机对于类的实例与数组的创建与操作使用了不同的字节码命令

在这里插入图片描述
5. 控制转移指令

可以让java虚拟机有条件或者无条件的从指定的位置指令,而不是控制转移指令的下一条

  1. 方法调用和返回指令

方法调用,分派执行过程

  1. 异常处理指令

在java红显示抛出异常情况(throw语句)都是由athrow指令来实现

在java虚拟机中,处理异常(catch语句),不是有字节码指令来实现,而是采用异常表来完成的

  1. 同步指令

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

方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用与返回操作之中,

总结

class文件是java虚拟机执行引擎的数据入口,上面主要介绍了class类文件的各个组成部分,以及每个部分的定义、数据结构和使用方法。

2. 虚拟机类加载机制

开篇:

这一部分的内容主要介绍class文件如何加装到虚拟机中,及在虚拟机中会发生怎样的变化

虚拟机类加载机制:

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

1. 类的加载时机

  类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载7个阶段

在这里插入图片描述

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

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

  1. 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,如果类没有进行初始化,则先初始化。这4个字节码常见的出现场景是:使用new关键字实例化对象的时候,读取或设置静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行初始化,则需要先触发其初始化
  3. 初始化一个类时,如果其父类还未初始化,则先出发父类初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类
  5. 针对jdk1.7,如果java.lang.invoke.MenthodHandle最后解析的结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个句柄所对应的类没有进行过初始化,则会触发其初始化

2. 类的加载过程

加载

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

  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文件的字节流中包含的信息符合当前虚拟机的要求,并却不会危害虚拟机自身的安全。

验证阶段是非常重要的,这个阶段是否严谨决定了java虚拟机是否能承受恶意代码的攻击,一些在编译层面上可以控制的事情(比如超边界访问数组,跨类型进行类型对象转换存在时,编译器是拒绝工作的)可以通过直接修改class文件的方式进行破解,这就是验证阶段存在的原因。

验证阶段分为4个校验动作

  1. 文件格式的验证

比如:验证魔数
主、次版本号
常量池中的常量是否有效且虚拟机支持

  1. 元数据的验证

类是否有父类(除了java.lang.Object)
如果不是抽象类,是否实现或者继承了父类所有的方法

  1. 字节码的验证

保证任意时刻操作数栈的数据类型与指令代码都能配合工作,比如在操作栈放了int类型,结果被long类型加载到本地变量中
保证方法体中的数据类型转换是有效的

  1. 符号引用验证

符号引用中通过字符串描述的全限定名是否能找到对应的类
在指定的类中是否存在符合方法的字段描述
符合引用中类、字段、方法访问性是否可被当前类访问

准备

  准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个时候内存分配的仅包括类变量(static变量),不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。

其次是这里所说的初始值“通常情况下”是数据类型的零值(随后在初始化阶段生成定义的初值)。如果该变量被final修饰,将在编译时生成ConstantValue,这样在准备阶段将直接设置成该初值。

基本类型零值

解析

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

符号引用在CLASS文件中它以CONSTANT_CLASS_INFO,CONSTANT_FIELDREF_INTO,CONSTANT_METHODREF_INFO等类型的常量出现。

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

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

初始化

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

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

3. 类加载器

双亲委派模型

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

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

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

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

类加载双亲委派模型

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

在不同的虚拟机中,执行引擎在执行java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)

1. 运行时栈帧结构

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

局部变量表

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

操作数栈

操作数栈也称为操作栈,后入先出,在一个方法执行之前操作栈是空的,在方法的执行过程中,会有各种字节码指令往操作栈中写入和读取内容,也就是入栈、出栈的操作

动态链接

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

方法返回地址

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

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

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

2. 方法调用

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

解析

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

在类加载的解析阶段,一部分符号引用会被转化为直接引用,这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,且这个方法的调用版本在运行时是不可改变的(“编译期可知,运行期不可变”)。

分派

静态分派

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

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

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

动态分派

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

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

单分派和多分派

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

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

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

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