代码编译的结果从本地机器码变为字节码,是储存格式发展的一小步,却是编程语言发展的一大步——《深入理解 Java 虚拟机》
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行 校验、转化解析和初始化,最终形成了可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
类型的加载、连接和初始化都是在程序 运行 期间完成的,虽说加大了运行时期的开销,但是大大增加了 Java 的灵活度,方便动态加载和连接。Java 不仅可以从 Class 文件获取属于,也可以从其他地方例如网络中直接获取二进制流数据,这极大提高了 Java 的延展性。
时机
类的生命周期
类从开始加载到卸载一共经过了七个过程,如下图。
其中验证、准备、解析统称为连接。另外,加载、验证、准备、初始化和卸载这 5 个过程只是开始要按照顺序,可以同时执行,不用等待上一个过程结束之后才执行。例如,我在 9 点开始准备,9 点 10 分开始初始化,9 点 20 准备结束。
初始化时机
有且只有下面五种情况,才可以称为“初始化”:
- 遇到 new、getstatic、putstatic、invokestatic 这 4 个字节码指令的时候,发现类没有进行初始化,才进行初始化。其中关于 new 的理解,除了生成普通的类实例,当调用类的静态方法的时候也会触发初始化。
- 使用 java.lang.reflect 包内的方法对类进行反射调用的时候。
- 当一个要初始化的类发现父类还没有初始化的时候,首先需要初始化父类。
- 当虚拟机启动的时候初始化要执行的主类(main()方法所在的类)。
- 使用 JDK1.7+ 版本的动态语言支持时,发现类没有初始化需要初始化之。
除此之外,所有引用类的方式都不会触发初始化,仅被称为被动引用。
开个小差,在一个类的静态代码块中,如果某变量提前被被赋值,就可以被使用;如果某变量之后才赋值的,在静态代码块中使用就会报错。但是无论何时赋值,只要声明了,在静态代码块中再赋值是被允许的。看下这个例子:
public class Test{
static{
i=0;// 给变量赋值可以正常编译通过
System.out.print(i);// 这句编译器会提示 "非法向前引用"
}
static int i=1;}
对于接口来说,有且仅有前三种情况才会被称为初始化。另外,对于接口,不需要满足提前让父接口初始化,除非你有用到父接口的时候。
过程
逐步看下加载、验证、准备、解析和初始化这 5 个过程。
加载
加载过程需要完成以下三个事情:
- 通过类的全限定名来获取此类的二进制字节流。
- 将这个字节流所代表的静态储存结构转化为方法区的运行时数据结构。
- 再内存种生成一个代表这个类的 java.lang.Class 对象,这种对象有别于其他普通对象,是在方法区的。
对于非数组的类,加载可以通过虚拟提供的 类加载器,也可以通过一用户自定义的加载器。对于数组类,数组本身不是通过加载器加载的,而是通过 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 加载器创造出来的类和原生的是同一款了,因为人家就没有被你自己写的类加载器所加载,而是某父层的加载器加载了。