《深入了解Java虚拟机》第七章

第七章 虚拟机类加载机制

7.1 概述

在Class中描述的信息最终都需要加载到虚拟机中之后才能被运行和使用。本章主要讲虚拟机如何加载class文件,class文件中的信息加载后会发生什么变化。
虚拟机加载机制:JVM把描述类的数据从Class文件加载进入内存,通过校验、转换解析、初始化,形成虚拟机能够直接使用的java类型的过程。
· 和其他在编译期间需要连接的语言不同,java语言在运行期间类型的加载、连接、初始化,所以java语言天生具有动态扩展性。例如接口可以在运行时再指定实现的实现类。还有现在可以在运行时,用户通过预置或自定义加载器,从网上直接加载一个二进制流作为代码一部分。这种动态组装应用广泛,从简单的JSP到复杂的OSGi技术,都是基于java语言在运行期间进行类加载的特性。
约定:
1. 每个Class文件都有代表一个接口或者类的可能,后文未明确指出,默认同时蕴含两种可能性。
2. 与上章一致,提到的Class文件并不是特指真的存储在磁盘中的文件,而是一串二进制字节流,存在形式不限。如磁盘文件,网络,数据库,内存,动态产生等。

7.2 类加载的时机

在这里插入图片描述

· 加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这个顺序开始(可能会在一个阶段执行时调用另一个阶段),但是解析阶段不一定:某些情况下可以在初始化之后开始,这是为了支持java运行时绑定特性(动态绑定)。
· 第一阶段加载开始时间由虚拟机的具体实现自由把握。不过初始化在开始了前三个阶段后,在面对以下六种情况(有且只有)必须立即对类初始化

  1. 遇到new、getstatic、putstatic、invokestatic这四个指令时,类型没有初始化。生成这四条指令的典型java代码场景:
    · 使用new实例化对象
    · 调用一个类型的静态方法
    · 读取或者设置一个类型的静态字段(被final修饰、已在编译期把结果放到常量池的静态字段除外)
  2. 使用java.lang.reflect的方法对类型进行反射调用的时,类型没有初始化。
  3. 虚拟机启动时,用户需要指定一个要执行的主类(main方法所在的类),虚拟机会先初始化这个主类
  4. 初始化类的时候,发现这个类的父类没有被初始化,先触发其父类的初始化
  5. 当一个接口在中定义了默认方法(被default修饰),如果由这个接口的实现类发生初始化,那该接口要在它之前被初始化。
  6. 当使用jdk7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化,先触发其初始化。
    这六种场景中的行为称为“对一个类型的主动引用”。除此之外的所有引用类型的方式都不会触发初始化,称为“被动引用”。
  • 对于静态字段,只有直接定义这个字段的类才会被初始化。
    例如通过子类来引用父类中定义的静态字段,只会触发父类初始化。是否触发子类加载和验证阶段,取决于虚拟机的具体实现。
  • 通过数组定义来引用类,不会触发此类的初始化。
    例如SuperClass[] sca = new SuperClass[10]; 不会触发类org.fenixsoft.classloading.SuperClass的初始化阶段,但会触发另一个名为“[Lorg.fenixsoft.classloading.SuperClass”的类的初始化阶段。这是一个由虚拟机自动生成的,直接继承于Object的子类,创建动作由newarray触发。这个类代表了一个元素类型为org.fenixsoft.classloading.SuperClass的一维数组,数组中应有的属性方法都在这个类中实现。因为越界检查封装在数据访问的指令中,所以比起c/c++直接翻译为指针的移动,java对数组访问更加安全。
  • A类引用B类的常量,并不会触发B类的初始化。因为在编译阶段通过常量传播优化,直接将要调用的常量值存储到A类的常量池中,之后对于这个常量的引用,都转换成对自身常量池的引用了。也就是说,当编译成class文件后,这两个类就不存在任何联系了。

7.3 类加载的过程

7.3.1 加载

步骤:

  1. 从类全限定名中获取这个类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转变为方法区的运行结构
  3. 在内存中生成一个代表该类的java.lang.Class对象,作为访问这个类的入口
    · 虚拟机实现的灵活度极大。如第一步只是说获取,获取的地方、方式都可以自由发挥:
  4. 从Zip压缩包中获取,如 JAR、WAR格式
  5. 从网络中获取,如Web Applet
  6. 运行时计算生成,如动态代理技术。用一个方法特定接口生成代理类的二进制字节流
    ……
    · 在整个类加载阶段,非数组类型的加载中获取二进制流的过程最具有可控性。加载阶段既可以使用JVM内置的引导类加载器完成,也可以使用自定义的类加载器(重写类加载器的findClass()、loadClass()方法)完成。
    · 对于数组类而言,情况有所不同。因为数组类本身不通过类加载器创建,而是JVM本身在内存中直接构造出来的。不过数组元素类型是需要靠类加载器完成的。
  • 如果数组的组件类型为引用类型,通过递归根据上面的加载过程加载出来的。数组将被标识在加载此组件类型的类加载器的类名称空间中。
  • 如果数组的组件类型不是引用类型,JVM将会把数组标记为与在引导类加载器关联。
    · 数组类的可访问性与数组组件类型一致,如果组件类型不是引用类型,默认public。
    · 加载阶段结束后,虚拟机外的二进制字节流就按照虚拟机设定的格式存储在方法区中了,方法区的数据存储格式是自定义的。
    · 通常来说加载阶段与连接阶段部分动作(如字节码文件格式验证)是交叉进行的,只需保持两个阶段固定的开始顺序即可。

7.3.2 验证

验证是连接阶段的第一步,为了保证class文件的字节流包含的信息符合《java虚拟机规范》的全部约束,防止字节码危害到虚拟机安全。
java语言本身是相对安全的,不能越界访问,跳转到不存在代码等(会抛出异常、拒绝编译)。但是由于class文件不止由java源文件编译而来,还可以从网上加载或者在二进制编辑器中得到二进制字节流,不安全的动作能够在字节码层实现。这会威胁到java虚拟机安全。所以验证阶段是必不可少的。2011年《java虚拟机规范》开始较为详细地建立了验证的约束与规则。验证阶段主要包括:

  1. 文件格式验证
    验证字节流是否符合class文件格式的规范,能否被当前版本的虚拟机处理。保证字节流正确解析存储在方法区内。
    验证内容包括且不限于:
    (1) 是否以魔术开头
    (2) 主、次版本号是否在当前JVM接受范围内
    (3) 常量池是否有不被支持的常量类型
    (4) 指向常量的各种索引值中是否有指向不存在或不符合类型常量
    ……
    只有这个阶段是基于二进制字节流进行的,通过这个阶段,字节流就能被允许进入JVM方法区中进行存储。之后三个验证阶段都是基于方法区的存储结构进行的。

  2. 元数据验证
    对类的元数据信息进行语义校验,保证描述的信息符合《java虚拟机规范》的要求。
    验证内容包括且不限于:
    (1) 这个类是否有父类(除了object类,其他都应该有父类)
    (2) 这个类是否继承了不被允许继承的类
    (3) 如果该类不是抽象类,是否实现了父类或接口要求实现的所有方法
    ……

  3. 字节码验证(最复杂)
    根据数据流和控制流分析,确保程序语义符合规范,就是检查方法体中进行校验分析,保证在运行期间不会出现对虚拟机危害的行为。
    验证内容包括且不限于:
    (1) 栈操作数据类型和指令代码序列时刻保持一致
    (2) 保证任何跳转指令不会跳转到方法体以外的字节码指令
    (3) 保证方法体中的类型转换都是有效的,例如把一个子类对象赋值给父类数据类型是安全的,反过来不行
    ……
    不能通过字节码验证的程序一定存在问题,但通过了不一定就肯定安全。
    数据流和控制流分析十分复杂耗时,JDK6后进行了优化,将尽可能多的校验措施挪到了javac编译器里进行。具体来说,在code的属性表中增加了一项“StackMapTable”属性,只需要检查该属性里面的记录是否合法即可,不需要再根据程序推导这些状态的合法性。即将字节码验证的类型推导变为类型检查。

  4. 符号引用验证
    最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作在连接的第三阶段——解析阶段中发生。符号引用验证,是对类自身以外的各类信息进行匹配性校验。看是否缺少或者禁止访问该类所依赖的外部类、方法、字段等。目的是为了确保解析行为能顺利进行。如果无法通过验证会报错。如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
    验证内容包括且不限于:
    (1) 符号引用中的全限定名是否能找到对应的类
    (2) 在指定类中是否存在符合方法描述符及简单名称所描述的方法、字段
    (3) 符号引用中类、方法、字段的可访问性是否能被当前类访问

7.3.3 准备

  • 只要是对象实例,必然会在java堆中分配。
  • 所有Class相关的信息都应该存放在方法区之中,但方法区的实现不同虚拟机可以灵活处理。JDK7以后Hotspot虚拟机选择把静态变量和类型在java语言一端的映射Class对象存放在一起,存储于java堆之中。

准备阶段是正式为类变量(静态成员变量)分配内存、设置初值的阶段。从逻辑概念上讲,这些变量是在方法区中分配的,jdk7及之前,Hotspot虚拟机使用永久代实现方法区,完全符合这种逻辑概念;但jdk7之后,类变量与class对象一起放在Java堆中,这时候方法区只是一个逻辑上的表述了。

注意:

  1. 准备阶段内存分配仅包括类变量,不包括实例变量,实例变量会在对象实例化的时候随对象一起分配在java堆中。
  2. 设置的初始值【一般为】数据类型的零值。例如public static int a = 123准备阶段后为0,而不是123.因为这时候还未开始执行任何java方法,把a赋值为123的putstatic指令是在程序被编译后,存放在类构造器()方法之中,所以要等到类初始化阶段才会被赋值为123。
    在这里插入图片描述

【特殊情况】:如果类字段的字段属性表中存在ConstantValue属性,那么编译时javac会为该变量生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue属性的设置将变量值赋值。例如public static final int a = 123准备阶段后为123。

7.3.4 解析

解析阶段是JVM将常量池内的符号引用替换为直接引用的过程。

· 符号引用:符号引用是用一组符号来描述所要引用的目标。符号可以是任意字面量。
各虚拟机能够接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。符号引用与虚拟机的内存布局无关,引用的目标也不一定是已加载到虚拟机中的内容。
· 直接引用:能够直接或者间接地定位到目标。
与虚拟机内存布局直接相关,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不同。引用的目标也一定已经加载到虚拟机中了。

解析阶段开始时间不固定,只是要求在执行getstatic、invokedynamic、instanceof等17个用于操作符号引用的字节码指令之前,对他们所使用的符号引用进行解析。所以虚拟机实现可以根据需要,在类被加载器加载时就对符号引用解析,还是等到一个符号引用被使用前去解析。

对同一个符号引用进行多次解析请求是很常见的事。
除了invokedynamic指令,虚拟机实现可以对第一次解析的结果进行缓存,从而避免解析动作重复进行。如果一个符号引用之前已经被成功解析过,那么之后的解析请求也会一直成功;如果第一次失败,其他指令对这个符号的解析请求也会失败。静态:刚完成加载阶段,还未开始执行代码就可以提前进行解析。
对于invokedynamic指令,当碰到前面已经由该指令触发过的符号引用,并不意味着这个解析结果对其他invokdynamic指令也同样生效。因为这个指令本身用于动态语言支持,它对应的引用称为“动态调用点限定符”。动态:当程序实际执行到这条指令时,解析动作才能进行。

解析动作主要针对:

符号引用 对应的常量池的常量类型
类或接口 CONSTANT_Class_info
字段 CON-STANT_Fieldref_info
类方法 CONSTANT_Methodref_info
接口方法 CONSTANT_InterfaceMethodref_info
方法类型 cCONSTANT_MethodType_info
方法句柄 CONSTANT_MethodHandle_info
调用点限定 CONSTANT_Dynamic_infoCONSTANT_InvokeDynamic_info
注:后四种与动态语言支持相关。
  1. 类或接口的解析
    假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或者接口C的直接引用,那虚拟机完成整个过程需要三步:
    1)如果C不是一个数组类型,那么虚拟机把代表N的全限定名传给D的类加载器去加载这个C。在加载过程中,由于元数据验证、字节码验证的需要,可能会触发其他相关类的加载动作,例如父类或者实现的接口。一旦加载过程出现异常,解析过程失败。
    2)如果C是一个数组类型,N的描述符类似“[Ljava/lang/Integer”形式,那么先按照规则1加载数组元素类型“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
    3)若1,2没有异常,C在虚拟机中实际上已经成为一个有效类或接口了,但解析完成前还需要进行符号引用验证,确定D是否具备访问C的权限,不具备抛出IllegalAccessError。JDK9引入模块化后,还需要检查模块间访问权限。

