代码编译的结构从本地机器码转变为字节码,是储存格式发展的一小步,却是编程语言发展的一大步。
类文件结构
虚拟机类加载机制
虚拟机字节码执行引擎
各种不同的虚拟机都可以载入和执行一种平台无关的字节码,从而实现“一次编写,到处运行”。
语言无关性越来越受到开发者重视,java只是运行在java虚拟机上的一种语言。
实现语言无关性的基础是虚拟机和字节码储存格式,使用java编译器可以把java编译成储存字节码的class文件,其他语言也可以把代码编译成class文件,虚拟机只是执行class文件而不关心文件来源。
Java中各种变量、关键字和运算符的最终语义都是由多条字节码组成的,因此字节码的语义描述能力强于java语言本身,这也为其他语言实现有别于java的语言特性提供了基础。
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序储存在class文件中,中间没有任何间隔,所以这些都是必要数据。
这种数据结构中只有两种类型的数据:无符号数和表。
无符号数是基本数据类型,可以描述数字、索引引用、数量值或者utf-8编码的字符串。
表是由多个无符号数或者表组成的混合数据,整个class文件本质上就是一张表。
Class文件前4个字符是魔数,唯一作用是用来确定这个文件是否可以被虚拟机接收。
魔数后的4个字符是class文件版本号,5/6为次版本,7/8为主版本。高版本的JDK能兼容低版本的class文件,反之不可以。
主次版本号之后就是常量池入口,常量池是class文件中与其他项目关联最多的数据类型。
常量池主要存放字面量和符号引用。字面量比较像java语言层面的常量概念,比如字符串,定义为final的变量;符号引用则属于编译原理方面的概念,包括下面3类常量:
类和接口的全限定名;字段的定义和描述符;方法的定义和描述符。
Java代码进行javac编译时不会保存各个方法和字段的最终布局信息,虚拟机运行时,需要从常量池获取对应的符号引用,再在类创建或运行时解析并翻译到具体内存地址中。
由于class文件中方法、字段等需要引用CONSTANT_Utf8_info型常量描述,所以他的最大长度就是方法和字段名的最大长度。最大长度为length的最大值,即u2类型最大值65535。
常量池之后是2字节的访问标志,表示类和接口层次的访问信息,比如是否为接口,是否public,是否抽象等。
类索引和父类索引是u2类型数据,接口索引集合是一组u2数据集合,class文件由这三个数据确定继承关系。
字段表用于描述接口或类中定义的变量。字段包括了类级别变量或实例级变量,不包括方法内部的变量。描述一个字段的信息,比如作用域、数据类型等各个修饰符都是布尔值。而字段名等无法固定的,只能引用常量池中的常量描述。
与字段表集合类似。
方法里的代码,经过编辑器编译成字节码指令之后存放在方法表集合属性中“code”的属性里。
Java中重载一个方法,需要有一个与原来方法不同的特征签名,他就是一个方法中各个参数在常量池中的字符引用集合,但是返回值不包含在特征签名中,所以无法用返回值不同重载方法。但是在class文件中特征签名包括了返回值,即方法返回值不同就可以同时存在,其他语言可以利用这个特性。
Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有信息。
不像class文件其他项目一样有严格的数据限制。其他人实现的编译器可以像属性表中添加自定义属性,虚拟机会忽略不认识的属性。
按照这个顺序开始加载,各个阶段可以重叠。解析和使用顺序不固定。
1.遇到new、getstatic、putstatic或invokestatic这4个指令时,如果类没初始化,则需要先触发初始化;
2.使用java.lang.reflect包的方法对类进行发射调用时;
3.初始化类时,如果父类没初始化则先初始化父类;
4.虚拟机启动时,需要先初始化执行主类(包含main()方法那个类);
这四种为主动引用,其余都是被动引用。
通过子类引用父类中的静态字段,只会初始化定义这个字段的类(父类)。
通过数组定义引用类,不会触发初始化。
常量在编译阶段会存入调用类的常量池中,本质上没直接引用定义常量的类,所以不会加载定义常量的类。
接口中不能使用“static{}”语句块,但是编译器仍然会生成类构造器用于初始化成员变量。接口只有在真正用到父类接口时才会初始化父接口,其余主动初始化与类一致。
加载:
1. 通过类的全限定名获取此类的二进制字节流。
2. 将这个字节流的静态储存结构转换为在方法区的运行时数据。
3. 在堆内存中生成代表这个类的java.lang.class文件,作为访问这些数据的入口。
验证:
保证class文件中的字节流数据符合虚拟机的要求,不会对虚拟机造成安全问题。
验证的四个阶段:文件格式、元数据、字节码和符号引用。
验证机制非常重要但不是必要的,可以关闭验证以缩短类加载时间。
准备:
正式为类变量分配内存并设置初始值,这些内存都在方法区中进行分配。
仅仅分配类变量(static)而不包括实例变量,实例变量将在初始化时在堆内存中分配。其次这里说的初始化“通常情况下”是数据的零值。比如:
Public static int value = 123;
准备阶段过后的初始值是零值(0),在初始化时才会赋值为123。而加上final后准备阶段可直接赋值为123。
解析:
解析过程是虚拟机将常量池中的符号引用替换为直接引用的过程。
初始化:
在类加载过程中,除了加载阶段可以用自定义类加载器之外,其他过程都有虚拟机主导和控制,这一阶段才真正开始执行类中定义的java程序代码(字节码)。
初始化阶段是执行类构造器
虚拟机会保证
类加载阶段中的“通过全限定名称来获取二进制字节流”动作放到虚拟机外实现,以便让程序自己决定如何去获取所需要的类。实现这个动作的代码块称为类加载器。
对于任意一个类,都需要这个类的加载器与类自身确定其在虚拟机中的唯一性。比较两个类是否“相等”,需要在同一个类加载器的前提下才有意义。
在虚拟机的角度只存在两种类加载器,一种是启动类加载器,用C++实现并是虚拟机的一部分。另一种就是其他类加载器,用java实现并且独立于虚拟机之外,都继承自抽象类java.lang.ClassLoader。
双亲委派模型的工作过程:类加载器收到加载一个类的请求时,会先把请求委派给父类加载器,每层都是如此,最终都会传到顶层的启动类加载器,当父类无法完成加载请求(找不到类)时,子类加载器才会尝试自己加载。
双亲委派模型的好处是,类随着他的类加载器一同具有了优先级的层次关系,对于保证程序稳定很重要。比如java.lang.Object类,他存放在rt.jar中,无论哪个加载器要加载他,最终都会委派为启动类加载器,保证Object类在各种类加载器环境中都是同一个类。
双亲委派模型不是强制的,只是设计者们推荐的模型。
栈帧是用于支持虚拟机进行方法调用和执行的数据结构,是虚拟机运行时数据区的虚拟机栈中的栈元素。栈帧储存了方法的局部变量表,操作数栈,动态链接和方法返回地址等信息。每一个方法从执行到完成,对应着一个栈帧从入栈到出栈的过程。
在程序编译代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈已经确定并写入到方法表的code属性中,不会在运行期受数据变量的影响,仅仅取决于虚拟机的实现。
局部变量表是一组变量值储存空间,用于存放方法参数和方法内定义的局部变量。
局部变量表的容量以变量槽(variable slot)为最小单位,一个slot可以存下32位以下的数据类型,其中包括了reference(对象的引用)和returnAddress。虚拟机至少(不明确)可以从此引用中查询出对象在堆内存中的起始地址索引和方法区中对象的类型参数。ReturnAddress指向了一条字节码指令的地址。
Slot是可重用的,方法体中定义的变量不一定贯穿整个方法,如果当前字节码PC计数器的值已经超出了某个变量的作用域,slot可以交给其他变量使用,这样不仅可以节省栈空间,还可以在一定程度上直接影响垃圾回收。比如:当离开了一个变量的作用域之后,没有任何对局部变量的读写操作,变量原先被占用的slot没有被其他变量复用,所以作为GC Roors的一部分的局部变量表仍然保持着对它的关联(不会被回收)。但是如果一个方法后面有一些很耗时的操作,而且定义了占用大量内存、实际上不会再使用变量,手动将其设置为null就不是一个毫无意义的作用。不过不应当对赋null过多依赖,以恰当的作用域控制回收时间才是优雅的方案。
它是一个后入先出的栈,它的每一个元素可以是任意数据类型。当一个方法刚开始执行时,操作栈是空的,执行过程中会有各种字节码指令提取和写入内容。
方法执行后有两个方式退出:一个是遇到返回字节码指令,另一个是异常退出。退出后都需要返回到方法被调用的位置,并可能需要在栈帧中保存一些信息来帮助上层方法恢复执行状态。退出过程相当于把当前栈帧出栈。
不等同于方法执行,唯一任务就是确定被调用方法的版本(即调用哪个方法),暂时不涉及方法内部运行过程。
解析:
所有方法调用中的目标方法在class文件中都是一个常量池的符号引用,在类加载的解析阶段,会把一部分符号引用转为直接引用,前提是方法在程序调用之前就有一个可用版本,并且在运行期是不可变的。
分派:
1. 静态分派
Human man = new Man();
上面的Human称为变量的静态类型,Man称为实际类型。两种类型在程序中都可以发生变化,区别是静态类型变化仅仅在使用时发生,变量本身的静态类型不会改变,编译期可知的;实际类型变化的结果在运行期才可确定,编译器在编译期并不知道一个对象的实际类型是什么。
所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。最典型的应用是方法重载。发生在编译阶段,即不是虚拟机来执行的。字面量没有显示的静态类型,所以重载时往往只能确定一个“更适合的类型”。
2. 动态分派
和重写有密切关联。重写方法的两条调用指令,无论是指令(都是invokeVirtual)还是参数(常量池中的常量)都一样,只是目标方法不同,原因是invokeVirtual指令第一步就是在运行期确定接收者的实际类型,所以两次调用中invokeVirtual指令把常量池中类方法符号引用解析到了不同的直接引用上,这就是重写的本质。把运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3. 单分派与多分派
编译阶段编译器的选择过程,即静态分派过程,是根据静态类型和方法参数两个宗量,重载方法的不同参数指向不同方法的符号引用,是根据多个宗量进行选择即静态分派属于多分派类型。
运行阶段虚拟机的选择,即动态分派过程,在执行代码对应的invokeVirtual指令时,由于编译期已经决定了目标方法的签名,参数的静态类型、实际类型都不会对方法的选择构成影响,唯一可以影响虚拟机选择的因素只有此方法的接收者实际类型。只有一个宗量作为选择依据,即动态分派属于单分派类型。
java1.6是静态多分派、动态单分派的语言,但是不代表以后不会改变。
许多java虚拟机在执行java代码时都有解释执行(解释器执行)和编译执行(通过即时编译器编译成本地代码)两种执行方式。
许多高级虚拟机语言,大多都遵循这种基于现代经典编译原理的思路,执行前先对源码进行词法和语法分析处理,把源码转化为抽象语法树。
对于一门语言,可以把几乎全部编译过程独立执行引擎,形成一个完整意义的编译器去实现,比如C/C++语言;也可以把其中一部分(抽象语法树之前)实现为一个半独立编译器,比如java;又可以把这些编译步骤和执行引擎全部封装在一个黑匣子里,比如大多数JavaScript执行器。
Java编译器输出的指令流,基本上是一种基于栈的指令集架构。
两者区别:
分别使用两种指令集计算“1+1”。
基于栈的指令集:把两个常量1压入栈,然后取出栈顶2个常量相加,返回结果放回栈顶,最后把栈顶的值放入局部变量第0个slot。
基于寄存器的指令集:把一个寄存器的值设为1,然后使值增加1,结果还保存在这个寄存器中。
基于栈的指令集可移植性好,不像寄存器一样由硬件直接提供,缺点是执行稍慢。