概述

在详细讲述类加载机制之前, 我们先了解一下JVM的内存结构图, 通过内存结构图,再理解类加载中的相关细节就会简单很多。

内存结构图

image-20230906131454907

根据内存简图。 我们可以知道,Java中我们书写的Java类通过编译成为字节码文件(Class Files), 然后就会到达类加载子系统

通过类加载子系统加载完成后,就会加载到内存中去

下面是详细的内存结构图

image-20230906131814188

注意:方法区只有HotSpot虚拟机有,J9,JRockit都没有

虚拟机 == 类加载器 + 执行引擎

通过上述的内存详细结构图我们可以得知,类加载子系统也分为三个阶段。 加载、链接、初始化。

下面我们就来详细的了解一下这些阶段要完成的事情, 以及完成这些阶段需要具备什么条件等等。至于剩余的部分, 后续会慢慢学习了解。

类加载的时机

首先,让我们看看类的生命周期

image-20230906132148955

通过图片可以知道, 类的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段。

在这个七个阶段中, 每个阶段都为发挥着类的对应作用。

一般情况下, 加载 (Loading)、验证(Verification)、准备(Preparation)、初始化 (Initialization)、卸载(Unloading)

这五个阶段是按照顺序按部就班的开始执行的。而解析阶段则不一定, 在某些情况下, 它可以在初始化之后再开始。当然,这里的开始执行并不是说就一定会完成 或者 按部就班的进行, 这些阶段通常都是相互交叉地混合进行的, 会在一个阶段执行的过程中 执行或者调用另一个阶段。

​ 对于加载 (Loading)阶段而言, 它是由虚拟机的具体实现来自由把控的。

但是对于初始化阶段《Java虚拟机规范》中明确表示只有六种情况必须对类立即进行初始化:

  1. 遇到new、getstaticputstaticinvokestatic这四条字节码指令时,如果类型没有进行过初始 化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
  • 使用new关键字实例化对象的时候。
  • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
  • 调用一个类型的静态方法的时候。
  1. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。
  2. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  3. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类。
  4. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  5. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有 这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类加载的过程

加载

“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段。是狭义上的加载, 而类加载是广义上的加载。

通过, 加载得到的内容 存放在方法区的内存空间(其中还存放有运行时的常量池信息)

在加载阶段,Java虚拟机需要完成以下三件事情:

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

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

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

获取加载文件的方式

  • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
  • 从网络中获取,这种场景最典型的应用就是Web Applet。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用 了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
  • 由其他文件生成,典型场景是JSP应用,由JSP文件生成对应的Class文件。
  • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择 把程序安装到数据库中来完成程序代码在集群间的分发。
  • 可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文 件来保障程序运行逻辑不被窥探。
  • ……

相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成

开发人员通过自动定义类加载器来定义类加载器去控制字节 流的获取方式(重写一个类加载器的findClass()loadClass()方法),实现根据自己的想法来赋予应用 程序获取运行代码的动态性。

验证

目的是确保Class文件的字节流中包含的信息符合《Java虚 拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

相对而言,验证阶段是我们开发人员基本上是不会做其他操作的, 但是对于虚拟机而言, 他为了保护自身的安全, 所以会做一系列的保护措施。 具体有

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

使用BinaryViewer软件查看字节码文件,其开头均为 CA FE BA BE ,如果出现不合法的字节码文件,那么将会验证不通过。

准备

准备阶段 是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段

这里不包含用final修饰的static, 因为被final修饰后 在编译阶段就会被分配。

这里不会也为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中

代码:变量a在准备阶段会赋初始值,但不是1,而是0,在初始化阶段会被赋值为 1

1
2
3
4
5
6
7
8
public class HelloApp {
private static int a = 1;//prepare:a = 0 ---> initial : a = 1


public static void main(String[] args) {
System.out.println(a);
}
}

解析

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

解析阶段往往伴随着JVM在执行完初始化之后再执行

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等

符号引用

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何 形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引 用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规 范》的Class文件格式中。

直接引用

直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能 间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚 拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机 的内存中存在。

初始化

类的初始化阶段是类加载过程的最后一个步骤。

除了在加载阶段用户应用程序可以通过自动逸类加载器的方式局部参与外, 其他的动作都是完全由Java虚拟机来主导控制的。直到初始化阶段, Java虚拟机才真正开始执行类中编写的Java代码程序, 然后将主导权交给应用程序

