前言
我们现在都知道了,JVM是java这门语言的基础,是java这门语言得以发扬光大的基础,是java程序运行的平台,是实现java特性的一个必要前提。
但是,JVM是具体做了什么工作呢?对于这个问题,我们依然还不清楚。因此,我们需要要深入的去了解一下JVM。这,也是我的一系列文章的主要目的。
我们先来看一张图:
这张图,显示的是JVM的主要组成部分。
我们发现,我们的java程序编译后形成的class字节码文件,是通过JVM中的类加载器加载到我们的JVM中的,那么,类加载器的加载过程,是怎么样的呢?
虽然我这篇文章的主题是类加载器,但是我们要知道的是,类加载器只是在类的加载过程中的某一个节点起到作用了而已,但是这不代表着我们不需要对类的加载流程做一个整体的认知,因为这个类的加载过程很重要。
也因此,我们要先对类的加载流程有一个整体的认知。
类的加载流程
在了解类加载器之前,我们要先了解一下类的加载的整体流程。
一个java类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如图所示。
类的生命周期(如图所示):
而一般来说,我们所谓的Java虚拟机中类加载的全过程,指的是以下5个:
1.加载
2.验证
3.准备
4.解析
5.初始化
与那些在编译时需要进行连接的语言不同,在Java语言里面,java类的加载、连接(连接,包括了验证,准备和解析这三个阶段)和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销, 但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。请注意,这里指的是按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。
要注意的一点是,只有在加载阶段,对于开发人员来说才是可见的,开发人员可以通过自己编写类加载器来参与这个步骤中。
接下来,让我们对java类的加载流程的每一个部分都进行一下研究。
1. 类的加载流程之:加载
加载这个阶段是类加载器(类加载器主要就是在这个阶段起到作用了) 通过一个类的全限定名来查找此类字节码文件,并且利用字节码文件来创建一个Class对象。因此,在这个阶段,JVM需要做三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
当然,类的加载就是由我们的类加载器完成的,类加载器一般来说是由JVM提供的,但是相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。
数组类型的特殊点在哪?
数组类:数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element
Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。
加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。
也因此,《Java虚拟机规范》对这个阶段的宽松管理,从而造成了java虚拟机开发者可以在加载阶段进行各种各样的尝试,用户可以自己定义自己的类加载器,而许多我们习以为常的java技术,都是建立在这个基础上了。
使用不同的类加载器,可以从不同的来源,来加载类的二进制数据。
- 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
- 从本地文件系统加载class文件,这是绝大部分程序的类加载方式。
- 通过网络加载class文件。
- 可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文件来保障程序运行逻辑不被窥探。
- 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了。
要注意的是,加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段(连接阶段,包含着验证,准备和解析这三个阶段)可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。
2. 类的加载流程之:验证
首先我们知道,相对于c语言,c++语言来说,java是相对比较安全的语言,如果使用纯粹的java代码,有一些事情是无法做到的,比如说:
- 无法做到访问类似于数组边界之外的数据。
- 无法将一个对象转型为它并未实现的类。
- 无法跳转到不存在的代码。
如果尝试的去做这些事,那么我们的编译器肯定会毫不犹豫的抛出错误的。
但是,我们都知道,由于JVM上运行的是java程序编译成的字节码文件,那么如果我们通过编写字节码代码,对程序进行修改,从而去实现刚刚我所说的那些事情,而JVM加载的时候,又没有验证,那么会导致我们的程序会出现大量的错误甚至程序崩溃的可能。这种攻击出现的可能性,是我们JVM必须有验证阶段的原因。
因此,我们可以得出一个简单的结论,那就是验证的目的,就是为了确保“.class”文件中字节流,是完全符合JVM的规范的,不会影响到JVM自身的安全,确保不会受到恶意的入侵。
那么,为了确保符合JVM的规范,在验证的时候,都做了哪些工作呢?
-
文件格式验证:主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。
-
元数据验证:对字节码描述的信息进行语义的分析,分析是否符合java的语言语法的规范。
-
字节码验证:最重要的验证环节,分析数据流和控制,确定语义是合法的,符合逻辑的。主要的针对元数据验证后对方法体的验证。保证类方法在运行时不会有危害出现。
-
符号引用验证:主要是针对符号引用转换为直接引用的时候,是会延伸到解析阶段,主要去确定访问类型等涉及到引用的情况,主要是要保证引用一定会被访问到,不会出现类等无法访问的问题。
3. 类的加载流程之:准备
准备阶段,是正式为java类中定义的变量(即静态变量,被static修饰的变量)分配内存,并且设置类变量初始值的阶段。
要注意的一点就是,这时候进行内存分配的,仅仅只是包括类变量(即静态变量),而不包括实例变量。
而这个阶段所谓的初始值,一般来说是这个数据类型的零值。
基础类型的零值如下:
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | ‘\u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
那么,有没有可能初始值不是零值的情况呢?确实是有的。
先看这段代码:
private static int id = 123456789;
我们根据我们之前所说,可以得出结论,那就是在准备阶段,id这个静态变量,最后能获取到的初始值,肯定是0,这个自然是毋庸置疑的了。
那么,我们接下来看第二段代码:
private static final int id = 123456789;
可以很明显的看出,这段代码,是比之前那段代码,多了一个“final”,那么,这个变量,它的初始值是什么呢?
在这一段代码里面,id这个变量,它的初始值却是123456789。
原因是,这一段代码,因为有了final之后,在从java程序编译成class字节码阶段,会在字节码的字段表中,为id这个变量生成一个属性:ConstantValue。
当我们的虚拟机在准备阶段,如果碰到了这个属性,那么就会将id的初始值设置为123465798。
请注意,我们可以说,有final修饰的静态变量,初始值会这样,但是,不仅仅是final修饰的静态变量会这样,如果这样说,那么是狭隘的,也可以说是不正确的。
是在经过编译后,字段表中出现了属性ConstantValue的变量,初始值才会这样。
4. 类的加载流程之:解析
首先,我们先了解这个阶段的定义:解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
这里,有了新的概念,就是符号引用。那么什么是符号引用呢?
当我们的java代码在编译成为Class字节码的时候,java并不知道所引用的类的实际地址,我所讲的实际地址,意思是这个类在JVM内存中的具体位置,这个当然没有啊,整个java程序都没有加载到JVM中,又怎么会有这个类的实际地址呢?
但是编译还是要继续,java类必须要一组符号来定位到这个类。于是,java决定使用符号引用来代替,符号引用可以是任何形式的字面量,只需要这个字面量能准确的无歧义的定位到目标就可以了。
而直接引用,是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只是规定了当在执行如下的操作符号引用的字节码指令之前,先对他们的符号引用进行解析:
操作符号引用的字节码指令:
ane-warray
checkcast
getfield
getstatic
instanceof
invokedynamic
invokeinterface
invoke-special
invokestatic
invokevirtual
ldc
ldc_w
ldc2_w
multianewarray
new
putfield
putstatic
所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
解析阶段,主要是针对了如下7类的符号引用进行解析:
1.类
2.接口
3.字段
4.类方法
5.方法类型
6.方法句柄
7.调用点限定符
5. 类的加载流程之:初始化
java类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段,用户可以通过自定义类加载器的方式局部参与加载外,其余动作都完全由Java虚拟机来主导控制。
直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,并且将主导权移交给java应用程序。
那么,这个初始化,是做什么操作呢?我们先看一段我们之前举过例子的代码。
private static int id = 123456789;
这段代码,在准备阶段,id这个静态变量会被赋值一个初始值,一般来说是这个数据类型的零值,也就是0。那么,到了我们现在的初始化阶段,id这个静态变量才会被赋值为132456789。
当然,初始化阶段做的不仅仅是这件事,在这个阶段的主要工作是:
- 给类变量(静态变量)赋定义的值
- 执行静态代码块(静态代码块只能访问定义在静态代码块之前的静态变量,定义在静态代码块之后的静态变量,可以赋值,但是不能访问)
我们要注意的是,在初始化阶段,Java虚拟机必须保证一个类在多线程环境中被正确地加锁同步。
如果多个线程同时去初始化同一个类,那么只会有其中一个线程会进行初始化,而其他线程都需要阻塞等待,直到活动线程执行初始化完毕。
当然,如果在这个类的方法中有耗时很长的操作,那就可能造成其他在等待的多个进程阻塞,而且在实际应用中这种阻塞往往是很隐蔽的,因此我在这里特地说明一下。
但是实际上,不管多线程还是怎么样,在同一个类加载器下,一个类型只会被初始化一次。
结束语
本篇文章,只写到了类的加载流程后,后续的关于类加载器是什么,类加载器的加载机制等等一些内容还没有写。
但是,想要了解类加载器,或者说想要了解整个java类的加载流程,我们就必须要先对本文所讲的东西有一个清晰的认识,当我们对类的加载过程有了清晰的认知,我们才能知道类加载器在其中能起到什么作用。
(ps:好吧,其实大家都知道,类加载器,就是用在类的加载流程中:“加载”这个步骤的。)
在写这篇文章的时候,我感觉我真的是心很累,如何从许许多多的已经存在的文章和书籍中,找出一条我觉得最适合的方向以及陈述方式,这对我来说,是一个非常有难度的事情。
就写到这儿吧,下一篇文章,《从头开始学习JVM(四):类加载器(中)》,我会尽快出的。
参考博客
https://blog.csdn.net/javazejian/article/details/73413292
https://blog.csdn.net/m0_38075425/article/details/81627349
https://blog.51cto.com/lavasoft/15433
https://juejin.im/post/6890566314587488263
参考书籍
周志明《深入理解java虚拟机》