抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

摘要:本文主要学习了如何将类加载到虚拟机。

环境

Windows 10 企业版 LTSC 21H2
Java 1.8

1 类生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括加载、连接、初始化、使用和卸载五个阶段。

如图:
20250731095857-类加载器

其中,连接包括验证、准备、解析三个阶段。解析阶段在某些情况下可以在初始化后再开始,这是为了支持运行时绑定。

这几个阶段按顺序开始,相互交叉混合进行,通常在一个阶段执行的过程中调用或激活另一个阶段。

1.1 加载阶段

1.1.1 做什么

类的加载就是将class文件中的二进制数据读取到运行时内存中,将class文件中类的信息放在方法区中,然后在堆中创建一个Class对象,用于封装方法区中的数据结构。

在进行加载的时候,虚拟机需要完成三件事:

  • 通过类的全类名获取该类的二进制字节流。
  • 将二进制字节流所代表的静态结构转化为方法区的运行时数据结构。
  • 在内存中创建一个代表该类的Class对象,作为方法区这个类的各种数据的访问入口。

1.1.2 何时做

虚拟机规范允许某个类在预料被使用的时候预先执行类的加载,不需要等到某个类首次被使用的时候才进行类的加载。

如果在进行类的加载时遇到class文件缺失,只有当使用到了该类的时候,类加载器才会报告错误,如果该类并没有被使用,那么类加载器是不会报告错误的。

1.1.3 文件的来源

加载的class文件有种来源:

  • 从本地硬盘直接加载,最为普通的场景。
  • 通过网络下载class文件加载,常用于Applet应用程序。
  • 从压缩文件中提取class文件加载,常用于Jar包和War包。
  • 运行时计算生成,常用于动态代理。
  • 由其他文件生成,常用于JSP应用。
  • 从数据库中提取加载,比较少见。
  • 将源文件编译成class文件加载,常用于防止反编译的保护措施。

1.1.4 补充说明

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义的类加载器来完成加载。

1.2 链接阶段

1.2.1 验证阶段

确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段大致会完成四个阶段的检验动作:

  • 文件格式验证:验证字节流是否符合class文件格式的规范。比如是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  • 元数据验证:对字节码描述的信息进行分析,以保证其描述的信息符合Java语言规范的要求。比如这个类是否有除了Object之外的超类。
  • 字节码验证:通过数据流和控制流分析,确定程序语义是安全法的、符合逻辑的,不会导致虚拟机崩溃。
  • 符号引用验证:确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,可以通过参数关闭验证以缩短虚拟机类加载的时间。

1.2.2 准备阶段

为类变量(也就是静态成员变量,不包括实例变量)分配内存并设置初始值的阶段,这些变量所使用的内存都在方法区中进行分配。

这个阶段只是为静态成员变量设置初始值,并不是赋值,赋值是在初始化阶段的cinit()方法中完成的。

如果静态成员变量同时是常量,常量在编译时分配初始值,如果赋值不涉及方法调用(包括构造方法调用),会在这个阶段进行赋值。

赋值:

  • 非static类型的变量,在初始化阶段的init()方法中赋值。
  • static类型的变量,在准备阶段设置初始值,在初始化阶段的cinit()方法中赋值。
  • static类型的变量,并且是final类型的变量,在编译阶段设置初始值。如果赋值不涉及方法调用,在准备阶段赋值,否则在初始化阶段的cinit()方法中赋值。

示例:

java
1
2
3
4
5
6
7
8
9
10
// 在准备阶段设置初始值为0,在初始化阶段的clinit方法中赋值
public static int i = 1;
// 在准备阶段赋值
public static final int INT_CONSTANT = 1;
// 在准备阶段设置初始值为null,在初始化阶段的clinit方法中赋值
public static final Integer INTEGER_CONSTANT=Integer.valueOf(1);
// 在准备阶段赋值
public static final String STR = "hello";
// 在准备阶段设置初始值为null,在初始化阶段的clinit方法中赋值
public static final String STR_CONSTANT = new String("hello");

1.2.3 解析阶段

虚拟机将常量池内的符号引用替换为直接引用,会把该类所引用的其他类全部加载进来,类中的引用方式包括继承、实现接口、域变量、方法定义、方法中定义的本地变量等等。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这七类符号引用进行。

引用:

  • 符号引用:在编译文件时,并不知道所引用的类的实际地址,因此只能使用符号引用来代替。
  • 直接引用:直接指向目标的指针(指向方法区,Class对象)、指向相对偏移量(指向堆区,Class实例对象)或指向能间接定位到目标的句柄。

