前言
上一篇文章已经介绍过JVM只跟class类型的文件发生交互。
本篇文章就要介绍JVM是如何将硬盘中的class文件加载到内存中的。
如果之前您使用过java的反射机制,就知道java中每个类都有个类对象,即Class
的对象,通过ClassName.class
或者instance.getClass()
可以拿到这个对象。
我们可能会问这个类对象是如何产生的呢?
简而言之是将class文件加载进内存后,JVM创建了这个类对象指向这块内存。
JVM中将这个过程分成了三步,分别是loading
,linking
,initializing
,本文就会介绍这三个步骤,其中着重介绍loading阶段。
loading
loading就是将class文件从硬盘中加载进JVM内存中的过程。
这个加载过程的核心是类加载器
(ClassLoader),负责将class文件加载进内存。
通过调用类对象的getClassLoader()
方法就能知道是哪个类加载器加载的当前的类文件。
下面我们就来介绍一下类加载器。
类加载器
要注意一点的是,JVM中的类加载器不只有一个,类加载器之间是有层次高低区别的。
类加载器的种类
JVM中的类加载器根据它们负责加载的类的不同,分为下面四种不同的类型:
Bootstrap:负责加载核心类。这个类加载器是由C++实现的,也就是说即使调用getClassLoader()
也得不到这个类加载器,只能得到null。
Extension:负责加载位于jre/lib/ext/
目录下的类。
App:负责加载classpath
指定的内容,我们自己编写的类都在classpath中。
Custom:我们可以自定义的类加载器,可以由我们来指定负责加载哪里的类。
类加载器的层次与双亲委派
JVM在加载类文件的时候,不同的类加载器的层次决定了各自发挥作用的顺序。
类加载器的层次从高到低依次是:Bootstrap,Extension,App,Custom。其中上一层的加载器是下一层加载器的父加载器。
注意,这里的父加载器,不是java中继承(extends)的关系,而是一种逻辑上的关系,父加载器在子加载器中以parent
的成员变量形式存在。
这个过程的流程如下图所示:
根据上面的流程图,JVM加载类文件的时候是分为两个阶段的——检查缓存与执行加载。
这个过程有个值得被记住的名字:双亲委派。
检查缓存
每个类加载器内部都维护了一个缓存,存放着之前加载过的类文件。
当JVM检查缓存的时候,会从最低级的类加载器开始,也就是Custom加载器。
如果命中缓存就直接返回结果。
如果没命中,就去上一层的父加载器的缓存中找。
依次寻找App、Extension、Boostrap加载器的缓存。如果命中就返回,如果都没命中,就进入加载阶段。
执行加载
当所有层次的加载器的缓存中都不存在要加载的类文件,就会执行加载操作。
从Bootstrap加载器开始,如果要加载的类文件是当前加载器负责,那么当前加载器完成加载。
如果不属于当前加载器负责的类,依次经过Extension、App、Custom加载器判断是否要加载这个类。
如果所有的加载器都负责加载这个类,就会报ClassNotFound的异常。
双亲委派的价值
主要是出于安全的考虑。
试想如果没有双亲委派的机制,即从下到上的缓存检查和从上到下的检查加载,那么黑客们完全可以自己开发一个最底层的custom加载器,将JDK运行需要的各种核心类替换成自己写的同名类,当把这些类加载进内存,我们的程序就运行在不可靠的平台上了。
源码阅读
ClassLoader加载类文件的代码在ClassLoader类中的loadClass(String name, boolean resolve)
方法中。
这个方法的大致执行流程如下图所示
开始执行loadClass
方法,首先会在当前类加载器上调用findLoadedClass()
方法,这个方法就是之前说的在缓存中查找,如果找到了直接返回该类。
如果findLoadedClass在当前类加载器中没找到要加载的类文件的缓存,就会检查这个加载器有没有父加载器,如果有父加载器,就递归调用父加载器的loadClass方法。
如果父加载器的缓存中有这个类文件,那么这个类文件会被返回,否则继续调用父加载器的父加载器继续找,直至最顶层的加载器。
如果都没有找到,那么就进入加载阶段,就是图中的findClass()
方法。
这个方法应用了模板方法设计模式,ClassLoader的子类重写这个方法来定义自己负责加载的类的范围。
linking
当class文件加载到内存后,就会对文件进行处理,主要分为verification
,preparation
,resolusion
。
下面对每一步进行解释
-
verification
对文件内容进行校验。class文件的内容都有固定的格式,如果载入的文件压根儿就不是class类型的文件,这一步就会报错。
-
preparation
是linking步骤的核心,主要负责将类中的静态成员变量设置为默认值。
-
resolusion
将类中的符号引用替换为直接引用,类似于把自己写的代码与用到的类库代码拼接在一起。
initializing
这一步就是执行类的初始化代码,给静态成员变量赋予代码中的设定值。
一道面试题
这里用一道面试题来进一步讲解linking的preparation步骤和initializing步骤的区别。
有如下一段代码
public class T {
public static T t = new T(); // 语句1
public static int count = 2; // 语句2
private T() {
count++;
}
}
public class Test {
public static void main(String[] args) {
System.out.println(T.count);
}
}
问题:上述代码的输出是多少?
答案:2
原因:当T.class文件加载进内存中,经过linking的preparation
,会将静态成员变量赋为默认值,也就是t = null
和 count = 0
状态。
然后经过initializing
后,会执行初始代码,给静态成员变量赋初始值。此时t = new T()
,构造函数的代码得到执行,count由0自增到1
,但此后count = 2语句得到执行,将count = 1的结果覆盖掉,最后count的值为2。
拓展问题:如果上述代码语句1和语句2位置进行调换,输出的结果是多少?
也就是
public static int count = 2; // 语句2
public static T t = new T(); // 语句1
答案:3
原因:经过preparation
之后,两个变量的值为默认值,也就是t = null
和 count = 0
。
经过initializing
之后,count的值先设为2,然后构造函数中的代码得到执行,count的值由2自增为3
。