“D拥有C的访问权限”意味着以下三条中至少存在一条:

  • 被访问类C是public,并且与访问类D处于同一个模块
  • 被访问类C是public,与访问类D不处于同一个模块,但C允许D的模块进行访问
  • 被访问类C不是public,但与访问类D处于同一个包
  1. 字段解析
    要解析一个未被解析过的字段符号引用:
    1)对字段表内class_index项中的索引的CONSTANT_Class_info符号引用(就是字段所属类或接口C的符号引用)进行解析;
    2)如果这个解析过程出现异常,字段符号引用解析将失败;如果解析成功,对C进行后续字段的搜索:
     ① 如果C本身包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。
     ② 否则,如果C中实现了接口(或者C不是Object类),那么会向上递归查找各个接口(或父类),如果在接口(或父类)中包含了简单名称和字段描述符都与目标相匹配的字段,直接返回这个字段的直接引用,查找结束。
     ③否则,查找失败,抛出NoSuchFieldError异常。
     如果成功返回引用,将会对该字段进行权限验证,不具备抛出IllegalAccessError异常。

以上规则能够保证JVM获得字段唯一的解析结果。实际中,Javac编译器更严格。如果有个同名字段同时出现在某个类的接口和父类中,或者同时在自己或父类的多个接口中出现,Javac可能拒绝编译。

  1. 方法解析
    方法解析的第一步与字段解析一样,先解析出方法表的class_index项中索引的方法所属类或接口C的符号引用,如果成功,按照以下进行搜索:
     ① 因为在class文件格式中,类和接口的方法符号引用的常量类型定义是分开的,所以如果在类的方法表中发现class_index索引的C是接口,直接抛出IncompatibleChangeError异常(不相容交换错误)。
     ② 如果通过第一步,在C中查找是否有简单名称和描述符都匹配目标的方法,有的话返回这个方法的直接引用,查找结束。
     ③ 否则,在类C的父类中递归查找是否有都匹配的方法,有则返回直接引用,查找结束。
     ④ 否则,在类C实现的接口列表及它们的父接口中递归查找都匹配的方法,如果存在,说明C是一个抽象类,查找结束,抛出AbstractMethodError异常。
     ⑤ 否则,宣告查找失败,抛出NoSuchMethodError。
     如果查找成功返回了直接引用,将会对这个方法进行权限验证,不具备抛出IllegalAccessError异常。

  2. 接口方法解析
    同样需要先解析出接口方法表class_index项中索引的方法所属类或接口C的符号引用,如果解析成功,进行后续搜索步骤:
     ① 与类方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,直接抛出IncompatibleClassChangeError异常。
     ② 如果通过第一步,在C中查找是否有简单名称和描述符都匹配目标的方法,有的话返回这个方法的直接引用,查找结束。
     ③ 否则,在类C的父接口中递归查找(查找范围包括Object类中方法)是否有都匹配的方法,有则返回直接引用,查找结束。
     ④ 对于规则③,由于java的接口允许多继承,如果C的不同父接口中有多个与目标匹配的方法,就从这些中返回一个并结束查找。但与字段查找类似,有的Javac编译器有可能会按照更严格约束拒绝编译。
     ⑤ 否则,宣告查找失败,抛出NoSuchMethodError异常。

