全方位带你深入探索类加载机制
- 专栏介绍
- 前提准备
- 面向人群
- 知识脉络
- 类加载是什么
- 类加载和Class类对象的关系
- JVM的预加载机制
- 加载class文件的方式
- 类加载过程(类的生命周期)
- 加载阶段
- 生成对应的Class文件
- 连接操作
- 验证(确保被加载的类的正确性)
- 关闭验证阶段
- 准备(类的静态变量分配内存+初始化默认值)
- 案例分享
- 指令赋值在初始化阶段
- 引用类型和数组元素的初始值
- 局部变量和类变量的赋值
- 局部变量和类变量、常量的赋值
- 解析(把类中的符号引用转换为直接引用)
- 符号引用转换为直接引用
- 解析后性能提升
- 解析阶段的延迟
- 初始化(类的静态变量分配内存+初始化默认值)
- 声明时直接赋值
- 静态代码块
- JVM初始化步骤
- JVM类初始化时机
- 结束生命周期
专栏介绍
学习JVM需要一定的编程经验和计算机基础知识,适用于从事Java开发、系统架构设计、性能优化、研究学习等领域的专业人士和技术爱好者。
前提准备
- 编程基础:具备良好的编程基础,理解面向对象编程(OOP)的基本概念,熟悉Java编程语言。
- 数据结构与算法:对基本的数据结构和算法有一定了解,理解内存管理、线程操作等基本概念。
面向人群
学习本专栏以及本章内容的前提和适用人群如下:
- Java开发人员:JVM是Java程序的核心执行引擎,因此Java开发人员需要深入了解JVM的工作原理和运行机制,以优化程序性能并解决相关问题。
- 系统架构师和高级工程师:对系统整体性能、稳定性有较高要求的人群,有必要深入理解JVM以优化系统性能。
- Java程序员和技术爱好者:具备一定Java编程经验,有意向深入了解JVM内部工作原理的人群。
- 研究人员和学生:从事计算机科学相关研究或学习的人群,有兴趣深入研究JVM内部原理和优化方法。
- JVM运维工程师:负责JVM性能优化、故障排查和调优的专业人员,需要对JVM有深入的理解。
知识脉络
每位Java开发者都了解到Java字节码是在Java运行时环境(JRE)上执行的。JRE包含了最为关键的组成部分:Java虚拟机(JVM),它负责分析和执行Java字节码。通常情况下,大多数Java开发者无需深入了解虚拟机的内部运行原理。即使对虚拟机的运行机制不甚了解,也不会对开发工作产生太多影响。然而,对JVM有一定了解的话,将更有助于深入理解Java语言,并解决一些看似困难的问题。
本专栏全面系统地剖析了特定虚拟机产品(即HotSpot,Oracle官方虚拟机)的实现,本人不仅深刻地讲解了看似深奥的原理,还提供了大量易于上手的实践案例,下面是总体的JVM相关的知识拓扑架构。
tips:当然还有一些最新的JVM特性未在这张图并非展示本专栏的全部内容,另外还包含了最新的JVM特性。
类加载是什么
类加载是Java中一个核心的概念,它主要指的是将类的 .class 文件中的二进制数值读取到内存中,对其进行处理,并储存在运行时数据区的方法区内。
这个过程还会在堆区创建一个 java.lang.Class 类的对象,这个对象的主要作用是去封装那些已存储在方法区内的类相关数据结构,如下图案例所示。
类加载和Class类对象的关系
类加载的最终就是在堆区中形成这样一个 Class 对象,这个Class对象不仅对类在方法区内的数据结构进行了封装,而且,它还为开发者提供了访问接口,使得程序员能访问到方法区内的数据结构,这个标准化的接口是在开发中还是至关重要的,因为它为我们使用和理解Java类内部数据结构提供了极大的便利性和可操作性。
JVM的预加载机制
类加载器并不必须等到某个类被首次主动使用时再加载它。根据JVM的规范,类加载器可以预测某个类将要被使用时之前加载它。
如果在预先加载的过程中发现某个类的.class文件缺失或存在错误,类加载器会等到程序首次主动使用该类时报告LinkageError错误,但如果这个类一直没有被程序主动使用,类加载器则不会报告错误。
加载class文件的方式
- 从本地系统中直接加载:可以直接从本地系统中加载所需文件,省去了通过网络获取的开销。 通过网络获取,典型场景:Web Applet:在Web
- Applet场景下,可以通过网络获取所需的资源文件,如图片、样式表等。
- 从压缩包中读取,成为日后jar、war格式的基础:从zip压缩包中读取文件可以作为以后生成jar、war格式文件的基础。这种方式可以减少文件大小,方便部署和分发。
- 运行时计算生成,使用最多的是动态代理技术:运行时计算生成是使用最多的动态代理技术。通过动态代理,可以在运行时动态生成代理类,实现对目标对象的增强功能。
- 由其他文件生成,典型场景:JSP应用从专有数据库中提取.class文件(较少见):在某些较少见的场景中,JSP应用可能需要从专有数据库中提取.class文件进行动态加载和运行。
- 从加密文件中获取,典型的防Class文件被反编译的保护措施:使用从加密文件中获取功能可以有效防止Class文件被反编译的安全隐患,提供了对代码的保护措施。
类加载过程(类的生命周期)
类加载过程包括加载、验证、准备、解析和初始化这五个阶段,如下图所示。
加载阶段
加载阶段是整个类生命周期的第一个步,此阶段主要是为了查找并加载Java类的class字节码文件对应的二进制数据,JVM需要完成以下三件事情:
- 获取类字节码:通过类的全限定名获取对应的二进制字节流。
- 转化为运行时数据结构:将获取的字节流转化为方法区中的运行时数据结构。
- 创建Class对象:在Java堆中创建一个java.lang.Class对象,作为对方法区运行时数据的访问入口。
生成对应的Class文件
加载阶段具有最高的可控性,开发人员可以选择使用系统提供的类加载器来进行加载,也可以自定义自己的类加载器来实现加载操作。
一旦加载阶段完成,外部的二进制字节流按Java虚拟机所需的格式存储在方法区,同时在Java堆中创建了一个java.lang.Class对象。通过该对象,可以方便地访问方法区中的数据。
连接操作
JVM类加载系统中的连接阶段可以大致描述为以下三个步骤:验证、准备和解析。
验证(确保被加载的类的正确性)
验证是连接阶段的首要步骤,其主要目的是确保Class文件中的信息符合虚拟机的要求,并且不会对虚拟机的安全性造成威胁。
验证阶段通常包括以下四个检查动作:
- 文件格式验证:验证Class文件的字节流是否符合规范格式,包括魔数和版本号的检查。
- 元数据验证:验证类的元数据信息,例如父类与接口的继承关系、方法与字段的访问权限等。
- 字节码验证:验证方法体中的字节码,包括操作数栈与局部变量表的类型匹配、跳转指令的目标合法性等。
- 符号引用验证:验证符号引用是否能够正确解析,确保类、方法和字段的引用能够找到对应的定义。
关闭验证阶段
验证阶段虽然至关重要,但并非必需,因为它并不会直接影响程序运行。
在某些场合,例如:如果一个类已经被验证过多次,那么我们可以考虑使用 -Xverify:none
参数来关闭大部分验证过程。这样做的目的是为了缩减JVM在类加载过程中所需的时间,从而提升运行效率。
注意,关闭类验证可能会带来安全性风险,因此,在实际操作时要谨慎选择。
准备(类的静态变量分配内存+初始化默认值)
准备阶段是类加载过程中的一个阶段。在这个阶段,会为类变量分配内存并设置初始值,这些操作发生在方法区。以下是需要注意和优化的几个方面:
-
仅类变量(static)会在准备阶段进行内存分配,实例变量则会在对象实例化时,随着对象一起分配在Java堆中。
-
类变量的初始值通常是数据类型的默认值。而这些默认值是在Java代码中没有显式赋值的情况下自动赋予的。
例如,整数类型的默认值是0,长整型的默认值是0L,引用类型的默认值是null,布尔类型的默认值是false。
案例分享
假设有一个类变量的定义:
public static int value = 3;
注意,在准备阶段结束后,变量value的初始值实际上是0,而不是3。这是因为在准备阶段只进行了内存分配和默认初始值的设置,并没有执行具体的赋值操作。
指令赋值在初始化阶段
上面的赋值操作将在初始化阶段执行,通过生成类构造器(())方法来实现。在该方法中,会执行将value赋值为3的指令,这个初始化阶段会在实际执行Java方法之前进行。
- 准备阶段后,变量value的初始值是0。
- 初始化阶段后,变量value的值被赋值为3
引用类型和数组元素的初始值
- 引用数据类型reference:如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
- 数组Array:初始化没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
局部变量和类变量的赋值
对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
局部变量和类变量、常量的赋值
- (final)被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
- (static final)对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;
public static final int value = 3;
在准备阶段,变量value会被初始化为3,因为它同时被final和static修饰。
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。可以理解为static final常量在编译期就将其结果放入了调用它的类的静态常量池中。
注意,不是一定被static final 定义的变量一定会被准备阶段就被放到静态常量池中。例如:static final a = getAConfig(),这种场景就不会放入常量池,因为只有在初始化阶段(Clinit)才能知道,真正执行出它的结果。
解析(把类中的符号引用转换为直接引用)
解析阶段,JVM会将静态常量池中的符号引用替换为直接引用,目的是为了提高代码的执行效率和减少运行时的解析开销。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。
符号引用转换为直接引用
-
符号引用:用于标识被引用内容的抽象引用形式,符号引用是在编译期间产生。
- 通过描述符来提供对被引用项的唯一标识,目的是为了在编译期间进行类型检查和名称解析等操作,并生成对应的字节码指令,描述符:类名、方法名、字段名等,用于定位具体的符号。
-
直接引用:直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄,可以直接访问到所引用的内容,直接引用是在解析阶段产生。
解析后性能提升
将符号引用替换为直接引用也可以减少运行时的解析开销,JVM可以在执行过程中直接访问到所引用的内容,而不需要再进行符号解析和查找过程,从而提高了代码的执行效率,提升程序的运行性能。
解析阶段的延迟
其中加载、验证、准备和初始化的顺序是确定的,而解析阶段的开始时间可以延迟到初始化阶段之后,解析阶段的延迟开始是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
注意,这些阶段是按顺序开始,并不是按顺序进行或完成,因为它们通常是相互交叉混合进行的,在一个阶段执行的过程中可能会调用或激活另一个阶段。
初始化(类的静态变量分配内存+初始化默认值)
初始化,为类的静态变量赋予真正的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
声明时直接赋值
类的声明过程中直接为静态变量赋予初始值。例如:
public class MyClass {
public static int myVariable = 10; // 直接赋值为10
}
静态代码块
静态代码块是在类加载过程中执行的代码块,在类被加载时自动执行。可以在静态代码块中为静态变量赋予初始值。例如:
public class MyClass {
public static int myVariable;
static {
myVariable = 10; // 在静态代码块中赋值为10
}
}
JVM初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
JVM类初始化时机
当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是使用
new
关键字来实例化一个类的对象。 - 访问某个类或接口的静态变量,或者对该静态变量进行赋值操作。
- 调用类的静态方法,可以通过类名直接调用静态方法。
- 反射,使用反射技术可以动态加载和使用类,通过
Class.forName("com.xxx.ClassName")
来获取类对象。 - 初始化某个类的子类时,其父类也会被自动初始化。
- Java虚拟机启动时被标明为启动类的类。
结束生命周期
在如下几种情况下,Java虚拟机将结束生命周期
- 执行System.exit()方法:通过调用System.exit()方法,可以显式地终止Java虚拟机的执行。
- 程序正常执行结束:当程序按照预期执行完所有的语句并正常结束时,没有遇到异常或错误。
- 程序在执行过程中遇到了异常或错误而异常终止:在程序执行过程中,如果遇到了无法处理的异常或发生了错误,程序会异常终止,这可能是由于代码逻辑问题、外部资源异常、或者其他意外情况导致的。
- 由于操作系统出现错误而导致Java虚拟机进程终止:在某些情况下,Java虚拟机进程可能会因为操作系统级别的错误(如内存不足、文件系统错误等)而被迫终止。