我们写的Java程序不会无故运行起来,需要底层的软硬件支持,java程序运行所需的内存等资源都需要通过jvm来申请,可以说JVM是Java程序运行的母体,那么程序是如何在Java虚拟机中运行起来的,接下来就来分析一下类加载机制。
虚拟机把描述类的数据class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
class文件从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括:加载(Loading),验证(Verification),准备(Preparation),解析(Resolution),初始化(Initialization),使用(Using)和卸载(Unloading) 七个阶段。其中验证,准备,解析3个部分称为链接(Linking)。 其中,加载,验证,准备,初始化和卸载这5个阶段的顺序是确定的,而解析阶段则不一定,某些情况下,可能在初始化阶段之后再开始。 什么情况下开始类加载的第一个阶段,Java虚拟机规范中没有进行强制约束,但是对于初始化阶段,虚拟机则是严格规定了有且只有5中情况必须立即对类进行“初始化”。
遇到new,getstatic,putstatic,或者invokestatic这4条指令时,如果类没有进行初始化,则需要先触发类初始化。而生成这4条指令最常见的场景如下: 使用new关键字实例化对象的时候读取或者设置一个类的静态字段的时候调用一个类的静态方法的时候 使用java.lang.reflect包的方法对类进行反射调用的时候,如果没有初始化,则需要先触发初始化。当初始化一个类的时候,如果发现其父类没有进行初始化,则先触发其父类进行初始化。当虚拟机启动的时候,用户需要指定一个要执行的类,则虚拟机回先初始化这个类。当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化的时候,则需要先触发其初始化。注意:当一个类在初始化时,要求其父类全部都已经初始化过了。但是一个接口在初始化时,并不要求其父接口全部都完成初始化,只有在真正使用到父接口的时候,才会进行初始化。
Java虚拟机中加载类的全过程分为:加载,验证,准备,解析和初始化,接下来会详细介绍一下这几个过程的具体内容。
“加载”是类加载过程中的第一个阶段,在这个阶段,虚拟机要完成3件事情: 1)通过一个类的全限定名来获取定义此类的二进制字节流。 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。 3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。 那么如何获取这些Class文件,主要有以下几种途径:
从本地ZIP包中读取,例如从JAR,EAR,WAR格式的文件中获取。从网络中获取,这种场景典型的应用就是Applet。运行时计算生成,这种场景使用的最多的就是动态代理技术。由其他文件生成,典型应用场景就是JSP文件生成对应的Class类。还可从数据库中读取,这种比较少见,不过也是一种思路。加载阶段即可使用系统提供的引导类加载器,也可以由用户自定义类加载器完成。后边将会详细介绍类加载器。
验证是连接的第一步,这一步的主要目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。根据虚拟机规范,验证阶段大致进行4个阶段的检验动作:文件格式验证,元数据验证,字节码验证,符号引用验证。 1)文件格式验证 第一阶段要验证字节流是否符合Class文件格式的规范,是否能被当前版本的虚拟机所接受处理。
是否以魔数0xCAFEBABE开头。主次版本号是否在当前虚拟机处理范围之内。常量池的常量中是否有不被支持的常量类型。指向常量的各种索引值是否有指向不存在的常量或者不符合类型的常量。CONSTANT_Utf8_iinfo型的常量中是否有不符合UTF8编码的数据。Class文件中的各个部分及文件本身是否有被删除的或附加的其他信息。 …该阶段的主要目的是保证输入的字节流能正确的解析并存储于方法区之内,格式上符合Java类型信息的要求。该阶段的验证是基于二进制字节流进行的,只有通过这个阶段的验证之后,字节流才会进入内存的方法区中进行存储,以后的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。 2)元数据验证 主要是对类的元数据信息进行语义校验,保存不存在不符合Java语言规范的元数据信息。
这个类是否有父类(除了java.lang.Object之外,所有类应当都有父类)这个类的父类是否继承了不允许被继承的类(被final修饰的类)如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法。类中的字段,方法是否与父类产生矛盾等。 …3)字节码验证 这个阶段的验证是最复杂的一个阶段,主要是通过数据流和控制流分析,确定程序语义是否合法,符合逻辑。
保证任意时刻操作数栈数据类型与字节码指令序列配合一致,不会出现类似在操作数栈放置的是int类型数据,而使用的时候却按long类型来加载到本地变量表。保证跳转指令不会跳转到方法体以外的字节码指令上。保证方法体中的类型转换是有效的。 …4)符号引用验证 最后一个阶段校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段-解析阶段中发生。符号引用验证的目的是确保解析动作能正常执行,这个验证可以看做是对类自身以外的信息进行匹配性校验,通常需要校验下列内容:
符号引用中通过字符串描述的全限定名是否能找到对应的类。在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。符号引用中的类,字段,方法的访问性是否可被当前类访问。 …对于虚拟机的类加载机制来说,验证阶段是非常重要的,但不是一定必要的阶段。如果所运行的代码已经被反复使用和验证过,则在实施阶段可以通过-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备阶段是正式为类变量分配内存并设置类变量的初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 这里需要强调的是,这个时候进行内存分配的仅仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。 例如:
public static int value = 100;通常情况下,变量value在准备阶段过后的初始值为0,而不是100。什么时候才会将100赋值给value呢,这个动作发生在初始化阶段。 那么也存在特殊情况,如果类字段的字段属性存在ConstantValue属性,那在准备阶段变量就会被初始化为ConstantValue属性所指定的值。
public static final int value = 100;编译时,javac会将value生成ConstantValue属性,在准备阶段虚拟机加就会根据ConstantValue的设置将value赋值为100。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。那么什么是符号引用,什么是直接引用呢? 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。与虚拟机内存布局无关。 直接引用:可以是直接指向目标的指针,相对偏移量或者是一个能间接定位到目标的句柄。与虚拟机实现的内存布局有关。 虚拟机规范中并未规定解析阶段发生的具体时间,只要求了在执行anewarray,checkcast,getfield,getstatic,instanceof,invokedynamic,invokeinterface,invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfield和putstatic这16个操作符号引用的字节码指令之前,先对他们所使用的符号引用进行解析。 解析动作主要针对:类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类符号引用进行,分别对应常量池的CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info,CONSTANT_InterfaceMethodref_info,CONSTANT_MethodType_info,CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型。
类初始化阶段是类加载过程的最后一步,到这一阶段,才真正开始执行类中定义的Java程序代码(或者说字节码)。 在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
类加载器可以说是Java语言的一项创新,也是Java语言广泛流行的重要原因之一。类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用远远不限于类加载阶段。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每个类加载器都拥有独立的类名称空间。比较两个类是否相等,只有这两个类由同一类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个class文件,被同一虚拟机加载,只要加载它们的类加载器不同,那这两个类必定不想等。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。 双亲委派模型的工作过程:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,当只有父类加载器反馈自己无法完成这个请求(也就是在其搜索范围内没有找到所需的类)时,子加载器才尝试自己去加载。 双亲委派模型对于保证Java程序的稳定运作很重要,但是它的实现确实非常的简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法中。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 首先检查这个类是否已经被加载 Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { //调用父类加载器加载 c = parent.loadClass(name, false); } else { //否则调用启动类加载器 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 如果父类加载器抛出异常,说明父类加载器复发完成加载请求 } if (c == null) { // 如果父类加载器无法完成加载,则调用本身的findclass方法进行类加载 long t1 = System.nanoTime(); c = findClass(name); sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }在OSGI环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGI将按照下面的顺序进行类搜索: 1)将以java.*开头的类委派给父类加载器加载。 2)否则,将委派列表名单内的类委派给父类加载器加载。 3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。 4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。 5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。 6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。 7)否则,类查找失败。 正如OSGI中的类及爱在器并不符合传统的双亲委派模型,并且业界为了实现热部署而带来的二外的高度复杂还存在不少争议,但在Java程序员中有一个共识:OSGI中对类加载器的使用很值得学习,弄懂OSGI的实现,就可以死算是掌握了类加载器的精髓。
参考:《深入理解Java虚拟机》—周志明
