《深入理解java虚拟机(第2版)》笔记(3)

声明:本文是对《深入理解java虚拟机(第2版)》的片段摘录,版权属于原作者。本文仅供学习交流使用,严禁用于商业用途。


敬请关注微信公众号:Java工程师成长日记(JavaEngineer777)

 

5 虚拟机类加载机制

 

5.1 类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、 验证(Verification)、 准备(Preparation)、 解析(Resolution)、 初始化(Initialization)、 使用(Using)和卸载(Unloading)7个阶段。

 其中验证、 准备、 解析3个部分统称为连接(Linking),这7个阶段的发生顺序如图7-1所示。

 

 《深入理解java虚拟机(第2版)》笔记(3)_第1张图片

图7-1中,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。注意,这里笔者写的是按部就班地“开始”,而不是按部就班地“进行”或“完成”,强调这点是因为这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用、 激活另外一个阶段。

什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。 但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。 生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、 已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、 REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这5种场景中的行为称为对一个类进行主动引用。 除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

 

5.2 类加载的过程

5.2.1 加载

加载类加载Class Loading)过程的一个阶段,希望读者没有混淆这两个看起来很相似的名词。

在加载阶段,虚拟机需要完成以下3件事情:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

 

5.2.2 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分。

直到2011年发布的《Java虚拟机规范(Java SE 7版)》 ,大幅增加了描述验证过程的篇幅(从不到10页增加到130页),这时约束和验证规则才变得具体起来。从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

 

5.2.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值通常情况下是数据类型的零值。

 

5.2.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在前一章讲解Class文件格式的时候已经出现过多次,在Class文件中它以CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?

符号引用(Symbolic References:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

直接引用(Direct References:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

 

5.2.5初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。

 

5.3 类加载器

虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为类加载器。类加载器可以说是Java语言的一项创新,也是Java语言流行的重要原因之一,它最初是为了满足Java Applet的需求而开发出来的。虽然目前Java Applet技术基本上已经死掉[1],但类加载器却在类层次划分、OSGi热部署、代码加密等领域大放异彩,成为了Java技术体系中一块重要的基石,可谓是失之桑榆,收之东隅。

 

5.3.1 类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。这里所指的相等,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

 

5.3.2 双亲委派模型

Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(BootstrapClassLoader),这个类加载器使用C++语言实现[1],是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader

Java开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器。

启动类加载器BootstrapClassLoader):前面已经介绍过,这个类将器负责将存放在<JAVA_HOME\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可,如代码清单7-9所示为java.lang.ClassLoader.getClassLoader()方法的代码片段。代码清单7-9 ClassLoader.getClassLoader()方法的代码片段

/**Returnsthe class loader for the class.Some implementations may use null to representthe bootstrap class loader.This method will return null in suchimplementationsif this class was loaded by the bootstrap class loader.*/public ClassLoadergetClassLoader(){ClassLoadercl=getClassLoader0();ifcl==nullreturn nullSecurityManager sm=System.getSecurityManager();ifsm=null{ClassLoaderccl=ClassLoader.getCallerClassLoader();ifccl=null&&ccl=cl&&!cl.isAncestorccl)){sm.checkPermissionSecurityConstants.GET_CLASSLOADER_PERMISSION);}}return cl}

 

 

扩展类加载器Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME\lib\ext目录中的,或者被java.ext.dirs   $ExtClassLoader实现,它负责加载<JAVA_HOME\lib\ext目录中的,或者被java.ext.dirs

统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。     统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

 

应用程序类加载器Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回     应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类   值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,开发者可以直接使用这个类加载器般情况下这个就是程序中默认的类加载器。

我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。

这些类加载器之间的关系一般如图7-2所示。

 《深入理解java虚拟机(第2版)》笔记(3)_第2张图片

图7-2中展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型(ParentsDelegationModel)。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。 这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

类加载器的双亲委派模型在JDK 1.2期间被引入并被广泛应用于之后几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。 例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

 如果读者有兴趣的话,可以尝试去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但永远无法被加载运行[2]。

双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中,如代码清单7-10所示,逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。 如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

 

5.3.3 破坏双亲委派模型

上文提到过双亲委派模型并不是一个强制性的约束模型,而是Java设计者推荐给开发者的类加载器实现方式。Java的世界中大部分的类加载器都遵循这个模型,但也有例外,到目前为止,双亲委派模型主要出现过3较大规模的被破坏情况。

双亲委派模型的第一次被破坏其实发生在双亲委派模型出现之前——JDK 1.2发布之前。由于双亲委派模型在JDK1.2之后才被引入,而类加载器和抽象类java.lang.ClassLoader则在JDK 1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2之后的java.lang.ClassLoader添加了一个新的protected方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。上一节我们已经看过loadClass()方法的代码,双亲委派的具体逻辑就实现在这个方法之中,JDK1.2之后已不提倡用户再去覆盖loadClass()方法,而应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。