1.3 初始化阶段

1.3.1 做什么

为类的静态变量赋予正确的初始值,虚拟机负责对类进行初始化,主要对类变量进行初始化。

对类变量进行初始值设定有两种方式:

  • 声明类变量时指定初始值。
  • 使用静态代码块为类变量指定初始值。

换句话说,初始化阶段是执行cinit()方法的过程。

在执行子类的cinit()方法前,虚拟机会先执行父类的cinit()方法。

1.3.2 线程安全

在执行cinit()方法时,虚拟机会保证在多线程环境中正确加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的cinit()方法。

如果一个类的cinit()方法中有耗时的操作,可能会造成多线程阻塞,从而导致产生死锁,并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。

赋值方法:

  • init()方法是对象构造器方法,在new一个对象并调用该类的构造方法时才会执行,用于对非静态变量进行赋值。
  • clinit()方法是类构造器方法,在初始化阶段执行,用于对静态变量进行赋值。

示例:

java
1
2
3
4
5
6
7
8
9
10
class X {
static Log log = LogFactory.getLog(); // <clinit>
private int x = 1; // <init>
X () {
// <init>
}
static {
// <clinit>
}
}

1.3.3 何时做

只有当主动使用时才会进行初始化,类的主动使用包括以下六种:

  • 创建类的实例,包括使用new的方式,也包括使用反射、克隆、序列化的方式,会触发初始化。
  • 调用Class.forName()方法,将Class文件加载到内存并返回Class对象,会触发初始化。
  • 访问某个类或接口的静态变量,或者对该静态变量赋值,会触发初始化。
  • 调用类的静态方法,会触发初始化。
  • 作为父类,初始化子类时,会触发初始化。
  • 类中包含main()方法作为主类,虚拟机在启动时,会触发初始化
  • JDK1.7开始提供的动态语言支持,涉及解析相关方法句柄对应的类,虚拟机在启动时,会触发初始化。

除此之外,其它所有引用类的方式都不会触发初始化,称为被动引用:

  • 子类引用父类静态属性,不会触发初始化。
  • 调用loadClass()方法,将Class文件加载到内存,不会触发初始化。
  • 通过数组引用类,不会触发初始化。
  • 引用类的常量,常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,不会触发初始化。

1.3.4 接口说明

接口中不能使用静态代码块,但是允许有静态变量初始化的赋值操作,因此接口和类一样也会生成静态构造方法。

接口在执行静态构造方法时,不需要加载父接口,也不需要在准备阶段执行父接口的静态构造方法,只有在使用了父接口的静态变量才会加载父接口,并执行父接口的静态构造方法。

1.4 使用阶段

调用成员变量或者成员方法执行业务逻辑。

1.5 卸载阶段

虚拟机在进行垃圾收集的时候卸载类。

2 类加载器

2.1 简介

类加载器是通过类的全类名来加载类的二进制字节流的代码模块,其主要作用是将class文件二进制数据放入方法区内,然后在堆内创建一个Class类型的对象。

使用Class对象封装类在方法区内的数据结构,向开发者提供了访问方法区内数据结构的接口。

2.2 唯一

对于任意一个类,都需要由类的类加载器和类本身共同确立其在虚拟机中的唯一性。

即使两个类来源于同一个Class文件,并且被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类也不相同。

这里的相同包括equals()方法和isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

2.3 分类

从虚拟机的角度来看,只存在两种不同的类加载器:

  • 启动类加载器:这个类加载器使用C++语言实现,是虚拟机自身的一部分。
  • 其他类加载器:这些类加载器由Java语言实现,独立存在于虚拟机外部,继承自ClassLoader类。

站在开发人员的角度来看,可以将其他类加载器划分得更细致一些。

2.2.1 启动类加载器

BootstrapClassLoader类负责加载存放在JDK安装目录中的lib目录中的类库,或被-Xbootclasspath参数指定路径中的类库(比如rt.jar和java开头的类)。

启动类加载器是无法被程序直接引用的,也就是说是无法直接获取的。

示例:

java
1
2
3
4
public static void main(String[] args) {
System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
System.out.println(String.class.getClassLoader());
}

结果:

log
1
2
null
null

2.2.2 扩展类加载器

ExtensionClassLoader类由sun.misc.Launcher$ExtClassLoader实现,负责加载JDK安装目录中的ext目录中的类库,或者被java.ext.dirs系统变量指定路径中的类库(比如javax开头的类)。

开发者可以直接使用扩展类加载器。

