我们写的类,编译之后,是如何加载到虚拟机中的呢?虚拟机有哪些神奇的操作?本文可以带读者去探索类加载机制。下面先从类加载各个阶段的主要任务说起,给读者一个大致的体验印象。现在想不起来也没关系。现在你只需要记住三个名词,加载-->连接-->初始化,记住,我们要开始幻想漂流了!在文章的最后,我们通过几个例子来加深对程序执行顺序的理解。1、loading我觉得这里用loading比较好。首先,它可以避免在类加载过程中与“加载”混淆。二、loading体现了“装”字,就是把货物从一个地方搬到另一个地方。只是另一个地方而已,但是这里的装车包括了搬货、装卸货物等一系列过程。在加载阶段,将.class字节码文件的二进制数据读入内存,然后将这些数据翻译成类的元数据。元数据包括方法代码、变量名、方法名、访问权限和返回值,然后元数据数据存储在方法区。最后会在堆中创建一个Class对象来封装类在方法区的数据结构,这样我们就可以通过访问这个Class对象来间接访问方法区的元数据。Java7和Java8之后,方法区有了不同的实现。这部分的细节可以参考我的另一篇博文《灵问——为什么要用元空间代替永久代?总结一下,加载的子流程是:.class文件被读入内存-->元数据被放入方法区-->Class对象被放入堆中。最后,我们可以访问Class对象,获取类在方法区的结构。2.ConnectionConnection还包括校验、准备、初始化2.1校验加载类的正确性和安全性,检查class文件是否正确,是否会对虚拟机造成安全问题等,主要是校验文件格式、元数据、字节码和合规性参考。2.1.1验证文件格式2.1.1.1验证文件类型每个文件都有特定的类型。类型标识字段存在于文件的开头,以十六进制表示。类型标识字段称为幻数,类文件的幻数为0xCAFEBABY。这个神奇数字的来历也很有趣。您可以阅读这篇关于类文件中幻数CAFEBABE起源的文章。2.1.1.2验证主次版本号检查主次版本号是否在当前jvm处理的范围内。主要和次要版本号的存储位置遵循幻数。2.1.1.3验证常量池常量池是类文件中最复杂的部分。常量池的校验主要是校验常量池中是否存在不支持的类型。例如有如下简答代码:publicclassMain{publicstaticvoidmain(String[]args){inta=1;intb=2;intc=a+b;}}在这个类的路径下,使用javacMain.java进行编译,然后使用javap-vMain输出如下信息:上面红色标出的地方就是class文件中存放常量池的地方。2.1.2校验元数据主要是对字节码描述的信息进行语义分析,确保其描述的信息符合Java语言规范的要求,比如校验类是否有父类,字段方法是否在该类与父类冲突等。2.1.3验证字节码这是整个验证过程中最复杂的阶段,主要通过数据流和控制流分析来判断程序语义是否合法和合乎逻辑。2.1.4符号引用的验证这是验证的最后阶段,发生在虚拟机将符号引用转换为直接引用时。主要是验证类本身以外的信息。目的是保证解析动作能够完成。对于整个类加载机制来说,验证阶段是一个非常重要但不是必须的阶段。如果我们的代码能够保证没有问题,那么就不需要验证了。毕竟验证是需要一定时间的,可以使用-Xverfity:none来关闭大部分验证。2.2准备这个阶段主要是为类变量(静态变量)分配内存和初始化默认值,因为静态变量全局只有一份,跟在类后面,所以内存分配实际上是在类中分配的方法区。这里需要注意三点:(1)在准备阶段,虚拟机只为静态变量分配内存,实例变量直到初始化阶段才开始分配内存。这个时候类还没有被实例化,没有对象,所以这个时候没有实例变量。(2)初始化静态变量的默认值。请注意,它初始化相应数据类型的默认值,而不是自定义值。比如代码是这样写的,自定义int类型的变量a的值为1privatestaticinta=1;但是,准备阶段完成后,a的值只会初始化为0,不会初始化为1。(3)final修饰的静态变量,如果值比较小,编译后会直接嵌入到字节码中。如果值比较大,编译后也直接放入常量池。因此,经过准备阶段,final类型的静态变量已经有了用户定义的值,而不是默认值。2.3分析分析阶段主要是将class文件中常量池中的符号引用转化为直接引用。符号引用的含义:可以直接理解为一个字符串,这个字符串用来表示一个目标。就像博主的名字是SunAlwaysOnline,SunAlwaysOnline这个字符串是一个符号引用,代表博主,但是现在不能直接通过名字找到我了。直接引用的含义:直接引用是指向目标的指针,可以通过直接引用定位到目标。例如,Students=newStudent();我们可以通过引用变量s直接定位到新创建的Student对象实例。将符号引用转换为直接引用会将平淡无奇的字符串转换为指向对象的指针。3.初始化执行初始化就是虚拟机执行类构造函数的()方法的过程。()方法是编译器自动收集类中的所有类变量并合并静态语句块生成的。可能有多个线程同时执行某个类的()方法,此时虚拟机会对方法加锁,保证只有一个线程可以执行。在这个阶段,类变量和类成员变量将被赋予用户定义的值。当然,一个类不会被多次初始化,只有第一次主动使用该类才会导致该类被初始化。主动使用包括以下方法:使用new语句创建一个类的对象来访问类的静态变量,或者给静态变量赋值并调用类的静态方法通过反射获取对象实例有一个publicstaticvoidmain(String[]args)methodClasseswillbeinitializedfirst当一个类被初始化时,如果父类还没有初始化,则先初始化父类,然后再初始化类。被动使用呢?当访问静态变量时,只有实际声明静态变量的类被初始化。例如:通过子类引用父类的静态变量不会导致子类被初始化。引用常量不会触发该类的初始化(常量嵌入字节码或在编译阶段存储在调用类的常量池中)。在声明和创建数组时,不会触发类的初始化。例如,Studentarray=newStudent[2];4、类的初始化序列现在有如下代码:classFather{publicstaticintfatherA=1;publicstaticfinalintfatherB=2;static{System.out.println("父类的静态代码块");}{System.out.println("父类的非静态代码块");}Father(){System.out.println("父类的构造函数");}}classSonextendsFather{publicstaticintsonA=3;publicstaticfinalintsonB=4;static{System.out.println("子类静态代码块");}{System.out.println("子类非静态代码块");}Son(){System.out.println("子类构造方法");}}(1)在Main方法中实例化子类:publicclassMain{publicstaticvoidmain(String[]args){Sonson=newSon();}}首先可以确定这是一个主动使用,父类先于子类初始化,所以会得到如下输出:这里可以总结一下,程序执行的顺序是:父类的静态域->子类的静态域->非父类的静态域->子类父类的非静态域->子类的构造方法此处的静态域包括静态变量和静态代码块s,静态变量和静态代码块的执行顺序由编码顺序决定。规则是静态在非静态之前,父类在子类之前,构造函数在最后。嗯,背三遍(2)在Mian方法中,输出子类的sonA属性publicclassMain{publicstaticvoidmain(String[]args){System.out.println(Son.sonA);}}这里只需要输出子类的static属性sonA,所以需要初始化子类,但是父类还没有初始化,所以先初始化父类。一般来说,一个静态代码块会为一个静态变量赋值,所以调用static属性,虚拟机在此之前调用静态代码块。因此输出如下:(3)Main方法输出继承自子类的fatherA属性publicclassMain{publicstaticvoidmain(String[]args){System.out.println(Son.fatherA);}}子类继承自父类属性,所以这是一种被动使用。只会对真正存在静态属性的类进行初始化,即只会对父类进行初始化。因此,输出:(4)在Main方法中声明并创建一个子类类型数组publicclassMain{publicstaticvoidmain(String[]args){Son[]sons=newSon[10];}}很明显,这是一种被动使用和不会初始化Son类。因此,没有输出。(5)Main方法输出子类被staticfinal修饰的变量publicclassMain{publicstaticvoidmain(String[]args){System.out.println(Son.sonB);}}很明显,staticfinal修饰的变量也是一个constant,编译器将其放入类的常量池中,不需要对类进行初始化。所以这里只输出sonB的值,也就是4。(6)声明前用一个静态变量publicclassMain{static{c=1;}publicstaticintc;},代码可以运行了,小子,你有没有大问题?但是让我仔细分析一下。首先在准备阶段,为静态变量c分配内存,然后赋初值0。等到初始化阶段,执行类的静态域,即静态代码块中执行c=1这里,此时c已经存在,也有默认值0,此时可以修改c的值。但是,如果我此时只在c=1之后使用c,比如:publicclassMain{static{c=1;System.out.println(c);}publicstaticintc;}这时候编译就无法通过,编辑器提示Illegal前向引用,也就是非法的前向引用,好像c只能写,不能读。我们之前已经分析过了。这时候内存里有这个c,为什么读不到c呢?一般情况下,如果要使用一个变量,需要先声明该变量。当然,java做了一种允许在使用前不声明的许可,但是必须满足几个条件,其中一个就是变量只能出现在赋值表达式的左边,即c=1可以,c=2可以,但是c+=1不行(c+=1就是c=c+1,违反了左值约定)。当然,如果这里使用完全限定名,即输出Main.c时,是可以正常运行的。有的朋友可能还有很大的疑惑,没关系,如果不明白,可以参考下面这篇解释illegalforwardreferencesjavaerrorIllegalforwardreferenceproblemJava编译提示illegalforwardreferenceIllegalforwardreferencejavaissue关于loading使用的类加载器,双亲委派机制,以及如何自定义类加载器可能需要另外一章。
