Java 入门指南:JVM(Java虚拟机)——类的生命周期与加载过程
文章目录
- 类的生命周期
- 类加载过程
- 1)载入(Loading)
- 2)验证(Verification)
- 文件格式验证
- 符号引用验证
- 3)准备(Preparation)
- 4)解析(Resolution)
- 符号引用
- 直接引用
- 5)初始化(Initialization)
JVM 运行 Java 代码的时候,需要将编译后的字节码文件加载到其内部的运行时数据区域中进行执行。这个过程涉及到了 Java 的类加载机制。
类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:
- 加载(Loading):类加载的过程由类加载器(ClassLoader)完成,类加载器负责将字节码文件(
.class
文件)加载到 JVM 中。类加载器的主要职责包括:
- 加载类文件:从指定位置读取字节码文件,并将其转换成字节数组。
- 创建 Class 对象:将字节数组转换成
Class
对象,并将其存储在方法区中。
- 验证(Verification):验证阶段是为了确保类文件的数据符合 JVM 的规范,不会对 JVM 造成危害。验证主要分为四个阶段:
- 文件格式验证:确保字节流的格式符合 Class 文件格式规范。
- 元数据验证:确保类的元数据信息(如常量池中的常量)正确无误。
- 字节码验证:确保字节码指令符合 JVM 规范,不会导致非法操作。
- 符号引用验证:确保符号引用能正确解析到实际存在的类、接口、方法或字段。
-
准备(Preparation):准备阶段主要是为类变量分配内存空间,并设置类变量的初始值。注意,这里的类变量指的是被
static
修饰的变量。实例变量则是在对象实例化时分配内存空间。 -
解析(Resolution):解析阶段是将符号引用转换为直接引用的过程。符号引用指的是类名、接口名、方法名等字符串形式的引用,而直接引用则指向目标对象在内存中的地址。
-
初始化(Initialization):初始化阶段是执行类构造器 (
<clinit>
) 方法的过程。在这个阶段,类中的静态变量会被赋予初始值,并执行静态块中的代码。初始化阶段还包括类中非静态方法的调用和类的实例化。 -
使用(Using):类初始化完成后即可正常使用。此时可以创建类的实例,调用类的方法等。
-
卸载(Unloading):当一个类不再被引用,并且垃圾回收器确定没有对该类的引用后,类加载器会释放该类占用的资源,最终类会被卸载。卸载的过程是由 JVM 自动管理的,通常发生在内存压力较大时。
其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。这 7 个阶段的顺序如下图所示:
类加载过程
类的生命周期除去使用和卸载,就是 Java 的类加载过程。这 5 个阶段一般是顺序发生的,但在动态绑定的情况下,解析阶段发生在初始化阶段之后。系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
1)载入(Loading)
类加载过程的第一步,主要完成下面 3 件事情:
- 通过全类名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
Class
对象,作为方法区这些数据的访问入口。
即将字节码从不同的数据源(可能是 class 文件、也可能是 jar 包,甚至网络)转化为二进制字节流加载到内存中,并生成一个代表该类的 java.lang.Class
对象。
虚拟机规范上面这 3 点并不具体,因此是非常灵活的。比如:“通过全类名获取定义此类的二进制字节流” 并没有指明具体从哪里获取( ZIP
、 JAR
、EAR
、WAR
、网络、动态代理技术运行时动态生成、其他文件生成比如 JSP
…)、怎样获取。
加载这一步主要是通过 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定
每个 Java 类都有一个引用指向加载它的 ClassLoader
。不过,数组类不是通过 ClassLoader
创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()
方法获取 ClassLoader
的时候和该数组的元素类型的 ClassLoader
是一致的。
一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass()
方法)。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
2)验证(Verification)
JVM 会在该阶段对二进制字节流进行校验,只有符合 JVM 字节码规范的才能被 JVM 正确执行。该阶段是保证 JVM 安全的重要屏障,下面是一些主要的检查。
验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。
验证阶段主要由四个检验阶段组成:
- 文件格式验证(Class 文件格式检查)
- 元数据验证(字节码语义检查)
- 字节码验证(程序语义检查)
- 符号引用验证(类的正确性检查)
文件格式验证
文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
符号引用验证
符号引用验证发生在类加载过程中的解析阶段,具体来说是 JVM 将符号引用转化为直接引用的时候(解析阶段会介绍符号引用和直接引用)。
符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:
-
java.lang.IllegalAccessError
:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。 -
java.lang.NoSuchFieldError
:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常。 -
java.lang.NoSuchMethodError
:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。 -
…
3)准备(Preparation)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
-
这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被
static
关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。 -
从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,
HotSpot
使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot
已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着Class
对象一起存放在 Java 堆中。
JVM 会在该阶段对类变量(也称为静态变量,static
关键字修饰的)分配内存并初始化,对应数据类型的默认初始值,"通常情况"下是数据类型默认的零值(如 0
、0L
、null
、false
等)。但若给变量加上了 final
关键字,在准备阶段变量的值就被赋值为指定的值。
基本数据类型的零值:
数据类型 | 零 值 |
---|---|
int | 0 |
long | 0L |
short | (short) 0 |
char | ‘\u0000’ |
byte | (byte) 0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
也就是说,假如有这样一段代码:
public String a = "Java";
public static String b = "Java";
public static final String c = "JVM";
a 不会被分配内存,而 b 会;但 b 的初始值不是“Java”而是 null
。
由于 static final
修饰的变量被称作为常量,和类变量不同。常量一旦赋值就不会改变了,所以 c 在准备阶段的值为“JVM”而不是 null
。
4)解析(Resolution)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对以下 7 类符号引用进行:
- 类或接口
- 字段
- 类方法
- 接口方法
- 方法类型
- 方法句柄
- 调用限定符
符号引用
符号引用
(Symbol References):以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。
各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Class
文件格式中。符号引用在编译时生成,存储在编译后的字节码文件的常量池中。
例如,在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如 com.Order
类引用了 com.Item
类,编译时 Order 类并不知道 Item 类的实际内存地址,因此只能使用符号 com.Item
。
直接引用
直接引用
(Direct References):可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。通过对符号引用进行解析,找到引用的实际内存地址。
直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。由于直接指向了内存地址或者偏移量,所以通过直接引用访问对象的效率较高。
在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
5)初始化(Initialization)
初始化阶段是执行初始化方法 <clinit> ()
方法的过程,该阶段是类加载过程的最后一步。在准备阶段,类变量已经被赋过默认初始值,而在初始化阶段,类变量将被赋值为代码期望赋的值。
例如:
String temp = new String("JVM");
上面这段代码使用了 new
关键字来实例化一个字符串对象,那么这时候,就会调用 String
类的构造方法对 temp
进行实例化。
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
对于 <clinit> ()
方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit> ()
方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。
对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
-
当遇到
new
、getstatic
、putstatic
或invokestatic
这 4 条字节码指令时,比如new
一个类,读取一个静态字段(未被final
修饰)、或调用一个类的静态方法时。- 当 jvm 执行
new
指令时会初始化类。即当程序创建一个类的实例对象。 - 当 jvm 执行
getstatic
指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。 - 当 jvm 执行
putstatic
指令时会初始化类。即程序给类的静态变量赋值。 - 当 jvm 执行
invokestatic
指令时会初始化类。即程序调用类的静态方法。
- 当 jvm 执行
-
使用
java.lang.reflect
包的方法对类进行反射调用时如Class.forName("...")
,newInstance()
等等。如果类没初始化,需要触发其初始化。 -
初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
-
当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main
方法的那个类),虚拟机会先初始化这个类。 -
MethodHandle
和VarHandle
可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,就必须先使用findStaticVarHandle
来初始化要调用的类。 -
当一个接口中定义了 JDK8 新加入的默认方法(被
default
关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。