面试官问我JVM类加载,我笑了

本文详细探讨了JVM中的类文件结构,包括魔数、版本号、常量池、访问标志、类索引等关键部分。接着介绍了类加载机制,包括类加载时机、加载过程、双亲委派模型以及自定义类加载器的应用,揭示了JVM如何确保类的安全加载和运行。文章适合对JVM感兴趣的开发者阅读。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在这里插入图片描述

上一篇聊了JVM的内存管理、垃圾回收、类文件结构三个方面,这一篇我们继续说说JVM有关的类文件结构和类的加载

小白读了这篇JVM,直呼真香,淦!(长篇干货预警)

类文件结构

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,无空隙存在。

对于超过8个字节的数据,将按照Big-Endian的顺序存储的,也就是说高位字节存储在低的地址上面,而低位字节存储到高地址上面(也就是高位在前的方式)分割成若干个8个字节进行存储

Class文件结构采用类似C语言的结构体来存储数据的,这种结构体只有两种数据类型:无符号数和表。后面的解析都是以这两种数据类型为基础,这里先介绍这两个概念:

无符号数是基本数据类型,用来表述数字、索引引用以及字符串等,比如 u1,u2,u4,u8分别代表1个字节,2个字节,4个字节,8个字节的无符号数

表是由多个无符号数以及其它的表组成的复合结构,习惯地以_info结尾。表用于描述有层次关系的符合结构的数据,整个Class文件本质上就是一张表

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据叫做某一类型的集合

如图所示,是Class的文件格式,接下来详细解释
在这里插入图片描述

魔数和版本号

Class文件的前四个字节称为魔数,它的唯一作用就是确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都是使用魔数来进行身份识别的,使用魔数而不是扩展名来进行识别主要是基于安全的考虑,因为文件扩展名可以随意的改动

Class 文件的魔数是用 16 进制表示的“CAFE BABE”,是不是很具有浪漫色彩?

紧接着的四个字节是版本信息,5-6 字节表示次版本号,7-8 字节表示主版本号,它们表示当前 Class 文件中使用的是哪个版本的 JDK。

Java的版本号是从45开始的,JDK1.1之后的每个JDK主版本向上加一。高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即时文件格式并未发生任何变化,虚拟机也必需拒绝执行超过其版本号的 Class 文件

常量池

主次版本号之后就是常量池入口,常量池可以理解为Class文件之中的资源仓库。

它是Class文件中和其它项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它也是Class文件中第一个出现的表类型数据项目。

由于常量池的数量不是固定的,所以在常量池的入口放置一项U2类型的数据代表常量池容量计数值。(这个数是从1开始而不是从0开始的)

在Class文件中,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池的项目”的含义,这种情况就可以把索引值置为0来表示。

class文件只有常量池的容量是从1开始的,其余都是从0开始的

常量池中存放两种类型的常量:

字面值常量:字面值常量就是我们在程序中定义的字符串、被 final 修饰的值

符号引用:符号引用就是我们定义的各种名字:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符

虚拟机加载Class文件时进行动态链接,也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期的转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用

常量池的每一项常量都是一个表,表的结构有很多种,表开始的第一位是一个 u1 类型的标志位(tag),如下图,代表当前这个常量属于哪种常量类型。

这十几种常量类型每种均有自己的结构,较复杂

在这里插入图片描述

举个例子:

对于 CONSTANT_Class_info(此类型的常量代表一个类或者接口的符号引用),它的二维表结构如下:
在这里插入图片描述

tag 是标志位,用于区分常量类型;

name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型常量,此常量代表这个类(或接口)的全限定名,这里 name_index 值若为 0x0002,也即是指向了常量池中的第二项常量

CONSTANT_Utf8_info 型常量的结构如下:
在这里插入图片描述

tag 是当前常量的类型;length 表示这个字符串的长度;bytes 是这个字符串的内容(采用缩略的 UTF8 编码)

顺便提一下,在JDK的bin下有一个专门分析Class文件字节码的工具:javap

在分析中可以查看到一些常量从未在代码中出现过,这部分自动生成的常量的确没有在Java代码中直接出现过,但它们会被后面的字段表、方法表、属性表引用到,它们会用来描述一些不方便使用固定字节进行表达的内容

访问标志

在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;

是否定义为 public 类型;是否被 abstract/final 修饰
在这里插入图片描述

类索引、父类索引、接口索引集合

类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名

由于 Java 不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。

一个类可能实现了多个接口,因此用接口索引集合来描述。

这个集合第一项为 u2 类型的数据,表示索引表的容量,接下来就是接口的名字索引

类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过该常量总的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串

字段表集合

字段表集合存储本类涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量

在一个Java中描述一个字段包含哪些信息?可以包括的信息有:字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile)、是否可被序列化(transient修饰符)、字段数据类型和字段名。上述信息中,各个修饰符都是布尔值,适合用标志位来表示。

