把Class文件加载到内存,并校验,转换解析,初始化,最终形成可直接使用的Java类型的过程称为虚拟机的类加载机制。
在Java里。类型的加载,链接,初始化过程不是编译期完成的,而是运行期完成的,这点和其他静态语言不同。因此这会损失一些性能开销,但是却带来了极高的灵活性,因为此时Class文件不仅可以从磁盘加载,也可以从网络流,甚至内存(动态代理生成的Class文件)加载。因为编译期不进行加载,就使得运行期可以无限添加新的类型。
类加载的时机
先说说一个类型的生命周期,分为7个:
加载、验证、准备、解析、初始化、使用、卸载。
其中验证、准备、解析又可称为链接。
加载就是把字节码文件从IO或内存加载到内存中的过程;初始化就是使用<clinit>()进行类初始化的过程,这不同于调用构造函数;使用就是字面意思;卸载就是从方法区移除类型。
加载->验证->准备->初始化->卸载这五个阶段的开始顺序是固定的,这里强调开始,因为这几个阶段可能交叉进行,但是开始顺序必定是固定的,解析阶段不一定,它有可能后于初始化,这个看实际情况。
另外类的加载没有要求什么时候开始,所以完全可以在用到了这个类再加载,如果这个类引用了别的类,再加载,链式反应。
对于初始化阶段,却有着明确的规定,有且仅有以下六种:
- 1️⃣使用new,putstatic,getstatic,invokestatic这四个指令时。比如:使用new关键词实例化对象时; 读取或者设置static字段时(被设置为final的字段不算,因为他们会被放在class文件常量池中);调用静态方法时。
- 2⃣️使用Reflect包对类型进行反射调用时,可能要先初始化类型。
- 3⃣️如果初始化时,这个类型的父类没有初始化,则触发父类初始化。
- 4⃣️JVM启动时会初始化Main类。
- 5⃣️如果一个类继承的接口有默认方法,那么这个类初始化,会触发这个有默认方法的接口的初始化。
- 6⃣️如果一个java.lang.invoke.M ethodHandle实例最后的解 析结果为REF_getStatic、REF_putStaticREF_invokeStatic、REF_newInvokeSp ecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
这六种方式又称为对类型的主动引用。既然有主动引用,就会有被动引用。
1⃣️一个典型的被动引用场景就是,使用子类引用父类的静态字段,只会触发父类初始化,而不出触发子类初始化;
2⃣️另一个就是使用对象数组时,新建一个对象数组并不会触发引用类型的初始化;
3⃣️还有一个就是对于常量值(static final修饰的域)的引用不会触发初始化,因为常量放在了常量池中。
类加载过程
类加载分为:加载、验证、准备、解析、初始化这五个阶段。
- 加载就是载入内存,没什么好说的;
- 验证主要验证加载的Class文件的合法性;
- 准备阶段负责为类变量(注意⚠️不是实例变量),也就是那些被修饰为static的变量分配空间并赋予零值的过程(注意⚠️是零值,不是初始值);
- 解析就是把符号引用替换成直接引用的过程;
- 初始化就是调用<clinit>()方法初始化,赋初始值的过程。
加载
加载阶段,虚拟机要做这么几件事:
- 1⃣️通过类的全限定名获取定义这个类的二进制字节流。
- 2⃣️把字节流描述的静态存储结构转换成方法区的运行时数据结构。
- 3⃣️在内存中生成对应的Class<T>对象,作为访问这个类的各种数据的访问入口。
类加载器干的就是这三个步骤,所以也可以自定义类加载器来完成这个过程。类加载器负责类的加载,类的加载是类加载的第一个过程。请不要搞混了,这几个词很像。
因为第一步没有限制从哪里加载二进制流,所以开发者可以玩出花来,动态代理技术便是依赖于此;对于非数组类型的二进制字节流的来源,没有限制,同时也可以使用自定义类加载器加载类。
这里要提一下数组类型的加载。如果数组类的组件类型(就是去掉一个维度的那个类型,int[] -> int, Integer[] -> Integer,二维数组本质就是一位数组)是引用类型,那么数组类型的加载将会使用组件类型的加载器加载,否则使用引导类加载器加载。
二进制流加载完了,就会按照虚拟机的设定,以一定顺序排列在方法区;然后设置Class<T>类型的clazz对象作为数据访问入口。
验证
验证的主要目的是保证这些class文件不会危害虚拟机安全。
验证基本分为四个方面,第一个会把字节流加载进方法区,后三个都是基于方法区的验证:
- 1⃣️文件格式验证。这个比较简单,就是验证文件格式是否是.class文件,文件版本是否可以被当前虚拟机支持等。都是一些最基本的验证。
- 2⃣️元数据验证。这个阶段主要是对类的元数据信息进行语意检验,比如是否继承了final类,是否实现了接口或父类的抽象方法,是否发生了不允许的方法覆盖。
- 3⃣️字节码验证。这是最复杂的阶段,此事涉及到对数据流的分析和控制流的分析,包括对Class文件中的Code属性进行分析,也就是方法体分析。比如是否字节码想操作int但是操作数栈里放的是long或double,又或者有指令跳转到了别处,访问了不该访问的地方,还有就是强制类型转换时的问题等,比如父类对象(是对象不是引用,这里和多态不一样)赋给子类类型。
- 4⃣️符号引用验证。通俗来说就是看这个类是否引用了不应该被它引用的类型,数据。
验证阶段一旦成功,那么接下来每次对于这个类的调用都是成功的。如果你对于项目中的类都很有把握的话(没有恶意攻击的类型),则可以关闭验证阶段,这个阶段很耗时,同时因为仅需验证一次即可,所以如果某一次验证通过了,就可以关闭验证,接下来次次使用均可。
准备
顾名思义,就是准备类变量。JDK7之前的类变量时分配到方法区(永生代)中的,JDK8之后随着Class对象一起存放在堆中。类变量指的是用static修饰的变量。实例变量会随着实例的初始化被一并分配到堆上。
为类变量分配空间之后,就是赋零值,至于每个类型的零值,想必大家都知道,就不说了。
解析
解析阶段就是把常量池中的符号引用替换成直接引用的过程。先来看看什么是符号引用,什么是直接引用。
符号引用:用一组符号来描述所引用的目标,符号可以是任意形式的字面量,但必须是无歧义的。
直接引用:可以是对象的内存地址,也可以是对象的句柄,也可以是一个偏移量。
至于什么时候进行解析,并没有限制,一般可以在使用这个符号时进行解析。
一般而言,解析解析可以分为四种解析:
- 1⃣️类/接口的解析。假设当前类为D,符号N要解析成类或接口C的直接引用,那么会发生如下过程:如果C不是数组类型,则把N代表的全限定名交给类加载器加载;如果是数组类型,则使用第一种方式加载数组元素类型。
- 2⃣️字段解析。首先找到这个字段的类型,假设为C,然后使用刚刚说的接口/字段解析方式进行解析,然后在C中,C实现的接口中,C继承的父类中寻找简单名称和字段描述符都与目标相匹配的字段,找不到就抛异常。
- 3⃣️方法解析。首先找到方法所属的类型,然后和字段解析一样balabala一堆。
- 4⃣️接口方法解析。首先获取接口方法类型C,然后在C中查找,若找不到,去C的父接口中查找。
初始化
直到初始化阶段,程序主导权才落到用户程序手中,此时会根据代码逻辑进行初始化类变量和其他资源。或者这么说,初始化阶段就是调用<clinit>()的过程。
<clinit>()是通过收集类变量的赋值操作和static{}语句块中的代码由JVM自动生成的。收集顺序和源文件中的编写顺序有关,因此不能“前向引用”。
此外,<clinit>()和<init>()不同,它不需要显示地调用父类构造器,它可以确保子类的<clinit>()在被调用时,父类的已经调用完毕。
另外<clinit>()不是必须的,如果没有类变量赋值等语句,则不需要。接口的<clinit>()调用不同于类的调用,子接口的调用不会触发父接口的<clinit>(),除非需要使用父接口。接口的实现类也是这样,实现类调用也一样可能不会触发接口的<clinit>()。
Java必须保证<clinit>()被同步,这样在多线程环境中可以保证安全性,如果<clinit>()中有耗时操作,就可能阻塞多个线程。同时<clinit>()只会被调用一次,其他线程在一个线程调用完成后就不会再调用<clinit>()了。
类加载器
一个类型和它的类加载器共同确定了唯一性,如果两个类相同,但是加载器不同,则他们是不同的,双亲委派机制的优点之一就在这。
双亲委派机制
在Java里,只有两种类加载器,一种是启动类加载器(BootstrapClassLoader),使用C++实现,属于JVM;另一部分属于自定义类加载器,使用Java实现,独立于JVM。如果使用过Unsafe类就知道,Unsafe只能由被Bootstrap加载的类使用,这也就限制了Unsafe的使用,确保了其安全性。
Java自从JDK2以来,一直保持着三层类加载器,双亲委派模型。
- 1⃣️启动类加载器(BootstrapClassLoader)主要负责加载lib包下面的标准库代码,用户程序无法直接使用Bootstrap。
- 2⃣️扩展类加载器(ExtensionClassLoader)用来加载/lib/ext包下面的代码,开发者可以直接使用。
- 3⃣️应用程序类加载器(ApplicationClassLoader)用来加载用户编写的代码,这也是默认的加载用户代码的加载器,可以直接使用。
- 4⃣️自定义类加载器用来实现程序员想要的特殊类加载功能。
类加载器之间通常是组合关系,而不是继承关系。
所谓的双亲委派模型指的就是,底层的类加载器会把实际的加载委托给上层完成,如果上层不能完成,就自己来完成,如果还不行就抛异常。这一点的好处就是,同一个类的加载总是可以被同一个加载器完成,而不论整个系统有多少个不一样的加载器,因为最终都能映射到上层去。加上比较类型一致的原则是类型+加载器,所以就能保证同一类型总是相同的。