在JDK9前,Java接口中所有方法都默认public,也没有模块化的访问约束,所以不存在访问权限问题。
JDK9中增加了接口的静态私有方法和模块化,所以也会有可能抛出IllegalAccessError异常。

7.3.5 初始化

 类的初始化阶段是类加载过程中最后一个阶段,在之前的几个阶段,除了加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其他都是完全由JVM主导控制。初始化阶段,JVM才真正开始执行类中编写的Java程序代码,将主导权交给应用程序。
 在准备阶段,变量已经赋值过一次零值的初始值。初始化阶段就是执行()方法的过程。
· ()是Javac编译器自动生成物。
· ()是编译器自动收集类中所有类变量的赋值动作静态语句块中的语句合并产生的。
· 编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量只能赋值不能访问。
· ()与实例构造器()方法不同,不需要显式地调用父类构造器,JVM会保证在执行子类的()方法之前父类的()方法已经执行完毕。所以JVM中第一个被执行的()方法一定是Object。
· 父类中定义的静态语句块优先于子类的变量赋值操作。
· ()方法对类或接口来说并不是必不可少的。如果一个类中没有静态语句块,也没有对变量的赋值动作,编译器就不会为该类生成此方法。
· 接口中不可使用静态语句块,但可以有变量赋值操作。但与类不同的是,执行接口的()方法不需要先执行父接口的()。只有当父接口中定义的变量被使用时,父类接口才会被初始化。同样,接口的实现类在初始化时也不会执行接口的()方法
· JVM必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,当一个线程执行这个类的()方法时,别的线程需要阻塞等待,直到活动线程完毕。之后别的线程也不会再进行初始化该类,因为同一个类加载器下,一个类只会初始化一次。
· 如果在一个类的()方法中有耗时长的操作,就可能造成多进程阻塞。