双亲委派模型的第二次被破坏是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为基础,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?这并非是不可能的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK 1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,ServiceProvider Interface)的代码,但启动类加载器不可能认识这些代码啊!那该怎么办?为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader这个类加载器可以通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,就可以做一些舞弊的事情了,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDIJDBCJCEJAXBJBI等。

双亲委派模型的第三次被破坏是由于用户对程序动态性的追求而导致的,这里所说的动态性指的是当前一些非常热门的名词:代码热替换(HotSwap)、模块热部署(HotDeployment)等,说白了就是希望应用程序能像我们的计算机外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用停机也不用重启。对于个人计算机来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是企业级软件开发者具有很大的吸引力。

Sun公司所提出的JSR-294[1]JSR-277[2]规范在与JCP组织的模块化规范之争中落败给JSR-291(即OSGi R4.2),虽然Sun不甘失去Java模块化的主导权,独立在发展Jigsaw项目,但目前OSGi已经成为了业界事实上Java模块化标准[3],而OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

1)将以java.*开头的类委派给父类加载器加载。

2)否则,将委派列表名单内的类委派给父类加载器加载。

3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。4)否则,查找当前BundleClassPath,使用自己的类加载器加载。

5)否则,查找类是否在自己的FragmentBundle中,如果在,则委派给Fragment Bundle的类加载器加载。

6)否则,查找DynamicImport列表的Bundle,委派给对应Bundle的类加载器加载。

7)否则,类查找失败。

上面的查找顺序中只有开头两点仍然符合双亲委派规则,其余的类查找都是在平级的类加载器中进行的。笔者虽然使用了被破坏这个词来形容上述不符合双亲委派模型原则的行为,但这里被破坏并不带有贬义的感情色彩。只要有足够意义和理由,突破已有的原则就可认为是一种创新。

正如OSGi中的类加载器并不符合传统的双亲委派的类加载器,并且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但在Java程序员中基本有一个共识:OSGi中对类加载器的使用是很值得学习的,弄懂了OSGi的实现,就可以算是掌握了类加载器的精髓。

 

6 虚拟机字节码执行引擎

6.1 方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

6.1.1 解析

前面关于方法调用的话题,所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、 编译器进行编译时就必须确定下来。 这类方法的调用称为解析(Resolution)。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。

invokestatic:调用静态方法。

invokespecial:调用实例构造器<init>方法、 私有方法和父类方法。

invokevirtual:调用所有的虚方法。

invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。 这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。

代码清单8-5演示了一个最常见的解析调用的例子,此样例中,静态方法sayHello()只可能属于类型StaticResolution,没有任何手段可以覆盖或隐藏这个方法。代码清单8-5 方法静态解析演示

/***方法静态解析演示**@author zzm*/public class StaticResolution{public static voidsayHello(){System.out.println("hello world");}publicstatic void main(String[]args){StaticResolution.sayHello();}}

使用javap命令查看这段程序的字节码,会发现的确是通过invokestatic命令来调用sayHello()方法的。

D:\Develop\>javap-verboseStaticResolutionpublic static void main(java.lang.String[]);Code:Stack=0,Locals=1,Args_size=10:invokestatic#31;//Method sayHello:()V3:returnLineNumberTable:line 15:0line 16:3

Java中的非虚方法除了使用invokestatic、 invokespecial调用的方法之外还有一种,就是被final修饰的方法。 虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数[1]可分为单分派和多分派。 这两类分派方式的两两组合就构成了静态单分派、 静态多分派、动态单分派、动态多分派4种分派组合情况,下面我们再看看虚拟机中的方法分派是如何进行的。

 

6.1.2 分派

众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装和多态。本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如重载重写Java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

 

 

6.1.2.1静态分派

在开始讲解静态分派[1]前,笔者准备了一段经常出现在面试题中的程序代码,读者不妨先看一遍,想一下程序的输出结果是什么。后面我们的话题将围绕这个类的方法来重载(Overload)代码,以分析虚拟机和编译器确定方法版本的过程。方法静态分派如代码清单8-6所示。

代码清单8-6 方法静态分派演示

packageorg.fenixsoft.polymorphic/***方法静态分派演示*@author zzm*/public class StaticDispatch{static abstractclass Human{}static class Man extends Human{}static class Woman extendsHuman{}public void sayHelloHuman guy{System.out.println"hello,guy");}public voidsayHelloMan guy{System.out.println"hello,gentleman");}public voidsayHelloWoman guy{System.out.println"hello,lady");}public static voidmainString[]args{Human man=new Man();Human woman=new Woman();StaticDispatchsr=new StaticDispatch();sr.sayHelloman);sr.sayHellowoman);}}

