本博客内容为《深入理解Java虚拟机:JVM高级特性与最佳实践》的阅读笔记。
仅从传统意义上来看,Sun官方所定义的Java技术体系包括以下几个组成部分:
其中Java程序设计语言、Java虚拟机和Java API类库这三部分统称为JDK(Java Development Kit)。JDK是用于支持Java程序开发的最小环境,即如果你要进行Java开发,你至少要在你的开发机器上安装JDK。
而Java SE API子集和Java虚拟机这两部分统称为JRE,JRE是支持Java程序运行的标准环境,如果你不需要进行Java程序开发,只是要运行Java程序,例如运行Jar文件,那么你可以在你的运行机器上只安装JRE。
如下图所示,展现了Java技术体系所包含的内容,以及JDK和JRE涵盖的范围:
Java技术体系按照关注的重点业务领域来划分,可以分为四个平台:
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些数据区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户的启动和结束而建立和销毁。
通常情况下Java虚拟机运行时数据区域分为如图所示的几个区域:
程序计数器
程序计数器(Program Counter Register)即图中的PC Register,它是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的模型概念中,字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换处理器执行时间的方式实现的,在任何一个确定的时刻,一个处理器的一个核只会执行一条线程中的指令,因此,为了线程切换后能够恢复到正确的执行位置,每一条线程都需要拥有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,这类内存区域称为“线程私有”的内存,即如上图所示,每一个线程都会拥有自己的一块内存区域。
程序计数器在执行本地方法时(例如调用C语言代码)计数器值为空,其他时候则是指向正在执行的虚拟机字节码指令的地址。
程序计数器是在Java虚拟机规范中唯一一个没有规定任何OutOfMemoryError情况的区域,因为Java程序计数器它所需要存储的内容仅仅就是下一个需要待执行的命令的地址,其所需内存是创建时即可只晓的,不需要后期进行扩容等其他的操作。
Java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stacks)即图中的Stack,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。Java每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至方法执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
虚拟机栈中局部变量表部分与Java对象内存分配关系密切,局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,该类型可能是一个指向对象起始地址的引用指针,也可能是一个代表对象的句柄或其他于此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
局部变量表中,64位长度的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态拓展,如果拓展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈作用类似,它们之间的区别是虚拟机栈为虚拟机执行Java方法,而本地方法栈则为虚拟机执行Native方法服务。有些虚拟机会将本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出StackOverflowErro和OutOfMemoryError异常。
Java堆
Java堆(Java Heap)即图中的Heap区域,对于大多数的应用来说,Java堆是虚拟机所管理的最大的一块内存。Java堆是被所有的线程所共享的,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都是在这里分配内存的(Java虚拟机规范中描述为所有的对象实例和数组都要在堆上分配内存)。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为GC。从内存回收的角度来看,由于现在收集器基本都是采用分代算法收集器,所以Java堆中还可以细分为:新生代和老年代;再细致一点可以分为Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
根据Java虚拟机规范,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,在实现时既可以是固定大小的,也可以是可拓展的,当前主流的虚拟机都是按照可拓展来实现的。如果在堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出OutOfMemoryError异常。
方法区
方法区(Method Area)与Java堆一样,是线程共享的,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java虚拟机规范堆方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可拓展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的内存回收成绩比较令人难以满意,尤其时类型卸载,条件相当苛刻,但是这个区域的内存回收也是必要的。
根据Java虚拟机规范规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分,Class文件中除了类的版本、字段、方法、接口等描述信息以外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量池放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范所定义的内存区域,但是这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。
在JDK1.4中新加入的NIO(New Input/Output)类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆中和Native堆中来回复制数据。
直接内存虽然不会收到Java堆大小的限制,但是受到本机总内存大小以及处理器寻址空间的限制,如果忽略了直接内存,当各个区域内存总和大于服务器内存时,将会导致动态拓展时出现OutOfMemoryError异常。
各数据区域异常发生举例
Java堆溢出:Java堆用于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆的容量限制后就会产生内存溢出异常。
虚拟机栈和本地方法栈溢出:如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverFlowError异常;如果虚拟机在拓展时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
方法区和运行时常量池溢出:通过不断的创建字符串常量,同时还要保证这些字符串常量不被垃圾回收机制回收。
Java程序在运行的过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常仅仅是一个new关键字,但是在虚拟机中,则是一个复杂的创建过程。
虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能够在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那么必须先执行相应的类加载过程。
在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可以完全确定,为对象分配空间的任务等同于把一块大小确定的内存从Java堆中划分出来。
Java对象内存分配有两种方式,第一种是“指针碰撞”,即假设Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象所需内存大小相等的距离。第二种分配方式为“空闲列表”,当Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单的使用指针碰撞这种方式来进行内存分配了,虚拟机这时候必须要维护一个列表,记录内存中哪些是空闲的,在分配内存的时候从列表中找到一块足够大的内存空间划分给对象,并更新列表。因此使用哪一种分配方式是由Java堆是否规整来决定的,而Java堆是否规整则是由所采用的垃圾收集器是否带有压缩整理功能来决定的。
除了如何划分可用空间外,还有一个需要考虑的问题是对象创建是一个非常频繁的过程,在并发情况下修改指针所指向的位置是不安全的,可能对象A和对象B被分配在了同一个内存块中。解决这个问题的方案有两种,一种是分配内存空间的动作进行同步处理,另一种是把内存分配动作按照线程划分在不同的空间之中进行,即每一个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋值就直接使用。
接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息都存放在对象头中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
当上述的过程都执行完成以后,从虚拟机的角度,一个新的Java对象已经创建完成了,但是对于Java程序而言,对象创建才刚刚开始,执行完new指令后会接着按照程序员的意愿对对象进行各种初始化的操作,至此一个对象就算真正的创建完成了。
在HotSpot虚拟机中,对象在内存中存储的的布局可以分为3块区域,对象头(Header)、实例数据(Instance Data)和对其填充(Padding)。
对象头
对象头包括两部分的信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,对象头中还必须有一块用于记录数组长度的数据,虚拟机可以以此来确定数组的大小。
实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要进行记录。
对齐填充
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用(因为HotSpot虚拟机的自动内存管理要求对象起始地址必须是8的整数倍)。
建立对象是为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机中只规定了一个指向对象的引用,并没有定义这个引用以何种方式去定位、访问堆中对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的,目前主流的访问方式有使用句柄和直接指针两种方式。
使用句柄
如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如下图所示(图片来自《深入理解Java虚拟机:JVM高级特性与最佳实践》):
使用句柄相当于在reference和对象中间增加了一层句柄层,对象的reference中只需要记录一个句柄的地址,而句柄中记录了对象中各个对象实例数据、对象类型数据的地址,访问时一共需要进行两次的指针定位,第一次先找到句柄,第二次通过句柄找到数据。
句柄访问的好处是当对象被移动后,只需要改变句柄中的实例数据指针即可,不需要修改reference。
直接指针
如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,如下图所示(图片来自《深入理解Java虚拟机:JVM高级特性与最佳实践》):
使用指针进行访问只需要进行一次指针定位就可以找到对象数据,在Java中对象的访问是非常频繁的,因此一次指定定位能够节省非常大的一部分开销。就HotSpot而言,它使用的就是指针访问的方式。
对象存活表示的是当前对象是否还在被使用,没有被使用的对象我们可以称其为已经“死亡”,如果对象依然在被使用,我们称其为“存活”状态,对象是否被使用则是通过对象的引用进行判断的。而垃圾回收机制就是负责将已经死亡的对象进行清理。
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域不需要过多的考虑回收的问题,当方法结束或者线程结束的时候,内存自然就跟着回收了。Java堆则和上述三种区域不同,Java中一个接口的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也不一样,而只有当Java程序运行时我们才能知道哪些对象会被创建,所以堆中的内存分配和回收都是动态进行的,因此垃圾收集器所关注的也是这部分的内存。
垃圾回收器在对堆进行回收前,第一件事情就是要判断堆中的对象哪些是依旧在使用的,哪些已经不可能再被使用了。这里的判断主要有两种方式,第一种是引用计数算法,第二种是可达性分析算法。
引用计数算法
引用计数算法给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1,当引用失效时,计数器就减1,任何时刻计数器为0的对象就是不可能再被使用的。这种算法实现简单,判定效率也很高,但是它难以解决对象之间循环引用的问题,例如对象A和对象B相互引用了对方,而A和B都没有在被使用了,但这两个对象却也不会被垃圾回收器回收。
可达性分析算法
主流的判断方法则是使用可达性分析算法来判断对象是否存活。这个算法需要选择一些对象作为“GC Roots”,每次都通过这些roots节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots不存在引用链的时候,则证明这个对象是不可用的。
如下图所示,对象4和对象5都没有与GC Roots连接,因此对象4和对象5将可以被垃圾回收器进行回收。
在Java语言中,可作为GC Roots的对象包括下面几种:
可达性分析算法中根据GC Roots找引用链,存在两个主要的问题,一个是可作为GC Roots的节点主要在全局性的引用(例如常量或者类静态属性)于上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,将会消耗很多的时间。还有一个问题是GC停顿,可达性分析必须确保在整个的分析过程中,执行系统就像被冻结在某个时间节点,整个分析过程中对象的引用关系不能发生变化,这样才能保证分析结果的准确性,因此在进行GC时,需要停顿所有的Java线程。
JDK1.2以前,Java中引用的定义很传统,如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义下的对象只存在两种状态,被引用和未被引用状态。但有些对象我们希望当内存存够的时候能够保留这些对象,当内存不足的时候则能够对这些对象进行清理,这一类对象则无法使用这种传统的定义来表示。
JDK1.2之后,Java对引用进行了扩充,将引用分为强引用、软引用、弱引用和虚引用四种,这四种引用的强度依次逐渐减弱。
即使在可达性分析算法中不可达的对象,也并非是”非死不可“的,这时候它们暂时处于”缓刑“阶段,要宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法(当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为”没有必要执行“)。如果一个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里的执行是指由虚拟机去触发这个方法,但并不一定会等待该方法执行完毕(为了避免finalize方法中出现类似死循环都操作,导致内存无法被回收,同时导致F-Queue队列中的其他对象一直处于等待状态)。当执行完finalze()方法后,GC将会对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()方法中又重新获得了引用,对象将会被移出对列并且继续存活,如果对象依旧存在于队列中并且被进行第二次标记,对象将被GC回收。
需要注意的是任何一个对象的finalize()方法只会执行一次,如果第一次通过finalize()方法救活了对象,那么第二次相同的方法就会失效。同时由于finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此应当尽量避免使用finalize()方法。
常规应用中进行一次垃圾回收,能够回收70%~95%的空间,而方法区中回收效率远低于此。方法区中回收的主要内容是:废弃常量和无用的类。
废弃常量回收与Java堆中的内存回收非常的类似,例如一个字符串,当没有任何String对象引用常量池中该字符串,也没有任何其他地方引用这个字面量时,该字符串常量将可以在下一次垃圾回收时被回收。
无用的类判定就会比较麻烦。类需要同时满足下面三个条件才算是”无用的类“:
当一个类满足上述条件时,该类将可以被回收,但不是说该类不使用了就必然会被回收,类的回收可以通过-Xnoclassgc参数进行配置。在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP已经OSGi这类频繁自定义ClassLoader的场景中都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
通过之前的方法,已经能确定哪些内存空间要被回收,而垃圾收集算法的目的是在已经明确了哪些内存块需要回收以后,如何高效的回收这些内存空间。
标记清除算法
标记清除算法是最基础的收集算法,后续很多算法都是基于它的思路进行改造而得出的,如同它的名字一样,算法分为两个阶段:首先标记出所有需要回收的对象(这里的标记就是使用之前提到的引用计数或者可达性分析算法),然后对所有被标记的对象进行统一回收。
标记清除算法主要有两个不足之处:一个是效率问题,标记和清除两个过程的效率都不高;另一个问题是空间问题,标记清除之后会造成内存空间中存在大量的内存碎片,空间碎片太多时,当要分配一片大内存空间时可能会找不到合适的连续内存空间进行分配,从而触发另一次垃圾收集动作。
复制算法
复制算法将内存按照容量划分为大小相等的两块,每次只使用其中的一块。当其中一块的内存用完了,就把这块内存中已存活的对象全部移动到另一块,再把已使用的这块内存全部清理掉。这样使得每次都是对整个半区的内存进行回收,避免了碎片空间的产生。这种算法实现简单,运行高效,但是要付出一般的内存空间作为代价来实现。
现在的商业虚拟机都会采用这种算法来回收新生代,根据统计新生代中98%的对象都是“朝夕生死”的,因此对于新生代的回收不用按照1:1的比例来进行内存划分,可以将内存划分为一块Eden区域和两块Survivor空间,每次使用时都选择Eden区域和一块Survivor区域进行内存分配。当回收时,将Eden区域和Survivor区域中还存活的对象全部移动到另一块Survivor区域,然后清理掉Eden区域和刚刚使用的Survivor区域。HotSpot虚拟机中Eden和Survivor的比例是1:8,即每次都有90%的内存空间在进行使用,只有10%的内存空间被浪费了。当然,如果每次内存都有98%被回收,那么每次被移动到另一块Survivor区域的内存只有2%,这样是没有任何问题的,但是如果移动到另一块Survivor区域的内存超过了10%,就需要依赖其他的内存(这里指老年代)进行分配担保了(将多出的对象分配到老年代)。
标记-整理算法
标记-整理算法的标记过程和标记-清除算法的标记过程一致,但是在标记完以后,标记-整理算法会将所有存活的对象都移动到一端,然后再进行清除。这种算法适用于老年代,因为老年代的对象存活率都会比较高,如果像之前一样进行复制移动,将会产生大量的复制操作导致效率变低,同时每次都会存活下大量对象导致需要很多的内存空间来进行分配担保。
分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法根据对象存活周期的不同将内存划分为几块,一般是把Java堆划分位新生代和老年代。新生代中每次都会有大批对象死去,只有少量对象存活,因此可以选用复制算法。老年代每次都会有大量对象存活,因此选择标记-清理或者标记-整理算法来进行。
Java内存体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存和回收分配给对象的内存。
对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区域,如果启动了本地线程分配缓冲,将按线程优先分配在TLAB上。少数情况下也可能直接分配在老年代中。具体的分配规则取决于垃圾收集器的类型以及虚拟机中参数的配置。但是有几条最普遍的内存分配规则如下:
JDK bin目录下除了“java.exe"、”javac.exe"这两个命令以外,还有一些其他的工具可以用于监控虚拟机和故障处理。
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,就是虚拟机的类加载机制。
在Java语言里面,类型的加载、连接和初始化都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态拓展的语言特性就是依赖运行期动态加载和动态链接这个特点实现的。
类从被加载到虚拟机内存中开始,到卸载出内存为止,一共经历了:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。
其中加载、验证、准备、初始化和卸载这5个阶段的顺序是一定的,类的加载过程必须按照这种顺序按部就班的开始,而解析则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。
加载
“加载"是”类加载“的一个阶段,在加载过程中,虚拟机需要完成三件事:
对于一个数组类而言,其创建过程遵循以下规则:
当加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义。然后再内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。
加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
验证
验证时连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分。
验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
准备
准备阶段是正式为类变量分配内存并设置变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。内存分配仅包括类变量、而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化
Java虚拟机规范并没有强制约束什么时候要开始类的加载过程,但是对于初始化阶段,则是严格规定了有且只有5种情况必须立即对类进行初始化:
接口与类初始化过程中大致相同,但是在规则3中,接口并不一定要求先初始化其父类,只有当有用到时才会进行初始化。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器< clinit >()方法的过程。
public class Test{
static {
i = 0; //编译通过
System.out.print(i); //报错 Error:(20, 28) java: 非法前向引用
}
static int i = 1;
}
虚拟机设计团队把类加载阶段中的”通过一个类的全限定名来获取描述此类的二进制字节流“这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为”类加载器“。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间(即比较两个类是否相等的前提是这两个类是由同一个类加载器产生的)。
从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.class.ClassLoader。
从Java开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下三种系统提供的类加载器。
类加载器之前的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了自己顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程时:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器都要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。
双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中。