《深入理解Java虚拟机》阅读笔记 第三部分 虚拟机执行子系统
前言:JVM的自动内存管理机制
一、类文件结构
Class文件时Java虚拟机执行引擎的数据入口,了解Class文件的结构对后面进一步了解虚拟机执行引擎有重要意义。
1. 基础概述
Java语言中的各种变量、运算符最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定比Java语言本身更强大。
2. Class类文件的结构
魔数与Class文件版本
每个Class文件头4个字节称为魔数,唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
紧跟着的4个字节存储的是Class文件的版本号,第5-6字节是次版本号,第7-8字节是主版本号。
常量池
紧接着主次版本号之后的是常量池入口,是Class文件结构中与其他项目关联最多的数据类型,也是占空间最大的数据项目之一。
访问标志
在常量池结束之后,紧接着两个字节代表访问标志,这个标志用于识别一些类或接口层次的访问信息。
类索引、父类索引与接口索引集合
按顺序排在访问标志后,这三项来确定类的继承关系类索引就是这个类的全限定名,父类索引是这个类的父类全限定名,接口索引集合描述这个类实现了哪些接口。
字段表集合
描述接口或类中声明的变量,字段包括了类级变量或实例级变量,但不包括在方法内部声明的变量。
方法表集合
对方法的描述和对字段的描述几乎采用了完全一致的方式,方法表的结构同字段表一样,包含访问标志、名称索引、描述符索引、属性表集合几项。
属性表集合
字段表和方法表都可以携带自己的属性表集合,用以描述某些场景专有的信息。不像其他的数据项目要求严格的顺序、长度和内容,属性表几个的限制稍微宽松一点,不要求顺序,并且任何人实现的编译器都可以向属性表写入自己定义的属性信息。
3. 字节码指令简介
Java虚拟机的指令由一个字节长度、代表着某种特定操作含义的数字+跟随其后的零至多个代表此操作所需参数构成。简单来说就是操作码+操作数组成。
- 字节码和数据类型
大多数的指令都包含了其操作所对应的的数据类型信息
- 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
- 运算指令
对两个操作数栈上的值进行特定的运算,并把结果重新存到操作栈顶,算术指令使用的都是Java虚拟机的数据类型,没有直接支持byte、short、char、boolean的算术指令,因此都是操作int类型代替。
- 类型转换指令
实现代码中的显示类型转换操作。转换或多或少会出现上限溢出、下限溢出和精度丢失等情况,并且不会导致虚拟机抛出异常。
- 对象创建和访问指令
类实例和数组都是对象,但它们使用了不同的字节码指令。
- 操作数栈和管理指令
Java虚拟机提供了一些用于直接操作操作数栈的指令。
- 控制转移指令
让Java虚拟机有条件或无条件的从指定的位置指令的下一条指令继续执行程序
4. Class文件结构的发展
Class文件的主体结构几乎没有发生过变化,主要改进都在访问标志、属性表这些设计上本就可扩展的数据结构中添加内容。
改进主要为了支持Java的新的语言特性,例如枚举、变长参数、泛型、动态注解等。
二、虚拟机类加载机制
1. 本章概述
虚拟机类加载机制简单来说就是把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
Java语言里类型的加载和连接过程都是在程序运行期间完成的,Java的运行期动态加载和动态连接这个特点使得Java具备可以动态扩展的特性,虽然增加了类加载时的性能开销,但是提供了高度的灵活性。
2. 类加载的时机
类的生命周期如下,从被加载到虚拟机内存到卸载出内存为止,包括有:加载->验证->准备->解析->初始化->使用->卸载,其中验证+准备+解析统称为连接。
类的生命周期
加载、验证、准备、初始化、卸载的“开始”顺序是确定的,解析不一定,有可能会发生在初始化之后,注意这里说的是“开始”,因为这些阶段都是互相交叉的混合式进行。
有且只有四种情况需要立即对类进行“初始化”(加载、验证、准备在这之前已经开始):
- 遇到new、getstatic、putstatis或invokestatic这4条字节码指令时
- 使用java.lang.reflect包的方法对类进行反射调用时
- 初始化一个类发现其父类还没有初始化,初始化他的父类
- 虚拟机启动时,包含main()方法的那个类
这四种场景称为对一个类进行主动引用,除此之外的引用都不会触发初始化,称为被动引用。
3. 类加载的过程
类加载过程包含:加载、验证、准备、解析和初始化五个阶段
加载
虚拟机在加载过程主要完成三件事:①通过类的全限定名获取定义此类的二进制字节流 ②将这个字节流代表的静态存储结构转化为方法区的运行时数据结构 ③在Java堆中生成一个代表这个类的对象,作为方法区数据的访问入口。
验证
验证是连接阶段的第一步,这一阶段的目的是确保类文件的字节流符合当前虚拟机的要求,不会危害虚拟机自身的安全。
不同虚拟机对类验证的实现可能会有所不同,大致上都会完成以下四个阶段的检验:
- 文件格式验证:魔数、常量池索引值和常量类型、数据的编码等等,保证输入的字节流能正确的解析并存储于方法区之中。
- 元数据验证:语义检验,保证其描述信息符合Java语言规范要求,验证点包括是否有父类、继承关系是否合法、是否实现了父类或接口中的方法、是否有不合规的重载等等。
- 字节码验证:数据流和控制流分析,保证被校验类不会在运行时危害虚拟机安全。
- 符号引用验证:虚拟机将符号引用转化为直接引用时,也就是解析阶段时发生,对类自身以外的信息进行匹配性校验。目的是确保解析正确执行。
准备
正式为类变量分配内存并设置类变量初始值,类变量的内存都在方法去中进行分配。内存分配仅包括类变量(static),不包括实例变量,并且准备阶段设置的初始值通常情况是数据类型的零值。
解析
这阶段虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用是用一组符号描述引用的的目标,只要使用时能够无歧义的定位到目标即可,直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄,直接引用与虚拟机实现的内存布局相关。
解析的动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。
初始化
这是类加载过程中的最后一步,初始化阶段是执行类构造器\<clinit>()方法的过程,该方法是编译器自动收集类中所有的类变量的赋值动作和静态语句块中的语句合并产生的。
父类的\<clinit>()方法不需要子类显示调用,子类该方法执行之前会确保父类的该方法执行完毕,因此在虚拟机中第一个被执行的\<clinit>()方法的类肯定是java.lang.Object。如果一个类没有静态语句块也没有对变量的复制操作,那么可以不生成该方法。
4. 类加载器
类加载器是一个代码模块,完成“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作。不同的类加载器加载的类即使来源于同一个Class文件他们也不相同(equal()、isInstance())。
因此,对于任意一个类,都需要由加载它的类加载器和这个类本身来一同确立其在Java虚拟机中的唯一性。
双亲委派模型
绝大多数Java程序都会使用到以下三种系统提供的类加载器:
- 启动加载器(Bootstrap ClassLoader)
- 扩展类加载器(Extension ClassLoader)
- 应用程序类加载器(Application ClassLoader)
上述三种加载器互相配合进行加载,有时也会有自己定义的类加载器,类加载器的层次关系如下图:
类加载器双亲委派模型(Parents Delegation Model)
除了启动加载器其他的都有自己的父类加载器,父子以组合关系来让子加载器复用父加载器的代码。
双亲委派的工作过程是:一个类加载器收到了类加载请求,会把这个请求委派给父类加载器去完成,所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子类加载器才会尝试自己去加载。
双亲委派模型是的类加载器具备了优先级的层次关系,使得不同地方的同一个类都是由同一个类加载器加载,不会出现类不同的情况。
破坏双亲委派模型
为什么要破坏双亲委派:某些特殊情况,父类加载器受限于加载范围的限制,无法加载需要的文件,需要委托子类加载器去加载Class文件。常见的例子有JDBC中的DriverManager使用上下文类加载器作为默认的系统加载器、Tomcat每个webappClassLoader加载自己目录下的Class文件,不会传递给父类加载器。
双亲委派并不是一个强制性约束的模型:
第一次被破坏是发生在JDK1.2之前。
第二次被破坏,为了解决一些基础类不在启动类加载器的加载范围内的情况,Java设计团队引入了线程上下文类加载器(Thread Context ClassLoader),它打通了双亲委派的层次结构,逆向使用类加载器,例如JNDI、JDBC、JCE、JAXB、JBI。
第三次被破坏,为了实现热插拔、热部署、模块化,把这些模块连同类加载器一起换掉实现代码的热替换。
三、 虚拟机字节码执行引擎
从表面来看,所有Java虚拟机的执行引擎都是一致的:输入字节码文件,处理过程是字节码解析的等效过程,输出是执行结果。本章从概念模型的角度讲解虚拟机的方法调用和字节码执行,分析虚拟机在执行代码是如何找到正确的方法,如何执行方法内的字节码,以及执行代码是涉及的内存结构。
1. 运行时栈帧结构
栈帧用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。
每一个方法从调用开始到执行完成的过程,就对应着栈帧在虚拟机栈里从入栈到出栈的过程。每一个栈帧包括局部变量表、操作数栈、动态链接、方法返回和一些额外信息。局部变量表大小和操作数栈深度取决于虚拟机的实现,是一个固定值不受运行期变量数据影响。
局部变量表
一组变量值存储空间,存放方法参数和方法内部定义的局部变量。以变量槽(Variable Slot)为最小单位。
一个Slot存放32位以内的数据,对象的引用也可以存进来获取对象在Java堆的起始地址和方法区的对象类型数据。局部变量不同于类变量有一个默认值,局部变量定义了但没有赋初始值是不能使用的。
操作数栈
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,这句话里的“栈”指的就是操作数栈。
操作数栈的每一个元素可以是任意的Java数据类型,32位占栈容量1,64位数据占栈容量2。操作数栈中的元素的数据类型必须和字节码指令的序列严格匹配。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
方法返回地址
一个方法被执行后,两种方式退出这个方法,一是正常完成出口:执行引擎遇到任意一个方法返回的字节码指令;二是异常完成出口,执行过程中遇到了异常,并且没有搜索到匹配的异常处理器,导致方法退出。
正常情况下,方法退出时,调用者PC计数器的值作为返回地址,把当前栈帧出栈,恢复上层方法的局部变量和操作数栈,如果是异常退出返回地址是要通过异常处理器来确定,栈帧中不会保存这部分信息。
2. 方法调用
这里说的方法调用不是方法执行,惟一的任务就是确定调用哪一个方法,不涉及方法内部的运行过程。一切方法调用在Class文件里面存储的都只是符号引用,不是方法在实际运行内存布局中的入口地址,这就需要Java在类加载期间甚至是运行期间才能确定目标方法的直接引用。
解析调用
方法在Class中都是符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,也就是调用目标在编译时就确定了下来,调用版本在运行期是不可改变的,符合“编译器可知,运行期不可变”,这类方法称为解析。
符合解析条件的有静态方法(invokestatic)、私有方法、实例构造方法、父类方法(这三个都是invokespecial)两大类,他们都会在解析阶段确定惟一的调用版本,都适合在加载阶段进行解析。
分派
分派围绕继承、封装、多态,帮助虚拟机正确地确定目标方法。
- 静态分派
依赖静态类型定位方法执行的动作称为静态分派,典型应用就是重载,重载时在编译阶段定位最合适的方法。
虚拟机在重载时是通过参数的静态类型作为判断依据的,而不是使用实际类型。并且静态类型在编译器就是可知的,Javac编译器就根据参数的静态类型决定使用哪个重载版本。
静态分派和解析不是二选一关系,是在不同层次上的筛选确定。
- 动态分配
Java虚拟机根据实际类型来分派方法执行版本,动态分配的典型应用是重写,主要依赖与Java虚拟机的invokevirtual指令,在运行期确定接收对象的实际类型。
- 单分派和多分派
Java语言是一个静态多分派、动态单分派的语言。
- 虚拟机动态分派的实现
在方法区中建立一个虚方法表,通过使用方法表的索引来代替元数据查找以提高性能,虚方法表中存放着各个方法的实际入口地址,方法表在类加载的连接阶段进行初始化,准备了子类的初始化值后,虚拟机会把该类的虚方法表也进行初始化。
3. 基于栈的字节码解释执行引擎
探讨虚拟机是如何执行方法里面的字节码指令。
解释执行
对Java来说,是解释执行还是编译执行,只有确定了Java实现版本和执行引擎运行模式时描述才会比较确切。Java语言中,Javac编译器完成了程序代码的经过词法分析、语法分析到抽象语法书,再遍历语法树生成线性字节码指令流的过程。
基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流基本上是基于栈的指令集架构,以来操作数栈进行工作,与之相对的另外一套常用的指令集架构师基于寄存器的指令集,也是主流PC中直接支持的指令集架构,依赖寄存器进行工作。
栈指令集的优点有可移植,不像寄存器受硬件约束,编译器实现更简单,缺点在于执行速度相对慢一些。
基于栈的解释器的执行过程
基于栈的解释器执行过程简单来说就是,根据具体的指令,先将数据放到局部变量表中,然后将局部变量表中的数据放到操作数栈中,从栈中取出数进行计算。整个运算过程的中间变量都以操作数栈的出栈和入栈为信息交换途径。
四、 类加载及执行子系统的案例
1. Tomcat:正统的类加载器架构
一个功能健全的Web需要解决:①两个Web应用使用的Java类库版本可能不一致,需要相互隔离。②两个Web应用使用到Java类库可以相互共享。③服务器要尽可能保证自身安全不受部署的应用影响。④支持JSP应用的Web服务器,大多都需要支持HotSwap功能。
Tomcat按照经典的双亲委派模型实现,加上自定义的多个类加载器,如下图:
Tomcat的目录结构中,“/common”-Tomcat和所有应用共同使用,“/server”-Tomcat使用,应用不可见,“/shared”-应用共同使用,Tomcat不可见,“/WEB_INF”-仅仅此应用使用的类库。这四个目录下的类库分别由上图的Common、Catalina、Shared、Webapp类加载器加载。前三个目录现在已经合并成一个“/lib”目录。被CommonClassLoader或SharedClassLoader加载的Spring通过上下文加载器访问不在其范围内的WebAppClassLoader加载的bean。
2. OSGi:灵活的类加载器架构
OSGi有一个灵活的类加载器架构,Bundle类加载器之间只有规则,没有固定的委派关系,一个Bundle声明了一个它依赖的Package,之后这个Package所有的类加载动作都会委派给发布它的Bundle类加载器去完成。各个Bundle加载器是平级关系,只有具体使用到某个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派和依赖。
OSGi描绘了一个模块化开发的目标,可以实现模块级的热插拔功能,当程序升级更新或调试除错时,可以只停用、重新安装然后启用程序其中的一部分。
3. 字节码生成技术与动态代理的实现
字节码生成技术的例子有很多,javac命令就是一种很原始的字节码生成技术,还有比如Web服务器中的JSP编译器、编译时织入的AOP框架,还有很常见的动态代理技术,甚至是使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。
这里用动态代理简述下字节码编程技术,动态代理实质是动态字节码与反射机制结合,运行期根据不同的入参生成不同的字节码文件,继承于Proxy对象,基于实现被代理对象的接口实现代理的功能,因此从这个角度来说,JDK的动态代理无法对非实现接口的类做代理。
Ref
周志明. 深入理解Java虚拟机[M]. 机械工业出版社, 2013.