JVM 虚拟机团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己去决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成。
在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类。而在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类。
子加载器所加载的类能够访问到父加载器所加载的类;而父加载器无法访问到子加载器所加载的类。
启动类加载器主要会去加载 JVM 自身需要的类,这个类由 C++ 代码实现,是虚拟机本身的一部分,它负责将
路径下的核心库或者-Xbootclasspath
参数指定的路径下的 jar 包加载到内存中。注意,由于虚拟机是按照文件名识别加载 jar 包的,如 rt.jar,所以如果文件名不被虚拟机识别,即使把 jar 包丢到 lib 目录下也是没有作用的(出于安全考虑,启动类加载器只加载包名为java、javax、sun等开头的包)。
扩展类加载器是指 Sun 公司(已被 Oracle 收购)实现的sun.misc.Launcher$ExtClassLoader
类,由 Java 语言实现的,是 Launcher 的静态内部类,它负责加载
目录下或者由系统变量-Djava.ext.dir
指定位路径中的类库,开发者可以直接使用标准扩展类加载器。
// ExtClassLoader类中获取路径的代码
private static File[] getExtDirs() {
//加载/lib/ext目录中的类库
String s = System.getProperty("java.ext.dirs");
File[] dirs;
if (s != null) {
StringTokenizer st =
new StringTokenizer(s, File.pathSeparator);
int count = st.countTokens();
dirs = new File[count];
for (int i = 0; i < count; i++) {
dirs[i] = new File(st.nextToken());
}
} else {
dirs = new File[0];
}
return dirs;
}
也会被称为系统类加载器,是指由 Sun 公司实现的sun.misc.Launcher$AppClassLoader
。它负责加载系统类路径java -classpath
或-Djava.class.path
指定路径下的类库,也就是我们常用到的 classpath 路径,开发者可以直接使用系统类加载器,一般情况下该类加载器是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()
方法可以获取到该类加载器。
public class Demo {
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("java.lang.String");
System.out.println(clazz.getClassLoader());
Class<?> clazz2 = Class.forName("com.jojo.jvm.classloader.C");
System.out.println(clazz2.getClassLoader());
}
}
class C {}
以上代码输出的分别是null
,以及 sun.misc.Launcher$AppClassLoader@1d16e93
,其中null
指代了系根类加载器,说明了 String 类是被根类加载器加载进来的。而后者是一个匿名内部类,从名字可以看出是系统类加载器,所以在这里我们自定义的类是由系统类加载器加载进来的。
在Java的日常应用程序开发中,类的加载几乎是由上述 3 种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,需要注意的是,Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式即把请求交由父类处理,它一种任务委派模式,下面我们进一步了解它。
双亲委派模型要求除了最顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器(注意:这里的父类加载器并不是通常所说的继承关系,而是采用组合关系来复用父类加载器的相关代码),如下所示:
双亲委派模式是在 Java 1.2 后引入的,其工作原理是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载器任务,就成功加载,否则就返回让子类加载器自己去尝试加载,这就是双亲委派模型。
protected synchronized Class<?> loadClass(String name, boolean resolve) throws
ClassNotFoundException {
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出 ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的 findClass 方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
这段代码逻辑:首先检查要加载的类是否已经被加载过,若没有则调用父类加载器的loadClass()
方法,若父类加载器为空则默认使用启动类加载器作为父类加载器。加入父类加载器
JVM通过这种双亲委派模型来组织类加载器之间的关系,使得 Java 中的类随着它的类加载器一起具备了一种有优先级的层次关系。例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来完成,因此能确保 Object 类在程序的各种类加载器环境中都能够保证是同一个类。
总结:一方面可以避免类的重复加载,另外也避免了 Java 和核心 API 被篡改。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一类加载器的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的“相等”,包括代表类的Class对象的equals()
方法、isAssignableFrom()
方法、isInstance()
方法的返回结果,也包括使用instanceof
关键字做对象所属关系判定情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,下面代码演示不同的类加载器对instanceof
关键字运算的结果的影响。
/*
类加载器与 instanceof 关键字演示
*/
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myLoader.loadClass("com.jojo.jvm.classloader.MyTest01").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof com.jojo.jvm.classloader.MyTest01);
}
}
运行结果:
class com.jojo.jvm.classloader.MyTest01
false
上面代码中构造了一个简单的类加载器,它可以加载与自己在同一路径下的Class文件。我们使用这个类加载器去加载一个名为com.jojo.jvm.classloader.MyTest01
的类,并实例化了这个类的对象。两行输出结果中,从第一句看出,这个对象确实是com.jojo.jvm.classloader.MyTest01
实例化出来的对象,但从第二句可以发现,这个对象与类com.jojo.jvm.classloader.MyTest01
做所属类型检查的时候却返回了false,这时因为虚拟机中存在了两个ClassLoaderTest
类,一个是由系统应用程序加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来自同一个Class文件,但依然是两个独立的类,做对象所属类型检查时结果自然为false。(所示代码通过覆盖 loadClass 方法从而破坏了父亲委托机制,这也是一个前提。)
双亲委派模型本身有一定的缺陷。双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被用户代码调用的API,但世事无绝对,如果基础类又要调用回用户的代码,那该怎么办?
这并非是不可能存在的情况,常见的例子就是JDBC,其主要的接口类等是由启动类加载器去加载(文件存放与rt.jar)。而对于其组件的具体实现,则是由MySQL或者Oracle等数据库厂商去实现的。这时它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的代码,但是启动类加载器不可能“认识”这些代码。
为了解决这个问题,Java设计团队引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread
类的setContextClassLoader()
方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用类加载器。
除去上面所提及的线程上下文类加载器外,双亲委派模型第一次“被破坏”发生在双亲委派模型出现之前(即 JDK 1.2面世之前)。那时的用户自定义类加载器主要靠重写loadClass()
方法来完成,这样对双亲委派模型是一种破坏,1.2 之后为了兼容前面的代码,只能在java.lang.ClassLoader
中再添加一个findClass()
方法,并引导用户编写时尽可能去重写这个方法。
此外,为了实现代码热部署,对双亲委派模型也产生了破坏,这个不展开讨论。
虽然 JVM 中为我们提供了成熟的类加载器以及类加载器模型,但是我们在一些场景下也是需要去自定义类加载器的,比如以下几种情况:
下面简单介绍一下如何针对第一种情况自定义一个类加载器,实现自定义类加载器可以去继承 ClassLoader 或 URLClassLoader,继承 ClassLoader 需要自己重写 findClass()
方法并编写加载逻辑,而继承 URLClassLoader 则可以省去编写findClass()
方法以及 class 文件加载后转换成字节码流的代码。以下简单举例代码:
public class Test {
@Override
public String toString() {
return "load success!!!";
}
}
将上面 java 文件编译后生成的 class 文件放置在其他位置,以便等等验证加载过程,同时记得删除 classPath 路径下已经生成的 class 文件,避免先加载了 classPaht 下的文件。接下来是类加载器的代码:
public class FileClassLoader extends ClassLoader {
private String rootDir;
public FileClassLoader(String rootDir) {
this.rootDir = rootDir;
}
/**
* 编写 findClass 方法的逻辑
*/
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 获取类的 class 文件字节数组
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
// 直接生成 class 对象
return defineClass(name, classData, 0, classData.length);
}
}
/**
* 编写获取class文件并转换为字节码流的逻辑
*/
private byte[] getClassData(String className) {
// 读取类文件的字节
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
// 读取类文件的字节码
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/**
* 生成类文件的完全路径
*/
private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
public static void main(String[] args) throws ClassNotFoundException {
String rootDir="your/file/dir";
// 创建自定义文件类加载器
FileClassLoader loader = new FileClassLoader(rootDir);
try {
//加载指定的class文件
Class<?> object1=loader.loadClass("com.jojo.classloader.Test");
System.out.println(object1.newInstance().toString());
//输出结果:I am DemoObj
} catch (Exception e) {
e.printStackTrace();
}
}
}
这里说明一下,上面代码定义的findClass
方法最终是会被loadClass
方法去调用的,所以如果想要打破双亲委派机制,还得去重写loadClass
方法。
参考:
《深入理解Java虚拟机》
深入理解Java类加载器(ClassLoader)