JVM-01-类加载子系统
从零开始的 JVM 学习生活
JVM 作为 Java 运行的平台,的确是需要好好玩一玩的!
JVM 声明周期
JVM 生命周期分为 启动 执行 结束 三个阶段。
启动
通过 BootStrap ClassLoader 这个类加载器把该虚拟机的初始类加载进来并执行(初始类负责完成启动工作,不同的 JVM 初始类叫法不同)
执行
一个 Java 程序的执行本质就是一个 JVM 进程的执行,执行过程中可以看到 OpenJDK Platform 这个进程的资源使用情况。
结束
JVM 结束可能情况:
- 正常程序结束
- 出现错误和未处理异常
- 操作系统出现错误
- Java 某个线程调用了 exit 方法(会结束整个 JVM 进程)
类加载子系统
Class Loading SubSystem
这个系统主要负责将一个 class 文件加载到 JVM 中的全过程,而这个过程包含下面的 加载-链接-初始化 三个步骤。
加载 Loading
加载 Loading 过程主要干了三件事:
- 先获取类的二进制字节流(读取 class 文件获取流)
- 将二进制字节流转化为内存中方法区的数据(将文件内容转移到内存中固定格式的数据)
- 在内存中生成此类的 Class 对象,作为方法区这些数据的入口(获取操作这些数据的接口)
链接 Linking
链接 Linking 也主要干三件事:验证-准备-解析
验证 Verify
主要是为了保证上一步加载进来的二进制数据都是合法的。
- 文件格式验证:
验证文件内容组成是否符合 JVM 规范(文件结构校验)
1 | 魔数 CAFEBABE |
- 元数据验证:
对类的描述信息进行语义验证(语法校验)
1 | 这个类是否继承了标了 final 的类 |
- 字节码验证
对方法体的字节码指令验证(运行时语法校验)
1 | 操作栈放入 int 却按照 float 加载 |
- 符号引用验证
1 | 比如常量池的引用类型全限定内名能否找到类 |
准备 Prepare
准备这一段主要在为类的非 static final 字段分配内存。该地址的内存空间中全是二进制零,也就是: 0 false 0x00 \u0000 0.0f null(引用类型) 等等。
对于一个非 static final 的字段来说,比如:
1 | public int i = 10; |
实现执行 prepare 阶段,为 i 分配内存,内存中值为 0,结束 prepare 之后,会在后面的阶段(static 是在类加载子系统的初始化阶段,非 static XXXXX)为 i 设置自定义初始值 10。
为什么 final 字段一定要自定义初始化?
如果不在定义的时候或者构造函数中对 final 字段进行赋值的话,则生成的对象中 final 字段的值是未知的,外部也不知道此字段是否能赋值。因此 final 字段要么在定义的时候给初始化值,要么在构造函数中进行初始化,总之绝不能在实例中调用 final 是一个未知量。
结合上面 final 的分析,因为 static final 不依赖于实例,所以自然没了 final 字段中构造函数初始化的方式,是只能也必须在定义的时候给出初始化值的。
这样 static final 初始化值一定是常量,是在编译成字节码的时候就确定的。因此这里在准备阶段分配完内存之后,直接默认给初值为常量值了。因此 static final 的字段初始化值确定就是在 Prepare 阶段完成的。
解析 Resolve
解析主要是符号引用(比如限定全类名,Ljava/lang/String;
toString()
)转化为一个指向目标的指针,比如(String 对象地址,方法区 toString() 的地址)
除了符号引用,还有字面量,尤其是字符串字面量的处理,JVM 会根据字符串的字面量在堆中创建一个 String 对象,然后将这个对象的引用放入到运行时字符串常量池中。(当然也不是一定会这样做,JVM 规范说这个步骤可以是 Lazy 的)
解析主要是在 JVM 执行完下面的初始化之后再执行的。
除了符号引用变为目标引用,还会在这个过程中建立一个虚方法表,用来解决由于多态引起的频繁方法分派的问题。
初始化 Inilalization
这个初始化是针对类 static (不包括 static final)字段和 static 代码块。链接的准备阶段为字段分配了内存空间,而这个现在所处的阶段就是为分配了空间的 static 字段进行赋值和执行 static 代码块里面的内容。
这里的初始化就是调用通过字节码指令分析(收集所有的 static 字段赋值指令和 static 代码块中的代码指令)生成的一个 <clinit>
(class init) 方法,把所有对 static 字段进行初始化的代码和结合在一起,然后执行,比如:
1 | private static int num=1; |
就会生成用来执行的初始化方法 <clinit>
,它的字节码如下:
1 | 0 iconst_1 |
这里生成的 <clinit>
就是将 static 的 num 赋值语句和 static 代码块里面的语句的字节码放在一起,然后在当前线程中执行它。
需要注意的是,在初始化阶段中的 <clinit>
执行内容中是不包括 static final 的初始化赋值的,因为这个过程已经在链接的 Prepare 阶段完成。
并且 <clinit>
的执行过程是线程安全的,一个类只会被类加载一次,也就是只会执行一次 <clinit>
方法。
类加载器
类加载器负责,类加载系统中的装载 Loader 阶段,也就读取二进制字节流并生成对应的 Class 对象。
来看看一个类是如何被加载到 JVM 中的吧:
分类
- 引导类加载器 BootStrapClassLoader
作用:用来加载 Java 核心库的类(java、javax、sun 开头的类),使用 C++ 编写,无法获取其对象,作为 JVM 的一部分。在 JVM 启动的时候,就会调用 BootStrapClassLoader 去加载一些 JVM 需要的类,其中就包括了 ExtClassLoader 和 AppClassLoader (所以这两个 ClassLoader 是 BootStrapClassLoader 加载出来的),并作为 ExtClassLoader 的父加载器。
这里是在 Launcher (BootStrapClassLoader 加载的)里面实现上面的内容的。
1 | public class Launcher { |
- 自定义类加载器 UserDefineClassLoader
概念:这里不是特指程序员自己写的类加载器,而是所有继承了 ClassLoader 类的加载器都叫自定义加载器。
比如用来加载 ext 目录下类的扩展类加载器 ExtClassLoader,以及负责加载 classpath 的应用程序类加载器 AppClassLoader 也就是我们自己编写的类的默认加载器。
当然我们也可以自己定义类加载器,来实现特殊的功能,这个后面再说。
获取类加载器
- 获取指定类的类加载器 clazz.getClassLoader()
- 获取当前线程执行类的类加载器 Thread.currentThread().getContextClassLoader()
- 获取当前系统加载第三方类的默认的 ClassLoader(一般来说是 AppClassLoader)ClassLoader.getSystemClassLoader()在我们代码里面,引用一个类的时候(需要被加载),是当前线程调用 getContextClassLoader() 获取的,默认是 AppClassLoader,也就是说,我们引用的类一般都是使用默认的 AppClassLoader 来处理。而加载核心库和 BootStrapClassLoader 和加载 ext 目录下的扩展类的 ExtClassLoader 什么时候排上用场呢,就要轮到下面介绍的双亲委派机制了:
双亲委派机制
当一个类加载器收到了类加载请求会先交给它的父类加载器处理,当父类加载器无法处理的时候,自己再判断能否处理,能就处理就处理。所以所有的类加载都会被上级 ClassLoader 确认,保证了一定是最顶级的类加载器去加载自己能加载的类:
说起类加载器的双亲委派机制,就要讨论一下 ClassLoader 这个抽象类了。
核心方法的核心代码:
1 | protected Class<?> loadClass(String name, boolean resolve) { |
自定义类加载器
想要自定义其实就继承 ClassLoader 抽象类即可,然后重写方法 findClass(),不直接重写被调用的 loadClass() 方法是因为想要保留双亲委派机制,所以就更改 findClass() 来更改对字节码的处理过程实现自定义类加载器。
下面就是一个针对 AES 加密算法加密过的 .class 文件进行加载的过程。
1 | public class AESClassLoader extends ClassLoader { |
类加载子系统暂时告一段落,下一个目标,运行时数据区概述及线程!