学一点关于JVM类加载的知识

发布时间 2023-12-27 15:51:43作者: Java备忘录

要研究类加载过程,我们先要知道关于 Java 处理代码的流程是怎么样的。

第一步:编写源代码

这一步是我们最熟悉的,就是我们在 idea 上写的业务代码,生成 Example.java 文件。

public class Example {
    public static void main(String[] args) {
        int a = 10;
        int b = 20;
        int sum = a + b;
        System.out.println(sum);
    }
}

第二步:编译源代码

我们通过 java 编译器(如‘javac’)将我们编写的源代码编译成字节码

wtf is 字节码?

要知道字节码之前,要先知道机器码

wtf is 机器码?

机器码就是机器才能看懂的码,机器能看懂什么码?机器就只能看懂‘0100110’这种二进制码。

这种码有这样几个特点:特定于硬件、高效、难以理解

C、C++的源代码通过编译器就可以转换成机器码,你写成什么样,机器就执行成什么样。

我们再说回字节码

字节码不同于机器码,它是一种中间代码,它不直接跟机器对话,它是面向 Java 虚拟机的(java 虚拟机与机器对话),而不是任何硬件,这也是字节码区别于机器码的主要特征。
也是所谓“一次编写,到处运行”的特征的主要大功臣。

我们把 Example.java 编译成字节码,存储在 Example.class 中。

它长这样:

cafe babe 0000 0034 005e 0a00 1500 4709
0005 0048 0900 0500 4909 0005 004a 0700
4b0a 0005 004c 0a00 0500 4d0a 0015 004e

这是给人看的?当然不是,这个给虚拟机看的。

JDK 提供自带的反编译工具,执行 **javap -v Example.class **可以将看不懂的上述字节码反编译成给人看的字节码,它长这样:

Compiled from "Example.java"
public class Example {
  public Example();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: bipush        10
       2: istore_1
       3: bipush        20
       5: istore_2
       6: iload_1
       7: iload_2
       8: iadd
       9: istore_3
      10: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      13: iload_3
      14: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
      17: return
}

什么?这你也看不懂?你不学肯定看不懂啊。

不过不懂没关系,这不是我们的任务,我们暂时或者永远也不需要看懂。

我们只要知道 Java 虚拟机大概在做什么就好了。

编译完了以后进行下一步。

第三步:加载字节码

有了 JVM 能看懂的字节码,你得用起来吧,所以 JVM 就得去读取字节码、创建相应的类等。

这就到了我们这一篇文章所要研究的东西了,类加载

这一步需要特别注意,这里所谓的“类”,指的是class 文件,而这个 class 文件也不是单纯指的是编译后产生的.class 文件,而是一串二进制字节流,它可能来源于磁盘文件、网络、数据库、内存或者动态产生。所以理解的时候不要过于片面。

接下来进入主题!

JVM 类加载机制

定义

Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制

类加载的时机

在讲类加载时机之前我们需要先讲类的生命周期

加载、验证、准备、解析、初始化、使用、卸载。

这是 7 个阶段,我们有以下 3 点要知道:

  • 验证、准备、解析阶段统称为链接阶段(Linking)
  • 类加载包含三步:加载、链接、初始化,也就是说本篇不讨论使用和卸载这 2 个阶段
  • 解析阶段可能在初始化前,也可能在初始化后,后面会解释。

具体 7 个阶段在做什么,我们后面会逐一解释。

下面我们讨论本小节重点:类加载的时机

我们记住以下 2 个事实:

  • 类加载包含 3 步:加载、链接、初始化(上面提到过)
  • 加载、验证、准备、初始化、卸载这 5 步必须按部就班,不准乱序

那么这个类加载到底什么时候发生呢?

JVM 规范对“加载”(loading,即类加载第一小步)这个阶段没有约束,也就是说,我们无法根据“什么时候 loading”来判断“什么时候类加载开始了”。

那 JVM 约束了什么呢?

JVM 规定了有 6 种情况必须立即对类进行初始化,我们前边说了:

  1. 初始化是类加载的其中一步
  2. 加载验证一定发生在初始化之前

我们我们就笼统的下一个结论:

JVM 规定的这 6 种情况,只要发生了,就说明了类加载过程一定发生了。
JVM 规定的这 6 种情况,只要发生了,就说明了类加载过程一定发生了。
JVM 规定的这 6 种情况,只要发生了,就说明了类加载过程一定发生了。

那么哪 6 种情况呢?

这 6 种情况可以统一成一种情况:对一个类进行主动引用时

那么有主动引用就会有被动引用咯。

再换句话说:

对一个类进行主动引用时,就会触发初始化,即触发类加载过程。
对一个类进行被动引用时,就不会触发初始化,也就不会触发类加载过程。

