java中的类加载原理
前言
JAVA中的类加载机制是JAVA技术体系中比较核心的部分,理解背后的机理有助于排查程序中出现的类加载失败的技术问题。对理解JAVA虚拟机的连接模型和JAVA语言的动态性都有很大的帮助
一、JAVA虚拟机类加载器的结构简述
1.1 JVM 三种预定义类型加载器
我们首先看一下JVM预定义的三种类型类加载器,当一个 JVM 启动的时候,Java缺省开始使用如下三种类型类装入器:
启动(Bootstrap)类加载器
:启动类加载器是用本地代码实现的类加载器,它负责将JAVA_HOME/lib下面的核心类库或-Xbootclasspath选项指定的jar包等虚拟机识别的类库加载到内存中。由于启动类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用。具体可由启动类加载器加载到的路径可通过System.getProperty(“sun.boot.class.path”)
查看。
扩展(Extension)类加载器
:扩展类加载器是由Sun的ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的,它负责将JAVA_HOME /lib/ext或者由系统变量-Djava.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器,具体可由扩展类加载器加载到的路径可通过System.getProperty("java.ext.dirs")
查看。
系统(System)类加载器
:系统类加载器是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的,它负责将用户类路径(java -classpath或-Djava.class.path变量所指的目录,即当前类所在路径及其引用的第三方类库的路径,如第四节中的问题6所述)下的类库加载到内存中。开发者可以直接使用系统类加载器,具体可由系统类加载器加载到的路径可通过System.getProperty("java.class.path")
查看。
自定义类加载器,通过继承ClassLoader实现,一般是加载我们的自定义类
Ps: 除了以上列举的三种类加载器,还有一种比较特殊的类型就是线程上下文类加载器,这个暂且不提。
1.2 类加载的机制
JVM类加载的机制主要有如下三种:
- 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个CLass时,改Class所依赖和引用的其它Class也将由将加载器负责载入。除非显式指定另一个类加载器载入
- 双亲委派:所谓的双亲委派,就是先让父类加载器试图加载该Class,只有当父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
- 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
这里着重说明一下双亲委派机制。
双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。
双亲委派机制的优势:采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
1.3 类加载的时机
Java类加载 会初始化的情况有且仅有以下几种:(也称为主动引用)
- 创建类的实例,也就是new一个对象
- 访问某个类或者接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(Class.forName("com.lyj.load"))
- 初始化一个类的子类(会首先初始化子类的父亲)
- JVM启动时表明的启动类,即文件名和类名相同的那个类
- 通过子类来引用父类的静态字段,只会触发父类的初始化,不会触发子类的初始化。
- superclass () sc = new superclass[]; //不会触发superclass初始化,因为底层实现是直接生成object子类。
- 引用一个类的静态常量也不会触发初始化,因为常量在编译阶段已经确认。
对于一个final类型的静态变量,如果该变量的值在编译时就可以确定下来,那么这个变量相当于“宏变量”。Java编译器会在编译时直接把这个变量出现的地方替换成它的值,因此即使程序使用该静态变量,也不会导致该类的初始化。反之,如果final类型的静态Field的值不能在编译时确定下来,则必须等到运行时才可以确定该变量的值,如果通过该类来访问它的静态变量,则会导致该类被初始化。
PS:接口也会有初始化的过程,但接口中不能有static块,但编译器也会为接口生成<clinit>类构造器,用于初始化接口中成员变量,接口子类的不同仅是和『初始化一个类的子类(会首先初始化子类的父亲)』不同,因为接口不要求父接口全部实现,而是用到哪些实现哪些。
二、类加载过程
一个类的生命周期包括加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using) 和 卸载(Unloading)七个阶段。其中,验证(Verification)、准备(Preparation)、解析(Resolution)合起来被称为链接阶段。
现在我们一一学习一下JVM在加载、验证、准备、解析和初始化五个阶段是如何对每个类进行操作的。
2.1 加载(Loading)
在加载阶段(可以参考java.lang.ClassLoader的loadClass()方法),虚拟机需要完成以下三件事情:
(1). 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等);
(2). 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
(3). 在内存中(对于HotSpot虚拟就而言就是方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;
加载阶段和连接阶段(Linking)的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
特别地,第一件事情(通过一个类的全限定名来获取定义此类的二进制字节流)是由类加载器完成的,具体涉及JVM预定义的类加载器、双亲委派模型等内容,。
2.2 验证(Verification)
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 验证阶段大致会完成4个阶段的检验动作:
-
- 文件格式验证:验证字节流是否符合Class文件格式的规范(例如,是否以魔术0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型)
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求(例如:这个类是否有父类,除了java.lang.Object之外);
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的;
- 符号引用验证:确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响。如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
2.3 准备(Preparation)
准备阶段是正式为类变量(static 成员变量)分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
那么,变量value在准备阶段过后的值为0而不是123。因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器方法<clinit>()之中,所以把value赋值为123的动作将在初始化阶段才会执行。至于“特殊情况”是指:当类字段的字段属性是ConstantValue时,会在准备阶段初始化为指定的值,所以标注为final之后,value的值在准备阶段初始化为123而非0。
1 public static final int value = 123;
初始化是为类的静态变量赋予正确的初始值,准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段,给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0,然后到解析(后面在说),到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。
2.4 解析(Resolution)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
2.5 初始化(Initialization)
类初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值(零值);而在初始化阶段,则根据程序猿通过程序制定的主观计划去初始化类变量和其他资源,或者更直接地说:初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。如下:
1 public class Test{ 2 static{ 3 i=0; 4 System.out.println(i);//Error:Cannot reference a field before it is defined(非法向前应用) 5 } 6 static int i=1; 7 }
那么注释报错的那行代码,改成下面情形,程序就可以编译通过并可以正常运行了。
1 public class Test{ 2 static{ 3 i=0; 4 //System.out.println(i); 5 } 6 7 static int i=1; 8 9 public static void main(String args[]){ 10 System.out.println(i); 11 } 12 }/* Output: 13 1 14 *///:~
类构造器<clinit>()与实例构造器<init>()不同,它不需要程序员进行显式调用,虚拟机会保证在子类类构造器<clinit>()执行之前,父类的类构造<clinit>()执行完毕。由于父类的构造器<clinit>()先执行,也就意味着父类中定义的静态语句块/静态变量的初始化要优先于子类的静态语句块/静态变量的初始化执行。特别地,类构造器<clinit>()对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生产类构造器<clinit>()。
虚拟机会保证一个类的类构造器<clinit>()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器<clinit>(),其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行<clinit>()方法,因为 在同一个类加载器下,一个类型只会被初始化一次。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的,如下所示:
1 public class DealLoopTest { 2 static{ 3 System.out.println("DealLoopTest..."); 4 } 5 static class DeadLoopClass { 6 static { 7 if (true) { 8 System.out.println(Thread.currentThread() 9 + "init DeadLoopClass"); 10 while (true) { // 模拟耗时很长的操作 11 } 12 } 13 } 14 } 15 16 public static void main(String[] args) { 17 Runnable script = new Runnable() { // 匿名内部类 18 public void run() { 19 System.out.println(Thread.currentThread() + " start"); 20 DeadLoopClass dlc = new DeadLoopClass(); 21 System.out.println(Thread.currentThread() + " run over"); 22 } 23 }; 24 25 Thread thread1 = new Thread(script); 26 Thread thread2 = new Thread(script); 27 thread1.start(); 28 thread2.start(); 29 } 30 }/* Output: 31 DealLoopTest... 32 Thread[Thread-1,5,main] start 33 Thread[Thread-0,5,main] start 34 Thread[Thread-1,5,main]init DeadLoopClass 35 *///:~
三、详细解释类加载双亲委派机制
3.1 类加载双亲委派机制介绍和分析
在这里,需要着重说明的是,JVM在加载类时默认采用的是双亲委派机制。关于虚拟机默认的双亲委派机制,我们可以从系统类加载器和扩展类加载器为例作简单分析。
图一 标准扩展类加载器继承层次图
图二系统类加载器继承层次图
通过图一和图二我们可以看出,类加载器均是继承自java.lang.ClassLoader抽象类。我们下面我们就看简要介绍一下java.lang.ClassLoader中几个最重要的方法:
1 //加载指定名称(包括包名)的二进制类型,供用户调用的接口
2 public Class<?> loadClass(String name) throws ClassNotFoundException{ … }
3
4 //加载指定名称(包括包名)的二进制类型,同时指定是否解析(但是这里的resolve参数不一定真正能达到解析的效果),供继承用
5 protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{ … }
6
7 //findClass方法一般被loadClass方法调用去加载指定名称类,供继承用
8 protected Class<?> findClass(String name) throws ClassNotFoundException { … }
9
10 //定义类型,一般在findClass方法中读取到对应字节码后调用,可以看出不可继承
11 //(说明:JVM已经实现了对应的具体功能,解析对应的字节码,产生对应的内部数据结构放置到方法区,所以无需覆写,直接调用就可以了)
12 protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError{ … }
通过进一步分析标准扩展类加载器( sun.misc.LauncherAppClassLoader )的代码以及其公共父类(java.net.URLClassLoader 和java.security.SecureClassLoader )的代码可以看出,都没有覆写 java.lang.ClassLoader中默认的加载委派规则---loadClass(… )方法。既然这样,我们就可以通过分析java.lang.ClassLoader 中的loadClass (String name)方法的代码就可以分析出虚拟机默认采用的双亲委派机制到底是什么模样:
1 public Class<?> loadClass(String name) throws ClassNotFoundException {
2 return loadClass(name, false);
3 }
4
5 protected synchronized Class<?> loadClass(String name, boolean resolve)
6 throws ClassNotFoundException {
7
8 // 首先判断该类型是否已经被加载
9 Class c = findLoadedClass(name);
10 if (c == null) {
11 //如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
12 try {
13 if (parent != null) {
14 //如果存在父类加载器,就委派给父类加载器加载
15 c = parent.loadClass(name, false);
16 } else {
17 //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,
18 //通过调用本地方法native findBootstrapClass0(String name)
19 c = findBootstrapClass0(name);
20 }
21 } catch (ClassNotFoundException e) {
22 // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
23 c = findClass(name);
24 }
25 }
26 if (resolve) {
27 resolveClass(c);
28 }
29 return c;
30 }
通过上面的代码分析,我们可以对JVM 采用的双亲委派类加载机制有了更感性的认识,下面我们就接着分析一下启动类加载器、标准扩展类加载器和系统类加载器三者之间的关系。可能大家已经从各种资料上面看到了如下类似的一幅图片:
图三 类加载器默认委派关系图
上面图片给人的直观印象是系统类加载器的父类加载器是标准扩展类加载器,标准扩展类加载器的父类加载器是启动类加载器,下面我们就用代码具体测试一下:
1 public class LoaderTest {
2
3 public static void main(String[] args) {
4 try {
5 System.out.println(ClassLoader.getSystemClassLoader());
6 System.out.println(ClassLoader.getSystemClassLoader().getParent());
7 System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
8 } catch (Exception e) {
9 e.printStackTrace();
10 }
11 }
12 }
说明:通过java.lang.ClassLoader.getSystemClassLoader()可以直接获取到系统类加载器。
代码输出如下:
1 sun.misc.Launcher$AppClassLoader@6d06d69c
2 sun.misc.Launcher$ExtClassLoader@70dea4e
3 null
通过以上的代码输出,我们可以判定系统类加载器的父加载器是标准扩展类加载器,但是我们试图获取标准扩展类加载器的父类加载器时确得到了 null,就是说标准扩展类加载器本身强制设定父类加载器为null 。我们还是借助于代码分析一下。
我们首先看一下java.lang.ClassLoader抽象类中默认实现的两个构造函数:
1 protected ClassLoader() {
2 SecurityManager security = System.getSecurityManager();
3 if (security != null) {
4 security.checkCreateClassLoader();
5 }
6 //默认将父类加载器设置为系统类加载器,getSystemClassLoader()获取系统类加载器
7 this.parent = getSystemClassLoader();
8 initialized = true;
9 }
10
11 protected ClassLoader(ClassLoader parent) {
12 SecurityManager security = System.getSecurityManager();
13 if (security != null) {
14 security.checkCreateClassLoader();
15 }
16 //强制设置父类加载器
17 this.parent = parent;
18 initialized = true;
19 }
我们再看一下 ClassLoader 抽象类中 parent 成员的声明:
1 // The parent class loader for delegation 2 private ClassLoader parent;
声明为私有变量的同时并没有对外提供可供派生类访问的public或者protected设置器接口(对应的setter方法),结合前面的测试代码的输出,我们可以推断出:
1. 系统类加载器(AppClassLoader)调用ClassLoader(ClassLoader parent)构造函数将父类加载器设置为标准扩展类加载器(ExtClassLoader)。(因为如果不强制设置,默认会通过调用getSystemClassLoader()方法获取并设置成系统类加载器,这显然和测试输出结果不符。)
2. 扩展类加载器(ExtClassLoader)调用ClassLoader(ClassLoader parent)构造函数将父类加载器设置为null。(因为如果不强制设置,默认会通过调用getSystemClassLoader()方法获取并设置成系统类加载器,这显然和测试输出结果不符。)
在我们可能会有这样的疑问: 扩展类加载器(ExtClassLoader)的父类加载器被强制设置为null了,那么扩展类加载器为什么还能将加载任务委派给启动类加载器呢?
图四 标准扩展类加载器和系统类加载器成员大纲视图
图五 扩展类加载器和系统类加载器公共父类成员大纲视图
通过图四和图五可以看出,标准扩展类加载器和系统类加载器及其父类(java.net.URLClassLoader和java.security.SecureClassLoader)都没有覆写java.lang.ClassLoader中默认的加载委派规则---loadClass(…)方法。有关java.lang.ClassLoader中默认的加载委派规则前面已经分析过,如果父加载器为null,则会调用本地方法进行启动类加载尝试。所以,图三中,启动类加载器、标准扩展类加载器和系统类加载器之间的委派关系事实上是仍就成立的。(在后面的用户自定义类加载器部分,还会做更深入的分析)。
3.2 类加载双亲委派示例
以上已经简要介绍了虚拟机默认使用的启动类加载器、标准扩展类加载器和系统类加载器,并以三者为例结合JDK代码对JVM默认使用的双亲委派类加载机制做了分析。下面我们就来看一个综合的例子。首先在IDE中建立一个简单的java应用工程,然后写一个简单的JavaBean如下:
1 package classloader.test.bean;
2 public class TestBean {
3 public TestBean() { }
4 }
在现有当前工程中另外建立一测试类(ClassLoaderTest.java)内容如下:
测试一:
1 package classloader.test.bean;
2
3 public class ClassLoaderTest {
4
5 public static void main(String[] args) {
6 try {
7 //查看当前系统类路径中包含的路径条目
8 System.out.println(System.getProperty("java.class.path"));
9 //调用加载当前类的类加载器(这里即为系统类加载器)加载TestBean
10 Class typeLoaded = Class.forName("classloader.test.bean.TestBean");
11 //查看被加载的TestBean类型是被那个类加载器加载的
12 System.out.println(typeLoaded.getClassLoader());
13 } catch (Exception e) {
14 e.printStackTrace();
15 }
16 }
17 }
对应的输出如下:
sun.misc.Launcher$AppClassLoader@73d16e93
说明:当前类路径默认的含有的一个条目就是工程的输出目录。
测试二:
将当前工程输出目录下的TestBean.class打包进test.jar剪贴到<Java_Runtime_Home>/lib/ext目录下(现在工程输出目录下和JRE扩展目录下都有待加载类型的class文件)。再运行测试一测试代码,结果如下:
sun.misc.Launcher$ExtClassLoader@15db9742
对比测试一和测试二,我们明显可以验证前面说的双亲委派机制, 系统类加载器在接到加载classloader.test.bean.TestBean类型的请求时,首先将请求委派给父类加载器(标准扩展类加载器),标准扩展类加载器抢先完成了加载请求。
测试三:
将test.jar拷贝一份到<Java_Runtime_Home>/lib下,运行测试代码,输出如下:
sun.misc.Launcher$ExtClassLoader@15db9742
测试三和测试二输出结果一致。那就是说,放置到<Java_Runtime_Home>/lib目录下的TestBean对应的class字节码并没有被加载,这其实和前面讲的双亲委派机制并不矛盾。 虚拟机出于安全等因素考虑,不会加载<Java_Runtime_Home>/lib存在的陌生类,开发者通过将要加载的非JDK自身的类放置到此目录下期待启动类加载器加载是不可能的。做个进一步验证,删除<Java_Runtime_Home>/lib/ext目录下和工程输出目录下的TestBean对应的class文件,然后再运行测试代码,则将会有ClassNotFoundException异常抛出。有关这个问题,大家可以在java.lang.ClassLoader中的loadClass(String name, boolean resolve)方法中设置相应断点运行测试三进行调试,会发现findBootstrapClass0()会抛出异常,然后在下面的findClass方法中被加载,当前运行的类加载器正是扩展类加载器(sun.misc.Launcher$ExtClassLoader),这一点可以通过JDT中变量视图查看验证。
四、java程序动态扩展方式
Java的连接模型允许用户运行时扩展引用程序,既可以通过当前虚拟机中预定义的加载器加载编译时已知的类或者接口,又允许用户自行定义类装载器,在运行时动态扩展用户的程序。通过用户自定义的类装载器,你的程序可以装载在编译时并不知道或者尚未存在的类或者接口,并动态连接它们并进行有选择的解析。
运行时动态扩展java应用程序有如下两个途径:
4.1 调用java.lang.Class.forName(…)加载类
这个方法其实在前面已经讨论过,在后面的问题2解答中说明了该方法调用会触发哪个类加载器开始加载任务。这里需要说明的是多参数版本的forName(…)方法:
public static Class<?> forName(String name, boolean initialize, ClassLoader loader) throws ClassNotFoundException
这里的initialize参数是很重要的。它表示在加载同时是否完成初始化的工作(说明:单参数版本的forName方法默认是完成初始化的)。 有些场景下需要将initialize设置为true来强制加载同时完成初始化。例如典型的就是利用DriverManager进行JDBC驱动程序类注册的问题。因为每一个JDBC驱动程序类的静态初始化方法都用DriverManager注册驱动程序,这样才能被应用程序使用。这就要求驱动程序类必须被初始化,而不单单被加载。Class.forName的一个很常见的用法就是在加载数据库驱动的时候。如 Class.forName("org.apache.derby.jdbc.EmbeddedDriver").newInstance()用来加载 Apache Derby 数据库的驱动。
4.2 用户自定义类加载器
通过前面的分析,我们可以看出,除了和本地实现密切相关的启动类加载器之外,包括标准扩展类加载器和系统类加载器在内的所有其他类加载器我们都可以当做自定义类加载器来对待,唯一区别是是否被虚拟机默认使用。前面的内容中已经对java.lang.ClassLoader抽象类中的几个重要的方法做了介绍,这里就简要叙述一下一般用户自定义类加载器的工作流程吧(可以结合后面问题解答一起看):
1、首先检查请求的类型是否已经被这个类装载器装载到命名空间中了,如果已经装载,直接返回;否则转入步骤2;
2、委派类加载请求给父类加载器(更准确的说应该是双亲类加载器,真实虚拟机中各种类加载器最终会呈现树状结构),如果父类加载器能够完成,则返回父类加载器加载的Class实例;否则转入步骤3;
3、调用本类加载器的findClass(…)方法,试图获取对应的字节码,如果获取的到,则调用defineClass(…)导入类型到方法区;如果获取不到对应的字节码或者其他原因失败,返回异常给loadClass(…), loadClass(…)转而抛异常,终止加载过程(注意:这里的异常种类不止一种)。
说明:这里说的自定义类加载器是指JDK 1.2以后版本的写法,即不覆写改变java.lang.loadClass(…)已有委派逻辑情况下。
整个加载类的过程如下图:
图六 自定义类加载器加载类的过程
五、常见问题分析
5.1 由不同的类加载器加载的指定类还是相同的类型吗?
在Java中,一个类用其完全匹配类名(fully qualified class name)作为标识,这里指的完全匹配类名包括包名和类名。但在JVM中一个类用其全名和一个加载类ClassLoader的实例作为唯一标识,不同类加载器加载的类将被置于不同的命名空间。我们可以用两个自定义类加载器去加载某自定义类型(注意不要将自定义类型的字节码放置到系统路径或者扩展路径中,否则会被系统类加载器或扩展类加载器抢先加载),然后用获取到的两个Class实例进行java.lang.Object.equals(…)判断,将会得到不相等的结果。这个大家可以写两个自定义的类加载器去加载相同的自定义类型,然后做个判断;同时,可以测试加载java.*类型,然后再对比测试一下测试结果。
5.2 在代码中直接调用Class.forName(String name)方法,到底会触发那个类加载器进行类加载行为?
Class.forName(String name)默认会使用调用类的类加载器来进行类加载。我们直接来分析一下对应的jdk的代码:
1 //java.lang.Class.java 2 publicstatic Class<?> forName(String className) throws ClassNotFoundException { 3 return forName0(className, true, ClassLoader.getCallerClassLoader()); 4 } 5 6 //java.lang.ClassLoader.java 7 // Returns the invoker's class loader, or null if none. 8 static ClassLoader getCallerClassLoader() { 9 // 获取调用类(caller)的类型 10 Class caller = Reflection.getCallerClass(3); 11 // This can be null if the VM is requesting it 12 if (caller == null) { 13 return null; 14 } 15 // 调用java.lang.Class中本地方法获取加载该调用类(caller)的ClassLoader 16 return caller.getClassLoader0(); 17 } 18 19 //java.lang.Class.java 20 //虚拟机本地实现,获取当前类的类加载器,前面介绍的Class的getClassLoader()也使用此方法 21 native ClassLoader getClassLoader0();
5.3 在编写自定义类加载器时,如果没有设定父加载器,那么父加载器是谁?
前面讲过,在不指定父类加载器的情况下,默认采用系统类加载器。可能有人觉得不明白,现在我们来看一下JDK对应的代码实现。众所周知,我们编写自定义的类加载器直接或者间接继承自java.lang.ClassLoader抽象类,对应的无参默认构造函数实现如下:
1 //摘自java.lang.ClassLoader.java 2 protected ClassLoader() { 3 SecurityManager security = System.getSecurityManager(); 4 if (security != null) { 5 security.checkCreateClassLoader(); 6 } 7 this.parent = getSystemClassLoader(); 8 initialized = true; 9 }
我们再来看一下对应的getSystemClassLoader()方法的实现:
1 private static synchronized void initSystemClassLoader() { 2 //... 3 sun.misc.Launcher l = sun.misc.Launcher.getLauncher(); 4 scl = l.getClassLoader(); 5 //... 6 }
System.out.println(sun.misc.Launcher.getLauncher().getClassLoader());
本机对应输出如下:
sun.misc.Launcher$AppClassLoader@73d16e93
所以,我们现在可以相信当自定义类加载器没有指定父类加载器的情况下,默认的父类加载器即为系统类加载器。同时,我们可以得出如下结论: 即使用户自定义类加载器不指定父类加载器,那么,同样可以加载如下三个地方的类:
1. <Java_Runtime_Home>/lib下的类;
2. < Java_Runtime_Home >/lib/ext下或者由系统变量java.ext.dir指定位置中的类;
3. 当前工程类路径下或者由系统变量java.class.path指定位置中的类。
5.4 在编写自定义类加载器时,如果将父类加载器强制设置为null,那么会有什么影响?如果自定义的类加载器不能加载指定类,就肯定会加载失败吗?
JVM规范中规定如果用户自定义的类加载器将父类加载器强制设置为null,那么会自动将启动类加载器设置为当前用户自定义类加载器的父类加载器(这个问题前面已经分析过了)。同时,我们可以得出如下结论:
即使用户自定义类加载器不指定父类加载器,那么,同样可以加载到<Java_Runtime_Home>/lib下的类,但此时就不能够加载<Java_Runtime_Home>/lib/ext目录下的类了。
说明:问题3和问题4的推断结论是基于用户自定义的类加载器本身延续了java.lang.ClassLoader.loadClass(…)默认委派逻辑,如果用户对这一默认委派逻辑进行了改变,以上推断结论就不一定成立了,详见问题5。
5.5 编写自定义类加载器时,一般有哪些注意点
1、一般尽量不要覆写已有的loadClass(...)方法中的委派逻辑
一般在JDK 1.2之前的版本才这样做,而且事实证明,这样做极有可能引起系统默认的类加载器不能正常工作。在JVM规范和JDK文档中(1.2或者以后版本中),都没有建议用户覆写loadClass(…)方法,相比而言,明确提示开发者在开发自定义的类加载器时覆写findClass(…)逻辑。举一个例子来验证该问题:
1 //用户自定义类加载器WrongClassLoader.Java(覆写loadClass逻辑) 2 public class WrongClassLoader extends ClassLoader { 3 4 public Class<?> loadClass(String name) throws ClassNotFoundException { 5 return this.findClass(name); 6 } 7 8 protected Class<?> findClass(String name) throws ClassNotFoundException { 9 // 假设此处只是到工程以外的特定目录D:\library下去加载类 10 // 具体实现代码省略 11 } 12 }
1 //问题5测试代码一 2 public class WrongClassLoaderTest { 3 4 publicstaticvoid main(String[] args) { 5 try { 6 WrongClassLoader loader = new WrongClassLoader(); 7 Class classLoaded = loader.loadClass("beans.Account"); 8 System.out.println(classLoaded.getName()); 9 System.out.println(classLoaded.getClassLoader()); 10 } catch (Exception e) { 11 e.printStackTrace(); 12 } 13 } 14 }
这里D:"classes"beans"Account.class是物理存在的。输出结果:
1 java.io.FileNotFoundException: D:"classes"java"lang"Object.class (系统找不到指定的路径。) 2 at java.io.FileInputStream.open(Native Method) 3 at java.io.FileInputStream.<init>(FileInputStream.java:106) 4 at WrongClassLoader.findClass(WrongClassLoader.java:40) 5 at WrongClassLoader.loadClass(WrongClassLoader.java:29) 6 at java.lang.ClassLoader.loadClassInternal(ClassLoader.java:319) 7 at java.lang.ClassLoader.defineClass1(Native Method) 8 at java.lang.ClassLoader.defineClass(ClassLoader.java:620) 9 at java.lang.ClassLoader.defineClass(ClassLoader.java:400) 10 at WrongClassLoader.findClass(WrongClassLoader.java:43) 11 at WrongClassLoader.loadClass(WrongClassLoader.java:29) 12 at WrongClassLoaderTest.main(WrongClassLoaderTest.java:27) 13 Exception in thread "main" java.lang.NoClassDefFoundError: java/lang/Object 14 at java.lang.ClassLoader.defineClass1(Native Method) 15 at java.lang.ClassLoader.defineClass(ClassLoader.java:620) 16 at java.lang.ClassLoader.defineClass(ClassLoader.java:400) 17 at WrongClassLoader.findClass(WrongClassLoader.java:43) 18 at WrongClassLoader.loadClass(WrongClassLoader.java:29) 19 at WrongClassLoaderTest.main(WrongClassLoaderTest.java:27)
这说明,连要加载的类型的超类型java.lang.Object都加载不到了。这里列举的由于覆写loadClass()引起的逻辑错误明显是比较简单的,实际引起的逻辑错误可能复杂的多。
1 //问题5测试二 2 //用户自定义类加载器WrongClassLoader.Java(不覆写loadClass逻辑) 3 public class WrongClassLoader extends ClassLoader { 4 5 protected Class<?> findClass(String name) throws ClassNotFoundException { 6 //假设此处只是到工程以外的特定目录D:\library下去加载类 7 //具体实现代码省略 8 } 9 }
将自定义类加载器代码WrongClassLoader.Java做以上修改后,再运行测试代码,输出结果如下:
beans.Account
WrongClassLoader@1c78e57
2、正确设置父类加载器
通过上面问题4和问题5的分析我们应该已经理解,个人觉得这是自定义用户类加载器时最重要的一点,但常常被忽略或者轻易带过。有了前面JDK代码的分析作为基础,我想现在大家都可以随便举出例子了。
3、保证findClass(String name)方法的逻辑正确性
事先尽量准确理解待定义的类加载器要完成的加载任务,确保最大程度上能够获取到对应的字节码内容。
5.6 如何在运行时判断系统类加载器能加载哪些路径下的类?
一是可以直接调用ClassLoader.getSystemClassLoader()或者其他方式获取到系统类加载器(系统类加载器和扩展类加载器本身都派生自URLClassLoader),调用URLClassLoader中的getURLs()方法可以获取到
二是可以直接通过获取系统属性java.class.path来查看当前类路径上的条目信息 :System.getProperty("java.class.path")。
5.7 如何在运行时判断标准扩展类加载器能加载哪些路径下的类?
方法之一:
1 import java.net.URL; 2 import java.net.URLClassLoader; 3 4 public class ClassLoaderTest { 5 6 /** 7 * @param args the command line arguments 8 */ 9 public static void main(String[] args) { 10 try { 11 URL[] extURLs = ((URLClassLoader) ClassLoader.getSystemClassLoader().getParent()).getURLs(); 12 for (int i = 0; i < extURLs.length; i++) { 13 System.out.println(extURLs[i]); 14 } 15 } catch (Exception e) { 16 //… 17 } 18 } 19 }
本机对应输出如下:
1 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/access-bridge-64.jar 2 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/cldrdata.jar 3 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/dnsns.jar 4 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/jaccess.jar 5 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/jfxrt.jar 6 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/localedata.jar 7 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/nashorn.jar 8 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/sunec.jar 9 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/sunjce_provider.jar 10 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/sunmscapi.jar 11 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/sunpkcs11.jar 12 file:/C:/Program%20Files/Java/jdk1.8.0_05/jre/lib/ext/zipfs.jar
六、开发自己的类加载器
在前面介绍类加载器的代理委派模式的时候,提到过类加载器会首先代理给其它类加载器来尝试加载某个类。这就意味着真正完成类的加载工作的类加载器和启动这个加载过程的类加载器,有可能不是同一个。真正完成类的加载工作是通过调用defineClass来实现的;而启动类的加载过程是通过调用loadClass来实现的。前者称为一个类的定义加载器(defining loader),后者称为初始加载器(initiating loader)。在Java虚拟机判断两个类是否相同的时候,使用的是类的定义加载器。也就是说,哪个类加载器启动类的加载过程并不重要,重要的是最终定义这个类的加载器。两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器。如类 com.example.Outer引用了类 com.example.Inner,则由类 com.example.Outer的定义加载器负责启动类 com.example.Inner的加载过程。
方法 loadClass()抛出的是 java.lang.ClassNotFoundException异常;方法 defineClass()抛出的是 java.lang.NoClassDefFoundError异常。
类加载器在成功加载某个类之后,会把得到的 java.lang.Class类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用。
在绝大多数情况下,系统默认提供的类加载器实现已经可以满足需求。但是在某些情况下,您还是需要为应用开发出自己的类加载器。比如您的应用通过网络来传输Java类的字节代码,为了保证安全性,这些字节代码经过了加密处理。这个时候您就需要自己的类加载器来从某个网络地址上读取加密后的字节代码,接着进行解密和验证,最后定义出要在Java虚拟机中运行的类来。下面将通过两个具体的实例来说明类加载器的开发。
6.1 文件系统类加载器
第一个类加载器用来加载存储在文件系统上的Java字节代码。完整的实现如下所示。
1 package classloader; 2 3 import java.io.ByteArrayOutputStream; 4 import java.io.File; 5 import java.io.FileInputStream; 6 import java.io.IOException; 7 import java.io.InputStream; 8 9 // 文件系统类加载器 10 public class FileSystemClassLoader extends ClassLoader { 11 12 private String rootDir; 13 14 public FileSystemClassLoader(String rootDir) { 15 this.rootDir = rootDir; 16 } 17 18 // 获取类的字节码 19 @Override 20 protected Class<?> findClass(String name) throws ClassNotFoundException { 21 byte[] classData = getClassData(name); // 获取类的字节数组 22 if (classData == null) { 23 throw new ClassNotFoundException(); 24 } else { 25 return defineClass(name, classData, 0, classData.length); 26 } 27 } 28 29 private byte[] getClassData(String className) { 30 // 读取类文件的字节 31 String path = classNameToPath(className); 32 try { 33 InputStream ins = new FileInputStream(path); 34 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 35 int bufferSize = 4096; 36 byte[] buffer = new byte[bufferSize]; 37 int bytesNumRead = 0; 38 // 读取类文件的字节码 39 while ((bytesNumRead = ins.read(buffer)) != -1) { 40 baos.write(buffer, 0, bytesNumRead); 41 } 42 return baos.toByteArray(); 43 } catch (IOException e) { 44 e.printStackTrace(); 45 } 46 return null; 47 } 48 49 private String classNameToPath(String className) { 50 // 得到类文件的完全路径 51 return rootDir + File.separatorChar 52 + className.replace('.', File.separatorChar) + ".class"; 53 } 54 }
如上所示,类 FileSystemClassLoader继承自类java.lang.ClassLoader。在java.lang.ClassLoader类的常用方法中,一般来说,自己开发的类加载器只需要覆写 findClass(String name)方法即可。java.lang.ClassLoader类的方法loadClass()封装了前面提到的代理模式的实现。该方法会首先调用findLoadedClass()方法来检查该类是否已经被加载过;如果没有加载过的话,会调用父类加载器的loadClass()方法来尝试加载该类;如果父类加载器无法加载该类的话,就调用findClass()方法来查找该类。 因此,为了保证类加载器都正确实现代理模式,在开发自己的类加载器时,最好不要覆写 loadClass()方法,而是覆写 findClass()方法。
类 FileSystemClassLoader的 findClass()方法首先根据类的全名在硬盘上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过defineClass()方法来把这些字节代码转换成 java.lang.Class类的实例。
加载本地文件系统上的类,示例如下:
1 package com.example; 2 3 public class Sample { 4 5 private Sample instance; 6 7 public void setSample(Object instance) { 8 System.out.println(instance.toString()); 9 this.instance = (Sample) instance; 10 } 11 }
1 package classloader; 2 3 import java.lang.reflect.Method; 4 5 public class ClassIdentity { 6 7 public static void main(String[] args) { 8 new ClassIdentity().testClassIdentity(); 9 } 10 11 public void testClassIdentity() { 12 String classDataRootPath = "C:\\Users\\JackZhou\\Documents\\NetBeansProjects\\classloader\\build\\classes"; 13 FileSystemClassLoader fscl1 = new FileSystemClassLoader(classDataRootPath); 14 FileSystemClassLoader fscl2 = new FileSystemClassLoader(classDataRootPath); 15 String className = "com.example.Sample"; 16 try { 17 Class<?> class1 = fscl1.loadClass(className); // 加载Sample类 18 Object obj1 = class1.newInstance(); // 创建对象 19 Class<?> class2 = fscl2.loadClass(className); 20 Object obj2 = class2.newInstance(); 21 Method setSampleMethod = class1.getMethod("setSample", java.lang.Object.class); 22 setSampleMethod.invoke(obj1, obj2); 23 } catch (Exception e) { 24 e.printStackTrace(); 25 } 26 } 27 }
运行输出:com.example.Sample@7852e922
6.2 网络类加载器
下面将通过一个网络类加载器来说明如何通过类加载器来实现组件的动态更新。即基本的场景是:Java 字节代码(.class)文件存放在服务器上,客户端通过网络的方式获取字节代码并执行。当有版本更新的时候,只需要替换掉服务器上保存的文件即可。通过类加载器可以比较简单的实现这种需求。
类 NetworkClassLoader负责通过网络下载Java类字节代码并定义出Java类。它的实现与FileSystemClassLoader类似。
1 package classloader; 2 3 import java.io.ByteArrayOutputStream; 4 import java.io.InputStream; 5 import java.net.URL; 6 7 public class NetworkClassLoader extends ClassLoader { 8 9 private String rootUrl; 10 11 public NetworkClassLoader(String rootUrl) { 12 // 指定URL 13 this.rootUrl = rootUrl; 14 } 15 16 // 获取类的字节码 17 @Override 18 protected Class<?> findClass(String name) throws ClassNotFoundException { 19 byte[] classData = getClassData(name); 20 if (classData == null) { 21 throw new ClassNotFoundException(); 22 } else { 23 return defineClass(name, classData, 0, classData.length); 24 } 25 } 26 27 private byte[] getClassData(String className) { 28 // 从网络上读取的类的字节 29 String path = classNameToPath(className); 30 try { 31 URL url = new URL(path); 32 InputStream ins = url.openStream(); 33 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 34 int bufferSize = 4096; 35 byte[] buffer = new byte[bufferSize]; 36 int bytesNumRead = 0; 37 // 读取类文件的字节 38 while ((bytesNumRead = ins.read(buffer)) != -1) { 39 baos.write(buffer, 0, bytesNumRead); 40 } 41 return baos.toByteArray(); 42 } catch (Exception e) { 43 e.printStackTrace(); 44 } 45 return null; 46 } 47 48 private String classNameToPath(String className) { 49 // 得到类文件的URL 50 return rootUrl + "/" 51 + className.replace('.', '/') + ".class"; 52 } 53 }
在通过NetworkClassLoader加载了某个版本的类之后,一般有两种做法来使用它。第一种做法是使用Java反射API。另外一种做法是使用接口。 需要注意的是,并不能直接在客户端代码中引用从服务器上下载的类,因为客户端代码的类加载器找不到这些类。使用Java反射API可以直接调用Java类的方法。而使用接口的做法则是把接口的类放在客户端中,从服务器上加载实现此接口的不同版本的类。在客户端通过相同的接口来使用这些实现类。我们使用接口的方式。示例如下:
客户端接口:
1 package classloader; 2 3 public interface Versioned { 4 5 String getVersion(); 6 }
1 package classloader; 2 3 public interface ICalculator extends Versioned { 4 5 String calculate(String expression); 6 }
网络上的不同版本的类:
1 package com.example; 2 3 import classloader.ICalculator; 4 5 public class CalculatorBasic implements ICalculator { 6 7 @Override 8 public String calculate(String expression) { 9 return expression; 10 } 11 12 @Override 13 public String getVersion() { 14 return "1.0"; 15 } 16 17 }
1 package com.example; 2 3 import classloader.ICalculator; 4 5 public class CalculatorAdvanced implements ICalculator { 6 7 @Override 8 public String calculate(String expression) { 9 return "Result is " + expression; 10 } 11 12 @Override 13 public String getVersion() { 14 return "2.0"; 15 } 16 17 }
在客户端加载网络上的类的过程:
1 package classloader; 2 3 public class CalculatorTest { 4 5 public static void main(String[] args) { 6 String url = "http://localhost:8080/ClassloaderTest/classes"; 7 NetworkClassLoader ncl = new NetworkClassLoader(url); 8 String basicClassName = "com.example.CalculatorBasic"; 9 String advancedClassName = "com.example.CalculatorAdvanced"; 10 try { 11 Class<?> clazz = ncl.loadClass(basicClassName); // 加载一个版本的类 12 ICalculator calculator = (ICalculator) clazz.newInstance(); // 创建对象 13 System.out.println(calculator.getVersion()); 14 clazz = ncl.loadClass(advancedClassName); // 加载另一个版本的类 15 calculator = (ICalculator) clazz.newInstance(); 16 System.out.println(calculator.getVersion()); 17 } catch (Exception e) { 18 e.printStackTrace(); 19 } 20 } 21 22 }
参考
jvm之java类加载机制和类加载器(ClassLoader)的详解
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· Qt个人项目总结 —— MySQL数据库查询与断言