“人生苦短,不如养狗 作者:Brucebat.Sun ”
在上一篇探秘Java:一个对象的生成(上)中笔者较为详细地介绍了对象生成相关的基本知识,在学习这些基础知识的过程中可以发现有一个关键性角色贯穿了一个对象生成的整个生命周期——类型信息。可以说没有了类型信息,Java中的对象就成了无源之水、无本之木。为什么类型信息对于Java对象来说这么重要呢?下面我们就来具体了解一下。
“以下内容均基于jdk 1.8版本及以下,jdk 1.9版本以后部分内容做了较多调整。 ”
无论是入门级教材还是进阶版教材,开篇对于Java的介绍必定是:“Java是一门面向对象编程语言”,而在Java经典巨著《Thinking in Java》中对于Java编程世界又作出这样描述:“一切皆对象”。这些相似的话术无不体现着Java语言最本质的设计理念,即数据的存储和操作均是通过对象来实现。但是单一具体的对象描述并不能很好的归纳总结一类结构相同、行为一致但部分属性不同的对象,所以针对这一类对象做出了抽象归纳,得到了对象模板——类型。通过类型,Java可以很好地描述了一类对象的数据结构、行为逻辑,并且能够很方便地基于这一模板信息创建出从属于这一类型的对象实例。为了让大家能够更好地脑补这一过程,这里我们可以将这一过程与使用模具做月饼的流程类比(不是非常恰当,但应该很有画面感,哈哈哈)。但是需要注意的是这里所说的类型还只是停留在应用程序层面(即开发人员自定义的程序源码,也就是我们日常编写的.java文件),而对于JVM来说,创建对象需要的是虚拟机层面的类型信息(也就是在上一篇文章中介绍的对象创建流程中使用到的类型信息)。
那么JVM是如何获取到类型信息的呢?下面我们会按照图中所示的内容逐一学习分析:
在上面的内容中我们谈到了,JVM无法直接使用开发人员自定义的.java文件中的内容来获取对象创建所需的类型信息,而必须从JVM能够读取识别的数据存储中来获取,这种数据存储就是我们经常听到的类文件(Class文件)。作为一种特定的二进制文件存储格式,类文件中存储的内容实际上是与语言无关的字节码,虽然这些字节码的存储格式会受到JVM在语法和结构化上的约束,但是任何一门语言都可以经过编译生成可以被JVM解释执行的类文件。例如,.java文件经过Java编译器可以编译成存储字节码的类文件。这也是Java语言除了平台无关性以外的另一个重要特性——语言无关性,即不同的语言只要实现特定的编译器就能将该语言的源码编译成JVM能够解释执行的类文件。
“注意,类文件是一种二进制文件存储形式,和类型信息并没有本质上的联系。Java只是通过类文件来存储和唯一对应一个类和接口的类型信息,但在Java中类型信息还可以通过动态生成的方式获取。 ”
在上面的分析中我们可以看到,类文件中存储的是以二进制格式表示的字节码数据(参见下图),存储单位为8个字节,每个数据项都严格按照JVM规范顺序紧凑地排列在文件中。这种数据存储格式和我们日常开发使用的JSON格式或者XML格式相比显得更为严格,所以在JVM解释执行类文件中的字节码数据时必须按照事先约定好的解析方式来处理。当然通过这种严苛的数据存储格式的约束,类文件中的所有数据可以说都是程序运行的必要数据,空间利用率基本达到了百分之百。
类文件存储内容
按照JVM规范,类文件中存储了以下类型信息的数据项:
以上数据项就是JVM抽象出的用于描述一个类或者接口的结构定义。通过这些数据项提供的类型信息,JVM能够确定生成对象需要赋值的字段、对象可以执行的操作等等相关的信息,由此确定出在运行时动态生成的对象中每个数据区域所需的内存大小。看到这里不知道大家是否会感到这好像与Spring当中的BeanDefinition有点相似,其实这两者的逻辑基本一致,只是解析和使用的层面不同,一个是应用程序层面,一个是JVM层面。
上面每个数据项所代表的具体含义,笔者在这里就不进行过多的赘述,有兴趣的同学可以阅读周志明的《深入理解Java虚拟机》。建议在阅读时配合使用之前推荐的反编译工具来配套分析Class文件的各个数据项,具体指令如下:
javap -verbose {TargetClass}.class // 注意,使用的目标对象必须是编译之后的class文件
在上一小节中我们了解到,类型信息会以二进制字节码的形式存储在类文件中。那么JVM是如何读取和使用存储在类文件中的类型信息呢?下面我们了解一下JVM读取和使用类文件的过程,也就是类加载机制。
类文件作为一种文件格式必定是存储在磁盘当中(当然也可以从网络流当中获取,此处我们不考虑运行时动态生成的字节码),而JVM要想使用类文件中存储的类信息就必须将类文件读取到JVM应用程序的内存当中。对于我们日常开发的项目而言,即使是一个非常简单的Java项目都会编译生成出非常多的类文件,为了提升项目启动的速度必须尽量减少初始化阶段加载的资源量,所以JVM必定不会在服务启动时就将所有的类文件加载到内存当中,而是在实际使用的过程将需要的类文件加载到内存当中并进行对应类型信息的初始化,这种机制其实就是我们经常见到的懒加载机制。
那么对于JVM来说,在什么情况下会进行类文件的加载呢?具体有如下几种情况:
new
、getstatic
、putstatic
、invokestatic
这四个字节码指令时,如果类尚未被初始化(这里的类初始化实际上是在JVM内根据类文件生成对应的类型信息数据,下面我们会在类加载过程中进行具体讲解),此时就需要先进行类初始化操作。new
:使用new关键字生成对象时;getstatic
、putstatic
:获取或者设置一个类的静态变量时(此静态变量未被final关键字修饰);invokestatic
:调用一个类的静态函数时;JDK 7新加入特性
:当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化时,则需要先进行类初始化操作。JDK 8新加入特性
:当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果当前接口的实现类发生了初始化,这个接口必须在实现类初始化之前进行类初始化。需要注意,在《Java虚拟机规范》中对于以上的触发场景做了一个非常严格的限定,即有且只有。只有在发生类型主动引用的情况下才会触发类的初始化,而对于间接、被动的类型引用则不会触发上述六中场景中的类型初始化流程。
在前面的内容中我们已经了解到,要想使用类型信息就必须将对应的类文件加载到JVM内存中,这一过程对应着下图类型信息生命周期中使用大括号圈出的五个阶段:
这里的加载阶段只是整个类加载过程中的其中一个阶段,在这个阶段中JVM需要完成以下三件事:
这里我们着重分析一下第一件事,从对于第一件事的描述来看,JVM并没有明确限定一定需要中类文件中获取定义该类的二进制字节流,也就是说对于获取二进制字节流的方式JVM持开放态度,这就给开发人员带来了很大的自由发挥空间。基于这一点,Java开发人员创造性地提出了基于静态资源和动态生成两种方式获取,具体如下图所示:
使用静态资源方式获取定义类的二进制字节流需要开发人员自定义新的类加载器来重写类加载器中的findClass()
或loadClass()
方法,通过重写这两个方法来实现特定的字节流获取方法。这种方式的诞生给开发人员的代码编写带来了一定的灵活性和动态性,但并不是非常明显,且对于一般的业务场景来讲未必很常用。
相比基于静态资源的获取方式,动态生成二进制字节流的方式则将Java语言有别于C/C++的运行时动态特性体现的淋漓尽致(主要是以动态代理技术为首的字节码生成技术)。通过动态生成二进制字节流的方式,开发人员能够实现在运行时按照自己的想法动态地为一些特定的类增加新的属性和方法,以此来完成一些特殊的逻辑处理,比如事务管理。
需要注意,单纯的动态生成技术并不能完成开发人员的需求,在类型信息中还包含了许多外部方法和常量的符号引用,这就需要JVM能够在运行时动态地将外部的引用进行连接(Linking)处理。
“Tip:加载阶段与连接阶段并不是严格意义顺序执行的,连接阶段的部分动作可能会在加载阶段未完成时便开始执行,即两个阶段会出现交叉执行的情况。但两个阶段的开始时间依然保持着固定的先后顺序。 ”
验证是连接阶段的第一步,其主要目的是为了确保Class文件中存储的类型信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身安全。之所以会出现这样的约束要求,主要原因还是在于JVM运行的Class文件并不一定是由Java源代码编译生成的。在之前的介绍中我们可以知道,开发人员可以通过任意一种语言或者方式生成能够被JVM解析的Class文件,但生成的Class文件是否有安全上的风险,开发人员本身并不能很好的进行评估,这就需要JVM自身提供对应的验证机制。
准备阶段是连接阶段的第二步。在这一阶段中,JVM会为类中定义的静态变量分配内存并设置变量的初始值。这里我们需要注意一下设置初始值的逻辑,对于非final关键字修饰的静态变量,JVM在当前阶段只会设置成对应数据类型的零值,而对于使用final关键字修饰的静态变量则会直接将指定的值作为初始值赋予给该变量。
解析阶段是连接阶段的最后一步。在这一阶段当中,JVM会将常量池内的符号引用替换成直接引用。
“符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。需要注意,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经记载到虚拟机内存当中的内容。从另一方面来说对于不同的虚拟机来说,其实现的内存布局可以给不相同,但能接受的符号引用必须都是保持一致的。 直接引用(Direct Reference):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。区别于符号引用,直接引用是与虚拟机内存布局直接相关的,也就是说相同符号引用在不同虚拟机中翻译出来的直接引用基本不会相同。除此以外,直接引用的目标一定已经存在于虚拟机的内存中。 ”
单纯从上面的描述来理解解析过程还是有会显得有点抽象,这里我们结合一段字节码来具体理解一下:
上图中展示了类型信息中常量池部分的数据,图中四个红色方框圈住的部分从左到右分别是:
在常量池中会存储两类常量数据,一类是字面,一类是符号引用。在上图中展示的数据项基本都是符号引用,可以看到符号引用存储的实际上就是类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等一些描述性信息,在类文件的字段表、方法表、属性表中使用到这些描述性信息的地方只需要引用这些数据项的名称(就像我们开发中使用某一个变量的名称一样),而无须每次都把这些描述信息重复声明一遍。
需要注意的是,这些描述性信息并没有明确指向对应目标的内存地址,而是单纯记录一些字面量。也就是说对于Java而言,编译阶段是不会将java源码编译成可以直接执行的指令,而是需要经过运行时的解析过程,将这些符号引用翻译成代表对应目标内存地址的直接引用,然后在进行代码执行操作。虽然说在运行阶段会出现效率上的降低,但得益于这样一个编译-解释执行的设计,Java的动态性得到了极大的提升。
初始化阶段是类加载过程中的最后一步,在这一阶段中,JVM才真正开始执行类中编写的Java程序代码(但好像又不是完全意义上的执行)。这里的初始化实际上就是执行**类构造器()**,注意,这里的类构造器不是我们日常所说构造函数,而是通过Javac编译器生成的类维度的构造方法,它是基于类中所有变量的赋值动作和静态语句块合并产生的(这也是为什么前面说不是完全意义上的执行Java程序代码)。
在上面的学习中我们了解到在加载阶段,JVM提供给了开发人员一个灵活发挥的空间来按照自己的意愿去获取描述某个类的二进制字节流,而实现这个灵活发挥的方案就是类加载器(ClassLoader)。当然类加载的作用不止于此,除了能够获取描述某个类的二进制字节流,类加载器还能够和类本身一起共同确定其在JVM当中的唯一性,每一个类的加载器在JVM当中都拥有一个独立的类名称空间。这也意味着,当我们在比较两个类型(注意是类型而不是对象)是否相等时,必须建立在这两个类是由同一个类加载器加载这一前提下,如果是有两个不相同的类加载器加载的,那么这两个类型必定是不相等的。
在实际使用时,JVM提供了一套三层类加载器、双亲委派的类加载器架构(即双亲委派模型)来实现上述提到的功能。相信大家在网络或者书籍中或多或少都已经了解过双亲委派模型,笔者就不再做过多的赘述。这里我们需要了解的是在双亲委派模型中每一层级实际上分别检索和读取了不同路径下的类库文件来完成对于这些类库文件的加载处理,并且优先会交由父级来完成类库的加载。JVM通过这种方式很好的对于不同级别的类库文件做了限定和隔离,也即在类之间建了一种带有优先级的层次关系。通过这种限制,JVM保证了在给予开发人员自由发挥空间的同时,也避免了开发人员做出破坏Java基础体系的行为,比如自行定义一个java.lang.Object类。
但双亲委派模型并不能够完成所有场景下的类型信息的加载操作,具体有以下三种场景:
通过上面的学习我们了解到类型信息在对象生成中的重要性,以及在JVM中是如何获取和使用类型信息的。在了解到这些信息之后,我们对于Java中对象生成的整个生命周期才算是拥有了较为完整的认知。当然在上面的文章中有很多细节性的内容做了省略,更多是对基本概念和设计思路上的学习和探讨,对于这些被忽略的细节性内容,大家可以在周志明的《深入理解Java虚拟机》中找到对应的内容。
本文分享自 Brucebat的伪技术鱼塘 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!