下面我们分别讲主动引用的 6 种情况和被动引用的 3 个例子。

主动引用的 6 种情况

  1. 使用 new 关键字实例化对象时
  2. 读取或设置类的静态字段时(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)
  3. 调用类的静态方法时
  4. 对类进行反射调用时

什么?你不会反射?《Java反射,看完就会用》

  1. 当初始化类时,发现父类没有初始化时,要先初始化其父类
  2. 虚拟机启动时,初始化包含 main()方法的那个主类

其实《深入理解 Java 虚拟机》中还有 2 种:

  1. 使用 JDK 7 新加入的动态语言支持时
  2. 接口中定义了 default 默认方法,此接口的实现类初始化时要先初始化此接口

以上前 6 种基本都比较好理解,就不啰嗦了,第 7 种我自己根本看不懂,也不想研究,有想研究的自己研究一下吧,在第七章 7.2 小节中。

被动引用的 3 个例子

  1. 通过子类引用父类的静态字段,不会导致子类初始化
public class Father {
    static {
        System.out.println("父类初始化完成!");
    }

    public static int value = 123;
}
public class Son extends Father {
    static {
        System.out.println("子类初始化完成!");
    }
}
public class Test {
    public static void main(String[] args) {
        System.out.println(Son.value);
    }
}

输出结果:

父类初始化完成!
123
  1. 通过数组定义来引用类,不会导致此类的初始化

依然使用上面的 Father、Son 类,测试如下:

public class Test02 {
    public static void main(String[] args) {
        Father[] arr = new Father[10];
    }
}

运行结果没有任何输出,说明没有触发 Father 类的初始化。

  1. 常量在编一阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
public class Const {
    static {
        System.out.println("Const类初始化完成!");
    }

    public static final String HELLO_WORLD = "hello world!";
}
public class Test03 {
    public static void main(String[] args) {
        System.out.println(Const.HELLO_WORLD);
    }
}

测试结果:

hello world!

发现 Const 类也是没有初始化,原因就是常量 HELLO_WORLD 在编译阶段已经被存储在了 Test03 这个类的常量池了,而不是通过引用来传递这个常量了。

上面我们知道了类加载发生在什么时候。

下面我们具体说一下类加载这 5 个小阶段具体在干什么。

类加载的 5 个阶段

加载

这一阶段 JVM 要完成 3 件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流

我们前面说了,这个二进制字节流不要片面的以为它只有来自我们编译获得的.class 文件,虽然大部分都是,但是它还可能来自网络、数据库、内存或者动态产生!

用什么获取呢?

类加载器。

So wtf is 类加载器?

类加载器就是把 class 文件加载到 JVM 内存中的工具(除了数组类不通过类加载器加载,了解一下即可)。

那也就是说!

我们现在看到的,马上要学习的,就是我们整篇文章最最核心的东西了!

类加载器的种类

怎么类加载器还要分种类?

没错,但为什么要分种类的?那自然是因为类本身是有分类的。

放屁,我写的类众生平等!

没错,我们写的类可能真的众生平等,但是架不住这个世界上有天龙人。

java 世界的天龙人就是 java 自己的核心类库,还有次一级天龙人就是 java 扩展库,然后才轮到我们自己写的应用类。

也就是说:

  • 引导类加载器(Bootstrap Class Loader):它是类加载器层次结构中的最顶层加载器,负责加载JVM的核心类库,这些类放在<JAVA_HOME>\lib 目录下, 如rt.jar中的类。
  • 扩展类加载器(Extension Class Loader):它负责加载Java的扩展库,通常是<JAVA_HOME>/lib/ext目录下的jar包。
  • 系统类加载器(System Class Loader):也称为应用类加载器,它根据Java应用的类路径(CLASSPATH)来加载Java类。

以上就是 3 种类加载器,当然我们还有一种自定义的类加载器,但是我理解加载过程现在都费劲,你让我自定义?不可能的,系统类加载器又强大又方便,自定义是不可能自定义的。
这样分类就是说,我们编译得到的 example.class 只能用系统类加载器加载,而高贵的天龙人 java.lang.String 类就用引导类加载器加载。

这时候陈某就不服了:王侯将相宁有种乎?

上来就自己造了一个自己的 java.lang.String 类在本地。

你是 String,我也是 String,谁比谁高贵?

不好意思啊,天龙人有自己的办法,你的 String 不好用,我的好用。

那就是双亲委派模型。

双亲委派模型

什么双亲?什么委派?

我们上述 3 种类加载器是分档次的,但是不要被“双亲”混淆,它们之间不是父子关系,而是层级关系,你贱民加载器不可能跟天龙人加载器是父子关系放心。

我们加载类,需要全限定名,拿到全限定名就得开始加载。

