JVM 基础 - 类装载器子系统
# 类的生命周期
类加载的过程,包括了五个阶段:
- 加载
- 验证
- 准备
- 解析:在某些情况下可以
初始化
阶段之后开始 - 初始化
注意
加载
、 验证
、 准备
和 初始化
这四个阶段发生的顺序是确定的,而 解析
阶段则不一定,它在某些情况下可以在 初始化
阶段之后开始,这是为了支持 Java 语言的运行时绑定 (也成为动态绑定或晚期绑定)。
这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
# 加载(Loading)
查找并加载类的二进制数据(字节码)
笔记
- 查找字节码,可以通过本地、压缩包、网络.....
- 将字节码 “转码” 成 运行时数据结构,并存到方法区
- 在 JVM 堆中创建一个类的 java.lang.Class 对象
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在 Java 堆中生成一个代表此类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
相对于类加载的其他阶段而言,加载阶段 (准确地说,是加载阶段获取类的二进制字节流的动作) 是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
什么时候触发加载?
类加载器并不需要等到某个类被 “首次主动使用” 时再加载它,JVM 规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class 文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误 (LinkageError 错误) 如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
加载.class文件的方式
- 从本地系统中直接加载
- 通过网络获取,典型场景:Web Applet
- 从 zip 压缩包中读取,成为日后 jar、war 格式的基础
- 运行时计算生成,使用最多的是:动态代理技术
- 由其他文件生成,典型场景:JSP 应用
- 从专有数据库中提取.class 文件,比较少见
- 从加密文件中获取,典型的防 Class 文件被反编译的保护措施
# 连接(Linking)
# 验证
作用:验证是连接阶段的第一步,确保被加载的类的正确性。
目的:确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致会完成 4 个阶段的检验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
# 文件格式验证
验证字节流是否符合 Class 文件格式的规范;
例如:
- 是否以
CAFE BABE
开头- 主次版本号是否在当前虚拟机的处理范围之内
- 常量池中的常量是否有不被支持的类型。
# 元数据验证
对字节码描述的信息进行语义分析 (注意:对比 javac
编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;
例如:
- 这个类是否有父类,除了
java.lang.Object
之外。
# 字节码验证
通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
# 符号引用验证
确保解析动作能正确执行。
笔记
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
# 准备
作用:为类的静态变量分配内存,并将其初始化为默认值
为类中的静态 (static) 变量初始化为数据类型对应的零值,即
0
、0L
、null
、false
等但不包括
final
修饰的常量 (final static
),因其已在编译时已分配值,准备阶段会显式初始化。准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
注意
仅包括类变量 (
static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。这里所设置的初始值通常情况下是数据类型默认的零值 (如
0
、0L
、null
、false
等),而不是被在 Java 代码中被显式地赋予的值。例如:假设一个类变量的定义为:
public static int value = 3
;那么变量 value 在准备阶段过后的初始值为0
,而不是3
,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 的put static
指令是在程序编译后,存放于类构造器<clinit>()
方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。对于基本数据类型:
- 类变量 (static) 和全局变量,可以不显式赋值,系统赋予默认的零值,
- 局部变量 (方法内的变量),在使用前必须显式赋值,否则编译时不通过。
对于常量:
- 同时被
static
和final
修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过; - 只被
final
修饰的常量,则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式赋值,系统不会为其赋予默认零值。
- 同时被
对于引用数据类型
reference
(数组引用、对象引用等),如果没有显式赋值而直接使用,系统赋予默认的零值 (null
)。- 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
对于静态常量 (同时被
final
和static
修饰),即类字段的字段属性表中存在 ConstantValue 属性,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。假设上面的类变量 value 被定义为:
public static final int value = 3;
编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 3。我们可以理解为static final
常量在编译期就将其结果放入了调用它的类的常量池中
# 解析
作用:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对 类
或 接口
、 字段
、 类方法
、 接口方法
、 方法类型
、 方法句柄
和 调用点
限定符 7 类符号引用进行。
符号引用:一组符号来描述目标,可以是任何字面量。
直接引用:直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
例如:
创建一个类,使用
System.out.println("Hello Wolrd")
打印,那么所编译的字节码文件,就需要引用的类包括 System、Object 等,而字节码中的常量池是通过符号引用引用的,在解析阶段会将其中符号引用替换为直接引用。
# 初始化(Initialization)
作用:为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化。
初始化阶段就是执行类构造器方法
<clinit>()
的过程。字节码中存在一个
<clinit>()
方法,这个方法不需要定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。(如果没有静态变量要赋值则没有这个方法)构造器方法中指令按语句在源文件中出现的顺序执行,即读到哪一行就执行哪一行,声明赋值和静态代码块赋值并没有优先顺序,谁写在后就执行在后。
<clinit>()
不同于类的构造器。关联:构造器是虚拟机视角下的
<init>()
若该类具有父类,JVM 会保证子类的
<clinit>()
执行前,父类的<clinit>()
已经执行完毕。虚拟机必须保证一个类的
<clinit>()
方法在多线程下被同步加锁。
对类变量进行初始值设定有两种方式:
- 声明类变量是指定初始值
- 使用静态代码块为类变量指定初始值
# JVM 初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
# 类初始化时机(主动使用)
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射 (如 Class.forName ("cn.nipx.jvm.Test"))
- 初始化某个类的子类,则其父类也会被初始化
- Java 虚拟机启动时被标明为启动类的类 (Java Test),直接使用 java.exe 命令来运行某个主类
例子 - 初始化赋值顺序:
点击查看
public class ClassInitTest {
private static int num = 1;
static {
num = 2;
number = 20;
// System.out.println(number); // Illegal forward reference(非法的前向引用)
}
private static int number = 10;
public static void main(String[] args) {
System.out.println(ClassInitTest.num);
System.out.println(ClassInitTest.number);
}
}
字节码反编译
static <clinit>()V
L0
LINENUMBER 8 L0
ICONST_1
PUTSTATIC cn/nipx/demo/ClassInitTest.num : I
L1
LINENUMBER 11 L1
ICONST_2
PUTSTATIC cn/nipx/demo/ClassInitTest.num : I
L2
LINENUMBER 12 L2
BIPUSH 20
PUTSTATIC cn/nipx/demo/ClassInitTest.number : I
L3
LINENUMBER 16 L3
BIPUSH 10
PUTSTATIC cn/nipx/demo/ClassInitTest.number : I
RETURN
MAXSTACK = 1
MAXLOCALS = 0
- 由反编译后的
<clinit>
方法可看出,num 变量依次被赋值为1、2
,number 变量依次被赋值为20、10
,由此可见赋值顺序时根据出现的顺序。 - 静态构造器不能访问构造器代码前未定义的变量
为什么num是ICONST,number是BIPUSH赋值?
当 int 类型
- 取值 - 1~5 采用 iconst 指令
- 取值 - 128~127 采用 bipush 指令
- 取值 - 32768~32767 采用 sipush 指令
- 取值 - 2147483648~2147483647 采用 ldc 指令。
例子 - 证明初始化时会加锁、证明类只加载一次:
点击查看
public class DeadThreadTest {
public static void main(String[] args) {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + "开始执行");
DeadThread deadThread = new DeadThread();
System.out.println(Thread.currentThread().getName() + "执行完毕");
};
new Thread(r, "线程1").start();
new Thread(r, "线程2").start();
}
}
class DeadThread {
static {
if (true) {
System.out.println(Thread.currentThread().getName() + "初始化当前类");
while (true) {
}
}
}
}
- 执行代码,程序阻塞,控制台打印
线程1(或2)初始化当前类
,即同时只能有一个线程执行静态代码块,说明有锁。 - 去掉 DeadThread 类静态代码块中的死循环,执行代码,直至程序结束,控制台只打印一条
线程1(或2)初始化当前类
,即至多只有一个线程执行静态代码块,说明类只加载一次。
具体参考《尚硅谷宋红康 JVM 全套教程》p30
# 使用
类访问方法区内的数据结构的接口, 对象是 Heap 区的数据。
# 卸载
Java 虚拟机将结束生命周期的几种情况
- 执行了
System.exit()
方法 - 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致 Java 虚拟机进程终止
# 类加载器
# 层次
注意
这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的。
# 虚拟机角度
站在 Java 虚拟机的角度来讲,只存在两种不同的类加载器:
# 启动类加载器
它使用C++ 实现(这里仅限于 Hotspot
,也就是 JDK1.5 之后默认的虚拟机,有很多其他的虚拟机是用 Java 语言实现的),是虚拟机自身的一部分;
# 所有其他的类加载器
这些类加载器都由Java 语言实现,独立于虚拟机之外,并且全部继承自抽象类 java.lang.ClassLoader
,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
# 开发人员角度
站在 Java 开发人员的角度来看,类加载器可以大致划分为以下三类 :
# 启动类加载器 (Bootstrap ClassLoader)
使用C/C++ 语言实现的,嵌套在 JVM 内部。
它用来加载 Java 的核心库(
JAVA_HOME/jre/lib/rt.jar
、resources.jar
、sun.boot.class.path
路径下的内容或被-Xbootclasspath
参数指定的路径中的),用于提供 JVM 自身需要的类并不继承自 java.lang.ClassLoader,没有父加载器。
出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类
无法被 Java 程序直接引用
# 扩展类加载器 (Extension ClassLoader)
Java 语言编写,由
sun.misc.Launcher$ExtClassLoader
实现。派生于 ClassLoader 类
父类加载器为启动类加载器
从
java.ext.dirs
系统属性所指定的目录中加载类库,或从 JDK 的安装目录的jre/lib/ext
子目录(扩展目录)下加载类库(如javax.*
开头的类)。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载。
# 应用程序类加载器 (Application ClassLoader)
Java 语言编写,由
sun.misc.Launcher$AppClassLoader
实现派生于 ClassLoader 类
父类加载器为扩展类加载器
它负责加载环境变量
classpath
或系统属性java.class.path
指定路径下的类库该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载
通过
ClassLoader#getSystemclassLoader()
方法可以获取到该类加载器
为什么需要自定义加载器?
在 Java 的日常应用程序开发中,类的加载几乎是由上述 3 种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
隔离加载类
修改类加载的方式:动态地创建符合用户特定需要的定制化构建类
扩展加载源:从特定的场所取得 java class,例如数据库中和网络中
防止源码泄漏:在执行非置信代码之前,自动验证数字签名
# 类加载器的加载路径
点击查看
public class ClassLoaderTest1 {
public static void main(String[] args) {
System.out.println("*********启动类加载器*********");
// 获取BootstrapClassLoader能够加载的api的路径
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL element : urls) {
System.out.println(element.toExternalForm());
}
// 从上面的路径中随意选择一个类,来看看他的类加载器是什么:【得到的结果是null,说明是引导类加载器】
ClassLoader classLoader = String.class.getClassLoader();
System.out.println(classLoader);
System.out.println("*********扩展类加载器*********");
String extDirs = System.getProperty("java.ext.dirs");
for (String path : extDirs.split(";")) {
System.out.println(path);
}
// 从上面的路径中随意选择一个类,来看看他的类加载器是什么:【得到的结果是sun.misc.Launcher$ExtClassLoader@18b4aac2】
}
}
# 寻找类的加载器
寻找类加载器小例子如下:
package cn.nipx.jvm.classloader;
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
System.out.println(loader);
System.out.println(loader.getParent());
System.out.println(loader.getParent().getParent());
}
}
结果如下:
sun.misc.Launcher$AppClassLoader@64fef26a
sun.misc.Launcher$ExtClassLoader@1ddd40f3
null
从上面的结果可以看出,并没有获取到 ExtClassLoader
的父 Loader,原因是 BootstrapLoader
(引导类加载器) 是用 C 语言实现的,找不到一个确定的返回父 Loader 的方式,于是就返回 null
。
# 类的加载方式
类加载有三种方式:
命令行启动应用时候由 JVM 初始化加载
通过
Class.forName()
方法动态加载通过
ClassLoader.loadClass()
方法动态加载
package cn.nipx.jvm.classloader;
public class LoaderTest {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader loader = LoaderTest.class.getClassLoader();
System.out.println(loader);
// 使用ClassLoader.loadClass()来加载类,不会执行初始化块
loader.loadClass("cn.nipx.jvm.classloader.Test2");
// 使用Class.forName()来加载类,默认会执行初始化块
// Class.forName("cn.nipx.jvm.classloader.Test2");
// 使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块
// Class.forName("cn.nipx.jvm.classloader.Test2", false, loader);
}
}
public class Test2 {
static {
System.out.println("静态初始化块执行了!");
}
}
分别切换加载方式,会有不同的输出结果。
Class.forName()和ClassLoader.loadClass()区别?
ClassLoader.loadClass()
:只干一件事情,就是将.class 文件加载到 jvm 中,不会执行 static 中的内容,只有在 newInstance 才会去执行 static 块。Class.forName()
:将类的.class 文件加载到 jvm 中之外,还会对类进行解释,执行类中的 static 块;Class.forName(name, initialize, loader)
:带参函数也可控制是否加载 static 块。并且只有调用了newInstance()
方法采用调用构造函数,创建类的对象 。
# 自定义类加载器
通常情况下,我们都是直接使用系统类加载器。但是,有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。
下面我们通过一个示例来演示自定义类加载器的流程:
点击查看
package cn.nipx.jvm.classloader;
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
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;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
classLoader.setRoot("D:\\temp");
Class<?> testClass = null;
try {
testClass = classLoader.loadClass("cn.nipx.jvm.classloader.Test2");
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对 class 文件进行加密,因此没有解密的过程。
这里有几点需要注意 :
这里传递的文件名需要是类的全限定性名称,即
cn.nipx.jvm.classloader.Test2
格式的,因为 defineClass 方法是按这种格式进行处理的。最好不要重写 loadClass 方法,因为这样容易破坏双亲委托模式。
这类 Test 类本身可以被 AppClassLoader 类加载,因此我们不能把 cn/nipx/jvm/classloader/Test2.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
# JVM 类加载机制
# 全盘负责
当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显式使用另外一个类加载器来载入
# 父类委托
先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
# 缓存机制
缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区。
这就是为什么修改了 Class 后,必须重启 JVM,程序的修改才会生效
# 双亲委派机制
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
# 过程
AppClassLoader -> ExtClassLoader -> BootStrapClassLoader
- 当 AppClassLoader 加载一个 class 时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器 ExtClassLoader 去完成。
- 当 ExtClassLoader 加载一个 class 时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给 BootStrapClassLoader 去完成。
- 如果 BootStrapClassLoader 加载失败 (例如在 $JAVA_HOME/jre/lib 里未查找到该 class),会使用 ExtClassLoader 来尝试加载;
- 若 ExtClassLoader 也加载失败,则会使用 AppClassLoader 来加载,如果 AppClassLoader 也加载失败,则会报出异常
ClassNotFoundException
。
# 代码实现
点击查看
public Class<?> loadClass(String name)throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 首先判断该类型是否已经被加载
Class c = findLoadedClass(name);
if (c == null) {
// 如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
try {
if (parent != null) {
// 如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
// 如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
# 优势
- 系统类防止内存中出现多份同样的字节码
- 保证 Java 程序安全稳定运行
沙箱安全机制
自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar 包中 java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 string 类。这样可以保证对 java 核心源代码的保护,这就是沙箱安全机制。
# 补充
如何判断两个 class 对象是否相同?
在 JVM 中表示两个 class 对象是否为同一个类存在两个必要条件:
- 类的完整类名必须一致,包括包名。
- 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同。
换句话说,在 JVM 中,即使这两个类对象(class 对象)来源同一个 Class 文件,被同一个虚拟机所加载,但只要加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的。
类的主动使用和被动使用
Java 程序对类的使用方式分为: 主动使用
和 被动使用
。
主动使用
,又分为七种情况:
创建类的实例
访问某个类或接口的静态变量,或者对该静态变量赋值
调用类的静态方法
反射(比如:Class.forName("com.atguigu.Test"))
初始化一个类的子类
Java 虚拟机启动时被标明为启动类的类
JDK 7 开始提供的动态语言支持:
java.lang.invoke.MethodHandle
实例的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 句柄对应的类没有初始化,则初始化
除了以上七种情况,其他使用 Java 类的方式都被看作是对类的 被动使用
,都不会导致类的初始化。