Java虚拟机类加载和执行机制

原创
2013/10/05 18:13
阅读数 1.5W

虚拟机的类加载和执行机制是虚拟机的最主要功能,在这里简单的对所知的内容进行一次温习,并记录以方便日后重温。

本篇主要引用《深入理解Java虚拟机——JVM高级特性与最佳实践》一书。

1、类文件结构

    java虚拟机要对类文件进行加载和执行,那么必须要能够理解类文件结构,而对于虚拟机而言,平台无关性和语言无关性是其最重要的两大特征,那么就势必要对类文件结构进行规范化和结构化,这样才能保证无论是什么语言编译成的字节码文件,java虚拟机都能够正常加载和执行。因此,对于字节码文件(即.class文件)的简单理解是进一步理解虚拟机运行机制的基本步骤。

    Class类文件,亦称字节码文件,是由虚拟机规范规定了其结构形式的文件。Class文件是一组以8位为基础单位的二进制流,各个数据项目严格按照顺序紧凑排列在Class文件中,中间没有任何分隔符,以保证整个Class文件中存储的内容全部是程序运行的必要数据,没有空隙。当遇到需要占用8位字节以上空间的数据时,则会按照高位在前的方式分割成若干上8位字节进行存储。

    根据虚拟机规范的规定,Class文件格式中只有两种数据类型:无符号数和表。无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节,2个字节,4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值、或者按照UTF-8编码构成字符串值。表是多个无符号数或其它表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾。无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的集合。

    说完了虚拟机规范对Class文件的基本约定后,我们来关注一下Class文件都有些啥。

    既然虚拟机是语言无关,那我们可以以java语言作为范本进行学习。回顾一下,我们在定义一个类的时候,都需要或者说可以定义些什么内容。首先,类的修饰符,是abstract,或public、protected、private,然后是类名,再接着,是否有继承或是实现父类或接口,这些是类的基本约束。再接着来看类的内容,我们可以定义类成员变量(static)和实例成员变量,接着是定义类的行为——类方法,类方法又有方法名,返回值,参数值,还有异常列表等。

    由上面这些定义的内容,我们可以猜到,当这个定义的类被编译成Class文件时,Class文件中应该要包含些什么内容了。我们再从虚拟机的角度来完整地了解Class文件的结构。

    首先,最简单的一个问题,虚拟机必须判定输入的文件是不是一个Class文件,java虚拟机通过识别输入文件的首4个字节的魔数(0xCAFEBABE)来确定其是否Class文件。接着虚拟机由于一直在不断地改进和更新,所以不断有新的版本出现,新的版本能兼容旧的版本,但旧的版本可能就完全无法读取新的虚拟机编译而成的Class文件了,因此,虚拟机就必须对Class文件进行版本的识别和检查,也就是说,Class文件必须要有版本号的数据(Class文件的第5到第8个字节)。

    紧接着是Class文件的常量池入口,常量池是Class文件结构中与其它项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时也是在Class文件中第一个出现的表类型数据项目。由于常量池中常量的数量不固定,因此在常量池的入口之前有一个u2类型的容量计数值。常量池之中主要存放两大常量:字面量(Literal)和符号引用(Symbolic Reference)。字面量即是java语言层面中的常量,如文本字符串(如"adb"等字面量),被声明成final的常量值。符号引用则属于编译原理方面的概念,包括三类常量:类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。这些符号引用在虚拟机中如果不经过转换则无法与实际内存相连接,即无法被虚拟机直接使用,在虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析并翻译到具体的内存地址中。每项常量都是一个表,而由于各个常量的类型不一,大小也不相同,所以同样需要一个u1类型的数据来标记常量的类型,以确定其后的常量表的格式。

    在常量池之后,紧接着的2个字节代表访问标志,即在前面说到的,这个Class是类还是接口,是用哪个修饰符来修饰,abstract,public等,还有,如果是类的话,是否被声明为final,等等。

    访问标志之后,则是类索引、父索引与接口索引的集合。类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。类索引用来确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按实现或继承的顺序从左到右的顺序排列在接口的索引集合中。类索引、父类索引和接口索引都按顺序排列在访问标志之后。

    接下来就是字段表了,此处字段表存的就是前文说的类成员变量或实例成员变量,但不包括方法内部声明的变量。如果类存在父类,则除非子类覆盖了父类的字段定义,否则在子类中不会列出从超类或父接口中继承而来的字段,但有可能列出原来java代码中不存在的字段,譬如在内部类为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,java中是不允许出现相同的字段名的,但对于字节码来说,如果两个字段的描述符不一致,则字段重名是合法的。

    字段表之后就是方法表集全了。方法表集合与字段表集合的结构形式几乎完全一致。此处,方法中的代码的存放位置则是方法表的属性表中的一项名为"Code"的属性里面。与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),则方法表集合中就不会出现来自父类的方法信息。

    最后来对上面说到的属性表作个解释。属性表是Class文件格式中最具扩展性的一种数据项目,在Class文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息(如方法表中专有的代码信息),具体的属性表的各个属性项目若有兴趣可以翻看《深入理解java虚拟机》这本书,也可以直接翻看虚拟机规范。