7.4 类加载器

JVM将“通过一个类全限定名获取描述该类的二进制字节流”这个动作放到虚拟机外面实现,实现这个动作代码称为“类加载器”。

7.4.1 类与类加载器

· 类本身和它的类加载器共同确定了这个类在JVM中的唯一性。每个类加载器都有一个独立的类名称空间。
· 加载类的类加载器不同,这两个类一定不相等。

7.4.2 双亲委派模型

· 从JVM角度来看,类加载器可分为两种:
1)启动类加载器。c++语言实现,是虚拟机自身一部分
2)其他所有类加载器。java语言实现,独立于虚拟机外部
· 从java开发人员角度来看,自从jdk1.2之后,java一直保持着三层类加载器、双亲委派的类加载结构

启动类加载器:这个类加载器负责把存放在\lib目录,或者被参数-Xbootclasspath指定的路径中存放的,并且能够被虚拟机正确识别的类库加载到虚拟机内存中。启动器类无法被java程序直接引用,如果要委派给引导类加载器中,使用null值替代即可。

扩展类加载器:它负责加载\lib\ext目录,或者被java.ext.dirs系统变量指定的路径中所有类库。
· 是java类库的扩展机制。jdk9之后被模块化天然扩展能力取代。
· 此类加载器在类ClassLoader中以java代码形式实现的,所以可直接在程序中使用它来加载class文件。

