【Android】JVM 学习之类加载机制

【Android】JVM 学习之类加载机制

JVM 与类加载

Java 语言中,类型的加载、连接和初始化都是在运行期进行的。这样会导致一些性能的开销,但同时使得 Java 成为了一种可以动态扩展的语言。比如编写一个面向接口的程序,可以到运行时再对其具体的实现类进行指定。Android 中的热修复技术的其中一种实现实际上也是依托于 Java 的动态类加载的机制的。因此,JVM 的这种动态加载的特性通过一定的性能损耗换来了 Java 中十分重要的运行期类加载特性。

类加载时机

类从被加载到内存到从内存中卸载的期间,包含了如下的生命周期:

image-20190113154956282

其中,连接包含了验证、准备、解析三个部分。在这样一条生命周期中,除了解析之外的顺序都是确定的。而解析阶段在某些情况下可能会在初始化之后进行。这是为了支持 Java 的运行时绑定(动态绑定、晚期绑定)特性。

关于类加载发生的时机在 Java 虚拟机规范中没有强制的约束,是和具体 JVM 的实现有关的。对于初始化阶段,则规定了有且仅有 5 种情况需要对类进行初始化:

  1. 遇到 new、getstatic、putstatic 或者 invokestatic 这 4 条字节码指令时,若类没有进行过初始化,需要先触发它的初始化。一般发生在我们使用 new 实例化对象、读取或设置类的静态字段的时候,以及调用一个类的静态方法时。

  2. 使用反射进行调用时,若类还没有进行过初始化,需要先进行初始化

  3. 初始化一个类时,发现其父类还未进行过初始化,则需要先触发这个类的初始化。
  4. 虚拟机启动时,虚拟机会先初始化用户要执行的主类(包含 main() 方法的类)
  5. 当使用 JDK 1.7 的动态语言支持,若一个 MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且方法句柄对应类未进行过初始化,则需要先触发这个类的初始化。

上面的五种方式也被称作主动引用。而除此之外的引用方式被称为被动引用

类加载过程

在 Java 虚拟机中,类加载的过程包括了加载、准备、验证、解析和初始化这 5 个阶段。下面我们分别进行讨论

加载

加载是类加载的一个阶段,虚拟机会在该阶段完成 3 件事:

  1. 通过类的全限定名获取定义此类的二进制字节流
  2. 将字节流代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据访问入口。

对于非数组类,是我们开发人员可控性强的,因为加载阶段使用的类加载器可以由我们自己选择,甚至可以自己实现一个类加载器来完成。

而对于数组类,它本身不通过类加载器创建,由 Java 虚拟机直接创建。但其元素类型最终是要通过类加载器去创建的,数组类的创建过程遵循了下述规则:

  • 若数组的组件类型(去掉一个维度的类型)是引用类型,则递归采用即将提到的加载过程进行加载这个组件类型。而这个数组类型会在加载这个组件类型的类加载器的类名称空间中被标识。
  • 若数组的组件类型不是引用类型(比如 int[]),JVM 会将数组类型标记为与引导类加载器关联。
  • 若数组类的可见性与组件类型的可见性一致,若组件类型不是引用类型,则数组类型的可见性会默认为 public。

加载完成后,这些二进制字节流会以 JVM 需要的格式存储在方法区中。之后在内存中实例化一个 java.lang.Class 的对象(对于 HotSpot 虚拟机,Class 对象会存放在方法区中)。这个对象会作为程序访问方法区中的类型数据的外部接口。

加载与连接阶段的部分内容是交叉进行的,并不是一定要加载阶段结束后才会开始连接阶段。

验证

连接阶段的第一步就是验证。这个阶段是为了确保这个 Class 文件的字节流符合 JVM 的要求,且不会危害 JVM 本身。

Java 中的一些操作如果从 Java 语言层面是不允许的(如越界访问数组)。但如果有恶意人员从 Class 角度入手,通过十六位编辑器直接编写 Class 文件,那么一些恶意代码可能就能够被成功被 JVM 所载入。为了避免这类情况,因此引入了验证这一阶段。