初始化的时机

  1. 创建类的实例
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(比如:Class.forName(“com.atguigu.Test”))
  5. 初始化一个类的子类
  6. Java虚拟机启动时被标明为启动类的类
  7. JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化,即不会执行初始化阶段(不会调用 clinit() 方法和 init() 方法)

在初始化阶段,则会根据程序员通 过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表 达:初始化阶段就是执行类构造器<clinit>()方法的过程。

关于<clinit>()方法

  1. **<clinit>()**并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物
  2. <clinit>()方法不需要定义。 他是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的 语句合并产生的。 如果类中不存在类变量的赋值动作和静态语句块(static{}块)。 那么也就不会产生<clinit>() 方法
  3. 编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
1
2
3
4
5
6
7
8
public class Test {
static {
i = 0; // 给变量复制可以正常编译通过
System.out.print(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}

  1. <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显 式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行 完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object

  2. 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

  3. <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的 赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

  4. ·Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同 时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等 待,直到活动线程执行完毕<clinit>()方法。

类加载器

JVM严格来讲支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)

自定义类加载器是开发人员根据需求自定义的一种类加载器 ,Java虚拟机将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。

image-20230906143604730

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ClassLoaderTest {
public static void main(String[] args) {

//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

//获取其上层:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d

//获取其上层:获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null

//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

//String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null


}
}

虚拟机自带的类加载器

1. 启动类加载器(引导类加载器、Bootstrap ClassLoader)

  1. 这个类加载使用C/C++语言实现的,嵌套在JVM内部
  2. 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
  3. 并不继承自java.lang.ClassLoader,没有父加载器
  4. 加载扩展类和应用程序类加载器,并作为他们的父类加载器
  5. 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

2. 扩展类加载器(Extension ClassLoader)

  1. Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
  2. 派生于ClassLoader
  3. 父类加载器为启动类加载器
  4. 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载

3. 应用程序类加载器(也称为系统类加载器,AppClassLoader)

  1. Java语言编写,由sun.misc.LaunchersAppClassLoader实现
  2. 派生于ClassLoader
  3. 父类加载器为扩展类加载器
  4. 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  5. 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  6. 通过classLoader.getSystemclassLoader()方法可以获取到该类加载器

用户自定义类加载器

什么时候需要自定义类加载器?

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。那为什么还需要自定义类加载器?

  1. 隔离加载类(比如说我假设现在Spring框架,和RocketMQ有包名路径完全一样的类,类名也一样,这个时候类就冲突了。不过一般的主流框架和中间件都会自定义类加载器,实现不同的框架,中间价之间是隔离的)
  2. 修改类加载的方式
  3. 扩展加载源(还可以考虑从数据库中加载类,路由器等等不同的地方)
  4. 防止源码泄漏(对字节码文件进行解密,自己用的时候通过自定义类加载器来对其进行解密)

如何定义

  1. 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
  2. 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中, 如下案例
  3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {

try {
byte[] result = getClassFromCustomPath(name);
if (result == null) {
throw new FileNotFoundException();
} else {
//defineClass和findClass搭配使用
return defineClass(name, result, 0, result.length);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}

throw new ClassNotFoundException(name);
}
//自定义流的获取方式
private byte[] getClassFromCustomPath(String name) {
//从自定义路径中加载指定类:细节略
//如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
return null;
}

public static void main(String[] args) {
CustomClassLoader customClassLoader = new CustomClassLoader();
try {
Class<?> clazz = Class.forName("One", true, customClassLoader);
Object obj = clazz.newInstance();
System.out.println(obj.getClass().getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}

双亲委派模型

工作原理(过程)

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加 载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的 加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请 求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

优点

  1. 使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类 加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一 个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类 在程序的各种类加载器环境中都能够保证是同一个类。

  2. 双亲委派模型对于保证Java程序的稳定运作极为重要

案例:

1
2
3
4
5
6
7
8
9
10
11
package java.lang;
public class String {
//
static{
System.out.println("我是自定义的String类的静态代码块");
}
//错误: 在类 java.lang.String 中找不到 main 方法
public static void main(String[] args) {
System.out.println("hello,String");
}
}

image-20230906144759938

由于双亲委派机制一直找父类, 而这个包是以Java开头的,所以最后找到了Bootstrap ClassLoader,Bootstrap ClassLoader找到的是 JDK 自带的 String 类,在那个String类中并没有 main() 方法,所以就报了上面的错误。

image-20230906144849584

沙箱机制

是为了保证对Java核心源代码的保护而出现的机制