第七章 虚拟机加载机制
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
7.2 类加载机制
一个类型从被加载到虚拟机内存中开始,到卸载到内存为止,整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中(验证、准备、解析)三个部分统称为连接。
对于初始化阶段,《虚拟机规范》严格规定只有6种情况必须立即对类进行初始化,而加载、验证和准备需要在此之前开始。
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果没有进行过初始化,则需要先触发初始化。能够生成四条指令的场景
- 使用new关键字实例化对象
- 读取或者设置一个类型的静态字段(被final修饰、在编译期将结果放到常量池的静态字段除外)的时候
- 调用一个类型的静态方法的时候
- 使用类型进行反射调用的时候
- 在初始化类,优先初始化父类
- 当虚拟机启动时,先初始化主类(包含main()方法的那个类)
- 如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStaic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,先触发方法句柄对应类的初始化
- 当一个接口定义了JDK8加入的默认方法(被default关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那么该接口要在此之前被初始化
反例:
(1)通过子类引用父类的静态字段,不会导致子类的初始化
(2)通过数组定义引用类,不会触发此类的初始化
(3)常量(static和final修饰)在编译阶段会存入调用类的常量池,不会触发定义常量类的初始化
(4)接口的初始化与类不同的地方在于第三点,初始化一个接口时,不会要求父接口全部初始化完成,只有真正用到父接口时才会初始化,譬如引用接口定义的常量
7.3 类加载的过程
类加载的过程分为:加载、验证、准备、解析和初始化五个阶段
7.3.1 加载
- 通过一个类的全限定名获取定义此类的二进制字节流
- 将这个字节流代表的静态存储结构转化为方法区运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
7.3.2 验证
这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求。验证阶段大致分为:文件格式验证、元数据验证、字节码验证和符号引用验证
1. 文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。包括但不限于是否以魔数0xCAFEBABE开头、主次版本号是否在当前虚拟机的接受范围内等。通过验证后,字节流进入虚拟机内存的方法区进行存储,后续的验证都是基于方法区的存储结构。
2. 元数据验证
对字节码描述的信息进行语义分析,保证其描述的信息符合《Java虚拟机规范》。包括但不限于是否继承了不能被继承的类(final修饰的类)、如果该类不是抽象类,是否实现了父类或接口中要求实现的方法等。
3. 字节码验证
对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
4. 符号引用验证
这个验证发生在虚拟机将符号引用转化成直接引用的时候,确保解析行为能正常执行,譬如符号引用中通过字符串描述的全限定名是否能找到对应的类等。
7.3.3 准备
为类中定义的变量(即静态变量)分配内存并设置类变量初始值的阶段,不包括实例变量,这部分在对象实例化的时候随着对象一起分配在Java堆中。这里的初始值是逻辑上的初值,并不是在程序中定义的初值,这一部分在编译后放到了类的构造器方法中,在类的初始化阶段才会执行。有一种例外的情况,如果这个类变量的字段属性表中存在ConstantValue属性,那么这里直接赋值成ConstantValue记录的值。
7.3.4 解析
将符号引用替换成直接引用的过程。大部分情况只需要解析一次,此后解析结果幂等,特殊情况是invokedynamic指令,每次都会重新解析。
1. 类或接口的解析
当前代码所处类D,将一个从未解析过的符号引用N解析为一个类或接口C的直接引用:
- 如果C不是数组,虚拟机会将代表N的全限定名传给传给类D的类加载器区加载这个类。
- 如果C是数组,且数组的元素类型为对象,会按照1加载这个类,接着虚拟机生成一个代表该数组维度和元素的数组对象。
- 最后还会进行符号引用验证,确认D是否具备了对C的访问权限。
2. 字段解析
根据字段表记录找到这个字段所属的类或者接口C的符号引用并解析,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:
- 如果C本身包含了简单名称和字段描述符与目标匹配的字段,直接返回这个字段的直接引用
- 否则,如果C实现了接口,按照继承关系,从下往上递归搜索各个接口及父接口
- 否则,如果C不是java.lang.Object类,按照继承关系从下往上递归搜索父类
- 查找失败的话,返回java.lang.NoSuchFieldError字段
如果有一个同名字段同时出现在某个类的接口或者父类当中,或者同时在自己或父类的多个接口中,Java编译器可能直接拒绝编译为Class文件。
3. 类方法解析
根据方法表记录找到这个方法所属的类C的符号引用,并解析,在该类和父类中搜索,需要注意的是,会校验这个方法所属的必须是一个类。
4. 接口方法解析
类似类方法解析,会校验这个方法所属的必须是一个接口。
7.3.5 初始化
执行类构造器<clinit>()方法。
- <clinit>()方法是编译器收集类中所有类变量赋值动作和静态语句块中语句合并产生的,收集顺序和源码中出现的顺序一致。
- Java虚拟机会保证父类的<clinit>()方法先执行完毕,即父类中定义的静态语句块要优先于子类的变量赋值。
- 如果一个类没有静态语句块,也没有对变量的赋值操作,编译器可以不生成<clinit>()方法
- 接口可以有静态变量的赋值操作,与类不同的是,只有当父接口的变量被用到时,父接口才会初始化。接口的实现类在初始化时,也不会执行接口的<clinit>()方法。
- 虚拟机保证类的<clinit>()方法在多线程下正确地加锁同步。
实例构造器<init>()方法只有在初始化实例的时候才会执行。
public static void main(String[] args) { Test.a = "1"; //执行类构造器方法 Test test = new Test(); //执行实例构造器方法,若没有初始化过类,还会执行类构造器方法 } static class Test { public static String a = "a"; static { System.out.println("类构造方法执行中"); } Test() { System.out.println("实例构造方法执行中"); } }
7.4 类加载器
类加载器实现通过一个类的全限定名获取该类的二进制字节流的功能。
7.4.1 类与类加载器
类在虚拟机中的唯一性是由类本身和加载这个类的类加载器确定的。一个Class文件,被两个类加载器加载产生的两个类,必定不相等。相等的判断包括类的Class对象的equals()方法、isInstance()方法的返回结果等。
7.4.2 双亲委派模型
- 启动类加载器(Bootstrap Class Loader) 虚拟机的一部分,由C++实现,负责加载负责加载<JAVA_HOME>\lib目录中或被-Xbootclasspath指定路径中的类。
- 扩展类加载器(Extension Class Loader)负责加载<JAVA_HOME>\lib\ext目录中或者被java.ext.dirs系统变量指定的路径中所有的类库。
- 应用程序类加载器(Application Class Loader)负责加载用户类路径(ClassPath)上所有的类库。
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 thrown if class not found // from the non-null parent class loader } //如果没有加载成功 if (c == null) { 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; } }
双亲委派:加载类优先让父类加载,不存在父类让启动类加载器加载,都无法加载才使用自定义加载器加载。
双亲委派的优势:1. 避免篡改重要的类 2. 避免重复加载
系统自带的加载器,加载特定目录下的类。避免我们篡改一个基础类(完整的类名由包名和类名组成),比如自定义java.lang.String,加载时交给最上层的启动类加载器根据类的全限定名去指定目录加载,启动类加载器从目录下的rt.jar中加载JDK中的java.lang.String类,从而导致自定义的String类不会被加载。即使我们自定义类加载,并改写loadClass方法,直接加载我们定义的String类,也会被JVM的校验拦住
7.4.3 破坏双亲委派机制
1. 向前兼容,JDK1.2版本加入双亲委派机制,在此之前,自定义类加载器直接重写loadClass方法。
2. 线程上下文类加载器,解决基础类调用用户代码。SPI接口由启动类加载器加载,而SPI实现类由系统类加载器加载,在SPI接口中又用到其实现类,而根据双亲委派规则,启动类加载器属于最顶层加载器无法调用系统类加器加载。解决办法是在线程中设置一个类加载器引用,默认是系统类加载器,加载SPI接口中的实现类时,使用线程上下文类加载器加载。
3. 热部署,修改功能后不需要重启,可以直接生效。将原始类和其类加载器一起卸载。
7.4.4 Tomcat类加载器
1. 相同类库,不同版本需要独立加载。同一个容器部署两个应用,依赖不同的spring版本
2. 不用应用相同版本的类库可以共享
3. 容器依赖的类库和容器部署的应用依赖的类库独立加载
4. JSP文件修改后不需要重启就生效
双亲委派不适用1,3,4。
-
- Common类加载器:Tomcat中最基本类加载器,加载路径的class对所有加载器可见。
- Catalina类加载器:Tomcat私有类加载器,加载路径的class对容器中应用不可见。实现上面第3点
- Shared类加载器:各应用共享的类加载器,加载路径的class对所有应用可见,对容器不可见。实现第2点
- WevApp类加载器:各个应用私有类加载器。实现第1点
- Jsp类加载器。实现第4点,当Jsp文件修改后,会创建新的Jsp类加载器替换旧的加载器
Tomcat不遵守双亲委派,对于基础类,会使用系统类加载器加载,此外,每个应用优先使用自己的webApp类加载器加载,若不能加载,则委托common类加载器加载。
参考:
类加载器部分:https://www.cnblogs.com/fanguangdexiaoyuer/p/10213324.html