一起探索👨‍🚀JVM类加载器和类加载过程(4K高清大图)✨

2023年 9月 16日 141.7k 0

类加载过程

类加载子系统

  • 负责从文件获取网络加载Class字节流
    • 由此得知,加载一个字节码文件到内存,可以从本地,也可以从网络
  • 负责读取字节码中的信息,加载到JVM运行时内存区域(方法区)
  • 任何字节码文件被加载到JVM之前,都要符合JVM字节码规范

类加载过程图示

graph LR;
start(开始加载) --> classLoader[类加载器]
classLoader --> hasLoad{是否加载过}
hasLoad --否--> step1[第一步Loading加载阶段加载字节码到内存中]
hasLoad --是--> step2[第二步链接阶段/验证/准备/解析]
step1 --> step2
step2 --> step3[第三步initialzation初始化执行类构造器clinit]
step3 --> endLoad(加载完成)
  • 加载(Loading)阶段:该阶段是类加载过程的第一步,它的目标是将类的字节码加载到内存中。在加载阶段,Java虚拟机会根据类的全限定名查找并加载类的字节码文件,可以是从本地文件系统、网络或其他来源获取。加载后的字节码会被存储在方法区中,并在内存中创建一个代表该类的Class对象。
  • 链接(Linking)阶段:链接阶段是类加载过程的第二步,它分为三个子阶段:验证、准备和解析。
    • 验证(Verification):在验证阶段,Java虚拟机会对加载的字节码进行各种验证,以确保字节码的正确性和安全性。验证的内容包括语法检查、字节码验证、符号引用验证等。
    • 准备(Preparation):在准备阶段,Java虚拟机会为类的静态变量分配内存,并设置默认初始值。这些静态变量会被存储在方法区中。
    • 解析(Resolution):在解析阶段,Java虚拟机会将类、接口、字段和方法的符号引用解析为直接引用。解析的过程可以在编译期间进行静态解析,也可以在运行期间进行动态解析。
  • 初始化(Initialization)阶段:初始化阶段是类加载过程的最后一步,它负责执行类的初始化代码。在初始化阶段,Java虚拟机会按照程序员定义的顺序执行类的静态变量赋值和静态代码块中的代码。初始化阶段标志着类的准备工作完成,可以开始使用该类创建对象和调用方法。
  • QA:为什么验证字节码不放在第一阶段执行?

    因为第一步的时候字节码还不存在,如果在这个时候验证可能会出现各种问题,例如网络断开,文件读取失败等。

    1.加载阶段

    • 读取字节码二进制流
    • 解析二进制流的静态数据,转换为JVM运行时方法区的数据
    • 生成一个类的java.lang.Class对象,放入堆内存中,作为方法区的访问入口
    • 检测是否存在父类,如果有执行父类的类加载,重新经过一次类加载流程

    假设有两个类:Base类、User类,User继承Base,那么加载User的时候就会是以下流程:

    image-20230901115248485

    // 获取user的class对象实例
    Class clazz = User.class;
    // 通过class对象获取方法区的信息
    Field age = clazz.getField("age");
    

    堆内存存放的Class对象实例是一个访问的入口,可以通过对象实例访问方法区的数据。

    Class实例何时会创建

    • 使用new关键字

      • User user = new User()
    • 使用反射

      • Class clazz = Class.forName("cn.yufire.User")
    • 子类创建时,父类自动加载

      • public class User extents Base
    • JVM启动时,包含入口main方法的主类

    • jdk1.7后动态语言类型支持

    2.Linking阶段

    链接阶段分为3个小阶段:

    验证阶段(Verify):确保字节码符合虚拟机要求,不通虚拟机版本要求可能不一致。

    • 文件格式校验
      • CAFEBABE
      • 版本号
      • ...
    • 元数据匹配,语义分析
      • 是否有父类
      • 是否继承了final修饰的类
      • ...
    • 字节码验证,数据流与控制流分析
      • 类型转换是否有效
      • 无法执行到return?
      • ...
    • 符号引用
      • 通过全限定名是否能找到类 ClassNotFoundException
      • 通过全限定名是否能找到方法 NoSuchMethodError

    准备阶段(Prepare):为字段赋予初始值。

    • 为类的static字段赋予初始值
      • public static int a = 100;
    • 在准备阶段时,字段的值还是初始值,即a=0
      • 初始化阶段时才会使a=100

    解析阶段(Resolve):字节码内的符号引用#6转换为直接引用。

    • 其实就是将#6这些符号引用,转换成JVM内存中实实在在的内存指针关联

    • 例如:字节码中类描述信息中:Super Class:cn.yufire.Base

    • 解析后将描述信息的字符串转换为指针信息,指向内存中的Base.classClass实例

    • image-20230901143947861
    • 像这些符号引用转为内存指针引用主要针对4种类型的引用,这些东西在字节码中都是以符号引用的方式进行存储,都是静态的字符串 -> 转换为动态指针关联。

      • 类解析
      • 字段解析
      • 方法解析
      • 接口解析

    3.初始化阶段

    初始化阶段(Initialzation)

    • 初始化阶段是执行类构造器方法()的过程
    • 类的初始化构造方法()不需要显式声明,Java编译器会自动生成1。
      • 这个阶段和其它两个阶段的区别是:其它两个阶段是虚拟机主导的,和我们的代码无关,而这个阶段会使用我们的代码创建方法,并完成类的初始化。

    初始化知识点

    • 对类的变量进行赋值,和执行static代码块
    • 子类初始化过程会优先执行父类的方法
    • 没有类变量以及static{}就不会产生
    • 使用-XX:+TraceClassLoading 查看类加载过程
    • 方法默认会增加同步锁,确保只会执行一次。

    案例

    类中的所有static属性和static{}代码块如下

    @Slf4j // lombok的注解,相当于生成一个static的log对象
    public static final ThreadLocal MY_BATIS_METHOD_NAME = new ThreadLocal();
    public static Integer NUM = 10;
    
    static {
       NUM = 20;
       System.out.println("num已赋值");
    }
    

    我们类里的静态变量和static{}代码自动生成的字节码如下:

    image-20230901145605809

    Java 类加载器

    类加载就是将.class文件通过IO读入内存,并生成一个Class对象的过程。

    类加载通常由JVM提供默认的类加载器,这些被称之为系统加载器,还可以通过继承ClassLoader来实现自定义加载器。

    一个类被加载到JVM中后,通常不会被加载第二次,因为通过双亲委派模型可以很好的避免重复加载的问题。

    一、类加载器种类

    Java中默认的类加载器有三种,分别为:

    • BootStrap ClassLoader (启动类加载器)
      • 执行优先级最高的类加载器
      • 用于加载JRE核心类库的.jar文件,如rt.jar
      • 使用C语言实现,双亲委派顶级祖宗加载器
    • Extensions ClassLoader 也称 (Ext ClassLoader) (扩展类加载器)
      • 用于加载JRE扩展目录下的.jar文件
      • 使用Java实现
    • Application ClassLoader (应用程序类加载器)
      • 用于加载用户自己编写的类文件,和项目中引用其他.jar包中的类文件
    • Custom ClassLoader (自定义加载器)
      • 用于加载指定目录下的类,通常用于自定义实现
    • 关系类图如下

    类加载器

    二、双亲委派模型

    1. 双亲委派模型其表达的意思是,类加载器与父亲加载器嵌套,当需要加载类的时候回去现寻找父亲加载器,如果有父亲加载器的话就优先使用父亲加载器,如果父亲加载器无法完成加载的话,再使用自己进行加载。

    说人话:现在有一个苹果(Class),你不吃你给你妈妈吃,你妈妈不吃又给你奶奶吃,你奶奶不吃有给你妈妈吃,你妈妈不吃又给你吃。

    2. 使用双亲委派模型有以下好处

    • 可以避免重复加载类
      • 虽然有多个类加载器,但是因为每个类都是唯一的,且都会通过父亲加载器加载,所以可以避免类加载重复。
    • 可以避免Java核心类被用户篡改
      • 如果用户自定义了一个类为java.lang.String,那么会一层一层向上加载,直到BootStrap ClassLoader祖宗类加载器(入口加载器),但是在这个加载器里已经将JDK包下的java.lang.String加载过了,所以会直接跳过加载用户编写的String类。
      • 但是此处有个BUG,就是双亲委派可以被破坏!

    3. 双亲委派模型图

    三、破坏双亲委派

    双亲委派有两个弊端:

    • 不可以不委派
    • 不可以向下委派

    但是这两个弊端都可以被打破

    • 通过自定义类加载器可以破坏:不可以不委派
    • 通过SPI机制可以破坏:不能向下委派

    源码中可以得知,双亲委派机制是由此处进行调用的

    // AppClassLoader  ||  ExtClassLoader 
    public Class loadClass(String name, boolean resolve)
       throws ClassNotFoundException
     {
       int i = name.lastIndexOf('.');
       // 权限验证
       if (i != -1) {
         SecurityManager sm = System.getSecurityManager();
         if (sm != null) {
           sm.checkPackageAccess(name.substring(0, i));
         }
       }
    
       // 判断类在classPath中是否存在
       if (ucp.knownToNotExist(name)) {
         // The class of the given name is not found in the parent
         // class loader as well as its local URLClassPath.
         // Check if this class has already been defined dynamically;
         // if so, return the loaded class; otherwise, skip the parent
         // delegation and findClass.
         // 查找此类是否已经被加载过了,如果是的话则返回Class对象,否则返回null,则调用父类loadClass()方法进行加载
         Class c = findLoadedClass(name);
         if (c != null) {
           if (resolve) {
             resolveClass(c);
           }
           return c;
         }
         throw new ClassNotFoundException(name);
       }
    	 // 调用父类的loadClass,内部实现了双亲委派
       return (super.loadClass(name, resolve));
     }
    

    父类的loadClass()

    protected Class loadClass(String name, boolean resolve)
      throws ClassNotFoundException
    {
      // 获得加载锁
      synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        // 查询是否被加载过了,如果加载过了,直接跳过
        Class c = findLoadedClass(name);
        if (c == null) {
          long t0 = System.nanoTime();
          try {
            // 查找当前加载器是否有父亲加载器,(双亲委派机制)
            if (parent != null) {
              // 调用父亲加载器进行加载
              c = parent.loadClass(name, false);
            } else {
              // 如果父亲加载器等于null的话,则代表执行当前方法的加载器为BootStrap ClassLoader,并使用此加载器进行加载
              // native方法,由C实现
              c = findBootstrapClassOrNull(name);
            }
          // 父亲加载器无法加载
          } catch (ClassNotFoundException e) {
            // ClassNotFoundException thrown if class not found
            // from the non-null parent class loader
          }
    			
          // 如果父亲加载器无法加载的话,则自己在进行加载
          if (c == null) {
            // If still not found, then invoke findClass in order
            // to find the class.
            long t1 = System.nanoTime();
            c = findClass(name);
    
            // this is the defining class loader; record the stats
            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. 不委派父亲加载器

    从上述源码中可以得知,双亲委派机制是由ClassLoader -> loadClass()方法进行实现的,而且此方法非final修饰,则代表此方法是可以被我们的自定义加载器重写的。

    双亲委派的核心源码为:

    // 查找当前加载器是否有父亲加载器,(双亲委派机制)
    if (parent != null) {
      // 调用父亲加载器进行加载
      c = parent.loadClass(name, false);
    }
    

    如果我们将loadClass()方法进行重写,且不执行这段逻辑的话,则双亲委派机制就会被打破,从而遭到破坏!

    2. 为什么建议重写findClass()方法

    因为是出于历史原因,且JDK是向上兼容的,所以该方法并不能随便进行更改,但是JDK开发者也意识到了这个问题,所以在ClassLoader抽象类中新增了findClass()方法,供开发者进行重写,和实现自定义类加载器。

    JDK源码中的loadClass()方法上有一段注释说明了此方法不建议被重写:

    /*加载具有指定二进制名称的类。该方法的默认实现按照以下顺序搜索类:调用findLoadedClass(String)检查类是否已经加载。在父类装入器上调用loadClass方法。如果父类为空,则使用虚拟机内置的类装入器。调用findClass(String)方法来查找类。如果使用上述步骤找到了类,并且resolve标志为真,则该方法将对生成的class对象调用resolveClass(class)方法。ClassLoader的子类被鼓励重写findClass(String),而不是这个方法。除非被重写,否则这个方法在整个类加载过程中同步getClassLoadingLock方法的结果。*/
    protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException {...}
    

    2. 向下委派子加载器

    Java中通过SPI机制可以实现向下委派子加载器,至于为什么这么做也是因为一些历史原因

    JDK中引入了SPI机制后,例如JDBC有个驱动管理类DriverManager,他是在rt.jar下的,由BootStrap ClassLoader进行加载,但是由于这个类里需要加载所有的Driver驱动,但是,像是MySQLOracle等数据库驱动都是由各家数据库厂商实现的,是以外部依赖的模式进行引入的,此时如果SPI机制想要顺利的加载驱动,就不能使用BootStrap ClassLoader因为各个数据库驱动并不在rt.jar下,所以无法加载,因此JDK搞了一个线程上下文类加载器,和setContextClassLoader() 来手动设置类加载器,才解决了这个问题,此时其实已经是调用了线程上下文类加载器,一般为Application ClassLoader,进行加载驱动的动作了。

    四、自定义Java内部

    如果在自己的工程目录中自定义了一个和JDK中同包,同类名的一个类,例如:java.lang.String,会有什么样的结果?

    首先我们了解类加载器和类加载器的双亲委派机制,因此得知,要加载这个类,可以使用Application ClassLoaderCustom ClassLoader进行加载,当其中一个类加载器加载时,会调用父亲的loadClass()方法,最终调用到BootStrap ClassLoader中去,但是由于java.lang.StringJDK核心类,并且已经被祖宗加载器加载过了,所以会跳过自己工程下的java.lang.String。从而避免了核心类篡改和重复加载的问题。

    此时如果调用自定义java.lang.String类的方法,会报**NoSuchMethodError**异常。因为自定义的类并没有被加载。

    五、附

    类加载图解

    img

    Footnotes

  • 编译器自动生成的Class init方法,并非默认的构造方法,不写类也会自动生成 ↩

  • 相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论