一、前言

  这是JVM系列博客的第五篇,也是最后一篇,写完这篇博客,我就要暂时停止对JVM的学习,开始学习其他方面的内容了。这篇博客就来说一说JVM中的类加载器,以及类加载的双亲委派模型。


二、正文

 2.1 什么是类加载器

  首先我们要知道一件事,那就是什么是类加载器?大家都知道,我们编写的代码需要先被编译为class字节码才能被执行,JVM解释器只能识别字节码,而不能执行Java源代码。而程序都是在内存中执行的,所以,为了能够执行字节码,就需要将它读取到内存中。将字节码读取到内存中这个工作,就是由类加载器来完成的。类加载器根据提供的全限定类名(包名+类名),找到对应路径下的类的class文件,将其读取到JVM管理的方法区中,这样才能执行其中的代码指令,访问类中的数据。关于类加载机制,可以参考这篇博客:JVM基础——分析类的加载过程

  对于类加载器,需要注意一个问题。每一个类都是由它本身和类加载器一同来确定唯一性。这是什么意思?这就是说,如果一个类的字节码,分别使用两个不同的类加载器进行加载,则对于JVM来说,会将这两次加载识别为不同的两个类。举个例子,我们有一个类Test,创建了一个自定义的类加载器去加载它,并通过自定义的类加载器得到的Class对象创建Test对象t(反射),此时运行t instanceof Test,得到的将是false。因为这个对象是通过自定义类加载器加载的Test创建,而intanceof语句中的TestJVM类加载器加载的Test,对于JVM来说,这是两个不同的类。除此之外,对于拥有相同全限定名的类,同一个类加载器只会加载一次,不会重复加载


 2.2 类加载器的分类

  在Java中,类加载器一般分为四类,分别是:

  • 启动类加载器(Bootstrap ClassLoader);
  • 扩展类加载器(Extension ClassLoader);
  • 应用程序类加载器(Application ClassLoader);
  • 自定义类加载器(User ClassLoader);

  下面就来分别介绍一下这四种不同的类加载器。


(1)启动类加载器(Bootstrap ClassLoader)

  启动类加载器不是由Java语言实现,而是由C++实现的(HotSpot虚拟机中),它负责加载%JAVA_HOME%\lib目录下的类,比如StringIntegerHashMap…….这些类都是放在这个目录下,所以都是由启动类加载器加载。除此之外,JVM还提供了一个配置参数-Xbootclasspath,这个参数指定的目录下的类文件(如jar包,class文件),也会被启动类加载器加载。由于这个类加载器是由C++实现,所以它并不属于Java的一部分,而是虚拟机的一部分,不能在Java代码中直接引用。通过下面的代码可以尝试获得String类的类加载器,也就是启动类加载器,但是输出结果为null,因为它不属于Java的一部分:

1
2
3
4
public static void main(String[] args) {
ClassLoader c = String.class.getClassLoader();
System.out.println(c); // 输出null
}

(2)扩展类加载器(Extension ClassLoader)

  扩展类加载器是由Java实现的,实现类是sun.misc.Launcher$ExtClassLoader(名字可以看出这是一个内部类),这个类加载器负责加载%JAVA_HOME%\lib\ext目录下的类。由于这个类加载器是由Java实现,所以可以直接在Java程序中引用。我们可以通过一个%JAVA_HOME%\lib\ext目录下的类的Class对象来获得这个类加载器,也可以通过下面这种方式获得(以下方法基于双亲委派模型,后面解释):

1
2
3
4
5
6
7
8
public static void main(String[] args) {
// 获得自己写的类的加载器
ClassLoader c = Main.class.getClassLoader();
// Main加载器的父加载器就是扩展类加载器
ClassLoader c2 = c.getParent();
// 输出:sun.misc.Launcher$ExtClassLoader@eed1f14
System.out.println(c2);
}

(3)应用程序类加载器(Application ClassLoader)

  这个类加载期也是由Java语言实现的,实现类是sun.misc.Launcher$AppClassLoader,从$符号可以看出,这也是一个内部类。它负责加载类路径(CLASSPATH)下的类库,而我们编写的代码也是属于这个路径下的(CLASSPATH包含当前所在路径)。所以,当我们自己编写的Java代码没有指定类加载器,则默认使用这个加载器进行加载。由于这个类加载器是Java实现,所以也可以在我们的代码中引用,引用方法如下:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
// 方式1:使用自定义类的Class对象获得
ClassLoader c1 = Main.class.getClassLoader();
// 方式2:使用ClassLoader类的getSystemClassLoader方法获得
ClassLoader c2 = ClassLoader.getSystemClassLoader();

// 输出:sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(c1);
// 输出:true
System.out.println(c1 == c2);
}