那么双亲委派模型就是:

一个类加载器在加载类时,首先会委托其父类加载器来尝试加载这个类,只有在父类加载器无法完成这个任务时,子类加载器才会尝试去加载这个类。

注意读法:父-类加载器,不是:父类-加载器。

举个例子,我们自己写了个类,java.com.test.pojo.Example,首先让应用类加载器加载,它说不敢不敢,先让扩展类加载器老爷加载,扩展类加载器说不敢不敢,先让引导类加载器大人加载,引导类加载器说:我这加载不了,你自己试试吧。扩展类这试了试发现也不行,就说:你自己加载吧。

于是这个 Example 类就由我们的应用类加载器加载了。

那万一陈某的 Java.lang.String 类呢?

首先应用类加载器就要让扩展类先加载,他不敢加载得让引导类先加载,引导类说:嗯,这个 String 是我们天龙人的类,我加载了。

于是 java 世界中,你的假冒的 String 类就永远也加载不进 JVM 内存,也就永远也用不了。

想做天龙人?

也不是不行,不过就要破坏双亲委派模型,我们就不讲了,因为我也不太会。

双亲委派模型的好处如下:

  • 避免类的重复加载:由于每个类加载器都会首先尝试使用其父类加载器来加载一个类,这保证了每个类在JVM中只被加载一次,防止了重复加载。
  • 保护核心Java API:这个机制阻止了用户自定义的类替换核心Java API。例如,用户不能定义自己的java.lang.Object类。
  1. 将这个字节流中的数据按照方法区的数据格式存储在方法区中

大概意思就是说:

我们本来的类信息全部存在 class 文件中,现在要加载到 JVM 内存,你得入乡随俗听 JVM 老大哥的安排,它说数据怎么存就怎么存,反正没给你落下东西就行了呗。

  1. 在堆内存中生成一个代表这个类的 java.lang.Class 实例对象,作为方法区这个类的各种数据的访问入口

东西在方法区存好了,怎么拿呀?去堆内存里面找类的实例对象 Class,拿到 Class 对象,然后再就能获取到类信息了。什么?你不懂?再去看看反射!

这部分搞不明白的再回去看看内存结构!

验证

这一步理解起来很简单,我们再重复一遍,字节流的来源有很多,不全是 java 文件编译而来的,你甚至可以自己 0101 的敲出来, 既然如此,那将来能不能用、对虚拟机是否有害就得考虑一下了,所以这一步需要验证一下。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。

类变量指的是属于类的变量,也就是被 static 修饰的静态变量,它们被存储在方法区(逻辑概念上的方法区,具体实现中有所不同)。

初始值的设置指的是把变量值设置为零值,什么是零值?

83a7d045137eb69724bd39f4c4cedc6.jpg

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用和直接引用又是什么?

我们通俗一点讲,符号引用就是给你在这个位置放一个符号标志,根据这个符号你将来肯定能找到目标定位就是了。

而直接引用就望文知义,就是说,你这个引用已经存在 JVM 内存里了,你拿着这个就能直接找到。

类似的解析过程我们可以类比地址的解析,baidu.com->具体服务器 ip 地址的解析就差不多这个意思。

总结来说,解析的过程就是未明确->明确的过程

这里我们再解答一下前面的问题:为什么解析可能发生在初始化前或者初始化后?

这里就涉及到多态的知识点。

我们前面说解析的过程是未明确->明确的过程,那万一,你之前的符号引用是一个接口呢?万一这个接口有 2 个实现类呢?那你解析阶段怎么明确呢?

那明确不了的话就只能放到初始化以后,运行阶段拿到具体的绑定信息以后才能进行解析了。

那前者就叫做静态解析,后者就叫动态解析。

初始化

前边我们讲类加载的时机已经讲过了 JVM 规范对于初始化时机的规定,这里我们就简单说一下初始化阶段做了什么,本篇文章就万事大吉了。

初始化阶段是执行初始化方法 ()方法的过程。

我们前边准备阶段给类变量赋了初始值,到初始化这阶段,我们才真正给类变量赋予程序员实际给定的“值”,除此之外,如果类中有 static 代码块,则执行该代码块。

以上我们说了两件事(静态字段初始化、执行静态初始化块),就是初始化方法 ()方法做的两件事。

那我们还有最最后一个疑问:

wtf is ()?

这个东西有以下几个特点:

  • 不是程序员写的,是编译时自动生成的
  • 只在初始化时执行一次
  • 里面包含了类中所有静态变量的赋值动作和初始化静态代码块的代码

什么?还不懂?那就把它忘了吧!

以上就是关于类加载过程的全部内容,感谢阅读。


联系我:https://haibin9527.gitee.io/about_me/