死磕P7:JVM类加载机制(一)

p7   jvm   java  
发布于 Sep 26, 2024

这是「死磕P7」系列第 005 篇文章,欢迎大家来跟我一起 死磕 100 天,争取在 2025 年来临之际,给自己一个交代。

前面几篇介绍了 JVM 的区域划分及垃圾收集相关的内容,只是告诉了你我有一栋豪华大酒店,大楼里有哪些房间,以及向你介绍了负责打扫卫生的几个清洁工,但是还没告诉你楼梯在哪呢,你该如何才能入住到房间呢?

今天我们就来介绍一下,Java 程序从一个 .java 文件是如何运行的,当然,依照目前的水平,还是尽量介绍的浅显易懂一点比较好。

大家比较熟知的流程是:编写 Hello.java -> 经过 javac 编译为 Hello.class 文件 -> 执行 java Hello 运行

这个过程,涉及到从源代码到字节码,最后再到机器码的过程,JVM 负责加载字节码并将其转化为机器码,绕了那么多,其实我就想说一个考点:类加载

类加载过程

类的源文件一般是 xx.java, 经过编译后得到字节码 xx.class 文件,程序在运行时,JVM 首先需要将所有 xx.class 文件加载到 JVM 中。

类从加载虚拟机内存中开始到卸载出内存为止,生命周期包括:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析统称为连接(Linking)。

类加载过程是有 5 步,类的生命周期是有 7 步(多了使用和卸载)。

类的加载由类加载器完成,类加载器通常由JVM提供,这些类加载器也是所有程序运行的基础,JVM提供的类加载器通常被称为系统类加载器。

除此之外,开发者可以通过继承 ClassLoader 基类来创建自己的类加载器(后续文章会提供案例,本文先罗列概念)。

加载阶段

加载阶段,JVM 主要做了三件事情:

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

二进制字节流的来源有很多,例如:从 ZIP 压缩包读取、从网络获取、运行时计算生成(动态代理)等。

静态存储结构就是指某个类或接口的静态变量还有静态方法。

稍微理解一下对象和类的概念,对象是实例化的类。

类的信息是存储在方法区中的,对象是存储在Java堆中的,类是对象的模板,对象是类的实例。

验证阶段

确保 Class 文件信息符合 JVM 规范,防止恶意代码危害虚拟机自身安全。

主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。

  • 文件格式验证:基于二进制字节流进行的文件格式验证,如版本是否兼容,编码是否是所允许的
  • 元数据验证:对类的信息进行语义校验,如一个类是否有父类,该父类是否继承了 final 修饰的类
  • 字节码验证:主要是数据流和控制流分析,确定语义合法、符合逻辑,如类型转换有效
  • 符号引用验证:发生在虚拟机将符号引用转为直接引用时,确保解析动作能正常执行,如符号引用中通过描述的完全限定名是否能找到对应的类

验证阶段虽然很重要,但并非必须执行,若程序代码已被反复使用和验证,可以考虑关闭大部分类验证,以缩短类加载的时间。

-Xverify:none

准备阶段

主要为类变量(即 static 修饰的静态变量)分配内存并设置「初始值」。

这里的初始值“通常”情况指的是类型零值,基本数据类型的零值如下:

看下面的示例:

// 经过「准备」阶段后,该初始值为 0
// 而把 value 赋值为 123 是在后面的「初始化」阶段
public static int value = 123;

注意:静态变量是被 final 修饰的字面值(就是具体的值,如123)的时候,由于静态变量字面值是在编译时(javac)确认的,准备阶段是直接赋值。

// 编译阶段将 value 生成常量值,在准备阶段虚拟机会直接将 value 赋值为 123
public static final int value = 123;

解析阶段

这个阶段的主要任务是将其在常量池中的符号引用替换成在内存中的直接引用。

符号引用,看例子吧:

public class A {
}

public class B {
  private A a;
}

其中 B 持有对 A 的引用,但此时两个类并未加载到内存中,仅仅是一个标记而已。

直接引用就是能够直接在内存中找到相应对象的内存地址。若有直接引用,则目标必定已在虚拟机中。

初始化阶段

初始化是为类的静态变量赋予用户声明的初始值(非基本类型的默认值),准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的。

如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段给a分配内存及默认值,此时a等于int类型的默认初始值0,即a=0,然后到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。

初始化阶段,才真正开始执行类中的Java程序代码,即执行类的初始化方法 clinit。

在编译生成class文件时,编译器会产生两个方法加于class文件中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init。

  • clinit 指的是类构造器,主要作用是在类加载过程中的初始化阶段进行执行,执行内容包括静态变量初始化和静态块的执行,该方法并不是必需的,若类中无静态语句块和对变量的赋值操作,编译器可以不生成这个方法。。
  • init 指的是实例构造器,主要作用是在类实例化过程中执行,执行内容包括成员变量初始化和代码块的执行。

JVM 必须保证一个类的 clinit 方法在多线程环境被正确地加锁同步。如果多个线程同时去初始化一个类,只能有一个线程去执行 clinit 方法,其他线程都要阻塞等待。

设计模式的「单例模式」就有一种写法是利用该机制来保证线程安全性的,示例代码如下:

public class BeanFactory {
  private BeanFactory() {
  }

  public BeanFactory getBeanFactory() {
    return BeanFactoryHolder.beanFactory;
  }

  /**
   * 使用内部嵌套类实现单例,利用 JVM 的类加载机制可保证线程安全
   */
  private static class BeanFactoryHolder {
    private static BeanFactory beanFactory = new BeanFactory();
  }
}

使用阶段

当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。

这个阶段也只是了解一下就可以。

卸载阶段

最后卸载阶段,也就是程序退出,有多种情况:

  • 执行了 System.exit() 方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

总结

今天先分享(死磕)到这里,本文注意罗列了 JVM 类加载过程的几个步骤,分别是 加载,链接(验证,准备,解析),初始化,类的生命周期还额外的补充了 2 点(使用,卸载),至于各个阶段干了啥,大概了解一下就行,可以重点理解一下准备阶段的赋默认值和初始化阶段的赋初始值的区别。

本次的分享到此结束,希望对你有所帮助。

如果你对我分享的内容感兴趣,欢迎扫码关注公众号:新质程序猿,并设置星标,推送更实时哟!

本文由 黄彦祥 创作,采用 知识共享署名 3.0 中国大陆许可协议 进行许可。
可自由转载、引用,但需署名作者且注明文章出处。