应用程序类加载器:负责加载用户路径(ClassPath)上所有类库。
· 由AppClassLoader类实现,也叫做“系统类加载器”。
· 开发者也可以直接在代码中使用这个类加载器。
· 一般应用程序如果没有自定义自己的类加载器,默认用这个。

JDK9前java应用都需要由这三种类加载器互相配合完成加载的,用户还可以加入自定义类加载器进行扩展。这些类加载器间的协作关系也被称为类加载器的双亲委派模型
在这里插入图片描述

  1. 除了启动类加载器,都有父类加载器。
  2. 父子关系通常(该模型JDK1.2引入,是一个最佳推荐模型,并非强制性约束)使用组合关系来复用父加载器代码。
  3. 工作过程:一个类加载器收到了类加载的请求,先不断将委托上派,最终到达启动类加载器。只有当父加载器反馈无法完成这个加载请求(搜索范围没有找到需要的类),子加载器才会去尝试加载。
  4. 代码逻辑:先检查请求加载的类型是否被加载过,没有则调用父加载器的loadClass()方法,如果父加载器为空,默认使用启动类加载器作为父加载器。如果父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试加载。
  5. 好处:保证同一个类被同一个类加载器加载。反之如果没有双亲委派模型,系统中可能会出现多个不同的类,程序混乱。