验证阶段会大致完成下面几个检验动作:

文件格式验证

首先是验证文件格式是否符合 Class 文件格式规范且能被当前版本 JVM 处理:

  • 开头的魔数是否为 0xCAFEBABE
  • 主次版本号是否能被当前虚拟机接收
  • 常量池是否有不被支持的类型(检查 tag 标志)
  • 指向常量的索引是否有指向不存在或不符合类型的常量
  • CONSTANT_Utf8_info 类型的常量是否有不满足 UTF-8 编码的数据
  • Class 文件各个部分及文件本身是否有被删除或者附加的信息
  • ……

第一个文件格式验证阶段是为了保证字节流能正确解析并存储于方法区。而后面的三个阶段验证都是基于方法区存储的数据的。

元数据验证

第二个阶段是对字节码所描述信息进行语义分析,保证其符合 Java 语言规范的要求:

  • 这个类是否有父类(除了 java.lang.Object 以外的类都有父类)
  • 这个类的父类是否是不能被继承的类(修饰为 final 的类)
  • 这个类如果不是抽象类,是否实现了所有父类或接口中要求实现的方法
  • 类中的字段、方法是否与父类矛盾(比如覆盖了父类的 final 字段等等)
  • ……

第二个元数据验证阶段是对类的元数据进行语义校验,保证只包含符合 Java 语言规范的元数据信息

字节码验证

这个阶段是验证过程最复杂的阶段,通过数据流及控制流分析,确定语义是合法的、符合逻辑的。这个阶段会对类的方法体进行校验,保证被校验方法不会做出危害虚拟机安全的事情:

  • 保证任意时刻操作数栈的数据与指令代码序列都能配合工作
  • 保证跳转指令不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转换是有效的
  • ……

虚拟机设计团队在 JDK 1.6 之后在 Javac 编译器和 JVM 中进行了一项优化:为 Code 属性表中添加了 StackMapTable 属性,描述了方法体所有基本块开始时本地变量表和操作栈应有的状态。这样在验证期间就不需要再根据程序推导这些状态的合法性,只需要检查 StackMapTable 中的记录是否合法即可。

第三个字节码验证阶段是对数据流及控制流进行分析,保证语义是合法的、符合逻辑的

符号引用验证

这个阶段发生在虚拟机将符号引用转化为直接引用时,这个转化会发生在解析阶段。可以将符号引用验证看作对类自身以外(常量池中各种符号引用)的信息进行匹配性校验:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类、字段、方法的访问性是否可以被当前类访问
  • ……

第四个符号引用验证阶段是对类自身之外的信息进行匹配校验,确保解析阶段能正常执行

通过参数关闭验证阶段

对类加载阶段来说,验证阶段并不是必要的。如果确认所运行的代码不存在任何问题时,可以通过 -Xverify:none 参数将大部分的类验证措施进行关闭,缩短类加载时间。

准备

准备阶段正式为类变量分配了内存并设置类变量初始值。这些类所使用的内存都会在方法区中进行分配。

这里有两点需要注意的:

第一点:上文提到的仅仅包括了类变量,也就是被 static 修饰了的变量,不包括实例变量。实例变量会在对象实例化时随对象分配在 Java 堆中。

第二点:假如我们通过 public static int value = 123; 定义了 value 变量,这个 value 变量的初始值将是 0 而不是 123。因为此处将 value 赋值为 123 的指令是在程序编译后存放于类构造器 <clinit> 方法中的,因此这个赋值操作在初始化阶段才会执行。

下面是所有基本数据类型对应的初始值表

数据类型 初始值 数据类型 初始值
int 0 boolean false
long 0L float 0.0f
short (short) 0 double 0.0d
char ‘\u0000’ reference null
byte (byte) 0

而当我们通过 public static final int value = 123; 将 value 设置为123时,那么在准备阶段 value 就会被初始化为 ConstantValue 属性指定的值,因此在准备阶段 value 就会被赋值为 123。

解析

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

符号引用以 CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型常量出现。

