类载入过程
类从被载入到虚拟机内存中開始,到卸载出内存为止,它的整个生命周期包含:载入、验证、准备、解析、初始化、使用和卸载七个阶段。它们開始的顺序例如以下图所看到的:
当中类载入的过程包含了载入、验证、准备、解析、初始化五个阶段。在这五个阶段中,载入、验证、准备和初始化这四个阶段发生的顺序是确定的。而解析阶段则不一定,它在某些情况下能够在初始化阶段之后開始,这是为了支持 Java 语言的执行时绑定(也成为动态绑定或晚期绑定)。
另外注意这里的几个阶段是按顺序開始,而不是按顺序进行或完毕,由于这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活还有一个阶段。
这里简要说明下 Java 中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对 Java 来说。绑定分为静态绑定和动态绑定:
- 静态绑定:即前期绑定。
在程序执行前方法已经被绑定,此时由编译器或其他连接程序实现。针对 Java。简单的能够理解为程序编译期的绑定。
Java 其中的方法仅仅有 final,static,private 和构造方法是前期绑定的。
- 动态绑定:即晚期绑定。也叫执行时绑定。
在执行时依据详细对象的类型进行绑定。在 Java 中,差点儿全部的方法都是后期绑定的。
以下具体讲述类载入过程中每一个阶段所做的工作。
载入
载入时类载入过程的第一个阶段,在载入阶段,虚拟机须要完毕下面三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的执行时数据结构。
- 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的訪问入口。
注意,这里第 1 条中的二进制字节流并不仅仅是单纯地从 Class 文件里获取,比方它还能够从 Jar 包中获取、从网络中获取(最典型的应用便是 Applet)、由其它文件生成(JSP 应用)等。
相对于类载入的其它阶段而言,载入阶段(准确地说,是载入阶段获取类的二进制字节流的动作)是可控性最强的阶段,由于开发者既能够使用系统提供的类载入器来完毕载入,也能够自己定义自己的类载入器来完毕载入。
载入阶段完毕后。虚拟机外部的 二进制字节流就依照虚拟机所需的格式存储在方法区之中,并且在 Java 堆中也创建一个 java.lang.Class 类的对象,这样便能够通过该对象訪问方法区中的这些数据。
说到载入,不得不提到类载入器,以下就详细讲述下类载入器。
类载入器尽管仅仅用于实现类的载入动作,但它在 Java 程序中起到的作用却远远不限于类的载入阶段。对于随意一个类,都须要由它的类载入器和这个类本身一同确定其在就 Java 虚拟机中的唯一性。也就是说,即使两个类来源于同一个 Class 文件,仅仅要载入它们的类载入器不同,那这两个类就必然不相等。这里的“相等”包含了代表类的 Class 对象的 equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包含了使用 instanceof keyword对对象所属关系的判定结果。
站在 Java 虚拟机的角度来讲。仅仅存在两种不同的类载入器:
- 启动类载入器:它使用 C++ 实现(这里仅限于 Hotspot,也就是 JDK1.5 之后默认的虚拟机。有非常多其它的虚拟机是用 Java 语言实现的),是虚拟机自身的一部分。
- 所有其它的类载入器:这些类载入器都由 Java 语言实现,独立于虚拟机之外,而且所有继承自抽象类 java.lang.ClassLoader,这些类载入器须要由启动类载入器载入到内存中之后才干去载入其它的类。
站在 Java 开发者的角度来看。类载入器能够大致划分为下面三类:
- 启动类载入器:Bootstrap ClassLoader。跟上面同样。它负责载入存放在
JDK\jre\li
(JDK
代表 JDK 的安装文件夹,下同)下,或被-Xbootclasspath
參数指定的路径中的,而且能被虚拟机识别的类库(如 rt.jar,全部的java.*
开头的类均被
Bootstrap ClassLoader 载入)。启动类载入器是无法被 Java 程序直接引用的。 - 扩展类载入器:Extension ClassLoader,该载入器由
sun.misc.Launcher$ExtClassLoader
实现。它负责载入JDK\jre\lib\ext
文件夹中,或者由
java.ext.dirs 系统变量指定的路径中的全部类库(如javax.*
开头的类)。开发人员能够直接使用扩展类载入器。 - 应用程序类载入器:Application ClassLoader,该类载入器由 sun.misc.Launcher$AppClassLoader 来实现,它负责载入用户类路径(ClassPath)所指定的类。开发人员能够直接使用该类载入器。假设应用程序中没有自己定义过自己的类载入器,普通情况下这个就是程序中默认的类载入器。
应用程序都是由这三种类载入器互相配合进行载入的,假设有必要。我们还能够增加自己定义的类载入器。由于 JVM 自带的 ClassLoader 仅仅是懂得从本地文件系统载入标准的 java class 文件,因此假设编写了自己的 ClassLoader,便能够做到例如以下几点:
- 在运行非置信代码之前,自己主动验证数字签名。
- 动态地创建符合用户特定须要的定制化构建类。
- 从特定的场所取得 java class,比如数据库中和网络中。
其实当使用 Applet 的时候,就用到了特定的 ClassLoader。由于这时须要从网络上载入 java class,而且要检查相关的安全信息,应用server也大都使用了自己定义的 ClassLoader 技术。
这几种类载入器的层次关系例如以下图所看到的:
这样的层次关系称为类载入器的双亲委派模型。我们把每一层上面的类载入器叫做当前层类载入器的父载入器。当然,它们之间的父子关系并非通过继承关系来实现的,而是使用组合关系来复用父载入器中的代码。该模型在 JDK1.2 期间被引入并广泛应用于之后差点儿全部的 Java 程序中。但它并非一个强制性的约束模型。而是 Java 设计者们推荐给开发人员的一种类的载入器实现方式。
双亲委派模型的工作流程是:假设一个类载入器收到了类载入的请求,它首先不会自己去尝试载入这个类,而是把请求托付给父载入器去完毕。依次向上。因此,全部的类载入请求终于都应该被传递到顶层的启动类载入器中,仅仅有当父载入器在它的搜索范围中没有找到所需的类时。即无法完毕该载入。子载入器才会尝试自己去载入该类。
使用双亲委派模型来组织类载入器之间的关系,有一个非常明显的优点。就是 Java 类随着它的类载入器(说白了,就是它所在的文件夹)一起具备了一种带有优先级的层次关系,这对于保证 Java 程序的稳定运作非常重要。比如,类java.lang.Object 类存放在JDK\jre\lib
下的
rt.jar 之中,因此不管是哪个类载入器要载入此类,终于都会委派给启动类载入器进行载入,这边保证了 Object 类在程序中的各种类载入器中都是同一个类。
验证
验证的目的是为了确保 Class 文件里的字节流包括的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完毕下面四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。
- 文件格式的验证:验证字节流是否符合 Class 文件格式的规范,而且能被当前版本号的虚拟机处理。该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储。后面的三个验证都是基于方法区的存储结构进行的。
- 元数据验证:对类的元数据信息进行语义校验(事实上就是对类中的各数据类型进行语法校验)。保证不存在不符合 Java 语法规范的元数据信息。
- 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在执行时不会做出危害虚拟机安全的行为。
- 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有解说),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有下面几点须要注意:
- 这时候进行内存分配的仅包含类变量(static),而不包含实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
- 这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。
如果一个类变量的定义为:
public static int value = 3。
那么变量 value 在准备阶段过后的初始值为 0,而不是 3,由于这时候尚未開始运行不论什么 Java 方法,而把 value 赋值为 3 的 putstatic 指令是在程序编译后。存放于类构造器 ()方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会运行。
下表列出了 Java 中全部基本数据类型以及 reference 类型的默认零值:
这里还须要注意例如以下几点:
- 对基本数据类型来说。对于类变量(static)和全局变量,假设不显式地对其赋值而直接使用,则系统会为其赋予默认的零值。而对于局部变量来说。在使用前必须显式地为其赋值,否则编译时不通过。
- 对于同一时候被 static 和 final 修饰的常量。必须在声明的时候就为其显式地赋值,否则编译时不通过。而仅仅被 final 修饰的常量则既能够在声明时显式地为其赋值。也能够在类初始化时显式地为其赋值。总之,在使用前必须为其显式地赋值。系统不会为其赋予默认零值。
- 对于引用数据类型 reference 来说。如数组引用、对象引用等,假设没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值。即null。
- 假设在数组初始化时没有对数组中的各元素赋值,那么当中的元素将依据相应的数据类型而被赋予默认的零值。
假设类字段的字段属性表中存在 ConstantValue 属性。即同一时候被 final 和 static 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。
如果上面的类变量 value 被定义为:
public static final int value = 3。
编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会依据 ConstantValue 的设置将 value 赋值为 3。
回顾上一篇博文中对象被动引用的第 2 个样例。便是这样的情况。我们能够理解为 static final 常量在编译期就将其结果放入了调用它的类的常量池中。
解析
解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。在 Class 类文件结构一文中已经比較过了符号引用和直接引用的差别和关联。这里不再赘述。
前面说解析阶段可能開始于初始化之前,也可能在初始化之后開始,虚拟机会依据须要来推断,究竟是在类被载入器载入时就对常量池中的符号引用进行解析(初始化之前),还是等到一个符号引用将要被使用前才去解析它(初始化之后)。
对同一个符号引用进行多次解析请求时非经常见的事情,虚拟机实现可能会对第一次解析的结果进行缓存(在执行时常量池中记录直接引用,并把常量标示为已解析状态),从而避免解析动作反复进行。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别相应于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 四种常量类型。
1、类或接口的解析:推断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用。从而进行不同的解析。
2、字段解析:对字段进行解析时。会先在本类中查找是否包括有简单名称和字段描写叙述符都与目标相匹配的字段。假设有,则查找结束。假设没有,则会依照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有。则依照继承关系从上往下递归搜索其父类,直至查找结束,查找流程例如以下图所看到的:
从以下一段代码的运行结果中非常easy看出来字段解析的搜索顺序:
class Super{
public static int m = 11;
static{
System.out.println("运行了super类静态语句块");
}
}
class Father extends Super{
public static int m = 33;
static{
System.out.println("运行了父类静态语句块");
}
}
class Child extends Father{
static{
System.out.println("运行了子类静态语句块");
}
}
public class StaticTest{
public static void main(String[] args){
System.out.println(Child.m);
}
}
运行结果例如以下:
运行了super类静态语句块
运行了父类静态语句块
33
假设凝视掉 Father 类中对 m 定义的那一行,则输出结果例如以下:
运行了super类静态语句块
11
另外,非常明显这就是上篇博文中的第 1 个样例的情况,这里我们便能够分析例如以下:static 变量发生在静态解析阶段,也即是初始化之前。此时已经将字段的符号引用转化为了内存引用,也便将它与相应的类关联在了一起,因为在子类中没有查找到与 m 相匹配的字段。那么 m 便不会与子类关联在一起,因此并不会触发子类的初始化。
最后须要注意:理论上是依照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。
假设有一个同名字段同一时候出如今该类的接口和父类中。或同一时候在自己或父类的接口中出现。编译器可能会拒绝编译。假设对上面的代码做些改动,将 Super 改为接口。并将 Child 类继承 Father 类且实现 Super 接口。那么在编译时会报出例如以下错误:
StaticTest.java:24: 对 m 的引用不明白,Father 中的 变量 m 和 Super 中的 变量 m
都匹配
System.out.println(Child.m);
^
1 错误
3、类方法解析:对类方法的解析与对字段解析的搜索步骤几乎相同,仅仅是多了推断该方法所处的是类还是接口的步骤,并且对类方法的匹配搜索。是先搜索父类。再搜索接口。
4、接口方法解析:与类方法解析步骤类似,知识接口不会有父类,因此。仅仅递归向上搜索父接口即可了。
初始化
初始化是类载入过程的最后一步。到了此阶段。才真正開始运行类中定义的 Java 程序代码。在准备阶段。类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是依据程序猿通过程序指定的主观计划去初始化类变量和其它资源,或者能够从还有一个角度来表达:初始化阶段是运行类构造器()方法的过程。
这里简单说明下()方法的运行规则:
1、()方法是由编译器自己主动收集类中的全部类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件里出现的顺序所决定的,静态语句块中仅仅能訪问到定义在静态语句块之前的变量。定义在它之后的变量,在前面的静态语句中能够赋值。可是不能訪问。
2、()方法与实例构造器()方法(类的构造函数)不同,它不须要显式地调用父类构造器,虚拟机会保证在子类的()方法运行之前,父类的()方法已经运行完成。
因此,在虚拟机中第一个被运行的()方法的类肯定是java.lang.Object。
3、()方法对于类或接口来说并非必须的。假设一个类中没有静态语句块,也没有对类变量的赋值操作。那么编译器能够不为这个类生成()方法。
4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成()方法。可是接口鱼类不同的是:运行接口的()方法不须要先运行父接口的()方法,仅仅有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会运行接口的()方法。
5、虚拟机会保证一个类的()方法在多线程环境中被正确地加锁和同步,假设多个线程同一时候去初始化一个类。那么仅仅会有一个线程去运行这个类的()方法。其它线程都须要堵塞等待,直到活动线程运行()方法完成。假设在一个类的()方法中有耗时非常长的操作,那就可能造成多个线程堵塞。在实际应用中这样的堵塞往往是非常隐蔽的。
以下给出一个简单的样例,以便更清晰地说明如上规则:
class Father{
public static int a = 1;
static{
a = 2;
}
}
class Child extends Father{
public static int b = a;
}
public class ClinitTest{
public static void main(String[] args){
System.out.println(Child.b);
}
}
运行上面的代码,会打印出 2。也就是说 b 的值被赋为了 2。
我们来看得到该结果的步骤。首先在准备阶段为类变量分配内存并设置类变量初始值,这样 A 和 B 均被赋值为默认值 0,而后再在调用()方法时给他们赋予程序中指定的值。当我们调用 Child.b 时,触发 Child 的()方法,依据规则 2,在此之前。要先运行完其父类Father的()方法。又依据规则1,在运行()方法时,须要按 static 语句或 static 变量赋值操作等在代码中出现的顺序来运行相关的 static 语句,因此当触发运行 Fathe r的()方法时。会先将 a 赋值为 1,再运行 static
语句块中语句。将 a 赋值为 2,而后再运行 Child 类的()方法。这样便会将 b 的赋值为 2。
假设我们颠倒一下 Father 类中“public static int a = 1;”语句和“static语句块”的顺序,程序运行后。则会打印出1。非常明显是依据规则 1,运行 Father 的()方法时,依据顺序先运行了 static 语句块中的内容。后运行了“public static int a = 1;”语句。
另外,在颠倒二者的顺序之后,假设在 static 语句块中对 a 进行訪问(比方将 a 赋给某个变量)。在编译时将会报错,由于依据规则 1,它仅仅能对 a 进行赋值,而不能訪问。
总结
整个类载入过程中。除了在载入阶段用户应用程序能够自己定义类载入器參与之外,其余全部的动作全然由虚拟机主导和控制。到了初始化才開始运行类中定义的 Java 程序代码(亦及字节码)。但这里的运行代码仅仅是个开端,它仅限于()方法。类载入过程中主要是将 Class 文件(准确地讲,应该是类的二进制字节流)载入到虚拟机内存中,真正运行字节码的操作,在载入完毕后才真正開始。