代码编译的结果从本地机器码变为字节码,是储存格式发展的一小步,却是编程语言发展的一大步——《深入理解Java虚拟机》
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行 校验、转化解析和初始化 ,最终形成了可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类型的加载、连接和初始化都是在程序 运行 期间完成的,虽说加大了运行时期的开销,但是大大增加了Java的灵活度,方便动态加载和连接。Java不仅可以从Class文件获取属于,也可以从其他地方例如网络中直接获取二进制流数据,这极大提高了Java的延展性。
类从开始加载到卸载一共经过了七个过程,如下图。
其中验证、准备、解析统称为连接。另外,加载、验证、准备、初始化和卸载这5个过程只是开始要按照顺序,可以同时执行,不用等待上一个过程结束之后才执行。例如,我在9点开始准备,9点10分开始初始化,9点20准备结束。
有且只有下面五种情况,才可以称为“初始化”:
除此之外,所有引用类的方式都不会触发初始化,仅被称为被动引用。
开个小差,在一个类的静态代码块中,如果某变量提前被被赋值,就可以被使用;如果某变量之后才赋值的,在静态代码块中使用就会报错。但是无论何时赋值,只要声明了,在静态代码块中再赋值是被允许的。看下这个例子:
public class Test{ static{ i=0;//给变量赋值可以正常编译通过 System.out.print(i);//这句编译器会提示"非法向前引用" } static int i=1; }
对于接口来说,有且仅有前三种情况才会被称为初始化。另外,对于接口,不需要满足提前让父接口初始化,除非你有用到父接口的时候。
逐步看下加载、验证、准备、解析和初始化这5个过程。
加载过程需要完成以下三个事情:
对于非数组的类,加载可以通过虚拟提供的 类加载器 ,也可以通过一用户自定义的加载器。对于数组类,数组本身不是通过加载器加载的,而是通过Java虚拟机直接创建的,数组中的元素是通过加载器创建的。
加载过程结束后,内存中就会得到一个该类的java.lang.Class对象,为后续铺垫。
在加载开始的同时,验证择机开启。验证是为了确保Class文件的字节流种包含的信息符合 上章 讲的规格,不会危害虚拟机本身。这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度讲,验证阶段的工作量在虚拟机类加载子系统中又占了相当大的一部分。
首先需要验证是否符合Class文件格式的规范,比如魔数(咖啡宝贝)是否存在,主次版本号是否可以被当前虚拟机运行、常量类型的tag标志等等。这个阶段的验证时基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行储存,后面三个验证阶段全是基于方法区的储存结构进行的,不再直接进行字节流操作。
此过程包含验证是否有父类、父类是否允许被继承啊,各种修饰符是否冲突啊等等。
主要目的时通过数据流和控制流分析确定程序语义是合法的、符合逻辑的。此过程保证任意时刻的操作数栈的数据类型与指令代码序列都能配合工作,保证跳转指令不会跳转到方法体以外的字节码指令上,保证类型转化是正常的,保证父类和子类之间的字段不冲突等等。
由于数据流验证非常复杂,为了减缓消耗的时间,自JDK1.6开始,方法体的Code属性的属性表中增加了一项为“StackMapTable”的属性,这项属性描述了方法体中所有的基本块。在字节码验证期间,就不需要根据程序推到这些状态的合法性,只需要检验StackMapTable属性中的记录是否合法即可。大大节省了字节码验证的时间。
此阶段发生在虚拟机将符号引用转化成直接引用的时候,这个转化动作将在连接的第三个阶段解析的时候发生。需要验证是否可以通过字符串的全限定名找到这个类,指定的类中是否符合方法的字段描述符以及简单名称所描述的方法和字段,类、方法、字段的访问性等等。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。此时给静态变量设置初始值是零值,并不是代码中设置的具体值,具体值还需要在putstatic指令执行时才会初始代码中设置的值。除非此static变量被final修饰了们就会在此时直接设置代码中的值。
解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程。
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存分布无关,引用的目标并不一定已经加载到内存中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
除了invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存。invokedynamic指令是可动态语言支持相关的指令,所以无法做到缓存。
类初始化时类加载过程的最后一步。前面的操作除了自定义的类加载器之外,都是虚拟机主导的操作,初始化阶段,开始整整执行类中定义的Java代码了。
初始化阶段时执行类构造器<client>()方法的过程。<client>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。<client>()方法不需要显示的构造父类的构造函数,已经自己构造好了,并且父类的静态代码块是先于子类的静态代码块的。并且<client>()方法执行时 带锁 的,不同线程执行这个方法可能会出现线程阻塞的现象。
虚拟机设计团队把类加载阶段中“通过一个类的全限定名来获取此类的二进制字节流”这个动作放到Java虚拟机外部去实现了,实现这个动作的代码块叫做 类加载器 。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性。 如果说某个类相等,那么这两个类一定是在同一个类加载器下加载完成的。 这里的相等可以使用Class的equals方法、isAssignableFrom()方法、isInstance()方法验证,也可以使用instanceof关键字做对象所属关系的判断。例如全限定名都是com.pjjlt.MyTest。一个用虚拟机自己的类加载器加载,一个用用户自定义的类加载器加载,那么这两个类就不相等,分别产生的对象实例用instanceof关键字只能作用域自己的类上才会是true。
那么问题来了,我要用自定义的类加载器加载一个Object放到内存中,那岂不是整个Java的基础功能全废了。其实不然,新建的Object类也会和原生的那个Object类是被一样对待的。这就涉及了双亲委派机制。
对于虚拟机的角度来说,只有虚拟机的类加载器和用户自定义的类记载器。对于用户来说有启动类加载器(Bootstrap ClassLoader)、拓展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)这么几种,而且他们是一种组合关系来复用父加载器。
双亲委派机制工作原理:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有它反馈自己无法加载的时候,才会交给子加载器加载。
这也解释了为什么你写的Object加载器创造出来的类和原生的是同一款了,因为人家就没有被你自己写的类加载器所加载,而是某父层的加载器加载了。