在Java语言里,类的加载、连接和初始化都是在程序运行期间完成的。这种方式虽然在性能上会一定的开销,但是它会是Java的应用程序具有更高的灵活性。
比如:
- 我们在写一个类中用了一个接口的方法,现在这个类编译后的class中是不知道我们具体是哪个类实现的,只有等到了运行程序的时候才指定了具体的实现类。
- 另外我们可以通过先定义类加载器,然后我们可以随时从任何地方加载一个二进制流来动态的加载一个类。
- 可以实现动态的替换jsp,还有OSGI的热插拔技术
这些都是java提供给我们的可以用类加载机制来实现的功能
Java中类的生命周期
加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)
一个类的存在的顺序大致会按照这个顺序进行,但是也会存在特殊的情况,在初始化的时候去解析
Java中什么情况下需要对类进行初始化(阶段),再此之前其他的操作:加载、验证等已经完成
- 在使用new创建个对象时,或者使用这个类的静态方法或者静态变量(有一种情况除外,在静态变量被final修饰的时候不会初始化对象,因为这种对象在编译期间已经把变量放在了常量池中了。)
- 在使用java.lang.reflect包中的方法,也就是在使用反射的时候会触发初始化操作
- 初始化一个类的时候如果还没有初始化父类的时候会初始化父类
- 启动的时候会初始化执行类的主类(Main方法)
- JDK1.7加上了动态语言支持,就是在遇到REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄时这个类如果没有初始化才会触发其初始化。(就是类似于Js、Python动态语言,不需要事先确定这个参数的类型,当运行到的时候在去,如果发现没有初始化的话再去进行初始化这个类)
加载阶段
加载阶段虚拟机主要做了这三件事情:
- 通过一个类的全限定名(包名+类名)来获取定义此类的二进制流(得到Class二进制流)
- 把这个字节流锁代表的经他爱存储结构转化为方法区的运行时数据结构(把流转化成方法区里能够使用的数据结构)
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口(生成Class对象,注:Class对象比较特殊,在HotSpot虚拟机中它是在方法区内不是在堆中)
这三个阶段中可控性最强的就是第一个得到二进制流的阶段,我们可以通过各种方式来得到,比如从本地磁盘,从数据库,从网络上...等到流之后我们可以重写一个类的加载器的loadClass()方法,或者是使用JDK提供的引导类加载器来完成。
对于数组和其他引用类型来说有些区别,数组本身是不通过类加载器创建的,它是由Java虚拟机自己直接创建的。但是数组类与类加载器还是有很多关系的,具体如下:
- 如果数组的组件类型是引用类型(就是数组去掉第一个维度的类型),那么就是使用对应的加载器加载组件类型,然后数组将会被组件类型的类加载器上被标识。
- 如果数组的组件类型不是引用类型(比如int等基本数据类型),java虚拟机将会吧数组C标记与引导类加载器关联
- 数组类的可见性与它的组件类型的可见性是一致的,如果组件类型不是引用类型,那么这个数组类的可见性就是public(就是数组这个类的可见性和它里面的类的可见性是一致的)
验证阶段
验证阶段是为了确保Class文件的字节流中包含的信息是符合房钱虚拟机要求的。
虽然Java本身是相对安全的,因为它有编译成Class这一步。编译器是有一定规则的,如果你写的不符合要求会拒绝编译。但我们知道Class文件并不是只靠Java源码编译过来的,它可以是通过任何途径获得(如果你自己用十六进制编辑器写了一个十六进制文件,只要符合要求也是可以运行的)。如果虚拟机没有检查输入的字节流那么可能会导致很严重的后果。(可能会有恶意的代码,或者不是恶意的但会造成程序的崩溃)
大致上验证阶段可以分为以下4个阶段:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
准备阶段
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些都会在方法区内分配。(这里的分配变量知识分配类变量,就是用static修饰的变量,不包括手里边了,实例变量将会在对象实例化的时候进行赋值。)
下图是基本数据类型的初始值,引用类型的初始值是null
注意:上面说的是通常的情况,有一种情况是比较特殊的。就是在用final修饰了之后会在初始化阶段直接初始化上ConstantValue的值。例如:
public static final int value =123;
这时候在编译阶段会吧value的值附上123了,准备阶段就自然会给value赋值成123
解析阶段
在解析阶段是把常量池中的符号引用转化成直接引用,符号引用是在编译成Class文件的时候生成的。
直接引用和符号引用之间的定义:
- 符号引用:符号引用用一组符号聊描述所引用的目标,符号只需要没有重复的能定位到目标即可。这个目标是不一定会加载到内存中的,也就是说这时候目标还不存在。
- 直接引用:直接引用是指向目标的指针、偏移量或者是句柄。直接引用和虚拟机内存布局有关系。也就是说如果有了直接引用就说明这个目标已经在内存存在了。
初始化阶段
初始化阶段必须是在前面的步骤都结束后才执行的最后一步。初始化阶段会真正的执行类中定义的java程序代码。
在初始化阶段是执行类构造器方法的过程(就是执行<clinit>类型的方法)。
- <clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中语句合并产生的结果(static{})。这个执行顺序是由语句在源文件出现的顺序所决定的。也就是说静态语句块只能访问到定义在静态语句块之前的变量,但是定义在后边的变量可以在前面的静态语句块中被复制。但是不能访问
public class Test{
static{
i =0;
System.out.print(i); //这块会有编译错误非法向前引用
}
static int i = 1;
}
<clinit>方法与类的构造函数不同,它不需要显示的调用父类的构造器,因为虚拟机会保证在子类执行之前父类的<clinit>方法一定会执行成功。因此我们可以知道在虚拟机中第一个被执行的<clinit>方法一定是java.lang.Object
接口中是不能使用静态语句块的,但是可以有静态变量赋值的操作。因此也会生成<clinit>方法。但接口月累不同的是执行接口不需要先执行父接口的方法,只有当付借款使用时才会初始化。
虚拟机会保证一个类的<clinit>方法会被正确的加锁,也就是在多个线程初始化一个类时只会有一个线程去执行这个类的方法。注意这种可能会造成阻塞(其他线程不会在重新执行一次,一个类只会执行一次)