在调用Java方法的过程中,JVM如何知道调用的是哪个类的方法源码?这里面有什么内幕?在本文中,我们将揭露JVM方法调用的静态绑定和动态绑定机制(自动绑定)。静态绑定机制//Calledclasspackagehr.test;classFather{publicstaticvoidf1(){System.out.println("Father—f1()");}}//调用静态方法importhr.test.Father;publicclassStaticCall{publicstaticvoidmain(){Father.f1();//调用静态方法}}以上源码执行方法调用语句(Father.f1())被编译器编译成一条指令:invokestatic#13。下面看看JVM是如何处理这条指令的(1)指令中的#13指的是StaticCall类常量池中第13个常量表的索引项(常量池详见《Class文件内容及常量池 》)。这个常量表(CONSTATN_Methodref_info)记录了方法f1的符号引用信息(包括f1的类名、方法名和返回类型)。JVM首先会根据这个符号引用找到方法f1所在类的全限定名:hr.test.Father;(2)然后JVM会加载、链接并初始化Father类;(3)然后在Father类所在的方法区中找到f1()方法的直接地址,将这个直接地址记录到StaticCall类的常量池中索引为13的常量表中。这个过程称为常量池分析。以后再次调用Father.f1()时,直接找到f1方法的字节码;(4)JVM解析完StaticCall类的常量池索引项13的常量表后,就可以调用f1()方法,开始解释执行f1()方法中的指令。通过上面的过程,我们发现,经过常量池分析,JVM可以判断出要调用的f1()方法在内存中的位置。其实这些信息在编译阶段就已经记录在StaticCall类的常量池中了。这种在编译阶段决定调用哪个方法的方式称为静态绑定机制。除了static修饰的静态方法,所有private修饰的private方法和final修饰的子类禁止重写的方法都会被编译成invokestatic指令。另外,所有类的初始化方法和都会被编译成invokespecial指令。JVM会使用静态绑定机制来顺利调用这些方法。动态绑定机制packagehr.test;//调用父类classFather{publicvoidf1(){System.out.println("father-f1()");}publicvoidf1(inti){System.out.println("father-f1()para-int"+i);}}//调用子类classSonextendsFather{publicvoidf1(){//重写父类的方法System.out.println("Son-f1()");}publicvoidf1(charc){System.out.println("Son-s1()para-char"+c);}}//调用方法importhr.test.*;publicclassAutoCall{publicstaticvoidmain(String[]args){Fatherfather=newSon();//多态father.f1();//打印结果:Son-f1()}}上面源码中有3个重要的概念:多态,方法覆盖和方法重载。打印出来的结果大家都很清楚,但是JVM怎么知道f.f1()调用的是子类Sun中的方法,而不是Father中的方法呢?在解释这个问题之前,先简单说一下JVM管理的一个非常重要的数据结构——方法表。JVM在加载一个类的时候,会在方法区存储很多关于这个类的信息(详见《Java 虚拟机体系结构 》)。其中有一个叫做方法表的数据结构。它以数组的形式记录了当前类及其所有超类的可见方法字节码在内存中的直接地址。下图是上面源码中方法区的Father类和Sun类的方法表:上图中的方法表有两个特点:(1)子类的方法表继承了父类的方法,例如FatherextendsObject.(2)相同的方法(相同的方法签名:方法名和参数列表)在所有类的方法表中具有相同的索引。例如Father方法表中的f1()和Son方法表中的f1()都位于各自方法表的第11项中。对于上面的源码,编译器首先会把main方法编译成如下字节码指令:0newhr.test.Son[13]//在堆中为Son对象开辟一块内存空间,并压缩对象引用入操作数栈3dup4invokespecial#7[15]//调用初始化方法初始化堆中的Son对象7astore_1//从操作数栈弹出Son对象引用,压入局部变量18aload_1//取出局部变量将1中的对象引用压入操作数栈9invokevirtual#15//调用f1()方法12returninvokevirtual指令的详细调用过程如下:(1)invokevirtual中的#15instruction指向池中第15个常量表的AutoCall类Index条目的常量。这个常量表(CONSTATN_Methodref_info)记录了方法f1的符号引用信息(包括f1的类名、方法名和返回类型)。JVM首先会根据这个符号引用找到调用方法f1的类的完全限定名:hr.test.Father。这是因为调用方法f1的类的对象父亲被声明为类型Father。(2)在Father类型的方法表中查找方法f1,如果找到,则将方法表中方法f1的索引项11(如上图)记录到常量池中的第15个常量表中AutoCall类(常量池解析)。这里需要注意一点:如果Father类型的方法表中没有方法f1,那么即使Son类型中有方法表,也不会通过编译。因为类调用方法f1的对象father的声明是Father类型的。(3)在调用invokevirtual指令之前有一条aload_1指令,会将在堆中创建的Son对象的引用压入操作数栈。那么invokevirtual指令会先根据Son对象的引用在堆中找到Son对象,然后进一步找到Son对象所属类型的方法表。流程如下图所示:(4)这是步骤(2)分析的#15常量表中方法表的索引项11,可以定位到方法表中的方法f1()Son类型,然后通过直接地址找到方法字节码所在的内存空间。显然,根据对象(Father)声明的类型(Father),无法确定调用方法f1的位置,必须根据父亲实际创建的对象Son的类型来确定方法f1的位置在堆中。这种在程序运行过程中通过动态创建对象的方法表来定位方法的方法称为动态绑定机制。上面的过程清楚地反映了在方法覆盖多态调用的情况下,JVM是如何准确定位方法的。但是下面的调用方法JVM是怎么定位的呢?(上面代码中仍然使用Father和Son类型)publicclassAutoCall{publicstaticvoidmain(String[]args){Fatherfather=newSon();charc='a';father.f1(c);//打印结果:father-f1()para-int97}}问题是Fahter类型中没有方法签名为f1(char)的方法。但是打印结果显示JVM调用了Father类型中的f1(int)方法,而没有调用Son类型中的f1(char)方法。根据上面详细描述的调用过程,可以清楚JVM首先根据对象father声明的类型Father解析常量池(即Father方法表中的索引项用于替换符号常量池中的引用)。如果Father中没有匹配到“合适”的方法,则无法进行常量池分析,会在编译阶段失败。那么什么是“合适”的方法呢?当然,具有完全相同方法签名的方法自然是合适的。但是如果在声明的类型中找不到方法中的参数类型怎么办?比如上面代码中调用father.f1(char),Father类型没有f1(char)的方法签名。实际上,JVM会找到一个“makedo”的方法,也就是通过参数的自动转换找到一个“合适”的方法。比如char可以自动转换成int,所以在Father类中可以匹配这个方法(Java的自动转换见《【解惑】Java类型间的转型》)。但是还有一个问题,如果两个方法可以通过自动转换来“凑”出来呢?例如下面的代码:classFather{publicvoidf1(Objecto){System.out.println("Object");}publicvoidf1(double[]d){System.out.println("double[]");}}publicclassDemo{publicstaticvoidmain(String[]args){newFather().f1(null);//打印结果:double[]}}null可以被任何引用类型引用,那么JVM是如何判断“合适”的方法的。一个很重要的标准是:如果一个方法可以接受传递给另一个方法的任何参数,那么第一个方法是相对不合适的。比如上面的代码:任何传递给f1(double[])方法的参数都可以传递给f1(Object)方法,反之则不行,那么f1(double[])方法更合适。所以JVM会调用这个更合适的方法。总结(1)所有私有方法、静态方法、构造函数和初始化方法均采用静态绑定机制。调用方法在常量池中的符号引用在编译阶段就已经指定,在JVM运行时只需要进行一次常量池解析即可。(2)类对象的方法调用在运行时必须采用动态绑定机制。首先,根据对象的声明类型(对象引用的类型)找到“合适”的方法。具体步骤如下:①如果在声明类型中能够匹配到一个方法签名相同(参数类型一致)的方法,那么这个方法是最合适的。②在第①条不能满足的情况下,想办法“凑合”。标准是匹配自动转换后的参数类型。如果多个自动转换的方法签名f(A)和f(B)匹配,则使用以下标准来确定合适的方法:传递给f(A)方法的所有参数都可以传递给f(B),则f(A)是最合适的。否则f(B)是最合适的。③如果在声明的类型中仍然没有找到“合适”的方法,则编译阶段不会通过。然后,根据在堆中创建的对象的实际类型找到对应的方法表,并从中确定具体方法在内存中的位置。覆盖一个实例方法可以覆盖其超类中可访问的具有相同签名的所有实例方法,从而实现动态调度;也就是说,VM会根据实例的Runtime类型来选择重写的方法来调用。覆盖是面向对象编程技术的基础,并且是通常不鼓励的最后一种名称重用形式:classBase{publicvoidf(){}}classDerivedextendsBase{publicvoidf(){}}隐藏字段、静态方法或A成员类型可以隐藏在其超类中分别可访问的所有字段、静态方法或具有相同名称(对于方法,相同方法签名)的成员类型。隐藏成员将阻止它被继承。classBase{publicstaticvoidf(){}}classDerivedextendsBase{privatestaticvoidf(){}//hidesBase.f()}一个类中的重载(overload)方法可以重载(overload)另一个方法,只要它们具有相同的名称和不同的签名.调用指定的重载方法是在编译时选择的。classCircuitBreaker{publicvoidf(inti){}//intoverloadingpublicvoidf(Strings){}//Stringoverloading}隐藏一个变量、方法或类型可以隐藏一个封闭的文本范围、方法或类型内的所有同名变量。如果一个实体被隐藏,你将无法通过它的简单名称来引用它;根据实体的不同,有时您根本无法引用它。classWhoKnows{staticStringsentence="我不知道。";publicstaticvoidmain(String[]args〕{Stringsentence="Idon'tknow.";//shadowsstaticfieldSystem.out.println(sentence);//printslocalvariable}}虽然阴影通常不被鼓励是的,但是有一个常见的习语确实涉及阴影。构造函数经常重用其封闭类中的字段名称作为参数来传递该命名字段的值。这种习惯用法并非没有风险,但大多数Java程序员都认为这种风格的好处大于风险:具有相同的名称,只要它们在同一范围内:如果在允许变量和类型的范围内使用该名称,那么它将引用该变量。类似地,变量或类型可以隐藏包。伪装是名称重用的唯一形式,其中两个名称驻留在不同的名称空间中:变量、包、方法或类型。如果类型或包被隐藏,则不能通过其简单名称引用它,除非在语法只允许在其名称空间中使用一个名称的上下文中。遵守命名约定可以大大排除遮挡的可能性:}}本篇到此结束!