示例:

java
1
2
3
public static void main(String[] args) {
System.out.println(ClassLoader.getSystemClassLoader().getParent());
}

结果:

log
1
sun.misc.Launcher$ExtClassLoader@4b67cf4d

2.2.3 应用类加载器

ApplicationClassLoader类由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径(ClassPath)所指定的类。

开发者可以直接使用应用类加载器,如果应用程序中没有自定义过自己的类加载器,默认使用这个类加载器。

示例:

java
1
2
3
4
5
public static void main(String[] args) {
System.out.println(ClassLoader.getSystemClassLoader());
System.out.println(Thread.currentThread().getContextClassLoader());
System.out.println(Demo.class.getClassLoader());
}

结果:

log
1
2
3
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2

2.2.4 自定义类加载器

如果上述虚拟机自带的类加载器不能满足需求,就需要自定义类加载器。

比如应用是通过网络来传输Java类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样就需要自定义类加载器来实现。

自定义类加载器需要继承ClassLoader抽象类,并重写父类的findClass()方法,在所有父类加载器无法加载的时候调用findClass()方法加载类。

被加载的类:

java
1
2
3
4
5
6
7
public class DemoBusiness {
public static void business() {
ClassLoader classLoader = DemoBusiness.class.getClassLoader();
System.out.println("ClassLoader >>> " + classLoader);
System.out.println("ClassLoader.parent >>> " + classLoader.getParent());
}
}

编译并生成class字节码文件:

cmd
1
D:\>javac -encoding utf8 DemoBusiness.java

使用自定义类加载器:

java
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
public class Demo {
public static void main(String[] args) throws Exception {
// 指定类加载器加载调用
DemoClassLoader classLoader = new DemoClassLoader();
classLoader.loadClass("DemoBusiness").getMethod("business").invoke(null);
}
}

class DemoClassLoader extends ClassLoader {
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 加载指定类名的Class
String classDir = "D:\\" + name.replace('.', File.separatorChar) + ".class";
byte[] classData = loadClassData(classDir);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}