2、类加载机制

    了解了类的文件结构,接着我们来了解虚拟机如何加载这些Class文件。

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

    类的生命周期包括加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)等七个阶段,其中验证、准备和解析三个部分统称为连接(Linking)。而类的加载指的就是从加载到初始化这五个阶段。

    这七个阶段的顺序除了解析阶段和使用阶段外,其它几个阶段的开始顺序是确定的,必须按这种顺序按部就班的开始,但不要求按这种顺序按部就班的完成,这些阶段通常是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用或激活另外一个阶段。解析阶段在某些情况下可以在初始化阶段之后再开始,以支持java的运行时绑定(RTTI),而使用阶段则是按类文件内容的定义的不同而在不同的阶段进行。

    虚拟机规范对于何时进行加载这一阶段并没有强制约束,但对于初始化阶段,虚拟机规范是严格规定了有且只有四种情况必须立即对类进行初始化:

    a、遇到new,getstatic,putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指定的场景是:使用new关键字实例化对象,读取或设置一个类的静态字段以及调用一个类的静态方法的时候。当然,被final修饰并在编译期就把结果放入常量池的静态字段不属于这些场景,这类静态字段的值在编译期时就会被编译器优化而直接放入常量池,其引用直接指向其在常量池的入口。

    b、使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行过初始化,则需要先触发其初始化。

    c、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

    d、当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个主类。

    以上四种场景中的行为称为对一个类进行主动引用,除此之外所有引用类的方式都不会触发初始化,称为被动引用。

    接口的加载过程与类加载过程最主要的区别在于第三点,即当初始化一个接口时,并不需要先初始化其父接口,而是只有真正使用到父接口中的字段的时候才会初始化。

    以下对类加载的各个阶段进行简单的说明。

    加载阶段,虚拟机需要完成三件事:通过一个类的全限定名来获取定义此类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

    验证阶段,不同虚拟机会进行不同类验证的实现,但大致都会完成以下四个阶段的检验过程:文件格式验证(验证字节流是否符合Class文件格式的规范,并能被当前版本的虚拟机处理),元数据验证(对字节码描述信息进行语义分析,保证其描述信息符合java语言规范),字节码验证(对类方法体进行数据流和控制流分析,保证类的方法在运行时不会做出危害虚拟机的行为)和符号引用验证(发生在将符号引用转化为直接引用的时候,在解析阶段中发生)。

    准备阶段,正式为类成员变量(注意,不是实例成员变量,实例变量会在对象实例化时随着对象一起分配在java堆上)分配内存并设置类变量初始值(通常情况下是数据类型的零值,不进行赋值操作)的阶段,这些内存都将在方法区中进行分配。

    例:public static int value=123;则在准备阶段过后,value的初始值为0而不是123,赋值指令是在初始化阶段通过构造方法来执行的。

    解析阶段,虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用与内存布局无关,而直接引用的目标必定已经在内存中存在。解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。

    初始化阶段,真正开始执行类中定义的java程序代码(字节码),是执行类构造器<clinit>()方法的过程。

    <clinit>()方法的一些特点:

    <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量。

    <clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会在子类的<clinit>()方法执行之前完成父类<clinit>()方法的执行。

    由于父类的<clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

    <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,则编译器可以不为这个类生成<clinit>()方法。

    接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法,不同于类的地方是执行接口的<clinit>()方法时不坱要先执行父类的<clinit>()方法。

    虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,则只有一个线程去执行这个类的<clinit>()方法,其它线程阻塞等待,直到活动线程执行<clinit>()方法完毕。

    了解完各个类加载机制的阶段后,我们需要进一步了解类加载器这个概念。类加载器只用于实现类的加载动作,即实现通过一个类的全限定名来获取描述此类的二进制字节流。但对于类来说,要判断两个类是否相等(instanceof,equal),其前提是两个类是由同一个类加载器所加载,否则,无论两个类是否来源于同一个Class文件,这两个类都必定不等,亦即是说,对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机的唯一性。

    在Java开发人员看来,类加载器可划分为以下三类系统提供的类加载器:启动类加载器(Boostrap ClassLoader,负责将存放在<JAVA_HOME>\lib目录中的类库加载到虚拟机内存中,其无法被Java程序直接引用),扩展类加载器(Extension ClassLoader,由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录中的类库,可被开发者直接使用),应用程序类加载器(由sun.misc.Launcher$AppClassLoader来实现,负责加载用户类路径(ClassPath)上指定的类库,可被开发者直接使用,且为默认的类加载器)。

    java中采用双亲委派模型(Parents Delegation Model)来实现类的加载模式。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,此处的父子关系不以继承来实现,而是采用组合来利用父加载器。

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

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

    了解了以上类文件结构和类加载机制后,我们最后再来看看字节码在虚拟机中是如何被执行的。

    不同的虚拟机实现时硕,执行引擎在执行Java执行的时候可能有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至可能包含几个不同级别的编译器执行引擎。

    在具体了解虚拟机是如何执行字节码之前,我们先来从概念上理解虚拟机是如何执行程序的。程序的执行可以直接解释为是对方法的递归调用,通过一连串的方法链来最终得出执行结果,亦即是说虚拟机对程序的执行,根本上是对方法的调用和执行。

    栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表(最小单位为变量槽Variable Slot)、操作数栈、动态连接和方法返回地址等信息,每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。栈帧的内容在编译时就已经完成确定,不受程序运行期变量数据的影响,仅取决于具体的虚拟机实现。

    前面说了,对程序的执行就是对方法链的调用和执行,即可能会出现很多方法同时处于执行状态,此时对于执行引擎来说,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,其关联的方法称为当前方法,执行引擎所运行的所有字节码指令只针对当前栈帧进行操作。

    方法调用包含两种方法:解析和分派。解析调用一定是个静态过程,在编译期间完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是动态的也可能是静态的,根据分派依据的宗量数可分为单分派和多分派。(具体情形请参考《深入理解java虚拟机》这本书第8章)

    方法执行即是指字节码解释执行引擎,包括解释执行和编译执行。而java编译器输出的指令流,基本上是一种基于栈的指令集架构。即Java虚拟机采用的是基于栈的字节码执行引擎。(具体情形请参考《深入理解java虚拟机》这本书第8章)

展开阅读全文
打赏
1
41 收藏
分享
加载中
受益颇深
2013/10/08 21:57
回复
举报
更多评论
打赏
1 评论
41 收藏
1
分享
返回顶部
顶部