运行结果:

hello,guyhello,guy

代码清单8-6中的代码实际上是在考验阅读者对重载的理解程度,相信对Java编程稍有经验的程序员看完程序后都能得出正确的运行结果,但为什么会选择执行参数类型为Human的重载呢?在解决这个问题之前,我们先按如下代码定义两个重要的概念。Human man=new Man();我们把上面代码中的“Human”称为变量的静态类型StaticType),或者叫做的外观类型ApparentType),后面的“Man”则称为变量的实际类型Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如下面的代码:

//实际类型变化Human man=new Man();

man=newWoman();//静态类型变化

sr.sayHello((Manman

sr.sayHello((Womanman

解释了这两个概念,再回到代码清单8-6的样例代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHelloHuman)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。

静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个更加合适的版本。这种模糊的结论在由01构成的计算机世界中算是比较稀罕的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。代码清单8-7演示了何为更加合适的版本。代码清单8-7 重载方法匹配优先级

packageorg.fenixsoft.polymorphicpublic class Overload{public static void sayHelloObject arg{System.out.println"helloObject");}public static void sayHelloint arg{System.out.println"helloint");}public static void sayHellolong arg{System.out.println"hellolong");}public static void sayHelloCharacter arg{System.out.println"helloCharacter");}public static void sayHellochar arg{System.out.println"hellochar");}public static void sayHellochar……arg{System.out.println"hellochar……");}public static void sayHelloSerializable arg{System.out.println"helloSerializable");}public static void mainString[]args{sayHello'a');}}

上面的代码运行后会输出:hello char这很好理解,'a'是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果注释掉sayHellochararg)方法,那输出会变为:helloint这时发生了一次自动类型转换,'a'除了可以代表一个字符串,还可以代表数字97(字符'a'Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的。我们继续注释掉sayHelloint arg)方法,那输出会变为:hello long这时发生了两次自动类型转换,'a'转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载。笔者在代码中没有写其他的类型如floatdouble等的重载,不过实际上自动转型还能继续发生多次,按照char-int-long-float-double的顺序转型进行匹配。但不会匹配到byteshort类型的重载,因为charbyteshort的转型是不安全的。我们继续注释掉sayHellolong arg)方法,那输出会变为:hello Character这时发生了一次自动装箱,'a'被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为Character的重载,继续注释掉sayHelloCharacterarg)方法,那输出会变为:helloSerializable这个输出可能会让人感觉摸不着头脑,一个字符或数字与序列化有什么关系?出现helloSerializable,是因为java.lang.Serializablejava.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。Character还实现了另外一个接口java.lang.ComparableCharacter>,如果同时出现两个参数分别为SerializableComparableCharacter>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:sayHello((ComparableCharacter>)'a'),才能编译通过。下面继续注释掉sayHelloSerializable arg)方法,输出会变为:hello Object这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。我们把sayHelloObject arg)也注释掉,输出将会变为:hello char……7个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符'a'被当做了一个数组元素。笔者使用的是char类型的变长参数,读者在验证时还可以选择int类型、Character类型、Object类型等的变长参数重载来把上面的过程重新演示一遍。但要注意的是,有一些在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的[2]。代码清单8-7演示了编译期间选择静态分派目标的过程,这个过程也是Java语言实现方法重载的本质。

演示所用的这段程序属于很极端的例子,除了用做面试题为难求职者以外,在实际工作中几乎不可能有实际用途。笔者拿来做演示仅仅是用于讲解重载时目标方法选择的过程,大部分情况下进行这样极端的重载都可算是真正的关于茴香豆的茴有几种写法的研究

无论对重载的认识有多么深刻,一个合格的程序员都不应该在实际应用中写出如此极端的重载代码。另外还有一点读者可能比较容易混淆:笔者讲述的解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如,前面说过,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

6.1.2.2 动态分派

了解了静态分派,我们接下来看一下动态分派的过程,它和多态性的另外一个重要体现[3]——重写(Override)有着很密切的关联。我们还是用前面的ManWoman一起sayHello的例子来讲解动态分派,请看代码清单8-8中所示的代码。代码清单8-8 方法动态分派演示

package org.fenixsoft.polymorphic/***方法动态分派演示*@author zzm*/public class DynamicDispatch{staticabstract class Human{protected abstract void sayHello();}static class Man extends Human{@Overrideprotectedvoid sayHello(){System.out.println"man say hello");}}static class Woman extends Human{@Overrideprotectedvoid sayHello(){System.out.println"woman say hello");}}public static void mainString[]args{Human man=new Man();Human woman=new Woman();man.sayHello();woman.sayHello();man=new Woman();man.sayHello();}}

