JVM 四 虚拟机类加载机制

类加载时机#

类加载分为:加载、验证、准备、解析、初始化、使用和卸载阶段。验证、准备、解析阶段统称为连接阶段。解析阶段有时可能在初始化阶段后开始。

Java虚拟机规范没有定义类何时被加载、验证、准备,但是定义了六种情况Java虚拟机必须立即将类进行初始化,并且有且只有这6种情况会触发初始化。所以在这6中情况发生时,排在初始化前面的加载、验证和准备阶段自然也要先行完成。

  1. 遇到newgetstaticputstaticinvokestatic这四条字节码指令时,要触发初始化阶段。
    • 使用new关键字实例化对象时
    • 读取或设置一个类型的静态字段时(非final)
    • 调用一个类型的静态方法时
  2. 使用java.lang.reflect包的方法对类型进行反射调用时,如果指定类型没有初始化,先对其进行初始化
  3. 初始化类时发现父类还没有初始化时,先对父类进行初始化(父接口不需要进行初始化)
  4. 虚拟机启动时,先初始化用户指定的主类(包含main方法的类)
  5. 使用JDK7加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial四种类型的方法句柄,并且方法句柄对应的类没有进行初始化,先对其进行初始化
  6. 当一个接口定义了JDK8中新增的default方法,并且该接口有实现类发生了初始化,接口要先触发初始化

有一些不会触发初始化的例子,称为被动引用:

public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}

public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

这个例子中,实际上获取的是SuperClass中的静态变量,子类没有被初始化,所以只能够看到SuperClass init!只有直接定义静态变量的类会被初始化

public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

这个例子中,SuperClass不会被初始化,而一个名为[Lorg.fenixsoft.classloading.SuperClass的类被初始化了,这是数组类型,是JVM直接创建的继承自java.lang.Object的子类,创建动作由newarray字节码指令触发。

public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLOWORLD = "hello world";
}



public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}

这个例子中的ConstClass不会被初始化,因为在编译阶段HELLOWORLD已经存储在NotInitialization的常量池中了,后面对ConstClass的引用实际上都转换成了对自身常量池的引用。

类加载过程#

加载#

加载(Loading)是类加载(Class Loading)中的一个阶段,不要混淆。类加载阶段虚拟机要做如下三件事

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

我们在类加载器中主要干的就是加载阶段中的第一个任务。

对于数组类型不通过类加载器创建,而是虚拟机自己创建,遵循以下原则

  1. 如果数组中的组件类型(元素类型)是引用类型,那么就递归使用上面的加载过程加载该引用类型,数组C会被标识在加载该组件类型的类加载器的类名称空间上。但是数组初始化时引用类型并不会被直接初始化,只有向其中填充实例时才会被初始化。
  2. 如果数组中的组件类型是基本类型,那么就将数组C关联到引导类加载器上(Bootstrap ClassLoader)

加载阶段和连接阶段是交叉执行的,因为加载的过程中可能需要连接操作,但是它们还是遵循开始顺序,加载在连接之前开始。

验证#

验证是连接阶段的第一步。主要是对字节码进行一系列验证保证不会出现破坏虚拟机的不安全操作

文件格式验证#

  1. 是否以魔数 0xCAFEBABE开头
  2. 主次版本号是否在当前Java虚拟机接受范围内
  3. 检查常量池中的常量类型是否支持(tag标志)
  4. 指向常量的索引中是否有不存在的常量或不符合类型的常量
  5. CONSTANT_Utf8_info型的常量中是否有不符合Utf8编码的数据
  6. Class文件各个部分是否完整,是否有附加信息
  7. ...

元数据验证#

第二阶段是对字节码描述的信息进行语义分析,确保符合《Java语言规范》的要求。

  1. 这个类是否有父类(除了java.lang.Object都有父类)
  2. 这个类的父类是否继承了不允许被继承的类(final修饰)(为什么不是检测他自己而是检测父类?)
  3. 如果这个类不是抽象类,是否实现了父接口中要求实现的所有方法
  4. 类中字段是否与父类字段产生矛盾(如覆盖了父类final字段)
  5. ...

字节码验证#

比较麻烦的验证,校验Code属性中的字节码指令的安全性

  1. 保证任意时刻操作数栈中的数据类型都能与指令代码序列配合工作,比如存int不能按long取
  2. 保证跳转指令不会跳转到方法体外
  3. 保证类型转换有效
  4. ...

通过字节码验证不代表一定安全,只是相对可靠。

Java6后将一部分工作移到了编译期。

符号引用验证#

  1. 符号引用中通过字符串描述的全限定名是否能找到对应的类
  2. 指定类中是否存在简单名称描述的方法和字段
  3. 方法和字段的可访问性是否能够被当前类访问
  4. ...

准备#

准备阶段会将静态变量分配内存空间并设置零值,final变量除外

下面是各种数据类型的零值表

解析#

解析阶段将符号引用替换为直接引用。

类或接口的解析#

当前代码所处类为D,要将符号引用N解析为直接引用C(可能是类或接口),步骤如下

  1. 如果C不是数组类型,使用D的类加载器加载N的全限定名
  2. 如果C是数组类型,先按照第一点加载元素类型,接着虚拟机生成数组对象
  3. 至此C已经是一个有效的类或接口,然后再检测D是否有C的访问权限

模块系统下的差异

字段解析#

先对这个字段所在的类或接口的符号引用进行解析,如上面的步骤,解析完成后,将这个字段的类或接口视为C

  1. 如果C本身包含这个字段,返回直接引用
  2. 如果C实现了接口,自下而上递归查找父接口中是否存在该字段,如果有返回直接引用
  3. 否则,如果C不是java.lang.Object,递归查找父类,如果父类中存在该字段,返回直接引用
  4. 否则查找失败

返回直接引用后会对字段权限进行检查,如果不符合权限则抛出异常

类方法解析#

  1. 如果发现C是个接口,直接抛出异常
  2. 如果C本身包含这个方法,返回直接引用
  3. 否则,如果C不是java.lang.Object,递归查找父类,如果父类中存在该方法,返回直接引用
  4. 如果C实现了接口,自下而上递归查找父接口中是否存在该字段,如果有说明C是抽象类,抛出异常
  5. 否则查找失败,抛出异常

接口方法解析#

  1. 如果发现C是个类,直接抛出异常
  2. 如果C本身包含这个方法,返回直接引用
  3. 查找父接口,如果父接口中有这个方法,返回直接引用
  4. 如果多个父接口都存在这个方法,返回其中的一个(没有规定具体是哪个,一般的Java编译器针对这种情况都会拒绝编译)
  5. 否则查找失败,抛出异常

初始化#

初始化阶段就是调用类的<clinit>方法。

这个方法中的内容就是静态代码块(static{})或静态变量的赋值动作。

前面说过静态变量的定义操作会在准备阶段就分配内存并设置零值,它们的赋值操作在<clinit>方法中完成。

静态语句块中允许对定义在它后面的变量进行赋值操作,不允许引用

子类<clinit>调用之前,父类的<clinit>一定会先被调用,所以java.lang.Object<clinit>一定先被执行。但接口不是,只有当父接口中的变量被引用时,父接口才会被初始化,所以接口的实现类或子接口的<clinit>被调用时,父接口的不会被调用。

<clinit>的执行是线程安全的,也就是说多个线程同时初始化一个类,<clinit>只会被调用一次,其它的调用会阻塞,等待这个方法完成,所以static块和赋值操作中不要有很耗时的操作。

类加载器#

未完...

posted @   yudoge  阅读(40)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· winform 绘制太阳,地球,月球 运作规律
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示
主题色彩