Java代码编写好之后,需要将其编译成Class文件,才能被JVM虚拟机执行。JVM将Class文件加载到内存,并对其进行校验、转换解析和初始化,最终形成可以直接被JVM使用的Java类型,就是JVM的类加载机制。
Java是一门动态语言,也就是在Java里面,类型的加载、连接和初始化过程是在程序的运行期间完成的。例如:如果编写一个面向接口的程序,可以等到在运行时再指定其实际的实现类。(C语言的链接过程是在编译期间完成的,C通过编译链接之后会直接生成一个可执行文件。)
1、类加载的时机
类从被加载到JVM内存中,到卸载出内存中为止,它的整个生命周期包括以下七个阶段:
JVM规范中没有强制约束什么时候开始一个类的加载阶段,但是对类的初始化阶段,严格规定了有且只有以下5种情况必须立即对类进行“初始化”,这5种行为称为对类的主动引用:
- new关键字实例化对象,读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的除外),调用一个类的静态方法;
- 使用java.lang.reflect包的方法对类进行反射调用;
- 初始化一个类时,若其父类尚未初始化,需要先初始化其父类;
- JVM启动时,JVM会初始化包含main()函数的主类;
- 当使用JDK 1.7的动态语言支持时,若一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
除了以上的几种情况,其他引用类的方式不会触发类的初始化,称为被动引用,如下示例:
- 例1:通过子类引用父类的静态字段,不会导致子类初始化
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
代码运行结果如下,没有打印"SuperClass init!"
SuperClass init!
123
- 例2:通过数组定义来引用类,不会触发此类的初始化:
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] s = new SuperClass[10];
System.out.println("execute over!");
}
}
代码运行结果如下,没有打印"SuperClass init!"
execute over!
- 例3:常量在编译阶段会存储调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化:
class Constant {
static {
System.out.println("Constant init!");
}
public static final String str = "Hello World";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(Constant.str);
}
}
代码运行结果,没有打印"Constant init!"。实际上,虽然源码中引用了Constant类的常量str,但是在编译阶段通过常量的传播优化,已经将常量的值“Hello World”存放到了NotInitialization类的常量池中,也就是编译之后说NotInitialization的Class文件中并没有对Constant类的符号引用了,这两个类再编译成Class文件之后就不存在任何联系了。
Hello World
接口的初始化与类的初始化有点区别:
当一个类初始化时,要求其父类全部已经初始化过了;但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用父接口中定义的常量)才会初始化。
2、类加载的过程
1、加载
在加载阶段,JVM需要做以下事情:
- 根据全限定名获取类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
对于HotSpot JVM而言,java.lang.Class对象比较特殊,它时存放在方法区里面的。
2、验证
验证是连接阶段的第一步,目的是为了确保Class文件中的字节流包含的信息符合JVM规范。虽然在Java代码层面,语法错误将会导致编译器编译失败,无法将具有语法错误的代码编译为Class文件。但是Class文件的来源不仅仅只是java代码编译而来的,可以通过任何途径生成Class文件,甚至直接编写Class文件。即Class文件的来源是不可信的,对Class文件的校验是非常重要的。包括以下几方面的验证:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
3、准备
准备阶段是正式为类变量(static变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。(此时没有实例化对象,不会为实例变量分配内存。)这里所说的初始值通常指的是对应数据类型的零值(0、null)。特殊情况下,如果类变量是final变量,则会直接初始化为属性指定的值。
4、解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
5、初始化
类的初始化阶段是类加载过程中的最后一步。前面的过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余的动作都是有JVM主导和控制的。而到了初始化阶段,就是真正开始执行类型定义的Java程序代码(或者说是字节码)了。
在准备阶段,类变量已经被赋值过一次初始值,而在初始化阶段,则会根据代码对类变量再次进行初始化,并为其赋予真正的值。
从另一个角度来讲,初始化阶段也是执行类构造器<clinit>()方法的过程。
- <clinit>()方法是由编译器自动收集类中所有类变量(static变量)的赋值动作和静态语句块(static语句)中的语句合并产生的,收集的顺序与源代码顺序相同。静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
- <clinit>()方法与类的构造函数(实例构造器<init>()方法)不同,无需显示调用父类构造器,JVM保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此JVM中第一个被执行的<clinit>()方法就是Object类的。
- 父类的<clinit>()方法优先于子类的<clinit>()方法执行,所以父类中定义的静态语句块要优先于子类的静态变量的赋值操作。
所以以下代码执行结果为:2
class SuperClass {
public static int a = 1;
static {
a = 2;
}
}
class SubClass extends SuperClass{
public static int b = a;
}
class Test {
public static void main(String[] args) {
System.out.println(SubClass.b);
}
}
- 如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
- 接口中不能使用静态语句块,但是仍然有变量初始化的赋值操作,因此接口也会生成<clinit>()方法。但与类不同的是,执行接口的<clinit>()方法,不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量被使用时,才会初始化父接口。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
- JVM会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法。
3、类加载器
类加载器的作用是通过一个类的全限定名称来将描述这个类的Class文件的二进制字节流加载到JVM中 。对于任意一个类,是由加载这个类的类加载器以及这个类本身共同来确定其在JVM中的唯一性。即便是同一个Class文件,如果分别由两个类加载器分别加载到JVM中,那么,这两个类也是不相等的。这里的不相等包括equals()方法、isInstance()方法等。
从JVM的角度看,类加载器只分为两种:
一种是启动类加载器(BootStrapClassLoader),由C++语言实现,是JVM本身的一部分;
一种就是除此之外的所有其他类加载器,有Java语言实现,独立于JVM外部。
从程序员的角度看,类加载器分为三种:
一是启动类加载器,负责将<JAVA_HOME>/lib目录中的或者被-Xbootclasspath参数指定的路径中的,并且是被虚拟机识别的类库加载到JVM中。启动类加载器无法在Java程序中被直接引用。程序员在编写自定义类加载器时,如果需要把加载请求委托给启动类加载器,直接使用null代替即可。
二是扩展类加载器,负责将<JAVA_HOME>/lib/ext目录中的,或者被java.ext.dirs系统变零所指定的路径中的所有类库,程序员可以直接使用扩展类加载器。
三是应用程序类加载器,负责加载用户类路径(classpath)下的类库。
双亲委派模型
类加载器之间的关系如下图所示,需要注意的是,这里类加载器之间的父子关系并非是采用继承方式实现的,而是通过组合方式实现的。
类加载器的工作机制遵从双亲委派模型,其工作过程是:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是会把这个请求委托给它的父加载器,每一个层次的类加载器都是如此。因此,所有的加载请求都会给到顶层的启动类加载器。只有当父加载器无法完成这个加载请求时(在父加载器的搜索范围内没有找到这个类),子加载器才会自己尝试去加载这个类。
使用双亲委派模型带来的好处是,可以保证核心类,比如Object,String等类,不会被用户恶意构造的类给破坏。假设用户自己写了一个java.lang.Object类(里面可能包含破坏性代码),那么这个类是永远无法被JVM所加载的,因为双亲委派模型这个机制的存在,JVM加载java.lang.Object类永远都会是rt.jar这个jar包里面的Object类,而不会加载用户自己写的这个类。