目录
什么是类加载
- 类加载指的是将类Class文件读入内存,并为之创建一个java.lang.Class对象,class文件被载入到了内存之后,才能被其它class所引用。
- JVM启动的时候,并不会一次性加载所有的class文件,而是根据需要去动态加载。
- 类加载器是jre的一部分,负责动态加载java类到java虚拟机的内存。
- 类的唯一性由类加载器和类共同决定。
双亲委派模型
在计算机体系中,程序如果想要运行,必须被加载到内存才能与CPU进行交互。JVM通过ClassLoader(类加载器)将字节码.class文件加载到内存中。在加载类时,使用的是Parents Delegation Model(双亲委派模型)。
双亲委派模型要求除顶层启动类加载器外其余类加载器都应该有自己的父类加载器。类加载器之间的关系不是继承而是组合,通过复用关系来复用父加载器的代码。
三层类加载器
- Bootstrap ClassLoader:加载存放在<JAVA_HOME>\lib中的类库。这里是最核心的Java类,例如Object、System、String等。
- Extension ClassLoader:加载存放在<JAVA_HOME>\lib\ext中扩展的系统类,例如XML、加密、压缩相关的功能等。
- Application ClassLoader:加载用户类路径Classpath上的类库。
工作过程
- 当Application ClassLoader 收到一个未知类的加载请求时,会执行loadClass方法,先判断这个类是否已经被加载过,如果没有,则交给它的parent(Extension ClassLoader)进行加载;
- Extension ClassLoader收到类加载请求后,同样执行loadClass的逻辑,如果这个类没有被加载过,它会继续向它的上级Bootstrap ClassLoader询问这个类是否被加载;
- Bootstrap ClassLoader收到类加载请求后,如果发现该类没有被加载过,此时它会尝试加载这个类;
- 如果Bootstrap ClassLoader加载失败(在<JAVA_HOME>\lib中未找到所需类),会抛出异常,下级Extension ClassLoader捕获异常后,自己尝试加载;
- 如果Extension ClassLoader也加载失败,与上面过程同理,由Application ClassLoader加载;
- 如果Application ClassLoader也加载失败,就会使用自定义加载器去尝试加载;
- 如果均加载失败,就会抛出ClassNotFoundException异常。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先检查请求的类是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的findClass方法进行类加载
long t1 = System.nanoTime();
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类伴随其类加载器具备了带有优先级的层次关系,确保了在各种环境下的加载顺序。 保证了运行的安全性,防止不可信类扮演可信任的角色。
自定义类加载器应用场景
- 隔离加载类。例如在某些框架内进行中间件与应用的模块隔离,把类加载到不同环境。
- 修改类加载方式。类的加载模型并非强制,除了Bootstrap ClassLoader外,其他的加载并非一定要引入。可以按需动态加载。
- 扩展加载源。例如从数据库、网络,甚至电视机机顶盒进行加载。
- 防止源码泄漏。Java代码容易被编译和篡改,可以通过自定义类加载器进行编译加密。
类加载时机
对类的主动引用会触发类的初始化:
- 使用new关键字实例化对象时,读取或设置一个类的静态字段时(被final修饰、已在编译期把结果放入常量池的静态字段除外),以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类初始化。
- 当虚拟机启动时,用户要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
类的被动引用不会触发初始化:
- 通过子类引用父类的静态字段,只会初始化父类,不会初始化子类。
- 通过数组定义来引用类,不会触发此类的初始化。例如:SuperClass[] sca = new SuperClass[10];
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。例如:public static final String HELLOWORLD = “hello world”;
- 一个接口在初始化时,并不要求其父类接口全部初始化完成,只有在真正用到父类接口时(如引用父类接口中的常量)才会初始化。
类加载过程
类加载是一个将.class字节码文件实例化成Class对象并进行相关初始化的过程。整个过程可分为三个步骤:Load、Link、Init,即加载、链接、初始化。
- 加载。Load阶段读取类文件产生二进制流,并转化为特定的数据结构,初步校验cafe babe魔法数、常量池、文件长度、是否有父类等,然后创建对应类的java.lang.Class实例。
- 链接。Link阶段又包括验证、准备、解析三个步骤。
- 验证。更详细的校验,比如final是否合规、类型是否正确、静态变量是否合理等;
- 准备。静态变量分配内存,并设定默认值;
- 解析。解析类和方法确保类与类之间的互相引用正确性,完成内存结构的布局。
- 初始化。Init阶段执行类构造器<clinit>方法,如果赋值运算是通过其他类的静态方法完成的,那么会马上解析另外一个类,在虚拟机栈中执行完毕后通过返回值进行赋值。
clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。static{}中只能访问定义在其之前的变量,定义在后面的变量只能赋值,不能访问。
父类的clinit()会优先于子类执行,虚拟机第一个被执行的clinit方法肯定是java.lang.Object类的。
接口中不能使用static{},但仍然有变量初始化的赋值操作,因此与类一样都会生成clinit方法,但与类不同,执行接口的clinit不需要先执行其父接口的,只有当父接口的变量被调用时,父接口才会初始化。
虚拟机会保证一个类的clinit方法在多线程环境下被正确的加锁、同步。
常见类加载异常
- ClassNotFoundException:当JVM要加载指定文件的字节码到内存时,文件不存在。
- NoClassDefFoundError:当时用new关键字、属性引用某个类、继承某个接口或类、方法的某个参数中引用了某个类,这时会触发JVM隐式加载这些类时发现该类不存在,引发该异常。
- ClassCastException:强制转换异常,如将String类型强制转换成Integer类型。
编译器、解释器、JIT即时编译器
编译器:将Java源文件(.java文件)编译成字节码文件(.class文件,是特殊的二进制文件,二进制字节码文件),这种字节码就是JVM的“机器语言”。javac.exe可以看成是Java编译器。
解释器:是JVM的一部分。Java解释器用来解释执行Java编译器编译后的程序。java.exe可以看成是Java解释器。JVM把每一条要执行的字节码交给解释器,翻译成对应的机器码,然后由解释器执行。JVM解释执行字节码文件就是JVM操作Java解释器进行解释执行字节码文件的过程。
JIT即时编译器(Just-in-time compilation: JIT):JIT是JRE的一部分。原本的Java程序都是要经过解释执行的,其执行速度肯定比可执行的二进制字节码程序慢。为了提高执行速度,引入了JIT。在运行时,JIT会把翻译过来的机器码保存起来,以备下次使用。而如果JIT对每条字节码都进行编译,则会负担过重,所以,JIT只会对经常执行的字节码进行编译,如循环,高频度使用的方法等。它会以整个方法为单位,一次性将整个方法的字节码编译为本地机器码,然后直接运行编译后的机器码。