虚拟机类加载机制

序言

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

类加载的时机

一个类型从被加载到虚拟机开始,到卸载出内存为止,它的整个生命周期会经过加载、验证、准备、解析、初始化、使用、卸载这七个阶段。如下图所示:

其中加载、验证、准备、初始化和卸载这五个阶段的顺序是固定的,但解释不一定,它在某些情况下可以在初始化之后再开始。

关于一个类何时会被初始化,《Java虚拟机规范》中已经明确规定了只有以下6种情况必须立即对类进行初始化(加载、验证、准备在此之前进行):

  1. 遇到 new、getstatic、putstatic、invokestatic 这四条指令时,如果类型没有进行初始化,则需要先触发其初始化阶段。能够生产这四条指令的代码场景有:
    • 使用 new 关键字实例化对象的时候
    • 读取或设置一个类型的静态字段的时候,被 final 修饰的除外
    • 在调用一个类型的静态方法时
  2. 对类型进行反射调用时,如果类型没有进行初始化,则需要先触发其初始化阶段
  3. 初始化类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会初始化这个主类。
  5. 当一个接口中定义了 JDK8 中新加入的默认方法时,如果有这个接口的实现类发生了初始化,那该接口要在其之前进行初始化。
  6. 使用 JDK 7 及以上版本中的动态语言支持(如 Java 调用 JavaScript)时

这六种场景中的行为称为对一个类型进行主动引用,除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

主动引用和被动引用

我们先来看一下代码示例:

  • 被动引用示例一
Java 复制代码
package jvm;

/**
 * ClassName: NotInitialization
 * Package: jvm
 * Description:
 * 被动使用字段演示一:
 * 通过子类引用父类的静态字段,不会导致子类初始化
 *
 * @Author ms
 * @Create 2024/12/6 20:11
 * @Version 1.0
 */

public class NotInitialization  {

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

class SuperClass{

    static {
        System.out.println("SuperClass init!");
    }
    
    public static int value = 123;
}

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

//输出结果如下
SuperClass init!
123
  • 被动引用示例二,为了节省版面,这里面的类复用上面的代码。
Java 复制代码
/**
 * ClassName: NotInitialization
 * Package: jvm
 * Description:
 * 被动使用字段演示二:
 * 通过数组定义来引用类,不会触发此类的初始化
 *
 * @Author ms
 * @Create 2024/12/6 20:11
 * @Version 1.0
 */
public class NotInitialization  {

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

}

// 没有任何结果输出
  • 被动引用示例三:
Java 复制代码
/**
 * ClassName: ConstClass
 * Package: jvm
 * Description:
 * 常量在编译阶段会存入调用类的常量池中
 * 本质上,调用类并没有直接引用到定义常量的类,因此并不会触发定义常量的类的初始化
 *
 * @Author ms
 * @Create 2024/12/6 20:23
 * @Version 1.0
 */
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);
    }
}

// 输出结果如下:
hello world

上面三个示例分别从子类引用父类的静态字段、定义一个数组类型、引用常量来说明被动引用不会导致类初始化,总结如下:

  • 主动引用:
    • 主动引用指的是直接对类或接口的主动使用。
    • 典型的主动引用包括通过new关键字实例化对象、访问或调用一个类的静态变量或静态方法等。这些操作都会导致类或接口的初始化过程。
    • 例如:new MyClass()、MyClass.staticMethod()、MyClass.staticField。
  • 被动引用:
    • 被动引用指的是间接地使用类或接口,不会导致其初始化。
    • 典型的被动引用包括通过子类引用父类的静态字段、定义一个数组类型、引用常量等
    • 例如:SuperClass.staticField、Constants.interfaceField、SubClass[] array

类加载的过程

接下来解析一下虚拟机类加载的全过程,说明其每个阶段所执行的具体动作。

加载

在加载阶段,Java 虚拟机需要完成以下三件事情:

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

验证

验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息不会危害虚拟机的安全。

准备

准备阶段是正式为类中定义的变量(被 static 修饰的静态变量)分配内存并设置类变量初始值的阶段。这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。假设一个类变量的定义为:public static int value = 123;那变量 value 在准备阶段过后的初始值为0而不是123,把 value 赋值为123的动作要到初始化阶段才会被执行。而对于如下类变量 public static final int value = 123;,编译时Javac将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为123。

解析

