深入理解JVM(3)——类加载机制
1、类加载时机
类的整个生命周期包括了:加载( Loading )、验证( Verification )、准备( Preparation )、解析( Resolution )、初始化( Initialization )、使用( Using )和卸载( Unloading )七个阶段。其中验证、准备和解析三个部分统称为连接( Linking ),这七个阶段的发生顺序如下图。
上图中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序开始。但解析阶段不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或晚期绑定)。这里是按顺序“开始”,而不是“进行”或“完成”,是因为这些阶段通常都是互相交叉地混合式进行的,通常再一个阶段 执行的过程中调用或激活另外一个阶段。
1.1、类的初始化时机
虚拟机规范严格规定了只有四种情况(主动引用)必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)。所有的 Java 虚拟机实现必须在每个类或接口被java程序“首次主动引用”时才初始化他们。
主动引用
- 遇到 new、getstatic、putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的常见Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的,如果其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,被标明为启动类的类(包含 main() 方法的那个类)。
被动引用
除了以上四种情况,其他使用 Java 类的方式都被看作是对类的被动使用,都不会导致类的初始化。
不属于主动使用的几种情况
1、当final 变量是基本数据类型以及 String 类型时,如果在编译期间能知道它的确切值,则编译器会把它当做编译期常量使用。静态变量是编译时的常量(即2.3准备阶段的中带 ConstantValue 属性的类变量),在使用这个静态变量的时候,就不会进行初始化。
import java.util.Random; class FinalTest { public static final int X = 6 / 3; public static final int Y = new Random().nextInt(100); static { System.out.println("FinalTest static block"); } } public class test { public static void main(String[] args) { System.out.println(FinalTest.X); //执行该句,没有打印静态块内输出语句 System.out.println(FinalTest.Y); //执行该句,打印了静态块内输出语句 } }
2、用子类去调用父类的静态变量时,子类不会被初始化。只有当程序访问的静态变量或静态方法确实在当前类或当前接口中定义时,才可以认为是对类或接口的主动使用。
class Parent { static int a = 3; static { System.out.println("Parent static block"); } static void doSomething() { System.out.println("do something"); } } class Child extends Parent { static { System.out.println("Child static block"); } } public class Test { public static void main(String[] args) { System.out.println(Child.a); Child.doSomething(); } } //输出结果,子类没有被初始化 //Parent static block //3 //do something
3、调用 ClassLoader 类的 loadClass 方法加载一个类,并不是对类的主动使用,不会导致类的初始化。
class C { static { System.out.println("Class C"); } } public class Test { public static void main(String[] args) throws Exception { // 获得系统类加载器 ClassLoader loader = ClassLoader.getSystemClassLoader(); Class<?> clazz = loader.loadClass("com.boomoom.classLoader.C"); System.out.println("---------------------"); clazz = Class.forName("com.boomoom.classLoader.C";) } }
2、类加载过程
类加载的全过程:加载、验证、准备、解析和初始化这五个阶段。其中的验证、准备和解析三个阶段被合称为连接阶段。
2.1、加载
“加载”阶段是“类加载”过程的一个阶段。在加载阶段你,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口。
其中二进制字节流可以从以下方式中获取:
- 从本地系统中直接读取
- 通过网络下载获取 .class 文件
- 从 zip,jar 等归档文件中读取 .class 文件
- 从专有数据库中读取 .class 文件
- 将 Java 源文件动态编译生成为 .class 文件
加载阶段完成,虚拟机外部的二进制字节流就存储在方法区之中。然后在内存中实例化一个 java.lang.Class 类的对象(多数情况改对象存放在堆中注1),Class 对象封装了类在方法区内的数据结构,这个对象将作为程序访问方法区中的这些类型数据的外部接口(反射接口)。
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
类加载器并不需要等到某个类被“首次主动使用”时再加载它。JVM 规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了 .class 文件缺失或存在错误 ,类加载器必须在程序首次主动使用该类时才报告错误( LinkageError 错误)。如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
2.2、验证
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。Java 语言本身是相对安全的语言,但 Class 文件可以使用任何途径产生。 所以虚拟机需要检查输入的字节流,避免因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、 字节码验证、 符号引用验证。
1.文件格式验证
第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不再直接操作字节流。
2.元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。
- 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、 方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
3.字节码验证
在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中。
- 保证跳转指令不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、 完全不相干的一个数据类型,则是危险和不合法的。
如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
4.符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。 符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
- 符号引用中的类、 字段、 方法的访问性(private、 protected、 public、 default)是否可被当前类访问。
符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError 异常的子类,如 java.lang.IllegalAccessError、 java.lang.NoSuchFieldError、 java.lang.NoSuchMethodError 等。
对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、 但不是一定必要(因为对程序运行期没有影响)的阶段。 如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经被反复使用和验证过,那么在实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
2.3、准备
在准备阶段,Java 虚拟机为类的静态变量在方法区分配内存,并设置默认的初始值,这个初始值“通常情况”下是数据类型的零值。
例如对于以下 Sample 类,在准备阶段,将为int类型的静态变量a分配4个字节的内存空间,并且赋予默认值0,为 long 类型的静态变量b分配8个字节的内存空间。并且赋予默认值0。
在准备阶段这时候尚未开始执行任何 Java 方法,把变量a、b赋值为1、2的 putstatic 指令是程序被编译后,存放于类构造器 <clinit>() 方法之中,所以把类变量a、b赋值为1、2的动作将在初始化阶段才会执行。
public class Sample { private static int a = 1; public static long b; static { b = 2; } ... }
“特殊情况”下的初始值:如果类字段属性表中存在 ConstantValue 属性,那在准备阶段 value 就会被初始化为 ConstantValue 属性所指定的值。例如:
public static final int value = 123;
编译时Javac将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为123。
2.4、解析
在解析阶段,Java 虚拟机会把类的二进制数据的常量池中的符号引用替换为直接引用。例如在 Worker 类的 gotoWork() 方法中会引用 Car 类的 run() 方法。
public void gotoWork() { car.run(); //这段代码在Worker类的二进制数据中表示为符号引用 }
在 Worker 类的二进制数据中,包含了一个对 Car 类的 run() 方法的符号引用,它由 run() 方法的全名和相关描述符号组成。在解析阶段,Java 虚拟机会把这个符号引用替换为一个指针,该指针指向 Car 类的 run() 方法在方法区内的内存位置,这个指针就是直接引用。
符号引用(Symbolic References)
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
直接引用(Direct References)
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
2.5、初始化
类初始化阶段是类加载过程的最后一步,到了该阶段,才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据 Java 代码去初始化类变量和其他资源,或者说:初始化阶段是执行类构造器 <clinit>() 方法的过程。<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定,如下例2.5-1为初始化顺序测试。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如例子2.5-2所示。
class Singleton { /** * 语句1在2、3前,输出结果为 counter1 = 1 counter2 = 0 * 语句1在2、3后,输出结果为 counter1 = 1 counter2 = 1 */ private static Singleton singleton = new Singleton(); //声明语句赋值1 public static int counter1; //声明语句2 public static int counter2 = 0; //声明语句赋值3 prviate Singleton() { counter1++; counter2++; } public static Singleton getInstance() { return singleton; } } public class MyTest { public static void main(String[] args) { Singleton singleton = Singleton.getInstance(); System.out.println("counter1 = " + singleton.counter1); System.out.println("counter2 = " + singleton.counter2); } }
public class test { static { i = 0; //给变量赋值可以编译通过 System.out.println(i); //这句编译器会提示"非法向前引用" } static int i = 1; }
<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如在例子2.5-3中,字段B的值将会是2而不是1。
static class Parent { public static int a = 1; static { a = 2; } } static class Sub extends Parent { public static int b = a; } public static void main(String[] args) { System.out.println(Sub.b); }
<clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。
虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。当 Java 虚拟机初始化一个类时,要求它的所以父类都已经被初始化,但是这个规则不适用于接口。
- 在初始化一个类时,不会先初始化它所实现的接口。
- 在初始化一个接口时,不会先初始化它的父接口。
因此,一个父接口不会因为它的子接口或实现类的初始化而初始化。只有当程序首次使用特定接口的静态变量时,才会导致该接口的初始化。
3、类加载器
类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作是在Java虚拟机的外部实现的,实现这个动作的代码模块称为“类加载器”。类加载器在类层次划分、 OSGi、 热部署、 代码加密等领域被广泛运用。
类加载器虽然只用于把类加载到 Java 虚拟机中,但它在 Java 程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
按定义方式,类加载器可分为 Java 虚拟机自带的加载器和用户自定义的类加载器两种。
3.1、Java虚拟机自带的加载器
根(Bootstrap)类加载器
该加载器雷友父加载器。它负责加载虚拟机的核心类库,如java.lang.*等。根类加载器从系统属性sun.boot.class.path 所指定的目录中加载类库。根类加载器的实现依赖底层操作系统,属于虚拟机的实现的一部分,它并没有继承java.lang.ClassLoader 类。
扩展(Extension)类加载器
它的父加载器为根类加载器。它从java.ext.dirs系统属性所指定的目录中加载类库,或者从JDK的安装目录的jre\lib\ext子目录(扩展目录)下加载类库,如果把用户创建的JAR文件也放在这个目录下,也会自动由扩展类加载器加载。扩展类加载器是纯Java类,是java.lang.ClassLoader类的子类。
系统(System)类加载器
也称为应用类加载器,它的父加载器为扩展类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中加载类,它是用户自定义的类加载器的默认父加载器。系统类加载器是纯Java类,是java.lang.ClassLoader类的子类。
除了以上虚拟机自带的加载器以外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器应该继承ClassLoader类。
3.2、父类委托机制
类加载器用来把类加载到Java虚拟机中。从JDK 1.2版本开始,类的加载过程采用父类委托机制,这种机制能更好地保证Java平台的安全。在此委托机制中,除了Java虚拟机自带的根类加载器以外,其余的类加载器都有且只有一个父加载器。当Java程序请求加载器loader1加载Sample类时,loader1首先委托自己的父加载器去加载Sample类,若父加载器能加载,则由父加载器完成加载任务,父加载器加载不了,还往上委托。都加载不了才由加载器loader1本身加载Sample类。
Class sampleClass = loader2.loadClass("Sample");
loader2 首先从自己的命名空间中查找 Sample 类是否已经被加载,如果已经加载,就直接返回代表 Sample 类的 Class 对象的引用。
如果 Sample 类还没有被加载,loader2 首先请求 loader1 代为加载,loader1 再请求系统类加载器代为加载,系统类加载器再请求扩展类加载器代为加载,扩展类加载器再请求根类加载器代为加载。若根类加载器不能加载,则再反向逐层尝试加载,若某个加载器能加载成功,则将Sample类对应的Class对象的引用逐层返回给 loader2,从而成功将 Sample 类加载进虚拟机。若所有的父加载器及loader2本身都不能加载,则抛出 ClassNotFoundException 异常。
若一个类加载器能成功加载 Sample 类,那么这个类加载器被称为定义类加载器,所有能成功返回 Class 对象的引用的类加载器(包括定义类加载器)都被称为初始类加载器。
假设 loader1 实际加载了Sample类,则loader1位 Sample 类的定义类加载器,loader2 和 loader1 位 Sample 类的初始化类加载器。
3.3、父子加载器
父子加载器关系实际上指的是加载器对象之间的包装关系(构造方法中),而非类之间的继承关系,也就是说子加载器不一定是继承了父加载器。
在父亲委托机制中,各个加载器按照父子关系形成树形结构,除了根类加载器以外,其余的类加载器都有且只有一个父加载器。
当生成一个自定义的类加载器实例时,如果没有指定他的父加载器,那么系统类加载器就自动成为该类加载器的父加载器。
在子加载器对象中包装了一个父加载器对象。例如以下loader1和loader2都是MyClassLoader类的实例,并且loader2包装了loader1,loader1是loader2的父类加载器。 ClassLoader Loader1 = new MyClassLoader();
// 参数loader1将作为loader2的父加载器
ClassLoader loader2 = new MyClassLoader(Loader1);
3.4、定义类加载器
如果某个类加载器能够加载一个类,那么该类加载器就称作定义类加载器。定义类加载器及其所有能返回Class对象的引用的子加载器都称作初始类加载器。
3.5、命名空间
每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成。在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类;在不同的命名空间中,有可能出现类的完整名字(包括类的包名)相同的两个类。
3.6、运行时包
由同一个类加载器加载的属于相同包的类组成了运行时包。决定两个类是不是属于同一个运行时包,不仅要看他们的包名是否相同,还要看定义类加载器是否相同。只有属于同一运行包的类才能互相访问包可见(即默认访问级别)的类和类成员。这样的限制能避免用户自定义的类冒充核心类库的类,去访问核心类库的包可见成员。假设用户自己定义了一个类java.lang.Spy,并由用户自定义的类加载器加载,由于java.lang.Spy和核心类库java.lang.*由不同的加载器加载,它们属于不同的运行时包,所以java.lang.Spy不能访问核心类库ava.lang包中的包可见成员。
3.7、创建用户自定义的类加载器
要创建用户自己的类加载器,只需要扩展java.lang.ClassLoader类,然后覆盖它的findClass(String name)方法即可,该方法根据参数指定的类的名字,返回对应的Class对象的引用。
public class MyClassLoader extends ClassLoader { private String name; private String path = "d:\\"; private final String fileType = ".class"; public MyClassLoader(String name) { super(); this.name = name; } public MyClassLoader(ClassLoader parent, String name) { super(parent); this.name = name; } @Override pbulic String toString() { return this.name; } public String getPath() { return path; } public void setPath (String path) { this.path = path; } @Override public Class<?> findClass(String name) throws ClassNotFoundException { byte[] data = this.loadClassData(name); return this.defineClass(name, data, 0, data.length); } private byte[] loadClassData(String name) { InputStream is = null; byte[] data = null; ByteArrayOutputStream baos = null; try { this.name = this.name.replace(".", "\\"); is = new FileInputStream(new File(path + name + fileType)); baos = new ByteArrayOutputStream(); int ch = 0; while(-1 != (ch = is.read())) { baos.write(ch); } data = baos.toByteArray(); } catch(Exception ex) { ex.printStackTrace(); } finally { try { is.close(); baos.close(); } catch(Exception ex) { ex.printStackTrace(); } } return data; } public static void main(String[] args) { MyClassLoader loader1 = new MyClassLoader("loader1"); loader1.setPath("d:\\myapp\\serverlib\\"); MyClassLoader loader2 = new MyClassLoader(loader1, "loader2"); loader2.setPath("d:\\myapp\\clientlib\\"); MyClassLoader loader3 = new MyClassLoader(null, "loader3"); loader3.setPath("d:\\myapp\\otherlib\\"); test(loader2); test(loader3); } public static void test(ClassLoader loader) throws Exception { Class clazz = loader.loadClass("com.boomoom.loader.Sample"); Object object = clazz.newInstance(); } } public class Sample { public int v1 = 1; public Sample() { System.out.println("Sample is loaded by: " + this.getClass().getClassLoader()); new Dog(); } } public class Dog { public Dog() { System.out.println("Dog is loaded by : " + this.getClass().getClassLoader()); } }
在上图中的serverlib和otherlib文件夹都放了Sample和Dog的class文件。在d:\myapp\syslib的目录下放入MyClassLoader.class文件,指定该目录为classPath。执行3.7-1自定义类加载器测试代码进行测试,得到的输出为:
Sample is loaded by : loader1
Dog is loaded by : loader1
Sample is loaded by : loader3
Dog is loaded by : loader3
若同时把d:\myapp\serverlib也设为classPath注2,那么输出为:
Sample is loaded by : sun.misc.Launcher$AppClassLoader@25317f
Dog is loaded by : sun.misc.Launcher$AppClassLoader@25317f
Sample is loaded by : loader3
Dog is loaded by : loader3
(1) 当执行loader2.loadClass("Sample")时,先由它上层的所有父加载器尝试加载Sample类。loader1从D:\myapp\serverlib目录下成功加载了Sample类,因此loader1是Sample类的定义类加载器,loader1和loader2是Sample类的初始类加载器。
当执行loader3.loaderClass("Sample")时,先由它上层的所有父加载器尝试加载Sample类。loader3的父加载器为根类加载器,它无法加载Sample类,接着loader3从D:\myapp\otherlib目录下成功地加载了Sample类,因此loader3是Sample类的定义类加载器及初始类加载器。
(2) 在Sample类中主动使用了Dog类,当执行Sample类的构造方法中的new Dog()语句时,Java虚拟机需要加载Dog类,从步骤(1)的输出1结果可以看出,加载Sample类的loader1还加载了Dog类,Java虚拟机会用Sample类的定义类加载器去加载Dog类,加载过程也同样采用父亲委托机制。进行测试:把D:\myapp\serverlib目录下的Dog.class文件删除,然后在D:\myapp\syslib目录下存放一个Dog.class文件,这样的打印结果就是:
Sample is loaded by loader1
Dog is loaded by sum.misc.Launcher$AppClassLoader@id3425
Sample is loaded by loader3
Dog is loaded by loader3
由此可见,当由loader1加载的Sample类首次主动使用Dog类时,Dog类由系统类加载器加载。如果把D:\myapp\serverlib和D:\myapp\syslib目录下的Dog.class文件都删除,再在D:\myapp\clientlib目录下存放一个Dog.class文件。当由loader1加载的Sample类首次主动使用Dog类时,由于loader1及它的父加载器都无法加载Dog类,因此test(loader2)方法会跑出ClassNotFoundException。
3.8、不同类加载器的命名空间关系
同一个命名空间的类相互可见。子加载器的命名空间包含所有父加载器的命名空间。因此由子加载器加载的类能看见父加载器加载的类。由父加载器加载的类不能看见子加载器加载的类。如果两个加载器之间没有直接或间接的父子关系,那么它们各自加载的类相互不可见。测试代码如下,修改MyClassLoader的main方法:
public static void main(String[] args) { MyClassLoader loader1 = new MyClassLoader("loader1"); loader1.setPath("d:\\myapp\\serverlib\\"); MyClassLoader loader2 = new MyClassLoader(loader1, "loader2"); loader2.setPath("d:\\myapp\\clientlib\\"); Class clazz = loader1.loaderClass("Sample"); Object object = clazz.newInstance(); Sample sample = (Sample)object; System.out.println(sample.v1); }
再把Sample.class和Dog.class仅仅拷贝到D:\myapp\serverlib目录下。MyClassLoader类由系统类加载器加载,而Sample类由loader1类加载,因此MyClassLoader类看不见Sample类。在MyClassLoader类的main()方法中使用Sample类,会导致NoClassDefFoundError错误。
当两个不同命名空间内的类相互不可见时,可采用Java反射机制来访问对方实例的属性和方法,测试代码如下,修改MyClassLoader的main方法:
public static void main(String[] args) { MyClassLoader loader1 = new MyClassLoader("loader1"); loader1.setPath("d:\\myapp\\serverlib\\"); MyClassLoader loader2 = new MyClassLoader(loader1, "loader2"); loader2.setPath("d:\\myapp\\clientlib\\"); Class clazz = loader1.loaderClass("Sample"); Object object = clazz.newInstance(); Filed filed = clazz.getField("v1"); int v1 = filed.getInt(object); System.out.println("v1: " + v1); }
4、类的卸载
当Sample类被加载、连接和初始化后,它的生命周期就开始了。当代表Sample类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,Sample类在方法区的数据也会被卸载,从而结束Sample类的生命周期。由此可见,一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。
由于Java虚拟机自带的类加载器(根、扩展和系统)所加载的类,在虚拟机的生命周期中,始终不会被卸载。Java虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的Class对象,因此这些Class对象始终是可触及的。由用户自定义的类加载器所加载的类是可以被卸载的。
修改3.7中的例子进行测试,修改后的MyClassLoader的main方法如下:
public static void main(String[] args) { MyClassLoader loader1 = new MyClassLoader("loader1"); //1 loader1.setPath("d:\\myapp\\serverlib\\"); //2 Class clazz = loader1.loaderClass("Sample"); //3 System.out.println(clazz.hashCode()); //4 Object object = clazz.newInstance(); //5 loader1 = null; //6 clazz = null; //7 object = null; //8 loader1 = new MyClassLoader("loader1"); //9 loader1.setPath("d:\\myapp\\serverlib\\"); //10 clazz = loader1.loadClass("Sample"); //11 System.out.println(clazz.hashCode()); //12 }
运行以上程序,Sample类由loader1加载。在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另一方面,一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法,就能获得它的类加载器。由此可见,代表Sample类的Class实例与loader1之间为双向关联关系。
一个类的实例总是引用代表这个类的Class对象。在Object类中定义了getClass()方法,这个方法返回代表对象所属类的Class对象的引用。此外,所有的Java类都有一个静态属性class,它引用代表这个类的Class对象。
执行完第5步,引用变量和对象间的关系如上图。从图可以看出,loader1变量和obj变量间接引用代表Sample类的Class对象,而objClass变量则直接引用它。当程序执行完第8步是,所有的引用变量都置为null,此时Sample对象结束生命周期,MyClassLoader对象结束生命周期,代表Sample类的Class对象也技术生命周期,Sample类在方法区的二进制数据被卸载。当程序执行完第10步时,Sample类又重新被加载,在Java虚拟机的堆区会生成一个新的代表Sample类的Class实例。
3646548
Sample is loaded by loader1
Dog is loaded by loader1
5864867
以上程序的打印结果显示,两次打印的objClass变量引用的Class对象的哈希码,数值不同。因此objClass变量两次引用不同的Class对象,那即表示在Java虚拟机的生命周期中,对Sample类先后加载了两次。
注释:
1、Class对象并没有明确规定是在Java堆中,对于jdk1.6中的HotSpot虚拟机而言,Class对象存放在方法区,而jdk1.7和jdk1.8所带的虚拟机,Class对象存放在堆中。
2、java -cp .;d:\myapp\serverlib MyClassLoader java -cp 后跟多路径以分号间隔,即可设置多个路径为classPath
参考:
1、周志明著《深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)》第七章
2、圣思园 张龙主讲 “深入Java虚拟机”