概述
虚拟机把描述类的数据从Class文件加载到内存,并对数据校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类加载的时机
类的生命周期包括:加载、验证、准备、解析、初始化、使用、卸载7个阶段,其中验证、准备、解析这三个阶段统称为解析。
那么虚拟机什么时候执行加载阶段呢?java虚拟机规范中,并没有规定类加载过程中的第一个阶段(即加载阶段)的执行时机,但是对于初始化阶段,虚拟机规范中严格规定了“有且只有”下面5种情况下必须立即对类进行初始化(而这时,加载、验证、准备自然需要在此之前开始):
(1)遇到new、getstatic、putstatic、invokestatic这四条指令时,必须触发其初始化。这四条指令最常见的场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已经在编译期把结果放入常量池的静态字段除外,即常量除外)、调用一个类的静态方法的时候; (2)使用java.lang.refelect包的方法进行反射调用的时候; (3)初始化一个类的时候,如果其父类还没有初始化,则需要先触发其父类的初始化; (4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类)虚拟机会先初始化这个主类; (5)当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic 、REF_putStatic、REF_invokeStatic的方法句柄,并且句柄所对应的类没有进行过初始化,则需要先触发其初始化。此外,需要注意接口与类初始化时的区别:当一个类在初始化时,要求其父类全部已经初始化过了,但是对于接口的初始化来说,并不要求其父接口全部都完成了初始化,只有在真正使用到付接口的时候(如引用接口中定义的常量)才会初始化。
类加载过程
加载
在加载阶段,需要完成如下三件事情:
(1)通过一个类的全限定名来获取其定义的二进制字节流。 (2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。 (3)在内存中生成一个代表这个类的java.lang.Class对象(并没有明确规定是在java堆中,对于HotSpot虚拟机来说,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),作为对方法区中这些数据的访问入口。虚拟机对于“通过类的全限定名来获取其定义的二进制字节流”这条,并没有规定二进制字节流的获取途径,可以从Class文件中获取,也可以从如下方式获取:
(1)从压缩包中获取,比如 JAR包、EAR、WAR包等 (2)从网络中获取,比如红极一时的Applet技术 (3)从运行过程中动态生成,最出名的便是动态代理技术,在java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为“$Proxy”的代理类的二进制流 (4)从其它文件生成,如JSP文件生成Class 类 (5)从数据库中读取,比如说有些中间件服务器(如SAP Netweaver),可以选择把程序安装到数据库中来完成程序代码在集群之间的分发相对于类加载过程的其他阶段,一个非数组类的加载阶段是开发人员可控性最强的,可以自定义类加载器在控制加载。而对于数组类,本身不通过类加载器创建,它是由Java虚拟机直接创建,但是数组的元素类型,最终是要靠类加载器去创建。
加载阶段完成后,虚拟机 外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,然后再内存中实例化一个java.lang.Class对象(对于HotSpot虚拟机,Class对象存放在方法区中),这个对象作为程序访问方法区中这些类型数据的外部接口。
验证
验证阶段用于确保类或接口的二进制表示结构上是正确的,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证过程可能会导致某些额外的类和接口被加载进来,但不应该会导致它们也需要验证或准备。
准备
准备阶段的任务是为类或接口的静态字段分配空间,并用默认值初始化这些字段。这个阶段不会执行任何的虚拟机字节码指令。在初始化阶段会有显式的初始化器来初始化这些静态字段,所以准备阶段不做这些事情。
解析
解析是根据运行时常量池的符号引用来动态决定具体的值的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行:
(1)类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。 (2)字段解析:在对字段进行解析前,会先查看该字段所属的类或接口的符号引用是否已经解析过,没有就先对字段所属的接口或类进行解析。在对字段进行解析的时候,先查找本类或接口中是否有该字段,有就直接返回;否则,再对实现的接口进行遍历,会按照继承关系从下往上递归(也就是说,每个父接口都会走一遍)搜索各个接口和它的父接口,返回最近一个接口的直接引用;再对继承的父类进行遍历,会按照继承关系从下往上递归(也就是说,每个父类都会走一遍)搜索各个父类,返回最近一个父类的直接引用。 (3)类方法解析:和字段解析搜索步骤差不多,只不过是先搜索父类,再搜索接口。 (4)接口方法解析:和类方法解析差不多,只不过接口中不会有父类,因此只需要对父接口进行搜索即可。初始化
初始化对于类或接口来说,就是执行它的初始化方法。在准备阶段,我们已经给变量赋过一次系统要求的初始值(零值),而在初始化阶段,则会根据程序员的意愿给类变量和其他资源赋值。主要是通过<clinit>
法来执行的:
<clinit>
法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。如下: public class Test { static{ i = 0;//可以给变量赋值,编译通过 System.out.println(i);//编译不通过,不能进行访问后面的静态变量 } static int i =1;}复制代码
(2)<clinit>
方法与实例构造器<init>
法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>
方法执行之前,父类的<<clinit>
方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>
方法的类肯定是java.lang.Object;
<clinit>
方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作; (4)<clinit>
方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>
方法; (5)接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>
方法。但是接口与类不同的是:执行接口的<clinit>
方法不需要先执行父接口的<clinit>
方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>
方法; (6)虚拟机会保证一个类的<clinit>
方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>
方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>
方法完毕。如果在一个类的<clinit>
方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。 类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为“类加载器”。
类和类加载器
任意一个类,都需要由加载它的类加载器和这个类本身共同确定其在Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达的更通俗一些:比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才意义。否则,即使这两个类来自同一个Class文件,被同一个虚拟机加载,但只要加载他们的类加载器不同,那这两个类就必定不相等。
这里的“相等”,包括代表类的 Class 对象的equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括 instanceof 关键字对对象所属关系判定等情况。
双亲委派模型
从虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器用 C++ 语言实现,是虚拟机自身的一部分;另一种就是所有其它的类加载器, 这些类加载器用Java 语言实现,独立于虚拟机外部,并且全都继承与抽象类 java.lang.ClassLoader。
从Java 开发人员的角度来看,类加载器还可以划分的更细致一些,绝大多数Java 程序都会用到以下3种系统提供的类加载器:
(1)启动类加载器(Bootstrap ClassLoader) : 这个类加载器负责将存放在<JAVA_HOME>
\lib 目录中的,或者被 -Xbootclasspath 参数指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar ,名字不符合类库不会加载) 类库加载到虚拟机内存中。启动类加载器无法被 java 程序直接引用,如需要,直接使用 null 代替即可。 (2)扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader 实现,它负责加载<JAVA_HOME>
\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。 (3)应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher$AppClassLoader 实现。这个这个类加载器是 ClassLoader 中的getSystemClassLoader() 方法的返回值,所以一般称它为系统类加载器。它负责加载用户路径(ClassPath)上所指定的类库,开发者可以使用这个类加载器,如果应用程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 双亲委派模型要求除了顶层的启动类加载器,其余的类加载器都应该有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承关系来实现,而是使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载器的请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类时),子加载类才会尝试自己去加载。使用双亲委派模型的好处:就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。比如对于类Object来说,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器去加载,因此Object类在程序中的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类自己去加载的话,按照我们前面说的,如果用户自己编写了一个Object类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,此时Java类型提醒中最基础的行为也就无法保证了,应用程序也将变得混乱。
因此,双亲委派模型对于保证Java程序的稳定运作很重要,但是他的实现其实很简单,实现双亲委派模型的代码几种在java.lang.ClassLoader的loadClass()方法之中,逻辑清晰易懂:先检查类是否被加载过,若没有则调用父加载器的loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载器失败,抛出 ClassNotFoundException 异常后,再调用自己的 finClass() 方法进行加载。