解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用是一种在编译阶段使用的、用来描述目标的引用方式,它以一组符号来表示所引用的目标,例如类的全限定名、字段名、方法名和方法描述符等。而直接引用则是可以直接指向目标的指针、相对偏移量或者能间接定位到目标的句柄。通过解析阶段,Java 虚拟机(JVM)能够在运行时准确地定位和访问类中的各种元素。可以将这个阶段理解为在内存找寻真实内存地址的过程。

  • 类或接口的解析
    • 假设在类A中引用了类B,在解析类B时,JVM 会检查B是否已经被加载。如果没有加载,会先触发B的加载过程。然后,JVM 会根据类B的全限定名在方法区(Method Area)中查找对应的Class对象。如果找到,就会将类A中对类B的符号引用替换为直接引用,这个直接引用指向方法区中类BClass对象。例如,在 Java 代码中A类中有B b = new B();这样的语句,当解析类B时,就是完成上述操作,以便后续能够正确地创建B类的实例。
  • 字段解析
    • 当解析一个字段时,JVM 首先会在当前类中查找是否有该字段的定义。如果没有找到,会按照继承层次结构向上查找父类,直到找到该字段或者到达java.lang.Object类。一旦找到字段,就会将符号引用转换为直接引用。例如,在类C中访问了从父类D继承来的字段field,在解析field这个字段引用时,JVM 会沿着C的继承链查找field,并最终确定其直接引用,使得对field的访问能够正确进行。
  • 方法解析
    • 对于方法解析,JVM 同样先在当前类中查找是否有对应的方法定义。如果没有找到,会按照继承层次结构向上查找父类。与字段解析不同的是,方法解析还需要考虑方法的重载和重写情况。如果找到匹配的方法,就会将符号引用转换为直接引用。例如,在类E中调用了一个方法method,JVM 会先在E类中查找method,如果没有找到,会在E的父类中查找,同时要判断找到的方法是否符合重写规则(如方法签名相同等),最终确定并转换为直接引用,以确保调用的方法是正确的

初始化

初始化阶段就是执行类构造器方法clinit()的过程。clinit是Classinit的缩写。此方法不是程序员定义的构造方法。是 javac 编译器自动收集类中的所有类变量(Static)的赋值动作和静态代码块的语句合并而来。构造器方法中指令按语句在源文件中出现的顺序执行,如该类具有父类,jvm 会保证子类的 clinit() 执行前,父类的 clinit() 已经执行完毕。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。

类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确定唯一性,每一个类加载器都有一个独立的类名称空间,比较两个类是否相等,只有这两个类是由同一类加载器加载的前提下才有意义,否则这两个类注定不相等。

这里的所指的相等,包括代表 Class 对象的 equals 方法,isAssignableForm() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等各种情况。如下代码,展示不同的类加载器对 instanceof 关键字运算的结果影响。

Java 复制代码
package jvm;

import java.io.InputStream;
import java.util.Objects;

/**
 * ClassName: ClassLoaderTest
 * Package: jvm
 * Description:
 * 测试类加载器
 *
 * @Author ms
 * @Create 2024/12/6 22:02
 * @Version 1.0
 */
public class ClassLoaderTest {