那么解析阶段所说的直接引用又与符号引用有何关联呢?

  • 符号引用用一组符号描述所引用的目标,它与虚拟机实现的内存布局无关,所引用的目标不一定已经加载到内存中。符号引用是以字面量形式明确定义与 Class 文件格式中的。
  • 直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。是和虚拟机的内存布局有关的。如果有了直接引用,那么引用的目标一定已存在于内存中。

虚拟机的规范中未明确定义解析阶段发生时间,因此虚拟机可以根据需要来判断是在类被加载器加载时就对符号引用进行解析还是在符号引用即将被使用时再去解析。

解析的动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符这 7 类符号引用进行,下面解释前面四种的解析:

类或接口的解析

假设当前为 D 类,要讲一个符号引用 N 解析为一个直接引用 C,那么需要下面的步骤:

  1. 若 C 不是数组类型,则虚拟机会将代表 N 的全限定名传递给 D 的类加载器去加载 C。在这个过程中可能由于元数据验证、字节码验证等阶段又触发了其他类的加载动作。
  2. 若 C 是一个数组,且其元素类型为对象。则会按 1 的规则加载数组元素类型。接着再由虚拟机生成一个代表了这个数组维度的数组对象。
  3. 若上面步骤未出现任何异常,则 C 在虚拟机中已经成为了一个有效的类或接口。在解析完成前还需要进行符号引用验证,确认 D 是否具备 C 的访问权限。

字段解析

解析一个未被解析过的字段符号引用,先会对字段所属的类或接口的符号引用进行解析,假设这个字段所属类或接口可以用 C 来表示,则虚拟机会以如下步骤进行后续字段解析:

  1. 若 C 本身包含了简单名称和字段描述符都与目标匹配的字段,则直接返回这个字段的直接引用。
  2. 否则,若在 C 中实现了接口,会按照继承关系从下到上递归搜索各个接口和其父接口。如果接口中包含了简单名称和描述符都匹配的字段,则直接返回这个字段的直接引用。
  3. 否则,若 C 不是 java.lang.Object 类型,会按继承关系从下向上递归搜索父类,若父类中包含了简单名称和字段描述符都与目标匹配的字段,则直接返回这个字段的直接引用。
  4. 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。

若查找成功返回直接引用,则会进行权限验证。验证不通过会抛出 java.lang.IlleaglAccessError 异常。

在实际中,编译器的实现会比上述更加复杂。比如如果有一个同名字段同时出现于 C 的接口与父类中,或在自己或父类的多个接口中出现,则编译器会拒绝编译。

类方法解析

类方法解析的第一个步骤与字段解析相同,先解析出所属类或接口的符号引用,若解析成功,我们用 C 表示这个类,则接下来会按照下面的步骤进行后续类方法搜索:

  1. 类方法和接口方法符号引用的常量类型定义是分开的,若类方法表中发现了 C 是一个接口,则直接抛出 java.lang.IncompatibleClassChangeError 异常。
  2. 否则在 C 中查找是否有简单名称和字段描述符都与目标匹配的方法,如果有则直接返回这个方法的引用。
  3. 否则在 C 的父类中递归查找是否有简单名称和字段描述符都与目标匹配的方法,如果有则直接返回这个方法的引用。
  4. 否则在 C 的实现的接口列表及它的父接口中查找是否有简单名称和字段描述符都与目标匹配的方法,如果有则说明 C 是一个抽象类,抛出 java.lang.AbstractMethodError 异常
  5. 否则,查找方法失败,抛出 java.lang.NoSuchMethodError 异常。

若查找成功返回来直接引用,则会进行权限验证。验证不通过会抛出 java.lang.IlleaglAccessError 异常。

接口方法解析

接口方法也需要先解析出所属类或接口的符号引用,若解析成功,我们用 C 表示这个接口,则接下来会按照下面的步骤进行后续类方法搜索:

  1. 若类方法表中发现了 C 是一个类而不是一个接口,则直接抛出 java.lang.IncompatibleClassChangeError 异常。
  2. 否则在 C 中查找是否有简单名称和字段描述符都与目标匹配的方法,如果有则直接返回这个方法的引用。
  3. 否则在 C 的父接口中递归查找,直到找到了 java.lang.Object 类(查找包括 Object 类),看是否有简单名称和字段描述符都与目标匹配的方法,如果有则直接返回这个方法的引用。
  4. 否则,查找方法失败,抛出 java.lang.NoSuchMethodError 异常。

