首先,尝试写出以下程序的输出:
public class Base { public static void funcStatic(String str){ System.out.println("Base - funcStatic - String"); } public static void funcStatic(Object obj){ System.out.println("Base - funcStatic - Object"); public void func(String str){ System.out.println("Base - func - String"); } public void func(Object obj){ System.out.println("Base - func - Object"); }}public class Child extends Base { public static void funcStatic(String str){ System.out.println("Child - funcStatic - String"); } public static void funcStatic(Object obj){ System.out.println("Child - funcStatic - Object"); } @Override public void func(String str){ System.out.println("Child - func - String"); } @Override public void func(Object obj){ System.out.println("Child - func - Object"); }}
public class Test{ public static void main(String[] args){ Object obj = new Object(); Object str = new String(); Base base = new Base(); Base child1 = new Child(); Child child2 = new Child(); base.funcStatic(obj); // 正常编程中不应该用实例去调用静态方法 child1.funcStatic(obj); child2.funcStatic(obj); base.func(str); child1.func(str); hild2.func(str); }}
程序输出:
Base - funcStatic - ObjectBase - funcStatic - ObjectChild - funcStatic - ObjectBase - func - ObjectChild - func - ObjectChild - func - Object
程序输出是否与你的预期一致呢?遇到困难了吗,相信这篇文章一定能帮到你...
延伸文章
每一个变量都有两种类型:静态类型(Static Type) & 实际类型(Actual Type)。例如下面代码中,Base 为变量 base 的静态类型,Child 为实际类型:
Base base = new Child();
两者的具体区别如下:
这里先谈到这里,后文会从字节码的角度理解继续讨论两个类型。
这一节,我们来讨论 Java 中方法调用的本质。我们知道,Java 前端编译的产物是字节码,与 C/C++ 不同,前端编译过程中并没有链接步骤,字节码中所有的方法调用都是使用符号引用。举个例子:
- 源码:public class Child extends Base { @Override void func() { } void test1(){ func(); } void test2(){ super.func(); }}- 字节码(javap -c Child.class):Compiled from "Child.java"public class com.Child extends com.Base { // 构造函数,默认调用父类构造函数 public com.Child(); Code: 0: aload_0 1: invokespecial #1 // Method com/Base."":()V 4: return void func(); Code: 0: return void test1(); Code: 0: aload_0 // invokevirtual 调用实例方法 1 invokevirtual #2 // Method func:()V 4: return void test2(); Code: 0: aload_0 // invokespecial 调用静态方法 1: invokespecial #3 // Method com/Base.func:()V 4: return}
上面的字节码中,invokespecial 和 invokevirtual 都是方法调用的字节码指令,具体细节下文会详细解释。后面的 #1 #2 #3 表示符号引用在常量池中的索引号,根据这个索引号检索常量表,可以查到最终表示的是一个字符串字面量,例如 func:()V,这个就是方法的符号引用。
为了方便理解字节码,javap 反编译的字节码已经在注释中提示了最终表示的值,例如 Method func:()V 。
符号引用(Symbolic References)是一个用来无歧义地标识一个实体(例如方法/字段)的字符串,在运行期它会翻译为 直接引用(Direct Reference)。对于方法来说,就是方法的入口地址。
下图描述了方法符号引用的基本格式:
这个符号引用包含了变量的静态类型(如果是变量的静态类型与本类相同,不需要指明)、简单方法名以及描述符(参数顺序、参数类型和方法返回值)。通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。但是,同一个符号引用,运行时翻译出来的直接引用可能是不同的,为什么会这样呢?
为什么同一个符号引用,运行时翻译出来的直接引用可能是不同的?这与使用的方法调用指令的处理过程有关,Java 字节码的方法调用指令一共有以下 5 种:
其中,根据 调用方法的版本是否在编译期可以确定,(注意:只是版本,而不是入口地址,入口地址只能在运行时确定)可以将方法调用划分为静态解析 & 动态分派两种。
误区(重要):
《深入理解Java虚拟机》中将方法调用分为解析、静态分派、动态分派三种,又根据宗量的数量引入了静态多分派,动态单分派的概念。这些概念事实上过于字典化,也很容易让读者误认为静态分派与动态分派是非此即彼的互斥关系。事实上,一个方法可以同时重写与重载 ,重载 & 重写是方法调用的两个阶段,而不是两个种类。
下面,我将介绍 Java 中方法选择的三个步骤:
3.1 步骤1:生成符号引用(编译时)
上一节我们提到过方法符号引用的基本格式,分为三个部分:
类的全限定名中将 . 替换为 /,例如 java.lang.Object 对应 java/lang/Object
方法的名称,例如 Object#toString() 的简单名称为:toString
方法的参数列表和返回值,例如 Object#toString() 的描述符为 ()LJava/lang/String;
描述符的规则不是本文重点,这里便不再赘述了,若不了解可阅读延伸文章。这里我们用两段程序验证上述规则,这两段程序中我们考虑了重载 & 重写、静态 & 实例两个维度的因素:
程序一(重载 & 重写)public class Base { public void func() {} public void func(int i){}}public class Child extends Base { @Override public void func() {} @Override public void func(int i){}}public class Test{ public static void main(String[] args){ Base base1 = new Base(); Base child1 = new Child(); Child child2 = new Child(); base1.func(); // invokevirtual com.Base.func:():V child1.func(); // invokevirtual com.Base.func:():V child2.func(); // invokevirtual com.Child.func:():V base1.func(1); // invokevirtual com.Base.func:(I):V child1.func(1); // invokevirtual com.Base.func:(I):V child2.func(1); // invokevirtual com.Child.func:(I):V }}
可以看到,符号引用中的类名确实是变量的静态类型,而不是变量的实际类型;方法名不用多说,方法描述符则选择重载方法中最合适的一个方法。这个例程很容易判断重载方法选择结果,具体选择规则其实更为复杂。
程序二(静态 & 实例)public class Base { public static void func() {} public void func(int i){}}public class Child extends Base { public static void func() {} @Override public void func(int i){}}public class Test{ public static void main(String[] args){ Base base1 = new Base(); Base child1 = new Child(); Child child2 = new Child(); 符号引用与程序一相同,仅指令不同 base1.func(); // invokestatic com.Base.func:():V child1.func(); // invokestatic com.Base.func:():V child2.func(); // invokestatic com.Child.func:():V base1.func(1); // invokevirtual com.Base.func:(I):V child1.func(1); // invokevirtual com.Base.func:(I):V child2.func(1); // invokevirtual com.Child.func:(I):V }}
可以看到,static 对符号引用没有影响,仅影响使用的指令(静态方法调用使用 invokestatic)。而通过对象实例去调用静态方法是 javac 的语法糖,编译时会转换为使用变量的静态类型固化到符号引用中。
1. 方法的符号引用在编译期确定,并固化到字节码中方法调用指令的参数中
2. 是否有 static 修饰对符号引用没有影响,仅影响使用的字节码指令,对象实例去调用静态方法是 javac 的语法糖
3.2 步骤二:解析(类加载时)
为什么静态方法、私有实例方法、实例构造器 、父类方法以及 final 修饰这五种方法(对应的关键字:static、private、、super、final)可以在编译期确定版本呢?因为无论运行时加载多少个类,这些方法都保证唯一的版本:
既然可以确定方法的版本,虚拟机在处理 invokestatic、invokespecial、invokevirtual(final) 时,就可以提前将符号引用转换为直接引用,不必延迟到方法调用时确定,具体来说,在类加载的解析阶段完成转换的。
invokestatic 指令
源码:String str = String.valueOf("1")字节码:0: iconst_11: invokestatic #2 // Method java/lang/String.valueOf:(I)Ljava/lang/String;4: astore_1
invokespecial 指令
1、源码(实例构造器):String str = new String();字节码:0: new #2 // class java/lang/String3: dup4: invokespecial #3 // Method java/lang/String."":()V7: astore_1--------------------------------------------------------------------2、源码(父类方法):super.func();字节码:0: aload_01: invokespecial #2 // Method com/Base.func:()V--------------------------------------------------------------------3、源码(私有方法):funcPrivate();字节码:0: aload_01: invokespecial #2 // Method funPrivate:()V
3.3 步骤三:动态分派(类使用时)
动态分派分为 invokevitrual、invokeinterface 与 invokedynamic,其中动态调用 invokedynamic 是 JDK 1.7 新增的指令,我们单独在另一篇中解析。有些同学可能会觉得方法不重写不就只有一个版本了吗?这个想法忽略了 Java 动态链接的特性,Java 可以从任何途径加载一个 class,除非解析的 5 种的情况外,无法保证方法不被重写。
invokevirtual指令
虚拟机为每个类生成虚方法表 vtable(virtual method table) 的结构,类中声明的方法的入口地址会按固定顺序存放在虚方法表中;虚方法表还会继承父类的虚方法表,顺序与父类保持一致,子类新增的方法按顺序添加到虚方法末尾(这以 Java 单继承为前提);若子类重写父类方法,则重写方法位置的入口地址修改为子类实现;
Object 是所有类的父类,所有每个类的虚方法表头部都会包含 Object 的虚方法表。另外,B 重写了 A#printMe(),所以对应位置的入口地址方法被修改为 B 重写方法的入口地址。
需要注意的是,被 final、static、private 修饰的方法不会出现在虚方法表中,因为这些方法无法被继承重写。
invokeinterface指令
接口方法的选择行为与类方法的选择行为略有区别,主要原因是 Java 接口是支持多继承的,就没办法像虚方法表那样直接继承父类的虚方法表。虚拟机提供了 itable(interface method table)来支持多接口,itable 由偏移量表 offset table 与方法表 method table 两部分组成。
当需要调用某个接口方法时,虚拟机会在`offset table`查找对应的 method table ,随后在该method table 上查找方法。
3.4 性能对比
参考资料
推荐阅读
感谢喜欢!请点赞,你的点赞和关注真的对我非常重要!欢迎关注[彭旭锐](https://github.com/pengxurui)的Github!