【JVM】类加载的全过程

「前言」

在Java语言中,每个类或接口都会被编译器编译程一个个class字节码文件。

类加载则是将这些class字节码文件的二进制数据读入到内存中,并且对数据进行校验、解析、初始化。最终,每一个类都会在方法区保存一份它的元数据,在堆中创建一个与之对应的Class对象。

  1. 类的生命周期需要经历7个阶段,分别是加载、验证、准备、解析、初始化、使用、卸载
  2. 类的加载过程则是前面5个阶段,分别是加载、验证、准备、解析、初始化,其中 验证、准备、解析 可以归纳为 “连接” 阶段。

image-20230314215836422


「加载」

加载阶段是类加载的第一个阶段。

在这个阶段,JVM的目的是将字节码从各个位置转化为二进制字节流加载到内存中,接着会为这个类在JVM的方法区创建一个对应的Class对象,这个 Class 对象就是这个类各种数据的访问入口。

也就是在这个阶段,虚拟机需要完成下面三件事情:

  1. 通过一个类的全限定名去找到其对应的class文件
  2. 将这个class文件内的二进制数据读取出来,转化成方法区的运行时数据结构
  3. 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

注意:Java虚拟机并没有规定类的字节流必从.class文件中加载,在加载阶段,程序员可以通过自定义的类加载器,自行定义读取的地方,例如通过网络、数据库等。


「验证」

在验证阶段,JVM完成了加载class字节码文件的步骤,并且在方法区创建对应的Class对象之后,JVM便会对这些字节码流进行校验,只有符合JVM规范的文件才能被JVM正确执行。

验证的过程,分为以下两个类型:

  1. JVM 规范校验
    1. JVM 会对字节流进行文件格式校验,判断其是否符合 JVM 规范,是否能被当前版本的虚拟机处理。
    2. 例如,校验文件是否是以 0x cafe babe 开头,主次版本号是否在当前虚拟机处理范围之内等。
  2. 代码逻辑校验
    1. JVM 会对代码组成的数据流和控制流进行校验,确保 JVM 运行该字节码文件后不会出现致命错误。
    2. 例如,一个方法要求传入 int 类型的参数,但是使用它的时候却传入了一个 String 类型的参数。

验证这个阶段虽然十分重要,但是不是必须,它对于程序的运行期没有影响。

如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

Java虚拟机允许程序员主动取消这个阶段,用来缩短类加载的时间,可以根据自身需求,使用 -Xverify:none参数来关闭大部分的类验证措施。


「准备」

在准备阶段中,JVM将类变量分配内存并初始化。

Java 中的变量有类变量类成员变量两种类型。「类变量」指的是被 static 修饰的变量,而其他所有类型的变量都属于类成员变量

在准备阶段,JVM 只会为类变量分配内存,而不会为类成员变量分配内存。类成员变量的内存分配需要等到初始化阶段才开始。

public static int classVariable = 3;
public String classMemberVariable = "Java is good";

上述代码在准备阶段只会为classVariable分配内存而不会给classMemberVariable分配内存

在准备阶段,JVM 会为类变量分配内存并为其初始化。这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值。

public static int sector = 3;

上述代码中,sector的值将会是0,而不会被赋值为3。

如果一个变量是常量(被 static final 修饰)的话,那么在准备阶段,变量便会被赋予用户希望的值。

final 关键字用在变量上,表示该变量不可变,一旦赋值就无法改变。所以,在准备阶段中,对类变量初始化赋值时,会直接赋予用户希望的值。

public static int sector = 3;

上述代码中,sector的值将会是3


「解析」

这个阶段,虚拟机会把这个Class文件中,常量池内的符号引用转换为直接引用。主要解析的是 类或接口、字段、类方法、接口方法、方法类型、方法句柄等符号引用

可以把解析阶段中,符号引用转换为直接引用的过程,理解为当前加载的这个类,和它所引用的类,正式进行“连接“的过程

什么是符号引用?
Java代码在编译期间,是不知道最终引用的类型,具体指向内存中哪个位置的,这时候会用一个符号引用,来表示具体引用的目标是"谁"。Java虚拟机规范中明确定义了符号引用的形式,符合这个规范的前提下,符号引用可以是任意值,只要能通过这个值能定位到目标。

什么是直接引用?
直接引用就是可以直接或间接指向目标内存位置的指针或句柄。

引用的类型,还未加载初始化怎么办?
当出现这种情况,会触发这个引用对应类型的加载和初始化。


「初始化」

初始化是类加载的最后一个步骤,初始化的过程,也就是执行类构造器 <clinit>()方法的过程

<clinit>() 方法的作用是什么?

在准备阶段,已经对类中static修饰的变量赋予了初始值<clinit>() 方法的作用,就是给这些变量赋予程序员实际定义的“值”。同时类中如果存在static代码块,也会执行这个静态代码块里面的代码。

<clinit>() 方法是什么?

<clinit>() 方法 和 <init> 方法是不同的,它们一个是“类构造器”,一个是实例构造器。
Java虚拟机会保证子类<clinit>() 方法在执行前,父类的 <clinit>() 已经执行完毕。而 <init> 方法则需要显性的调用父类的构造器。
<clinit>() 方法由编译器自动生成,但不是必须生成的,只有这个类存在static修饰的变量,或者类中存在静态代码块但时候,才会自动生成<clinit>()方法。

当 JVM 遇到下面 5 种情况的时候会触发初始化

  1. 遇到 new、getstatic、putstatic、invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化
    • 生成这 4 条指令的最常见的 Java 代码场景是使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用 JDK 1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果是 REF_getstaticREF_putstaticREF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化时,则需要先出触发其初始化。

