对象的创建
类加载
类的加载一般分为三个步骤:加载、链接、初始化。其中链接又分为:验证、准备、解析三个子过程。
类的加载的执行顺序是会按照加载、验证、准备、初始化严格开始,但仅仅是开始顺序如此,因为每一个过程的执行都是一段时间,存在当前阶段没有执行完,下一个阶段就开始的情况。
注意,上述开始顺序并不包括解析阶段,因为解析阶段是可以在初始化阶段之后开始的,这也是为了支持晚期绑定的特性。
晚期绑定:被调用的方法无法再编译时期被确定,只能在运行时进行动态绑定的,这种绑定被称为晚期绑定。而晚期绑定也成为动态绑定,都是针对虚方法的调用。
虚方法:可以被重写的方法,因为这类方法并不能直接确定调用者是谁,至少在编译期。需要通过动态分配的方式来完成调用者的确定。
动态分配:
1. 找到当前操作数栈中的方法接收者(调用者),记下来,比如叫Caller
2. 然后在类型Caller中去找方法,如果找到简单名称和描述符都一致的方法,则停止搜索,开始对方法校验,校验通过直接调用,校验不通过,直接抛IllegalAccessError异常
3. 如果在Caller中没有找到简单名称和描述符一致的方法,则往上找父类,以此类推,直到找到为止,如果到顶了还没找到匹配的方法,则抛出AbstractMethodError异常。但是如果每次都是用动态分配的方式查找调用者,那么就会相当耗时,所以应该缓存起来,所以使用了一个虚方法表的方式存储,虚方法表会存储虚方法以及对应的调用者。
加载阶段
主要完成三件事:
-
通过类的全限定名获取该类的二进制字节流
-
将这个字节流所代表的静态存储结构化为方法区的运行时数据结构
-
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口(这其实也是反射的一种体现)
其中需要注意的是,java 虚拟机规范并没有指定二进制流的加载位置,可以是 Class 文件,也可以是网络获取,也可以是动态生成(CGLib 使用了)。
对于非数组类对象,因为获取字节流的操作是由类加载器来完成,可以是系统自带的,也可以是自定义的,这也为后面的双亲委派模型的破坏提供了实现方式。
对于数组类而言,数组类本身并不是通过类加载器创建,而是 JVM 直接在内存中动态构造,但是数组类的元素类型(String 数组对应的就是 String)还是需要通过类加载器加载。
数组类创建过程遵循规则如下:
-
若数组类的组件类型为非引用类型,如 int 数组,JVM 会直接将该数组类与引导类加载器关联。(注意:一个类必须要和他的加载器关联在一起,以保证唯一性,因为即使是由同一个 Class 文件通过不同的类加载器加载,他们创建的对象也是不完全相等的,可以用 instanceof 测试)
-
若数组类的组件类型(也即数组类去掉一个维度的类型)是引用类型,如果该类型未被加载过,则采用递归的方式将所有的组件类型标识在与对应的加载器的类名称空间上。(有点难以理解,理解不了就算了,不影响)
-
数组类的可访问性(就是访问权限,如 public)与他的组件类型是一致的。
加载结束后,对应的二进制字节流就会按照虚拟机所设定的格式存储在方法区中(jdk9 及之后的方法区存储的是元数据,元数据可以理解为一个类的最基本的信息,方便通过这些信息创建对象),并且在堆中创建 java.lang.Class 对象作为程序访问该类的类型数据的接口。
这里就存在一些问题,如果按照上面说的顺序来执行的话,验证阶段保证字节流包含信息的安全性,那么是不是说上述加载到方法区的信息没有安全保证呢?其实我这里也就是提一嘴,上面说的是开始时间,也并不是按照上述顺序完成。也就是说其实验证阶段也是部分穿插在加载阶段的,但加载阶段没有开始,验证阶段绝对不会开始。
链接
验证
这一阶段主要确保 Class 文件的字节流包含的信息符合 《java 虚拟机规范》,并且运行时不会危害虚拟机安全,这一阶段主要包括完成了四个检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证
-
文件格式验证
主要保证输入的字节流可以被正确解析并存储与方法区内部(验证的内容其实挺多的,这里偷懒,要了解的自己去百度吧)。如果验证成功字节流就会被存储到方法区中。
-
元数据验证
对类的元数据信息进行语义校验,比如说当前类是否继承了 final 类(final 类是不能被继承的);这个类是否实现了抽象类或者接口中要求实现的方法等等
-
字节码验证
确定程序语义正确。比如说类型转换是否合理,因为子类可以转换为父类,反之则不行;保证任何指令跳转都不会跳转到当前方法体以外的字节码中,防止像 c 中的不合法访问等等
-
符号引用验证
该阶段实在解析阶段发生的,该阶段用于保证解析行为能正常执行。比如说,通过全限定名是否能找到该类;而在指定类中是否存在描述符与简单名称一致的方法或字段;是否有指定类、方法等的访问权限。
不过,其实验证阶段并不是全都是必须的,虽然它非常重要,但是同样也比较耗时,所以可以通过 -Xverify:none来关闭大部分的验证部分。
准备
为类变量分配内存并设置默认值(注意是默认值,并不是自己设定的值,比如你的代码是 a = 5,但这里执行的是 a = 0).另外要注意是针对类变量进行内存分配,而不包括实例变量.
这里有一个特殊的类变量(只包括基本数据类型以及String),在准备阶段就会初始化为指定的值,这种变量就是用 final 修饰的类变量,这种类变量会在编译时期赋默认值,在准备阶段赋具体值.
解析
将常量池中的符号引用替换为直接引用.
符号引用:是一组描述引用目标的符号,可以是任何形式,只要可以无歧义的定位到该目标即可。
直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄。总之可以定位到该目标在内存中的地址。
为避免重复解析同一个符号引用,虚拟机可以将第一次的结果进行缓存。(接下来的话不针对invokedynamic 指令)如果解析成功,则后续对该引用的解析就应当一直成功;反之,若解析失败,即使后面该符号引用对应的类已经加载到虚拟机中,也仍然会受到相同的异常。
而对于 invokedynamic 指令,因为用于动态语言支持,比如说 lambda 表达式,必须等到时机运行到该指令时,解析动作才能完成
初始化
初始化就是执行 <clinit>() 方法,<clinit> 就是将类中所有的静态代码块、类变量赋值操作合并产生的,其中执行顺序和该代码在源文件出现的顺序时一致的。
public class Test {
static {
num = 6;
}
static int num = 2;
@Test
public void test() {
System.out.println(num);// 输出 2
}
}
有人或许会问,不是在执行 num = 6时,还没有执行 static int num = 2吗,为什么不是报错,没有定义该变量呢?因为类变量在准备阶段就已经分配了内存并赋默认值,在解析阶段就有了直接引用,所以当然可以直接使用了。当然还有一个问题,如下代码:
public class Test {
static {
num = 6;
System.out.println(num);// 非法前向引用
}
static int num = 2;
@Test
public void test() {
System.out.println(num);// 输出 2
}
}
这段代码会报错,因为在这个 <clinit> 方法中,在定义语句之前,只能进行赋值操作,而不能进行访问,具体为什么,我也不清楚 😐
多线程初始化问题
如果多个线程同时初始化一个类,因为 <clinit> 里的都是静态操作,那么显然多线程操作会造成线程安全问题,所以采用了独占锁的方式进行初始化,但如果 <clinit> 中有耗时操作,那么就会造成多个线程阻塞。
浙公网安备 33010602011771号