    public static void main(String[] args) {
        ClassLoader loader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream inputStream = getClass().getResourceAsStream(fileName);
                    if (Objects.isNull(inputStream)){
                        return super.loadClass(name);
                    }
                    byte[] bytes = new byte[inputStream.available()];
                    inputStream.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (Exception e) {
                    throw new ClassNotFoundException();
                }
            }
        };
        Object obj = null;
        try {
            obj = loader.loadClass("jvm.ClassLoaderTest").newInstance();
            System.out.println(obj.getClass());
            System.out.println(obj instanceof jvm.ClassLoaderTest);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

//运行结果
class jvm.ClassLoaderTest
false

结果为 false ,这是因为 Java 虚拟机中同时存在两个 ClassLoaderTest 类,一个是由虚拟机应用程序类加载器加载的,另外一个是我们自定义的类加载加载的,虽然它们来自同一个 Class 文件,但在虚拟机中还是两个独立的类。

双亲委派模型

从加载的示意图中可以看出,Java 虚拟机中内置了三种类加载器,分别是启动类加载器、扩展类加载器、应用程序类加载器,接下来分别说一下它们各自的职责。

  • 启动类加载器(Bootstrap Class Loader) :负责加载存放在 <JAVA+HOME>\lib 目录,或者被 -Xbootclaspath 参数所制定的路径中存放的,而且是 Java 虚拟机能够识别的类库加载到虚拟机的内存中,启动类加载器无法被 java 程序引用,获取它时,总是返回 null 值。
  • 扩展类加载器(Extension Class Loader):它负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所制定的路径中的所有类库。扩展类加载器是由 Java 代码所编写的,开发者可以直接在程序中使用扩展类加载器来加载 Class 文件。
  • 应用程序类加载器(Application Class Loader):它用于加载用户类路径(classpath)上的类库,为我们代码中所写的类,通常都是由这个类加载器加载。

双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,直到最上层的启动类加载器,如果父类加载器无法完成类加载,子类加载器才会尝试去自己加载

双亲委派模型的优势:

  • 避免了类的重复加载: 当自己程序中定义了一个和Java.lang包同名的类,此时,由于使用的是双亲委托机制,会由启动类加载器区加载JAVA_HOME/lib中的类,而不是加载用户自定义的类。此时,程序可以正常编译,但是自己定义的类无法被加载执行

  • 保护程序安全,防止核心API被随意篡改

破坏双亲委派模型

前面说过,当一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。 但这存在一个问题:在一些大型的软件框架中,例如 Java EE 应用服务器,需要加载各种不同来源的插件或模块。这些插件可能是由第三方开发者提供的,并且有自己独立的类加载需求。如果严格遵循双亲委派模型,无法实现这些插件对特定类的加载。所以我们需要破坏双亲委派模型。

破坏双亲委派模型使用的较多的就是以下两种方法:

  • 自定义类加载器覆盖loadClass方法 :我们可以通过自定义类加载器来破坏双亲委派模型。每个类加载器都有一个loadClass方法,这个方法实现了双亲委派模型的逻辑。如果我们在自定义类加载器中重写loadClass方法,不按照先委派给父类加载器的逻辑,就可以破坏双亲委派模型。
Java 复制代码
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

class MyClassLoader extends ClassLoader {
    private String classPath;

    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        try {
            byte[] data = loadClassData(name);
            return defineClass(name, data, 0, data.length);
        } catch (IOException e) {
            throw new ClassNotFoundException();
        }
    }

    private byte[] loadClassData(String className) throws IOException {
        InputStream is = new FileInputStream(classPath + File.separatorChar + className.replace('.', File.separatorChar)+".class");
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        int data = is.read();
        while(data!= -1) {
            buffer.write(data);
            data = is.read();
        }
        return buffer.toByteArray();
    }
}
  • 使用线程上下文类加载器:另一种可以破坏双亲委派模型的方式。它可以在运行时,通过线程来加载类,而不是遵循传统的双亲委派层次结构。当需要加载一些插件或者动态加载的模块时,无法使用双亲委派模型来加载。因为这些插件可能是由不同的类加载器加载的,并且可能需要访问系统类或者其他类加载器已经加载的类。此时就可以使用线程上下文类加载器。
java 复制代码
public class ThreadContextClassLoaderExample {
    public static void main(String[] args) {
        Thread.currentThread().setContextClassLoader(new MyClassLoader("."));
        try {
            Class<?> clazz = Thread.currentThread().getContextClassLoader().loadClass("MyClass");
            System.out.println(clazz.getName());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

总结

本文介绍了类加载过程中加载、验证、准备、解析和初始化这5个阶段中虚拟机进行了哪些动作,还介绍类加载器的工作原理以及双亲委派模型,这些知识对理解 JVM 有着一定的帮助,同时也是面试官喜欢问的问题。

相关推荐
火烧屁屁啦18 分钟前
【JavaEE进阶】初始Spring Web MVC
java·spring·java-ee
w_312345432 分钟前
自定义一个maven骨架 | 最佳实践
java·maven·intellij-idea
岁岁岁平安34 分钟前
spring学习(spring-DI(字符串或对象引用注入、集合注入)(XML配置))
java·学习·spring·依赖注入·集合注入·基本数据类型注入·引用数据类型注入
武昌库里写JAVA37 分钟前
Java成长之路(一)--SpringBoot基础学习--SpringBoot代码测试
java·开发语言·spring boot·学习·课程设计
Q_192849990644 分钟前
基于Spring Boot的九州美食城商户一体化系统
java·spring boot·后端
张国荣家的弟弟1 小时前
【Yonghong 企业日常问题 06】上传的文件不在白名单,修改allow.jar.digest属性添加允许上传的文件SH256值?
java·jar·bi
ZSYP-S1 小时前
Day 15:Spring 框架基础
java·开发语言·数据结构·后端·spring
yuanbenshidiaos1 小时前
C++----------函数的调用机制
java·c++·算法
是小崔啊2 小时前
开源轮子 - EasyExcel01(核心api)
java·开发语言·开源·excel·阿里巴巴