而字段叫什么名字、字段被定义成什么类型无法固定,只能引用常量池中的常量来描述(注意这里,是引用常量池的常量)

字段表的结构如下:

在这里插入图片描述

如上图所示,字段修饰符放在access_flags项目中,它与类中的access_flags项目很类似,都是一个u2类型数据,其中可以设置的标志位和含义如下表:
在这里插入图片描述

字段表集合中不会出现从父类(或接口)中继承而来的字段,但有可能出现原本 Java 代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段

方法表集合

Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的字段如同字段表的一样,依次是访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)。

volatile 关键字 和 transient 关键字不能修饰方法,所以方法表的访问标志中没有 ACC_VOLATILE 和 ACC_TRANSIENT 标志。

方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译后的字节码指令

属性表集合

在Class文件、字段表、方法表都可以携带自己的属性表集合,用于描述某些场景的专有信息。属性表中不要求各个属性表具有严格的顺序,只要不与已有属性重名即可。

每个属性对应一张属性表,属性表的结构如下:
在这里插入图片描述

类加载机制

上面了解了Class文件存储格式的具体细节,在Class文件中描述的各种具体信息,最终都需要加载到虚拟机之后才可以运行使用。

而虚拟机如何加载这些Class文件的呢?

接下来我们要说的就是虚拟机的类加载机制,虚拟机把描述类的数据从Class文件加载到内存(例如Class文件常量池加载到方法区的运行时常量池),并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,即虚拟机的类加载机制

类加载时机

类的生命周期有7个阶段,其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。解析阶段可以在初始化之后再开始(运行时绑定或动态绑定或晚期绑定)

在这里插入图片描述

虚拟机规范严格规定了有且只有5中情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前完成),下面一一介绍:

遇到new、getstatic、putstatic或 invokestatic 这 4 条字节码指令时没初始化触发初始化。使用场景:使用 new 关键字实例化对象、读取一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法

使用 java.lang.reflect 包的方法对类进行反射调用的时候

当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化

当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类

当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化

前面的五种方式是对一个类的主动引用,除此之外,所有引用类的方法都不会触发初始化,称为被动引用。看下面的例子:

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

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

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) {
    /**
     * 非主动使用类字段演示 output : SuperClass init!
     * 
     * 通过子类引用父类的静态对象不会导致子类的初始化 只有直接定义这个字段的类才会被初始化
     */
    System.out.println(SubClass.value);

    /**
     * 通过数组引用未定义类,不会触发此类的初始化 output : SuperClass init!
     * 
     * 通过子类引用父类的静态对象不会导致子类的初始化 只有直接定义这个字段的类才会被初始化
     */
    SuperClass[] sca = new SuperClass[10];

    /**
     * output :
     * 
     * 常量在编译阶段会存入调用类的常量池当中,本质上并没有直接引用到定义类常量的类, 因此不会触发定义常量的类的初始化。“hello world”
     * 在编译期常量传播优化时已经存储到 NotInitialization 常量池中了,以后NotInitialization 对常量
     * ConstClass.HELLOWORLD的引用时机上都转化成NotInitialization 类对自身常量池的引用了。
     * (NotInitialization 的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译之后便不存在联系了)
     */
    System.out.println(ConstClass.HELLOWORLD);

  }
}

针对接口的特殊说明:上面介绍的类的初始化场景第3中,当一个类初始化时要求其父类全部都已经初始化过了,但是在一个接口初始化时,并不要求其父接口都已经初始化,只有在真正使用父接口的时候(如引用到接口中定义的常量)才会初始化

类加载过程

下面讲解类加载的全过程,也就是加载、验证、准备、解析和初始化这5个阶段

1、加载

加载是类加载机制的第一个过程,在加载阶段,虚拟机主要完成三件事:

通过一个类的全限定名来获取其定义的二进制字节流(ZIP 包、网络、运算生成、JSP 生成、数据库读取)

将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

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

数组类的特殊性:数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建的,数组创建过程如下:

如果数组的组件类型是引用类型,那就递归采用类加载加载

如果数组的组件类型不是引用类型(int[] 数组),Java 虚拟机会把数组标记为引导类加载器关联

数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public

内存中实例的 java.lang.Class 对象存在方法区中,作为程序访问方法区中这些类型数据的外部接口。

加载阶段与连接阶段的部分内容是交叉进行的,但是开始时间保持先后顺序。相对于类加载的其他阶段而言,加载阶段是可控性最强的阶段,因为程序员可以使用系统的类加载器加载,还可以使用自己的类加载器加载

2、验证

验证属于连接的第一步,确保 Class 文件的字节流中包含的信息符合当前虚拟机要求。说白了也就是我们加载好的.class文件不能对我们的虚拟机有危害,所以先检测验证一下。他主要是完成四个阶段的验证:

文件格式验证:验证.class文件字节流是否符合class文件的格式的规范,并且能够被当前版本的虚拟机处理。

这里面主要对魔数、主版本号、常量池等等的校验(魔数、主版本号都是.class文件里面包含的数据信息)

元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等

字节码验证:这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出危害虚拟机安全的事

符号引用验证:验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成

对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证

3、准备

这个阶段正式为类分配内存并设置类变量初始值,内存在方法去中分配(含 static 修饰的变量不含实例变量)。

类变量(static)会分配内存,但是实例变量不会,实例变量主要随着对象的实例化一块分配到java堆中,这里的初始值指的是数据类型默认值,而不是代码中被显示赋予的值

比如:public static int value = 1; //在这里准备阶段过后的value值为0,而不是1。赋值为1的动作在初始化阶段。当然还有其他的默认值

在这里插入图片描述

注意,在上面value是被static所修饰的准备阶段之后是0,但是如果同时被final和static修饰准备阶段之后就是1了。

我们可以理解为static final在编译器就将结果放入调用它的类的常量池中了

如果类字段的字段属性表中存在 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 1127

4、解析

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

符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量

直接引用:直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和迅疾的内存布局实现有关

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 7 中常量类型

5、初始化

这是类加载机制的最后一步,在这个阶段,java程序代码才开始真正执行。

我们知道,在准备阶段已经为类变量赋过一次值。在初始化阶端,程序员可以根据自己的需求来赋值了。

一句话描述这个阶段就是执行类构造器< clinit >()方法的过程。

在初始化阶段,主要为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化

类加载器

类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。

一旦一个类被加载如JVM中,同一个类就不会被再次载入了。

正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。

在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。

JVM预定义有三种类加载器,当一个 JVM启动的时候,Java开始使用如下三种类加载器:

根类加载器(Bootstrap ClassLoader):它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作

扩展类加载器(Extensions ClassLoader):它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null

系统类加载器(Appclass Loader):也称为SystemAppClass,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。

程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader

双亲委派模型

从 Java 虚拟机角度讲,只存在两种类加载器:一种是启动类加载器(C++ 实现,是虚拟机的一部分);另一种是其他所有类的加载器(Java 实现,独立于虚拟机外部且全继承自 java.lang.ClassLoader)

在这里插入图片描述

如图所示展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。

双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是通过使用组合关系来复用父加载器的代码

工作过程:

如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式

优势:

采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改

自定义类加载器:

遵守双亲委派模型:继承ClassLoader,重写findClass()方法

破坏双亲委派模型:继承ClassLoader,重写loadClass()方法。通常我们推荐采用第一种方法自定义类加载器,最大程度上的遵守双亲委派模型

破坏双亲委派模型

双亲委派模型主要3次较大规模的“被破坏”情况,接下来一一介绍

1、第一次被破坏发生在双亲委派模型出现之前,因为双亲委派模型是在JDK1.2之后才引入的,但是在JDK1.0之前就已经有用户自定义的类加载器存在了,所以Java的设计者在引入双亲委派模型时不得不做出一些妥协。

2、第二次由于该模型本身的缺陷所导致的,双亲委派模型很好的解决的各个类加载器的基础类为同一个的问题,但是如果是这个基础类又想要都调用用户所写的代码时,就会有问题产生了。

比如JNDI服务的问题,JNDI服务是java的一个基础服务,这个服务就是为了对资源进行统一的管理和查找,它的代码由启动类进行加载,需要调用独立厂商实现并部署在应用程序的ClassPath下的JNDI接口,但是启动类使不认识这些代码的

这时,引入了线程上下文类加载器(Thread Context ClassLoader() ),如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

3、第三次破坏是由于用户过于追求程序的“动态性”导致的,这个“动态性”是指一些热门的名词,例如:代码的热交换、模块的热部署等等,就是说不用重启电脑,直接部署就可以用。

破坏双亲委派模型的例子:

原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。

例如,MySQL的mysql-connector-.jar中的Driver类具体实现的。原生的JDBC中的类是放在rt.jar包的,是由启动类加载器进行类加载的,在JDBC中的Driver类中需要动态去加载不同数据库类型的Driver类,而mysql-connector-.jar中的Driver类是用户自己写的代码,那启动类加载器肯定是不能进行加载的,既然是自己编写的代码,那就需要由应用程序启动类去进行类加载。

于是乎,这个时候就引入线程上下文件类加载器(Thread Context ClassLoader)。有了这个东西之后,程序就可以把原本需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了

结束语

感谢大家能够做我最初的读者和传播者,请大家相信,只要你给我一

份爱,我终究会还你们一页情的。

欢迎大家关注我的公众号【左耳君】,探索技术,分享生活

哦对了,后续所有的文章都会更新到这里

https://github.com/DayuMM2021/Java

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值