(4)自定义类加载器(User ClassLoader)

  根据需要,我们可以编写自己的类加载器,而自己编写的类加载器就称为自定义加载器。编写自定义类加载器的方式很简单:继承ClassLoader类,然后重写其中的方法即可。对于Java实现的类加载器,都是调用其中的loadClass()方法对类进行加载的,所以我们可以重写这个方法,但是这种做法不推荐,因为这样容易破坏类的加载机制(之后会讲到的双亲委派模型)。最好的做法是重写findClass()方法,这个方法会在loadClass()中被调用。在findClass()方法中,读取需要加载的类的class文件,转换成一个字节数组,再调用父类的defineClass方法将字节数组转换为Class对象返回即可,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 自定义类加载器,实现ClassLoader
class MyClassLoader extends ClassLoader {

//指定class文件的路径路径
private String path;
public MyClassLoader(String classPath) {
path = classPath;
}

/**
* 重写findClass方法
* @param name 是我们这个类的全路径
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class log = null;
// 获取该class文件字节码数组
byte[] classData = getData();

if (classData == null) {
throw new ClassNotFoundException("Class Not Found");
}
// 将class的字节码数组转换成Class类的实例,并返回
return defineClass(name, classData, 0, classData.length);;
}

/**
* 读取class,将字节码转换成字节数组
* @return
*/
private byte[] getData() {
// 省略读取的代码
// 普通IO读取即可
}
}

 2.3 双亲委派模型

  上面这张图所对应的关系,就被称为类加载器的双亲委派模型。对于每一个类加载器,都有一个父加载器(除了启动类加载器)。当类加载器需要加载一个类时,它首先会将这个类委托给它的父加载器进行加载,若这个父加载器也有父加载器,则继续向上委托,一直到启动类加载器。启动类加载器尝试加载这个类,若这个类在自己管理的目录之下,且还没有被加载,则成功加载,否则加载失败;若加载失败,则交给下层的扩展类加载器进行加载,而扩展类加载器也进行同样的操作。总的来说,双亲委派模型就是先将类交给父类加载器尝试加载,若加载失败,再由子类来加载,每一层都是如此。而在实际实现中,这种父子关系并不是由继承实现,而是由组合实现,在ClassLoader类中,有一个属性,名叫parent,就是指向它的父加载器的引用(之前的扩展类加载器代码就是这样得到的)。

  下面我们是ClassLoader类的loadClass方法源码,看看双亲委派模型的具体实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// name为类的全限定类名
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}

// 这个重载方法被上面的方法调用
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {

// 对以下代码进行多线程同步
synchronized (getClassLoadingLock(name)) {
// 首先,判断这个类是否已经被加载
Class<?> c = findLoadedClass(name);

// c为null表示还没有被加载,则进行加载
if (c == null) {
long t0 = System.nanoTime();
try {
// 此处是双亲委托机制的关键
// 如果当前加载器有父加载器
if (parent != null) {
// 调用父加载器的loadClass对类进行加载
c = parent.loadClass(name, false);
} else {
// 若没有父类加载器,则调用引导类加载器尝试加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 此时加载失败将抛出异常,提示类找不到
// 不需要处理,继续执行
}

// c为null表示调用上层类加载器加载失败
if (c == null) {
long t1 = System.nanoTime();
// 调用当前类加载器的findClass进行加载
// 所以自定义类加载器推荐重写findClass方法
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

  通过上面的代码我们可以清楚地看到双亲委派机制是如何实现的。那这种机制有什么好处呢?使用双亲委派机制可以确保类是被第一个满足条件的类加载器所加载。而对于一个类加载器来说,每一个类只会加载一次,所以不会造成同一个类加载多次,或者一个类被不同的类加载器所加载的情况。之前说过,类的唯一性是由它的本身和加载它的类加载器确定的,使用双亲委派模型很好地避免了类被多个加载器加载,导致在内存中出现相同却不等价的类。比如说我们也定义一个java.lang.String类,如果没有双亲委派模型,内存中将出现两个String,但是在我们看来只有一个,此时将会导致程序产生莫名其妙的错误。而双亲委派模型保证了这种同名的类永远没有办法被加载运行,所以我们自己定义的重名类将永远无法使用,虽然编译可以通过(这里可以自己去试试)。

  当然,双亲委派模型并不是JVM规范中强制要求的,而是一种推荐的策略。所以我们完全可以在编写自己的类加载器时不遵守这个模型,比如重写loadClass方法覆盖这个机制。但是如果不是必要,还是不推荐这么做。


三、总结

  关于类加载器和双亲委派模型就先说这么多吧。上面对这两部分内容做了一个还算具体的描述,相信看完之后会对Java的一些相关特性有更加深入的理解,也能解决我们平常的一些疑惑。


四、参考

  • 《深入理解Java虚拟机》