7.4.3 破坏双亲委派模型

第一次“破坏”:jdk1.2之前,用户已经自定义了类加载器代码,java设计者们在引入双亲委派模型时不得不做出一些妥协,增加protected方法findClass(),让用户尽可能重写该方法,保证写出来的类加载器符合双亲委派规则。

第二次“破坏”:模型自身缺陷导致。双亲委派模型解决了各个类加载器工作时基础类型一致性问题,但是如果有基础类型,但是又需要调用回用户代码,就会出现问题。
因此java设计者引入了:ContextClassLoader (线程上下文类加载器)。这个类加载器可以通过Thread类中的方法设置。如果创建线程时还没设置,就从父线程中继承一个,如果在应用程序的全局范围内都没有设置过,就默认为应用程序类加载器。
这是一种父类加载器去请求子了加载器完成类加载行为的逆向类加载器。java中涉及SPI(服务提供者接口)的加载基本都采用该方式,如JDBC等。
为了不让SPI有多个服务提供者时候,采用硬编码判断,JDK6后引入ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,给SPI的加载提供了一种方案。

第三次“破坏”:由于用户对程序动态性的追求导致的。
· 动态性:代码热替换、模块热部署等。其实就是希望java应用程序能够像鼠标这样的外设一样,即插即用,不用关机重启。
· OSGI实现模块化热部署的关键是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当要替换一个bundle时,就连同类加载器一起换掉,实现代码的热替换。
· OSGI环境下,类加载器不是双亲委派模型推荐的树状结构,而是发展为更复杂的网状结构。当收到类加载请求时,OSGI将按照以下步骤顺序搜索:

  1. 将以java.*开头的类,委派给父类加载器加载
  2. 否则,将委派列表名单内(配置文件org.osgi.framework.bootdelegation中定义)的类,委派给父类加载器加载
  3. 否则,检查是否在Import-Package中声明,如果是,则委派给Export类的Bundle的类加载器加载
  4. 否则,检查是否在Require-Bundle中声明,如果是,则将类加载请求委托给required bundle的类加载器
  5. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
  6. 否则,查找类是否在自己的Fragment Bundle 中,如果在,则委派给Fragment Bundle的类加载器加载
  7. 否则,查找Dynamic Import-Package(Dynamic Import只有在真正用到此Package的时候才进行加载)的Bundle,委派给对应Bundle的类加载器加载
  8. 否则,类查找失败
    除了开头两点符合双亲委派规则的原则,剩余都是平级类加载器中查找的。

7.5 Java模块化系统

jdk9中引入了java模块化系统(Java Platform Module System,JPMS)。
· 为了实现模块化的关键目标——可配置的封装隔离机制,JVM对类加载架构做了相应调整。JDK9的模块不仅同之前jar包那样仅充当代码容器,还包含:

  • 依赖其他模块的列表
  • 导出的包列表,即其他模块可以使用的列表
  • 开放的包列表,即其他模块可以反射访问模块的列表
  • 使用的服务列表
  • 提供服务的实现列表
    · 可配置的封装隔离机制,首先要解决JDK9之前基于类路径(classpath)来查找依赖的可靠性问题。此前,如果类路径缺失运行时依赖类型,只有当代码运行到发生该类型加载、链接时才会报错。但是jdk9以后,如果启动了模块化封装,模块能够声明对其他模块的显示依赖,jvm在启动时会检查依赖关系在运行期是否完备,如果缺失直接启动失败。
    · 其次解决了原来类路径上跨JAR文件的public类型访问问题。JDK9之后,必须声明哪一些public的类型可以被哪一些模块访问。这种访问控制也是在类加载中完成的。