「类加载机制」

当编译器将 Java 源码编译为字节码之后,虚拟机便可以将字节码读取进内存,从而进行解析、运行等整个过程。我们将这个过程称为 Java 虚拟机的类加载机制

类加载机制中,通过类加载器(classloader)来完成类加载的过程。


「类加载」

通过一个类全限定名称来获取其二进制文件(.class)流的工具,被称为类加载器(classloader)。

Java中支持4中类加载器

img

启动类加载器(Bootstrap ClassLoader

  • 用于加载 Java 的核心类
  • 它不是一个 Java 类,是由底层的 C++ 实现。因此,启动类加载器不属于 Java 类库,无法被 Java 程序直接引用。Bootstrap ClassLoaderparent 属性为 null

标准扩展类加载器(Extention ClassLoader)

  • sun.misc.Launcher$ExtClassLoader 实现
  • 负责加载 JAVA_HOMElibext 目录下的或者被 java.ext.dirs 系统变量所指定的路径中的所有类库

应用类加载器(Application ClassLoader)

  • sun.misc.Launcher$AppClassLoader 实现
  • 负责在 JVM 启动时加载用户类路径上的指定类库

用户自定义类加载器(User ClassLoader)

  • 当上述 3 种类加载器不能满足开发需求时,用户可以自定义加载器
  • 自定义类加载器时,需要继承 java.lang.ClassLoader 类。如果不想打破双亲委派模型,那么只需要重写 findClass 方法即可;如果想打破双亲委派模型,则需要重写 loadClass 方法

classloader 类存在一个 parent 属性,可以配置双亲属性。默认情况下,JDK 中设置如下。

ExtClassLoader.parent=null;

AppClassLoader.parent=ExtClassLoader

//自定义
XxxClassLoader.parent=AppClassLoader

「类加载特点」

「类加载机制」中,通过「类加载器(classloader)」来完成类加载的过程。Java 中的类加载机制,有如下 3 个特点

  1. 双亲委派
    • JVM 中,类加载器默认使用双亲委派原则
  2. 负责依赖
    • 如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项。
  3. 缓存加载
    • 为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加载,那么它会缓存这个加载结果,不会重复加载。

「双亲委派」

双亲委派机制是一种任务委派模式,是 Java 中通过加载工具(classloader)加载类文件的一种具体方式。 具体表现为

  1. 如果一个类加载器收到了类加载请求,它并不会自己先加载,而是把这个请求委托给父类的加载器去执行
  2. 如果父类加载器还存在其父类加载器,**则进一步向上委托,依次递归,**请求最终将到达顶层的引导类加载器 BootstrapClassLoader
  3. 如果父类加载器可以完成类加载任务,就成功返回;倘若父类加载器无法完成加载任务,子加载器才会尝试自己去加载。
  4. 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类;如果将加载任务分配至系统类加载器(AppClassLoader)也无法加载此类,则抛出异常。
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException {
    
    
    synchronized (getClassLoadingLock(name)) {
    
    
        // 1. 检查该类是否已经加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
    
    
            long t0 = System.nanoTime();
            try {
    
    
                if (parent != null) {
    
    
                    // 2. 有上级的话,委派上级 loadClass
                    c = parent.loadClass(name, false);
                } else {
    
    
                    // 3. 如果没有上级了(ExtClassLoader),则委派
                    BootstrapClassLoader
                        c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
    
    
            }
            if (c == null) {
    
    
                long t1 = System.nanoTime();
                // 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
                c = findClass(name);
                // 5. 记录耗时
                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. 若没有加载,则调用父加载器的 loadClass() 方法进行加载
  3. 若父加载器为空,则默认使用启动类加载器作为父加载器
  4. 如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载

image-20230315175115390

ClassLoader 中和类加载有关的方法有很多,前面提到了 loadClass(),除此之外,还有 findClass()defineClass() 等。这3个方法的区别如下

  • loadClass():默认的双亲委派机制在此方法中实现
  • findClass():根据名称或位置加载 .class 字节码
  • definclass():把 .class 字节码转化为 Class 对象

「双亲委派优缺点」

双亲委派可以保证一个类不会被多个类加载器重复加载,并且保证核心 API 不会被篡改,其优点如下

  1. 避免类的重复加载
    1. 通过双亲委派的方式,可以保证安全性 。因为 BootstrapClassLoader 在加载的时候,只会加载 JAVA_HOME 中的 jar 包里面的类,如 java.lang.String,那么这个类是不会被随意替换的,除非有人跑到你的机器上,破坏你的 JDK

其缺点如下:

  1. 在双亲委派中,子类加载器可以使用父类加载器已经加载过的类,但是父类加载器无法使用子类加载器加载过的类(类似继承的关系)。

Java 提供了很多服务提供者接口(SPI,Service Provider Interface),它可以允许第三方为这些接口提供实现,比如数据库中的 SPI 服务 - JDBC。这些 SPI 的接口由 Java 核心类提供,实现者确是第三方。如果继续沿用双亲委派,就会存在问题,提供者由 Bootstrap ClassLoader 加载,而实现者是由第三方自定义类加载器加载。这个时候,顶层类加载就无法使用子类加载器加载过的类


参考:

  1. JVM类加载机制、双亲委派和SPI机制 - 掘金

猜你喜欢

转载自blog.csdn.net/weixin_51146329/article/details/129562664
今日推荐