private byte[] loadClassData(String path) {
try (InputStream ins = new FileInputStream(path); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}

结果:

log
1
2
ClassLoader >>> DemoClassLoader@7ea987ac
ClassLoader.parent >>> sun.misc.Launcher$AppClassLoader@18b4aac2

2.4 关系

类加载器的关系如图:
20250730170959-类加载器

这里类加载器之间的父子关系一般不会以继承(Inheritance)关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

2.5 机制

2.5.1 全盘负责机制

当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。

重要性:

  • 保证一致性:确保一个类及其所有依赖项都由同一个类加载器加载。
  • 命名空间隔离:这是沙箱机制和模块化的基础。不同类加载器加载的类属于不同的命名空间,即使类名相同也被视为不同的类。

2.5.2 双亲委派机制

先让父类加载器加载该类,如果父类加载器之上还有加载器,则进一步向上委托,只有在父类加载器无法加载该类时才尝试自己加载该类。

重要性:

  • 避免重复加载:确保一个类在JVM中只被加载一次,防止同一个类被不同加载器加载多次导致类型混乱和资源浪费。
  • 保证核心库安全:保证程序安全稳定运行,防止核心类被随意篡改。

在某些特定场景下,类加载机制不遵循双亲委派的原则,而是由子加载器主动加载或选择特定的加载路径,破坏了双亲委派模型。

但是这种破坏并非设计缺陷,而是为满足特定需求而采取的必要手段:

在JDK1.2之前,尚未引入双亲委派模型,用户自定义类加载器需要重写loadClass()方法,可能未遵循委派模型。

在JDK1.2之后,引入了双亲委派模型,用户自定义类加载器需要重写findClass()方法,符合双亲委派模型。

总结:

  • 如果想破坏双亲委派模型,就重写loadClass()方法。
  • 如果想保持双亲委派模型,就重写findClass()方法。

在某些特殊场景中,接口由启动类加载器加载,实现类由应用类加载器加载,此时应当由应用类加载器负责接口的加载。

比如JNDI服务,接口由启动类加载器去加载,由独立厂商实现的接口提供者(SPI,Service Provider Interface)的代码属于实现类,启动类加载器无法加载用户自定义的实现类。

解决办法是使用线程上下文类加载器(Thread Context ClassLoader),支持设置和获取:

  • 使用Thread.setContextClassLoaser()方法进行设置。
  • 使用Thread.currentThread().getContextClassLoader()方法获取。

当启动类加载器加载核心类后,需要加载实现类时,启动类加载器尝试获取线程上下文类加载器加载实现类。如果创建线程时没有设置,将使用父线程的线程上下文类加载器,如果在全局范围内都没有设置,默认使用应用程序类加载器。

所有涉及SPI的加载动作基本上都采用这种逆向委派方式加载,例如JNDI和JDBC等。

Tomcat需要部署多个Web应用,每个应用可能依赖不同版本的类库。若遵循双亲委派,这些类会被父加载器加载并共享,导致版本冲突。

Tomcat使用WebAppClassLoader加载,失败后再委托SharedClassLoader加载,而非默认双亲委派。

这么做可以让每个Web应用的类由自己的WebAppClassLoader加载,实现类隔离,避免版本冲突。

为了追求程序的动态性,使用热替换(Hot Swap)和热部署(Hot Deployment)等技术,在不重启JVM的情况下更新类。

OSGi框架为每个模块创建独立的BundleClassLoader类加载器,当模块动态更新时,OSGi会销毁旧的类加载器,创建新的类加载器重新加载类,此时新类与旧类虽然全限定名相同,但属于不同类加载器加载的不同类,实现热部署。

这种模型完全打破了双亲委派的层级关系,类加载器之间可以平级委托甚至双向委托,灵活性极高。

2.5.3 缓存机制

加载过的Class会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。

这就是为什么修改了Class后,必须重启虚拟机才会生效,这也是热部署需要解决的问题。

2.5.4 沙箱机制

沙箱机制是将代码限定在虚拟机特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。

沙箱机制是对核心源代码的保护,在一定程度上可以保护程序安全,保护原生的JDK代码。

3 类加载顺序

当加载类时:

  • 加载同时被static和final修饰的基本类型(包括String类型)的常量并赋值。在准备阶段完成。
  • 加载父类的静态代码,包括静态代码块和静态变量,优先加载写在前面的代码。在初始化阶段完成。
  • 加载类的静态代码,包括静态代码块和静态变量,优先加载写在前面的代码。在初始化阶段完成。
  • 加载父类的成员属性,包括构造代码块和成员变量,优先加载写在前面的代码。在初始化阶段完成。
  • 加载父类的构造器方法。在初始化阶段完成。
  • 加载类的成员属性,包括构造代码块和成员变量,优先加载写在前面的代码。在初始化阶段完成。
  • 加载类的构造器方法。在初始化阶段完成。

示例:

java
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
public class Demo {
public static void main(String[] args) throws Exception {
new Person();
}
}

class Person extends Human {
public static PersonOtherStatic personOtherStatic = new PersonOtherStatic();

static {
System.out.println("Person 静态代码块 ...");
}

public PersonOther personOther = new PersonOther();

{
System.out.println("Person 普通代码块 ...");
}

public Person() {
super();
System.out.println("Person 构造器 ...");
}
}

class Human {
public static HumanOtherStatic humanOtherStatic = new HumanOtherStatic();

static {
System.out.println("Human 静态代码块 ...");
}

{
System.out.println("Human 普通代码块 ...");
}

public Human() {
super();
System.out.println("Human 构造器 ...");
}
}

class PersonOther {
static {
System.out.println("PersonOther 静态代码块 ...");
}

{
System.out.println("PersonOther 普通代码块 ...");
}

public PersonOther() {
super();
System.out.println("PersonOther 构造器 ...");
}
}

class PersonOtherStatic {
static {
System.out.println("PersonOtherStatic 静态代码块 ...");
}

{
System.out.println("PersonOtherStatic 普通代码块 ...");
}

public PersonOtherStatic() {
super();
System.out.println("PersonOtherStatic 构造器 ...");
}
}

class HumanOtherStatic {
static {
System.out.println("HumanOtherStatic 静态代码块 ...");
}

{
System.out.println("HumanOtherStatic 普通代码块 ...");
}

public HumanOtherStatic() {
super();
System.out.println("HumanOtherStatic 构造器 ...");
}
}

结果:

log
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HumanOtherStatic 静态代码块 ...
HumanOtherStatic 普通代码块 ...
HumanOtherStatic 构造器 ...
Human 静态代码块 ...
PersonOtherStatic 静态代码块 ...
PersonOtherStatic 普通代码块 ...
PersonOtherStatic 构造器 ...
Person 静态代码块 ...
Human 普通代码块 ...
Human 构造器 ...
PersonOther 静态代码块 ...
PersonOther 普通代码块 ...
PersonOther 构造器 ...
Person 普通代码块 ...
Person 构造器 ...

评论