运行结果:

man say hellowoman say hellowoman say hello

这个运行结果相信不会出乎任何人的意料,对于习惯了面向对象思维的Java程序员会觉得这是完全理所当然的。现在的问题还是和前面的一样,虚拟机是如何知道要调用哪个方法的?显然这里不可能再根据静态类型来决定,因为静态类型同样都是Human的两个变量manwoman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用javap命令输出这段代码的字节码,尝试从中寻找答案,输出结果如代码清单8-9所示。

代码清单8-9main()方法的字节码

public static void mainjava.lang.String[]);CodeStack=2Locals=3Args_size=10new#16//class org/fenixsoft/polymorphic/DynamicDispatch $Man3dup4invokespecial#18//Method org/fenixsoft/polymorphic/DynamicDispatch$Man."init":()V7astore_18new#19//class org/fenixsoft/polymorphic/DynamicDispatch $Woman11dup12invokespecial#21//Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."init":()V15astore_216aload_117invokevirtual#22//Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V20aload_221invokevirtual#22//Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V24new#19//class org/fenixsoft/polymorphic/DynamicDispatch $Woman27dup28invokespecial#21//Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."init":()V31astore_132aload_133invokevirtual#22//Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V36return

015行的字节码是准备动作,作用是建立manwoman的内存空间、调用ManWoman类型的实例构造器,将这两个实例的引用存放在第12个局部变量表Slot之中,这个动作也就对应了代码中的这两句:Human man=new Man();Human woman=new Woman();接下来的1621句是关键部分,1620两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);1721句是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。

 我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

6.1.2.3 单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。单分派和多分派的定义读起来拗口,从字面上看也比较抽象,不过对照着实例看就不难理解了。代码清单8-10中列举了一个FatherSon一起来做出一个艰难的决定的例子。

代码清单8-10 单分派和多分派

/***单分派、多分派演示*@author zzm*/public classDispatch{static class QQ{}static class_360{}public static class Father{publicvoid hardChoiceQQ arg{System.out.println"father chooseqq");}public void hardChoice_360 arg{System.out.println"father choose360");}}public static class Son extends Father{public voidhardChoiceQQ arg{System.out.println"son chooseqq");}public void hardChoice_360 arg{System.out.println"son choose360");}}public static void mainString[]args{Father father=newFather();Father son=new Son();father.hardChoicenew_360());son.hardChoicenew QQ());}}

 运行结果:

father choose 360

son choose qq

main函数中调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已经显示得很清楚了。我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice360)及Father.hardChoiceQQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。再看看运行阶段虚拟机的选择,也就是动态分派的过程。

执行“son.hardChoicenewQQ())这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoiceQQ),虚拟机此时不会关心传递过来的参数“QQ”到底是腾讯QQ”还是奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型

根据上述论证的结果,我们可以总结一句:今天(直至还未发布的Java1.8)的Java语言是一门静态多分派、动态单分派的语言强调今天的Java语言是因为这个结论未必会恒久不变,C#3.0及之前的版本与Java一样是动态单分派语言,但在C#4.0中引入了dynamic类型后,就可以很方便地实现动态多分派。按照目前Java语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如JavaScript)执行引擎的方式来满足动态性的需求。但是Java虚拟机层面上则不是如此,在JDK1.7中实现的JSR-292[4]里面就已经开始提供对动态语言的支持了,JDK 1.7中新增的invokedynamic指令也成为了最复杂的一条方法调用的字节码指令,稍后笔者将专门讲解这个JDK1.7的新特性。

6.1.2.4虚拟机动态分派的实现

前面介绍的分派过程,作为对虚拟机概念模型的解析基本上已经足够了,它已经解决了虚拟机在分派中会做什么这个问题。但是虚拟机具体是如何做到的,可能各种虚拟机的实现都会有些差别。由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的稳定优化手段就是为类在方法区中建立一个虚方法表Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——IntefaceMethod Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。我们先看看代码清单8-10所对应的虚方法表结构示例,如图8-3所示。

 《深入理解java虚拟机(第2版)》笔记(3)_第3张图片

虚方法表中存放着各个方法的实际入口地址如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。8-3中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是SonFather都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

上文中笔者说方法表是分派调用的稳定优化手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(InlineCache和基于类型继承关系分析ClassHierarchy Analysis,CHA)技术的守护内联(Guarded Inlining两种非稳定的激进优化手段来获得更高的性能,关于这两种优化技术的原理和运作过程,读者可以参考本书第11章中的相关内容。


你可能感兴趣的:(读书笔记)