7.5.1 模块兼容性

为了使封装隔离机制能兼容原来的类路径查找机制,JDK9提出了“模块路径(ModulePath)”:某个类库到底是模块还是传统jar包,只取决于存放在哪个路径上。

模块化系统按照以下规则,来保证使用传统类路径依赖的java程序能直接运行在jdk9及以上的版本(向后兼容),保证了传统程序可以访问到所有标准类库模块中导出的包。
- JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块里。这个模块几乎没有任何隔离。
- 模块在模块路径的访问规则:模块路径下的具体模块只能访问到它列明的依赖模块和包,匿名模块中所有内容对具体模块不可见,即看不到传统jar包。
- JAR文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的JAR文件放置到模块路径中,它就会变成一个自动模块。尽管不包含module-info.class,但自动模块默认依赖于整个【模块路径】中所有模块,也默认导出自己的包。

模块间的管理和兼容性问题:如果一个模块有很多版本,就只能由开发人员在编译打包时人工选择正确版本的模块来保证依赖的正确性。
· Java模块系统目前不支持在模块定义中加入版本号来管理和约束依赖,也不支持版本选择功能。
· JDK9加入class文件格式的Module属性,用户可以在编译时指定模块版本,也存在API接口在运行时获得模块版本号。
· 但“JPMS目的不是代替OSGi”,希望维持一个足够简单的模块化系统。但Jigsaw仿佛在刻意给OSGi让出空间,使得IBM不反对Jigsaw,代价是JPMS不可能拥有OSGi那样的支持多版本模块并存、支持运行时热替换、热部署模块的能力。所以JDK9后实现这种目的,只能将OSGi和Jigsaw混合使用,没有内置Java模块化系统和Java虚拟机中,必须通过类加载器实现
· JVM内置的JVMTI接口提供了一定程度的运行时修改类的能力,不过有很多限制。不可能直接用来实现OSGi那样的热替换和多版本并存,可以用在IDEA,Eclipse这些IDE上做HotSwap(指IDE编辑方法的代码后不需要重启即可生效)。

7.5.2 模块化下的类加载器

为了兼容性,JDK9并未从根本上动摇三层类加载器架构以及双亲委派模型。不过模块化下的类加载器为了模块化系统的施行,主要进行了如下几方面的变动:

  1. 扩展类加载器被平台类加载器取代。整个JDK都基于模块化构建(原来的rt.jar和tools.jar被拆分成10个JMOD文件),先天具有了可扩展性,扩展类加载器没有存在必要,无需再保留\lib\ext目录。类似的,新版JDK中也取消了\jre目录,因为可以根据组合构建出程序运行所需的JRE。
  2. 平台类加载器、应用程序类加载器都不再派生自URLClassLoader,如果有程序依赖这个类或特有方法,那么代码很可能在JDK9及更高版本中崩溃。
    · 现在,启动类加载器、平台类加载器、应用程序类加载器都继承于jdk.internal.loader.BuiltinClassLoader。在这个类中实现了在新的模块化架构下,如何从模块中加载的逻辑,以及模块中资源可访问性的处理。
    · 启动类加载器现在是在JVM内部和java类库共同协作实现的类加载器尽管有了BootClassLoader这样的Java类,但为了与之前的代码保持兼容,所有获得启动类加载器的场景中仍然会返回null,而不是BootClassLoader的实例。
    在这里插入图片描述
  3. JDK9中虽然仍维持着三层类加载器和双亲委派的架构,但委派关系发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载之前,要先判断该类是否能够归属到某个系统模块中,如果可以就先委派给负责该模块的类加载器完成加载。
    Java模块化系统明确规定了三个类加载器负责各自的加载模块。