JVM之虚拟机类加载机制
有兴趣可以先参考前面的几篇JVM总结:
我们知道,在编写一个Java程序后,需要由虚拟机将描述类的数据从Class文件(这里面的Class文件不是指某个特定存在于磁盘上面的文件,而是一串二进制字节流)加载到内存,并对数据进行校验、转换解析和初始化,最终形成可被虚拟机使用的Java类型,这就是虚拟机的类加载机制。与编译时需要进行连接工作的语言不同,Java中类型的加载、连接和初始化过程都是在程序运行期间完成的,虽然会有一些性能的开销但是也为其提供了比较高的灵活性,Java中可以动态扩展和依赖运行期动态加载和动态连接就是依赖于这种特点的。
一、类加载概述
1、类加载过程概述
类从被夹在到虚拟机内存中开始,到被卸载出内存的整个生命周期大概包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。这其中的加载、验证、准备、初始化和卸载5个阶段的顺序是一定的,类的加载过程必须要按照这几个顺序来开始(这些不同的阶段可能是混合的交叉执行,可能再一个阶段执行的时候激活另一个阶段)。
而解析阶段不同的特点就是:为了支持Java的运行时绑定(动态绑定或者晚期绑定),在某些情况下可以在初始化之后再执行。
2、初始化阶段的5中情况(必须对类进行初始化)
a)遇到new、getstatic、putstatic、invokstatic这四条字节码指令的时候,如果类没有进行初始化,那么必须要对类触发其初始化。典型场景
①使用new实例化对象的时候;
②读取或者设置一个类的静态字段(除开被final修饰、编译器将结果放入常量池的静态字段);
③调用一个类的静态方法的时候;
b)使用反射包的时候(java.lang.reflect),使用其中的方法进行反射调用时必须对类触发其初始化(如果类没有被初始化过)
c)当初始化一个类的时候,其父类如果还没有被初始化过,那么必须先触发器父类的初始化
d)当用户在虚拟机启动时候指定需要执行的主类(包含main()方法的那个类),会首先初始化这个类
e)使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例的最后解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则要先触发其初始化。
3、不会执行初始化的几种情况
①通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化(后面有示例程序)。
②定义对象数组,不会触发该类的初始化。
③常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
④通过类名获取Class对象,不会触发类的初始化。
⑤通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
⑥通过ClassLoader默认的loadClass方法,也不会触发初始化动作。
4、主动引用和被动引用
a)主动引用:上面的5中情况中的场景会触发类进行初始化,这些行为被称为对一个类进行主动引用
b)被动引用:所引用类的方式不会被初始化。下面介绍集中被动引用的例子以及测试代码
①通过子类引用父类的静态字段,只会初始化父类,子类不会被初始化。在下面的例子中,子类继承自父类,但是输出的时候只会输出“父类被初始化”,而没有“子类被初始化”。
1 package cn.jvm.classLoad; 2 3 class SuperClass { 4 static { 5 System.out.println("父类被初始化"); 6 } 7 8 public static int test = 666; 9 } 10 11 class SubClass extends SuperClass { 12 static { 13 System.out.println("子类被初始化"); 14 } 15 } 16 17 public class TestClass1 { 18 public static void main(String[] args) { 19 System.out.println(SubClass.test); 20 } 21 }
总结来说就是:对于静态字段,只有直接定义这个字段的类才会被初始化,索引通过子类引用父类中定义的静态字段只会触发父类的初始化而不会触发子类的初始化。
②通过数组定义来引用类,不会触发该类的初始化。下面的测试程序也很明显,运行之后不会输出“父类被初始化”
1 package cn.jvm.classLoad; 2 class SuperClass1 { 3 static { 4 System.out.println("父类被初始化"); 5 } 6 7 public static int test = 666; 8 } 9 public class TestClass2 { 10 11 public static void main(String[] args) { 12 SuperClass[] sc = new SuperClass[6]; 13 } 14 }
当初始化对象数组时,并不会实际触发对象的初始化操作。但是会触发一个是由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。值得注意的是:该类代表了实际的对象数组,数组中应有的方法和属性都实现在这个类里。
③常量在编译阶段就会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量类的初始化。如同下面的测试程序,只会输出定义的常量字符串,而不会输出“定义常量的Test类被初始化”。
虽然在TestClass3类中引用了Test类的常量test,但是在编译阶段经过常量传播优化,将常量的值存到了TestClass3类的常量池中,以后再使用test的引用实际上都是转换为TestClass3类对自身常量池的引用
1 package cn.jvm.classLoad; 2 3 class Test { 4 static { 5 System.out.println("定义常量的Test类被初始化"); 6 } 7 public static final String test = "TestClass"; 8 } 9 10 public class TestClass3 { 11 12 public static void main(String[] args) { 13 System.out.println(Test.test); 14 } 15 }
⑥当一个常量的值并非在编译期间可以确定的,那么其值就不会被放到调用类的常量池中。这个时候在程序运行时,会导致主动使用这个常量所在的类,即会触发这个类的初始化。
5、关于接口的初始化
a)接口也有自己的初始化过程:接口中没有static静态代码块来输出初始化信息,编译器会为接口生成“<clinit>()”类构造器,用于初始化接口中所定义的成员变量。
b)接口和类初始化的区别:当一个类在初始化时,其父类都基本上初始化过了,然而接口在初始化的时候,只有真正用到父接口的时候(如引用接口中定义的常量)才会进行初始化。
二、类加载全过程
1、加载
a)在加载阶段虚拟机通过一个类的限定名来获取定义此类(某个Class文件)的二进制字节流,然后将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,之后在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
①对于非数组类的加载阶段中获取类的二进制字节流的动作,是可控性最强的。因为在加载阶段中既可以使用系统提供的引导类加载器完成类加载,也可以由用户自定义的类加载器完成,开发人云通过定义自己的类加载器去控制字节流的获取方式(重写一个累加器的LoadClass方法,后面会有例子程序作为示例)
②对于数组类而言,由于数组类本身不通过类加载器创建,而是有JVM直接创建的,但是数组类与类加载器仍然有比较密切的关系(数组类的元素类型最终是需要通过类加载器去进行加载的)
2、连接阶段(分为验证,准备和解析三个阶段)
a)验证
验证是连接阶段的第一步,这一步的目的是为了确保Class文件的字节流包含的信息对于虚拟机是安全的。验证阶段大概会包括四个小的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
①文件格式验证:这一阶段的验证主要指验证字节流是否符合Class文件的规范,并且能够被当前版本的虚拟机处理(魔数0XCAFEBABE开头、主次版本号是否在虚拟机处理范围之内、常量池中常量是否有不被支持的常量类型等等));
②元数据验证:这个阶段是对字节码的信息进行语义描述,保证描述的信息符合Java语言规范(除了java.lang.Object类之外的类是否有父类、这个类是否继承了不应被继承的类、如果这个类不是抽象类是否实现类其父类或者接口中的所有方法);
③字节码验证:通过数据流和控制流进行分析,确定程序语义是合法的、符合逻辑的,在元数据验证完毕之后这个阶段对类的方法进行验证,保证类的方法运行时是对虚拟机安全的;
④符号引用验证:该阶段发生在虚拟机将符号引用转换为直接引用的时候,这个转换的动作会发生在连接的第三个阶段(解析),符号引用验证可以看做是对类自身之外的信息进行匹配性校验(符号引用中字符串描述的全限定名是否能够找到对应的类;在指定类中是否存在方法的字段描述符以及简单名称所描述的方法和字段;符号引用中的类、字段、方法的访问权限(private、protected、public、default)是否可以被当前类访问)
b)准备
准备阶段是为类变量分配内存并设置类的初始值(通常情况下指的是数据类型的默认零值,如果被是常量值比如public static final int test = 123,这就会在准备阶段将变量test初始化为123)的阶段,这些变量所使用的内存都会在方法区中进行分配。
注意:
①这里进行内存分配的变量只包括类变量(static变量),而不包括实例化变量,实例变量将会在对象实例化的时候随着对象一起分配在堆中;
②初始值(通常情况下指的是数据类型的默认零值,如果被是常量值比如public static final int test = 123,这就会在准备阶段将变量test初始化为123)
c)解析
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程,符号引用在Class文件中以CONSTANT_Class_info、CONSTANT_Field_info等类型的变量出现。
①符号引用:是以一组符号来描述所引用的目标,可以使任何形式的字面量(使用时候没有歧义的量)
②直接引用:可以直接指向目标的指针、相对偏移量或者是一个能够间接定位到目标的句柄
3、初始化阶段
初始化阶段是类加载过程的最后一个阶段,在前面基本上都是类加载器参与执行(包括自定义的类加载器),在初始化阶段才是执行定义的Java程序(字节码)。前面在准备阶段变量被赋以所属数据类型的默认值,在初始化阶段是通过程序制定的值去进行变量的初始化。
初始化阶段是执行类的<clinit()>方法的过程:
①<clinit()>方法是有编译器自动收集的所有类变量的赋值动作和static块中的语句产生的(顺序就是在源文件中定义的顺序出现的,所以在静态语句块之中,只能定义在其后定义的static变量而不能访问)
②<clinit()>方法和类的构造器(这里指的是实例化的构造器<init>())不同,它不需要显示的调用分类的构造器,因为虚拟机会保证在子类的<clinit()>方法执行之前一定会将父类的<clinit()>方法执行完毕(侧面说明虚拟机中第一个被执行的<clinit()>肯定是java.lang.Object的)
③由第②条可以得出的是,由于父类的<clinit()>方法先执行,所以在父类中定义的static语句块要先于子类的变量赋值操作。下面的测试代码中输出的Son类的testSon值应该是2,而不是1。
1 package cn.jvm.classLoad; 2 3 class parent { 4 public static int testPar = 1; 5 static { 6 testPar = 2; 7 } 8 } 9 10 class Son extends parent { 11 public static int testSon = testPar; 12 } 13 14 public class TestClass5 { 15 public static void main(String[] args) { 16 System.out.println(Son.testSon); 17 } 18 }
④如果一个类中没有静态语句块,也没有变量赋值的操作,那么编译器可以不用为这个类生成<client()>方法;
⑤接口中虽然没有静态语句块,但是可以存在变量赋值的操作,所以接口中也会生成 <client()>方法。但是接口中的<client()>方法不需要先执行父接口中的<client()>方法方法,只需要在父接口中变量被使用的时候才会初始化,同理接口的实现类在初始化的时候也不需要先执行接口中的<client()>方法;
⑥虚拟机会保证一个类在多线程环境中的<client()>方法被正确加锁、同步。如果多个线程去同时初始化一个类,那么只有一个线程会执行<client()>方法,其他的线程会阻塞等待知道执行完毕。
三、再看堆、栈、方法区
可以参考前面的JVM自动内存管理机制-Java内存区域(上)中讲到的这三个区域的详细概念和联系,这里我们通过一个简单的程序并结合类加载的过程来看一下这三者的关系。首先先简单描述一下方法区和堆区,方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;堆区:存放对象实例,几乎所有的对象实例都在这里分配内存。
1 package cn.jvm.classLoad; 2 class TestDemo{ 3 public static int test=100; //静态变量,静态域 4 static{ //静态代码块 5 System.out.println("静态初始化类A"); 6 test = 300 ; 7 } 8 public TestDemo() { 9 System.out.println("创建A类对象的实例化构造方法"); 10 } 11 } 12 public class TestClass4 { 13 public static void main(String[] args) { 14 TestDemo testDemo = new TestDemo(); 15 System.out.println(testDemo.test); 16 } 17 }
我们先看下上面的代码,代码中TestDemo类中定义了静态区域(包括代码块和静态变量),然后在main中实例化TestDemo类的对象并访问他的静态变量,这里我们先再看一下这张图,结合这个图(简单描述堆、栈、方法区的关系)通过类加载的过程来具体的分析一下堆、栈、方法区在类加载过程以及完毕之后里面存放的信息。
①JVM加载TestClass的时候,首先在方法区中形成TestClass类对应静态数据(类变量、类方法、代码…),同时在堆里面也会形成java.lang.Class对象(反射对象),代表TestClass类,通过对象可以访问到类二进制结构(方法区)。然后加载TestDemo类信息,同时也会在堆里面形成TestDemo对象,代表TestDemo类。
②main方法执行时会在栈里面形成main方法栈帧,一个方法对应一个栈帧。如果main方法调用了别的方法,会在栈里面挨个往里压,main方法里面有个局部变量A类型的a,一开始testDemo值为null,通过new调用类A的构造器,栈里面生成TestDemo()方法同时堆里面生成testDemo对象,然后把TestDemo类的对象地址赋给栈中的testDemo,此时testDemo拥有类TestDemo的对象的地址。
③当调用testDemo.test时,调用方法区数据。
反正总结下来就是:类加载最终在堆区中生成的Class 对象、堆中的Class对象封装了类在方法区内的数据结构,并且提供了访问方法区内的数据结构的接口。