接口中所有方法都是 public ,不存在权限访问问题,符号解析不会抛出 java.lang.IlleaglAccessError 异常。

初始化

类的加载最后一步就是类初始化了。到了初始化阶段,才真正开始执行类中的 Java 代码(字节码)。

在准备阶段,变量已经被赋予了系统规定的初始值。而初始化阶段,则会根据程序员制定的计划去初始化类变量及其他资源。

初始化阶段是类执行 <clinit>() 的阶段

  • <clinit>() 方法是编译器自动收集类中所有类变量的赋值动作及 static 块中的语句合并后产生的。编译器的收集顺序是由语句在源文件中出现的顺序决定。static 块只能访问到定义在其之前的变量。对于 static 块之后的变量,只能进行赋值,不能进行访问。
  • <clinit>() 方法与类的构造函数(<init>() 方法)不同,它不需要显式调用父类构造器。虚拟机会保证在执行子类的 <clinit>() 方法前已经执行完毕父类的 <clinit>() 方法。因此,虚拟机中第一个执行 <clinit>() 的类是 java.lang.Object。
  • 父类的 <clinit>() 方法先执行,因此父类中定义的静态语句块会优先于子类的变量赋值操作。
  • 如果一个类没有 static 块及赋值操作,则编译器不会为这个类生成 <clinit>() 方法
  • 接口中不能用 static 块,但仍有赋值操作。但执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。父类中定义的变量被使用时,父接口才会初始化。接口的实现类初始化时也不会执行接口的 <clinit>() 方法

  • 虚拟机能保证一个类的 <clinit>() 方法在多线程环境会被正确地加锁、同步。如果多个线程同时初始化一个类,则只会有一个线程去执行它的 <clinit>() 方法,其他线程都会阻塞等待直到执行完成。如果在类的 <clinit>() 方法中有耗时操作,就可能导致多个进程阻塞。

类加载器

在类加载阶段过程中的『通过一个类的全限定名来获取描述此类的二进制字节流』这个动作交给了类加载器来实现。

虽然它只是用来实现类的加载动作,但它在 Java 中起到的作用却不止于类加载阶段。对任意的类,如果使用了不同的类加载器进行加载,那么这两个类就一定不『相等』。

比如下面的例子,我们通过自己自定义的类加载器对类进行了加载。但由于加载的类加载器不同,导致了 instanceof 返回了 false。

public class ClassLoaderDemo {
    public static void main(String[] args) {
        ClassLoader customLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream input = getClass().getResourceAsStream(fileName);
                    if (input == null) {
                        return super.loadClass(name);
                    }
                    byte[] bytes = new byte[input.available()];
                    input.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };

        try {
            Object obj = customLoader.loadClass("ClassLoaderDemo").newInstance();

            System.out.println(obj.getClass());
            System.out.println(obj instanceof ClassLoaderDemo);
        } catch (Exception e){
            e.printStackTrace();
        }
    }
}

结果如下:

class ClassLoaderDemo
false

可以发现,由于一个是系统应用程序类加载器加载的,一个是我们自定义的类加载器加载的,它们虽然都来自同一个 Class 文件,但仍然是两个独立的类。经过 instanceof 检查时的结果为 false。

双亲委派模型

从 Java 虚拟机的角度来看,只有两种不同的类加载器,一种是由 C++ 实现的启动类加载器(Bootstrap ClassLoader),一种是由 Java 实现的继承自 ClassLoader 类的其他类加载器。

关于类加载器和双亲委派机制,可以查看我之前写的一篇有关 ClassLoader 的文章:Android中的ClassLoader

参考资料:《深入理解 Java 虚拟机(第二版)》

发表评论

电子邮件地址不会被公开。 必填项已用*标注

%d 博主赞过: