探索Java类加载的奥秘:从加载到初始化的完整指南

前言

Java 一直以来备受青睐,是“互联网大厂”企业级后端服务偏爱的语言。我们通过 TIOBE 编程排行榜也可一览其平均江湖地位和受欢迎程度。

image.png 近两年随着人工智能的兴起(风头正盛),Python赶超跃居榜首,Java看似不再佔據前两位。查看2023年8月份的榜单时,甚至已被挤出前三!难道Java已经凉凉?已跌落神坛?实际上,Java的需求依然是很大,Java在后端语言的领导地位仍无法撼动「手动狗头」(但不可否认的,很卷、市场饱和、门槛已经大幅度提升...)。

image.png

这主要还得归因于它很多优化点和特性。如跨平台、面向对象编程、强大的开源生态和社区、稳定性和可靠性、安全性等。

跨平台 这个特性,绝对是Java的巨大优势。是实现号称“一次编译到处运行”的关键。而实现这一目标的重要环节就是类加载。

一、程序的执行过程

要理解类的加载,那先了解在java中一个类的执行过程。
废话不多说了,上代码:

package com.jvm.test;

public class Book {

    public static void main(String[] args) {
        String name = "《三体》";
        System.out.printf("一本你不看,都不知道何为“惊艳”二字的书:" + name);
    }
}

简单的代码,那具体的执行过程是怎样的呢?

image.png

如上图,java源文件通过编译器编译为.class字节码文件,字节码文件通过JVM虚拟机,先进行字节码校验,然后再通过解释器 或 JIT编译器 翻译为特定平台的本地机器码,最终实现在不同的操作系统上运行。
其中,关于字节码翻译为机器码,JVM并不一定总是先将字节码翻译成本地机器码。JVM有两种执行策略:

  • 解释执行:JVM会逐条解释执行字节码指令,将其翻译成本地机器码后直接执行。一句话,就是边翻译边执行。
  • JIT编译:JIT编译器会将经常执行的热点代码翻译成机器码,缓存起来以供后续使用,以提高性能。并且通常只对热点代码进行一次编译。然后在后续执行中直接使用已翻译好的本地机器码。
    而如何判断是否为热点代码,会使用到一种叫“热点探测”的技术。这个不是本章的重点,了解下即可。

Java编译器将源代码编译为字节码,这是一种与具体平台无关的中间表示。然后,JVM负责将字节码翻译成适用于特定操作系统和硬件平台的机器码。从软件层面屏蔽了不同操作系统在底层硬件与指令的区别。使得Java应用程序可以在不同的操作系统上运行,从而实现“一次编译到处运行“的跨平台目标。

二、类的加载过程

当我们通过开发工具(IDEA、Eclipse等)运行这个类的main方法来启动程序时,实际上也是在后台调用java命令来执行该类的.class字节码文件。‘

通过Java命令执行代码后,其底层大体流程如下:

image.png

再看一个类在JVM中的生命周期:

image.png 而类的加载过程需要经过前五个步骤,即:加载 >> 验证 >> 准备 >> 解析 >> 初始化。

  • 加载(Loading):类加载过程的第一步,类加载器会根据类的全限定名查找并加载类的字节码。将字节码从磁盘上查找并加载到内存,并创建一个java.lang.Class对象表示这个类,作为方法区这个类的各种数据的访问入口。
    需要注意的是,是使用到这个类才会加载,例如调用类的main()方法、new对象等。

  • 链接(Linking): 链接阶段分为三个子阶段:

    • 验证(Verification): 验证被加载类的字节码,确保它符合Java虚拟机规范,不会引发安全问题。

    • 准备(Preparation): 为类的静态变量分配内存,并赋默认的初始值。
      例如下面的代码,在准备阶段,只会为price属性分配内存和赋初始值0,而不会为name属性分配内存。
      这里的初始值,一般是根据数据类型来决定的,比如数值类型会设置为0, 引用类型会设置为null。至于用户希望的最终值的赋予,将在初始化阶段进行。
      例如下面代码,在这个阶段赋予price属性的初始值是0,而不是45。

      public static int price = 45;
      public String name = "《三体》";
      

      但如果一个变量是常量(被static final修饰)的话,那么在这个阶段,属性将会被赋予用户希望的值。例如下面的代码,在这个阶段,num的值将直接是99,而不是0。

      public static final int num = 99;
      

      为何static final变量会直接赋予用户希望的值,而static变量会被赋予零值?稍微想一下也能明白了。
      final关键字,在Java中代表不可变的意思。既然一旦赋值就不会再变,那就在准备阶段直接赋予用户希望的值。而没有被final修饰的静态变量,而没有被filal修饰的静态变量,可能在初始化阶段或运行阶段还会发生变化,所以没必要在准备阶段赋予最终值。

  • 解析(Resolution): 将符号引用(例如类、方法、字段的引用)解析为直接引用(内存地址)。

  • 初始化(Initialization): 在这一阶段,类的静态初始化代码块(static块)会被执行,静态变量会被赋予初始值。
    例如下面的代码,在初始化阶段,将会触发静态变量book对象的实例化、静态代码块将会被执行、静态变量price将会被赋予用户希望的值45。

      static TestDynamicLoadBook book = new TestDynamicLoadBook(); 
      static 
      {
          System.out.println("书的静态代码块");
      }
      public static int price = 45;
    
  • 使用(Using): 当JVM完成初始化后,JVM便开始从入口方法执行程序代码。

  • 卸载(Uninstall): 当程序执行完毕,JVM便开始销毁创建的Class对象,最后负责运行的JVM也退出内存。

类被加载到方法区后,主要包含 运行时常量池类型信息字段信息方法信息类加载器的引用对应class实例的引用等信息。

类加载器的引用:这个类到类加载器实例的引用。
对应class实例的引用:类加载器把类信息加载放到方法区后,会创建一个对应的java.lang.Class对象实例放到堆(Heap)中,作为开发人员访问方法区中类定义的入口。

另外,主类在运行过程中如果使用到其它类,会逐步加载这些类。jar包 或 war包的类也不是一次性加载,是使用到才加载。

三、牛刀小试

理解了类的加载过程,那我们来看一个栗子。
看看输出结果是依次是什么。

package com.jvm;

public class TestDynamicLoadBook {
    public static void main(String[] args)
    {
        staticFunc();
        System.out.printf("price="+price);
    }

    static TestDynamicLoadBook book = new TestDynamicLoadBook();

    static
    {
        System.out.println("书的静态代码块");
    }

    {
        System.out.println("书的普通代码块");
    }

    TestDynamicLoadBook()
    {
        System.out.println("书的构造方法");
        System.out.println("name=" + name +",author=" + author +",price=" + price);
    }

    public static void staticFunc(){
        System.out.println("书的静态方法");

    }

    public static int price = 45;
    public static final String author  = "刘慈欣";
    public String name = "《三体》";
}

输出结果是:

书的普通代码块
书的构造方法
name=《三体》,author=刘慈欣,price=0
书的静态代码块
书的静态方法
price=45

下面来分析一下代码的整个执行过程。
首先,再回顾类加载过程的几个步骤:加载、链接(验证、准备、解析)、初始化。因为准备 和 初始化 是影响我们这个示例输出结果的两个重要阶段。

  • 准备阶段:为静态变量分配内存并设置初始值。
  • 初始化阶段:静态变量被赋予用户期望的值,静态代码块会被执行。

代码示例分析:

  • 首先,在准备阶段,静态变量book被分配内存,初始值为null; 静态变量price也被分配内存,初始值为0; 常量author被分配内存并赋值为“刘慈欣”;

      static TestDynamicLoadBook book = new TestDynamicLoadBook();
      public static int price = 45;
      public static final String author  = "刘慈欣";
      
    
  • 然后,进入初始化阶段:
    a. Book类被触发实例化,也就是创建book对象。先执行对象的实例初始化块,也就是普通代码块,因此输出:「书的普通代码块」;

    在Java中,无论使用哪种构造器构造对象,首先都会运行初始化块(实例初始化块),然后才会运行构造器的主体部分。

    然后执行构造方法,输出:「书的构造方法」、「name=《三体》,author=刘慈欣,price=0」。

    这个结果,关于 name=《三体》和 price=0 可能有点小疑问。

    先解释price: 这里的price为0,是因为在Java中,代码的执行是按代码在类文件中的顺序依次执行的。在我们的示例中, public static int price = 45;语句在 static TestDynamicLoadBook book = new TestDynamicLoadBook();语句之后,初始化阶段,此时还才执行到book对象的实例化的构造方法的调用,静态变量price的初始化还未被执行到,因此尚未被赋值。

    解释name=《三体》:name是Book类的成员变量,在执行 static TestDynamicLoadBook book = new TestDynamicLoadBook(); 语句后进行对象的实例化,构造器会被调用,成员变量就会被赋予预期的值。

    b. 执行静态代码块,输出:「书的静态代码块」

  • 最后,在main方法中:
    a. 静态方法staticFunc被调用,输出:「书的静态方法」。
    b. 打印静态变量price的值,此时price的值已经在初始化阶段赋值为45,所以输出: 「price=45」。

写到最后

今天,我们介绍Java类加载的全过程,还深入研究了类加载的几个很重要阶段。希望能够为你对类加载的理解带来些许启发和帮助。

作为一个第一次正式尝试写技术文章的小白,心里有些小激动和小紧张。文中可能有些地方理解得不准确,不对的地方,希望大佬们不吝赐教。

写文章的的初衷,主要是为了加深对知识的理解、强化记忆、构建更完整的知识体系、同时锻炼自己的表达能力。

如果你觉得文章还行,不妨点个赞、留个言,让我知道你来过,对我将是一种极大的正反馈。

参考资料:

  1. zhuanlan.zhihu.com/p/68304671
  2. zhuanlan.zhihu.com/p/636282878
全部评论

相关推荐

10-28 14:42
门头沟学院 Java
watermelon1124:因为嵌入式炸了
点赞 评论 收藏
分享
点赞 收藏 评论
分享
牛客网
牛客企业服务